From feb5f93fa722e208991562e029c330a702b59d6d Mon Sep 17 00:00:00 2001 From: s Date: Fri, 21 Aug 2026 13:30:08 +0300 Subject: [PATCH 1/3] feat: add V3 native object model --- CONTRIBUTING.md | 10 +- MANIFEST.in | 1 + README.md | 121 +- ...-V2-ARCHITECTURE.md => V3-ARCHITECTURE.md} | 76 +- setup.cfg | 2 + src/supernote_module_generator/__init__.py | 2 +- .../binding_codegen.py | 828 +++++- src/supernote_module_generator/cli.py | 13 +- src/supernote_module_generator/conversion.py | 932 +++++++ .../conversion_codegen.py | 280 ++ .../cpp_object_binding_codegen.py | 1738 ++++++++++++ .../cpp_object_runtime_codegen.py | 286 ++ .../cpp_projection.py | 467 +++- src/supernote_module_generator/cpp_routes.py | 501 ++++ .../cross_family_codegen.py | 696 +++++ .../feature_cli_operations.py | 11 +- .../feature_generator.py | 115 +- .../feature_model.py | 35 +- .../feature_operations.py | 68 +- .../feature_workflows.py | 4 +- src/supernote_module_generator/generator.py | 5 + src/supernote_module_generator/helptext.py | 6 +- src/supernote_module_generator/integration.py | 8 +- .../internal_codegen.py | 89 +- src/supernote_module_generator/jvm_codegen.py | 404 ++- .../jvm_manifest.py | 147 +- .../jvm_object_binding_codegen.py | 2479 +++++++++++++++++ .../jvm_object_runtime_codegen.py | 226 ++ .../jvm_projection.py | 322 ++- src/supernote_module_generator/jvm_routes.py | 530 ++++ src/supernote_module_generator/lowering.py | 20 +- .../plugin_build_integration.py | 99 +- .../plugin_runtime_codegen.py | 317 ++- src/supernote_module_generator/project.py | 20 +- .../reachability.py | 294 ++ src/supernote_module_generator/semantic.py | 786 +++++- .../semantic_types.py | 219 ++ .../source_models.py | 150 +- .../v2.SupernotePluginExport.java.tmpl | 7 +- .../v2.SupernotePluginInternal.java.tmpl | 2 +- .../templates/v2.SupernoteV2Module.kt.tmpl | 141 +- .../templates/v2.SupernoteV2Processor.kt.tmpl | 727 ++++- .../templates/v2.common_codegen.py.tmpl | 45 +- .../templates/v2.processor.provider.tmpl | 2 +- .../v3.SupernotePluginObject.java.tmpl | 10 + .../v3.SupernotePluginValue.java.tmpl | 10 + .../typescript_codegen.py | 220 +- src/supernote_module_generator/v3_schemas.py | 21 + .../verification.py | 4 + .../accept_exact_qualified_reference.hpp | 10 + .../accept_forward_then_marked_definition.hpp | 6 + .../accept_global_complete.hpp | 2 + .../accept_named_namespace.hpp | 4 + .../accept_nested_namespace.hpp | 4 + .../accept_unqualified_enclosing.hpp | 9 + tests/fixtures/v3_cpp_resolution/cases.json | 19 + .../reject_alias_signature.hpp | 12 + .../reject_ambiguous_unqualified.hpp | 16 + .../reject_anonymous_namespace.hpp | 4 + .../v3_cpp_resolution/reject_forward_only.hpp | 2 + .../v3_cpp_resolution/reject_marked_alias.hpp | 6 + .../v3_cpp_resolution/reject_nested_type.hpp | 5 + .../reject_public_name_collision.hpp | 8 + tests/fixtures/v3_typescript/consumer.ts | 68 + tests/fixtures/v3_typescript/index.d.ts | 112 + tests/test_binding_codegen.py | 86 +- tests/test_doctor_spec.py | 50 + tests/test_documentation.py | 59 +- tests/test_feature_generator.py | 58 +- tests/test_feature_metadata_diagnostics.py | 2 +- tests/test_feature_model.py | 13 +- tests/test_feature_operations.py | 18 +- tests/test_generator.py | 9 +- tests/test_internal_codegen.py | 8 +- tests/test_jvm_manifest_projection.py | 76 +- tests/test_operations_spec.py | 189 +- tests/test_packaging.py | 2 + tests/test_plugin_build_integration.py | 64 +- tests/test_plugin_runtime_codegen.py | 221 +- tests/test_project.py | 65 + tests/test_typescript_codegen.py | 4 +- tests/test_v2_models.py | 24 +- tests/test_v3_cpp_resolution_contract.py | 39 + tests/test_v3_phase0_schemas.py | 106 + tests/test_v3_phase2_frontends.py | 798 ++++++ .../test_v3_phase3_reachability_typescript.py | 426 +++ tests/test_v3_phase4_conversion.py | 529 ++++ tests/test_v3_phase4_generated_kernels.py | 225 ++ tests/test_v3_phase5_cpp_object_runtime.py | 330 +++ tests/test_v3_phase5_cpp_routes.py | 361 +++ tests/test_v3_phase6_jvm_routes.py | 432 +++ tests/test_v3_phase7_cross_family.py | 315 +++ tests/test_v3_semantic_model.py | 596 ++++ 93 files changed, 18225 insertions(+), 663 deletions(-) rename docs/{V1-TO-V2-ARCHITECTURE.md => V3-ARCHITECTURE.md} (56%) create mode 100644 src/supernote_module_generator/conversion.py create mode 100644 src/supernote_module_generator/conversion_codegen.py create mode 100644 src/supernote_module_generator/cpp_object_binding_codegen.py create mode 100644 src/supernote_module_generator/cpp_object_runtime_codegen.py create mode 100644 src/supernote_module_generator/cpp_routes.py create mode 100644 src/supernote_module_generator/cross_family_codegen.py create mode 100644 src/supernote_module_generator/jvm_object_binding_codegen.py create mode 100644 src/supernote_module_generator/jvm_object_runtime_codegen.py create mode 100644 src/supernote_module_generator/jvm_routes.py create mode 100644 src/supernote_module_generator/reachability.py create mode 100644 src/supernote_module_generator/semantic_types.py create mode 100644 src/supernote_module_generator/templates/v3.SupernotePluginObject.java.tmpl create mode 100644 src/supernote_module_generator/templates/v3.SupernotePluginValue.java.tmpl create mode 100644 src/supernote_module_generator/v3_schemas.py create mode 100644 tests/fixtures/v3_cpp_resolution/accept_exact_qualified_reference.hpp create mode 100644 tests/fixtures/v3_cpp_resolution/accept_forward_then_marked_definition.hpp create mode 100644 tests/fixtures/v3_cpp_resolution/accept_global_complete.hpp create mode 100644 tests/fixtures/v3_cpp_resolution/accept_named_namespace.hpp create mode 100644 tests/fixtures/v3_cpp_resolution/accept_nested_namespace.hpp create mode 100644 tests/fixtures/v3_cpp_resolution/accept_unqualified_enclosing.hpp create mode 100644 tests/fixtures/v3_cpp_resolution/cases.json create mode 100644 tests/fixtures/v3_cpp_resolution/reject_alias_signature.hpp create mode 100644 tests/fixtures/v3_cpp_resolution/reject_ambiguous_unqualified.hpp create mode 100644 tests/fixtures/v3_cpp_resolution/reject_anonymous_namespace.hpp create mode 100644 tests/fixtures/v3_cpp_resolution/reject_forward_only.hpp create mode 100644 tests/fixtures/v3_cpp_resolution/reject_marked_alias.hpp create mode 100644 tests/fixtures/v3_cpp_resolution/reject_nested_type.hpp create mode 100644 tests/fixtures/v3_cpp_resolution/reject_public_name_collision.hpp create mode 100644 tests/fixtures/v3_typescript/consumer.ts create mode 100644 tests/fixtures/v3_typescript/index.d.ts create mode 100644 tests/test_project.py create mode 100644 tests/test_v3_cpp_resolution_contract.py create mode 100644 tests/test_v3_phase0_schemas.py create mode 100644 tests/test_v3_phase2_frontends.py create mode 100644 tests/test_v3_phase3_reachability_typescript.py create mode 100644 tests/test_v3_phase4_conversion.py create mode 100644 tests/test_v3_phase4_generated_kernels.py create mode 100644 tests/test_v3_phase5_cpp_object_runtime.py create mode 100644 tests/test_v3_phase5_cpp_routes.py create mode 100644 tests/test_v3_phase6_jvm_routes.py create mode 100644 tests/test_v3_phase7_cross_family.py create mode 100644 tests/test_v3_semantic_model.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf2594d..4d4b30c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ copyability, stream separation, machine output, keyboard safety, or recovery. - Integration tests cover CLI lifecycle behavior against temporary plugin roots, parent wiring, source preservation, rollback, and documentation artifacts. -- Android fixture tests must compile the single plugin-level V2 runtime with +- Android fixture tests must compile the single plugin-level V3 runtime with mixed C/C++ and Kotlin/Java feature input. Do not describe Python-only tests or generated-text checks as Android compilation proof. - Device tests are relevant only when qualifying generated runtime integration, @@ -110,11 +110,11 @@ validation tier completed. - `README.md` is a short product entry point; the separate GitHub Wiki contains generator-specific user guidance. - `CONTRIBUTING.md` is contributor documentation. -- `docs/V1-TO-V2-ARCHITECTURE.md` records contributor-facing architectural - history without defining a supported migration workflow. +- `docs/V3-ARCHITECTURE.md` records the contributor-facing V3 model without + defining a supported V2 migration workflow. - `maintainers/` contains release/operation procedures. -- The immutable `v1-final` tag and Git history preserve the implementation - baseline. They create no V1 maintenance or compatibility contract. +- Historical tags and Git history preserve earlier implementation baselines. + They create no V2 maintenance or compatibility contract. The main repository must not contain a second copy of Wiki user guides. GitHub stores Wiki pages in `supernote-module-generator.wiki.git`; update and review diff --git a/MANIFEST.in b/MANIFEST.in index 5ab9ce5..f2bc9d3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,5 +6,6 @@ recursive-include docs *.md recursive-include maintainers *.md recursive-include architecture *.md recursive-include tests *.py +recursive-include tests/fixtures * recursive-include src/supernote_module_generator/templates * global-exclude __pycache__ *.py[cod] diff --git a/README.md b/README.md index aaeb487..b8b2c34 100644 --- a/README.md +++ b/README.md @@ -5,21 +5,20 @@ existing Supernote plugin. It generates the JSI, JNI, Kotlin Symbol Processing, TypeScript, build, and lifecycle code that connects those implementations to JavaScript. -V2 models one user-facing feature, regardless of where its implementation +V3 models one user-facing feature, regardless of where its implementation lives. One feature may contain C++, C helper files, Kotlin, and Java together. -JSI is the only JavaScript frontend, and the plugin compiles one generated V2 +JSI is the only JavaScript frontend, and the plugin compiles one generated V3 runtime/build component shared by all features. -V2 is the current stable architecture. Version `2.0.4` aligns the CLI help with -the actual Add, Update, Remove, feature-version, and Doctor behavior. It also -includes the cross-platform generator and generated-build improvements from -`2.0.3`, including Windows command discovery, Android toolchain diagnostics, -short coordinated runtime build paths, hardened failure handling, and safer -generated runtime teardown. Actual feature calls still require the plugin -runtime to be ready. -The initial V2 release series deliberately keeps advanced value/object features -and caller-controlled cancellation out of scope; the supported foundation is -described below. +Version `3.0.0.dev0` is the development line for first-class native objects and +declared copied value types. JavaScript keeps references to original C++, +Kotlin, and Java object instances, while declared value objects are validated +and copied. Arrays, nullable values, string enums, live object fields, +returned-only objects, explicit constructors/factories, and async object retention use +one language-neutral JavaScript and TypeScript model. + +There are no V2 users or migration requirements. V3 deliberately has no V2 +manifest reader, converter, compatibility mode, or migration tool. ## Install @@ -118,31 +117,59 @@ declaration to JavaScript or TypeScript. `SupernotePluginAsync` is always explic Kotlin `suspend`, C++ future-like types, or blocking implementation code never silently change the public API. -An exported class publishes the object type. Its single eligible public -constructor becomes the normal `create(...)` factory, while every other method -still needs its own marker: +`SupernotePluginObject` declares reference semantics; +`SupernotePluginValue` declares copied structural semantics. Neither marker +publishes members or construction by itself. Every JavaScript-visible function, +method, field, and constructor requires its own explicit marker: ```cpp -// @SupernotePluginExport -class Document { +// @SupernotePluginValue +struct Point { + // @SupernotePluginExport + double x; + // @SupernotePluginExport + double y; +}; + +// @SupernotePluginObject +class Stroke { public: - explicit Document(std::string path); + // @SupernoteConstructor + explicit Stroke(std::vector points); + + // @SupernotePluginExport + bool intersects(const std::shared_ptr &other) const; + + // @SupernotePluginExport + std::shared_ptr transformed(Point offset) const; // @SupernotePluginExport - std::int32_t pageCount() const; + std::string label; - void resetInternalCache(); // ignored + void resetInternalCache(); // ignored }; + +// @SupernotePluginExport +std::shared_ptr loadStroke(std::string path); ``` -Initial V2 also supports the same narrow per-JavaScript-object model for -deliberately marked Kotlin/Java classes. Object parameters/results, -returned-only objects, inheritance, properties, custom factories, and general -object graphs are deferred. +JavaScript receives stable runtime-local identity: if the same live native +instance is exposed again in one active runtime generation, the same JavaScript +object is returned. C++ objects use generated shared ownership; JVM objects use +managed global references and `IsSameObject`. Returned-only objects omit a +constructor but retain the same methods, argument/result behavior, lifetime, +and identity. Marked native-object fields are live properties; source +mutability determines whether they are writable. + +Kotlin data classes and supported Java records/final classes can declare copied +values. Kotlin/Java object classes use `@SupernotePluginObject`, and an eligible +constructor uses `@SupernoteConstructor`. Static/top-level functions returning +an object are ordinary explicitly marked factories; no separate factory marker +is needed. -## Initial value types +## V3 types and copied values -The initial semantic types and JavaScript/TypeScript mappings are: +The closed V3 semantic types and JavaScript/TypeScript mappings are: | Supernote value | JavaScript/TypeScript | | --- | --- | @@ -153,11 +180,37 @@ The initial semantic types and JavaScript/TypeScript mappings are: | `float32`, `float64` | `number` | | `string` | `string` | | `bytes` | `Uint8Array` | +| string enum | string-literal union | +| declared value object | typed plain object | +| native reference object | nominally branded generated interface | +| homogeneous array of `T` | `T[]` | +| nullable `T` | `T \| null` | Strings use UTF-8 when crossing native/JNI boundaries. Byte values use copy-based snapshot semantics and pass only the visible `Uint8Array` view. -Nullability, generic collections, maps, value structs, enums, unsigned values, -and zero-copy buffers are not part of the initial foundation. +Declared value fields are required and strictly validated. Extra JavaScript +fields are ignored without being read. Values and array containers are copied; +native-object leaves retain references and identity. Arrays must be dense and +homogeneous. `null` is accepted only where declared, while omitted values and +`undefined` remain invalid. + +V3 intentionally does not accept arbitrary JavaScript objects, dynamic/JSON +trees, callbacks, maps, sets, tuples, general unions, recursive value objects, +raw pointers, numeric native handles, unsigned/platform-dependent C++ integer +types, or unmarked structural lookalikes. + +## Language-family routing + +The public API does not expose implementation-family details. Current V3 passes +C++ native objects only to C++ routes and Kotlin/Java native objects within the +shared JVM family. Complete copied values may cross generated C++/JVM internal +routes when both families declare the same logical schema. + +Current V3 does not generate C++/JVM native-object proxies. A direct or nested +cross-family object reference is rejected during generation with a source- +located diagnostic. Object type IDs and public TypeScript shapes remain +language-neutral so a later proxy implementation does not require a public API +redesign. ## Async, errors, and lifetime @@ -192,6 +245,11 @@ compilation for that environment, not that a particular Supernote firmware, PluginHost, linker namespace, or SELinux policy will load and execute the code. Target-device behavior must be validated on the intended device. +Same-process native runtime replacement is generation-checked and bounded. A +PluginHost process accepts at most 32 generated native generations for one +plugin component; restart PluginHost before another replacement if that limit +is reached. + The generator does not create the surrounding Supernote plugin. Plugin creation, installation, and device debugging are covered by the [official Supernote plugin documentation](https://docs.supernote.com/). @@ -200,10 +258,9 @@ installation, and device debugging are covered by the See [CONTRIBUTING.md](https://github.com/Ziv-Ink/supernote-module-generator/blob/main/CONTRIBUTING.md) for development and validation rules and -[V1 to V2 architecture](https://github.com/Ziv-Ink/supernote-module-generator/blob/main/docs/V1-TO-V2-ARCHITECTURE.md) -for contributor-facing -architectural history. That history is not a project migration guide or a -compatibility promise. +[V3 architecture](https://github.com/Ziv-Ink/supernote-module-generator/blob/main/docs/V3-ARCHITECTURE.md) +for the contributor-facing runtime and type model. It is not a V2 migration +guide or compatibility promise. ## License diff --git a/docs/V1-TO-V2-ARCHITECTURE.md b/docs/V3-ARCHITECTURE.md similarity index 56% rename from docs/V1-TO-V2-ARCHITECTURE.md rename to docs/V3-ARCHITECTURE.md index f6ea420..d37d3ef 100644 --- a/docs/V1-TO-V2-ARCHITECTURE.md +++ b/docs/V3-ARCHITECTURE.md @@ -1,25 +1,24 @@ -# V1 to V2 architecture +# V3 architecture -This document is architectural history for contributors. It explains why V2 -code does not preserve several V1 shapes. It is not a converter guide, migration -analyzer, compatibility promise, or supported V1 maintenance policy. +This document summarizes the V3 architecture for contributors. It is not a V2 +converter guide, migration analyzer, compatibility promise, or supported V2 +maintenance policy. -## Same product, deliberate architecture break +## Deliberate architecture break -V2 remains in the same repository, Python distribution +V3 remains in the same repository, Python distribution (`supernote-module-generator`), and CLI command (`supernote-module`). The -immutable `v1-final` tag preserves the exact final V1 development baseline; -`v1.0.0` remains the earlier historical release tag. Mainline V2 development -reuses proven V1 machinery where its behavior still matches the V2 contract. +historical tags preserve earlier development baselines. Mainline V3 development +reuses proven machinery only where its behavior still matches the V3 contract. -There are no external V1 projects requiring migration support. Experimental -V1 projects can be updated manually. Do not add an automatic converter, +There are no V2 users requiring migration support. Experimental projects can be +updated manually. Do not add an automatic converter, read-only analyzer, legacy mode, source rewriter, or hidden compatibility branch unless a real future user need produces a new explicit decision. ## Logical features replace backend-specific modules -V1 asked developers to create Native, Native JNI, or JSI module types. V2 asks +Earlier generators asked developers to create backend-specific module types. V3 asks which starter source families to scaffold: ```text @@ -27,7 +26,7 @@ C/C++ (native) Kotlin/Java (JVM) ``` -That selection creates example files only. A logical feature remains +That selection creates starter files only. A logical feature remains language-neutral and may contain either or both families. Marked source and KSP manifests determine its actual build and routing requirements. @@ -37,7 +36,7 @@ second React Native bridge frontend. ## Source facts, API meaning, and routes are separate -The V2 pipeline is: +The V3 pipeline is: ```text language source model @@ -51,23 +50,26 @@ adapters where compiler knowledge is required. The common model contains only facts with common Supernote meaning; it is not a collection of optional JNI, C++, or Kotlin backend fields. -## Explicit intent replaces inference +## First-class objects and explicit intent -V1 object exports exposed supported public methods automatically. V2 ignores -ordinary code regardless of language visibility. `SupernotePluginExport` publishes a -declaration to JavaScript, `SupernotePluginInternal` creates hidden generated routing, +V3 represents declared native instances as nominal, runtime-local JavaScript +objects with stable identity, automatic lifetime management, and live marked +fields. Declared value types are validated copied data. Arbitrary JavaScript +object graphs are not accepted. + +V3 ignores ordinary code regardless of language visibility. +`SupernotePluginExport` publishes a declaration to JavaScript, +`SupernotePluginInternal` creates hidden generated routing, `SupernotePluginAsync` selects async Supernote semantics, and `SupernoteConstructor` resolves an otherwise ambiguous construction path. -An exported class publishes its type and automatically uses its one eligible -public constructor as `create(...)`. Every regular method, property-like API, -static API, or special factory still requires explicit intent. There is no V1 -automatic-member compatibility mode. +A marked object publishes its nominal type. Construction, every method, field, +static API, and factory still require explicit intent. Returned-only object +types are valid. There is no automatic-member compatibility mode. -## One compiled runtime per plugin +## One generated runtime per plugin -V1 generated a local React Native/Android package for each module. V2 generates -one plugin-level native build component containing shared runtime services and +V3 generates one plugin-level native build component containing shared runtime services and all generated feature bindings. Logical features remain independent ownership units, but they do not compile separate worker pools, JVM services, or runtime singletons. @@ -78,6 +80,15 @@ each feature gets a child FeatureSession. Background work never stores a thread and receives valid runtime access only if the originating generation is still alive. +Plugin replacement loads a uniquely named copy of the generated bindings and +performs an explicit native/JVM generation-identity handshake before JNI +registration. A stale or mismatched publication fails closed. Dependency lookup +uses one process-global SoLoader source per generated plugin component; native +generations retained by SoLoader are capped at 32 per PluginHost process. The +33rd load fails with a restart instruction instead of growing process state +without a bound. This leaves room for the required 25-cycle reload stress while +making the operational limit explicit. + ## Async and teardown Async is explicit API intent, not a Kotlin/C++ implementation inference. @@ -112,10 +123,13 @@ context. Contributors must preserve all parts of that contract: - final component shutdown cannot unload code while queued or late cleanup can still execute. -## What V1 still contributes +## Language-family boundary + +Current native-object routes remain within one implementation family: C++ +objects go to C++ and Kotlin/Java objects stay on the JVM. Declared copied +values may cross generated internal C++/JVM routes. Cross-family object proxies +are deferred without changing the public JavaScript or TypeScript model. -V1 remains useful for its parsers, code generation, JSI HostFunction/HostObject -patterns, shared ownership, transactions, diagnostics, build knowledge, KSP/JNI -machinery, tests, and regression history. Reuse those pieces when they satisfy -V2 decisions. Replace behavior that V2 deliberately changed instead of wrapping -it in a compatibility branch. +Earlier parsers, code generation, JSI HostFunction/HostObject patterns, shared +ownership, transactions, diagnostics, build knowledge, KSP/JNI machinery, +tests, and regression history remain useful only when they satisfy V3 decisions. diff --git a/setup.cfg b/setup.cfg index ffb9518..48cdd14 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,6 +2,8 @@ name = supernote-module-generator version = attr: supernote_module_generator.__version__ description = Generate typed C/C++ and Kotlin/Java features for existing Supernote plugins +author = Ziv-Ink +author_email = einkatelier@gmail.com long_description = file: README.md long_description_content_type = text/markdown license = MIT diff --git a/src/supernote_module_generator/__init__.py b/src/supernote_module_generator/__init__.py index 3c7a374..8c03237 100644 --- a/src/supernote_module_generator/__init__.py +++ b/src/supernote_module_generator/__init__.py @@ -1,3 +1,3 @@ """Safe generator for local native code modules in Supernote React Native plugins.""" -__version__ = "2.0.4" +__version__ = "3.0.0.dev0" diff --git a/src/supernote_module_generator/binding_codegen.py b/src/supernote_module_generator/binding_codegen.py index a3975a8..59573b9 100644 --- a/src/supernote_module_generator/binding_codegen.py +++ b/src/supernote_module_generator/binding_codegen.py @@ -10,6 +10,8 @@ import sys if __package__: + from .cpp_object_binding_codegen import render_cpp_object_bindings + from .cpp_routes import CppRouteError, plan_cpp_routes from .cpp_projection import ( CppProjectionError, project_cpp_api, @@ -25,7 +27,9 @@ from .source_models import ( CppClassSource, CppConstructorSource, + CppEnumSource, CppFunctionSource, + CppFieldSource, CppMethodSource, CppParameterSource, DeclarationTarget, @@ -35,6 +39,13 @@ SupernoteMarker, ) else: + from supernote_codegen.cpp_object_binding_codegen import ( # type: ignore[no-redef] + render_cpp_object_bindings, + ) + from supernote_codegen.cpp_routes import ( # type: ignore[no-redef] + CppRouteError, + plan_cpp_routes, + ) from supernote_codegen.cpp_projection import ( # type: ignore[no-redef] CppProjectionError, project_cpp_api, @@ -50,7 +61,9 @@ from supernote_codegen.source_models import ( # type: ignore[no-redef] CppClassSource, CppConstructorSource, + CppEnumSource, CppFunctionSource, + CppFieldSource, CppMethodSource, CppParameterSource, DeclarationTarget, @@ -736,7 +749,7 @@ def _parse_parameter( ) -> Parameter: line = tokens[0].line if tokens else marker_line expected = ( - f"argument {argument_index} must use one named canonical V2 value " + f"argument {argument_index} must use one named canonical V3 value " "type, for example 'std::int32_t value'" ) if not tokens: @@ -750,7 +763,6 @@ def _parse_parameter( ) values = [token.value for token in tokens] forbidden = { - "&": "references", "*": "raw pointers", "=": "default arguments", "...": "variadic arguments", @@ -771,15 +783,9 @@ def _parse_parameter( f"{description} are not supported; {expected}", ) - cpp_type, consumed = _type_prefix(tokens) - name = ( - tokens[consumed].value - if cpp_type is not None - and cpp_type != "void" - and consumed + 1 == len(tokens) - and tokens[consumed].kind == "identifier" - else None - ) + name = tokens[-1].value if tokens[-1].kind == "identifier" else None + type_tokens = tokens[:-1] + cpp_type = _cpp_type_spelling(type_tokens) if name is not None else None if cpp_type is None or name is None: raise _source_error( module_root, @@ -802,6 +808,32 @@ def _parse_parameter( return Parameter(cpp_type, name) +def _cpp_type_spelling(tokens: list[_Token]) -> str | None: + if not tokens: + return None + allowed_punctuation = {"::", "<", ">", "&"} + if any( + token.kind != "identifier" and token.value not in allowed_punctuation + for token in tokens + ): + return None + if sum(token.value == "<" for token in tokens) != sum( + token.value == ">" for token in tokens + ): + return None + if any( + token.value == "&" and index != len(tokens) - 1 + for index, token in enumerate(tokens) + ): + return None + value = " ".join(token.value for token in tokens) + value = re.sub(r"\s*::\s*", "::", value) + value = re.sub(r"\s*<\s*", "<", value) + value = re.sub(r"\s*>\s*", ">", value) + value = re.sub(r"\s*&\s*", "&", value) + return value + + def _parse_function_source( *, module_root: Path, @@ -814,6 +846,7 @@ def _parse_function_source( ) -> CppFunctionSource: marker = stack.first marker_export = "" + namespace, namespace_depth = _namespace_at(lexed, marker.start) for comment in stack.comments: if not comment.line_only: raise _source_error( @@ -834,14 +867,15 @@ def _parse_function_source( "Supernote markers are not allowed inside a preprocessor " "conditional (#if, #ifdef, or #ifndef block)", ) - if comment.brace_depth: + if comment.brace_depth != namespace_depth: raise _source_error( module_root, path, comment.line, module_name, marker_export, - "a free-function marker must be at global C++ scope", + "a free-function marker must be at namespace scope, not inside " + "a class or function", ) occurrences = tuple( @@ -957,7 +991,31 @@ def _parse_function_source( "have ordinary external C++ linkage with no modifiers", ) - return_type, consumed = _type_prefix(following) + opening_signature = next( + (index for index, token in enumerate(following) if token.value == "("), + None, + ) + if opening_signature is None or opening_signature < 2: + return_type = None + consumed = 0 + else: + consumed = opening_signature - 1 + return_type = _cpp_type_spelling(following[:consumed]) + return_tokens = following[:consumed] + if any(token.value == "*" for token in return_tokens): + raise _source_error( + module_root, path, following[0].line, module_name, + marker_export, + "raw pointers are not supported as marked C++ results; " + "return one canonical owned V3 type", + ) + if any(token.value in {"&", "&&"} for token in return_tokens): + raise _source_error( + module_root, path, following[0].line, module_name, + marker_export, + "references are not supported as marked C++ results; return " + "one canonical owned V3 type", + ) if return_type is None: description = ( "unsupported declaration prefix or macro" @@ -971,28 +1029,11 @@ def _parse_function_source( module_name, marker_export, "not a supported top-level function definition: " - f"{description} {first_value!r}; expected one canonical V2 return " + f"{description} {first_value!r}; expected one canonical V3 return " "type followed by a function name", ) cursor = consumed - if ( - cursor < len(following) - and following[cursor].value in {"*", "&", "&&"} - ): - declarator = following[cursor].value - description = "raw pointers" if declarator == "*" else "references" - raise _source_error( - module_root, - path, - following[cursor].line, - module_name, - marker_export, - f"unsupported return type {return_type + declarator!r}: " - f"{description} are not supported; return one canonical V2 value " - "type by value", - ) - if cursor >= len(following) or following[cursor].kind != "identifier": line = following[min(cursor, len(following) - 1)].line raise _source_error( @@ -1173,6 +1214,7 @@ def _parse_function_source( intent=intent, noexcept=is_noexcept, definition_offset=function_token.start, + namespace=namespace, ) @@ -1801,12 +1843,13 @@ def _marker_entries( if match and match.group("name") not in SOURCE_MARKERS: message = ( f"unknown Supernote marker {match.group('name')!r}; supported " - "markers are SupernotePluginExport, SupernotePluginInternal, " + "markers are SupernotePluginObject, SupernotePluginValue, " + "SupernotePluginExport, SupernotePluginInternal, " "SupernotePluginAsync, and SupernoteConstructor" ) else: message = ( - "malformed Supernote marker; initial V2 markers take no " + "malformed Supernote marker; initial V3 markers take no " "arguments and must be written exactly, for example " "// @SupernotePluginExport" ) @@ -1922,6 +1965,44 @@ def _validate_marker_stack_location( ) +def _namespace_at(lexed: _LexedSource, offset: int) -> tuple[tuple[str, ...], int]: + tokens = [item for item in lexed.tokens if item.conditional_depth == 0] + ranges: list[tuple[int, int, tuple[str, ...]]] = [] + for index, token in enumerate(tokens): + if token.value != "namespace" or token.start >= offset: + continue + cursor = index + 1 + names: list[str] = [] + expect_name = True + while cursor < len(tokens) and tokens[cursor].value != "{": + current = tokens[cursor] + if expect_name and current.kind == "identifier": + names.append(current.value) + expect_name = False + elif not expect_name and current.value == "::": + expect_name = True + else: + names = [] + break + cursor += 1 + if cursor >= len(tokens) or tokens[cursor].value != "{" or not names or expect_name: + continue + opening = tokens[cursor] + closing = next( + ( + item + for item in tokens[cursor + 1:] + if item.value == "}" and item.brace_depth == opening.brace_depth + 1 + ), + None, + ) + if closing is not None and opening.end <= offset < closing.start: + ranges.append((opening.start, closing.start, tuple(names))) + ranges.sort(key=lambda item: item[0]) + namespace = tuple(name for _, _, names in ranges for name in names) + return namespace, len(ranges) + + def _constructor_suffix( tokens: list[_Token], *, @@ -1973,68 +2054,97 @@ def _parse_v2_class_source( path: Path, text: str, lexed: _LexedSource, - class_stack: _MarkerStack, + class_stack: _MarkerStack | None, + class_token: _Token | None, stacks: list[_MarkerStack], module_name: str, ) -> tuple[CppClassSource, set[int]]: - _validate_marker_stack_location( - module_root, - path, - module_name, - class_stack, - brace_depth=0, - description="class", - ) - class_intent = _intent_from_stack( - module_root, - path, - module_name, - class_stack, - DeclarationTarget.CLASS, - None, - ) active_tokens = [ token for token in lexed.tokens if token.conditional_depth == 0 ] - preceding = [ - token for token in active_tokens if token.end <= class_stack.first.start - ] - prefix: list[_Token] = [] - for token in reversed(preceding): - if token.value in {";", "{", "}"}: - break - prefix.append(token) - prefix.reverse() - if prefix: - raise _source_error( + if class_stack is not None: + class_offset = class_stack.first.start + namespace, namespace_depth = _namespace_at(lexed, class_offset) + _validate_marker_stack_location( module_root, path, - prefix[0].line, module_name, + class_stack, + brace_depth=namespace_depth, + description="class", + ) + class_intent = _intent_from_stack( + module_root, + path, + module_name, + class_stack, + DeclarationTarget.CLASS, None, - "unsupported declaration prefix before the class marker " - f"{_tokens_text(prefix)!r}; templates and declaration modifiers " - "are not supported", ) - following = [ - token for token in active_tokens if token.start >= class_stack.last.end - ] + preceding = [ + token for token in active_tokens if token.end <= class_offset + ] + prefix: list[_Token] = [] + for token in reversed(preceding): + if token.value in {";", "{", "}"}: + break + prefix.append(token) + prefix.reverse() + if prefix: + raise _source_error( + module_root, + path, + prefix[0].line, + module_name, + None, + "unsupported declaration prefix before the class marker " + f"{_tokens_text(prefix)!r}; templates and declaration " + "modifiers are not supported", + ) + following = [ + token for token in active_tokens + if token.start >= class_stack.last.end + ] + diagnostic_line = class_stack.first.line + else: + if class_token is None: + raise ValueError("an unmarked class parse requires its class token") + class_offset = class_token.start + namespace, namespace_depth = _namespace_at(lexed, class_offset) + if class_token.brace_depth != namespace_depth: + raise _source_error( + module_root, + path, + class_token.line, + module_name, + None, + "generated members require a top-level or namespace-level " + "implementation owner class", + ) + class_intent = SourceIntent(DeclarationTarget.CLASS) + following = [ + token for token in active_tokens if token.start >= class_token.start + ] + diagnostic_line = class_token.line if not following: raise _source_error( module_root, path, - class_stack.first.line, + diagnostic_line, module_name, None, "a class marker stack must be followed by a complete class or " "struct definition", ) first = following[0] - if text[class_stack.last.end:first.start].strip(): + if ( + class_stack is not None + and text[class_stack.last.end:first.start].strip() + ): raise _source_error( module_root, path, - class_stack.first.line, + diagnostic_line, module_name, None, "only whitespace may appear between the final class marker and " @@ -2085,7 +2195,7 @@ def _parse_v2_class_source( before_body[0].line, module_name, cpp_name, - "inheritance is not supported for initial V2 generated classes", + "inheritance is not supported for initial V3 generated classes", ) if before_body: raise _source_error( @@ -2142,7 +2252,11 @@ def _parse_v2_class_source( if opening_token.end <= stack.first.start < closing_token.start and stack is not class_stack ] - consumed = {comment.start for comment in class_stack.comments} + consumed = ( + {comment.start for comment in class_stack.comments} + if class_stack is not None + else set() + ) stack_by_declaration: dict[int, _MarkerStack] = {} for stack in member_stacks: _validate_marker_stack_location( @@ -2196,6 +2310,7 @@ def _parse_v2_class_source( constructors: list[CppConstructorSource] = [] methods: list[CppMethodSource] = [] + fields: list[CppFieldSource] = [] method_names: dict[str, int] = {} constructor_ids: set[str] = set() has_user_constructor = False @@ -2206,16 +2321,79 @@ def _parse_v2_class_source( stack = stack_by_declaration.get(declaration[0].start) values = [token.value for token in declaration] if "(" not in values: - if stack is not None: + if stack is None: + if class_intent.declares_value and "static" not in values: + raise _source_error( + module_root, + path, + declaration[0].line, + module_name, + cpp_name, + "every non-static stored value member requires " + "SupernotePluginExport", + ) + continue + intent = _intent_from_stack( + module_root, + path, + module_name, + stack, + DeclarationTarget.FIELD, + cpp_name, + ) + if access != "public": raise _source_error( module_root, path, stack.first.line, module_name, cpp_name, - "properties, fields, and other non-method generated " - "members are deferred in initial V2", + "a generated field must be public in C++", + ) + if any(value in values for value in {"=", ",", "*", "[["}): + raise _source_error( + module_root, + path, + stack.first.line, + module_name, + cpp_name, + "a generated field must be one directly declared named " + "canonical V3 field without initializer, pointer, attribute, " + "or multiple declarator", + ) + is_static = values[0] == "static" + field_tokens = declaration[1:] if is_static else declaration + is_const = bool(field_tokens and field_tokens[0].value == "const") + if is_const: + field_tokens = field_tokens[1:] + if len(field_tokens) < 2 or field_tokens[-1].kind != "identifier": + raise _source_error( + module_root, path, stack.first.line, module_name, cpp_name, + "a generated field requires a canonical type and ordinary name", + ) + field_type = _cpp_type_spelling(field_tokens[:-1]) + if field_type is None: + raise _source_error( + module_root, path, stack.first.line, module_name, cpp_name, + "unsupported generated field type spelling", + ) + field_name = field_tokens[-1].value + fields.append( + CppFieldSource( + SourceProvenance( + f"cpp:{relative}:{cpp_name}.{field_name}#field", + "cpp", + relative, + stack.first.line, + ), + field_name, + field_type, + intent, + access, + not is_const, + is_static, ) + ) continue opening_index = values.index("(") try: @@ -2389,7 +2567,6 @@ def _parse_v2_class_source( ) for forbidden, description in ( ("operator", "operators"), - ("static", "static methods"), ("virtual", "virtual methods"), ): if forbidden in prefix_values: @@ -2401,27 +2578,28 @@ def _parse_v2_class_source( cpp_name, f"{description} are deferred generated-member forms", ) - return_type, consumed_type = _type_prefix(declaration[:opening_index]) - if ( - return_type is None - or consumed_type + 1 != opening_index - or declaration[consumed_type].kind != "identifier" - ): + method_prefix = declaration[:opening_index] + is_static = bool(method_prefix and method_prefix[0].value == "static") + if is_static: + method_prefix = method_prefix[1:] + return_type = _cpp_type_spelling(method_prefix[:-1]) + method_token = method_prefix[-1] if method_prefix else declaration[0] + if return_type is None or not method_prefix or method_token.kind != "identifier": raise _source_error( module_root, path, declaration[0].line, module_name, cpp_name, - "a marked method must use one canonical V2 result type " + "a marked method must use one canonical V3 result type " "followed by an ordinary method name", ) - method_name = declaration[consumed_type].value + method_name = method_token.value if method_name in CPP23_KEYWORDS: raise _source_error( module_root, path, - declaration[consumed_type].line, + method_token.line, module_name, cpp_name, f"method name {method_name!r} is a C++23 keyword", @@ -2430,7 +2608,7 @@ def _parse_v2_class_source( raise _source_error( module_root, path, - declaration[consumed_type].line, + method_token.line, module_name, cpp_name, f"duplicate generated method name {method_name!r}; first " @@ -2454,7 +2632,7 @@ def _parse_v2_class_source( allow_default=False, ) signature = ",".join(parameter.cpp_type for parameter in parameters) - method_names[method_name] = declaration[consumed_type].line + method_names[method_name] = method_token.line methods.append( CppMethodSource( provenance=SourceProvenance( @@ -2475,6 +2653,7 @@ def _parse_v2_class_source( access=access, const=is_const, noexcept=is_noexcept, + static=is_static, ) ) @@ -2487,7 +2666,7 @@ def _parse_v2_class_source( ), language="cpp", path=relative, - line=class_stack.first.line, + line=diagnostic_line, ), parameters=(), access="public", @@ -2500,7 +2679,7 @@ def _parse_v2_class_source( declaration_id=f"cpp:{relative}:class:{cpp_name}", language="cpp", path=relative, - line=class_stack.first.line, + line=diagnostic_line, ), cpp_name=cpp_name, include=path.relative_to(source_root).as_posix(), @@ -2508,10 +2687,48 @@ def _parse_v2_class_source( constructors=tuple(constructors), methods=tuple(methods), declaration_kind=first.value, + fields=tuple(fields), + namespace=namespace, ) return class_source, consumed +def _top_level_class_extents( + lexed: _LexedSource, +) -> list[tuple[_Token, _Token, _Token]]: + tokens = [item for item in lexed.tokens if item.conditional_depth == 0] + result: list[tuple[_Token, _Token, _Token]] = [] + for index, token in enumerate(tokens): + if token.value not in {"class", "struct"}: + continue + if index and tokens[index - 1].value == "enum": + continue + _, namespace_depth = _namespace_at(lexed, token.start) + if token.brace_depth != namespace_depth: + continue + opening = next( + ( + item for item in tokens[index + 1:] + if item.value in {"{", ";"} + ), + None, + ) + if opening is None or opening.value != "{": + continue + closing = next( + ( + item for item in tokens + if item.start > opening.start + and item.value == "}" + and item.brace_depth == opening.brace_depth + 1 + ), + None, + ) + if closing is not None: + result.append((token, opening, closing)) + return result + + def scan_cpp_class_source_model( module_root: Path, *, @@ -2530,8 +2747,35 @@ def scan_cpp_class_source_model( entries = _marker_entries(module_root, path, lexed, module_name) stacks = _marker_stacks(text, entries) consumed: set[int] = set() + parsed_class_offsets: set[int] = set() for stack in stacks: - if stack.first.brace_depth != 0: + namespace, namespace_depth = _namespace_at(lexed, stack.first.start) + if stack.first.brace_depth != namespace_depth: + if any( + marker in {SupernoteMarker.OBJECT, SupernoteMarker.VALUE} + for marker in stack.markers + ): + raise _source_error( + module_root, + path, + stack.first.line, + module_name, + None, + "marked C++ types must be at global or named-namespace " + "brace depth; anonymous namespaces and nested types " + "are unsupported", + ) + continue + following = next( + ( + item for item in lexed.tokens + if item.conditional_depth == 0 + and item.start >= stack.last.end + ), + None, + ) + if following is not None and following.value == "enum": + consumed.update(comment.start for comment in stack.comments) continue item, item_consumed = _parse_v2_class_source( module_root=module_root, @@ -2540,6 +2784,43 @@ def scan_cpp_class_source_model( text=text, lexed=lexed, class_stack=stack, + class_token=None, + stacks=stacks, + module_name=module_name, + ) + classes.append(item) + consumed.update(item_consumed) + if following is not None: + parsed_class_offsets.add(following.start) + + extents = _top_level_class_extents(lexed) + owner_offsets: set[int] = set() + for stack in stacks: + if all(comment.start in consumed for comment in stack.comments): + continue + owner = next( + ( + extent for extent in extents + if extent[1].end <= stack.first.start < extent[2].start + ), + None, + ) + if owner is not None: + owner_offsets.add(owner[0].start) + for owner_offset in sorted(owner_offsets): + if owner_offset in parsed_class_offsets: + continue + class_token = next( + token for token, _, _ in extents if token.start == owner_offset + ) + item, item_consumed = _parse_v2_class_source( + module_root=module_root, + source_root=source_root, + path=path, + text=text, + lexed=lexed, + class_stack=None, + class_token=class_token, stacks=stacks, module_name=module_name, ) @@ -2553,8 +2834,8 @@ def scan_cpp_class_source_model( comment.line, module_name, None, - "a marked C++ member requires a marked top-level " - "SupernotePluginExport or SupernotePluginInternal class", + "a marked C++ member requires a top-level or namespace-level " + "implementation owner class", ) classes.sort( key=lambda item: ( @@ -2566,6 +2847,127 @@ def scan_cpp_class_source_model( return classes +def _parse_cpp_enum_source( + *, + module_root: Path, + source_root: Path, + path: Path, + text: str, + lexed: _LexedSource, + stack: _MarkerStack, + module_name: str, +) -> tuple[CppEnumSource, set[int]]: + namespace, namespace_depth = _namespace_at(lexed, stack.first.start) + _validate_marker_stack_location( + module_root, path, module_name, stack, + brace_depth=namespace_depth, description="enum", + ) + intent = _intent_from_stack( + module_root, path, module_name, stack, DeclarationTarget.ENUM, None + ) + following = [ + item for item in lexed.tokens + if item.conditional_depth == 0 and item.start >= stack.last.end + ] + if len(following) < 5 or following[0].value != "enum" or following[1].value != "class": + raise _source_error( + module_root, path, stack.first.line, module_name, None, + "SupernotePluginValue on an enum requires a complete enum class definition", + ) + if text[stack.last.end:following[0].start].strip(): + raise _source_error( + module_root, path, stack.first.line, module_name, None, + "only whitespace may appear between SupernotePluginValue and enum class", + ) + name_token = following[2] + if name_token.kind != "identifier" or following[3].value != "{": + raise _source_error( + module_root, path, name_token.line, module_name, None, + "a marked enum class requires an ordinary name and no base type", + ) + opening = following[3] + closing_index = next( + ( + index for index, item in enumerate(following[4:], start=4) + if item.value == "}" and item.brace_depth == opening.brace_depth + 1 + ), + None, + ) + if closing_index is None or closing_index + 1 >= len(following) or following[closing_index + 1].value != ";": + raise _source_error( + module_root, path, name_token.line, module_name, name_token.value, + "marked enum class must be a complete definition ending in '};'", + ) + body = following[4:closing_index] + constants: list[str] = [] + expect_constant = True + for token in body: + if expect_constant and token.kind == "identifier": + constants.append(token.value) + expect_constant = False + elif not expect_constant and token.value == ",": + expect_constant = True + else: + raise _source_error( + module_root, path, token.line, module_name, name_token.value, + "string enums allow only comma-separated source constant names; " + "explicit values, aliases, and attributes are unsupported", + ) + if not constants or (expect_constant and body and body[-1].value != ","): + raise _source_error( + module_root, path, name_token.line, module_name, name_token.value, + "a marked enum class requires at least one valid constant", + ) + relative = str(path.relative_to(module_root)) + return ( + CppEnumSource( + SourceProvenance( + f"cpp:{relative}:enum:{'::'.join((*namespace, name_token.value))}", + "cpp", relative, stack.first.line, + ), + name_token.value, + path.relative_to(source_root).as_posix(), + intent, + tuple(constants), + namespace, + ), + {item.start for item in stack.comments}, + ) + + +def scan_cpp_enum_source_model( + module_root: Path, + *, + module_name: str | None = None, +) -> list[CppEnumSource]: + source_root = module_root / "android/src/main/cpp" + _, resolved_name = _scan_context(module_root, None, module_name) + result: list[CppEnumSource] = [] + for path in sorted(source_root.rglob("*")): + if not path.is_file() or path.suffix.lower() not in CPP_HEADER_SUFFIXES: + continue + text = path.read_text(encoding="utf-8") + lexed = _lex_source(text) + stacks = _marker_stacks( + text, _marker_entries(module_root, path, lexed, resolved_name) + ) + for stack in stacks: + following = next( + ( + item for item in lexed.tokens + if item.conditional_depth == 0 and item.start >= stack.last.end + ), + None, + ) + if following is not None and following.value == "enum": + item, _ = _parse_cpp_enum_source( + module_root=module_root, source_root=source_root, path=path, + text=text, lexed=lexed, stack=stack, module_name=resolved_name, + ) + result.append(item) + return sorted(result, key=lambda item: (item.provenance.path, item.provenance.line)) + + def scan_cpp_source_model( module_root: Path, *, @@ -2612,7 +3014,7 @@ def scan_cpp_source_model( ): if suffix == ".c": message = ( - "direct marked C bindings are unsupported in initial V2; " + "direct marked C bindings are unsupported in initial V3; " "use ordinary C23 implementation code behind a canonical " "marked C++ boundary" ) @@ -2691,9 +3093,18 @@ def scan_cpp_semantic_model( module_name: str | None = None, ) -> SemanticApi: try: + feature_id = "supernote:feature:legacy" + metadata_path = module_root / ".supernote-module.json" + if metadata_path.is_file(): + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + candidate = metadata.get("feature_id") + if isinstance(candidate, str): + feature_id = candidate return project_cpp_api( scan_cpp_source_model(module_root, module_name=module_name), scan_cpp_class_source_model(module_root, module_name=module_name), + scan_cpp_enum_source_model(module_root, module_name=module_name), + feature_id=feature_id, ) except (CppProjectionError, SourceModelError, ValueError) as exc: raise CodegenError(str(exc)) from exc @@ -2991,7 +3402,7 @@ def scan_objects( comment.line, module_name, None, - "SupernoteExportObject is removed in V2; mark the class with " + "SupernoteExportObject is removed in V3; mark the class with " "SupernotePluginExport and mark each generated method explicitly", ) class_sources = scan_cpp_class_source_model( @@ -3688,6 +4099,7 @@ def _jsi_range_validation( prefix = ( f"{diagnostic_name}: argument {number + 1} ({parameter.name}) " ) + path = f"{diagnostic_name}.argument[{number}]({parameter.name})" if parameter.cpp_type in {"int32_t", "std::int32_t"}: return [ f"{indent}const double {argument_name} = arguments[{number}].asNumber();", @@ -3698,14 +4110,17 @@ def _jsi_range_validation( f"{indent} {argument_name} > static_cast(", f"{indent} std::numeric_limits::max())) {{", f"{indent} supernote_throw_range_error(", - f"{indent} runtime, {json.dumps(prefix + 'must be a signed 32-bit integer')});", + f"{indent} runtime, {json.dumps(prefix + 'must be a signed 32-bit integer')},", + f"{indent} \"OUT_OF_RANGE\", {json.dumps(path)}, \"int32\",", + f"{indent} supernote_describe_value(runtime, arguments[{number}]));", f"{indent}}}", ] if parameter.cpp_type in {"int64_t", "std::int64_t"}: return [ f"{indent}if (!arguments[{number}].getBigInt(runtime).isInt64(runtime)) {{", f"{indent} supernote_throw_range_error(", - f"{indent} runtime, {json.dumps(prefix + 'must fit in a signed 64-bit integer')});", + f"{indent} runtime, {json.dumps(prefix + 'must fit in a signed 64-bit integer')},", + f"{indent} \"OUT_OF_RANGE\", {json.dumps(path)}, \"int64 bigint\", \"bigint\");", f"{indent}}}", ] if parameter.cpp_type == "float": @@ -3717,7 +4132,8 @@ def _jsi_range_validation( f"{indent} {argument_name} > static_cast(", f"{indent} std::numeric_limits::max()))) {{", f"{indent} supernote_throw_range_error(", - f"{indent} runtime, {json.dumps(prefix + 'must fit in a 32-bit float')});", + f"{indent} runtime, {json.dumps(prefix + 'must fit in a 32-bit float')},", + f"{indent} \"OUT_OF_RANGE\", {json.dumps(path)}, \"float32\", \"number\");", f"{indent}}}", ] return [] @@ -3751,29 +4167,113 @@ def _jsi_result_lines(call: str, return_type: str, indent: str) -> list[str]: def _jsi_value_helpers() -> str: - return r'''[[noreturn]] void supernote_throw_builtin_error( + return r'''std::string supernote_describe_value( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value) { + if (value.isUndefined()) return "undefined"; + if (value.isNull()) return "null"; + if (value.isBool()) return "boolean"; + if (value.isNumber()) return "number"; + if (value.isBigInt()) return "bigint"; + if (value.isString()) return "string"; + if (value.isSymbol()) return "symbol"; + if (!value.isObject()) return "unknown"; + auto object = value.getObject(runtime); + if (object.isArray(runtime)) return "Array"; + if (object.isFunction(runtime)) return "function"; + return "object"; +} + +facebook::jsi::Value supernote_make_builtin_error( facebook::jsi::Runtime &runtime, const char *constructor_name, - const std::string &message) { + const std::string &message, + const std::string &reason, + const std::string &path, + const std::string &expected, + const std::string &actual) { auto constructor = runtime.global().getPropertyAsFunction(runtime, constructor_name); const facebook::jsi::Value argument( facebook::jsi::String::createFromUtf8(runtime, message)); - auto error = constructor.callAsConstructor( + auto error_value = constructor.callAsConstructor( runtime, &argument, static_cast(1)); + auto error = error_value.getObject(runtime); + error.setProperty( + runtime, "reason", + facebook::jsi::String::createFromAscii(runtime, reason)); + error.setProperty( + runtime, "path", + facebook::jsi::String::createFromUtf8(runtime, path)); + error.setProperty( + runtime, "expected", + facebook::jsi::String::createFromUtf8(runtime, expected)); + error.setProperty( + runtime, "actual", + facebook::jsi::String::createFromUtf8(runtime, actual)); + return facebook::jsi::Value(std::move(error)); +} + +[[noreturn]] void supernote_throw_builtin_error( + facebook::jsi::Runtime &runtime, + const char *constructor_name, + const std::string &message, + const std::string &reason = "TYPE_MISMATCH", + const std::string &path = "", + const std::string &expected = "", + const std::string &actual = "unknown") { + auto error = supernote_make_builtin_error( + runtime, constructor_name, message, reason, path, expected, actual); throw facebook::jsi::JSError(runtime, std::move(error)); } [[noreturn]] void supernote_throw_type_error( facebook::jsi::Runtime &runtime, - const std::string &message) { - supernote_throw_builtin_error(runtime, "TypeError", message); + const std::string &message, + const std::string &reason = "TYPE_MISMATCH", + const std::string &path = "", + const std::string &expected = "", + const std::string &actual = "unknown") { + supernote_throw_builtin_error( + runtime, "TypeError", message, reason, path, expected, actual); } [[noreturn]] void supernote_throw_range_error( facebook::jsi::Runtime &runtime, - const std::string &message) { - supernote_throw_builtin_error(runtime, "RangeError", message); + const std::string &message, + const std::string &reason = "OUT_OF_RANGE", + const std::string &path = "", + const std::string &expected = "", + const std::string &actual = "unknown") { + supernote_throw_builtin_error( + runtime, "RangeError", message, reason, path, expected, actual); +} + +facebook::jsi::Value supernote_validation_success( + facebook::jsi::Runtime &runtime) { + facebook::jsi::Object result(runtime); + result.setProperty(runtime, "ok", true); + return facebook::jsi::Value(std::move(result)); +} + +facebook::jsi::Value supernote_validation_failure( + facebook::jsi::Runtime &runtime, + facebook::jsi::Value error) { + facebook::jsi::Object result(runtime); + result.setProperty(runtime, "ok", false); + result.setProperty(runtime, "error", std::move(error)); + return facebook::jsi::Value(std::move(result)); +} + +facebook::jsi::Function supernote_attach_preflight( + facebook::jsi::Runtime &runtime, + facebook::jsi::Function function, + facebook::jsi::Function accepts, + facebook::jsi::Function check_arguments) { + function.setProperty(runtime, "accepts", std::move(accepts)); + function.setProperty( + runtime, "checkArguments", std::move(check_arguments)); + return function; } [[noreturn]] void supernote_throw_error( @@ -3888,7 +4388,7 @@ class SupernoteOwnedBytesBuffer final : public facebook::jsi::MutableBuffer { def _jsi_async_helpers() -> str: return r'''constexpr char kPromiseContinuationsGlobal[] = - "__supernoteV2PromiseContinuations_a7db36cf3b5e"; + "__supernoteV3PromiseContinuations_a7db36cf3b5e"; facebook::jsi::Object supernote_error_object( facebook::jsi::Runtime &runtime, @@ -4573,6 +5073,11 @@ def _jsi_binding( objects: list[ObjectExport], *, feature_id: str | None = None, + extra_includes: tuple[str, ...] = (), + extra_declarations: tuple[str, ...] = (), + extra_wrappers: tuple[str, ...] = (), + extra_registrations: tuple[str, ...] = (), + extra_uses_async: bool = False, ) -> str: namespace = str(config["android_namespace"]) module_name = str(config["module_name"]) @@ -4582,12 +5087,14 @@ def _jsi_binding( feature_suffix = "" if feature_id is not None: if not re.fullmatch(r"supernote:feature:[0-9a-f]{16}", feature_id): - raise CodegenError(f"invalid V2 feature identity {feature_id!r}") + raise CodegenError(f"invalid V3 feature identity {feature_id!r}") feature_suffix = feature_id.removeprefix("supernote:feature:") declarations = _cpp_declarations(exports) object_includes = "\n".join( f'#include "{include}"' - for include in dict.fromkeys(item.include for item in objects) + for include in dict.fromkeys( + [item.include for item in objects] + list(extra_includes) + ) ) object_include_block = f"{object_includes}\n\n" if object_includes else "" object_wrappers = "\n\n".join( @@ -4599,6 +5106,10 @@ def _jsi_binding( ) for index, item in enumerate(objects) ) + if extra_wrappers: + object_wrappers = "\n\n".join( + filter(None, (object_wrappers, *extra_wrappers)) + ) object_wrapper_block = f"{object_wrappers}\n\n" if object_wrappers else "" registrations: list[str] = [] sync_capture = "[feature_session]" if feature_id is not None else "[]" @@ -4612,7 +5123,7 @@ def _jsi_binding( if export.async_: if feature_id is None: raise CodegenError( - "V2 async bindings require plugin-level feature lowering" + "V3 async bindings require plugin-level feature lowering" ) registrations.append(_jsi_async_registration(module_name, export)) continue @@ -4709,13 +5220,14 @@ def _jsi_binding( ) for index, item in enumerate(objects) ) + registrations.extend(extra_registrations) has_async_methods = any( method.async_ for item in objects for method in item.methods ) async_helper_block = ( _jsi_async_helpers() + "\n\n" if feature_id is not None - and (any(item.async_ for item in exports) or has_async_methods) + and (any(item.async_ for item in exports) or has_async_methods or extra_uses_async) else "" ) namespace_open = ( @@ -4730,7 +5242,7 @@ def _jsi_binding( """ else: bootstrap_constants = f"""constexpr char kFeatureRegistryGlobal[] = - "__supernoteV2FeatureRegistry_63f6999c8c67"; + "__supernoteV3FeatureRegistry_63f6999c8c67"; constexpr char kFeatureId[] = {json.dumps(feature_id)}; """ value_helpers = _jsi_value_helpers() @@ -4872,9 +5384,11 @@ def _jsi_binding( #include #include #include +#include #include #include {object_include_block}{declarations} +{chr(10).join(extra_declarations)} {namespace_open} @@ -4907,24 +5421,90 @@ def render_v2_feature_jsi( *, module_name: str, feature_id: str, + conversion_digest: str | None = None, + include_prefix: str | None = None, ) -> str: """Render one feature registration unit without owning plugin bootstrap.""" + extra_includes: tuple[str, ...] = () + extra_declarations: tuple[str, ...] = () + extra_wrappers: tuple[str, ...] = () + extra_registrations: tuple[str, ...] = () + extra_uses_async = False if (module_root / "android/src/main/cpp").is_dir(): - bindings = scan_v2_bindings(module_root, module_name=module_name) + function_sources = scan_cpp_source_model( + module_root, module_name=module_name + ) + class_sources = scan_cpp_class_source_model( + module_root, module_name=module_name + ) + enum_sources = scan_cpp_enum_source_model( + module_root, module_name=module_name + ) + try: + semantic = project_cpp_api( + function_sources, + class_sources, + enum_sources, + feature_id=feature_id, + ) + routes = plan_cpp_routes( + semantic, function_sources, class_sources, enum_sources + ) + ( + extra_includes, + extra_declarations, + extra_wrappers, + extra_registrations, + ) = render_cpp_object_bindings(routes, module_name=module_name) + if include_prefix is not None: + extra_includes = tuple( + f"{include_prefix.rstrip('/')}/{include}" + for include in extra_includes + ) + extra_uses_async = any( + route.execution is ExecutionMode.ASYNC + for route in routes.functions + ) or any( + route.execution is ExecutionMode.ASYNC + for item in routes.objects + for route in item.methods + ) + except (CppProjectionError, CppRouteError, SourceModelError, ValueError) as exc: + raise CodegenError(str(exc)) from exc + # The V3 route renderer owns every public C++ function, including + # scalar-only functions. Keeping the legacy scalar renderer empty is + # important because it loses namespace ownership and cannot safely + # distinguish identical starter symbols from separate features. + bindings = ScannedBindings((), ()) else: bindings = ScannedBindings((), ()) config: dict[str, object] = { - "android_namespace": "supernote.generated.v2", + "android_namespace": "supernote.generated.v3", "module_name": module_name, - "class_prefix": "V2Feature", - "jsi_global_name": "__supernoteV2", + "class_prefix": "V3Feature", + "jsi_global_name": "__supernoteV3", } - return _jsi_binding( + rendered = _jsi_binding( config, list(bindings.exports), list(bindings.objects), feature_id=feature_id, + extra_includes=extra_includes, + extra_declarations=extra_declarations, + extra_wrappers=extra_wrappers, + extra_registrations=extra_registrations, + extra_uses_async=extra_uses_async, + ) + if conversion_digest is None: + return rendered + if not re.fullmatch(r"[0-9a-f]{64}", conversion_digest): + raise CodegenError("invalid V3 conversion-plan digest") + return ( + f"// Supernote V3 conversion plan SHA-256: {conversion_digest}\n" + "#include \n" + "#include \n" + + rendered ) @@ -4938,16 +5518,16 @@ def render_v2_plugin_jsi( validated: list[tuple[str, str]] = [] for feature_id in feature_ids: if not re.fullmatch(r"supernote:feature:[0-9a-f]{16}", feature_id): - raise CodegenError(f"invalid V2 feature identity {feature_id!r}") + raise CodegenError(f"invalid V3 feature identity {feature_id!r}") validated.append( (feature_id, feature_id.removeprefix("supernote:feature:")) ) if len({feature_id for feature_id, _ in validated}) != len(validated): - raise CodegenError("duplicate V2 feature identity in plugin registry") + raise CodegenError("duplicate V3 feature identity in plugin registry") jvm_features = set(jvm_feature_ids or ()) unknown_jvm = jvm_features - {feature_id for feature_id, _ in validated} if unknown_jvm: - raise CodegenError("JVM routes refer to an unknown V2 feature") + raise CodegenError("JVM routes refer to an unknown V3 feature") declarations = "\n".join( "namespace supernote::generated::feature_" f"{suffix} {{\n" @@ -4999,7 +5579,7 @@ def render_v2_plugin_jsi( namespace {{ constexpr char kFeatureRegistryGlobal[] = - "__supernoteV2FeatureRegistry_63f6999c8c67"; + "__supernoteV3FeatureRegistry_63f6999c8c67"; [[noreturn]] void throw_type_error( facebook::jsi::Runtime &runtime, const std::string &message) {{ @@ -5036,7 +5616,7 @@ def render_v2_plugin_jsi( if (argument_count != 1 || !arguments[0].isString()) {{ throw_type_error( runtime, - "Supernote V2 runtime feature(id) expects exactly one string"); + "Supernote V3 runtime feature(id) expects exactly one string"); }} const auto feature_id = arguments[0].asString(runtime).utf8(runtime); auto registry = runtime.global().getPropertyAsObject( @@ -5044,13 +5624,13 @@ def render_v2_plugin_jsi( auto binding = registry.getProperty(runtime, feature_id.c_str()); if (binding.isUndefined()) {{ throw_type_error( - runtime, "unknown Supernote V2 feature: " + feature_id); + runtime, "unknown Supernote V3 feature: " + feature_id); }} return binding; }}); public_runtime.setProperty(runtime, "feature", std::move(feature)); runtime.global().setProperty( - runtime, "__supernoteV2", std::move(public_runtime)); + runtime, "__supernoteV3", std::move(public_runtime)); }} }} // namespace supernote::generated diff --git a/src/supernote_module_generator/cli.py b/src/supernote_module_generator/cli.py index 31fdbe7..4f895be 100644 --- a/src/supernote_module_generator/cli.py +++ b/src/supernote_module_generator/cli.py @@ -192,7 +192,10 @@ def _usage_recovery(command: str, message: str) -> str: if message == "node is not available": return "Install Node.js, then rerun the command." if message.startswith("not a Supernote plugin"): - return "Expected PluginConfig.json, package.json, and android/.\nRun the command from the plugin root." + return ( + "Expected package.json, android/, and either PluginConfig.json or the\n" + "official template build script. Run the command from the plugin root." + ) if message.startswith("non-interactive Add is missing required decisions"): return "" if "needs more information in non-interactive mode" in message: @@ -521,7 +524,13 @@ def _interactive_loop( except ConfigurationError: ui.header() print(f"\nNot a Supernote plugin: {cwd.resolve()}\n", file=renderer.stderr) - print("Expected:\n PluginConfig.json\n package.json\n android/\n", file=renderer.stderr) + print( + "Expected:\n" + " package.json\n" + " android/\n" + " PluginConfig.json or scripts/buildPlugin.sh/.ps1\n", + file=renderer.stderr, + ) try: choice = ui.menu( "", diff --git a/src/supernote_module_generator/conversion.py b/src/supernote_module_generator/conversion.py new file mode 100644 index 0000000..7452866 --- /dev/null +++ b/src/supernote_module_generator/conversion.py @@ -0,0 +1,932 @@ +"""Shared V3 JavaScript validation and transactional conversion planning. + +The semantic plan in this module is backend-neutral. C++ and JVM lowering may +choose different native storage, but they must consume this exact tree so null, +array, enum, value-field, object-leaf, path, and budget behavior cannot drift. +The executable Python snapshot engine is also the reference oracle used by +generated C++/JVM harnesses and failure-injection tests. +""" +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from enum import Enum +import math +import struct +from typing import Callable, Optional, Tuple + +from .semantic import ( + SemanticApi, + SemanticBinding, + SemanticEnumDeclaration, + SemanticObjectDeclaration, + SemanticParameter, + SemanticValueDeclaration, +) +from .semantic_types import ScalarKind, SemanticType, SemanticTypeKind + + +class ConversionPlanError(ValueError): + """Raised when common semantics cannot form a conversion plan.""" + + +class ConversionTypeError(TypeError): + """Reference equivalent of the generated JavaScript TypeError.""" + + +class ConversionRangeError(ValueError): + """Reference equivalent of the generated JavaScript RangeError.""" + + +class ConversionAllocationError(MemoryError): + """Injected or real temporary-allocation failure before publication.""" + + +class ConversionDirection(str, Enum): + INPUT = "input" + OUTPUT = "output" + + +class ConversionNodeKind(str, Enum): + VOID = "void" + SCALAR = "scalar" + ENUM = "enum" + VALUE = "value" + OBJECT = "object" + ARRAY = "array" + NULLABLE = "nullable" + + +@dataclass(frozen=True) +class ConversionLimits: + """One consistent set of overflow-safe temporary-conversion limits.""" + + max_depth: int = 32 + max_array_length: int = 65_536 + max_visited_nodes: int = 262_144 + max_string_bytes: int = 8 * 1024 * 1024 + max_byte_buffer_bytes: int = 32 * 1024 * 1024 + max_temporary_bytes: int = 64 * 1024 * 1024 + + def __post_init__(self) -> None: + for name, value in self.manifest().items(): + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ConversionPlanError(f"conversion limit {name} must be positive") + if value > (1 << 63) - 1: + raise ConversionPlanError( + f"conversion limit {name} exceeds the shared signed 64-bit range" + ) + + def manifest(self) -> dict[str, int]: + return { + "max_depth": self.max_depth, + "max_array_length": self.max_array_length, + "max_visited_nodes": self.max_visited_nodes, + "max_string_bytes": self.max_string_bytes, + "max_byte_buffer_bytes": self.max_byte_buffer_bytes, + "max_temporary_bytes": self.max_temporary_bytes, + } + + +DEFAULT_CONVERSION_LIMITS = ConversionLimits() + + +@dataclass(frozen=True) +class ConversionField: + name: str + node: "ConversionNode" + + def manifest(self) -> dict[str, object]: + return {"name": self.name, "node": self.node.manifest()} + + +@dataclass(frozen=True) +class ConversionNode: + kind: ConversionNodeKind + expected: str + scalar: Optional[ScalarKind] = None + type_id: Optional[str] = None + public_name: Optional[str] = None + constants: Tuple[str, ...] = () + fields: Tuple[ConversionField, ...] = () + element: Optional["ConversionNode"] = None + + def __post_init__(self) -> None: + if not self.expected: + raise ConversionPlanError("conversion nodes require a public expectation") + if self.kind is ConversionNodeKind.SCALAR: + if self.scalar is None: + raise ConversionPlanError("scalar conversion nodes require a scalar") + elif self.scalar is not None: + raise ConversionPlanError("only scalar conversion nodes carry a scalar") + if self.kind in { + ConversionNodeKind.ENUM, + ConversionNodeKind.VALUE, + ConversionNodeKind.OBJECT, + }: + if not self.type_id or not self.public_name: + raise ConversionPlanError("named conversion nodes require identity and name") + elif self.type_id is not None or self.public_name is not None: + raise ConversionPlanError("unnamed conversion nodes cannot carry type identity") + if self.kind is ConversionNodeKind.ENUM: + if not self.constants: + raise ConversionPlanError("enum conversion nodes require constants") + elif self.constants: + raise ConversionPlanError("only enum conversion nodes carry constants") + if self.kind is ConversionNodeKind.VALUE: + if not self.fields: + raise ConversionPlanError("value conversion nodes require fields") + elif self.fields: + raise ConversionPlanError("only value conversion nodes carry fields") + if self.kind in {ConversionNodeKind.ARRAY, ConversionNodeKind.NULLABLE}: + if self.element is None: + raise ConversionPlanError("wrapper conversion nodes require an element") + elif self.element is not None: + raise ConversionPlanError("non-wrapper conversion nodes forbid an element") + + def manifest(self) -> dict[str, object]: + value: dict[str, object] = { + "kind": self.kind.value, + "expected": self.expected, + } + if self.scalar is not None: + value["scalar"] = self.scalar.value + if self.type_id is not None: + value["type_id"] = self.type_id + value["public_name"] = self.public_name + if self.constants: + value["constants"] = list(self.constants) + if self.fields: + value["fields"] = [item.manifest() for item in self.fields] + if self.element is not None: + value["element"] = self.element.manifest() + return value + + +@dataclass(frozen=True) +class ParameterConversion: + name: str + semantic_type: SemanticType + node: ConversionNode + + def manifest(self) -> dict[str, object]: + return { + "name": self.name, + "semantic_type": self.semantic_type.manifest(), + "node": self.node.manifest(), + } + + +@dataclass(frozen=True) +class BindingConversionPlan: + binding_id: str + parameters: Tuple[ParameterConversion, ...] + result_type: SemanticType + result: ConversionNode + limits: ConversionLimits = DEFAULT_CONVERSION_LIMITS + + def __post_init__(self) -> None: + if not self.binding_id: + raise ConversionPlanError("binding conversion identity cannot be empty") + names = [item.name for item in self.parameters] + if len(names) != len(set(names)): + raise ConversionPlanError("binding conversion parameters must be unique") + + def manifest(self) -> dict[str, object]: + return { + "binding_id": self.binding_id, + "parameters": [item.manifest() for item in self.parameters], + "result_type": self.result_type.manifest(), + "result": self.result.manifest(), + "limits": self.limits.manifest(), + } + + def validate_binding(self, binding: SemanticBinding) -> None: + if binding.binding_id != self.binding_id: + raise ConversionPlanError("conversion plan references another binding") + expected_parameters = tuple(item.name for item in binding.parameters) + actual_parameters = tuple(item.name for item in self.parameters) + if actual_parameters != expected_parameters: + raise ConversionPlanError("conversion parameter order disagrees with semantics") + actual_types = tuple(item.semantic_type for item in self.parameters) + expected_types = tuple(item.type for item in binding.parameters) + if actual_types != expected_types or self.result_type != binding.result: + raise ConversionPlanError("conversion signature disagrees with semantics") + + +@dataclass(frozen=True) +class ConstructorConversionPlan: + type_id: str + public_name: str + parameters: Tuple[ParameterConversion, ...] + limits: ConversionLimits = DEFAULT_CONVERSION_LIMITS + + def manifest(self) -> dict[str, object]: + return { + "type_id": self.type_id, + "public_name": self.public_name, + "parameters": [item.manifest() for item in self.parameters], + "limits": self.limits.manifest(), + } + + +@dataclass(frozen=True) +class FieldConversionPlan: + field_id: str + owner_id: str + public_name: str + mutable: bool + node: ConversionNode + + def manifest(self) -> dict[str, object]: + return { + "field_id": self.field_id, + "owner_id": self.owner_id, + "public_name": self.public_name, + "mutable": self.mutable, + "node": self.node.manifest(), + } + + +@dataclass(frozen=True) +class ApiConversionPlan: + bindings: Tuple[BindingConversionPlan, ...] + constructors: Tuple[ConstructorConversionPlan, ...] + fields: Tuple[FieldConversionPlan, ...] + limits: ConversionLimits = DEFAULT_CONVERSION_LIMITS + + def manifest(self) -> dict[str, object]: + return { + "schema_version": 1, + "kind": "supernote_v3_conversion_plan", + "limits": self.limits.manifest(), + "bindings": [item.manifest() for item in self.bindings], + "constructors": [item.manifest() for item in self.constructors], + "fields": [item.manifest() for item in self.fields], + } + + +def plan_api_conversion( + api: SemanticApi, + *, + limits: ConversionLimits = DEFAULT_CONVERSION_LIMITS, +) -> ApiConversionPlan: + declarations = {item.type_id: item for item in api.declarations} + bindings = list(api.functions) + bindings.extend( + method + for item in api.declarations + if isinstance(item, SemanticObjectDeclaration) + for method in item.methods + ) + constructors = [] + fields = [] + for item in api.declarations: + if isinstance(item, SemanticObjectDeclaration) and item.constructor is not None: + constructors.append( + ConstructorConversionPlan( + item.type_id, + item.name, + tuple( + ParameterConversion( + parameter.name, + parameter.type, + _plan_type( + parameter.type, + declarations, + active_values=(), + ), + ) + for parameter in item.constructor.parameters + ), + limits, + ) + ) + if isinstance(item, (SemanticObjectDeclaration, SemanticValueDeclaration)): + fields.extend( + FieldConversionPlan( + value.field_id, + value.owner_id, + value.name, + value.mutable, + _plan_type(value.type, declarations, active_values=()), + ) + for value in item.fields + ) + return ApiConversionPlan( + tuple( + plan_binding_conversion(api, binding, limits=limits) + for binding in sorted(bindings, key=lambda value: value.binding_id) + ), + tuple(sorted(constructors, key=lambda value: value.type_id)), + tuple(sorted(fields, key=lambda value: value.field_id)), + limits, + ) + + +def plan_binding_conversion( + api: SemanticApi, + binding: SemanticBinding, + *, + limits: ConversionLimits = DEFAULT_CONVERSION_LIMITS, +) -> BindingConversionPlan: + declarations = {item.type_id: item for item in api.declarations} + plan = BindingConversionPlan( + binding.binding_id, + tuple( + ParameterConversion( + item.name, + item.type, + _plan_type(item.type, declarations, active_values=()), + ) + for item in binding.parameters + ), + binding.result, + _plan_type(binding.result, declarations, active_values=()), + limits, + ) + plan.validate_binding(binding) + return plan + + +def plan_type_conversion(api: SemanticApi, semantic_type: SemanticType) -> ConversionNode: + return _plan_type( + semantic_type, + {item.type_id: item for item in api.declarations}, + active_values=(), + ) + + +def _plan_type( + semantic_type: SemanticType, + declarations: dict[str, object], + *, + active_values: Tuple[str, ...], +) -> ConversionNode: + if semantic_type.kind is SemanticTypeKind.VOID: + return ConversionNode(ConversionNodeKind.VOID, "void") + if semantic_type.kind is SemanticTypeKind.SCALAR: + assert semantic_type.scalar is not None + return ConversionNode( + ConversionNodeKind.SCALAR, + _SCALAR_EXPECTED[semantic_type.scalar], + scalar=semantic_type.scalar, + ) + if semantic_type.kind in {SemanticTypeKind.ARRAY, SemanticTypeKind.NULLABLE}: + assert semantic_type.element is not None + child = _plan_type( + semantic_type.element, + declarations, + active_values=active_values, + ) + if semantic_type.kind is SemanticTypeKind.ARRAY: + return ConversionNode( + ConversionNodeKind.ARRAY, + f"dense Array<{child.expected}>", + element=child, + ) + return ConversionNode( + ConversionNodeKind.NULLABLE, + f"{child.expected} or null", + element=child, + ) + assert semantic_type.type_id is not None + declaration = declarations.get(semantic_type.type_id) + if declaration is None: + raise ConversionPlanError( + f"conversion references unknown type {semantic_type.type_id!r}" + ) + if isinstance(declaration, SemanticEnumDeclaration): + if semantic_type.kind is not SemanticTypeKind.ENUM_REF: + raise ConversionPlanError("enum declaration has a non-enum reference") + return ConversionNode( + ConversionNodeKind.ENUM, + declaration.name, + type_id=declaration.type_id, + public_name=declaration.name, + constants=declaration.constants, + ) + if isinstance(declaration, SemanticObjectDeclaration): + if semantic_type.kind is not SemanticTypeKind.OBJECT_REF: + raise ConversionPlanError("object declaration has a non-object reference") + return ConversionNode( + ConversionNodeKind.OBJECT, + declaration.name, + type_id=declaration.type_id, + public_name=declaration.name, + ) + if not isinstance(declaration, SemanticValueDeclaration): + raise ConversionPlanError("unsupported semantic declaration in conversion plan") + if semantic_type.kind is not SemanticTypeKind.VALUE_REF: + raise ConversionPlanError("value declaration has a non-value reference") + if declaration.type_id in active_values: + raise ConversionPlanError( + "recursive value conversion graph: " + + " -> ".join((*active_values, declaration.type_id)) + ) + nested_active = (*active_values, declaration.type_id) + return ConversionNode( + ConversionNodeKind.VALUE, + declaration.name, + type_id=declaration.type_id, + public_name=declaration.name, + fields=tuple( + ConversionField( + item.name, + _plan_type(item.type, declarations, active_values=nested_active), + ) + for item in declaration.fields + ), + ) + + +_SCALAR_EXPECTED = { + ScalarKind.BOOL: "boolean", + ScalarKind.INT32: "int32 number", + ScalarKind.INT64: "int64 bigint", + ScalarKind.FLOAT32: "float32 number", + ScalarKind.FLOAT64: "float64 number", + ScalarKind.STRING: "string", + ScalarKind.BYTES: "Uint8Array", +} + + +class _Undefined: + def __repr__(self) -> str: + return "undefined" + + +UNDEFINED = _Undefined() +ARRAY_HOLE = _Undefined() + + +@dataclass(frozen=True) +class JsBigInt: + value: int + + def __post_init__(self) -> None: + if not isinstance(self.value, int) or isinstance(self.value, bool): + raise TypeError("JsBigInt requires an integer") + + +@dataclass(frozen=True) +class JsUint8Array: + buffer: bytes + byte_offset: int = 0 + byte_length: Optional[int] = None + + def __post_init__(self) -> None: + if not isinstance(self.buffer, bytes): + raise TypeError("JsUint8Array buffer must be bytes") + length = len(self.buffer) - self.byte_offset if self.byte_length is None else self.byte_length + if ( + not isinstance(self.byte_offset, int) + or isinstance(self.byte_offset, bool) + or not isinstance(length, int) + or isinstance(length, bool) + or self.byte_offset < 0 + or length < 0 + or self.byte_offset > len(self.buffer) + or length > len(self.buffer) - self.byte_offset + ): + raise ValueError("Uint8Array view exceeds its ArrayBuffer") + object.__setattr__(self, "byte_length", length) + + def visible_bytes(self) -> bytes: + assert self.byte_length is not None + return self.buffer[self.byte_offset : self.byte_offset + self.byte_length] + + +@dataclass(frozen=True) +class NativeObjectToken: + type_id: str + backend_family: str + instance: object + + def __post_init__(self) -> None: + if not self.type_id or self.backend_family not in {"cpp", "jvm"}: + raise ValueError("native object tokens require nominal type and backend") + + +@dataclass(frozen=True) +class PreparedValue: + value: object + retained_objects: Tuple[NativeObjectToken, ...] + + +@dataclass(frozen=True) +class PreparedArguments: + values: Tuple[object, ...] + retained_objects: Tuple[NativeObjectToken, ...] + + +@dataclass +class AllocationFaultInjector: + """Fail the Nth temporary reservation; zero fails the first reservation.""" + + fail_after: Optional[int] = None + reservations: int = 0 + + def reserve(self, path: str) -> None: + if self.fail_after is not None and self.reservations >= self.fail_after: + raise ConversionAllocationError( + f"{path}: injected temporary allocation failure" + ) + self.reservations += 1 + + +@dataclass +class ConversionBudget: + limits: ConversionLimits + injector: AllocationFaultInjector = field(default_factory=AllocationFaultInjector) + visited_nodes: int = 0 + temporary_bytes: int = 0 + + _MAX_COUNTER = (1 << 63) - 1 + + def visit(self, path: str, depth: int) -> None: + if depth > self.limits.max_depth: + raise ConversionRangeError( + f"{path}: conversion depth exceeds {self.limits.max_depth}" + ) + self.visited_nodes = self._checked_add( + self.visited_nodes, 1, path, "visited-node" + ) + if self.visited_nodes > self.limits.max_visited_nodes: + raise ConversionRangeError( + f"{path}: conversion visits exceed {self.limits.max_visited_nodes}" + ) + + def reserve(self, amount: int, path: str) -> None: + if amount < 0: + raise ConversionRangeError(f"{path}: negative allocation size") + self.injector.reserve(path) + self.temporary_bytes = self._checked_add( + self.temporary_bytes, amount, path, "temporary-byte" + ) + if self.temporary_bytes > self.limits.max_temporary_bytes: + raise ConversionRangeError( + f"{path}: temporary allocation exceeds " + f"{self.limits.max_temporary_bytes} bytes" + ) + + def _checked_add(self, left: int, right: int, path: str, label: str) -> int: + if left < 0 or right < 0 or right > self._MAX_COUNTER - left: + raise ConversionRangeError(f"{path}: {label} counter overflow") + return left + right + + +def prepare_arguments( + plan: BindingConversionPlan, + arguments: Sequence[object], + *, + public_path: str, + injector: Optional[AllocationFaultInjector] = None, +) -> PreparedArguments: + return _prepare_parameters( + plan.parameters, + arguments, + limits=plan.limits, + public_path=public_path, + injector=injector, + ) + + +def prepare_constructor_arguments( + plan: ConstructorConversionPlan, + arguments: Sequence[object], + *, + public_path: str, + injector: Optional[AllocationFaultInjector] = None, +) -> PreparedArguments: + return _prepare_parameters( + plan.parameters, + arguments, + limits=plan.limits, + public_path=public_path, + injector=injector, + ) + + +def _prepare_parameters( + parameters: Sequence[ParameterConversion], + arguments: Sequence[object], + *, + limits: ConversionLimits, + public_path: str, + injector: Optional[AllocationFaultInjector], +) -> PreparedArguments: + if not isinstance(arguments, (list, tuple)): + raise ConversionTypeError(f"{public_path}: arguments must be an ordered list") + if len(arguments) != len(parameters): + raise ConversionTypeError( + f"{public_path}: expected {len(parameters)} arguments, got {len(arguments)}" + ) + budget = ConversionBudget(limits, injector or AllocationFaultInjector()) + converted = [] + retained: list[NativeObjectToken] = [] + for index, (parameter, value) in enumerate(zip(parameters, arguments)): + path = f"{public_path}.argument[{index}]({parameter.name})" + converted.append( + _convert( + parameter.node, + value, + ConversionDirection.INPUT, + path, + 1, + budget, + retained, + ) + ) + return PreparedArguments(tuple(converted), tuple(retained)) + + +def construct_transactionally( + plan: ConstructorConversionPlan, + arguments: Sequence[object], + constructor: Callable[..., object], + *, + public_path: str, + injector: Optional[AllocationFaultInjector] = None, +) -> object: + """Invoke construction only after every caller-visible input is owned.""" + + prepared = prepare_constructor_arguments( + plan, arguments, public_path=public_path, injector=injector + ) + return constructor(*prepared.values) + + +def prepare_result( + plan: BindingConversionPlan, + value: object, + *, + public_path: str, + injector: Optional[AllocationFaultInjector] = None, +) -> PreparedValue: + budget = ConversionBudget(plan.limits, injector or AllocationFaultInjector()) + retained: list[NativeObjectToken] = [] + converted = _convert( + plan.result, + value, + ConversionDirection.OUTPUT, + f"{public_path}.result", + 1, + budget, + retained, + ) + return PreparedValue(converted, tuple(retained)) + + +def invoke_transactionally( + plan: BindingConversionPlan, + arguments: Sequence[object], + implementation: Callable[..., object], + *, + public_path: str, + injector: Optional[AllocationFaultInjector] = None, +) -> PreparedValue: + prepared = prepare_arguments( + plan, arguments, public_path=public_path, injector=injector + ) + result = implementation(*prepared.values) + return prepare_result(plan, result, public_path=public_path, injector=injector) + + +def accept_transactionally( + plan: BindingConversionPlan, + arguments: Sequence[object], + accept: Callable[[PreparedArguments], None], + *, + public_path: str, + injector: Optional[AllocationFaultInjector] = None, +) -> PreparedArguments: + """Prepare a complete retained snapshot before an async queue accepts it.""" + + prepared = prepare_arguments( + plan, arguments, public_path=public_path, injector=injector + ) + accept(prepared) + return prepared + + +def assign_transactionally( + node: ConversionNode, + value: object, + setter: Callable[[object], None], + *, + public_path: str, + limits: ConversionLimits = DEFAULT_CONVERSION_LIMITS, + injector: Optional[AllocationFaultInjector] = None, +) -> PreparedValue: + budget = ConversionBudget(limits, injector or AllocationFaultInjector()) + retained: list[NativeObjectToken] = [] + converted = _convert( + node, + value, + ConversionDirection.INPUT, + public_path, + 1, + budget, + retained, + ) + setter(converted) + return PreparedValue(converted, tuple(retained)) + + +def _convert( + node: ConversionNode, + value: object, + direction: ConversionDirection, + path: str, + depth: int, + budget: ConversionBudget, + retained: list[NativeObjectToken], +) -> object: + budget.visit(path, depth) + if value is UNDEFINED: + _type_error(path, node.expected, "undefined") + if node.kind is ConversionNodeKind.VOID: + if direction is ConversionDirection.OUTPUT and value is None: + return None + _type_error(path, "void", _actual(value)) + if node.kind is ConversionNodeKind.NULLABLE: + if value is None: + return None + assert node.element is not None + return _convert( + node.element, value, direction, path, depth + 1, budget, retained + ) + if value is None: + _type_error(path, node.expected, "null") + if node.kind is ConversionNodeKind.SCALAR: + assert node.scalar is not None + return _convert_scalar(node.scalar, value, direction, path, budget) + if node.kind is ConversionNodeKind.ENUM: + if not isinstance(value, str) or value not in node.constants: + _type_error(path, node.expected, _actual(value)) + byte_count = len(value.encode("utf-8")) + _check_string_bytes(byte_count, path, budget) + budget.reserve(byte_count, path) + return value[:] + if node.kind is ConversionNodeKind.OBJECT: + if not isinstance(value, NativeObjectToken) or value.type_id != node.type_id: + _type_error(path, node.expected, _actual(value)) + budget.reserve(struct.calcsize("P"), path) + retained.append(value) + return value + if node.kind is ConversionNodeKind.ARRAY: + valid_array = ( + isinstance(value, list) + if direction is ConversionDirection.INPUT + else isinstance(value, (list, tuple)) + ) + if not valid_array: + _type_error(path, node.expected, _actual(value)) + if len(value) > budget.limits.max_array_length: + raise ConversionRangeError( + f"{path}: array length {len(value)} exceeds " + f"{budget.limits.max_array_length}" + ) + budget.reserve(24 + len(value) * struct.calcsize("P"), path) + assert node.element is not None + result = [] + for index, item in enumerate(value): + item_path = f"{path}[{index}]" + if item is ARRAY_HOLE: + raise ConversionTypeError(f"{item_path}: sparse array hole is invalid") + result.append( + _convert( + node.element, + item, + direction, + item_path, + depth + 1, + budget, + retained, + ) + ) + return tuple(result) if direction is ConversionDirection.INPUT else result + assert node.kind is ConversionNodeKind.VALUE + if not isinstance(value, Mapping): + _type_error(path, node.expected, _actual(value)) + budget.reserve(32 + len(node.fields) * 16, path) + result: dict[str, object] = {} + for item in node.fields: + field_path = f"{path}.{item.name}" + try: + raw = value[item.name] + except KeyError: + raise ConversionTypeError(f"{field_path}: required field is missing") + result[item.name] = _convert( + item.node, + raw, + direction, + field_path, + depth + 1, + budget, + retained, + ) + return result + + +def _convert_scalar( + scalar: ScalarKind, + value: object, + direction: ConversionDirection, + path: str, + budget: ConversionBudget, +) -> object: + if scalar is ScalarKind.BOOL: + if type(value) is not bool: + _type_error(path, "boolean", _actual(value)) + return value + if scalar is ScalarKind.INT32: + if type(value) not in {int, float}: + _type_error(path, "int32 number", _actual(value)) + numeric = float(value) + if not math.isfinite(numeric) or math.trunc(numeric) != numeric: + raise ConversionRangeError(f"{path}: int32 value must be finite and integral") + if numeric < -(1 << 31) or numeric > (1 << 31) - 1: + raise ConversionRangeError(f"{path}: int32 value is out of range") + return int(numeric) + if scalar is ScalarKind.INT64: + if direction is ConversionDirection.INPUT: + if not isinstance(value, JsBigInt): + _type_error(path, "int64 bigint", _actual(value)) + numeric = value.value + else: + if type(value) is not int: + _type_error(path, "native int64", _actual(value)) + numeric = value + if numeric < -(1 << 63) or numeric > (1 << 63) - 1: + raise ConversionRangeError(f"{path}: int64 value is out of range") + return numeric if direction is ConversionDirection.INPUT else JsBigInt(numeric) + if scalar in {ScalarKind.FLOAT32, ScalarKind.FLOAT64}: + if type(value) not in {int, float}: + _type_error(path, _SCALAR_EXPECTED[scalar], _actual(value)) + numeric = float(value) + if scalar is ScalarKind.FLOAT32 and math.isfinite(numeric): + if abs(numeric) > 3.4028234663852886e38: + raise ConversionRangeError(f"{path}: float32 value is out of range") + return numeric + if scalar is ScalarKind.STRING: + if not isinstance(value, str): + _type_error(path, "string", _actual(value)) + encoded = value.encode("utf-8") + _check_string_bytes(len(encoded), path, budget) + budget.reserve(len(encoded), path) + return encoded.decode("utf-8") + assert scalar is ScalarKind.BYTES + if direction is ConversionDirection.INPUT: + if not isinstance(value, JsUint8Array): + _type_error(path, "Uint8Array", _actual(value)) + raw = value.visible_bytes() + else: + if not isinstance(value, (bytes, bytearray, memoryview)): + _type_error(path, "native byte buffer", _actual(value)) + raw = bytes(value) + if len(raw) > budget.limits.max_byte_buffer_bytes: + raise ConversionRangeError( + f"{path}: byte buffer exceeds {budget.limits.max_byte_buffer_bytes} bytes" + ) + budget.reserve(len(raw), path) + copied = bytes(raw) + return copied if direction is ConversionDirection.INPUT else JsUint8Array(copied) + + +def _check_string_bytes(length: int, path: str, budget: ConversionBudget) -> None: + if length > budget.limits.max_string_bytes: + raise ConversionRangeError( + f"{path}: UTF-8 string exceeds {budget.limits.max_string_bytes} bytes" + ) + + +def _type_error(path: str, expected: str, actual: str) -> None: + raise ConversionTypeError(f"{path}: expected {expected}, got {actual}") + + +def _actual(value: object) -> str: + if value is None: + return "null" + if value is UNDEFINED: + return "undefined" + if value is ARRAY_HOLE: + return "array hole" + if isinstance(value, NativeObjectToken): + return f"native object {value.type_id}" + if isinstance(value, JsUint8Array): + return "Uint8Array" + if isinstance(value, JsBigInt): + return "bigint" + if type(value) is bool: + return "boolean" + if type(value) in {int, float}: + return "number" + if isinstance(value, str): + return "string" + if isinstance(value, list): + return "Array" + if isinstance(value, Mapping): + return "object" + return type(value).__name__ diff --git a/src/supernote_module_generator/conversion_codegen.py b/src/supernote_module_generator/conversion_codegen.py new file mode 100644 index 0000000..e3fbcbe --- /dev/null +++ b/src/supernote_module_generator/conversion_codegen.py @@ -0,0 +1,280 @@ +"""Render the shared generated C++ and JVM conversion-budget kernel.""" +from __future__ import annotations + +from .conversion import ConversionLimits, DEFAULT_CONVERSION_LIMITS + + +def render_cpp_conversion_kernel( + limits: ConversionLimits = DEFAULT_CONVERSION_LIMITS, +) -> str: + return f'''// Generated by supernote_module_generator. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace supernote::conversion {{ + +enum class FailureKind {{ TYPE, RANGE, ALLOCATION }}; + +class Failure final : public std::runtime_error {{ + public: + Failure(FailureKind kind, std::string path, std::string message) + : std::runtime_error(path + ": " + message), + kind_(kind), + path_(std::move(path)) {{}} + + [[nodiscard]] FailureKind kind() const noexcept {{ return kind_; }} + [[nodiscard]] const std::string &path() const noexcept {{ return path_; }} + + private: + FailureKind kind_; + std::string path_; +}}; + +struct Limits final {{ + static constexpr std::uint64_t max_depth = {limits.max_depth}ULL; + static constexpr std::uint64_t max_array_length = {limits.max_array_length}ULL; + static constexpr std::uint64_t max_visited_nodes = {limits.max_visited_nodes}ULL; + static constexpr std::uint64_t max_string_bytes = {limits.max_string_bytes}ULL; + static constexpr std::uint64_t max_byte_buffer_bytes = {limits.max_byte_buffer_bytes}ULL; + static constexpr std::uint64_t max_temporary_bytes = {limits.max_temporary_bytes}ULL; +}}; + +class AllocationGate final {{ + public: + explicit AllocationGate( + std::optional fail_after = std::nullopt) noexcept + : fail_after_(fail_after) {{}} + + void reserve(std::string_view path) {{ + if (fail_after_ && reservations_ >= *fail_after_) {{ + throw Failure( + FailureKind::ALLOCATION, std::string(path), + "injected temporary allocation failure"); + }} + reservations_ = checked_add(reservations_, 1, path, "reservation"); + }} + + [[nodiscard]] std::uint64_t reservations() const noexcept {{ + return reservations_; + }} + + private: + static std::uint64_t checked_add( + std::uint64_t left, std::uint64_t right, + std::string_view path, std::string_view label) {{ + constexpr auto max_counter = + static_cast(std::numeric_limits::max()); + if (left > max_counter || right > max_counter - left) {{ + throw Failure( + FailureKind::RANGE, std::string(path), + std::string(label) + " counter overflow"); + }} + return left + right; + }} + + std::optional fail_after_; + std::uint64_t reservations_ = 0; +}}; + +class Budget final {{ + public: + explicit Budget(AllocationGate *allocation_gate = nullptr) noexcept + : allocation_gate_(allocation_gate) {{}} + + void visit(std::string_view path, std::uint64_t depth) {{ + if (depth > Limits::max_depth) {{ + throw Failure( + FailureKind::RANGE, std::string(path), + "conversion depth exceeds configured limit"); + }} + visited_nodes_ = checked_add( + visited_nodes_, 1, path, "visited-node"); + if (visited_nodes_ > Limits::max_visited_nodes) {{ + throw Failure( + FailureKind::RANGE, std::string(path), + "conversion visits exceed configured limit"); + }} + }} + + void check_array_length(std::string_view path, std::uint64_t length) const {{ + if (length > Limits::max_array_length) {{ + throw Failure( + FailureKind::RANGE, std::string(path), + "array length exceeds configured limit"); + }} + }} + + void check_string_bytes(std::string_view path, std::uint64_t size) const {{ + if (size > Limits::max_string_bytes) {{ + throw Failure( + FailureKind::RANGE, std::string(path), + "UTF-8 string exceeds configured limit"); + }} + }} + + void check_byte_buffer(std::string_view path, std::uint64_t size) const {{ + if (size > Limits::max_byte_buffer_bytes) {{ + throw Failure( + FailureKind::RANGE, std::string(path), + "byte buffer exceeds configured limit"); + }} + }} + + void reserve(std::string_view path, std::uint64_t amount) {{ + if (allocation_gate_ != nullptr) allocation_gate_->reserve(path); + temporary_bytes_ = checked_add( + temporary_bytes_, amount, path, "temporary-byte"); + if (temporary_bytes_ > Limits::max_temporary_bytes) {{ + throw Failure( + FailureKind::RANGE, std::string(path), + "temporary allocation exceeds configured limit"); + }} + }} + + [[nodiscard]] std::uint64_t visited_nodes() const noexcept {{ + return visited_nodes_; + }} + [[nodiscard]] std::uint64_t temporary_bytes() const noexcept {{ + return temporary_bytes_; + }} + + private: + static std::uint64_t checked_add( + std::uint64_t left, std::uint64_t right, + std::string_view path, std::string_view label) {{ + constexpr auto max_counter = + static_cast(std::numeric_limits::max()); + if (left > max_counter || right > max_counter - left) {{ + throw Failure( + FailureKind::RANGE, std::string(path), + std::string(label) + " counter overflow"); + }} + return left + right; + }} + + AllocationGate *allocation_gate_; + std::uint64_t visited_nodes_ = 0; + std::uint64_t temporary_bytes_ = 0; +}}; + +inline std::string field_path(std::string_view parent, std::string_view field) {{ + std::string result(parent); + result.push_back('.'); + result.append(field); + return result; +}} + +inline std::string index_path(std::string_view parent, std::uint64_t index) {{ + return std::string(parent) + "[" + std::to_string(index) + "]"; +}} + +}} // namespace supernote::conversion +''' + + +def render_jvm_conversion_kernel( + limits: ConversionLimits = DEFAULT_CONVERSION_LIMITS, +) -> str: + return f'''// Generated by supernote_module_generator. Do not edit. +package supernote.generated.runtime + +internal object SupernoteConversionLimits {{ + const val MAX_DEPTH: Long = {limits.max_depth}L + const val MAX_ARRAY_LENGTH: Long = {limits.max_array_length}L + const val MAX_VISITED_NODES: Long = {limits.max_visited_nodes}L + const val MAX_STRING_BYTES: Long = {limits.max_string_bytes}L + const val MAX_BYTE_BUFFER_BYTES: Long = {limits.max_byte_buffer_bytes}L + const val MAX_TEMPORARY_BYTES: Long = {limits.max_temporary_bytes}L +}} + +internal enum class SupernoteConversionFailureKind {{ TYPE, RANGE, ALLOCATION }} + +internal class SupernoteConversionFailure( + val kind: SupernoteConversionFailureKind, + val path: String, + message: String, +) : IllegalArgumentException("$path: $message") + +internal class SupernoteAllocationGate(private val failAfter: Long? = null) {{ + var reservations: Long = 0 + private set + + fun reserve(path: String) {{ + if (failAfter != null && reservations >= failAfter) {{ + throw SupernoteConversionFailure( + SupernoteConversionFailureKind.ALLOCATION, + path, + "injected temporary allocation failure", + ) + }} + reservations = checkedAdd(reservations, 1, path, "reservation") + }} +}} + +internal class SupernoteConversionBudget( + private val allocationGate: SupernoteAllocationGate? = null, +) {{ + var visitedNodes: Long = 0 + private set + var temporaryBytes: Long = 0 + private set + + fun visit(path: String, depth: Long) {{ + if (depth > SupernoteConversionLimits.MAX_DEPTH) range(path, "conversion depth") + visitedNodes = checkedAdd(visitedNodes, 1, path, "visited-node") + if (visitedNodes > SupernoteConversionLimits.MAX_VISITED_NODES) {{ + range(path, "conversion visits") + }} + }} + + fun checkArrayLength(path: String, length: Long) {{ + if (length > SupernoteConversionLimits.MAX_ARRAY_LENGTH) range(path, "array length") + }} + + fun checkStringBytes(path: String, size: Long) {{ + if (size > SupernoteConversionLimits.MAX_STRING_BYTES) range(path, "UTF-8 string") + }} + + fun checkByteBuffer(path: String, size: Long) {{ + if (size > SupernoteConversionLimits.MAX_BYTE_BUFFER_BYTES) range(path, "byte buffer") + }} + + fun reserve(path: String, amount: Long) {{ + if (amount < 0) range(path, "negative allocation size") + allocationGate?.reserve(path) + temporaryBytes = checkedAdd(temporaryBytes, amount, path, "temporary-byte") + if (temporaryBytes > SupernoteConversionLimits.MAX_TEMPORARY_BYTES) {{ + range(path, "temporary allocation") + }} + }} + + private fun range(path: String, label: String): Nothing = + throw SupernoteConversionFailure( + SupernoteConversionFailureKind.RANGE, + path, + "$label exceeds configured limit", + ) +}} + +private fun checkedAdd(left: Long, right: Long, path: String, label: String): Long {{ + if (left < 0 || right < 0 || right > Long.MAX_VALUE - left) {{ + throw SupernoteConversionFailure( + SupernoteConversionFailureKind.RANGE, + path, + "$label counter overflow", + ) + }} + return left + right +}} + +internal fun conversionFieldPath(parent: String, field: String): String = "$parent.$field" +internal fun conversionIndexPath(parent: String, index: Long): String = "$parent[$index]" +''' diff --git a/src/supernote_module_generator/cpp_object_binding_codegen.py b/src/supernote_module_generator/cpp_object_binding_codegen.py new file mode 100644 index 0000000..6ca0d97 --- /dev/null +++ b/src/supernote_module_generator/cpp_object_binding_codegen.py @@ -0,0 +1,1738 @@ +"""Emit synchronous JSI bindings for V3 C++ object routes.""" +from __future__ import annotations + +import hashlib +import json +from typing import Iterable + +from .cpp_routes import ( + CppCallableKind, + CppCallableRoute, + CppObjectPassing, + CppObjectRoute, + CppParameterRoute, + CppRouteError, + CppRoutePlan, +) +from .semantic import ExecutionMode +from .semantic_types import ScalarKind, SemanticType, SemanticTypeKind + + +def _type_suffix(semantic: SemanticType) -> str: + payload = json.dumps( + semantic.manifest(), sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest()[:12] + + +def _from_js_name(semantic: SemanticType) -> str: + return f"supernote_v3_from_js_{_type_suffix(semantic)}" + + +def _to_js_name(semantic: SemanticType) -> str: + return f"supernote_v3_to_js_{_type_suffix(semantic)}" + + +def _retain_native_name(semantic: SemanticType) -> str: + return f"supernote_v3_retain_native_{_type_suffix(semantic)}" + + +def _cpp_type(semantic: SemanticType, plan: CppRoutePlan) -> str: + if semantic.kind is SemanticTypeKind.VOID: + return "void" + if semantic.kind is SemanticTypeKind.SCALAR: + return { + ScalarKind.BOOL: "bool", + ScalarKind.INT32: "std::int32_t", + ScalarKind.INT64: "std::int64_t", + ScalarKind.FLOAT32: "float", + ScalarKind.FLOAT64: "double", + ScalarKind.STRING: "std::string", + ScalarKind.BYTES: "std::vector", + }[semantic.scalar] + if semantic.kind in { + SemanticTypeKind.ENUM_REF, + SemanticTypeKind.VALUE_REF, + SemanticTypeKind.OBJECT_REF, + }: + assert semantic.type_id is not None + native = plan.named_types_by_id[semantic.type_id].cpp_type + if semantic.kind is SemanticTypeKind.OBJECT_REF: + return f"std::shared_ptr<{native}>" + return native + assert semantic.element is not None + child = _cpp_type(semantic.element, plan) + wrapper = "std::vector" if semantic.kind is SemanticTypeKind.ARRAY else "std::optional" + return f"{wrapper}<{child}>" + + +def _collect_types( + roots: Iterable[SemanticType], plan: CppRoutePlan +) -> tuple[SemanticType, ...]: + found: dict[str, SemanticType] = {} + + def visit(item: SemanticType) -> None: + if item.kind is SemanticTypeKind.VOID: + return + key = _type_suffix(item) + if key in found: + return + found[key] = item + if item.element is not None: + visit(item.element) + elif item.kind is SemanticTypeKind.VALUE_REF: + assert item.type_id is not None + route = next( + value for value in plan.values + if value.named_type.type_id == item.type_id + ) + for field in route.fields: + visit(field.semantic_type) + + for root in roots: + visit(root) + return tuple(found[key] for key in sorted(found)) + + +def _conversion_prototype(semantic: SemanticType, plan: CppRoutePlan) -> str: + native = _cpp_type(semantic, plan) + return f"""{native} {_from_js_name(semantic)}( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value, + supernote::conversion::Budget &budget, + std::vector &retained, + const std::string &path, + std::uint64_t depth); +facebook::jsi::Value {_to_js_name(semantic)}( + facebook::jsi::Runtime &runtime, + const {native} &value, + const std::shared_ptr ®istry, + const std::shared_ptr &feature, + supernote::conversion::Budget &budget, + const std::string &path, + std::uint64_t depth); +void {_retain_native_name(semantic)}( + const {native} &value, + std::vector &retained, + const std::shared_ptr &cleanup);""" + + +def _validation_prototype(semantic: SemanticType) -> str: + return f"""void {_validate_js_name(semantic)}( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value, + supernote::conversion::Budget &budget, + const std::string &path, + std::uint64_t depth);""" + + +def _validate_js_name(semantic: SemanticType) -> str: + return "supernote_validate_js_" + _type_suffix(semantic) + + +def _validate_js_definition(semantic: SemanticType, plan: CppRoutePlan) -> str: + lines = [ + f"void {_validate_js_name(semantic)}(", + " facebook::jsi::Runtime &runtime,", + " const facebook::jsi::Value &value,", + " supernote::conversion::Budget &budget,", + " const std::string &path,", + " std::uint64_t depth) {", + " budget.visit(path, depth);", + " if (value.isUndefined()) {", + f" {_input_type_error('a defined value')};", + " }", + ] + kind = semantic.kind + if kind is SemanticTypeKind.NULLABLE: + assert semantic.element is not None + lines.extend([ + " if (value.isNull()) return;", + f" {_validate_js_name(semantic.element)}(", + " runtime, value, budget, path, depth + 1);", + ]) + else: + lines.extend([ + " if (value.isNull()) {", + f" {_input_type_error('a non-null value')};", + " }", + ]) + if kind is SemanticTypeKind.SCALAR: + scalar = semantic.scalar + if scalar is ScalarKind.BOOL: + lines.append(f" if (!value.isBool()) {_input_type_error('boolean')};") + elif scalar is ScalarKind.INT32: + lines.extend([ + f" if (!value.isNumber()) {_input_type_error('an int32 number')};", + " const double number = value.asNumber();", + " if (!std::isfinite(number) || std::trunc(number) != number ||", + " number < static_cast(std::numeric_limits::min()) ||", + " number > static_cast(std::numeric_limits::max())) {", + " supernote_throw_range_error(runtime, path + \": int32 value is out of range\",", + " \"OUT_OF_RANGE\", path, \"int32\", supernote_describe_value(runtime, value));", + " }", + ]) + elif scalar is ScalarKind.INT64: + lines.extend([ + f" if (!value.isBigInt()) {_input_type_error('an int64 bigint')};", + " if (!value.getBigInt(runtime).isInt64(runtime)) {", + " supernote_throw_range_error(runtime, path + \": int64 value is out of range\",", + " \"OUT_OF_RANGE\", path, \"int64 bigint\", \"bigint\");", + " }", + ]) + elif scalar is ScalarKind.FLOAT32: + lines.extend([ + f" if (!value.isNumber()) {_input_type_error('a float32 number')};", + " const double number = value.asNumber();", + " if (std::isfinite(number) &&", + " (number < static_cast(std::numeric_limits::lowest()) ||", + " number > static_cast(std::numeric_limits::max()))) {", + " supernote_throw_range_error(runtime, path + \": float32 value is out of range\",", + " \"OUT_OF_RANGE\", path, \"float32\", \"number\");", + " }", + ]) + elif scalar is ScalarKind.FLOAT64: + lines.append(f" if (!value.isNumber()) {_input_type_error('a float64 number')};") + elif scalar is ScalarKind.STRING: + lines.extend([ + f" if (!value.isString()) {_input_type_error('a string')};", + " auto text = value.asString(runtime).utf8(runtime);", + " budget.check_string_bytes(path, text.size());", + ]) + else: + lines.extend([ + f" if (!supernote_is_uint8_array(runtime, value)) {_input_type_error('a Uint8Array')};", + " auto view = value.getObject(runtime);", + " budget.check_byte_buffer(path, supernote_view_index(runtime, view, \"byteLength\"));", + ]) + elif kind is SemanticTypeKind.OBJECT_REF: + assert semantic.type_id is not None + named = plan.named_types_by_id[semantic.type_id] + lines.extend([ + f" auto managed = supernote::runtime::try_extract_cpp_object<{named.cpp_type}>(", + f" runtime, value, {json.dumps(named.type_id)});", + " if (!managed) {", + f" supernote_throw_type_error(runtime, path + \": expected {named.public_name}\",", + f" \"NOMINAL_MISMATCH\", path, {json.dumps(named.public_name)},", + " supernote_describe_value(runtime, value));", + " }", + " budget.reserve(path, sizeof(void *));", + ]) + elif kind is SemanticTypeKind.ENUM_REF: + assert semantic.type_id is not None + route = next(item for item in plan.enums if item.named_type.type_id == semantic.type_id) + condition = " && ".join( + f"text != {json.dumps(constant)}" for constant in route.constants + ) or "true" + lines.extend([ + f" if (!value.isString()) {_input_type_error(route.named_type.public_name)};", + " auto text = value.asString(runtime).utf8(runtime);", + " budget.check_string_bytes(path, text.size());", + f" if ({condition}) {{", + f" supernote_throw_type_error(runtime, path + \": expected {route.named_type.public_name}\",", + f" \"INVALID_ENUM\", path, {json.dumps(route.named_type.public_name)}, \"string\");", + " }", + ]) + elif kind is SemanticTypeKind.ARRAY: + assert semantic.element is not None + lines.extend([ + f" if (!value.isObject()) {_input_type_error('a dense Array')};", + " auto object = value.getObject(runtime);", + f" if (!object.isArray(runtime)) {_input_type_error('a dense Array')};", + " auto array = object.getArray(runtime);", + " const auto length = static_cast(array.size(runtime));", + " budget.check_array_length(path, length);", + " for (std::uint64_t index = 0; index < length; ++index) {", + " auto item = array.getValueAtIndex(runtime, static_cast(index));", + " auto item_path = supernote::conversion::index_path(path, index);", + f" {_validate_js_name(semantic.element)}(", + " runtime, item, budget, item_path, depth + 1);", + " }", + ]) + else: + assert kind is SemanticTypeKind.VALUE_REF and semantic.type_id is not None + route = next(item for item in plan.values if item.named_type.type_id == semantic.type_id) + lines.extend([ + f" if (!value.isObject()) {_input_type_error(route.named_type.public_name)};", + " auto object = value.getObject(runtime);", + f" if (object.isArray(runtime)) {_input_type_error(route.named_type.public_name)};", + ]) + for field in route.fields: + lines.extend([ + f" auto {field.cpp_name}_path = supernote::conversion::field_path(path, {json.dumps(field.public_name)});", + f" auto {field.cpp_name}_value = object.getProperty(runtime, {json.dumps(field.public_name)});", + f" {_validate_js_name(field.semantic_type)}(", + f" runtime, {field.cpp_name}_value, budget, {field.cpp_name}_path, depth + 1);", + ]) + lines.append("}") + return "\n".join(lines) + + +def _input_type_error(expected: str) -> str: + return ( + "supernote_throw_type_error(runtime, path + \": expected " + + expected.replace('"', '\\"') + + "\", \"TYPE_MISMATCH\", path, \"" + + expected.replace('"', '\\"') + + "\", supernote_describe_value(runtime, value))" + ) + + +def _from_js_definition(semantic: SemanticType, plan: CppRoutePlan) -> str: + native = _cpp_type(semantic, plan) + name = _from_js_name(semantic) + lines = [ + f"{native} {name}(", + " facebook::jsi::Runtime &runtime,", + " const facebook::jsi::Value &value,", + " supernote::conversion::Budget &budget,", + " std::vector &retained,", + " const std::string &path,", + " std::uint64_t depth) {", + " (void)retained;", + " budget.visit(path, depth);", + " if (value.isUndefined()) {", + f" {_input_type_error('a defined value')};", + " }", + ] + kind = semantic.kind + if kind is SemanticTypeKind.NULLABLE: + assert semantic.element is not None + child = _from_js_name(semantic.element) + lines.extend( + [ + " if (value.isNull()) return std::nullopt;", + f" return {child}(runtime, value, budget, retained, path, depth + 1);", + ] + ) + else: + lines.extend( + [ + " if (value.isNull()) {", + f" {_input_type_error('a non-null value')};", + " }", + ] + ) + if kind is SemanticTypeKind.SCALAR: + scalar = semantic.scalar + if scalar is ScalarKind.BOOL: + lines.extend([ + f" if (!value.isBool()) {_input_type_error('boolean')};", + " return value.getBool();", + ]) + elif scalar is ScalarKind.INT32: + lines.extend([ + f" if (!value.isNumber()) {_input_type_error('an int32 number')};", + " const double number = value.asNumber();", + " if (!std::isfinite(number) || std::trunc(number) != number ||", + " number < static_cast(std::numeric_limits::min()) ||", + " number > static_cast(std::numeric_limits::max())) {", + " supernote_throw_range_error(runtime, path + \": int32 value is out of range\");", + " }", + " return static_cast(number);", + ]) + elif scalar is ScalarKind.INT64: + lines.extend([ + f" if (!value.isBigInt()) {_input_type_error('an int64 bigint')};", + " const auto bigint = value.getBigInt(runtime);", + " if (!bigint.isInt64(runtime)) {", + " supernote_throw_range_error(runtime, path + \": int64 value is out of range\");", + " }", + " return static_cast(bigint.asInt64(runtime));", + ]) + elif scalar is ScalarKind.FLOAT32: + lines.extend([ + f" if (!value.isNumber()) {_input_type_error('a float32 number')};", + " const double number = value.asNumber();", + " if (std::isfinite(number) &&", + " (number < static_cast(std::numeric_limits::lowest()) ||", + " number > static_cast(std::numeric_limits::max()))) {", + " supernote_throw_range_error(runtime, path + \": float32 value is out of range\");", + " }", + " return static_cast(number);", + ]) + elif scalar is ScalarKind.FLOAT64: + lines.extend([ + f" if (!value.isNumber()) {_input_type_error('a float64 number')};", + " return value.asNumber();", + ]) + elif scalar is ScalarKind.STRING: + lines.extend([ + f" if (!value.isString()) {_input_type_error('a string')};", + " auto result = value.asString(runtime).utf8(runtime);", + " budget.check_string_bytes(path, result.size());", + " budget.reserve(path, result.size());", + " return result;", + ]) + else: + assert scalar is ScalarKind.BYTES + lines.extend([ + f" if (!supernote_is_uint8_array(runtime, value)) {_input_type_error('a Uint8Array')};", + " auto view = value.getObject(runtime);", + " const auto length = supernote_view_index(runtime, view, \"byteLength\");", + " budget.check_byte_buffer(path, length);", + " budget.reserve(path, length);", + " return supernote_copy_uint8_array(runtime, value);", + ]) + elif kind is SemanticTypeKind.OBJECT_REF: + assert semantic.type_id is not None + named = plan.named_types_by_id[semantic.type_id] + lines.extend([ + f" auto managed = supernote::runtime::try_extract_cpp_object<{named.cpp_type}>(", + f" runtime, value, {json.dumps(named.type_id)});", + " if (!managed) {", + f" supernote_throw_type_error(runtime, path + \": expected {named.public_name}\",", + f" \"NOMINAL_MISMATCH\", path, {json.dumps(named.public_name)},", + " supernote_describe_value(runtime, value));", + " }", + " budget.reserve(path, sizeof(void *));", + " retained.emplace_back(", + " managed.shared_ref(), supernote::runtime::process_services().cleanup());", + " return managed.shared_ref();", + ]) + elif kind is SemanticTypeKind.ENUM_REF: + assert semantic.type_id is not None + route = next(item for item in plan.enums if item.named_type.type_id == semantic.type_id) + lines.extend([ + f" if (!value.isString()) {_input_type_error(route.named_type.public_name)};", + " auto text = value.asString(runtime).utf8(runtime);", + " budget.check_string_bytes(path, text.size());", + " budget.reserve(path, text.size());", + ]) + for constant in route.constants: + lines.append( + f" if (text == {json.dumps(constant)}) return {route.named_type.cpp_type}::{constant};" + ) + lines.append( + " supernote_throw_type_error(runtime, path + \": expected " + + route.named_type.public_name + + "\", \"INVALID_ENUM\", path, " + + json.dumps(route.named_type.public_name) + + ", supernote_describe_value(runtime, value));" + ) + elif kind is SemanticTypeKind.ARRAY: + assert semantic.element is not None + child = _from_js_name(semantic.element) + lines.extend([ + f" if (!value.isObject()) {_input_type_error('a dense Array')};", + " auto object = value.getObject(runtime);", + f" if (!object.isArray(runtime)) {_input_type_error('a dense Array')};", + " auto array = object.getArray(runtime);", + " const auto length = static_cast(array.size(runtime));", + " budget.check_array_length(path, length);", + " budget.reserve(path, 24ULL + length * sizeof(void *));", + f" {native} result;", + " result.reserve(static_cast(length));", + " for (std::uint64_t index = 0; index < length; ++index) {", + " const auto item_path = supernote::conversion::index_path(path, index);", + " auto item = array.getValueAtIndex(runtime, static_cast(index));", + f" result.push_back({child}(runtime, item, budget, retained, item_path, depth + 1));", + " }", + " return result;", + ]) + else: + assert kind is SemanticTypeKind.VALUE_REF and semantic.type_id is not None + route = next(item for item in plan.values if item.named_type.type_id == semantic.type_id) + lines.extend([ + f" if (!value.isObject()) {_input_type_error(route.named_type.public_name)};", + " auto object = value.getObject(runtime);", + f" if (object.isArray(runtime)) {_input_type_error(route.named_type.public_name)};", + f" budget.reserve(path, 32ULL + {len(route.fields)}ULL * 16ULL);", + ]) + locals_: list[str] = [] + for index, field in enumerate(route.fields): + child = _from_js_name(field.semantic_type) + local = f"field_{index}" + locals_.append(f"std::move({local})") + lines.extend([ + f" auto {local}_path = supernote::conversion::field_path(path, {json.dumps(field.public_name)});", + f" auto {local}_value = object.getProperty(runtime, {json.dumps(field.public_name)});", + f" auto {local} = {child}(runtime, {local}_value, budget, retained, {local}_path, depth + 1);", + ]) + lines.append( + " return " + + route.named_type.cpp_type + + "{" + + ", ".join(locals_) + + "};" + ) + lines.append("}") + return "\n".join(lines) + + +def _to_js_definition(semantic: SemanticType, plan: CppRoutePlan) -> str: + native = _cpp_type(semantic, plan) + name = _to_js_name(semantic) + lines = [ + f"facebook::jsi::Value {name}(", + " facebook::jsi::Runtime &runtime,", + f" const {native} &value,", + " const std::shared_ptr ®istry,", + " const std::shared_ptr &feature,", + " supernote::conversion::Budget &budget,", + " const std::string &path,", + " std::uint64_t depth) {", + " budget.visit(path, depth);", + ] + kind = semantic.kind + if kind is SemanticTypeKind.NULLABLE: + assert semantic.element is not None + child = _to_js_name(semantic.element) + lines.extend([ + " if (!value.has_value()) return facebook::jsi::Value::null();", + f" return {child}(runtime, *value, registry, feature, budget, path, depth + 1);", + ]) + elif kind is SemanticTypeKind.SCALAR: + scalar = semantic.scalar + if scalar is ScalarKind.BOOL: + lines.append(" return facebook::jsi::Value(value);") + elif scalar in {ScalarKind.INT32, ScalarKind.FLOAT32, ScalarKind.FLOAT64}: + lines.append(" return facebook::jsi::Value(static_cast(value));") + elif scalar is ScalarKind.INT64: + lines.extend([ + " return facebook::jsi::Value(facebook::jsi::BigInt::fromInt64(", + " runtime, static_cast(value)));", + ]) + elif scalar is ScalarKind.STRING: + lines.extend([ + " budget.check_string_bytes(path, value.size());", + " budget.reserve(path, value.size());", + " return facebook::jsi::Value(facebook::jsi::String::createFromUtf8(runtime, value));", + ]) + else: + assert scalar is ScalarKind.BYTES + lines.extend([ + " budget.check_byte_buffer(path, value.size());", + " budget.reserve(path, value.size());", + " return supernote_make_uint8_array(runtime, value);", + ]) + elif kind is SemanticTypeKind.OBJECT_REF: + assert semantic.type_id is not None + wrapper = _wrap_function(_object_index(plan, semantic.type_id)) + lines.extend([ + " budget.reserve(path, sizeof(void *));", + f" return facebook::jsi::Value({wrapper}(runtime, registry, feature, value));", + ]) + elif kind is SemanticTypeKind.ENUM_REF: + assert semantic.type_id is not None + route = next(item for item in plan.enums if item.named_type.type_id == semantic.type_id) + for constant in route.constants: + lines.extend([ + f" if (value == {route.named_type.cpp_type}::{constant}) {{", + f" constexpr char text[] = {json.dumps(constant)};", + " budget.check_string_bytes(path, sizeof(text) - 1);", + " budget.reserve(path, sizeof(text) - 1);", + " return facebook::jsi::Value(facebook::jsi::String::createFromAscii(runtime, text));", + " }", + ]) + lines.append( + f" throw std::invalid_argument({json.dumps('native enum ' + route.named_type.public_name + ' has an invalid value')});" + ) + elif kind is SemanticTypeKind.ARRAY: + assert semantic.element is not None + child = _to_js_name(semantic.element) + lines.extend([ + " const auto length = static_cast(value.size());", + " budget.check_array_length(path, length);", + " budget.reserve(path, 24ULL + length * sizeof(void *));", + " facebook::jsi::Array result(runtime, static_cast(length));", + " for (std::uint64_t index = 0; index < length; ++index) {", + " const auto item_path = supernote::conversion::index_path(path, index);", + f" auto item = {child}(runtime, value[static_cast(index)], registry, feature, budget, item_path, depth + 1);", + " result.setValueAtIndex(runtime, static_cast(index), std::move(item));", + " }", + " return facebook::jsi::Value(std::move(result));", + ]) + else: + assert kind is SemanticTypeKind.VALUE_REF and semantic.type_id is not None + route = next(item for item in plan.values if item.named_type.type_id == semantic.type_id) + lines.extend([ + f" budget.reserve(path, 32ULL + {len(route.fields)}ULL * 16ULL);", + " facebook::jsi::Object result(runtime);", + ]) + for field in route.fields: + child = _to_js_name(field.semantic_type) + lines.extend([ + f" auto {field.cpp_name}_path = supernote::conversion::field_path(path, {json.dumps(field.public_name)});", + f" auto {field.cpp_name}_value = {child}(runtime, value.{field.cpp_name}, registry, feature, budget, {field.cpp_name}_path, depth + 1);", + f" result.setProperty(runtime, {json.dumps(field.public_name)}, std::move({field.cpp_name}_value));", + ]) + lines.append(" return facebook::jsi::Value(std::move(result));") + lines.append("}") + return "\n".join(lines) + + +def _retain_native_definition(semantic: SemanticType, plan: CppRoutePlan) -> str: + native = _cpp_type(semantic, plan) + lines = [ + f"void {_retain_native_name(semantic)}(", + f" const {native} &value,", + " std::vector &retained,", + " const std::shared_ptr &cleanup) {", + ] + kind = semantic.kind + if kind is SemanticTypeKind.OBJECT_REF: + lines.append(" if (value) retained.emplace_back(value, cleanup);") + elif kind in {SemanticTypeKind.ARRAY, SemanticTypeKind.NULLABLE}: + assert semantic.element is not None + child = _retain_native_name(semantic.element) + if kind is SemanticTypeKind.ARRAY: + lines.extend([ + " for (const auto &item : value) {", + f" {child}(item, retained, cleanup);", + " }", + ]) + else: + lines.extend([ + f" if (value) {child}(*value, retained, cleanup);", + ]) + elif kind is SemanticTypeKind.VALUE_REF: + assert semantic.type_id is not None + route = next(item for item in plan.values if item.named_type.type_id == semantic.type_id) + for field in route.fields: + lines.append( + f" {_retain_native_name(field.semantic_type)}(value.{field.cpp_name}, retained, cleanup);" + ) + else: + lines.extend([" (void)value;", " (void)retained;", " (void)cleanup;"]) + lines.append("}") + return "\n".join(lines) + + +def _conversion_helpers(types: tuple[SemanticType, ...], plan: CppRoutePlan) -> str: + if not types: + return "" + prototypes = "\n\n".join( + _conversion_prototype(item, plan) + "\n" + _validation_prototype(item) + for item in types + ) + definitions = "\n\n".join( + value + for item in types + for value in ( + _from_js_definition(item, plan), + _validate_js_definition(item, plan), + _to_js_definition(item, plan), + _retain_native_definition(item, plan), + ) + ) + failure = r'''constexpr char kCppObjectRegistryProperty[] = + "__supernoteV3CppObjectRegistry_5f271b119c3a"; + +std::shared_ptr +supernote_v3_object_registry(facebook::jsi::Runtime &runtime) { + auto registry = runtime.global().getPropertyAsObject( + runtime, kFeatureRegistryGlobal); + auto exports = registry.getPropertyAsObject(runtime, kFeatureId); + auto owner = exports.getPropertyAsObject( + runtime, kCppObjectRegistryProperty); + return owner.getHostObject( + runtime)->registry(); +} + +[[noreturn]] void supernote_v3_throw_conversion_failure( + facebook::jsi::Runtime &runtime, + const supernote::conversion::Failure &failure) { + if (failure.kind() == supernote::conversion::FailureKind::TYPE) { + supernote_throw_type_error(runtime, failure.what()); + } + if (failure.kind() == supernote::conversion::FailureKind::RANGE) { + supernote_throw_range_error(runtime, failure.what()); + } + supernote_throw_error(runtime, "INTERNAL", failure.what()); +}''' + return f"{prototypes}\n\n{failure}\n\n{definitions}" + + +def _identifier(index: int) -> str: + return f"GeneratedV3Object{index}HostObject" + + +def _wrap_function(index: int) -> str: + return f"supernote_wrap_v3_object_{index}" + + +def _object_index(plan: CppRoutePlan, type_id: str) -> int: + for index, item in enumerate(plan.objects): + if item.named_type.type_id == type_id: + return index + raise CppRouteError(f"C++ object type {type_id!r} has no generated wrapper") + + +def _argument_expression( + parameter: CppParameterRoute, + number: int, +) -> str: + local = f"supernote_input_{number}" + if parameter.object_passing in { + CppObjectPassing.BORROWED_MUTABLE, + CppObjectPassing.BORROWED_CONST, + }: + return f"*{local}" + if parameter.object_passing in { + CppObjectPassing.SHARED_VALUE, + CppObjectPassing.SHARED_CONST_REF, + }: + return local + return local + + +def _prepare_parameter( + parameter: CppParameterRoute, + number: int, + *, + diagnostic: str, + plan: CppRoutePlan, + value_expression: str, + indent: str, +) -> list[str]: + local = f"supernote_input_{number}" + semantic = parameter.semantic_type + path = f"{diagnostic}.argument[{number}]({parameter.name})" + return [ + f"{indent}auto {local} = {_from_js_name(semantic)}(", + f"{indent} runtime, {value_expression}, conversion_budget, retained_objects,", + f"{indent} {json.dumps(path)}, 1);", + ] + + +def _result_lines( + semantic: SemanticType, + call: str, + *, + plan: CppRoutePlan, + registry: str, + feature: str, + diagnostic: str, + indent: str, +) -> list[str]: + if semantic.kind is SemanticTypeKind.VOID: + return [f"{indent}{call};", f"{indent}return Value::undefined();"] + return [ + f"{indent}auto native_result = {call};", + f"{indent}supernote::conversion::Budget result_budget;", + f"{indent}return {_to_js_name(semantic)}(", + f"{indent} runtime, native_result, {registry}, {feature}, result_budget,", + f"{indent} {json.dumps(diagnostic + '.result')}, 1);", + ] + + +def _callable_body( + route: CppCallableRoute, + call: str, + *, + diagnostic: str, + plan: CppRoutePlan, + registry: str, + feature: str, + indent: str, +) -> str: + lines = [ + f"{indent}if (argument_count != {len(route.parameters)}) {{", + f"{indent} supernote_throw_type_error(", + f"{indent} runtime, {json.dumps(diagnostic + ': wrong argument count')},", + f"{indent} \"ARITY_MISMATCH\", {json.dumps(diagnostic)},", + f"{indent} {json.dumps(str(len(route.parameters)) + ' arguments')},", + f"{indent} std::to_string(argument_count) + \" arguments\");", + f"{indent}}}", + f"{indent}try {{", + f"{indent} [[maybe_unused]] supernote::conversion::Budget conversion_budget;", + f"{indent} [[maybe_unused]] std::vector retained_objects;", + ] + for number, parameter in enumerate(route.parameters): + lines.extend( + _prepare_parameter( + parameter, + number, + diagnostic=diagnostic, + plan=plan, + value_expression=f"arguments[{number}]", + indent=indent + " ", + ) + ) + lines.extend( + [ + f"{indent} auto active_feature = {feature};", + f"{indent} if (!active_feature ||", + f"{indent} active_feature->state() != supernote::runtime::FeatureState::ACTIVE) {{", + f"{indent} supernote_throw_error(runtime, \"FEATURE_CLOSED\", \"feature is closed\");", + f"{indent} }}", + f"{indent} supernote::runtime::FeatureCallScope feature_call_scope(active_feature);", + ] + ) + lines.extend( + _result_lines( + route.result, + call, + plan=plan, + registry=registry, + feature="active_feature", + diagnostic=diagnostic, + indent=indent + " ", + ) + ) + lines.extend( + [ + f"{indent}}} catch (const facebook::jsi::JSError &) {{", + f"{indent} throw;", + f"{indent}}} catch (const supernote::conversion::Failure &failure) {{", + f"{indent} supernote_v3_throw_conversion_failure(runtime, failure);", + f"{indent}}} catch (const std::exception &error) {{", + f"{indent} supernote_throw_error(", + f"{indent} runtime, \"IMPLEMENTATION_ERROR\",", + f"{indent} std::string({json.dumps(diagnostic + ': ')}) + error.what());", + f"{indent}}} catch (...) {{", + f"{indent} supernote_throw_error(", + f"{indent} runtime, \"IMPLEMENTATION_ERROR\",", + f"{indent} {json.dumps(diagnostic + ': unknown C++ exception')});", + f"{indent}}}", + ] + ) + return "\n".join(lines) + + +def _call_expression(route: CppCallableRoute, receiver: str | None = None) -> str: + arguments = ", ".join( + _argument_expression(parameter, number) + for number, parameter in enumerate(route.parameters) + ) + if route.kind is CppCallableKind.FUNCTION: + return f"{route.cpp_name}({arguments})" + if route.kind is CppCallableKind.STATIC_METHOD: + return f"{route.owner_cpp_type}::{route.cpp_name}({arguments})" + if route.kind is CppCallableKind.INSTANCE_METHOD: + assert receiver is not None + return f"{receiver}->{route.cpp_name}({arguments})" + if route.kind is CppCallableKind.CONSTRUCTOR: + return f"std::make_shared<{route.owner_cpp_type}>({arguments})" + raise AssertionError(route.kind) + + +def _async_host_function( + route: CppCallableRoute, + *, + diagnostic: str, + plan: CppRoutePlan, + name: str, + receiver: str | None, + capture: str, + feature_expression: str, + indent: str, +) -> str: + if route.kind is CppCallableKind.CONSTRUCTOR: + raise CppRouteError(f"{diagnostic}: constructors cannot be async") + argument_parameter = "const Value *arguments" if route.parameters else "const Value *" + lines = [ + f"{indent} if (argument_count != {len(route.parameters)}) {{", + f"{indent} supernote_throw_type_error(", + f"{indent} runtime, {json.dumps(diagnostic + ': wrong argument count')},", + f"{indent} \"ARITY_MISMATCH\", {json.dumps(diagnostic)},", + f"{indent} {json.dumps(str(len(route.parameters)) + ' arguments')},", + f"{indent} std::to_string(argument_count) + \" arguments\");", + f"{indent} }}", + f"{indent} try {{", + f"{indent} supernote::conversion::Budget conversion_budget;", + f"{indent} std::vector retained_objects;", + ] + for number, parameter in enumerate(route.parameters): + lines.extend( + _prepare_parameter( + parameter, + number, + diagnostic=diagnostic, + plan=plan, + value_expression=f"arguments[{number}]", + indent=indent + " ", + ) + ) + lines.extend([ + f"{indent} auto active_feature = {feature_expression};", + f"{indent} if (!active_feature ||", + f"{indent} active_feature->state() != supernote::runtime::FeatureState::ACTIVE) {{", + f"{indent} supernote_throw_error(runtime, \"FEATURE_CLOSED\", \"feature is closed\");", + f"{indent} }}", + ]) + result_type = _cpp_type(route.result, plan) + if route.result.kind is SemanticTypeKind.VOID: + state_fields = "bool success{false};\n std::string error;" + else: + state_fields = ( + "bool success{false};\n" + f" std::optional<{result_type}> value;\n" + " std::vector retained_result;\n" + " std::string error;" + ) + lines.extend([ + f"{indent} struct AsyncState {{", + f"{indent} {state_fields}", + f"{indent} }};", + f"{indent} auto state = std::make_shared();", + ]) + retained_types = [ + "std::vector", + *( + [ + "supernote::runtime::ManagedRef<" + + str(route.owner_cpp_type) + + ">" + ] + if receiver is not None + else [] + ), + *(_cpp_type(item.semantic_type, plan) for item in route.parameters), + ] + retained_values = [ + "retained_objects", + *([receiver] if receiver is not None else []), + *(f"supernote_input_{number}" for number, _ in enumerate(route.parameters)), + ] + lines.extend([ + f"{indent} auto retained_input_state = std::make_shared>(", + f"{indent} {', '.join(retained_values)});", + ]) + executor_captures = [ + "active_feature", + "state", + "retained_input_state", + "retained_objects = std::move(retained_objects)", + ] + worker_captures = [ + "operation", + "operation_id", + "weak_feature", + "state", + "retained_objects = std::move(retained_objects)", + ] + if receiver is not None: + executor_captures.append(f"{receiver} = std::move({receiver})") + worker_captures.append(f"{receiver} = std::move({receiver})") + for number, _ in enumerate(route.parameters): + local = f"supernote_input_{number}" + executor_captures.append(f"{local} = std::move({local})") + worker_captures.append(f"{local} = std::move({local})") + call = _call_expression(route, receiver) + if route.result.kind is SemanticTypeKind.VOID: + execution = f"{call};\n{indent} state->success = true;" + resolution = ( + f"{indent} supernote_resolve_operation(\n" + f"{indent} runtime, operation_id, Value::undefined());" + ) + else: + execution = ( + f"state->value.emplace({call});\n" + f"{indent} {_retain_native_name(route.result)}(\n" + f"{indent} *state->value, state->retained_result,\n" + f"{indent} supernote::runtime::process_services().cleanup());\n" + f"{indent} state->success = true;" + ) + resolution = ( + f"{indent} auto object_registry = supernote_v3_object_registry(runtime);\n" + f"{indent} supernote::conversion::Budget result_budget;\n" + f"{indent} auto value = {_to_js_name(route.result)}(\n" + f"{indent} runtime, *state->value, object_registry,\n" + f"{indent} completion_feature, result_budget,\n" + f"{indent} {json.dumps(diagnostic + '.result')}, 1);\n" + f"{indent} supernote_resolve_operation(\n" + f"{indent} runtime, operation_id, std::move(value));" + ) + lines.extend([ + f"{indent} auto executor = Function::createFromHostFunction(", + f"{indent} runtime, PropNameID::forAscii(runtime, \"SupernoteAsyncExecutor\"), 2,", + f"{indent} [{', '.join(executor_captures)}](facebook::jsi::Runtime &runtime,", + f"{indent} const Value &, const Value *continuation_arguments,", + f"{indent} std::size_t continuation_count) mutable -> Value {{", + f"{indent} if (continuation_count != 2 ||", + f"{indent} !continuation_arguments[0].isObject() ||", + f"{indent} !continuation_arguments[1].isObject()) {{", + f"{indent} throw facebook::jsi::JSError(", + f"{indent} runtime, \"Promise supplied invalid continuation functions\");", + f"{indent} }}", + f"{indent} auto operation = active_feature->accept_factory(", + f"{indent} [](supernote::runtime::SessionId operation_id) {{", + f"{indent} return [operation_id](void *runtime_pointer) {{", + f"{indent} auto &runtime = *static_cast(runtime_pointer);", + f"{indent} supernote_reject_operation(", + f"{indent} runtime, operation_id, \"FEATURE_CLOSED\",", + f"{indent} \"feature closed before async completion\");", + f"{indent} }};", + f"{indent} }});", + f"{indent} if (!operation) {{", + f"{indent} supernote_reject_new_promise(", + f"{indent} runtime, continuation_arguments[1], \"FEATURE_CLOSED\",", + f"{indent} \"feature is closed\");", + f"{indent} return Value::undefined();", + f"{indent} }}", + f"{indent} operation->set_retained_state(retained_input_state);", + f"{indent} const auto operation_id = operation->id();", + f"{indent} supernote_register_continuation(", + f"{indent} runtime, operation_id, continuation_arguments[0],", + f"{indent} continuation_arguments[1]);", + f"{indent} std::weak_ptr weak_feature = active_feature;", + f"{indent} auto work = supernote::runtime::process_services().workers().submit(", + f"{indent} [{', '.join(worker_captures)}](supernote::runtime::CancellationToken executor_cancel) mutable {{", + f"{indent} (void)retained_objects;", + f"{indent} if (executor_cancel.is_cancelled() ||", + f"{indent} operation->cancellation_token().is_cancelled()) return;", + f"{indent} auto implementation_feature = weak_feature.lock();", + f"{indent} if (!implementation_feature) return;", + f"{indent} supernote::runtime::FeatureCallScope feature_call_scope(implementation_feature);", + f"{indent} implementation_feature.reset();", + f"{indent} try {{", + f"{indent} {execution}", + f"{indent} }} catch (const std::exception &error) {{", + f"{indent} state->error = error.what();", + f"{indent} }} catch (...) {{", + f"{indent} state->error = \"unknown C++ implementation failure\";", + f"{indent} }}", + f"{indent} if (executor_cancel.is_cancelled() ||", + f"{indent} operation->cancellation_token().is_cancelled()) return;", + f"{indent} auto completion_feature = weak_feature.lock();", + f"{indent} if (!completion_feature) return;", + f"{indent} completion_feature->schedule_completion(", + f"{indent} operation, [state, operation_id, completion_feature](void *runtime_pointer) {{", + f"{indent} auto &runtime = *static_cast(runtime_pointer);", + f"{indent} if (!state->success) {{", + f"{indent} supernote_reject_operation(", + f"{indent} runtime, operation_id, \"IMPLEMENTATION_ERROR\",", + f"{indent} state->error.empty() ? \"C++ implementation failed\" : state->error);", + f"{indent} return;", + f"{indent} }}", + f"{indent} try {{", + resolution, + f"{indent} }} catch (const std::exception &error) {{", + f"{indent} supernote_reject_operation(", + f"{indent} runtime, operation_id, \"INTERNAL\", error.what());", + f"{indent} }}", + f"{indent} }});", + f"{indent} }});", + f"{indent} operation->set_work(work);", + f"{indent} if (!work.accepted()) {{", + f"{indent} active_feature->schedule_completion(", + f"{indent} operation, [operation_id](void *runtime_pointer) {{", + f"{indent} auto &runtime = *static_cast(runtime_pointer);", + f"{indent} supernote_reject_operation(", + f"{indent} runtime, operation_id, \"RESOURCE_EXHAUSTED\",", + f"{indent} \"Supernote worker queue is full\");", + f"{indent} }});", + f"{indent} }}", + f"{indent} return Value::undefined();", + f"{indent} }});", + f"{indent} auto promise = runtime.global().getPropertyAsFunction(runtime, \"Promise\");", + f"{indent} const Value executor_argument(std::move(executor));", + f"{indent} return promise.callAsConstructor(", + f"{indent} runtime, &executor_argument, static_cast(1));", + f"{indent} }} catch (const facebook::jsi::JSError &) {{", + f"{indent} throw;", + f"{indent} }} catch (const supernote::conversion::Failure &failure) {{", + f"{indent} supernote_v3_throw_conversion_failure(runtime, failure);", + f"{indent} }} catch (const std::exception &error) {{", + f"{indent} supernote_throw_error(", + f"{indent} runtime, \"INTERNAL\", std::string({json.dumps(diagnostic + ': ')}) + error.what());", + f"{indent} }}", + ]) + return ( + "Function::createFromHostFunction(\n" + f"{indent} runtime, PropNameID::forAscii(runtime, {json.dumps(name)}),\n" + f"{indent} {len(route.parameters)},\n" + f"{indent} {capture}(facebook::jsi::Runtime &runtime, const Value &,\n" + f"{indent} {argument_parameter}, std::size_t argument_count) mutable -> Value {{\n" + + "\n".join(lines) + + f"\n{indent} }})" + ) + + +def _host_function( + route: CppCallableRoute, + *, + diagnostic: str, + plan: CppRoutePlan, + name: str, + receiver: str | None = None, + capture: str, + feature_expression: str, + registry_expression: str, + indent: str, +) -> str: + if route.execution is ExecutionMode.ASYNC: + function = _async_host_function( + route, + diagnostic=diagnostic, + plan=plan, + name=name, + receiver=receiver, + capture=capture, + feature_expression=feature_expression, + indent=indent, + ) + else: + body = _callable_body( + route, + _call_expression(route, receiver), + diagnostic=diagnostic, + plan=plan, + registry=registry_expression, + feature=feature_expression, + indent=indent + " ", + ) + arguments = "const Value *arguments" if route.parameters else "const Value *" + function = ( + "Function::createFromHostFunction(\n" + f"{indent} runtime, PropNameID::forAscii(runtime, {json.dumps(name)}),\n" + f"{indent} {len(route.parameters)},\n" + f"{indent} {capture}(facebook::jsi::Runtime &runtime, const Value &,\n" + f"{indent} {arguments}, std::size_t argument_count) -> Value {{\n" + f"{body}\n" + f"{indent} }})" + ) + accepts = _preflight_host_function( + route, + diagnostic=diagnostic, + name=name + ".accepts", + check=False, + indent=indent, + ) + check_arguments = _preflight_host_function( + route, + diagnostic=diagnostic, + name=name + ".checkArguments", + check=True, + indent=indent, + ) + return ( + "supernote_attach_preflight(\n" + f"{indent} runtime,\n" + f"{indent} {function},\n" + f"{indent} {accepts},\n" + f"{indent} {check_arguments})" + ) + + +def _preflight_host_function( + route: CppCallableRoute, + *, + diagnostic: str, + name: str, + check: bool, + indent: str, +) -> str: + argument_parameter = "const Value *arguments" if route.parameters else "const Value *" + lines: list[str] = [] + if check: + lines.extend([ + f"{indent} if (argument_count != {len(route.parameters)}) {{", + f"{indent} auto error = supernote_make_builtin_error(", + f"{indent} runtime, \"TypeError\",", + f"{indent} {json.dumps(diagnostic + ': wrong argument count')},", + f"{indent} \"ARITY_MISMATCH\", {json.dumps(diagnostic)},", + f"{indent} {json.dumps(str(len(route.parameters)) + ' arguments')},", + f"{indent} std::to_string(argument_count) + \" arguments\");", + f"{indent} return supernote_validation_failure(runtime, std::move(error));", + f"{indent} }}", + ]) + else: + lines.extend([ + f"{indent} if (argument_count != {len(route.parameters)}) {{", + f"{indent} return Value(false);", + f"{indent} }}", + ]) + lines.extend([ + f"{indent} try {{", + f"{indent} [[maybe_unused]] supernote::conversion::Budget conversion_budget;", + ]) + for number, parameter in enumerate(route.parameters): + path = f"{diagnostic}.argument[{number}]({parameter.name})" + lines.extend([ + f"{indent} {_validate_js_name(parameter.semantic_type)}(", + f"{indent} runtime, arguments[{number}], conversion_budget,", + f"{indent} {json.dumps(path)}, 1);", + ]) + lines.append( + f"{indent} return " + + ("supernote_validation_success(runtime);" if check else "Value(true);") + ) + lines.extend([ + f"{indent} }} catch (const facebook::jsi::JSError &error) {{", + ( + f"{indent} return supernote_validation_failure(\n" + f"{indent} runtime, Value(runtime, error.value()));" + if check + else f"{indent} return Value(false);" + ), + f"{indent} }} catch (const supernote::conversion::Failure &failure) {{", + f"{indent} if (failure.kind() == supernote::conversion::FailureKind::ALLOCATION) {{", + f"{indent} supernote_throw_error(runtime, \"RESOURCE_EXHAUSTED\", failure.what());", + f"{indent} }}", + ]) + if check: + lines.extend([ + f"{indent} const bool range =", + f"{indent} failure.kind() == supernote::conversion::FailureKind::RANGE;", + f"{indent} auto error = supernote_make_builtin_error(", + f"{indent} runtime, range ? \"RangeError\" : \"TypeError\", failure.what(),", + f"{indent} range ? \"LIMIT_EXCEEDED\" : \"TYPE_MISMATCH\",", + f"{indent} failure.path(), \"within generated conversion limits\", \"rejected\");", + f"{indent} return supernote_validation_failure(runtime, std::move(error));", + ]) + else: + lines.append(f"{indent} return Value(false);") + lines.extend([ + f"{indent} }} catch (const std::exception &error) {{", + f"{indent} supernote_throw_error(runtime, \"INTERNAL\", error.what());", + f"{indent} }}", + ]) + return ( + "Function::createFromHostFunction(\n" + f"{indent} runtime, PropNameID::forAscii(runtime, {json.dumps(name)}),\n" + f"{indent} {len(route.parameters)},\n" + f"{indent} [](facebook::jsi::Runtime &runtime, const Value &,\n" + f"{indent} {argument_parameter}, std::size_t argument_count) -> Value {{\n" + + "\n".join(lines) + + f"\n{indent} }})" + ) + + +def _wrapper(plan: CppRoutePlan, item: CppObjectRoute, index: int, module: str) -> str: + class_name = _identifier(index) + instance_methods = [ + route for route in item.methods + if route.kind is CppCallableKind.INSTANCE_METHOD + and route.javascript_public + ] + branches = [] + for route in instance_methods: + capture = ( + "[native_instance = this->managed_ref(), feature = feature_session_]" + if route.execution is ExecutionMode.ASYNC + else ( + "[native_instance = this->managed_ref(), " + "feature = feature_session_, registry = registry_]" + ) + ) + function = _host_function( + route, + diagnostic=f"{module}.{item.named_type.public_name}.{route.public_name}", + plan=plan, + name=route.public_name, + receiver="native_instance", + capture=capture, + feature_expression="feature.lock()", + registry_expression="registry", + indent=" ", + ) + branches.append( + f" if (property_name == {json.dumps(route.public_name)}) {{\n" + f" return {function};\n" + " }" + ) + for field in item.fields: + path = f"{module}.{item.named_type.public_name}.{field.public_name}" + result = ( + "supernote::conversion::Budget field_budget;\n" + f" return {_to_js_name(field.semantic_type)}(\n" + f" runtime, this->managed_ref()->{field.cpp_name}, registry_,\n" + " active_feature, field_budget,\n" + f" {json.dumps(path)}, 1);" + ) + branches.append( + f" if (property_name == {json.dumps(field.public_name)}) {{\n" + " auto active_feature = feature_session_.lock();\n" + " if (!active_feature ||\n" + " active_feature->state() != supernote::runtime::FeatureState::ACTIVE) {\n" + " supernote_throw_error(runtime, \"FEATURE_CLOSED\", \"feature is closed\");\n" + " }\n" + " supernote::runtime::FeatureCallScope feature_call_scope(active_feature);\n" + " try {\n" + f" {result}\n" + " } catch (const facebook::jsi::JSError &) {\n" + " throw;\n" + " } catch (const supernote::conversion::Failure &failure) {\n" + " supernote_v3_throw_conversion_failure(runtime, failure);\n" + " } catch (const std::exception &error) {\n" + " supernote_throw_error(\n" + " runtime, \"IMPLEMENTATION_ERROR\",\n" + f" std::string({json.dumps(module + '.' + item.named_type.public_name + '.' + field.public_name + ': ')}) + error.what());\n" + " } catch (...) {\n" + " supernote_throw_error(\n" + " runtime, \"IMPLEMENTATION_ERROR\",\n" + f" {json.dumps(module + '.' + item.named_type.public_name + '.' + field.public_name + ': unknown C++ exception')});\n" + " }\n" + " }" + ) + properties = "\n".join( + " properties.push_back(PropNameID::forAscii(runtime, " + f"{json.dumps(name)}));" + for name in [ + *(route.public_name for route in instance_methods), + *(field.public_name for field in item.fields), + ] + ) or " (void)runtime;" + mutable_fields = [field for field in item.fields if field.mutable] + set_branches = [] + for number, field in enumerate(mutable_fields): + parameter = CppParameterRoute( + field.public_name, + field.cpp_spelling, + field.semantic_type, + ( + CppObjectPassing.SHARED_VALUE + if field.semantic_type.kind is SemanticTypeKind.OBJECT_REF + else None + ), + ) + prepared = _prepare_parameter( + parameter, + number, + diagnostic=f"{module}.{item.named_type.public_name}.{field.public_name}", + plan=plan, + value_expression="value", + indent=" ", + ) + expression = _argument_expression(parameter, number) + set_branches.append( + f" if (property_name == {json.dumps(field.public_name)}) {{\n" + " try {\n" + " supernote::conversion::Budget conversion_budget;\n" + " std::vector retained_objects;\n" + + "\n".join(" " + line for line in prepared) + + "\n auto active_feature = feature_session_.lock();\n" + " if (!active_feature ||\n" + " active_feature->state() != supernote::runtime::FeatureState::ACTIVE) {\n" + " supernote_throw_error(runtime, \"FEATURE_CLOSED\", \"feature is closed\");\n" + " }\n" + " supernote::runtime::FeatureCallScope feature_call_scope(active_feature);\n" + f" this->managed_ref()->{field.cpp_name} = {expression};\n" + " return;\n" + " } catch (const facebook::jsi::JSError &) {\n" + " throw;\n" + " } catch (const supernote::conversion::Failure &failure) {\n" + " supernote_v3_throw_conversion_failure(runtime, failure);\n" + " } catch (const std::exception &error) {\n" + " supernote_throw_error(\n" + " runtime, \"IMPLEMENTATION_ERROR\",\n" + f" std::string({json.dumps(module + '.' + item.named_type.public_name + '.' + field.public_name + ': ')}) + error.what());\n" + " } catch (...) {\n" + " supernote_throw_error(\n" + " runtime, \"IMPLEMENTATION_ERROR\",\n" + f" {json.dumps(module + '.' + item.named_type.public_name + '.' + field.public_name + ': unknown C++ exception')});\n" + " }\n" + " }" + ) + set_override = "" + if set_branches: + set_override = f""" + + void set(facebook::jsi::Runtime &runtime, + const facebook::jsi::PropNameID &name, + const facebook::jsi::Value &value) override {{ + const std::string property_name = name.utf8(runtime); +{chr(10).join(set_branches)} + }}""" + return f"""class {class_name} final + : public supernote::runtime::CppObjectHandle<{item.named_type.cpp_type}> {{ + public: + {class_name}( + supernote::runtime::ManagedRef<{item.named_type.cpp_type}> instance, + std::weak_ptr feature_session, + std::shared_ptr registry) + : supernote::runtime::CppObjectHandle<{item.named_type.cpp_type}>( + {json.dumps(item.named_type.type_id)}, std::move(instance)), + feature_session_(std::move(feature_session)), + registry_(std::move(registry)) {{}} + + facebook::jsi::Value get( + facebook::jsi::Runtime &runtime, + const facebook::jsi::PropNameID &name) override {{ + using facebook::jsi::Function; + using facebook::jsi::PropNameID; + using facebook::jsi::String; + using facebook::jsi::Value; + const std::string property_name = name.utf8(runtime); +{chr(10).join(branches)} + return Value::undefined(); + }}{set_override} + + std::vector getPropertyNames( + facebook::jsi::Runtime &runtime) override {{ + using facebook::jsi::PropNameID; + std::vector properties; + properties.reserve({len(instance_methods) + len(item.fields)}); +{properties} + return properties; + }} + + private: + std::weak_ptr feature_session_; + std::shared_ptr registry_; +}};""" + + +def _wrap_declarations(plan: CppRoutePlan) -> str: + lines = [] + for index, item in enumerate(plan.objects): + lines.extend( + [ + f"class {_identifier(index)};", + f"facebook::jsi::Object {_wrap_function(index)}(", + " facebook::jsi::Runtime &runtime,", + " const std::shared_ptr ®istry,", + " const std::shared_ptr &feature,", + f" std::shared_ptr<{item.named_type.cpp_type}> instance);", + ] + ) + return "\n".join(lines) + + +def _wrap_definitions(plan: CppRoutePlan) -> str: + values = [] + for index, item in enumerate(plan.objects): + values.append( + f"""facebook::jsi::Object {_wrap_function(index)}( + facebook::jsi::Runtime &runtime, + const std::shared_ptr ®istry, + const std::shared_ptr &feature, + std::shared_ptr<{item.named_type.cpp_type}> instance) {{ + return registry->wrap<{item.named_type.cpp_type}>( + runtime, {json.dumps(item.named_type.type_id)}, std::move(instance), + [feature, registry]( + supernote::runtime::ManagedRef<{item.named_type.cpp_type}> managed) {{ + return std::make_shared<{_identifier(index)}>( + std::move(managed), feature, registry); + }}); +}}""" + ) + return "\n\n".join(values) + + +def _object_registration( + plan: CppRoutePlan, + item: CppObjectRoute, + module: str, +) -> str: + semantic = SemanticType.object_ref(item.named_type.type_id) + members = [ + f" auto existing_type = exports.getProperty(runtime, {json.dumps(item.named_type.public_name)});", + " Object object_type = existing_type.isObject()", + " ? existing_type.getObject(runtime)", + " : Object(runtime);", + f" auto is_type = {_type_guard_host_function(semantic, diagnostic=f'{module}.{item.named_type.public_name}', name='is', check=False, indent=' ')};", + " object_type.setProperty(runtime, \"is\", std::move(is_type));", + f" auto check_type = {_type_guard_host_function(semantic, diagnostic=f'{module}.{item.named_type.public_name}', name='check', check=True, indent=' ')};", + " object_type.setProperty(runtime, \"check\", std::move(check_type));", + ] + if item.constructor is not None: + route = item.constructor + function = _host_function( + route, + diagnostic=f"{module}.{item.named_type.public_name}.create", + plan=plan, + name="create", + capture=( + "[feature_session]" + if route.execution is ExecutionMode.ASYNC + else "[feature_session, object_registry]" + ), + feature_expression="feature_session", + registry_expression="object_registry", + indent=" ", + ) + members.extend( + [ + f" auto create = {function};", + " object_type.setProperty(runtime, \"create\", std::move(create));", + ] + ) + for route in item.methods: + if route.kind is not CppCallableKind.STATIC_METHOD: + continue + if not route.javascript_public: + continue + function = _host_function( + route, + diagnostic=f"{module}.{item.named_type.public_name}.{route.public_name}", + plan=plan, + name=route.public_name, + capture="[feature_session, object_registry]", + feature_expression="feature_session", + registry_expression="object_registry", + indent=" ", + ) + members.extend( + [ + f" auto method = {function};", + f" object_type.setProperty(runtime, {json.dumps(route.public_name)}, std::move(method));", + ] + ) + members.append( + f" exports.setProperty(runtime, {json.dumps(item.named_type.public_name)}, std::move(object_type));" + ) + return " {\n" + "\n".join(members) + "\n }" + + +def _type_guard_host_function( + semantic: SemanticType, + *, + diagnostic: str, + name: str, + check: bool, + indent: str, +) -> str: + lines = [] + if check: + lines.extend([ + f"{indent} if (argument_count != 1) {{", + f"{indent} auto error = supernote_make_builtin_error(", + f"{indent} runtime, \"TypeError\", {json.dumps(diagnostic + ': expected one value')},", + f"{indent} \"ARITY_MISMATCH\", {json.dumps(diagnostic)}, \"1 argument\",", + f"{indent} std::to_string(argument_count) + \" arguments\");", + f"{indent} return supernote_validation_failure(runtime, std::move(error));", + f"{indent} }}", + ]) + else: + lines.extend([ + f"{indent} if (argument_count != 1) return Value(false);", + ]) + lines.extend([ + f"{indent} try {{", + f"{indent} supernote::conversion::Budget conversion_budget;", + f"{indent} {_validate_js_name(semantic)}(", + f"{indent} runtime, arguments[0], conversion_budget,", + f"{indent} {json.dumps(diagnostic)}, 1);", + f"{indent} return " + ( + "supernote_validation_success(runtime);" if check else "Value(true);" + ), + f"{indent} }} catch (const facebook::jsi::JSError &error) {{", + ( + f"{indent} return supernote_validation_failure(\n" + f"{indent} runtime, Value(runtime, error.value()));" + if check + else f"{indent} return Value(false);" + ), + f"{indent} }} catch (const supernote::conversion::Failure &failure) {{", + f"{indent} if (failure.kind() == supernote::conversion::FailureKind::ALLOCATION) {{", + f"{indent} supernote_throw_error(runtime, \"RESOURCE_EXHAUSTED\", failure.what());", + f"{indent} }}", + ]) + if check: + lines.extend([ + f"{indent} const bool range =", + f"{indent} failure.kind() == supernote::conversion::FailureKind::RANGE;", + f"{indent} auto error = supernote_make_builtin_error(", + f"{indent} runtime, range ? \"RangeError\" : \"TypeError\", failure.what(),", + f"{indent} range ? \"LIMIT_EXCEEDED\" : \"TYPE_MISMATCH\",", + f"{indent} failure.path(), \"valid declared value\", \"rejected\");", + f"{indent} return supernote_validation_failure(runtime, std::move(error));", + ]) + else: + lines.append(f"{indent} return Value(false);") + lines.extend([ + f"{indent} }} catch (const std::exception &error) {{", + f"{indent} supernote_throw_error(runtime, \"INTERNAL\", error.what());", + f"{indent} }}", + ]) + return ( + "Function::createFromHostFunction(\n" + f"{indent} runtime, PropNameID::forAscii(runtime, {json.dumps(name)}), 1,\n" + f"{indent} [](facebook::jsi::Runtime &runtime, const Value &,\n" + f"{indent} const Value *arguments, std::size_t argument_count) -> Value {{\n" + + "\n".join(lines) + + f"\n{indent} }})" + ) + + +def _copied_type_registration( + semantic: SemanticType, + *, + public_name: str, + module: str, +) -> str: + diagnostic = f"{module}.{public_name}" + is_function = _type_guard_host_function( + semantic, diagnostic=diagnostic, name="is", check=False, indent=" " + ) + check_function = _type_guard_host_function( + semantic, diagnostic=diagnostic, name="check", check=True, indent=" " + ) + return f''' {{ + auto existing_type = exports.getProperty(runtime, {json.dumps(public_name)}); + Object object_type = existing_type.isObject() + ? existing_type.getObject(runtime) + : Object(runtime); + auto is_type = {is_function}; + object_type.setProperty(runtime, "is", std::move(is_type)); + auto check_type = {check_function}; + object_type.setProperty(runtime, "check", std::move(check_type)); + exports.setProperty(runtime, {json.dumps(public_name)}, std::move(object_type)); + }}''' + + +def _cpp_object_info_registration(plan: CppRoutePlan) -> str: + branches = [] + for item in plan.objects: + branches.extend([ + f" if (type_id == {json.dumps(item.named_type.type_id)}) {{", + " Object result(runtime);", + f" result.setProperty(runtime, \"type\", {json.dumps(item.named_type.public_name)});", + " result.setProperty(runtime, \"originFamily\", \"cpp\");", + " return Value(std::move(result));", + " }", + ]) + return f''' {{ + auto inspect = Function::createFromHostFunction( + runtime, PropNameID::forAscii(runtime, "__supernoteCppObjectInfo"), 1, + [](facebook::jsi::Runtime &runtime, const Value &, + const Value *arguments, std::size_t argument_count) -> Value {{ + if (argument_count != 1) return Value::undefined(); + auto type_id = supernote::runtime::cpp_object_type_id(runtime, arguments[0]); + if (type_id.empty()) return Value::undefined(); +{chr(10).join(branches)} + return Value::undefined(); + }}); + exports.setProperty( + runtime, "__supernoteCppObjectInfo", std::move(inspect)); + }}''' + + +def _function_registration( + plan: CppRoutePlan, + route: CppCallableRoute, + module: str, +) -> str: + function = _host_function( + route, + diagnostic=f"{module}.{route.public_name}", + plan=plan, + name=route.public_name, + capture=( + "[feature_session]" + if route.execution is ExecutionMode.ASYNC + else "[feature_session, object_registry]" + ), + feature_expression="feature_session", + registry_expression="object_registry", + indent=" ", + ) + return f""" {{ + auto function = {function}; + exports.setProperty(runtime, {json.dumps(route.public_name)}, std::move(function)); + }}""" + + +def render_cpp_object_bindings( + plan: CppRoutePlan, + *, + module_name: str, +) -> tuple[ + tuple[str, ...], + tuple[str, ...], + tuple[str, ...], + tuple[str, ...], +]: + """Return include, namespace-body, and registration fragments. + + Every public free function is emitted through the V3 route renderer. This + preserves its exact namespace-qualified native symbol while keeping the + public JavaScript name independent of the implementation spelling. + """ + + object_functions = tuple( + item for item in plan.functions if item.javascript_public + ) + if not plan.objects and not object_functions: + return (), (), (), () + roots: list[SemanticType] = [] + for route in object_functions: + roots.extend(parameter.semantic_type for parameter in route.parameters) + roots.append(route.result) + for item in plan.objects: + if item.constructor is not None: + roots.extend( + parameter.semantic_type for parameter in item.constructor.parameters + ) + roots.append(item.constructor.result) + for route in item.methods: + roots.extend(parameter.semantic_type for parameter in route.parameters) + roots.append(route.result) + roots.extend(field.semantic_type for field in item.fields) + conversion_types = _collect_types(roots, plan) + wrappers = ( + _wrap_declarations(plan), + _conversion_helpers(conversion_types, plan), + *(_wrapper(plan, item, index, module_name) for index, item in enumerate(plan.objects)), + _wrap_definitions(plan), + ) + registrations = [ + " auto object_registry = std::make_shared(\n" + " supernote::runtime::process_services().cleanup());\n" + " exports.setProperty(\n" + " runtime, kCppObjectRegistryProperty,\n" + " Object::createFromHostObject(\n" + " runtime, std::make_shared(\n" + " object_registry)));" + ] + registrations.extend( + _function_registration(plan, item, module_name) for item in object_functions + ) + registrations.extend( + _object_registration(plan, item, module_name) for item in plan.objects + ) + converted_named_types = { + (item.kind, item.type_id) + for item in conversion_types + if item.type_id is not None + } + registrations.extend( + _copied_type_registration( + SemanticType.value_ref(item.named_type.type_id), + public_name=item.named_type.public_name, + module=module_name, + ) + for item in plan.values + if (SemanticTypeKind.VALUE_REF, item.named_type.type_id) + in converted_named_types + ) + registrations.extend( + _copied_type_registration( + SemanticType.enum_ref(item.named_type.type_id), + public_name=item.named_type.public_name, + module=module_name, + ) + for item in plan.enums + if (SemanticTypeKind.ENUM_REF, item.named_type.type_id) + in converted_named_types + ) + if plan.objects: + registrations.append(_cpp_object_info_registration(plan)) + includes = tuple( + dict.fromkeys(item.include for item in plan.named_types if item.include) + ) + declarations = tuple( + _function_declaration(item) for item in object_functions + ) + return ( + includes, + declarations, + tuple(filter(None, wrappers)), + tuple(registrations), + ) + + +def _requires_recursive_conversion(semantic: SemanticType) -> bool: + return semantic.kind not in { + SemanticTypeKind.VOID, + SemanticTypeKind.SCALAR, + } + + +def _function_declaration(route: CppCallableRoute) -> str: + parameters = ", ".join( + f"{item.cpp_spelling} {item.name}" for item in route.parameters + ) + exception = " noexcept" if route.noexcept else "" + declaration = ( + f"{route.result_cpp_spelling} {route.public_name}({parameters})" + f"{exception};" + ) + for namespace in reversed(route.cpp_namespace): + declaration = f"namespace {namespace} {{\n{declaration}\n}}" + return declaration diff --git a/src/supernote_module_generator/cpp_object_runtime_codegen.py b/src/supernote_module_generator/cpp_object_runtime_codegen.py new file mode 100644 index 0000000..9d70b12 --- /dev/null +++ b/src/supernote_module_generator/cpp_object_runtime_codegen.py @@ -0,0 +1,286 @@ +"""Render the shared V3 C++ nominal-object handle and identity registry.""" +from __future__ import annotations + + +def render_cpp_object_runtime() -> str: + return r'''// Generated by supernote_module_generator. Do not edit. +#pragma once + +#include "runtime_services.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace supernote::runtime { + +class ManagedAnyRef final { + public: + ManagedAnyRef() = default; + + template + ManagedAnyRef( + std::shared_ptr value, + std::shared_ptr cleanup) + : value_(std::move(value)), cleanup_(std::move(cleanup)) {} + + ManagedAnyRef(const ManagedAnyRef &) = default; + ManagedAnyRef &operator=(const ManagedAnyRef &other) { + if (this == &other) return *this; + reset(); + value_ = other.value_; + cleanup_ = other.cleanup_; + return *this; + } + ManagedAnyRef(ManagedAnyRef &&) noexcept = default; + ManagedAnyRef &operator=(ManagedAnyRef && other) noexcept { + if (this == &other) return *this; + reset(); + value_ = std::move(other.value_); + cleanup_ = std::move(other.cleanup_); + return *this; + } + ~ManagedAnyRef() { reset(); } + + explicit operator bool() const noexcept { return static_cast(value_); } + + void reset() noexcept { + auto value = std::move(value_); + auto cleanup = cleanup_; + cleanup_.reset(); + if (!value) return; + if (cleanup && cleanup->submit( + [value = std::move(value)]() mutable { value.reset(); })) return; + value.reset(); + } + + private: + std::shared_ptr value_; + std::shared_ptr cleanup_; +}; + +class CppObjectHandleBase : public facebook::jsi::HostObject { + public: + ~CppObjectHandleBase() override = default; + [[nodiscard]] virtual std::string_view type_id() const noexcept = 0; + [[nodiscard]] virtual const void *native_address() const noexcept = 0; +}; + +template +class CppObjectHandle : public CppObjectHandleBase { + public: + CppObjectHandle(std::string type_id, ManagedRef instance) + : type_id_(std::move(type_id)), instance_(std::move(instance)) { + if (type_id_.empty() || !instance_) { + throw std::invalid_argument( + "a C++ object handle requires nominal identity and an instance"); + } + } + + [[nodiscard]] std::string_view type_id() const noexcept override { + return type_id_; + } + + [[nodiscard]] const void *native_address() const noexcept override { + return instance_.get(); + } + + [[nodiscard]] ManagedRef managed_ref() const { return instance_; } + [[nodiscard]] const std::shared_ptr &shared_ref() const noexcept { + return instance_.shared_ref(); + } + + private: + std::string type_id_; + ManagedRef instance_; +}; + +class CppObjectIdentity final { + public: + template + static CppObjectIdentity from( + std::string type_id, const std::shared_ptr &instance) { + if (type_id.empty() || !instance) { + throw std::invalid_argument( + "C++ object identity requires nominal identity and an instance"); + } + return CppObjectIdentity( + std::move(type_id), std::weak_ptr(instance), instance.get()); + } + + template + [[nodiscard]] bool matches( + std::string_view type_id, const std::shared_ptr &instance) const { + if (!instance || type_id_ != type_id || address_ != instance.get()) { + return false; + } + const std::weak_ptr candidate(instance); + const std::owner_less> less; + return !less(owner_, candidate) && !less(candidate, owner_); + } + + [[nodiscard]] bool expired() const noexcept { return owner_.expired(); } + [[nodiscard]] std::string_view type_id() const noexcept { return type_id_; } + [[nodiscard]] const void *address() const noexcept { return address_; } + [[nodiscard]] bool same_address_and_type( + std::string_view type_id, const void *address) const noexcept { + return type_id_ == type_id && address_ == address; + } + + private: + CppObjectIdentity( + std::string type_id, std::weak_ptr owner, const void *address) + : type_id_(std::move(type_id)), + owner_(std::move(owner)), + address_(address) {} + + std::string type_id_; + std::weak_ptr owner_; + const void *address_; +}; + +class CppObjectRegistry final + : public std::enable_shared_from_this { + public: + explicit CppObjectRegistry(std::shared_ptr cleanup) + : cleanup_(std::move(cleanup)) { + if (!cleanup_) { + throw std::invalid_argument( + "the C++ object registry requires deferred destruction"); + } + } + + CppObjectRegistry(const CppObjectRegistry &) = delete; + CppObjectRegistry &operator=(const CppObjectRegistry &) = delete; + + template + facebook::jsi::Object wrap( + facebook::jsi::Runtime &runtime, + std::string_view type_id, + std::shared_ptr instance, + Factory &&factory) { + assert_runtime(runtime); + if (!instance) { + throw std::invalid_argument("a native object result cannot be null"); + } + for (auto current = entries_.begin(); current != entries_.end();) { + if (current->identity.expired()) { + current = entries_.erase(current); + continue; + } + if (!current->identity.matches(type_id, instance)) { + if (current->identity.same_address_and_type(type_id, instance.get())) { + throw std::logic_error( + "conflicting C++ shared owners for one live native address"); + } + ++current; + continue; + } + auto locked = current->javascript.lock(runtime); + if (locked.isObject()) return locked.getObject(runtime); + current = entries_.erase(current); + break; + } + + auto identity = CppObjectIdentity::from(std::string(type_id), instance); + auto managed = ManagedRef(std::move(instance), cleanup_); + auto host = std::invoke( + std::forward(factory), std::move(managed)); + static_assert( + std::is_convertible_v>); + auto object = facebook::jsi::Object::createFromHostObject( + runtime, std::move(host)); + entries_.emplace_back( + std::move(identity), facebook::jsi::WeakObject(runtime, object)); + return object; + } + + void purge(facebook::jsi::Runtime &runtime) { + assert_runtime(runtime); + for (auto current = entries_.begin(); current != entries_.end();) { + if (current->identity.expired() || + !current->javascript.lock(runtime).isObject()) { + current = entries_.erase(current); + } else { + ++current; + } + } + } + + [[nodiscard]] std::size_t size_for_testing() const noexcept { + return entries_.size(); + } + + private: + struct Entry final { + Entry(CppObjectIdentity identity, facebook::jsi::WeakObject javascript) + : identity(std::move(identity)), javascript(std::move(javascript)) {} + CppObjectIdentity identity; + facebook::jsi::WeakObject javascript; + }; + + void assert_runtime(facebook::jsi::Runtime &runtime) { + if (runtime_ == nullptr) { + runtime_ = &runtime; + } else if (runtime_ != &runtime) { + throw std::logic_error( + "a C++ object registry cannot cross JavaScript runtimes"); + } + } + + std::shared_ptr cleanup_; + facebook::jsi::Runtime *runtime_ = nullptr; + std::list entries_; +}; + +class CppObjectRegistryOwner final : public facebook::jsi::HostObject { + public: + explicit CppObjectRegistryOwner(std::shared_ptr registry) + : registry_(std::move(registry)) { + if (!registry_) throw std::invalid_argument("object registry is required"); + } + + [[nodiscard]] const std::shared_ptr ®istry() const { + return registry_; + } + + private: + std::shared_ptr registry_; +}; + +inline std::string cpp_object_type_id( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value) { + if (!value.isObject()) return {}; + auto object = value.getObject(runtime); + if (!object.isHostObject(runtime)) return {}; + return std::string( + object.getHostObject(runtime)->type_id()); +} + +template +ManagedRef try_extract_cpp_object( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value, + std::string_view expected_type_id) { + if (!value.isObject()) return {}; + auto object = value.getObject(runtime); + if (!object.isHostObject(runtime)) return {}; + auto base = object.getHostObject(runtime); + if (base->type_id() != expected_type_id) return {}; + auto typed = std::dynamic_pointer_cast>(base); + return typed ? typed->managed_ref() : ManagedRef{}; +} + +} // namespace supernote::runtime +''' diff --git a/src/supernote_module_generator/cpp_projection.py b/src/supernote_module_generator/cpp_projection.py index c20f2f5..7fb0531 100644 --- a/src/supernote_module_generator/cpp_projection.py +++ b/src/supernote_module_generator/cpp_projection.py @@ -1,25 +1,36 @@ """Projection of C++ frontend records into common Supernote semantics.""" from __future__ import annotations +from dataclasses import dataclass import re from typing import Dict, Iterable, Optional, Protocol, Tuple from .semantic import ( BindingCapabilities, BindingKind, + BackendFamily, DeclarationRole, + MemberScope, SemanticApi, SemanticBinding, SemanticClass, SemanticClassKind, SemanticConstructor, + SemanticEnumDeclaration, + SemanticField, + SemanticObjectDeclaration, SemanticParameter, + SemanticProjection, SemanticType, + SemanticValueDeclaration, SourceProvenance, + semantic_type_id, ) from .source_models import ( CppClassSource, CppConstructorSource, + CppEnumSource, + CppFieldSource, CppFunctionSource, CppMethodSource, ) @@ -47,19 +58,203 @@ class _CppDeclaration(Protocol): } +@dataclass(frozen=True) +class _CppNamedType: + qualified_name: str + public_name: str + kind: str + type_id: str + + +class _CppTypeRegistry: + def __init__( + self, + feature_id: str, + classes: Iterable[CppClassSource], + enums: Iterable[CppEnumSource], + ) -> None: + self.feature_id = feature_id + self.by_qualified: dict[str, _CppNamedType] = {} + self.by_final: dict[str, list[_CppNamedType]] = {} + for item in classes: + kind = "object" if item.intent.declares_object else ( + "value" if item.intent.declares_value else "owner" + ) + if kind == "owner": + continue + self._add(item.qualified_name, item.cpp_name, kind) + for item in enums: + self._add(item.qualified_name, item.cpp_name, "enum") + + def _add(self, qualified_name: str, public_name: str, kind: str) -> None: + value = _CppNamedType( + qualified_name, + public_name, + kind, + semantic_type_id(self.feature_id, public_name), + ) + if qualified_name in self.by_qualified: + raise CppProjectionError( + f"duplicate marked C++ type definition {qualified_name!r}" + ) + self.by_qualified[qualified_name] = value + self.by_final.setdefault(public_name, []).append(value) + + def resolve( + self, + spelling: str, + namespace: Tuple[str, ...], + source: _CppDeclaration, + ) -> _CppNamedType: + name = spelling.removeprefix("::") + if "::" in name: + candidate = self.by_qualified.get(name) + else: + candidate = None + for count in range(len(namespace), -1, -1): + qualified = "::".join((*namespace[:count], name)) + candidate = self.by_qualified.get(qualified) + if candidate is not None: + break + if candidate is None: + matches = self.by_final.get(name, []) + if len(matches) == 1: + candidate = matches[0] + elif len(matches) > 1: + raise _error( + source, + f"ambiguous unqualified marked C++ type {spelling!r}; " + "use its exact namespace-qualified name", + ) + if candidate is None: + raise _error(source, f"unknown marked C++ type {spelling!r}") + return candidate + + +def _normalized_cpp_type(spelling: str) -> str: + value = spelling.strip() + value = re.sub(r"\s*::\s*", "::", value) + value = re.sub(r"\s*<\s*", "<", value) + value = re.sub(r"\s*>\s*", ">", value) + value = re.sub(r"\s*&\s*", "&", value) + value = re.sub(r"\s+", " ", value) + return value + + +def _template_inner(value: str, template: str) -> Optional[str]: + prefix = template + "<" + if not value.startswith(prefix) or not value.endswith(">"): + return None + inner = value[len(prefix):-1] + depth = 0 + for character in inner: + if character == "<": + depth += 1 + elif character == ">": + depth -= 1 + elif character == "," and depth == 0: + return None + return inner if inner and depth == 0 else None + + def canonical_cpp_type( spelling: str, *, result: bool, source: _CppDeclaration, + registry: Optional[_CppTypeRegistry] = None, + namespace: Tuple[str, ...] = (), + position: str = "direct", ) -> SemanticType: """Map one exact DR-027 C++ value spelling to a semantic type.""" - canonical = spelling.strip() - canonical = re.sub(r"\s*::\s*", "::", canonical) - canonical = re.sub(r"\s*<\s*", "<", canonical) - canonical = re.sub(r"\s*>\s*", ">", canonical) + canonical = _normalized_cpp_type(spelling) semantic = _CPP_TYPES.get(canonical) + if semantic is not None: + if semantic is SemanticType.VOID and not result: + raise _error(source, "void is valid only as a marked function result") + return semantic + if registry is not None: + vector_inner = _template_inner(canonical, "std::vector") + if vector_inner is not None: + return SemanticType.array( + canonical_cpp_type( + vector_inner, + result=False, + source=source, + registry=registry, + namespace=namespace, + position="contained", + ) + ) + optional_inner = _template_inner(canonical, "std::optional") + if optional_inner is not None: + return SemanticType.nullable( + canonical_cpp_type( + optional_inner, + result=False, + source=source, + registry=registry, + namespace=namespace, + position="contained", + ) + ) + reference = canonical.endswith("&") + core = canonical[:-1] if reference else canonical + core_const = core.startswith("const ") + if core_const: + core = core[len("const "):] + shared_inner = _template_inner(core, "std::shared_ptr") + borrowed = reference and shared_inner is None + named_spelling = shared_inner or core + try: + named = registry.resolve(named_spelling, namespace, source) + except CppProjectionError as exc: + if "ambiguous unqualified" in str(exc): + raise + named = None + if named is not None: + if named.kind == "object": + valid_object_form = shared_inner is not None or ( + borrowed and position == "parameter" + ) + if result or position in {"field", "contained"}: + valid_object_form = ( + shared_inner is not None and not reference and not core_const + ) + elif position == "parameter" and shared_inner is not None: + valid_object_form = ( + (not reference and not core_const) + or (reference and core_const) + ) + if not valid_object_form: + expected = ( + "std::shared_ptr" + if result or position in {"field", "contained"} + else "T&, const T&, std::shared_ptr, or const std::shared_ptr&" + ) + raise _error( + source, + f"native object {named.public_name!r} requires {expected} " + f"in this C++ position", + ) + return SemanticType.object_ref(named.type_id) + if shared_inner is not None: + raise _error( + source, + f"std::shared_ptr is valid only for native object types, not " + f"{named.kind} {named.public_name!r}", + ) + if reference or core_const: + raise _error( + source, + f"{named.kind} {named.public_name!r} must use its exact " + "owned value spelling in this C++ position", + ) + if named.kind == "value": + return SemanticType.value_ref(named.type_id) + if named.kind == "enum": + return SemanticType.enum_ref(named.type_id) if semantic is None: accepted = ( "void, bool, int32_t/std::int32_t, int64_t/std::int64_t, " @@ -68,11 +263,9 @@ def canonical_cpp_type( raise _error( source, f"unsupported marked C++ type {spelling!r}; use a canonical " - f"owned V2 type ({accepted})", + f"owned V3 type ({accepted})", ) - if semantic is SemanticType.VOID and not result: - raise _error(source, "void is valid only as a marked function result") - return semantic + raise AssertionError("unreachable") def semantic_binding_id(source: CppFunctionSource) -> str: @@ -87,6 +280,8 @@ def semantic_class_id(source: CppClassSource) -> str: def project_cpp_function( source: CppFunctionSource, + *, + registry: Optional[_CppTypeRegistry] = None, ) -> Optional[SemanticBinding]: """Project a marked C++ definition; ordinary code returns ``None``.""" @@ -95,7 +290,7 @@ def project_cpp_function( if source.provenance.language == "c" or source.provenance.path.endswith(".c"): raise _error( source, - "direct marked C bindings are unsupported in initial V2; place the " + "direct marked C bindings are unsupported in initial V3; place the " "marker on a canonical C++ boundary that calls ordinary C23 code", ) if source.provenance.language != "cpp": @@ -111,6 +306,9 @@ def project_cpp_function( parameter.type_spelling, result=False, source=source, + registry=registry, + namespace=source.namespace, + position="parameter", ), ) for parameter in source.parameters @@ -119,6 +317,9 @@ def project_cpp_function( source.return_type_spelling, result=True, source=source, + registry=registry, + namespace=source.namespace, + position="result", ) return SemanticBinding( binding_id=semantic_binding_id(source), @@ -147,6 +348,7 @@ def _project_method( owner: CppClassSource, owner_id: str, class_kind: SemanticClassKind, + registry: Optional[_CppTypeRegistry] = None, ) -> SemanticBinding: if source.intent.role is DeclarationRole.ORDINARY: raise _error(source, "ordinary methods do not become semantic bindings") @@ -166,6 +368,9 @@ def _project_method( parameter.type_spelling, result=False, source=source, + registry=registry, + namespace=owner.namespace, + position="parameter", ), ) for parameter in source.parameters @@ -174,6 +379,9 @@ def _project_method( source.return_type_spelling, result=True, source=source, + registry=registry, + namespace=owner.namespace, + position="result", ) kind = ( BindingKind.OBJECT_METHOD @@ -191,11 +399,15 @@ def _project_method( source=source.provenance, owner_id=owner_id, owner_name=owner.cpp_name, + member_scope=(MemberScope.STATIC if source.static else MemberScope.INSTANCE), ) def _constructor_parameters( source: CppConstructorSource, + *, + registry: Optional[_CppTypeRegistry] = None, + namespace: Tuple[str, ...] = (), ) -> Optional[Tuple[SemanticParameter, ...]]: try: return tuple( @@ -205,6 +417,9 @@ def _constructor_parameters( parameter.type_spelling, result=False, source=source, + registry=registry, + namespace=namespace, + position="parameter", ), ) for parameter in source.parameters @@ -217,10 +432,13 @@ def _constructor_parameters( def _select_js_constructor( source: CppClassSource, + registry: Optional[_CppTypeRegistry] = None, ) -> tuple[CppConstructorSource, Tuple[SemanticParameter, ...]]: eligible = [] for constructor in source.constructors: - parameters = _constructor_parameters(constructor) + parameters = _constructor_parameters( + constructor, registry=registry, namespace=source.namespace + ) if ( constructor.access == "public" and not constructor.deleted @@ -231,7 +449,7 @@ def _select_js_constructor( raise _error( constructor, "SupernoteConstructor must select an eligible public, " - "non-deleted constructor using canonical V2 value types", + "non-deleted constructor using canonical V3 value types", ) if not eligible: raise _error( @@ -325,18 +543,229 @@ def project_cpp_class(source: CppClassSource) -> Optional[SemanticClass]: def project_cpp_api( functions: Iterable[CppFunctionSource], classes: Iterable[CppClassSource], + enums: Iterable[CppEnumSource] = (), + *, + feature_id: str = "supernote:feature:legacy", ) -> SemanticApi: - bindings = [] + class_sources = tuple(classes) + enum_sources = tuple(enums) + registry = _CppTypeRegistry(feature_id, class_sources, enum_sources) + bindings: list[SemanticBinding] = [] for source in functions: - binding = project_cpp_function(source) + binding = project_cpp_function(source, registry=registry) if binding is not None: bindings.append(binding) - semantic_classes = [] - for source in classes: - semantic_class = project_cpp_class(source) - if semantic_class is not None: - semantic_classes.append(semantic_class) - return SemanticApi(tuple(bindings), tuple(semantic_classes)) + legacy_classes: list[SemanticClass] = [] + declarations = [] + for source in class_sources: + if source.intent.declares_object: + declarations.append(_project_cpp_object(source, registry, feature_id)) + elif source.intent.declares_value: + declarations.append(_project_cpp_value(source, registry, feature_id)) + elif any( + method.intent.role is not DeclarationRole.ORDINARY + for method in source.methods + ): + generated_methods = tuple( + method for method in source.methods + if method.intent.role is not DeclarationRole.ORDINARY + ) + if any(not method.static for method in generated_methods): + eligible = [ + constructor for constructor in source.constructors + if constructor.access == "public" + and not constructor.deleted + and not constructor.parameters + ] + if len(eligible) != 1: + raise _error( + source, + "an unmarked C++ implementation owner with instance " + "methods requires one unambiguous public zero-argument " + "construction path", + ) + if any(constructor.selected for constructor in source.constructors): + raise _error( + source, + "SupernoteConstructor is valid only on a " + "SupernotePluginObject class", + ) + bindings.extend( + _project_implementation_method(source, method, registry) + for method in generated_methods + ) + else: + semantic_class = project_cpp_class(source) + if semantic_class is not None: + legacy_classes.append(semantic_class) + declarations.extend( + _project_cpp_enum(source, feature_id) for source in enum_sources + ) + return SemanticApi(tuple(bindings), tuple(legacy_classes), tuple(declarations)) + + +def _field_type( + source: CppFieldSource, + owner: CppClassSource, + registry: _CppTypeRegistry, +) -> SemanticType: + return canonical_cpp_type( + source.type_spelling, + result=False, + source=source, + registry=registry, + namespace=owner.namespace, + position="field", + ) + + +def _semantic_field( + source: CppFieldSource, + owner: CppClassSource, + owner_id: str, + registry: _CppTypeRegistry, +) -> SemanticField: + if source.access != "public": + raise _error(source, "a generated C++ field must be public") + if source.static: + raise _error(source, "static generated fields are unsupported") + if source.intent.role is not DeclarationRole.EXPORTED: + raise _error(source, "generated fields require SupernotePluginExport") + return SemanticField( + field_id=f"{owner_id}:field:{source.cpp_name}", + owner_id=owner_id, + name=source.cpp_name, + type=_field_type(source, owner, registry), + source=source.provenance, + mutable=source.mutable, + ) + + +def _selected_object_constructor( + source: CppClassSource, + registry: _CppTypeRegistry, +) -> Optional[SemanticConstructor]: + selected = [item for item in source.constructors if item.selected] + if len(selected) > 1: + raise _error(source, "an object may select at most one SupernoteConstructor") + if not selected: + return None + constructor = selected[0] + if constructor.access != "public" or constructor.deleted: + raise _error( + constructor, + "SupernoteConstructor must select a public non-deleted constructor", + ) + parameters = _constructor_parameters( + constructor, registry=registry, namespace=source.namespace + ) + assert parameters is not None + return SemanticConstructor(constructor.provenance, parameters) + + +def _project_cpp_object( + source: CppClassSource, + registry: _CppTypeRegistry, + feature_id: str, +) -> SemanticObjectDeclaration: + type_id = semantic_type_id(feature_id, source.cpp_name) + methods = tuple( + _project_method( + method, + owner=source, + owner_id=type_id, + class_kind=SemanticClassKind.JS_OBJECT, + registry=registry, + ) + for method in source.methods + if method.intent.role is not DeclarationRole.ORDINARY + ) + fields = tuple( + _semantic_field(item, source, type_id, registry) for item in source.fields + ) + return SemanticObjectDeclaration( + feature_id, + type_id, + source.cpp_name, + SemanticProjection(BackendFamily.CPP, source.provenance), + _selected_object_constructor(source, registry), + methods, + fields, + ) + + +def _project_cpp_value( + source: CppClassSource, + registry: _CppTypeRegistry, + feature_id: str, +) -> SemanticValueDeclaration: + if any(item.selected for item in source.constructors): + raise _error(source, "SupernoteConstructor cannot mark a value type") + if any( + item.intent.role is not DeclarationRole.ORDINARY for item in source.methods + ): + raise _error(source, "value types expose fields, not generated methods") + type_id = semantic_type_id(feature_id, source.cpp_name) + fields = tuple( + _semantic_field(item, source, type_id, registry) for item in source.fields + ) + return SemanticValueDeclaration( + feature_id, + type_id, + source.cpp_name, + fields, + (SemanticProjection(BackendFamily.CPP, source.provenance),), + ) + + +def _project_cpp_enum( + source: CppEnumSource, + feature_id: str, +) -> SemanticEnumDeclaration: + return SemanticEnumDeclaration( + feature_id, + semantic_type_id(feature_id, source.cpp_name), + source.cpp_name, + source.constants, + (SemanticProjection(BackendFamily.CPP, source.provenance),), + ) + + +def _project_implementation_method( + owner: CppClassSource, + source: CppMethodSource, + registry: _CppTypeRegistry, +) -> SemanticBinding: + return SemanticBinding( + binding_id=f"supernote:binding:{source.provenance.declaration_id}", + kind=BindingKind.FUNCTION, + name=source.cpp_name, + capabilities=BindingCapabilities.for_role(source.intent.role), + execution=source.intent.execution, + parameters=tuple( + SemanticParameter( + item.name, + canonical_cpp_type( + item.type_spelling, + result=False, + source=source, + registry=registry, + namespace=owner.namespace, + position="parameter", + ), + ) + for item in source.parameters + ), + result=canonical_cpp_type( + source.return_type_spelling, + result=True, + source=source, + registry=registry, + namespace=owner.namespace, + position="result", + ), + source=source.provenance, + ) def cpp_type_table() -> Dict[str, SemanticType]: diff --git a/src/supernote_module_generator/cpp_routes.py b/src/supernote_module_generator/cpp_routes.py new file mode 100644 index 0000000..3301770 --- /dev/null +++ b/src/supernote_module_generator/cpp_routes.py @@ -0,0 +1,501 @@ +"""Source-backed C++ routes for the V3 semantic object model. + +The semantic API deliberately forgets source-language ownership spellings. This +module joins those public semantics back to the exact declarations found by the +C++ frontend. Renderers consume this plan instead of guessing from a public +type name or rescanning one class at a time. +""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import re +from typing import Iterable, Optional, Tuple + +from .semantic import ( + BackendFamily, + BindingKind, + ExecutionMode, + MemberScope, + SemanticApi, + SemanticBinding, + SemanticConstructor, + SemanticField, + SemanticObjectDeclaration, + SemanticValueDeclaration, + SemanticModelError, + validate_semantic_route, +) +from .semantic_types import SemanticType, SemanticTypeKind +from .source_models import ( + CppClassSource, + CppConstructorSource, + CppEnumSource, + CppFieldSource, + CppFunctionSource, + CppMethodSource, + CppParameterSource, +) + + +class CppRouteError(ValueError): + """Raised when projected semantics and the source model no longer agree.""" + + +class CppCallableKind(str, Enum): + FUNCTION = "function" + CONSTRUCTOR = "constructor" + INSTANCE_METHOD = "instance_method" + STATIC_METHOD = "static_method" + + +class CppObjectPassing(str, Enum): + """The four accepted direct C++ object parameter forms.""" + + BORROWED_MUTABLE = "borrowed_mutable" + BORROWED_CONST = "borrowed_const" + SHARED_VALUE = "shared_value" + SHARED_CONST_REF = "shared_const_ref" + + +@dataclass(frozen=True) +class CppNamedTypeRoute: + type_id: str + public_name: str + cpp_type: str + include: str + kind: SemanticTypeKind + source_declaration_id: str + + +@dataclass(frozen=True) +class CppParameterRoute: + name: str + cpp_spelling: str + semantic_type: SemanticType + object_passing: Optional[CppObjectPassing] = None + + def __post_init__(self) -> None: + is_direct_object = self.semantic_type.kind is SemanticTypeKind.OBJECT_REF + if is_direct_object != (self.object_passing is not None): + raise CppRouteError( + "only direct object parameters carry a C++ object passing form" + ) + + +@dataclass(frozen=True) +class CppCallableRoute: + source_declaration_id: str + kind: CppCallableKind + public_name: str + cpp_name: str + owner_cpp_type: Optional[str] + parameters: Tuple[CppParameterRoute, ...] + result: SemanticType + result_cpp_spelling: str + execution: ExecutionMode + noexcept: bool + javascript_public: bool + cpp_namespace: Tuple[str, ...] + const: bool = False + + +@dataclass(frozen=True) +class CppFieldRoute: + source_declaration_id: str + field_id: str + public_name: str + cpp_name: str + cpp_spelling: str + semantic_type: SemanticType + mutable: bool + + +@dataclass(frozen=True) +class CppObjectRoute: + named_type: CppNamedTypeRoute + constructor: Optional[CppCallableRoute] + methods: Tuple[CppCallableRoute, ...] + fields: Tuple[CppFieldRoute, ...] + + +@dataclass(frozen=True) +class CppValueRoute: + named_type: CppNamedTypeRoute + fields: Tuple[CppFieldRoute, ...] + + +@dataclass(frozen=True) +class CppEnumRoute: + named_type: CppNamedTypeRoute + constants: Tuple[str, ...] + + +@dataclass(frozen=True) +class CppRoutePlan: + functions: Tuple[CppCallableRoute, ...] + objects: Tuple[CppObjectRoute, ...] + values: Tuple[CppValueRoute, ...] + enums: Tuple[CppEnumRoute, ...] + named_types: Tuple[CppNamedTypeRoute, ...] + + @property + def named_types_by_id(self) -> dict[str, CppNamedTypeRoute]: + return {item.type_id: item for item in self.named_types} + + +def _normalized_cpp_type(spelling: str) -> str: + value = spelling.strip() + value = re.sub(r"\s*::\s*", "::", value) + value = re.sub(r"\s*<\s*", "<", value) + value = re.sub(r"\s*>\s*", ">", value) + value = re.sub(r"\s*&\s*", "&", value) + return re.sub(r"\s+", " ", value) + + +def _qualified_cpp_type(source: CppClassSource | CppEnumSource) -> str: + return "::" + source.qualified_name.removeprefix("::") + + +def _object_passing(spelling: str) -> CppObjectPassing: + canonical = _normalized_cpp_type(spelling) + if canonical.startswith("const std::shared_ptr<") and canonical.endswith(">&"): + return CppObjectPassing.SHARED_CONST_REF + if canonical.startswith("std::shared_ptr<") and canonical.endswith(">"): + return CppObjectPassing.SHARED_VALUE + if canonical.startswith("const ") and canonical.endswith("&"): + return CppObjectPassing.BORROWED_CONST + if canonical.endswith("&"): + return CppObjectPassing.BORROWED_MUTABLE + raise CppRouteError( + f"unsupported direct C++ object parameter spelling {spelling!r}" + ) + + +def _parameters( + semantic_parameters: tuple, + source_parameters: Tuple[CppParameterSource, ...], +) -> Tuple[CppParameterRoute, ...]: + if len(semantic_parameters) != len(source_parameters): + raise CppRouteError("semantic and C++ parameter counts disagree") + routes = [] + for semantic, source in zip(semantic_parameters, source_parameters): + if semantic.name != source.name: + raise CppRouteError("semantic and C++ parameter names disagree") + passing = ( + _object_passing(source.type_spelling) + if semantic.type.kind is SemanticTypeKind.OBJECT_REF + else None + ) + routes.append( + CppParameterRoute( + semantic.name, + source.type_spelling, + semantic.type, + passing, + ) + ) + return tuple(routes) + + +def _function_route( + binding: SemanticBinding, + source: CppFunctionSource, +) -> CppCallableRoute: + _require_source(binding.source.declaration_id, source.provenance.declaration_id) + return CppCallableRoute( + source.provenance.declaration_id, + CppCallableKind.FUNCTION, + binding.name, + "::" + "::".join((*source.namespace, source.cpp_name)).removeprefix("::"), + None, + _parameters(binding.parameters, source.parameters), + binding.result, + source.return_type_spelling, + binding.execution, + source.noexcept, + binding.capabilities.javascript_public, + source.namespace, + ) + + +def _constructor_route( + semantic: SemanticConstructor, + source: CppConstructorSource, + owner: CppClassSource, + type_id: str, +) -> CppCallableRoute: + _require_source(semantic.source.declaration_id, source.provenance.declaration_id) + cpp_type = _qualified_cpp_type(owner) + return CppCallableRoute( + source.provenance.declaration_id, + CppCallableKind.CONSTRUCTOR, + "create", + cpp_type, + cpp_type, + _parameters(semantic.parameters, source.parameters), + SemanticType.object_ref(type_id), + f"std::shared_ptr<{cpp_type}>", + ExecutionMode.SYNC, + source.noexcept, + True, + owner.namespace, + ) + + +def _method_route( + binding: SemanticBinding, + source: CppMethodSource, + owner: CppClassSource, +) -> CppCallableRoute: + _require_source(binding.source.declaration_id, source.provenance.declaration_id) + expected_kind = ( + CppCallableKind.STATIC_METHOD + if binding.member_scope is MemberScope.STATIC + else CppCallableKind.INSTANCE_METHOD + ) + if source.static != (expected_kind is CppCallableKind.STATIC_METHOD): + raise CppRouteError("semantic and C++ method scopes disagree") + return CppCallableRoute( + source.provenance.declaration_id, + expected_kind, + binding.name, + source.cpp_name, + _qualified_cpp_type(owner), + _parameters(binding.parameters, source.parameters), + binding.result, + source.return_type_spelling, + binding.execution, + source.noexcept, + binding.capabilities.javascript_public, + owner.namespace, + source.const, + ) + + +def _field_route( + semantic: SemanticField, + source: CppFieldSource, + *, + copied_projection: bool = False, +) -> CppFieldRoute: + if copied_projection: + if semantic.name != source.cpp_name: + raise CppRouteError("semantic and C++ copied field names disagree") + else: + _require_source(semantic.source.declaration_id, source.provenance.declaration_id) + if not copied_projection and semantic.mutable != source.mutable: + raise CppRouteError("semantic and C++ field mutability disagree") + return CppFieldRoute( + source.provenance.declaration_id, + semantic.field_id, + semantic.name, + source.cpp_name, + source.type_spelling, + semantic.type, + source.mutable, + ) + + +def _require_source(expected: str, actual: str) -> None: + if expected != actual: + raise CppRouteError( + f"semantic source {expected!r} does not match C++ source {actual!r}" + ) + + +def plan_cpp_routes( + api: SemanticApi, + functions: Iterable[CppFunctionSource], + classes: Iterable[CppClassSource], + enums: Iterable[CppEnumSource] = (), +) -> CppRoutePlan: + """Join one projected C++ API to its exact source declarations.""" + + function_sources = { + item.provenance.declaration_id: item for item in functions + } + class_sources = { + item.provenance.declaration_id: item for item in classes + } + implementation_method_ids = { + method.provenance.declaration_id + for owner in class_sources.values() + if not owner.intent.declares_object + for method in owner.methods + } + enum_sources = { + item.provenance.declaration_id: item for item in enums + } + + def validate_type(value: SemanticType, source) -> None: + try: + validate_semantic_route( + api, + value, + BackendFamily.CPP, + BackendFamily.CPP, + source, + source, + ) + except SemanticModelError as exc: + raise CppRouteError(str(exc)) from exc + + def validate_binding(binding: SemanticBinding) -> None: + for parameter in binding.parameters: + validate_type(parameter.type, binding.source) + validate_type(binding.result, binding.source) + + function_routes = [] + for binding in api.functions: + if binding.source.language != "cpp": + continue + validate_binding(binding) + if binding.kind is not BindingKind.FUNCTION: + raise CppRouteError("top-level C++ binding is not a function") + try: + source = function_sources[binding.source.declaration_id] + except KeyError as exc: + if binding.source.declaration_id in implementation_method_ids: + # Unmarked implementation-owner methods are lowered by the + # internal facade, which owns service construction/receiver + # lookup. They are not direct C++ function routes. + continue + raise CppRouteError( + f"missing C++ function source {binding.source.declaration_id!r}" + ) from exc + function_routes.append(_function_route(binding, source)) + + named_routes = [] + object_routes = [] + value_routes = [] + enum_routes = [] + for declaration in api.declarations: + cpp_projections = [ + item for item in declaration.projections + if item.backend is BackendFamily.CPP + ] + if not cpp_projections: + continue + if len(cpp_projections) != 1: + raise CppRouteError( + f"type {declaration.name!r} has multiple C++ projections" + ) + projection = cpp_projections[0] + if declaration.kind.value == "enum": + try: + source = enum_sources[projection.source.declaration_id] + except KeyError as exc: + raise CppRouteError( + f"missing C++ enum source {projection.source.declaration_id!r}" + ) from exc + named = CppNamedTypeRoute( + declaration.type_id, + declaration.name, + _qualified_cpp_type(source), + source.include, + SemanticTypeKind.ENUM_REF, + source.provenance.declaration_id, + ) + named_routes.append(named) + if tuple(declaration.constants) != tuple(source.constants): + raise CppRouteError( + f"semantic and C++ enum constants disagree for {declaration.name!r}" + ) + enum_routes.append(CppEnumRoute(named, tuple(source.constants))) + continue + try: + owner = class_sources[projection.source.declaration_id] + except KeyError as exc: + raise CppRouteError( + f"missing C++ class source {projection.source.declaration_id!r}" + ) from exc + kind = ( + SemanticTypeKind.OBJECT_REF + if isinstance(declaration, SemanticObjectDeclaration) + else SemanticTypeKind.VALUE_REF + ) + named = CppNamedTypeRoute( + declaration.type_id, + declaration.name, + _qualified_cpp_type(owner), + owner.include, + kind, + owner.provenance.declaration_id, + ) + named_routes.append(named) + if isinstance(declaration, SemanticValueDeclaration): + field_sources = {item.cpp_name: item for item in owner.fields} + fields = [] + for field in declaration.fields: + try: + field_source = field_sources[field.name] + except KeyError as exc: + raise CppRouteError( + f"missing C++ value field source for {declaration.name!r}" + ) from exc + fields.append( + _field_route(field, field_source, copied_projection=True) + ) + value_routes.append(CppValueRoute(named, tuple(fields))) + continue + if not isinstance(declaration, SemanticObjectDeclaration): + continue + constructor = None + if declaration.constructor is not None: + for parameter in declaration.constructor.parameters: + validate_type(parameter.type, declaration.constructor.source) + by_id = { + item.provenance.declaration_id: item for item in owner.constructors + } + try: + constructor_source = by_id[ + declaration.constructor.source.declaration_id + ] + except KeyError as exc: + raise CppRouteError( + f"missing C++ constructor source for {declaration.name!r}" + ) from exc + constructor = _constructor_route( + declaration.constructor, + constructor_source, + owner, + declaration.type_id, + ) + method_sources = { + item.provenance.declaration_id: item for item in owner.methods + } + methods = [] + for binding in declaration.methods: + validate_binding(binding) + try: + method_source = method_sources[binding.source.declaration_id] + except KeyError as exc: + raise CppRouteError( + f"missing C++ method source {binding.source.declaration_id!r}" + ) from exc + methods.append(_method_route(binding, method_source, owner)) + field_sources = { + item.provenance.declaration_id: item for item in owner.fields + } + fields = [] + for field in declaration.fields: + validate_type(field.type, field.source) + try: + field_source = field_sources[field.source.declaration_id] + except KeyError as exc: + raise CppRouteError( + f"missing C++ field source {field.source.declaration_id!r}" + ) from exc + fields.append(_field_route(field, field_source)) + object_routes.append( + CppObjectRoute(named, constructor, tuple(methods), tuple(fields)) + ) + + return CppRoutePlan( + tuple(function_routes), + tuple(object_routes), + tuple(value_routes), + tuple(enum_routes), + tuple(named_routes), + ) diff --git a/src/supernote_module_generator/cross_family_codegen.py b/src/supernote_module_generator/cross_family_codegen.py new file mode 100644 index 0000000..6ee8a9c --- /dev/null +++ b/src/supernote_module_generator/cross_family_codegen.py @@ -0,0 +1,696 @@ +"""Generate copied C++ to JVM internal-route conversions for V3 values.""" +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +from pathlib import Path +from typing import Iterable + +from .binding_codegen import ( + scan_cpp_class_source_model, + scan_cpp_enum_source_model, + scan_cpp_source_model, +) +from .cpp_object_binding_codegen import _cpp_type +from .cpp_routes import CppRoutePlan, plan_cpp_routes +from .jvm_manifest import JvmSourceManifest, jvm_adapter_identity +from .jvm_routes import JvmRoutePlan, plan_jvm_routes +from .semantic import ( + BackendFamily, + DeclarationRole, + SemanticApi, + SemanticBinding, + SemanticClassKind, + SemanticModelError, + SourceProvenance, + validate_semantic_route, +) +from .semantic_types import ScalarKind, SemanticType, SemanticTypeKind + + +class CrossFamilyCodegenError(ValueError): + """Raised before emission when an internal copied route is impossible.""" + + +_PRIMITIVE_FIELDS = { + ScalarKind.BOOL: ("z", "JNI_TRUE", "JNI_FALSE", "jboolean", "CallStaticBooleanMethodA"), + ScalarKind.INT32: ("i", None, None, "jint", "CallStaticIntMethodA"), + ScalarKind.INT64: ("j", None, None, "jlong", "CallStaticLongMethodA"), + ScalarKind.FLOAT32: ("f", None, None, "jfloat", "CallStaticFloatMethodA"), + ScalarKind.FLOAT64: ("d", None, None, "jdouble", "CallStaticDoubleMethodA"), +} + + +def _suffix(value: SemanticType) -> str: + encoded = json.dumps( + value.manifest(), sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest()[:12] + + +def _to_name(value: SemanticType) -> str: + return f"supernote_v3_cross_to_jvm_{_suffix(value)}" + + +def _from_name(value: SemanticType) -> str: + return f"supernote_v3_cross_from_jvm_{_suffix(value)}" + + +def _route_expression(key: str, adapter: str, descriptor: str, method: str) -> str: + return ( + "supernote_v3_jvm_route(feature, " + + json.dumps(key) + + ", " + + json.dumps(adapter) + + ", " + + json.dumps(descriptor) + + ", " + + json.dumps(method) + + ")" + ) + + +def _adapter_class(identity: str) -> str: + return "supernote.generated.adapters.Adapter_" + identity.rsplit(".", 1)[-1] + + +@dataclass(frozen=True) +class CrossFamilyRenderer: + api: SemanticApi + cpp: CppRoutePlan + jvm: JvmRoutePlan + feature_id: str + + def __post_init__(self) -> None: + for binding in self.internal_jvm_bindings: + self.validate_binding(binding) + + @property + def internal_jvm_bindings(self) -> tuple[SemanticBinding, ...]: + result = [ + item + for item in self.api.functions + if item.source.language in {"kotlin", "java"} + and item.capabilities.role is DeclarationRole.INTERNAL + ] + for owner in self.api.classes: + if ( + owner.kind is SemanticClassKind.INTERNAL_SERVICE + and owner.source.language in {"kotlin", "java"} + ): + result.extend(owner.methods) + return tuple(result) + + def validate_binding(self, binding: SemanticBinding) -> None: + source = self._cpp_endpoint(binding) + try: + for parameter in binding.parameters: + validate_semantic_route( + self.api, + parameter.type, + BackendFamily.CPP, + BackendFamily.JVM, + source, + binding.source, + ) + validate_semantic_route( + self.api, + binding.result, + BackendFamily.CPP, + BackendFamily.JVM, + source, + binding.source, + ) + except SemanticModelError as exc: + raise CrossFamilyCodegenError(str(exc)) from exc + + def _cpp_endpoint(self, binding: SemanticBinding) -> SourceProvenance: + declarations = {item.type_id: item for item in self.api.declarations} + + def find(value: SemanticType) -> SourceProvenance | None: + if value.element is not None: + return find(value.element) + if value.type_id is None: + return None + declaration = declarations[value.type_id] + for projection in declaration.projections: + if projection.backend is BackendFamily.CPP: + return projection.source + return None + + for value in (*[item.type for item in binding.parameters], binding.result): + endpoint = find(value) + if endpoint is not None: + return endpoint + return SourceProvenance( + "generated:cpp-internal:" + binding.binding_id, + "cpp", + f"", + 1, + 1, + ) + + def cpp_type(self, value: SemanticType) -> str: + try: + return _cpp_type(value, self.cpp) + except KeyError as exc: + raise CrossFamilyCodegenError( + f"copied internal type {value.value!r} has no C++ projection" + ) from exc + + def descriptor(self, binding: SemanticBinding, owner_class: str | None) -> str: + named = self.jvm.named_types_by_id + parameters = "" + if owner_class is not None: + parameters += f"L{owner_class.replace('.', '/')};" + parameters += "".join(self._descriptor(item.type, named) for item in binding.parameters) + return f"({parameters}){self._descriptor(binding.result, named)}" + + def suspend_descriptor( + self, binding: SemanticBinding, owner_class: str | None + ) -> str: + descriptor = self.descriptor(binding, owner_class) + parameters, _result = descriptor.split(")", 1) + return parameters + "J)Lkotlinx/coroutines/Job;" + + def _descriptor(self, value: SemanticType, named) -> str: + if value.kind is SemanticTypeKind.VOID: + return "V" + if value.kind is SemanticTypeKind.NULLABLE: + assert value.element is not None + child = value.element + if child.kind is SemanticTypeKind.SCALAR and child.scalar in _PRIMITIVE_FIELDS: + return { + ScalarKind.BOOL: "Ljava/lang/Boolean;", + ScalarKind.INT32: "Ljava/lang/Integer;", + ScalarKind.INT64: "Ljava/lang/Long;", + ScalarKind.FLOAT32: "Ljava/lang/Float;", + ScalarKind.FLOAT64: "Ljava/lang/Double;", + }[child.scalar] + return self._descriptor(child, named) + if value.kind is SemanticTypeKind.ARRAY: + return "Ljava/util/List;" + if value.kind is SemanticTypeKind.SCALAR: + return { + ScalarKind.BOOL: "Z", + ScalarKind.INT32: "I", + ScalarKind.INT64: "J", + ScalarKind.FLOAT32: "F", + ScalarKind.FLOAT64: "D", + ScalarKind.STRING: "[B", + ScalarKind.BYTES: "[B", + }[value.scalar] + assert value.type_id is not None + route = named.get(value.type_id) + if route is None: + raise CrossFamilyCodegenError( + f"copied internal type {value.type_id!r} has no JVM projection" + ) + return f"L{route.owner_class.replace('.', '/')};" + + def includes(self) -> tuple[str, ...]: + used = {item.type_id for item in self._collected_types() if item.type_id} + return tuple( + sorted( + {item.include for item in self.cpp.named_types if item.type_id in used} + ) + ) + + def render_helpers(self) -> str: + values = self._collected_types() + prototypes = [] + definitions = [] + for value in values: + if value.kind is SemanticTypeKind.VOID: + continue + native = self.cpp_type(value) + prototypes.append( + f"jobject {_to_name(value)}(const {native} &value, JNIEnv *env, " + "const std::shared_ptr &feature, " + "supernote::conversion::Budget &budget, const std::string &path, " + "std::uint64_t depth);" + ) + prototypes.append( + f"{native} {_from_name(value)}(jobject value, JNIEnv *env, " + "const std::shared_ptr &feature, " + "supernote::conversion::Budget &budget, const std::string &path, " + "std::uint64_t depth);" + ) + for value in values: + if value.kind is SemanticTypeKind.VOID: + continue + definitions.append(self._render_to(value)) + definitions.append(self._render_from(value)) + return "\n".join((*prototypes, *definitions)) + + def worker_invocation(self, binding: SemanticBinding, takes_owner: bool) -> str: + self.validate_binding(binding) + offset = 1 if takes_owner else 0 + size = len(binding.parameters) + offset + lines = [ + " supernote::conversion::Budget cross_budget;", + f" jvalue jvm_arguments[{max(1, size)}]{{}};", + ] + if takes_owner: + lines.append( + " jvm_arguments[0].l = static_cast(owner->value.get());" + ) + for index, parameter in enumerate(binding.parameters): + target = index + offset + value = parameter.type + if self._direct_primitive(value): + assert value.scalar is not None + field = _PRIMITIVE_FIELDS[value.scalar][0] + expression = parameter.name + if value.scalar is ScalarKind.BOOL: + expression += " ? JNI_TRUE : JNI_FALSE" + else: + expression = f"static_cast<{_PRIMITIVE_FIELDS[value.scalar][3]}>({expression})" + lines.append(f" cross_budget.visit({json.dumps(parameter.name)}, 0);") + lines.append(f" jvm_arguments[{target}].{field} = {expression};") + else: + local = f"cross_argument_{index}" + lines.append( + f" auto {local} = {_to_name(value)}({parameter.name}, env, feature, " + f"cross_budget, {json.dumps(parameter.name)}, 0);" + ) + lines.append(f" jvm_arguments[{target}].l = {local};") + call = self._jni_call(binding.result) + expression = ( + f"env->{call}(static_cast(resolved->adapter_class.get()), " + "resolved->method, jvm_arguments)" + ) + if binding.result.kind is SemanticTypeKind.VOID: + lines.extend([f" {expression};", " require_no_implementation_exception(env);"]) + elif self._direct_primitive(binding.result): + lines.extend([ + f" auto result = {expression};", + " require_no_implementation_exception(env);", + " cross_budget.visit(\"result\", 0);", + f" return {self._primitive_result(binding.result, 'result')};", + ]) + else: + lines.extend([ + f" auto result = {expression};", + " require_no_implementation_exception(env);", + f" return {_from_name(binding.result)}(result, env, feature, cross_budget, \"result\", 0);", + ]) + return "\n".join(lines) + + def suspend_worker_arguments( + self, binding: SemanticBinding, takes_owner: bool + ) -> str: + self.validate_binding(binding) + offset = 1 if takes_owner else 0 + size = len(binding.parameters) + offset + 1 + lines = [ + "supernote::conversion::Budget cross_budget;", + f"jvalue jvm_arguments[{max(1, size)}]{{}};", + ] + if takes_owner: + lines.append( + "jvm_arguments[0].l = static_cast(owner->value.get());" + ) + for index, parameter in enumerate(binding.parameters): + target = index + offset + value = parameter.type + if self._direct_primitive(value): + assert value.scalar is not None + field = _PRIMITIVE_FIELDS[value.scalar][0] + expression = parameter.name + if value.scalar is ScalarKind.BOOL: + expression += " ? JNI_TRUE : JNI_FALSE" + else: + expression = ( + f"static_cast<{_PRIMITIVE_FIELDS[value.scalar][3]}>" + f"({expression})" + ) + lines.append( + f"cross_budget.visit({json.dumps(parameter.name)}, 0);" + ) + lines.append(f"jvm_arguments[{target}].{field} = {expression};") + else: + local = f"cross_argument_{index}" + lines.append( + f"auto {local} = {_to_name(value)}({parameter.name}, env, " + f"feature, cross_budget, {json.dumps(parameter.name)}, 0);" + ) + lines.append(f"jvm_arguments[{target}].l = {local};") + lines.append( + f"jvm_arguments[{size - 1}].j = static_cast(completion_id);" + ) + return "\n".join(lines) + + def suspend_result_expression( + self, + value: SemanticType, + *, + expression: str, + feature: str, + budget: str, + ) -> str: + if value.kind is SemanticTypeKind.VOID: + raise CrossFamilyCodegenError("void has no copied suspend result") + return ( + f"{_from_name(value)}(static_cast({expression}), env, " + f"{feature}, {budget}, \"result\", 0)" + ) + + def _collected_types(self) -> tuple[SemanticType, ...]: + roots = [ + value + for binding in self.internal_jvm_bindings + for value in (*[item.type for item in binding.parameters], binding.result) + ] + values_by_id = {item.named_type.type_id: item for item in self.cpp.values} + found: dict[str, SemanticType] = {} + + def visit(value: SemanticType) -> None: + if value.kind is SemanticTypeKind.VOID: + return + key = _suffix(value) + if key in found: + return + found[key] = value + if value.element is not None: + visit(value.element) + elif value.kind is SemanticTypeKind.VALUE_REF: + assert value.type_id is not None + route = values_by_id.get(value.type_id) + if route is not None: + for field in route.fields: + visit(field.semantic_type) + + for root in roots: + visit(root) + return tuple(found[key] for key in sorted(found)) + + @staticmethod + def _direct_primitive(value: SemanticType) -> bool: + return ( + value.kind is SemanticTypeKind.SCALAR + and value.scalar in _PRIMITIVE_FIELDS + ) + + @staticmethod + def _jni_call(value: SemanticType) -> str: + if value.kind is SemanticTypeKind.VOID: + return "CallStaticVoidMethodA" + if CrossFamilyRenderer._direct_primitive(value): + assert value.scalar is not None + return _PRIMITIVE_FIELDS[value.scalar][4] + return "CallStaticObjectMethodA" + + @staticmethod + def _primitive_result(value: SemanticType, expression: str) -> str: + assert value.scalar is not None + if value.scalar is ScalarKind.BOOL: + return f"{expression} == JNI_TRUE" + return f"static_cast<{CrossFamilyRenderer._primitive_cpp(value.scalar)}>({expression})" + + @staticmethod + def _primitive_cpp(value: ScalarKind) -> str: + return { + ScalarKind.BOOL: "bool", + ScalarKind.INT32: "std::int32_t", + ScalarKind.INT64: "std::int64_t", + ScalarKind.FLOAT32: "float", + ScalarKind.FLOAT64: "double", + }[value] + + def _helper_route(self, method: str, descriptor: str) -> str: + digest = hashlib.sha256(self.feature_id.encode("utf-8")).hexdigest()[:20] + return _route_expression( + f"jvm-v3-cross-helper:{method}:{descriptor}", + f"supernote.generated.adapters.Identity_{digest}", + descriptor, + method, + ) + + def _render_to(self, value: SemanticType) -> str: + native = self.cpp_type(value) + lines = [ + f"jobject {_to_name(value)}(const {native} &value, JNIEnv *env,", + " const std::shared_ptr &feature,", + " supernote::conversion::Budget &budget, const std::string &path,", + " std::uint64_t depth) {", + " budget.visit(path, depth);", + ] + kind = value.kind + if kind is SemanticTypeKind.SCALAR: + assert value.scalar is not None + if value.scalar in _PRIMITIVE_FIELDS: + method, descriptor, field = { + ScalarKind.BOOL: ("boxBoolean", "(Z)Ljava/lang/Object;", "z"), + ScalarKind.INT32: ("boxInt", "(I)Ljava/lang/Object;", "i"), + ScalarKind.INT64: ("boxLong", "(J)Ljava/lang/Object;", "j"), + ScalarKind.FLOAT32: ("boxFloat", "(F)Ljava/lang/Object;", "f"), + ScalarKind.FLOAT64: ("boxDouble", "(D)Ljava/lang/Object;", "d"), + }[value.scalar] + expression = "value ? JNI_TRUE : JNI_FALSE" if value.scalar is ScalarKind.BOOL else f"static_cast<{_PRIMITIVE_FIELDS[value.scalar][3]}>(value)" + lines.extend([ + f" auto route = {self._helper_route(method, descriptor)};", + " jvalue arguments[1]{};", + f" arguments[0].{field} = {expression};", + " auto result = env->CallStaticObjectMethodA(", + " static_cast(route->adapter_class.get()), route->method, arguments);", + " require_no_implementation_exception(env);", + " if (result == nullptr) throw std::runtime_error(\"JVM boxing returned null\");", + " return result;", + ]) + else: + data = "reinterpret_cast(value.data())" if value.scalar is ScalarKind.STRING else "value.data()" + check = "check_string_bytes" if value.scalar is ScalarKind.STRING else "check_byte_buffer" + lines.extend([ + f" budget.{check}(path, value.size());", + " budget.reserve(path, value.size());", + f" return write_byte_array(env, {data}, value.size());", + ]) + elif kind is SemanticTypeKind.NULLABLE: + assert value.element is not None + lines.extend([ + " if (!value) return nullptr;", + f" return {_to_name(value.element)}(*value, env, feature, budget, path, depth + 1);", + ]) + elif kind is SemanticTypeKind.ENUM_REF: + assert value.type_id is not None + cpp_enum = next(item for item in self.cpp.enums if item.named_type.type_id == value.type_id) + jvm_enum = next(item for item in self.jvm.enums if item.named_type.type_id == value.type_id) + adapter = _adapter_class(jvm_adapter_identity(jvm_enum.named_type.source_declaration_id + "#enum")) + descriptor = f"([B)L{jvm_enum.named_type.owner_class.replace('.', '/')};" + lines.extend([" const char *name = nullptr;", " switch (value) {"]) + for constant in cpp_enum.constants: + lines.append(f" case {cpp_enum.named_type.cpp_type}::{constant}: name = {json.dumps(constant)}; break;") + lines.extend([ + " }", + " if (name == nullptr) throw std::runtime_error(\"unknown C++ enum value\");", + " const std::string text(name);", + f" auto route = {_route_expression('jvm-v3-cross-enum-from:' + value.type_id, adapter, descriptor, 'fromName')};", + " jvalue arguments[1]{};", + " arguments[0].l = write_byte_array(env, reinterpret_cast(text.data()), text.size());", + " auto result = env->CallStaticObjectMethodA(static_cast(route->adapter_class.get()), route->method, arguments);", + " require_no_implementation_exception(env);", + " if (result == nullptr) throw std::runtime_error(\"JVM enum adapter returned null\");", + " return result;", + ]) + elif kind is SemanticTypeKind.ARRAY: + assert value.element is not None + lines.extend([ + " budget.check_array_length(path, value.size());", + f" auto create_route = {self._helper_route('newList', '()Ljava/util/List;')};", + " auto list = env->CallStaticObjectMethod(static_cast(create_route->adapter_class.get()), create_route->method);", + " require_no_implementation_exception(env);", + " if (list == nullptr) throw std::runtime_error(\"JVM list adapter returned null\");", + f" auto add_route = {self._helper_route('listAdd', '(Ljava/util/List;Ljava/lang/Object;)V')};", + " for (std::size_t index = 0; index < value.size(); ++index) {", + " auto item_path = supernote::conversion::index_path(path, index);", + f" auto item = {_to_name(value.element)}(value[index], env, feature, budget, item_path, depth + 1);", + " jvalue arguments[2]{}; arguments[0].l = list; arguments[1].l = item;", + " env->CallStaticVoidMethodA(static_cast(add_route->adapter_class.get()), add_route->method, arguments);", + " require_no_implementation_exception(env);", + " if (item != nullptr) env->DeleteLocalRef(item);", + " }", + " return list;", + ]) + elif kind is SemanticTypeKind.VALUE_REF: + assert value.type_id is not None + cpp_value = next(item for item in self.cpp.values if item.named_type.type_id == value.type_id) + jvm_value = next(item for item in self.jvm.values if item.named_type.type_id == value.type_id) + lines.extend([ + f" auto route = {_route_expression('jvm-v3-cross-value:' + value.type_id, _adapter_class(jvm_value.constructor.adapter_identity), jvm_value.constructor.adapter_descriptor, 'invoke')};", + f" jvalue arguments[{max(1, len(jvm_value.constructor_fields) + 1)}]{{}};", + " auto runtime_session = feature->runtime();", + " auto context = runtime_session ? runtime_session->platform_context() : nullptr;", + " if (!context) throw std::runtime_error(\"platform Context is unavailable\");", + " arguments[0].l = static_cast(context.get());", + ]) + cpp_fields = {item.public_name: item for item in cpp_value.fields} + for index, field in enumerate(jvm_value.constructor_fields, 1): + cpp_field = cpp_fields[field.public_name] + expression = f"value.{cpp_field.cpp_name}" + if self._direct_primitive(field.semantic_type): + assert field.semantic_type.scalar is not None + jfield = _PRIMITIVE_FIELDS[field.semantic_type.scalar][0] + cast = expression + " ? JNI_TRUE : JNI_FALSE" if field.semantic_type.scalar is ScalarKind.BOOL else f"static_cast<{_PRIMITIVE_FIELDS[field.semantic_type.scalar][3]}>({expression})" + lines.append(f" arguments[{index}].{jfield} = {cast};") + else: + lines.extend([ + f" auto field_{index} = {_to_name(field.semantic_type)}({expression}, env, feature, budget, supernote::conversion::field_path(path, {json.dumps(field.public_name)}), depth + 1);", + f" arguments[{index}].l = field_{index};", + ]) + lines.extend([ + " auto result = env->CallStaticObjectMethodA(static_cast(route->adapter_class.get()), route->method, arguments);", + " require_no_implementation_exception(env);", + " if (result == nullptr) throw std::runtime_error(\"JVM value constructor returned null\");", + " return result;", + ]) + else: + raise CrossFamilyCodegenError("native objects cannot use copied converters") + lines.append("}") + return "\n".join(lines) + + def _render_from(self, value: SemanticType) -> str: + native = self.cpp_type(value) + lines = [ + f"{native} {_from_name(value)}(jobject value, JNIEnv *env,", + " const std::shared_ptr &feature,", + " supernote::conversion::Budget &budget, const std::string &path,", + " std::uint64_t depth) {", + " budget.visit(path, depth);", + ] + kind = value.kind + if kind is SemanticTypeKind.NULLABLE: + assert value.element is not None + lines.extend([ + " if (value == nullptr) return std::nullopt;", + f" return {_from_name(value.element)}(value, env, feature, budget, path, depth + 1);", + ]) + else: + lines.append(" if (value == nullptr) throw std::runtime_error(\"non-null JVM copied result was null\");") + if kind is SemanticTypeKind.SCALAR: + assert value.scalar is not None + if value.scalar in _PRIMITIVE_FIELDS: + method, descriptor, call = { + ScalarKind.BOOL: ("unboxBoolean", "(Ljava/lang/Object;)Z", "CallStaticBooleanMethodA"), + ScalarKind.INT32: ("unboxInt", "(Ljava/lang/Object;)I", "CallStaticIntMethodA"), + ScalarKind.INT64: ("unboxLong", "(Ljava/lang/Object;)J", "CallStaticLongMethodA"), + ScalarKind.FLOAT32: ("unboxFloat", "(Ljava/lang/Object;)F", "CallStaticFloatMethodA"), + ScalarKind.FLOAT64: ("unboxDouble", "(Ljava/lang/Object;)D", "CallStaticDoubleMethodA"), + }[value.scalar] + lines.extend([ + f" auto route = {self._helper_route(method, descriptor)};", + " jvalue arguments[1]{}; arguments[0].l = value;", + f" auto result = env->{call}(static_cast(route->adapter_class.get()), route->method, arguments);", + " require_no_implementation_exception(env);", + f" return {self._primitive_result(value, 'result')};", + ]) + else: + check = "check_string_bytes" if value.scalar is ScalarKind.STRING else "check_byte_buffer" + lines.extend([ + " auto bytes = read_byte_array(env, static_cast(value));", + f" budget.{check}(path, bytes.size());", + " budget.reserve(path, bytes.size());", + ]) + if value.scalar is ScalarKind.STRING: + lines.append(" return std::string(reinterpret_cast(bytes.data()), bytes.size());") + else: + lines.append(" return bytes;") + elif kind is SemanticTypeKind.ENUM_REF: + assert value.type_id is not None + cpp_enum = next(item for item in self.cpp.enums if item.named_type.type_id == value.type_id) + jvm_enum = next(item for item in self.jvm.enums if item.named_type.type_id == value.type_id) + adapter = _adapter_class(jvm_adapter_identity(jvm_enum.named_type.source_declaration_id + "#enum")) + enum_descriptor = ( + f"(L{jvm_enum.named_type.owner_class.replace('.', '/')};)[B" + ) + lines.extend([ + f" auto route = {_route_expression('jvm-v3-cross-enum-name:' + value.type_id, adapter, enum_descriptor, 'name')};", + " jvalue arguments[1]{}; arguments[0].l = value;", + " auto raw = env->CallStaticObjectMethodA(static_cast(route->adapter_class.get()), route->method, arguments);", + " require_no_implementation_exception(env);", + " auto bytes = read_byte_array(env, static_cast(raw));", + " std::string name(reinterpret_cast(bytes.data()), bytes.size());", + ]) + for constant in cpp_enum.constants: + lines.append(f" if (name == {json.dumps(constant)}) return {cpp_enum.named_type.cpp_type}::{constant};") + lines.append(" throw std::runtime_error(\"unknown JVM enum name\");") + elif kind is SemanticTypeKind.ARRAY: + assert value.element is not None + lines.extend([ + f" auto size_route = {self._helper_route('listSize', '(Ljava/util/List;)I')};", + " jvalue size_arguments[1]{}; size_arguments[0].l = value;", + " auto size = env->CallStaticIntMethodA(static_cast(size_route->adapter_class.get()), size_route->method, size_arguments);", + " require_no_implementation_exception(env);", + " if (size < 0) throw std::runtime_error(\"JVM list size was negative\");", + " budget.check_array_length(path, static_cast(size));", + f" {native} result; result.reserve(static_cast(size));", + f" auto get_route = {self._helper_route('listGet', '(Ljava/util/List;I)Ljava/lang/Object;')};", + " for (jint index = 0; index < size; ++index) {", + " jvalue arguments[2]{}; arguments[0].l = value; arguments[1].i = index;", + " auto item = env->CallStaticObjectMethodA(static_cast(get_route->adapter_class.get()), get_route->method, arguments);", + " require_no_implementation_exception(env);", + " auto item_path = supernote::conversion::index_path(path, static_cast(index));", + f" result.push_back({_from_name(value.element)}(item, env, feature, budget, item_path, depth + 1));", + " if (item != nullptr) env->DeleteLocalRef(item);", + " }", + " return result;", + ]) + elif kind is SemanticTypeKind.VALUE_REF: + assert value.type_id is not None + cpp_value = next(item for item in self.cpp.values if item.named_type.type_id == value.type_id) + jvm_value = next(item for item in self.jvm.values if item.named_type.type_id == value.type_id) + jvm_fields = {item.public_name: item for item in jvm_value.fields} + for index, cpp_field in enumerate(cpp_value.fields): + field = jvm_fields[cpp_field.public_name] + adapter = _adapter_class(field.accessor_identity) + lines.append(f" auto field_route_{index} = {_route_expression('jvm-v3-cross-field:' + field.field_id, adapter, field.getter_descriptor, 'get')};") + lines.append(f" jvalue field_arguments_{index}[1]{{}}; field_arguments_{index}[0].l = value;") + if self._direct_primitive(field.semantic_type): + assert field.semantic_type.scalar is not None + call = _PRIMITIVE_FIELDS[field.semantic_type.scalar][4] + lines.extend([ + f" auto field_raw_{index} = env->{call}(static_cast(field_route_{index}->adapter_class.get()), field_route_{index}->method, field_arguments_{index});", + " require_no_implementation_exception(env);", + f" auto field_{index} = {self._primitive_result(field.semantic_type, f'field_raw_{index}')};", + ]) + else: + lines.extend([ + f" auto field_raw_{index} = env->CallStaticObjectMethodA(static_cast(field_route_{index}->adapter_class.get()), field_route_{index}->method, field_arguments_{index});", + " require_no_implementation_exception(env);", + f" auto field_{index} = {_from_name(field.semantic_type)}(field_raw_{index}, env, feature, budget, supernote::conversion::field_path(path, {json.dumps(field.public_name)}), depth + 1);", + ]) + values = ", ".join(f"field_{index}" for index in range(len(cpp_value.fields))) + lines.append(f" return {cpp_value.named_type.cpp_type}{{{values}}};") + else: + raise CrossFamilyCodegenError("native objects cannot use copied converters") + lines.append("}") + return "\n".join(lines) + + +def build_cross_family_renderer( + module_root: Path, + api: SemanticApi, + manifest: JvmSourceManifest, + *, + feature_id: str, + module_name: str, +) -> CrossFamilyRenderer: + """Build and validate the shared internal copied-route renderer.""" + + functions = scan_cpp_source_model(module_root, module_name=module_name) + classes = scan_cpp_class_source_model(module_root, module_name=module_name) + enums = scan_cpp_enum_source_model(module_root, module_name=module_name) + return CrossFamilyRenderer( + api, + plan_cpp_routes(api, functions, classes, enums), + plan_jvm_routes(api, manifest.owners), + feature_id, + ) + + +__all__ = [ + "CrossFamilyCodegenError", + "CrossFamilyRenderer", + "build_cross_family_renderer", +] diff --git a/src/supernote_module_generator/feature_cli_operations.py b/src/supernote_module_generator/feature_cli_operations.py index fa51c03..f8b1bef 100644 --- a/src/supernote_module_generator/feature_cli_operations.py +++ b/src/supernote_module_generator/feature_cli_operations.py @@ -1,4 +1,4 @@ -"""Transactional public CLI operations for V2 logical features.""" +"""Transactional public CLI operations for V3 logical features.""" from __future__ import annotations from dataclasses import replace @@ -11,7 +11,11 @@ from .errors import ConfigurationError, GeneratorError, SubprocessFailure from .feature_generator import FeatureConfig -from .feature_operations import FeatureOperationService, FeatureRecord +from .feature_operations import ( + LEGACY_RUNTIME_RELATIVE_ROOT, + FeatureOperationService, + FeatureRecord, +) from .feature_workflows import ( FeatureAddDecisions, FeatureRemoveDecisions, @@ -341,6 +345,7 @@ def _snapshot_operation( *parent_mutation_targets(self.root), *integration_mutation_files(self.root), self.root / RUNTIME_RELATIVE_ROOT, + self.root / LEGACY_RUNTIME_RELATIVE_ROOT, *feature_paths, ] transaction.snapshot(paths) @@ -480,7 +485,7 @@ def _build(self) -> None: if not success: assert error is not None raise SubprocessFailure( - "Gradle could not build the V2 plugin runtime.", + "Gradle could not build the V3 plugin runtime.", kind="build_failed", phase="build", subprocess=error.to_dict(), diff --git a/src/supernote_module_generator/feature_generator.py b/src/supernote_module_generator/feature_generator.py index 3978362..dc6ef0b 100644 --- a/src/supernote_module_generator/feature_generator.py +++ b/src/supernote_module_generator/feature_generator.py @@ -1,4 +1,4 @@ -"""Transactional scaffolding for one language-neutral V2 logical feature.""" +"""Transactional scaffolding for one language-neutral V3 logical feature.""" from __future__ import annotations from dataclasses import dataclass @@ -66,10 +66,12 @@ def stage_feature( temporary, relative, "#include \n\n" + f"namespace supernote_feature_{config.public_name} {{\n\n" "// @SupernotePluginExport\n" "std::string greet(std::string name) {\n" ' return "Hello, " + name;\n' - "}\n", + "}\n\n" + f"}} // namespace supernote_feature_{config.public_name}\n", ) namespace_path = config.android_namespace.replace(".", "/") jvm_starter = f"android/src/main/java/{namespace_path}/FeatureApi.kt" @@ -116,7 +118,7 @@ def stage_feature( "package.json", json.dumps(package, indent=2, ensure_ascii=False) + "\n", ) - global_name = "__supernoteV2" + global_name = "__supernoteV3" _write( temporary, "index.js", @@ -129,7 +131,67 @@ def stage_feature( " }\n" "}\n\n" "const ERROR_CONSTRUCTOR_PROPERTY = '__supernoteErrorConstructor';\n" - f"const INSTALL_ERROR = {_javascript_string(config.public_name + ' is not installed in the Supernote V2 runtime')};\n\n" + "const CPP_OBJECT_INFO_PROPERTY = '__supernoteCppObjectInfo';\n" + "const JVM_OBJECT_INFO_PROPERTY = '__supernoteJvmObjectInfo';\n" + f"const INSTALL_ERROR = {_javascript_string(config.public_name + ' is not installed in the Supernote V3 runtime')};\n\n" + "const VALIDATION_REASONS = new Set([\n" + " 'ARITY_MISMATCH',\n" + " 'TYPE_MISMATCH',\n" + " 'NOMINAL_MISMATCH',\n" + " 'MISSING_FIELD',\n" + " 'INVALID_ENUM',\n" + " 'OUT_OF_RANGE',\n" + " 'LIMIT_EXCEEDED',\n" + "]);\n\n" + "function currentFeature() {\n" + " const runtime = globalThis." + + global_name + + ";\n" + " if (!runtime || typeof runtime.feature !== 'function') {\n" + " return {status: 'runtime-unavailable'};\n" + " }\n" + " try {\n" + f" const value = runtime.feature({_javascript_string(feature.feature_id)});\n" + " if (!value || (typeof value !== 'object' && typeof value !== 'function')) {\n" + " return {status: 'feature-unavailable'};\n" + " }\n" + " return {status: 'available', value};\n" + " } catch (_error) {\n" + " return {status: 'feature-unavailable'};\n" + " }\n" + "}\n\n" + "export function getFeatureStatus() {\n" + " return currentFeature().status;\n" + "}\n\n" + "export function isFeatureAvailable() {\n" + " return getFeatureStatus() === 'available';\n" + "}\n\n" + "export function nativeObjectInfo(value) {\n" + " const current = currentFeature();\n" + " if (current.status !== 'available') return undefined;\n" + " for (const property of [CPP_OBJECT_INFO_PROPERTY, JVM_OBJECT_INFO_PROPERTY]) {\n" + " const inspect = current.value[property];\n" + " if (typeof inspect !== 'function') continue;\n" + " const info = inspect(value);\n" + " if (info !== undefined) return info;\n" + " }\n" + " return undefined;\n" + "}\n\n" + "function hasValidationDetails(value) {\n" + " return Boolean(\n" + " value &&\n" + " VALIDATION_REASONS.has(value.reason) &&\n" + " typeof value.path === 'string' &&\n" + " typeof value.expected === 'string' &&\n" + " typeof value.actual === 'string',\n" + " );\n" + "}\n\n" + "export function isSupernoteTypeError(value) {\n" + " return value instanceof TypeError && hasValidationDetails(value);\n" + "}\n\n" + "export function isSupernoteRangeError(value) {\n" + " return value instanceof RangeError && hasValidationDetails(value);\n" + "}\n\n" "function requireFeature() {\n" " const runtime = globalThis." + global_name @@ -138,6 +200,9 @@ def stage_feature( " throw new Error(INSTALL_ERROR);\n" " }\n" f" const value = runtime.feature({_javascript_string(feature.feature_id)});\n" + " if (!value || (typeof value !== 'object' && typeof value !== 'function')) {\n" + " throw new Error(INSTALL_ERROR);\n" + " }\n" " if (value[ERROR_CONSTRUCTOR_PROPERTY] !== SupernoteError) {\n" " Object.defineProperty(value, ERROR_CONSTRUCTOR_PROPERTY, {\n" " configurable: true,\n" @@ -152,24 +217,32 @@ def stage_feature( " {},\n" " {\n" " get(_target, property) {\n" - " if (property === ERROR_CONSTRUCTOR_PROPERTY) {\n" + " if (property === ERROR_CONSTRUCTOR_PROPERTY ||\n" + " property === CPP_OBJECT_INFO_PROPERTY ||\n" + " property === JVM_OBJECT_INFO_PROPERTY) {\n" " return undefined;\n" " }\n" " return requireFeature()[property];\n" " },\n" " has(_target, property) {\n" - " if (property === ERROR_CONSTRUCTOR_PROPERTY) {\n" + " if (property === ERROR_CONSTRUCTOR_PROPERTY ||\n" + " property === CPP_OBJECT_INFO_PROPERTY ||\n" + " property === JVM_OBJECT_INFO_PROPERTY) {\n" " return false;\n" " }\n" " return property in requireFeature();\n" " },\n" " ownKeys() {\n" " return Reflect.ownKeys(requireFeature()).filter(\n" - " property => property !== ERROR_CONSTRUCTOR_PROPERTY,\n" + " property => property !== ERROR_CONSTRUCTOR_PROPERTY &&\n" + " property !== CPP_OBJECT_INFO_PROPERTY &&\n" + " property !== JVM_OBJECT_INFO_PROPERTY,\n" " );\n" " },\n" " getOwnPropertyDescriptor(_target, property) {\n" - " if (property === ERROR_CONSTRUCTOR_PROPERTY) {\n" + " if (property === ERROR_CONSTRUCTOR_PROPERTY ||\n" + " property === CPP_OBJECT_INFO_PROPERTY ||\n" + " property === JVM_OBJECT_INFO_PROPERTY) {\n" " return undefined;\n" " }\n" " const descriptor = Object.getOwnPropertyDescriptor(\n" @@ -186,6 +259,12 @@ def stage_feature( temporary, "index.d.ts", "/* Generated by supernote_module_generator. Do not edit. */\n" + "export type SupernoteFeatureStatus = 'available' | 'runtime-unavailable' | 'feature-unavailable';\n" + "export function isFeatureAvailable(): boolean;\n" + "export function getFeatureStatus(): SupernoteFeatureStatus;\n" + "export function nativeObjectInfo(value: unknown): {readonly type: string; readonly originFamily: 'cpp' | 'jvm'} | undefined;\n" + "export function isSupernoteTypeError(value: unknown): value is TypeError;\n" + "export function isSupernoteRangeError(value: unknown): value is RangeError;\n" f"export interface {config.public_name}Feature {{}}\n" f"declare const feature: {config.public_name}Feature;\n" "export default feature;\n", @@ -222,7 +301,7 @@ def stage_feature( def _feature_readme(config: FeatureConfig) -> str: return f"""# {config.npm_name} -Generated Supernote V2 feature package. Its logical feature is language-neutral: +Generated Supernote V3 feature package. Its logical feature is language-neutral: C/C++ and Kotlin/Java source may coexist under `android/src/main/`. Only declarations with explicit Supernote markers enter generated APIs. Ordinary @@ -232,6 +311,24 @@ def _feature_readme(config: FeatureConfig) -> str: is source-language-specific (`// @SupernotePluginExport` in C++ and `@SupernotePluginExport` on Kotlin/Java declarations). +`SupernotePluginObject` declares reference identity and lifetime; +`SupernotePluginValue` declares a validated copied plain-object schema. Neither exposes +members or construction automatically. Mark each public method/field/factory explicitly +and use `SupernoteConstructor` only when JavaScript construction is wanted. +Returned-only objects are supported. Native-object fields are live; value fields, arrays, and nullable +compositions are copied/validated through their declared types. + +Current object routes stay within one implementation family: C++ objects go to C++ and +Kotlin/Java objects stay in the JVM family. Copied declared values may cross generated +C++/JVM internal routes. Cross-family native-object proxies, arbitrary JavaScript +objects/JSON trees, callbacks, maps, and untyped arrays are not supported and fail during +generation instead of changing the public JavaScript shape. + +Accepted async work retains every native receiver/object argument and copied input until +physical access ends. Failures use `TypeError`, `RangeError`, or the generated structured +`SupernoteError` contract. Generated code does not serialize calls to user objects; their +implementation remains responsible for thread safety. + The generated TypeScript API is `index.d.ts`. Run the plugin's Android/Gradle generation after changing marked declarations. `supernote-module update {config.npm_name}` refreshes generator-owned files while preserving user-owned diff --git a/src/supernote_module_generator/feature_model.py b/src/supernote_module_generator/feature_model.py index e4170f8..08f833f 100644 --- a/src/supernote_module_generator/feature_model.py +++ b/src/supernote_module_generator/feature_model.py @@ -1,4 +1,4 @@ -"""Language-neutral feature ownership and plugin runtime registry for V2.""" +"""Language-neutral feature ownership and plugin runtime registry for V3.""" from __future__ import annotations from dataclasses import dataclass, field @@ -8,16 +8,23 @@ import re from typing import Dict, Iterable, Tuple -from .semantic import ExecutionMode, SemanticApi +from .semantic import ( + ExecutionMode, + SemanticApi, + SemanticObjectDeclaration, +) +from .v3_schemas import ( + FEATURE_MANIFEST_KIND, + FEATURE_MANIFEST_SCHEMA_VERSION, + PLUGIN_REGISTRY_KIND, + PLUGIN_REGISTRY_SCHEMA_VERSION, +) - -FEATURE_MANIFEST_SCHEMA_VERSION = 2 -PLUGIN_REGISTRY_SCHEMA_VERSION = 1 _IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") class FeatureModelError(ValueError): - """Raised when V2 ownership/build metadata violates its contract.""" + """Raised when V3 ownership/build metadata violates its contract.""" class StarterFamily(str, Enum): @@ -112,7 +119,7 @@ def create( def manifest(self) -> Dict[str, object]: return { "schema_version": self.schema_version, - "kind": "supernote_feature", + "kind": FEATURE_MANIFEST_KIND, "feature_id": self.feature_id, "npm_name": self.npm_name, "public_name": self.public_name, @@ -135,11 +142,19 @@ def from_semantic_api(cls, api: SemanticApi) -> "FeatureRequirements": bindings = list(api.functions) for semantic_class in api.classes: bindings.extend(semantic_class.methods) + for declaration in api.declarations: + if isinstance(declaration, SemanticObjectDeclaration): + bindings.extend(declaration.methods) languages = { source.language for source in ( [binding.source for binding in bindings] + [semantic_class.source for semantic_class in api.classes] + + [ + projection.source + for declaration in api.declarations + for projection in declaration.projections + ] ) } unknown = languages - {"cpp", "kotlin", "java"} @@ -158,6 +173,10 @@ def from_semantic_api(cls, api: SemanticApi) -> "FeatureRequirements": ) or any( semantic_class.capabilities.javascript_public for semantic_class in api.classes + ) or any( + isinstance(declaration, SemanticObjectDeclaration) + and declaration.constructor is not None + for declaration in api.declarations ) asynchronous = any( binding.execution is ExecutionMode.ASYNC for binding in bindings @@ -265,7 +284,7 @@ def create( def manifest(self) -> Dict[str, object]: return { "schema_version": self.schema_version, - "kind": "supernote_plugin_runtime_registry", + "kind": PLUGIN_REGISTRY_KIND, "plugin_id": self.plugin_id, "component_name": self.component_name, "generator_version": self.generator_version, diff --git a/src/supernote_module_generator/feature_operations.py b/src/supernote_module_generator/feature_operations.py index 32718e7..2118652 100644 --- a/src/supernote_module_generator/feature_operations.py +++ b/src/supernote_module_generator/feature_operations.py @@ -1,4 +1,4 @@ -"""Atomic V2 logical-feature and shared-runtime mutations.""" +"""Atomic V3 logical-feature and shared-runtime mutations.""" from __future__ import annotations import json @@ -12,6 +12,7 @@ from .errors import ConfigurationError, GeneratorError from .feature_generator import FeatureConfig, stage_feature from .feature_model import ( + FEATURE_MANIFEST_KIND, FeatureModelError, FeatureManifest, FeatureRegistryEntry, @@ -41,12 +42,15 @@ class FeatureMetadataError(GeneratorError): class FeatureSourceError(GeneratorError): - """A marked user declaration cannot be represented by V2 bindings.""" + """A marked user declaration cannot be represented by V3 bindings.""" kind = "invalid_source" phase = "preflight" +LEGACY_RUNTIME_RELATIVE_ROOT = Path("android/.supernote-module/v2-runtime") + + @dataclass(frozen=True) class FeatureRecord: path: Path @@ -77,7 +81,10 @@ def __init__(self, plugin_root: Path) -> None: def add(self, config: FeatureConfig) -> Path: had_features = bool(self.feature_paths()) verify_runtime_wiring( - self.root, enabled=had_features, allow_missing_package=True + self.root, + enabled=had_features, + allow_missing_package=True, + allow_legacy_v2=True, ) destination = config.output.resolve() if destination.exists(): @@ -89,6 +96,8 @@ def add(self, config: FeatureConfig) -> Path: feature_activated = False runtime_activated = False integration_mutated = False + legacy_runtime_backup = None + legacy_runtime_deactivated = False try: future = self._entries(extra=(staged_feature,), excluding=()) staged_runtime = stage_plugin_runtime(self.root, self._registry(future)) @@ -98,11 +107,16 @@ def add(self, config: FeatureConfig) -> Path: staged_runtime, self.root / RUNTIME_RELATIVE_ROOT ) runtime_activated = True + legacy_runtime_backup = self._deactivate( + self.root / LEGACY_RUNTIME_RELATIVE_ROOT + ) + legacy_runtime_deactivated = True set_runtime_wiring(self.root, enabled=True) integration_mutated = True verify_runtime_wiring(self.root, enabled=True) self._finalize(feature_backup) self._finalize(runtime_backup) + self._finalize(legacy_runtime_backup) return destination except BaseException: if feature_activated: @@ -111,6 +125,11 @@ def add(self, config: FeatureConfig) -> Path: self._restore(self.root / RUNTIME_RELATIVE_ROOT, runtime_backup) if integration_mutated: set_runtime_wiring(self.root, enabled=had_features) + if legacy_runtime_deactivated: + self._restore( + self.root / LEGACY_RUNTIME_RELATIVE_ROOT, + legacy_runtime_backup, + ) shutil.rmtree(staged_feature, ignore_errors=True) if staged_runtime is not None: shutil.rmtree(staged_runtime, ignore_errors=True) @@ -119,7 +138,10 @@ def add(self, config: FeatureConfig) -> Path: def update(self, npm_name: str) -> Path: had_features = bool(self.feature_paths()) verify_runtime_wiring( - self.root, enabled=had_features, allow_missing_package=True + self.root, + enabled=had_features, + allow_missing_package=True, + allow_legacy_v2=True, ) current = self.find(npm_name) metadata = read_feature_manifest(current) @@ -141,6 +163,8 @@ def update(self, npm_name: str) -> Path: feature_activated = False runtime_activated = False integration_mutated = False + legacy_runtime_backup = None + legacy_runtime_deactivated = False try: future = self._entries(extra=(staged_feature,), excluding=(current,)) staged_runtime = stage_plugin_runtime(self.root, self._registry(future)) @@ -150,11 +174,16 @@ def update(self, npm_name: str) -> Path: staged_runtime, self.root / RUNTIME_RELATIVE_ROOT ) runtime_activated = True + legacy_runtime_backup = self._deactivate( + self.root / LEGACY_RUNTIME_RELATIVE_ROOT + ) + legacy_runtime_deactivated = True set_runtime_wiring(self.root, enabled=True) integration_mutated = True verify_runtime_wiring(self.root, enabled=True) self._finalize(feature_backup) self._finalize(runtime_backup) + self._finalize(legacy_runtime_backup) return current except BaseException: if feature_activated: @@ -163,6 +192,11 @@ def update(self, npm_name: str) -> Path: self._restore(self.root / RUNTIME_RELATIVE_ROOT, runtime_backup) if integration_mutated: set_runtime_wiring(self.root, enabled=had_features) + if legacy_runtime_deactivated: + self._restore( + self.root / LEGACY_RUNTIME_RELATIVE_ROOT, + legacy_runtime_backup, + ) shutil.rmtree(staged_feature, ignore_errors=True) if staged_runtime is not None: shutil.rmtree(staged_runtime, ignore_errors=True) @@ -171,13 +205,18 @@ def update(self, npm_name: str) -> Path: def remove(self, npm_name: str) -> None: had_features = bool(self.feature_paths()) verify_runtime_wiring( - self.root, enabled=had_features, allow_missing_package=True + self.root, + enabled=had_features, + allow_missing_package=True, + allow_legacy_v2=True, ) current = self.find(npm_name) staged_runtime = None runtime_backup = None runtime_activated = False integration_mutated = False + legacy_runtime_backup = None + legacy_runtime_deactivated = False feature_backup = current.parent / f".{current.name}.removed-{uuid.uuid4().hex}" try: future = self._entries(extra=(), excluding=(current,)) @@ -195,11 +234,16 @@ def remove(self, npm_name: str) -> None: self.root / RUNTIME_RELATIVE_ROOT ) runtime_activated = True + legacy_runtime_backup = self._deactivate( + self.root / LEGACY_RUNTIME_RELATIVE_ROOT + ) + legacy_runtime_deactivated = True set_runtime_wiring(self.root, enabled=bool(future)) integration_mutated = True verify_runtime_wiring(self.root, enabled=bool(future)) shutil.rmtree(feature_backup) self._finalize(runtime_backup) + self._finalize(legacy_runtime_backup) except BaseException: if feature_backup.exists() and not current.exists(): os.replace(feature_backup, current) @@ -207,6 +251,11 @@ def remove(self, npm_name: str) -> None: self._restore(self.root / RUNTIME_RELATIVE_ROOT, runtime_backup) if integration_mutated: set_runtime_wiring(self.root, enabled=had_features) + if legacy_runtime_deactivated: + self._restore( + self.root / LEGACY_RUNTIME_RELATIVE_ROOT, + legacy_runtime_backup, + ) if staged_runtime is not None: shutil.rmtree(staged_runtime, ignore_errors=True) raise @@ -304,10 +353,13 @@ def verify_generated_state(self) -> list[str]: verify_runtime_wiring(self.root, enabled=bool(records)) except Exception as exc: issues.append(str(exc)) + legacy_runtime = self.root / LEGACY_RUNTIME_RELATIVE_ROOT + if legacy_runtime.exists(): + issues.append(f"stale generated V2 runtime exists: {legacy_runtime}") runtime = self.root / RUNTIME_RELATIVE_ROOT if not records: if runtime.exists(): - issues.append("shared V2 runtime exists without any features") + issues.append("shared V3 runtime exists without any features") return issues try: expected = generated_runtime_files(self.expected_registry()) @@ -476,10 +528,10 @@ def _read_feature_metadata(metadata: Path) -> dict[str, object]: if not isinstance(value, dict): raise _invalid_feature_metadata(metadata, "top-level value must be an object") kind = value.get("kind") - if kind != "supernote_feature": + if kind != FEATURE_MANIFEST_KIND: raise _invalid_feature_metadata( metadata, - f"kind must be 'supernote_feature', got {kind!r}", + f"kind must be {FEATURE_MANIFEST_KIND!r}, got {kind!r}", ) return value diff --git a/src/supernote_module_generator/feature_workflows.py b/src/supernote_module_generator/feature_workflows.py index 8f209e8..b9de0a9 100644 --- a/src/supernote_module_generator/feature_workflows.py +++ b/src/supernote_module_generator/feature_workflows.py @@ -1,4 +1,4 @@ -"""Public V2 CLI decisions for language-neutral logical features.""" +"""Public V3 CLI decisions for language-neutral logical features.""" from __future__ import annotations from dataclasses import dataclass @@ -428,7 +428,7 @@ def doctor_scope(self) -> str: if self.interactive: assert self.ui is not None self.ui.header("Doctor") - self.ui.info("Checking the tool requirements for this V2 plugin.", dim=True) + self.ui.info("Checking the tool requirements for this V3 plugin.", dim=True) return "plugin" def _choose_one(self, heading: str, records: list[FeatureRecord]) -> FeatureRecord: diff --git a/src/supernote_module_generator/generator.py b/src/supernote_module_generator/generator.py index d9abed0..227d784 100644 --- a/src/supernote_module_generator/generator.py +++ b/src/supernote_module_generator/generator.py @@ -24,9 +24,14 @@ CODEGEN_SUPPORT_MODULES = ( + "conversion.py", + "v3_schemas.py", "semantic.py", + "semantic_types.py", "source_models.py", "cpp_projection.py", + "cpp_routes.py", + "cpp_object_binding_codegen.py", "lowering.py", ) diff --git a/src/supernote_module_generator/helptext.py b/src/supernote_module_generator/helptext.py index 2c9d1fe..0322f5a 100644 --- a/src/supernote_module_generator/helptext.py +++ b/src/supernote_module_generator/helptext.py @@ -6,7 +6,7 @@ ROOT_HELP = """Supernote Module Generator -Generate and manage language-neutral V2 features in an existing Supernote plugin. +Generate and manage language-neutral V3 features in an existing Supernote plugin. Usage: supernote-module @@ -265,7 +265,7 @@ DOCTOR_HELP = """Supernote Module Generator -Verify the development environment required by this V2 plugin. +Verify the development environment required by this V3 plugin. Usage: supernote-module doctor [options] @@ -284,7 +284,7 @@ Behavior: Doctor checks JavaScript, Kotlin/KSP, Gradle, Java 17 through 23 (Java 17 is recommended), Android SDK/NDK tools, NDK Clang with C23/C++23, CMake, and JSI - requirements used by the plugin-level V2 runtime. It also reports the target- + requirements used by the plugin-level V3 runtime. It also reports the target- device runtime boundary that cannot be proven locally. Examples: diff --git a/src/supernote_module_generator/integration.py b/src/supernote_module_generator/integration.py index 4a66971..ebbc070 100644 --- a/src/supernote_module_generator/integration.py +++ b/src/supernote_module_generator/integration.py @@ -14,6 +14,7 @@ from .config import METADATA_FILES, gradle_project_name, normalize_backend from .errors import ConfigurationError, FilesystemError, GeneratorError from .platform_tools import host_command +from .project import resolve_plugin_root from .subprocesses import run_process LOCAL_MODULES_DIR = "local_modules" @@ -21,12 +22,7 @@ def plugin_root(path: Path) -> Path: - root = path.expanduser().resolve() - if not (root / "PluginConfig.json").is_file() or not (root / "package.json").is_file(): - raise ConfigurationError("Run this command from a Supernote plugin root (PluginConfig.json and package.json are required)") - if not (root / "android").is_dir(): - raise ConfigurationError("Supernote plugin is missing its android directory") - return root + return resolve_plugin_root(path) def settings_file(root: Path) -> Path: diff --git a/src/supernote_module_generator/internal_codegen.py b/src/supernote_module_generator/internal_codegen.py index 05556cc..cd9f3e1 100644 --- a/src/supernote_module_generator/internal_codegen.py +++ b/src/supernote_module_generator/internal_codegen.py @@ -1,9 +1,10 @@ -"""Generate typed handwritten-C++ facades for hidden V2 bindings.""" +"""Generate typed handwritten-C++ facades for hidden V3 bindings.""" from __future__ import annotations import json import re from pathlib import Path +from typing import TYPE_CHECKING from .binding_codegen import ( scan_cpp_class_source_model, @@ -21,6 +22,9 @@ ) from .source_models import CppClassSource, CppFunctionSource, CppMethodSource +if TYPE_CHECKING: + from .cross_family_codegen import CrossFamilyRenderer + def internal_header_path(feature_id: str) -> str: return f"include/supernote/{_suffix(feature_id)}/internal.hpp" @@ -33,6 +37,8 @@ def render_cpp_internal_facade( feature_id: str, jvm_manifest: JvmSourceManifest | None = None, jvm_semantic: SemanticApi | None = None, + cross_family: "CrossFamilyRenderer | None" = None, + include_prefix: str | None = None, ) -> tuple[str, str]: native_root = module_root / "android/src/main/cpp" functions = ( @@ -52,7 +58,11 @@ def render_cpp_internal_facade( for item in scan_cpp_class_source_model( module_root, module_name=module_name ) - if item.intent.role is DeclarationRole.INTERNAL + if any( + method.intent.role is DeclarationRole.INTERNAL + and not method.static + for method in item.methods + ) ] if native_root.is_dir() else [] @@ -81,9 +91,25 @@ def render_cpp_internal_facade( declarations = [_function_declaration(item) for item in functions] declarations.extend(_service_declaration(item) for item in services) declarations.extend( - _jvm_function_declaration(binding) for binding, _ in jvm_functions + _jvm_function_declaration(binding, cross_family) + for binding, _ in jvm_functions + ) + declarations.extend( + _jvm_service_declaration(item, cross_family) for item in jvm_services + ) + def include_path(value: str) -> str: + if include_prefix is None: + return value + return f"{include_prefix.rstrip('/')}/{value}" + + copied_includes = ( + "\n".join( + f'#include "{include_path(value)}"' + for value in cross_family.includes() + ) + if cross_family is not None + else "" ) - declarations.extend(_jvm_service_declaration(item) for item in jvm_services) header = f'''// Generated by supernote_module_generator. Do not edit. #pragma once @@ -94,6 +120,7 @@ def render_cpp_internal_facade( #include #include +{copied_includes} namespace supernote::internal::{namespace} {{ @@ -103,7 +130,7 @@ def render_cpp_internal_facade( ''' forward = [_function_forward(item) for item in functions] includes = "\n".join( - f'#include "{item.include}"' for item in services + f'#include "{include_path(item.include)}"' for item in services ) definitions = [ _function_definition(item, namespace) for item in functions @@ -122,6 +149,7 @@ def render_cpp_internal_facade( #include #include #include +#include #include {chr(10).join(forward)} @@ -129,7 +157,7 @@ def render_cpp_internal_facade( namespace supernote::internal::{namespace} {{ namespace {{ -constexpr char kInternalLogTag[] = "SupernoteV2Internal"; +constexpr char kInternalLogTag[] = "SupernoteV3Internal"; std::shared_ptr require_feature() {{ auto feature = supernote::runtime::current_feature_session(); @@ -317,6 +345,14 @@ def _async_definition( f"outcome = supernote::Result<{result}>::success({call});" ) result_type = f"supernote::Result<{result}>" + retained_types = ", ".join(item.type_spelling for item in parameters) + retained_values = ", ".join(item.name for item in parameters) + retained_state = ( + " auto retained_input_state = std::make_shared>({retained_values});\n" + if retained_types + else " auto retained_input_state = std::make_shared>();\n" + ) return f'''{signature} {{ auto feature = require_feature(); if (!completion) {{ @@ -325,11 +361,13 @@ def _async_definition( }} auto callback = std::make_shared( std::move(completion)); +{retained_state.rstrip()} auto operation = feature->accept({{}}, std::move(callback)); if (!operation) {{ throw supernote::Error( supernote::ErrorCode::FEATURE_CLOSED, "feature is closed"); }} + operation->set_retained_state(retained_input_state); std::weak_ptr weak_feature = feature; auto work = supernote::runtime::process_services().workers().submit( [operation, weak_feature{capture_suffix}]( @@ -415,50 +453,63 @@ def internal_namespace(module_name: str) -> str: return value if value and not value[0].isdigit() else f"Feature_{value}" -def _jvm_function_declaration(item: SemanticBinding) -> str: - parameters = _semantic_parameters(item) +def _jvm_function_declaration( + item: SemanticBinding, cross_family: "CrossFamilyRenderer | None" = None +) -> str: + parameters = _semantic_parameters(item, cross_family) if item.execution is ExecutionMode.ASYNC: parameters = _append_parameter( - parameters, f"{_semantic_callback(item.result)} completion" + parameters, f"{_semantic_callback(item.result, cross_family)} completion" ) return f"void {item.name}({parameters});" - return f"{_semantic_cpp_type(item.result)} {item.name}({parameters});" + return f"{_semantic_cpp_type(item.result, cross_family)} {item.name}({parameters});" -def _jvm_service_declaration(item: SemanticClass) -> str: +def _jvm_service_declaration( + item: SemanticClass, cross_family: "CrossFamilyRenderer | None" = None +) -> str: rows = [] for method in item.methods: - parameters = _semantic_parameters(method) + parameters = _semantic_parameters(method, cross_family) if method.execution is ExecutionMode.ASYNC: parameters = _append_parameter( - parameters, f"{_semantic_callback(method.result)} completion" + parameters, + f"{_semantic_callback(method.result, cross_family)} completion", ) rows.append(f" static void {method.name}({parameters});") else: rows.append( - f" static {_semantic_cpp_type(method.result)} " + f" static {_semantic_cpp_type(method.result, cross_family)} " f"{method.name}({parameters});" ) return f"struct {item.name} final {{\n" + "\n".join(rows) + "\n};" -def _semantic_parameters(item: SemanticBinding) -> str: +def _semantic_parameters( + item: SemanticBinding, cross_family: "CrossFamilyRenderer | None" = None +) -> str: return ", ".join( - f"{_semantic_cpp_type(parameter.type)} {parameter.name}" + f"{_semantic_cpp_type(parameter.type, cross_family)} {parameter.name}" for parameter in item.parameters ) -def _semantic_callback(result: SemanticType) -> str: +def _semantic_callback( + result: SemanticType, cross_family: "CrossFamilyRenderer | None" = None +) -> str: return ( "std::function)>" + f"{_semantic_cpp_type(result, cross_family)}>)>" if result is not SemanticType.VOID else "std::function)>" ) -def _semantic_cpp_type(value: SemanticType) -> str: +def _semantic_cpp_type( + value: SemanticType, cross_family: "CrossFamilyRenderer | None" = None +) -> str: + if cross_family is not None: + return cross_family.cpp_type(value) return { SemanticType.VOID: "void", SemanticType.BOOL: "bool", diff --git a/src/supernote_module_generator/jvm_codegen.py b/src/supernote_module_generator/jvm_codegen.py index 6977105..0efa9ab 100644 --- a/src/supernote_module_generator/jvm_codegen.py +++ b/src/supernote_module_generator/jvm_codegen.py @@ -2,7 +2,7 @@ from __future__ import annotations import json -from typing import Iterable +from typing import TYPE_CHECKING, Iterable from .binding_codegen import ( Parameter, @@ -16,6 +16,9 @@ _jsi_value_helpers, ) from .jvm_manifest import JvmSourceManifest +from .jvm_object_binding_codegen import render_jvm_object_bindings +from .jvm_object_runtime_codegen import render_jvm_object_runtime +from .jvm_routes import JvmRouteError, plan_jvm_routes from .internal_codegen import internal_header_path, internal_namespace from .semantic import ( DeclarationRole, @@ -26,6 +29,7 @@ SemanticClassKind, SemanticType, ) +from .semantic_types import SemanticTypeKind from .source_models import ( JvmConstructorSource, JvmDeclarationSource, @@ -33,6 +37,9 @@ JvmOwnerSource, ) +if TYPE_CHECKING: + from .cross_family_codegen import CrossFamilyRenderer + class JvmCodegenError(ValueError): pass @@ -74,6 +81,8 @@ def render_jvm_feature_jsi( *, feature_id: str, module_name: str, + conversion_digest: str | None = None, + cross_family: "CrossFamilyRenderer | None" = None, ) -> str: if manifest.feature_id != feature_id: raise JvmCodegenError("JVM manifest and feature identity disagree") @@ -101,6 +110,7 @@ def render_jvm_feature_jsi( module_name=module_name, feature_suffix=suffix, helper_prefix=f"internal_service_{index}", + cross_family=cross_family, ) internal_helpers.extend(helpers) internal_facades.extend(facades) @@ -108,8 +118,31 @@ def render_jvm_feature_jsi( wrapper, registration = _render_object(owner, item, index, module_name) object_wrappers.append(wrapper) object_registrations.append(registration) + try: + v3_jvm_routes = plan_jvm_routes(semantic, manifest.owners) + v3_wrappers, v3_registrations = render_jvm_object_bindings( + v3_jvm_routes, + feature_id=feature_id, + module_name=module_name, + ) + except JvmRouteError as exc: + raise JvmCodegenError(str(exc)) from exc + object_wrappers.extend(v3_wrappers) + object_registrations.extend(v3_registrations) + has_v3_jvm_objects = bool(v3_jvm_routes.objects) or any( + item.kind.value not in {"void", "scalar"} + for route in v3_jvm_routes.functions + for item in (*route.parameters, route.result) + ) registrations: list[str] = [] - has_async = False + has_async = any( + route.execution is ExecutionMode.ASYNC + for route in v3_jvm_routes.functions + ) or any( + route.execution is ExecutionMode.ASYNC + for item in v3_jvm_routes.objects + for route in item.methods + ) for owner in manifest.owners: if owner.intent.role is not DeclarationRole.ORDINARY: continue @@ -125,10 +158,22 @@ def render_jvm_feature_jsi( module_name=module_name, feature_suffix=suffix, helper_name=f"internal_function_{len(internal_helpers)}", + cross_family=cross_family, ) internal_helpers.append(helper) internal_facades.append(facade) continue + if any( + item.kind.value not in {"void", "scalar"} + for item in ( + *(parameter.type for parameter in binding.parameters), + binding.result, + ) + ): + # Recursive V3 JVM routes own object/value/enum/array/nullable + # conversion. The retained scalar renderer must not guess + # descriptors for those types. + continue if binding.execution is ExecutionMode.ASYNC: if declaration.is_suspend: registrations.append( @@ -163,7 +208,30 @@ def render_jvm_feature_jsi( ) if has_async: helpers += "\n\n" + _jsi_async_helpers() - return f'''#include + if conversion_digest is not None and ( + len(conversion_digest) != 64 + or any(value not in "0123456789abcdef" for value in conversion_digest) + ): + raise JvmCodegenError("invalid V3 conversion-plan digest") + digest_comment = ( + "" + if conversion_digest is None + else ( + f"// Supernote V3 conversion plan SHA-256: {conversion_digest}\n" + "#include \n" + ) + ) + object_runtime = render_jvm_object_runtime() + v3_registry_setup = ( + " auto object_registry = std::make_shared();\n" + " exports.setProperty(\n" + " runtime, kJvmObjectRegistryProperty,\n" + " Object::createFromHostObject(\n" + " runtime, std::make_shared(object_registry)));\n" + if has_v3_jvm_objects + else "" + ) + return digest_comment + f'''#include #include #include @@ -173,12 +241,17 @@ def render_jvm_feature_jsi( #include #include #include +#include +#include #include #include #include #include #include #include +#include +#include +#include #include #include @@ -188,10 +261,12 @@ def render_jvm_feature_jsi( namespace supernote::generated::jvm_feature_{suffix} {{ namespace {{ -constexpr char kLogTag[] = "SupernoteV2Jvm"; +constexpr char kLogTag[] = "SupernoteV3Jvm"; constexpr char kFeatureRegistryGlobal[] = - "__supernoteV2FeatureRegistry_63f6999c8c67"; + "__supernoteV3FeatureRegistry_63f6999c8c67"; constexpr char kFeatureId[] = {json.dumps(feature_id)}; +constexpr char kJvmObjectRegistryProperty[] = + "__supernoteV3JvmObjectRegistry_2cfbc9ce6375"; {helpers} @@ -332,6 +407,8 @@ class LocalReference {{ }}); }} +{object_runtime} + struct JvmRoute {{ std::shared_ptr adapter_class; jmethodID method{{nullptr}}; @@ -455,6 +532,7 @@ class LazyJvmRoute {{ }} // namespace +{cross_family.render_helpers() if cross_family is not None else ""} {chr(10).join(internal_helpers)} void register_jvm_feature( @@ -468,7 +546,7 @@ class LazyJvmRoute {{ using facebook::jsi::Value; auto exports = feature_registry.getPropertyAsObject(runtime, kFeatureId); -{chr(10).join(registrations)} +{v3_registry_setup}{chr(10).join(registrations)} {chr(10).join(object_registrations)} }} @@ -490,6 +568,7 @@ def _render_internal_function( module_name: str, feature_suffix: str, helper_name: str, + cross_family: "CrossFamilyRenderer | None" = None, ) -> tuple[str, str]: if source.is_suspend: return _render_internal_suspend_route( @@ -499,9 +578,14 @@ def _render_internal_function( feature_suffix=feature_suffix, helper_name=helper_name, facade_name=binding.name, + cross_family=cross_family, ) helper = _render_internal_blocking_helper( - owner, source, binding, helper_name=helper_name + owner, + source, + binding, + helper_name=helper_name, + cross_family=cross_family, ) if binding.execution is ExecutionMode.ASYNC: facade = _render_internal_blocking_async_facade( @@ -509,6 +593,7 @@ def _render_internal_function( feature_suffix=feature_suffix, helper_name=helper_name, facade_name=binding.name, + cross_family=cross_family, ) else: facade = _render_internal_sync_facade( @@ -516,6 +601,7 @@ def _render_internal_function( feature_suffix=feature_suffix, helper_name=helper_name, facade_name=binding.name, + cross_family=cross_family, ) return helper, facade @@ -527,6 +613,7 @@ def _render_internal_service( module_name: str, feature_suffix: str, helper_prefix: str, + cross_family: "CrossFamilyRenderer | None" = None, ) -> tuple[list[str], list[str]]: del module_name declarations = { @@ -552,10 +639,15 @@ def _render_internal_service( feature_suffix=feature_suffix, helper_name=helper_name, facade_name=facade_name, + cross_family=cross_family, ) else: helper = _render_internal_blocking_helper( - owner, source, binding, helper_name=helper_name + owner, + source, + binding, + helper_name=helper_name, + cross_family=cross_family, ) if binding.execution is ExecutionMode.ASYNC: facade = _render_internal_blocking_async_facade( @@ -563,6 +655,7 @@ def _render_internal_service( feature_suffix=feature_suffix, helper_name=helper_name, facade_name=facade_name, + cross_family=cross_family, ) else: facade = _render_internal_sync_facade( @@ -570,6 +663,7 @@ def _render_internal_service( feature_suffix=feature_suffix, helper_name=helper_name, facade_name=facade_name, + cross_family=cross_family, ) helpers.append(helper) facades.append(facade) @@ -582,15 +676,27 @@ def _render_internal_blocking_helper( binding: SemanticBinding, *, helper_name: str, + cross_family: "CrossFamilyRenderer | None" = None, ) -> str: takes_owner = owner.form is JvmOwnerForm.CLASS - result_type = _internal_cpp_type(binding.result) - parameters = _internal_parameters(binding) + result_type = _internal_cpp_type(binding.result, cross_family) + parameters = _internal_parameters(binding, cross_family) signature_tail = f", {parameters}" if parameters else "" - descriptor = _adapter_descriptor(binding, owner if takes_owner else None) + descriptor = ( + cross_family.descriptor( + binding, owner.owner_class if takes_owner else None + ) + if cross_family is not None + else _adapter_descriptor(binding, owner if takes_owner else None) + ) route_key = f"jvm-route:{source.provenance.declaration_id}" owner_setup = _render_internal_owner_setup(owner) if takes_owner else "" - invocation = _worker_invocation(binding, takes_owner) + invocation = ( + cross_family.worker_invocation(binding, takes_owner) + if cross_family is not None + else _worker_invocation(binding, takes_owner) + ) + release_before_call = "" if cross_family is not None else " feature.reset();\n" return f'''static {result_type} {helper_name}( std::shared_ptr feature{signature_tail}) {{ auto route = feature->service( @@ -600,7 +706,7 @@ def _render_internal_blocking_helper( {json.dumps(descriptor)}); }}); {owner_setup} auto resolved = route->get(feature); - feature.reset(); +{release_before_call} AttachedEnv attached; auto *env = attached.get(); if (env == nullptr) {{ @@ -631,9 +737,10 @@ def _render_internal_sync_facade( feature_suffix: str, helper_name: str, facade_name: str, + cross_family: "CrossFamilyRenderer | None" = None, ) -> str: - result_type = _internal_cpp_type(binding.result) - parameters = _internal_parameters(binding) + result_type = _internal_cpp_type(binding.result, cross_family) + parameters = _internal_parameters(binding, cross_family) arguments = ", ".join(item.name for item in binding.parameters) call_tail = f", {arguments}" if arguments else "" invoke = ( @@ -672,9 +779,10 @@ def _render_internal_blocking_async_facade( feature_suffix: str, helper_name: str, facade_name: str, + cross_family: "CrossFamilyRenderer | None" = None, ) -> str: - parameters = _internal_parameters(binding) - callback = _internal_callback_type(binding.result) + parameters = _internal_parameters(binding, cross_family) + callback = _internal_callback_type(binding.result, cross_family) signature = f"{parameters}, {callback} completion" if parameters else f"{callback} completion" captures = ", ".join( f"{item.name} = std::move({item.name})" for item in binding.parameters @@ -682,7 +790,18 @@ def _render_internal_blocking_async_facade( capture_tail = f", {captures}" if captures else "" arguments = ", ".join(item.name for item in binding.parameters) call_tail = f", {arguments}" if arguments else "" - result_type = _internal_result_type(binding.result) + result_type = _internal_result_type(binding.result, cross_family) + retained_types = ", ".join( + _internal_cpp_type(item.type, cross_family) + for item in binding.parameters + ) + retained_values = ", ".join(item.name for item in binding.parameters) + retained_state = ( + " auto retained_input_state = std::make_shared>({retained_values});\n" + if retained_types + else " auto retained_input_state = std::make_shared>();\n" + ) if binding.result is SemanticType.VOID: invoke = ( f"route::{helper_name}(std::move(feature){call_tail});\n" @@ -706,11 +825,13 @@ def _render_internal_blocking_async_facade( }} auto callback = std::make_shared( std::move(completion)); +{retained_state.rstrip()} auto operation = feature->accept({{}}, std::move(callback)); if (!operation) {{ throw supernote::Error( supernote::ErrorCode::FEATURE_CLOSED, "feature is closed"); }} + operation->set_retained_state(retained_input_state); std::weak_ptr weak_feature = feature; auto work = supernote::runtime::process_services().workers().submit( [operation, weak_feature{capture_tail}]( @@ -784,6 +905,7 @@ def _render_internal_suspend_route( feature_suffix: str, helper_name: str, facade_name: str, + cross_family: "CrossFamilyRenderer | None" = None, ) -> tuple[str, str]: if binding.execution is not ExecutionMode.ASYNC: raise _error( @@ -791,25 +913,42 @@ def _render_internal_suspend_route( "a Kotlin suspend implementation requires SupernotePluginAsync intent", ) takes_owner = owner.form is JvmOwnerForm.CLASS - parameters = _internal_parameters(binding) + parameters = _internal_parameters(binding, cross_family) signature_tail = f", {parameters}" if parameters else "" route_key = f"jvm-route:{source.provenance.declaration_id}" cancel_key = "jvm-route:supernote-coroutine-cancel" owner_setup = _render_internal_owner_setup(owner) if takes_owner else "" offset = 1 if takes_owner else 0 argument_count = len(binding.parameters) + offset + 1 - argument_rows = [] - if takes_owner: - argument_rows.append( - " jvm_arguments[0].l = static_cast(owner->value.get());" - ) - argument_rows.extend( - _owned_argument_lines(binding.parameters, offset, " ") + argument_rows = ( + cross_family.suspend_worker_arguments(binding, takes_owner).splitlines() + if cross_family is not None + else [ + f"jvalue jvm_arguments[{max(1, argument_count)}]{{}};", + *( + ["jvm_arguments[0].l = static_cast(owner->value.get());"] + if takes_owner + else [] + ), + *( + line.strip() + for line in _owned_argument_lines( + binding.parameters, offset, "" + ) + ), + f"jvm_arguments[{argument_count - 1}].j = static_cast(completion_id);", + ] ) - argument_rows.append( - f" jvm_arguments[{argument_count - 1}].j = " - "static_cast(completion_id);" + descriptor = ( + cross_family.suspend_descriptor( + binding, owner.owner_class if takes_owner else None + ) + if cross_family is not None + else _suspend_adapter_descriptor( + binding, owner if takes_owner else None + ) ) + release_before_call = "" if cross_family is not None else " feature.reset();\n" helper = f'''static std::pair, std::shared_ptr> {helper_name}( std::shared_ptr feature, @@ -818,7 +957,7 @@ def _render_internal_suspend_route( {json.dumps(route_key)}, [] {{ return std::make_shared( {json.dumps(_adapter_class(source.adapter_identity))}, - {json.dumps(_suspend_adapter_descriptor(binding, owner if takes_owner else None))}); + {json.dumps(descriptor)}); }}); auto cancel_route = feature->service( {json.dumps(cancel_key)}, [] {{ @@ -828,20 +967,19 @@ def _render_internal_suspend_route( }}); {owner_setup} auto resolved = route->get(feature); auto cancel_resolved = cancel_route->get(feature); - feature.reset(); +{release_before_call} AttachedEnv attached; auto *env = attached.get(); if (env == nullptr) {{ throw std::runtime_error("cannot attach to JavaVM"); }} LocalFrame frame(env); - jvalue jvm_arguments[{max(1, argument_count)}]{{}}; -{chr(10).join(argument_rows)} +{chr(10).join(' ' + line for line in argument_rows)} auto local_job = env->CallStaticObjectMethodA( static_cast(resolved->adapter_class.get()), resolved->method, jvm_arguments); - if (env->ExceptionCheck() || local_job == nullptr) {{ - clear_exception(env); + if (env->ExceptionCheck()) require_no_implementation_exception(env); + if (local_job == nullptr) {{ throw std::runtime_error( "cannot launch generated Kotlin coroutine adapter"); }} @@ -852,6 +990,7 @@ def _render_internal_suspend_route( feature_suffix=feature_suffix, helper_name=helper_name, facade_name=facade_name, + cross_family=cross_family, ) return helper, facade @@ -862,9 +1001,10 @@ def _render_internal_suspend_facade( feature_suffix: str, helper_name: str, facade_name: str, + cross_family: "CrossFamilyRenderer | None" = None, ) -> str: - parameters = _internal_parameters(binding) - callback_type = _internal_callback_type(binding.result) + parameters = _internal_parameters(binding, cross_family) + callback_type = _internal_callback_type(binding.result, cross_family) signature = ( f"{parameters}, {callback_type} completion" if parameters @@ -876,8 +1016,21 @@ def _render_internal_suspend_facade( capture_tail = f", {captures}" if captures else "" arguments = ", ".join(item.name for item in binding.parameters) call_tail = f", {arguments}" if arguments else "" - result_type = _internal_result_type(binding.result) - decode = _internal_suspend_decode(binding.result, result_type) + result_type = _internal_result_type(binding.result, cross_family) + decode = _internal_suspend_decode( + binding.result, result_type, cross_family + ) + retained_types = ", ".join( + _internal_cpp_type(item.type, cross_family) + for item in binding.parameters + ) + retained_values = ", ".join(item.name for item in binding.parameters) + retained_state = ( + " auto retained_input_state = std::make_shared>({retained_values});\n" + if retained_types + else " auto retained_input_state = std::make_shared>();\n" + ) return f'''void {facade_name}({signature}) {{ auto feature = supernote::runtime::current_feature_session(); if (!feature || @@ -891,11 +1044,13 @@ def _render_internal_suspend_facade( }} auto callback = std::make_shared( std::move(completion)); +{retained_state.rstrip()} auto operation = feature->accept({{}}, std::move(callback)); if (!operation) {{ throw supernote::Error( supernote::ErrorCode::FEATURE_CLOSED, "feature is closed"); }} + operation->set_retained_state(retained_input_state); std::weak_ptr weak_feature = feature; namespace route = supernote::generated::jvm_feature_{feature_suffix}; const auto completion_id = @@ -1005,10 +1160,46 @@ def _render_internal_suspend_facade( def _internal_suspend_decode( result: SemanticType, result_type: str, + cross_family: "CrossFamilyRenderer | None" = None, ) -> str: indent = " " * 16 if result is SemanticType.VOID: return f"{indent}outcome = {result_type}::success();" + if cross_family is not None: + expression = cross_family.suspend_result_expression( + result, + expression="result", + feature="conversion_feature", + budget="cross_budget", + ) + lines = [ + f"{indent}auto *env = static_cast(environment);", + f"{indent}if (env == nullptr) {{", + f'{indent} throw std::runtime_error("Kotlin coroutine result has no JNI environment");', + f"{indent}}}", + ] + if result.kind is not SemanticTypeKind.NULLABLE: + lines.extend( + [ + f"{indent}if (result == nullptr) {{", + f'{indent} throw std::runtime_error("Kotlin coroutine returned null");', + f"{indent}}}", + ] + ) + lines.extend( + [ + f"{indent}auto conversion_feature = weak_feature.lock();", + f"{indent}if (!conversion_feature ||", + f"{indent} conversion_feature->state() !=", + f"{indent} supernote::runtime::FeatureState::ACTIVE) {{", + f'{indent} throw std::runtime_error("feature closed before coroutine result conversion");', + f"{indent}}}", + f"{indent}route::LocalFrame frame(env);", + f"{indent}supernote::conversion::Budget cross_budget;", + f"{indent}outcome = {result_type}::success({expression});", + ] + ) + return "\n".join(lines) lines = [ f"{indent}auto *env = static_cast(environment);", f"{indent}auto object = static_cast(result);", @@ -1069,27 +1260,37 @@ def _internal_suspend_decode( return "\n".join(lines) -def _internal_parameters(binding: SemanticBinding) -> str: +def _internal_parameters( + binding: SemanticBinding, cross_family: "CrossFamilyRenderer | None" = None +) -> str: return ", ".join( - f"{_internal_cpp_type(item.type)} {item.name}" + f"{_internal_cpp_type(item.type, cross_family)} {item.name}" for item in binding.parameters ) -def _internal_cpp_type(value: SemanticType) -> str: +def _internal_cpp_type( + value: SemanticType, cross_family: "CrossFamilyRenderer | None" = None +) -> str: + if cross_family is not None: + return cross_family.cpp_type(value) return "void" if value is SemanticType.VOID else _CPP_TYPES[value] -def _internal_result_type(value: SemanticType) -> str: +def _internal_result_type( + value: SemanticType, cross_family: "CrossFamilyRenderer | None" = None +) -> str: return ( "supernote::Result" if value is SemanticType.VOID - else f"supernote::Result<{_CPP_TYPES[value]}>" + else f"supernote::Result<{_internal_cpp_type(value, cross_family)}>" ) -def _internal_callback_type(value: SemanticType) -> str: - return f"std::function" +def _internal_callback_type( + value: SemanticType, cross_family: "CrossFamilyRenderer | None" = None +) -> str: + return f"std::function" def _render_function( @@ -1120,6 +1321,9 @@ def _render_function( owner_setup = _render_owner_setup(owner) validations = _validations(binding, f"{module_name}.{binding.name}") invocation = _invocation(binding, takes_owner) + preflight = _scalar_preflight_statements( + binding, f"{module_name}.{binding.name}" + ) return ( " {\n" + "\n".join(setup) @@ -1156,6 +1360,7 @@ def _render_function( " supernote_throw_error(runtime, \"INTERNAL\", error.what());\n" " }\n" " });\n" + f"{preflight}\n" f" exports.setProperty(runtime, {json.dumps(binding.name)}, " "std::move(function));\n" " }" @@ -1241,6 +1446,9 @@ def _render_async_function( implementation_name="Kotlin/Java", implementation_exception_type="JvmImplementationFailure", ) + preflight = _scalar_preflight_statements( + binding, f"{module_name}.{binding.name}" + ) return ( " {\n" + "\n".join(setup) @@ -1248,6 +1456,8 @@ def _render_async_function( + invoker + "\n" + f" auto function = {function};\n" + + preflight + + "\n" + f" exports.setProperty(runtime, {json.dumps(binding.name)}, " + "std::move(function));\n" + " }" @@ -1390,6 +1600,9 @@ def _render_suspend_function( "std::move(function));\n }" ) ) + preflight = _scalar_preflight_statements( + binding, diagnostic, indent=" " + ) return f'''{opening} {chr(10).join(setup)} auto function = Function::createFromHostFunction( @@ -1527,8 +1740,10 @@ def _render_suspend_function( auto local_job = env->CallStaticObjectMethodA( static_cast(resolved->adapter_class.get()), resolved->method, jvm_arguments); - if (env->ExceptionCheck() || local_job == nullptr) {{ - clear_exception(env); + if (env->ExceptionCheck()) {{ + require_no_implementation_exception(env); + }} + if (local_job == nullptr) {{ throw std::runtime_error( "cannot launch generated Kotlin coroutine adapter"); }} @@ -1573,6 +1788,7 @@ def _render_suspend_function( return promise.callAsConstructor( runtime, &executor_argument, static_cast(1)); }}); +{preflight} {closing}''' @@ -1634,12 +1850,19 @@ def _render_async_object_method( implementation_name="Kotlin/Java", implementation_exception_type="JvmImplementationFailure", ) + preflight = _scalar_preflight_statements( + method, + f"{module_name}.{item.name}.{method.name}", + indent=" ", + ) return f''' if (property == {json.dumps(method.name)}) {{ auto route = {route_name}_; auto owner = owner_; auto feature_session = feature_session_; {invoker} - return facebook::jsi::Value({function}); + auto function = {function}; +{preflight} + return facebook::jsi::Value(std::move(function)); }}''' @@ -1890,6 +2113,79 @@ def _validations(binding: SemanticBinding, diagnostic: str) -> str: return _validations_parameters(binding.parameters, diagnostic) +def _scalar_preflight_function( + binding: SemanticBinding, + diagnostic: str, + *, + name: str, + check: bool, + indent: str, +) -> str: + validations = _validations(binding, diagnostic) + success = ( + "supernote_validation_success(runtime)" + if check + else "facebook::jsi::Value(true)" + ) + rejected = ( + "supernote_validation_failure(\n" + f"{indent} runtime, facebook::jsi::Value(runtime, error.value()))" + if check + else "facebook::jsi::Value(false)" + ) + arguments = ( + "const facebook::jsi::Value *arguments" + if binding.parameters + else "const facebook::jsi::Value *" + ) + return f'''facebook::jsi::Function::createFromHostFunction( +{indent} runtime, +{indent} facebook::jsi::PropNameID::forAscii(runtime, {json.dumps(name)}), +{indent} {len(binding.parameters)}, +{indent} [](facebook::jsi::Runtime &runtime, +{indent} const facebook::jsi::Value &, +{indent} {arguments}, +{indent} std::size_t argument_count) -> facebook::jsi::Value {{ +{indent} try {{ +{validations} +{indent} return {success}; +{indent} }} catch (const facebook::jsi::JSError &error) {{ +{indent} return {rejected}; +{indent} }} catch (const std::exception &error) {{ +{indent} supernote_throw_error(runtime, "INTERNAL", error.what()); +{indent} }} +{indent} }})''' + + +def _scalar_preflight_statements( + binding: SemanticBinding, + diagnostic: str, + *, + indent: str = " ", +) -> str: + accepts = _scalar_preflight_function( + binding, + diagnostic, + name=binding.name + ".accepts", + check=False, + indent=indent, + ) + check = _scalar_preflight_function( + binding, + diagnostic, + name=binding.name + ".checkArguments", + check=True, + indent=indent, + ) + return ( + f"{indent}auto accepts = {accepts};\n" + f"{indent}auto check_arguments = {check};\n" + f"{indent}function = supernote_attach_preflight(\n" + f"{indent} runtime, std::move(function), std::move(accepts),\n" + f"{indent} std::move(check_arguments));" + ) + + def _validations_parameters(parameters, diagnostic: str) -> str: expected = ", ".join( f"{_jsi_expected_type(_CPP_TYPES[item.type])} {item.name}" @@ -1901,7 +2197,10 @@ def _validations_parameters(parameters, diagnostic: str) -> str: f" if (argument_count != {count}) {{", " supernote_throw_type_error(", f" runtime, std::string({json.dumps(diagnostic + ': expected ' + description + '; received ')}) +", - " std::to_string(argument_count));", + " std::to_string(argument_count),", + f" \"ARITY_MISMATCH\", {json.dumps(diagnostic)},", + f" {json.dumps(description)},", + " std::to_string(argument_count) + \" arguments\");", " }", ] for index, item in enumerate(parameters): @@ -1910,7 +2209,10 @@ def _validations_parameters(parameters, diagnostic: str) -> str: [ f" if ({_jsi_type_check(parameter, index)}) {{", " supernote_throw_type_error(", - f" runtime, {json.dumps(diagnostic + ': argument ' + str(index + 1) + ' (' + item.name + ') has the wrong JavaScript type')});", + f" runtime, {json.dumps(diagnostic + ': argument ' + str(index + 1) + ' (' + item.name + ') has the wrong JavaScript type')},", + f" \"TYPE_MISMATCH\", {json.dumps(diagnostic + '.argument[' + str(index) + '](' + item.name + ')')},", + f" {json.dumps(_jsi_expected_type(_CPP_TYPES[item.type]))},", + f" supernote_describe_value(runtime, arguments[{index}]));", " }", ] ) diff --git a/src/supernote_module_generator/jvm_manifest.py b/src/supernote_module_generator/jvm_manifest.py index eb405c3..1dc21be 100644 --- a/src/supernote_module_generator/jvm_manifest.py +++ b/src/supernote_module_generator/jvm_manifest.py @@ -8,25 +8,27 @@ from typing import Any from .semantic import SourceProvenance +from .v3_schemas import ( + JVM_SOURCE_MANIFEST_KIND as JVM_MANIFEST_KIND, + JVM_SOURCE_MANIFEST_SCHEMA_VERSION as JVM_MANIFEST_SCHEMA_VERSION, +) from .source_models import ( DeclarationTarget, JvmConstructorSource, JvmDeclarationSource, JvmInjectedDependency, + JvmFieldSource, JvmLanguage, JvmOwnerForm, JvmOwnerSource, JvmParameterSource, + JvmTypeSource, MarkerOccurrence, SourceIntent, SupernoteMarker, ) -JVM_MANIFEST_SCHEMA_VERSION = 1 -JVM_MANIFEST_KIND = "supernote_jvm_source_manifest" - - class JvmManifestError(ValueError): pass @@ -66,6 +68,12 @@ def __post_init__(self) -> None: declaration.jvm_descriptor, ) _validate_identity(declaration.provenance, declaration.adapter_identity, expected) + for source_field in owner.fields: + expected = jvm_field_identity(owner.owner_class, source_field.name) + if source_field.provenance.declaration_id != expected: + raise JvmManifestError("JVM field identity is not deterministic") + if source_field.accessor_identity != jvm_field_accessor_identity(expected): + raise JvmManifestError("JVM field accessor identity is not deterministic") def manifest(self) -> dict[str, object]: return { @@ -147,6 +155,15 @@ def jvm_adapter_identity(declaration_id: str) -> str: return f"supernote.jvm.adapter.{digest}" +def jvm_field_identity(owner_class: str, name: str) -> str: + return f"jvm:{owner_class}#field:{name}" + + +def jvm_field_accessor_identity(declaration_id: str) -> str: + digest = hashlib.sha256(declaration_id.encode("utf-8")).hexdigest()[:20] + return f"supernote.jvm.field.{digest}" + + def _owner_manifest(owner: JvmOwnerSource) -> dict[str, object]: return { "source": owner.provenance.manifest(), @@ -168,6 +185,16 @@ def _owner_manifest(owner: JvmOwnerSource) -> dict[str, object]: owner.declarations, key=lambda value: value.provenance.declaration_id ) ], + "fields": [ + _field_manifest(item) + for item in owner.fields + ], + "enum_constants": list(owner.enum_constants), + "is_data": owner.is_data, + "is_record": owner.is_record, + "is_final": owner.is_final, + "type_parameter_count": owner.type_parameter_count, + "supertypes": list(owner.supertypes), } @@ -198,6 +225,23 @@ def _declaration_manifest(source: JvmDeclarationSource) -> dict[str, object]: "language": source.language.value, "is_suspend": source.is_suspend, "is_static": source.is_static, + "result_type_arguments": [ + _type_manifest(item) for item in source.result_type_arguments + ], + } + + +def _field_manifest(source: JvmFieldSource) -> dict[str, object]: + return { + "source": source.provenance.manifest(), + "owner_declaration_id": source.owner_declaration_id, + "name": source.name, + "type": _type_manifest(source.type), + "markers": _intent_manifest(source.intent), + "visibility": source.visibility, + "mutable": source.mutable, + "is_static": source.is_static, + "accessor_identity": source.accessor_identity, } @@ -207,6 +251,15 @@ def _parameter_manifest(source: JvmParameterSource) -> dict[str, object]: "name": source.name, "nullable": source.nullable, "injected": source.injected.value if source.injected is not None else None, + "type_arguments": [_type_manifest(item) for item in source.type_arguments], + } + + +def _type_manifest(source: JvmTypeSource) -> dict[str, object]: + return { + "jvm_type": source.jvm_type, + "nullable": source.nullable, + "arguments": [_type_manifest(item) for item in source.arguments], } @@ -228,7 +281,9 @@ def _parse_owner(raw: Any, index: int) -> JvmOwnerSource: value, { "source", "language", "owner_class", "source_name", "form", - "markers", "constructors", "declarations", "visibility", + "markers", "constructors", "declarations", "visibility", "fields", + "enum_constants", "is_data", "is_record", "is_final", + "type_parameter_count", "supertypes", }, label, ) @@ -273,6 +328,30 @@ def _parse_owner(raw: Any, index: int) -> JvmOwnerSource: for item_index, item in enumerate(_list(value["declarations"], f"{label}.declarations")) ), visibility=_string(value["visibility"], f"{label}.visibility"), + fields=tuple( + _parse_field(item, owner_id, f"{label}.fields[{item_index}]") + for item_index, item in enumerate( + _list(value["fields"], f"{label}.fields") + ) + ), + enum_constants=tuple( + _string(item, f"{label}.enum_constants[{item_index}]") + for item_index, item in enumerate( + _list(value["enum_constants"], f"{label}.enum_constants") + ) + ), + is_data=_bool(value["is_data"], f"{label}.is_data"), + is_record=_bool(value["is_record"], f"{label}.is_record"), + is_final=_bool(value["is_final"], f"{label}.is_final"), + type_parameter_count=_integer( + value["type_parameter_count"], f"{label}.type_parameter_count" + ), + supertypes=tuple( + _string(item, f"{label}.supertypes[{item_index}]") + for item_index, item in enumerate( + _list(value["supertypes"], f"{label}.supertypes") + ) + ), ) @@ -314,6 +393,7 @@ def _parse_declaration( "jvm_descriptor", "parameters", "result_jvm_type", "result_nullable", "markers", "visibility", "adapter_identity", "language", "is_suspend", "is_static", + "result_type_arguments", }, label, ) @@ -347,6 +427,15 @@ def _parse_declaration( language=language, is_suspend=_bool(value["is_suspend"], f"{label}.is_suspend"), is_static=_bool(value["is_static"], f"{label}.is_static"), + result_type_arguments=tuple( + _parse_type(item, f"{label}.result_type_arguments[{index}]") + for index, item in enumerate( + _list( + value["result_type_arguments"], + f"{label}.result_type_arguments", + ) + ) + ), ) @@ -371,7 +460,7 @@ def _validate_identity( def _parse_parameter(raw: Any, label: str) -> JvmParameterSource: value = _object(raw, label) - _keys(value, {"jvm_type", "name", "nullable", "injected"}, label) + _keys(value, {"jvm_type", "name", "nullable", "injected", "type_arguments"}, label) injected_raw = value["injected"] injected = None if injected_raw is None else _enum(JvmInjectedDependency, injected_raw, f"{label}.injected") return JvmParameterSource( @@ -379,6 +468,52 @@ def _parse_parameter(raw: Any, label: str) -> JvmParameterSource: name=_string(value["name"], f"{label}.name"), nullable=_bool(value["nullable"], f"{label}.nullable"), injected=injected, + type_arguments=tuple( + _parse_type(item, f"{label}.type_arguments[{index}]") + for index, item in enumerate( + _list(value["type_arguments"], f"{label}.type_arguments") + ) + ), + ) + + +def _parse_type(raw: Any, label: str) -> JvmTypeSource: + value = _object(raw, label) + _keys(value, {"jvm_type", "nullable", "arguments"}, label) + return JvmTypeSource( + _string(value["jvm_type"], f"{label}.jvm_type"), + _bool(value["nullable"], f"{label}.nullable"), + tuple( + _parse_type(item, f"{label}.arguments[{index}]") + for index, item in enumerate( + _list(value["arguments"], f"{label}.arguments") + ) + ), + ) + + +def _parse_field(raw: Any, owner_id: str, label: str) -> JvmFieldSource: + value = _object(raw, label) + _keys( + value, + { + "source", "owner_declaration_id", "name", "type", "markers", + "visibility", "mutable", "is_static", "accessor_identity", + }, + label, + ) + if _string(value["owner_declaration_id"], f"{label}.owner_declaration_id") != owner_id: + raise JvmManifestError(f"{label}.owner_declaration_id does not match owner") + return JvmFieldSource( + _parse_provenance(value["source"], f"{label}.source"), + owner_id, + _string(value["name"], f"{label}.name"), + _parse_type(value["type"], f"{label}.type"), + _parse_intent(value["markers"], DeclarationTarget.FIELD, f"{label}.markers"), + _string(value["visibility"], f"{label}.visibility"), + _bool(value["mutable"], f"{label}.mutable"), + _bool(value["is_static"], f"{label}.is_static"), + _string(value["accessor_identity"], f"{label}.accessor_identity"), ) diff --git a/src/supernote_module_generator/jvm_object_binding_codegen.py b/src/supernote_module_generator/jvm_object_binding_codegen.py new file mode 100644 index 0000000..6015498 --- /dev/null +++ b/src/supernote_module_generator/jvm_object_binding_codegen.py @@ -0,0 +1,2479 @@ +"""Emit synchronous JSI bindings for V3 JVM object and composite routes.""" +from __future__ import annotations + +import hashlib +import json +from typing import Iterable + +from .jvm_manifest import jvm_adapter_identity +from .jvm_routes import ( + JvmCallableRoute, + JvmFieldRoute, + JvmObjectRoute, + JvmRouteError, + JvmRoutePlan, +) +from .semantic import ExecutionMode +from .semantic_types import ScalarKind, SemanticType, SemanticTypeKind + + +def _suffix(semantic: SemanticType) -> str: + encoded = json.dumps( + semantic.manifest(), sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest()[:12] + + +def _from_name(semantic: SemanticType) -> str: + return f"supernote_v3_jvm_from_js_{_suffix(semantic)}" + + +def _to_name(semantic: SemanticType) -> str: + return f"supernote_v3_jvm_to_js_{_suffix(semantic)}" + + +def _validate_name(semantic: SemanticType) -> str: + return f"supernote_v3_jvm_validate_js_{_suffix(semantic)}" + + +def _native(semantic: SemanticType) -> str: + if semantic.kind is SemanticTypeKind.VOID: + return "void" + if semantic.kind is SemanticTypeKind.SCALAR: + return { + ScalarKind.BOOL: "bool", + ScalarKind.INT32: "std::int32_t", + ScalarKind.INT64: "std::int64_t", + ScalarKind.FLOAT32: "float", + ScalarKind.FLOAT64: "double", + ScalarKind.STRING: "std::string", + ScalarKind.BYTES: "std::vector", + }[semantic.scalar] + if semantic.kind is SemanticTypeKind.OBJECT_REF: + return "ManagedJvmRef" + return "ManagedJvmValue" + + +def _collect_types( + roots: Iterable[SemanticType], plan: JvmRoutePlan +) -> tuple[SemanticType, ...]: + found: dict[str, SemanticType] = {} + + def visit(item: SemanticType) -> None: + if item.kind is SemanticTypeKind.VOID: + return + key = _suffix(item) + if key in found: + return + found[key] = item + if item.element is not None: + visit(item.element) + elif item.kind is SemanticTypeKind.VALUE_REF: + assert item.type_id is not None + route = next( + value for value in plan.values + if value.named_type.type_id == item.type_id + ) + for field in route.fields: + visit(field.semantic_type) + + for root in roots: + visit(root) + return tuple(found[key] for key in sorted(found)) + + +def _bridge_class(feature_id: str) -> str: + digest = hashlib.sha256(feature_id.encode("utf-8")).hexdigest()[:20] + return f"supernote.generated.adapters.Identity_{digest}" + + +def _enum_class(source_declaration_id: str) -> str: + identity = jvm_adapter_identity(source_declaration_id + "#enum") + return "supernote.generated.adapters.Adapter_" + identity.rsplit(".", 1)[-1] + + +def _route_expression( + *, key: str, adapter_class: str, descriptor: str, method: str = "invoke" +) -> str: + return ( + "supernote_v3_jvm_route(feature, " + + json.dumps(key) + + ", " + + json.dumps(adapter_class) + + ", " + + json.dumps(descriptor) + + ", " + + json.dumps(method) + + ")" + ) + + +def _helper_route( + feature_id: str, method: str, descriptor: str +) -> str: + return _route_expression( + key=f"jvm-v3-helper:{method}:{descriptor}", + adapter_class=_bridge_class(feature_id), + descriptor=descriptor, + method=method, + ) + + +def _prototype(semantic: SemanticType) -> str: + native = _native(semantic) + return f"""{native} {_from_name(semantic)}( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value, + JNIEnv *env, + const std::shared_ptr &feature, + supernote::conversion::Budget &budget, + const std::string &path, + std::uint64_t depth); +facebook::jsi::Value {_to_name(semantic)}( + facebook::jsi::Runtime &runtime, + const {native} &value, + JNIEnv *env, + const std::shared_ptr ®istry, + const std::shared_ptr &feature, + supernote::conversion::Budget &budget, + const std::string &path, + std::uint64_t depth); +void {_validate_name(semantic)}( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value, + supernote::conversion::Budget &budget, + const std::string &path, + std::uint64_t depth);""" + + +def _validate_definition(semantic: SemanticType, plan: JvmRoutePlan) -> str: + lines = [ + f"void {_validate_name(semantic)}(", + " facebook::jsi::Runtime &runtime,", + " const facebook::jsi::Value &value,", + " supernote::conversion::Budget &budget,", + " const std::string &path,", + " std::uint64_t depth) {", + " budget.visit(path, depth);", + " if (value.isUndefined()) {", + f" {_type_error('a defined value')};", + " }", + ] + kind = semantic.kind + if kind is SemanticTypeKind.NULLABLE: + assert semantic.element is not None + lines.extend([ + " if (value.isNull()) return;", + f" {_validate_name(semantic.element)}(", + " runtime, value, budget, path, depth + 1);", + ]) + else: + lines.extend([ + " if (value.isNull()) {", + f" {_type_error('a non-null value')};", + " }", + ]) + if kind is SemanticTypeKind.SCALAR: + scalar = semantic.scalar + if scalar is ScalarKind.BOOL: + lines.append(f" if (!value.isBool()) {_type_error('boolean')};") + elif scalar is ScalarKind.INT32: + lines.extend([ + f" if (!value.isNumber()) {_type_error('an int32 number')};", + " const auto number = value.asNumber();", + " if (!std::isfinite(number) || std::trunc(number) != number ||", + " number < static_cast(std::numeric_limits::min()) ||", + " number > static_cast(std::numeric_limits::max())) {", + " supernote_throw_range_error(runtime, path + \" is outside int32 range\",", + " \"OUT_OF_RANGE\", path, \"int32\", \"number\");", + " }", + ]) + elif scalar is ScalarKind.INT64: + lines.extend([ + f" if (!value.isBigInt()) {_type_error('an int64 bigint')};", + " if (!value.getBigInt(runtime).isInt64(runtime)) {", + " supernote_throw_range_error(runtime, path + \" is outside int64 range\",", + " \"OUT_OF_RANGE\", path, \"int64 bigint\", \"bigint\");", + " }", + ]) + elif scalar is ScalarKind.FLOAT32: + lines.extend([ + f" if (!value.isNumber()) {_type_error('a float32 number')};", + " const auto number = value.asNumber();", + " if (std::isfinite(number) &&", + " (number < static_cast(std::numeric_limits::lowest()) ||", + " number > static_cast(std::numeric_limits::max()))) {", + " supernote_throw_range_error(runtime, path + \" is outside float32 range\",", + " \"OUT_OF_RANGE\", path, \"float32\", \"number\");", + " }", + ]) + elif scalar is ScalarKind.FLOAT64: + lines.append(f" if (!value.isNumber()) {_type_error('a number')};") + elif scalar is ScalarKind.STRING: + lines.extend([ + f" if (!value.isString()) {_type_error('a string')};", + " auto text = value.asString(runtime).utf8(runtime);", + " budget.check_string_bytes(path, text.size());", + ]) + else: + lines.extend([ + f" if (!supernote_is_uint8_array(runtime, value)) {_type_error('a Uint8Array')};", + " auto view = value.getObject(runtime);", + " budget.check_byte_buffer(path, supernote_view_index(runtime, view, \"byteLength\"));", + ]) + elif kind is SemanticTypeKind.OBJECT_REF: + assert semantic.type_id is not None + named = plan.named_types_by_id[semantic.type_id] + lines.extend([ + f" auto object = try_extract_jvm_object(runtime, value, {json.dumps(semantic.type_id)});", + " if (!object) {", + f" supernote_throw_type_error(runtime, path + \": expected {named.public_name}\",", + f" \"NOMINAL_MISMATCH\", path, {json.dumps(named.public_name)},", + " supernote_describe_value(runtime, value));", + " }", + " budget.reserve(path, sizeof(void *));", + ]) + elif kind is SemanticTypeKind.ENUM_REF: + assert semantic.type_id is not None + route = next(item for item in plan.enums if item.named_type.type_id == semantic.type_id) + condition = " && ".join( + f"text != {json.dumps(constant)}" for constant in route.constants + ) or "true" + lines.extend([ + f" if (!value.isString()) {_type_error(route.named_type.public_name)};", + " auto text = value.asString(runtime).utf8(runtime);", + " budget.check_string_bytes(path, text.size());", + f" if ({condition}) {{", + f" supernote_throw_type_error(runtime, path + \": expected {route.named_type.public_name}\",", + f" \"INVALID_ENUM\", path, {json.dumps(route.named_type.public_name)}, \"string\");", + " }", + ]) + elif kind is SemanticTypeKind.ARRAY: + assert semantic.element is not None + lines.extend([ + f" if (!value.isObject()) {_type_error('a dense Array')};", + " auto object = value.getObject(runtime);", + f" if (!object.isArray(runtime)) {_type_error('a dense Array')};", + " auto array = object.getArray(runtime);", + " const auto length = static_cast(array.size(runtime));", + " budget.check_array_length(path, length);", + " for (std::uint64_t index = 0; index < length; ++index) {", + " auto item = array.getValueAtIndex(runtime, static_cast(index));", + " auto item_path = supernote::conversion::index_path(path, index);", + f" {_validate_name(semantic.element)}(", + " runtime, item, budget, item_path, depth + 1);", + " }", + ]) + else: + assert kind is SemanticTypeKind.VALUE_REF and semantic.type_id is not None + route = next(item for item in plan.values if item.named_type.type_id == semantic.type_id) + lines.extend([ + f" if (!value.isObject()) {_type_error(route.named_type.public_name)};", + " auto object = value.getObject(runtime);", + f" if (object.isArray(runtime)) {_type_error(route.named_type.public_name)};", + ]) + for index, field in enumerate(route.fields): + lines.extend([ + f" auto field_{index}_path = supernote::conversion::field_path(path, {json.dumps(field.public_name)});", + f" auto field_{index}_value = object.getProperty(runtime, {json.dumps(field.public_name)});", + f" {_validate_name(field.semantic_type)}(", + f" runtime, field_{index}_value, budget, field_{index}_path, depth + 1);", + ]) + lines.append("}") + return "\n".join(lines) + + +def _type_error(expected: str) -> str: + return ( + "supernote_throw_type_error(runtime, path + " + + json.dumps(f": expected {expected}") + + ", \"TYPE_MISMATCH\", path, " + + json.dumps(expected) + + ", supernote_describe_value(runtime, value))" + ) + + +def _box_scalar( + scalar: ScalarKind, expression: str, feature_id: str, indent: str +) -> list[str]: + method, descriptor, field = { + ScalarKind.BOOL: ("boxBoolean", "(Z)Ljava/lang/Object;", "z"), + ScalarKind.INT32: ("boxInt", "(I)Ljava/lang/Object;", "i"), + ScalarKind.INT64: ("boxLong", "(J)Ljava/lang/Object;", "j"), + ScalarKind.FLOAT32: ("boxFloat", "(F)Ljava/lang/Object;", "f"), + ScalarKind.FLOAT64: ("boxDouble", "(D)Ljava/lang/Object;", "d"), + }[scalar] + cast = { + ScalarKind.BOOL: f"{expression} ? JNI_TRUE : JNI_FALSE", + ScalarKind.INT32: f"static_cast({expression})", + ScalarKind.INT64: f"static_cast({expression})", + ScalarKind.FLOAT32: f"static_cast({expression})", + ScalarKind.FLOAT64: f"static_cast({expression})", + }[scalar] + route = _helper_route(feature_id, method, descriptor) + return [ + f"{indent}auto boxed_route = {route};", + f"{indent}jvalue boxed_arguments[1]{{}};", + f"{indent}boxed_arguments[0].{field} = {cast};", + f"{indent}auto boxed = env->CallStaticObjectMethodA(", + f"{indent} static_cast(boxed_route->adapter_class.get()),", + f"{indent} boxed_route->method, boxed_arguments);", + f"{indent}require_no_implementation_exception(env);", + f"{indent}if (boxed == nullptr) throw std::runtime_error(\"JVM boxing returned null\");", + ] + + +def _unbox_scalar( + scalar: ScalarKind, expression: str, feature_id: str, indent: str +) -> tuple[list[str], str]: + method, descriptor, call, cast = { + ScalarKind.BOOL: ("unboxBoolean", "(Ljava/lang/Object;)Z", "CallStaticBooleanMethodA", "unboxed == JNI_TRUE"), + ScalarKind.INT32: ("unboxInt", "(Ljava/lang/Object;)I", "CallStaticIntMethodA", "static_cast(unboxed)"), + ScalarKind.INT64: ("unboxLong", "(Ljava/lang/Object;)J", "CallStaticLongMethodA", "static_cast(unboxed)"), + ScalarKind.FLOAT32: ("unboxFloat", "(Ljava/lang/Object;)F", "CallStaticFloatMethodA", "static_cast(unboxed)"), + ScalarKind.FLOAT64: ("unboxDouble", "(Ljava/lang/Object;)D", "CallStaticDoubleMethodA", "static_cast(unboxed)"), + }[scalar] + route = _helper_route(feature_id, method, descriptor) + lines = [ + f"{indent}auto unbox_route = {route};", + f"{indent}jvalue unbox_arguments[1]{{}};", + f"{indent}unbox_arguments[0].l = {expression};", + f"{indent}auto unboxed = env->{call}(", + f"{indent} static_cast(unbox_route->adapter_class.get()),", + f"{indent} unbox_route->method, unbox_arguments);", + f"{indent}require_no_implementation_exception(env);", + ] + return lines, cast + + +def _managed_from_child( + child: SemanticType, + expression: str, + feature_id: str, + indent: str, +) -> list[str]: + if child.kind is SemanticTypeKind.SCALAR: + if child.scalar in { + ScalarKind.BOOL, + ScalarKind.INT32, + ScalarKind.INT64, + ScalarKind.FLOAT32, + ScalarKind.FLOAT64, + }: + lines = _box_scalar(child.scalar, expression, feature_id, indent) + lines.append(f"{indent}return ManagedJvmValue(retain_global(env, boxed));") + return lines + lines = [] + if child.scalar is ScalarKind.STRING: + data = ( + "reinterpret_cast(" + f"{expression}.data())" + ) + else: + data = f"{expression}.data()" + lines.extend([ + f"{indent}auto boxed = write_byte_array(env, {data}, {expression}.size());", + f"{indent}return ManagedJvmValue(retain_global(env, boxed));", + ]) + return lines + if child.kind is SemanticTypeKind.OBJECT_REF: + return [ + f"{indent}return ManagedJvmValue({expression}.global_ref());" + ] + return [f"{indent}return {expression};"] + + +def _from_definition( + semantic: SemanticType, plan: JvmRoutePlan, feature_id: str +) -> str: + native = _native(semantic) + lines = [ + f"{native} {_from_name(semantic)}(", + " facebook::jsi::Runtime &runtime,", + " const facebook::jsi::Value &value,", + " JNIEnv *env,", + " const std::shared_ptr &feature,", + " supernote::conversion::Budget &budget,", + " const std::string &path,", + " std::uint64_t depth) {", + " budget.visit(path, depth);", + " if (value.isUndefined()) {", + f" {_type_error('a defined value')};", + " }", + ] + kind = semantic.kind + if kind is SemanticTypeKind.NULLABLE: + assert semantic.element is not None + child = semantic.element + lines.extend([ + " if (value.isNull()) return {};", + f" auto converted = {_from_name(child)}(", + " runtime, value, env, feature, budget, path, depth + 1);", + ]) + lines.extend(_managed_from_child(child, "converted", feature_id, " ")) + else: + lines.extend([ + " if (value.isNull()) {", + f" {_type_error('a non-null value')};", + " }", + ]) + if kind is SemanticTypeKind.SCALAR: + scalar = semantic.scalar + if scalar is ScalarKind.BOOL: + lines.extend([ + f" if (!value.isBool()) {_type_error('boolean')};", + " return value.getBool();", + ]) + elif scalar is ScalarKind.INT32: + lines.extend([ + f" if (!value.isNumber()) {_type_error('an int32 number')};", + " const auto number = value.asNumber();", + " if (!std::isfinite(number) || std::trunc(number) != number ||", + " number < static_cast(std::numeric_limits::min()) ||", + " number > static_cast(std::numeric_limits::max())) {", + " supernote_throw_range_error(runtime, path + \" is outside int32 range\");", + " }", + " return static_cast(number);", + ]) + elif scalar is ScalarKind.INT64: + lines.extend([ + f" if (!value.isBigInt()) {_type_error('an int64 bigint')};", + " auto bigint = value.getBigInt(runtime);", + " if (!bigint.isInt64(runtime)) {", + " supernote_throw_range_error(runtime, path + \" is outside int64 range\");", + " }", + " return static_cast(bigint.asInt64(runtime));", + ]) + elif scalar is ScalarKind.FLOAT32: + lines.extend([ + f" if (!value.isNumber()) {_type_error('a float32 number')};", + " const auto number = value.asNumber();", + " if (std::isfinite(number) &&", + " (number < static_cast(std::numeric_limits::lowest()) ||", + " number > static_cast(std::numeric_limits::max()))) {", + " supernote_throw_range_error(runtime, path + \" is outside float32 range\");", + " }", + " return static_cast(number);", + ]) + elif scalar is ScalarKind.FLOAT64: + lines.extend([ + f" if (!value.isNumber()) {_type_error('a number')};", + " return value.asNumber();", + ]) + elif scalar is ScalarKind.STRING: + lines.extend([ + f" if (!value.isString()) {_type_error('a string')};", + " auto result = value.asString(runtime).utf8(runtime);", + " budget.check_string_bytes(path, result.size());", + " budget.reserve(path, result.size());", + " return result;", + ]) + else: + lines.extend([ + f" if (!supernote_is_uint8_array(runtime, value)) {_type_error('a Uint8Array')};", + " auto result = supernote_copy_uint8_array(runtime, value);", + " budget.check_byte_buffer(path, result.size());", + " budget.reserve(path, result.size());", + " return result;", + ]) + elif kind is SemanticTypeKind.OBJECT_REF: + assert semantic.type_id is not None + lines.extend([ + f" auto result = try_extract_jvm_object(runtime, value, {json.dumps(semantic.type_id)});", + " if (!result) {", + f" {_type_error('the exact nominal JVM object type')};", + " }", + " return result;", + ]) + elif kind is SemanticTypeKind.ENUM_REF: + assert semantic.type_id is not None + route = next( + item for item in plan.enums + if item.named_type.type_id == semantic.type_id + ) + adapter = _enum_class(route.named_type.source_declaration_id) + descriptor = f"([B)L{route.named_type.owner_class.replace('.', '/')};" + resolved = _route_expression( + key=f"jvm-v3-enum-from:{semantic.type_id}", + adapter_class=adapter, + descriptor=descriptor, + method="fromName", + ) + lines.extend([ + f" if (!value.isString()) {_type_error(route.named_type.public_name)};", + " auto text = value.asString(runtime).utf8(runtime);", + " budget.check_string_bytes(path, text.size());", + f" auto route = {resolved};", + " jvalue arguments[1]{};", + " arguments[0].l = write_byte_array(", + " env, reinterpret_cast(text.data()), text.size());", + " auto local = env->CallStaticObjectMethodA(", + " static_cast(route->adapter_class.get()), route->method, arguments);", + " require_no_implementation_exception(env);", + " if (local == nullptr) throw std::runtime_error(\"JVM enum adapter returned null\");", + " return ManagedJvmValue(retain_global(env, local));", + ]) + elif kind is SemanticTypeKind.ARRAY: + assert semantic.element is not None + child = semantic.element + create = _helper_route(feature_id, "newList", "()Ljava/util/List;") + add = _helper_route( + feature_id, + "listAdd", + "(Ljava/util/List;Ljava/lang/Object;)V", + ) + lines.extend([ + f" if (!value.isObject()) {_type_error('a dense Array')};", + " auto object = value.getObject(runtime);", + f" if (!object.isArray(runtime)) {_type_error('a dense Array')};", + " auto array = object.getArray(runtime);", + " const auto length = static_cast(array.size(runtime));", + " budget.check_array_length(path, length);", + f" auto create_route = {create};", + " auto list = env->CallStaticObjectMethod(", + " static_cast(create_route->adapter_class.get()),", + " create_route->method);", + " require_no_implementation_exception(env);", + " if (list == nullptr) throw std::runtime_error(\"JVM list adapter returned null\");", + f" auto add_route = {add};", + " for (std::uint64_t index = 0; index < length; ++index) {", + " auto item_value = array.getValueAtIndex(runtime, static_cast(index));", + " auto item_path = supernote::conversion::index_path(path, index);", + f" auto item = {_from_name(child)}(runtime, item_value, env, feature, budget, item_path, depth + 1);", + ]) + lines.extend(_as_jobject(child, "item", feature_id, " ")) + lines.extend([ + " jvalue add_arguments[2]{};", + " add_arguments[0].l = list;", + " add_arguments[1].l = item_object;", + " env->CallStaticVoidMethodA(", + " static_cast(add_route->adapter_class.get()),", + " add_route->method, add_arguments);", + " require_no_implementation_exception(env);", + " }", + " return ManagedJvmValue(retain_global(env, list));", + ]) + else: + assert kind is SemanticTypeKind.VALUE_REF and semantic.type_id is not None + route = next( + item for item in plan.values + if item.named_type.type_id == semantic.type_id + ) + lines.extend([ + f" if (!value.isObject()) {_type_error(route.named_type.public_name)};", + " auto object = value.getObject(runtime);", + f" if (object.isArray(runtime)) {_type_error(route.named_type.public_name)};", + ]) + for index, field in enumerate(route.fields): + lines.extend([ + f" auto field_{index}_path = supernote::conversion::field_path(path, {json.dumps(field.public_name)});", + f" auto field_{index}_value = object.getProperty(runtime, {json.dumps(field.public_name)});", + f" auto field_{index} = {_from_name(field.semantic_type)}(", + f" runtime, field_{index}_value, env, feature, budget, field_{index}_path, depth + 1);", + ]) + resolved = _route_expression( + key=f"jvm-v3-value-constructor:{semantic.type_id}", + adapter_class="supernote.generated.adapters.Adapter_" + + route.constructor.adapter_identity.rsplit(".", 1)[-1], + descriptor=route.constructor.adapter_descriptor, + ) + lines.extend([ + f" auto route = {resolved};", + f" jvalue arguments[{max(1, len(route.fields) + 1)}]{{}};", + " auto runtime_session = feature->runtime();", + " auto context = runtime_session ? runtime_session->platform_context() : nullptr;", + " if (!context) throw std::runtime_error(\"platform Context is unavailable\");", + " arguments[0].l = static_cast(context.get());", + ]) + for argument_index, field in enumerate(route.constructor_fields): + field_index = next( + index for index, candidate in enumerate(route.fields) + if candidate.field_id == field.field_id + ) + lines.extend( + _argument_assignment( + field.semantic_type, + f"field_{field_index}", + argument_index + 1, + feature_id, + " ", + ) + ) + lines.extend([ + " auto local = env->CallStaticObjectMethodA(", + " static_cast(route->adapter_class.get()), route->method, arguments);", + " require_no_implementation_exception(env);", + " if (local == nullptr) throw std::runtime_error(\"JVM value constructor returned null\");", + " return ManagedJvmValue(retain_global(env, local));", + ]) + lines.append("}") + return "\n".join(lines) + + +def _as_jobject( + semantic: SemanticType, expression: str, feature_id: str, indent: str +) -> list[str]: + if semantic.kind is SemanticTypeKind.SCALAR: + if semantic.scalar in { + ScalarKind.BOOL, + ScalarKind.INT32, + ScalarKind.INT64, + ScalarKind.FLOAT32, + ScalarKind.FLOAT64, + }: + lines = _box_scalar(semantic.scalar, expression, feature_id, indent) + lines.append(f"{indent}jobject item_object = boxed;") + return lines + if semantic.scalar is ScalarKind.STRING: + return [ + f"{indent}jobject item_object = write_byte_array(", + f"{indent} env, reinterpret_cast({expression}.data()), {expression}.size());", + ] + return [ + f"{indent}jobject item_object = write_byte_array(", + f"{indent} env, {expression}.data(), {expression}.size());" + ] + return [ + f"{indent}jobject item_object = {expression} ? {expression}.get() : nullptr;" + ] + + +def _argument_assignment( + semantic: SemanticType, + expression: str, + index: int, + feature_id: str, + indent: str, +) -> list[str]: + if semantic.kind is SemanticTypeKind.SCALAR: + if semantic.scalar is ScalarKind.BOOL: + return [f"{indent}arguments[{index}].z = {expression} ? JNI_TRUE : JNI_FALSE;"] + field, cast = { + ScalarKind.INT32: ("i", "jint"), + ScalarKind.INT64: ("j", "jlong"), + ScalarKind.FLOAT32: ("f", "jfloat"), + ScalarKind.FLOAT64: ("d", "jdouble"), + }.get(semantic.scalar, (None, None)) + if field is not None: + return [f"{indent}arguments[{index}].{field} = static_cast<{cast}>({expression});"] + data = ( + f"reinterpret_cast({expression}.data())" + if semantic.scalar is ScalarKind.STRING + else f"{expression}.data()" + ) + return [ + f"{indent}arguments[{index}].l = write_byte_array(", + f"{indent} env, {data}, {expression}.size());", + ] + return [ + f"{indent}arguments[{index}].l = {expression} ? {expression}.get() : nullptr;" + ] + + +def _read_jobject( + semantic: SemanticType, + expression: str, + plan: JvmRoutePlan, + feature_id: str, + indent: str, + target: str, +) -> list[str]: + if semantic.kind is SemanticTypeKind.NULLABLE: + return [ + f"{indent}ManagedJvmValue {target} = {expression} == nullptr", + f"{indent} ? ManagedJvmValue{{}}", + f"{indent} : ManagedJvmValue(retain_global(env, {expression}));", + ] + if semantic.kind is SemanticTypeKind.OBJECT_REF: + assert semantic.type_id is not None + return [ + f"{indent}if ({expression} == nullptr) throw std::runtime_error(\"JVM object result was null\");", + f"{indent}ManagedJvmRef {target}({json.dumps(semantic.type_id)}, retain_global(env, {expression}));", + ] + if semantic.kind in { + SemanticTypeKind.ARRAY, + SemanticTypeKind.VALUE_REF, + SemanticTypeKind.ENUM_REF, + }: + return [ + f"{indent}if ({expression} == nullptr) throw std::runtime_error(\"JVM result was null\");", + f"{indent}ManagedJvmValue {target}(retain_global(env, {expression}));", + ] + assert semantic.kind is SemanticTypeKind.SCALAR + if semantic.scalar in {ScalarKind.STRING, ScalarKind.BYTES}: + lines = [ + f"{indent}auto {target}_bytes = read_byte_array(env, static_cast({expression}));", + ] + if semantic.scalar is ScalarKind.STRING: + lines.extend([ + f"{indent}std::string {target}(", + f"{indent} reinterpret_cast({target}_bytes.data()), {target}_bytes.size());", + ]) + else: + lines.append(f"{indent}auto {target} = std::move({target}_bytes);") + return lines + unbox, converted = _unbox_scalar( + semantic.scalar, expression, feature_id, indent + ) + unbox.append(f"{indent}auto {target} = {converted};") + return unbox + + +def _to_definition( + semantic: SemanticType, plan: JvmRoutePlan, feature_id: str +) -> str: + native = _native(semantic) + lines = [ + f"facebook::jsi::Value {_to_name(semantic)}(", + " facebook::jsi::Runtime &runtime,", + f" const {native} &value,", + " JNIEnv *env,", + " const std::shared_ptr ®istry,", + " const std::shared_ptr &feature,", + " supernote::conversion::Budget &budget,", + " const std::string &path,", + " std::uint64_t depth) {", + " budget.visit(path, depth);", + ] + kind = semantic.kind + if kind is SemanticTypeKind.NULLABLE: + assert semantic.element is not None + child = semantic.element + lines.append(" if (!value) return facebook::jsi::Value::null();") + lines.extend( + _read_jobject( + child, "value.get()", plan, feature_id, " ", "child" + ) + ) + lines.append( + f" return {_to_name(child)}(runtime, child, env, registry, feature, budget, path, depth + 1);" + ) + elif kind is SemanticTypeKind.SCALAR: + if semantic.scalar is ScalarKind.BOOL: + lines.append(" return facebook::jsi::Value(value);") + elif semantic.scalar in {ScalarKind.INT32, ScalarKind.FLOAT32, ScalarKind.FLOAT64}: + lines.append(" return facebook::jsi::Value(static_cast(value));") + elif semantic.scalar is ScalarKind.INT64: + lines.append( + " return facebook::jsi::Value(facebook::jsi::BigInt::fromInt64(runtime, value));" + ) + elif semantic.scalar is ScalarKind.STRING: + lines.extend([ + " budget.check_string_bytes(path, value.size());", + " return facebook::jsi::Value(facebook::jsi::String::createFromUtf8(runtime, value));", + ]) + else: + lines.extend([ + " budget.check_byte_buffer(path, value.size());", + " return supernote_make_uint8_array(runtime, value);", + ]) + elif kind is SemanticTypeKind.OBJECT_REF: + assert semantic.type_id is not None + index = next( + index for index, route in enumerate(plan.objects) + if route.named_type.type_id == semantic.type_id + ) + lines.extend([ + " budget.reserve(path, sizeof(void *));", + f" return facebook::jsi::Value(supernote_v3_wrap_jvm_object_{index}(", + " runtime, env, registry, feature, value));", + ]) + elif kind is SemanticTypeKind.ENUM_REF: + assert semantic.type_id is not None + route = next( + item for item in plan.enums + if item.named_type.type_id == semantic.type_id + ) + adapter = _enum_class(route.named_type.source_declaration_id) + resolved = _route_expression( + key=f"jvm-v3-enum-name:{semantic.type_id}", + adapter_class=adapter, + descriptor=f"(L{route.named_type.owner_class.replace('.', '/')};)[B", + method="name", + ) + lines.extend([ + f" auto route = {resolved};", + " jvalue arguments[1]{};", + " arguments[0].l = value.get();", + " auto bytes = env->CallStaticObjectMethodA(", + " static_cast(route->adapter_class.get()), route->method, arguments);", + " require_no_implementation_exception(env);", + " auto text_bytes = read_byte_array(env, static_cast(bytes));", + " std::string text(reinterpret_cast(text_bytes.data()), text_bytes.size());", + " budget.check_string_bytes(path, text.size());", + " return facebook::jsi::Value(facebook::jsi::String::createFromUtf8(runtime, text));", + ]) + elif kind is SemanticTypeKind.ARRAY: + assert semantic.element is not None + size_route = _helper_route(feature_id, "listSize", "(Ljava/util/List;)I") + get_route = _helper_route( + feature_id, + "listGet", + "(Ljava/util/List;I)Ljava/lang/Object;", + ) + lines.extend([ + f" auto size_route = {size_route};", + " jvalue size_arguments[1]{};", + " size_arguments[0].l = value.get();", + " auto length = env->CallStaticIntMethodA(", + " static_cast(size_route->adapter_class.get()),", + " size_route->method, size_arguments);", + " require_no_implementation_exception(env);", + " if (length < 0) throw std::runtime_error(\"JVM list has invalid size\");", + " budget.check_array_length(path, static_cast(length));", + " facebook::jsi::Array result(runtime, static_cast(length));", + f" auto get_route = {get_route};", + " for (jint index = 0; index < length; ++index) {", + " jvalue get_arguments[2]{};", + " get_arguments[0].l = value.get();", + " get_arguments[1].i = index;", + " auto local = env->CallStaticObjectMethodA(", + " static_cast(get_route->adapter_class.get()),", + " get_route->method, get_arguments);", + " require_no_implementation_exception(env);", + " auto item_path = supernote::conversion::index_path(path, static_cast(index));", + ]) + lines.extend( + _read_jobject( + semantic.element, + "local", + plan, + feature_id, + " ", + "item", + ) + ) + lines.extend([ + f" auto converted = {_to_name(semantic.element)}(", + " runtime, item, env, registry, feature, budget, item_path, depth + 1);", + " result.setValueAtIndex(runtime, static_cast(index), std::move(converted));", + " }", + " return facebook::jsi::Value(std::move(result));", + ]) + else: + assert kind is SemanticTypeKind.VALUE_REF and semantic.type_id is not None + route = next( + item for item in plan.values + if item.named_type.type_id == semantic.type_id + ) + lines.extend([ + " facebook::jsi::Object result(runtime);", + ]) + for index, field in enumerate(route.fields): + adapter = "supernote.generated.adapters.Adapter_" + field.accessor_identity.rsplit(".", 1)[-1] + getter = _route_expression( + key=f"jvm-v3-field-get:{field.source_declaration_id}", + adapter_class=adapter, + descriptor=field.getter_descriptor, + method="get", + ) + lines.extend([ + f" auto getter_{index} = {getter};", + f" jvalue getter_{index}_arguments[1]{{}};", + f" getter_{index}_arguments[0].l = value.get();", + ]) + lines.extend( + _call_and_convert_result( + field.semantic_type, + f"getter_{index}", + f"getter_{index}_arguments", + f"field_{index}", + plan, + feature_id, + " ", + ) + ) + lines.extend([ + f" auto field_{index}_path = supernote::conversion::field_path(path, {json.dumps(field.public_name)});", + f" auto field_{index}_js = {_to_name(field.semantic_type)}(", + f" runtime, field_{index}, env, registry, feature, budget, field_{index}_path, depth + 1);", + f" result.setProperty(runtime, {json.dumps(field.public_name)}, std::move(field_{index}_js));", + ]) + lines.append(" return facebook::jsi::Value(std::move(result));") + lines.append("}") + return "\n".join(lines) + + +def _jni_call(semantic: SemanticType) -> str: + if semantic.kind is SemanticTypeKind.SCALAR: + return { + ScalarKind.BOOL: "CallStaticBooleanMethodA", + ScalarKind.INT32: "CallStaticIntMethodA", + ScalarKind.INT64: "CallStaticLongMethodA", + ScalarKind.FLOAT32: "CallStaticFloatMethodA", + ScalarKind.FLOAT64: "CallStaticDoubleMethodA", + ScalarKind.STRING: "CallStaticObjectMethodA", + ScalarKind.BYTES: "CallStaticObjectMethodA", + }[semantic.scalar] + if semantic.kind is SemanticTypeKind.VOID: + return "CallStaticVoidMethodA" + return "CallStaticObjectMethodA" + + +def _call_and_convert_result( + semantic: SemanticType, + route: str, + arguments: str, + target: str, + plan: JvmRoutePlan, + feature_id: str, + indent: str, +) -> list[str]: + call = _jni_call(semantic) + if semantic.kind is SemanticTypeKind.VOID: + return [ + f"{indent}env->{call}(", + f"{indent} static_cast({route}->adapter_class.get()),", + f"{indent} {route}->method, {arguments});", + f"{indent}require_no_implementation_exception(env);", + ] + lines = [ + f"{indent}auto {target}_raw = env->{call}(", + f"{indent} static_cast({route}->adapter_class.get()),", + f"{indent} {route}->method, {arguments});", + f"{indent}require_no_implementation_exception(env);", + ] + if semantic.kind is SemanticTypeKind.SCALAR and semantic.scalar not in { + ScalarKind.STRING, + ScalarKind.BYTES, + }: + conversion = { + ScalarKind.BOOL: f"{target}_raw == JNI_TRUE", + ScalarKind.INT32: f"static_cast({target}_raw)", + ScalarKind.INT64: f"static_cast({target}_raw)", + ScalarKind.FLOAT32: f"static_cast({target}_raw)", + ScalarKind.FLOAT64: f"static_cast({target}_raw)", + }[semantic.scalar] + lines.append(f"{indent}auto {target} = {conversion};") + else: + lines.extend( + _read_jobject( + semantic, + f"{target}_raw", + plan, + feature_id, + indent, + target, + ) + ) + return lines + + +def _callable_body( + route: JvmCallableRoute, + *, + diagnostic: str, + plan: JvmRoutePlan, + feature_id: str, + instance: bool, + context: bool = False, + indent: str, +) -> str: + if route.execution is ExecutionMode.ASYNC: + raise AssertionError("async callables require the async host-function emitter") + lines = [ + f"{indent}if (argument_count != {len(route.parameters)}) {{", + f"{indent} supernote_throw_type_error(runtime, {json.dumps(diagnostic + ': wrong argument count')},", + f"{indent} \"ARITY_MISMATCH\", {json.dumps(diagnostic)},", + f"{indent} {json.dumps(str(len(route.parameters)) + ' arguments')},", + f"{indent} std::to_string(argument_count) + \" arguments\");", + f"{indent}}}", + f"{indent}if (!feature || feature->state() != supernote::runtime::FeatureState::ACTIVE) {{", + f"{indent} supernote_throw_error(runtime, \"FEATURE_CLOSED\", \"feature is closed\");", + f"{indent}}}", + f"{indent}AttachedEnv attached;", + f"{indent}auto *env = attached.get();", + f"{indent}if (env == nullptr) throw std::runtime_error(\"cannot attach to JavaVM\");", + f"{indent}LocalFrame frame(env);", + f"{indent}supernote::conversion::Budget input_budget;", + ] + for index, parameter in enumerate(route.parameters): + lines.extend([ + f"{indent}auto argument_{index} = {_from_name(parameter)}(", + f"{indent} runtime, arguments[{index}], env, feature, input_budget,", + f"{indent} {json.dumps(diagnostic + ': argument ' + str(index + 1))}, 0);", + ]) + adapter = "supernote.generated.adapters.Adapter_" + route.adapter_identity.rsplit(".", 1)[-1] + resolved = _route_expression( + key=f"jvm-v3-call:{route.source_declaration_id}", + adapter_class=adapter, + descriptor=route.adapter_descriptor, + ) + offset = 1 if (instance or context) else 0 + lines.extend([ + f"{indent}auto resolved = {resolved};", + f"{indent}jvalue jvm_arguments[{max(1, len(route.parameters) + offset)}]{{}};", + ]) + if instance: + lines.append(f"{indent}jvm_arguments[0].l = owner.get();") + elif context: + lines.extend([ + f"{indent}auto runtime_session = feature->runtime();", + f"{indent}auto context_value = runtime_session ? runtime_session->platform_context() : nullptr;", + f"{indent}if (!context_value) throw std::runtime_error(\"platform Context is unavailable\");", + f"{indent}jvm_arguments[0].l = static_cast(context_value.get());", + ]) + for index, parameter in enumerate(route.parameters): + assignments = _argument_assignment( + parameter, + f"argument_{index}", + index + offset, + feature_id, + indent, + ) + lines.extend(line.replace("arguments[", "jvm_arguments[") for line in assignments) + if route.result.kind is SemanticTypeKind.VOID: + lines.extend( + _call_and_convert_result( + route.result, + "resolved", + "jvm_arguments", + "result", + plan, + feature_id, + indent, + ) + ) + lines.append(f"{indent}return facebook::jsi::Value::undefined();") + else: + lines.extend( + _call_and_convert_result( + route.result, + "resolved", + "jvm_arguments", + "result", + plan, + feature_id, + indent, + ) + ) + lines.extend([ + f"{indent}supernote::conversion::Budget output_budget;", + f"{indent}return {_to_name(route.result)}(", + f"{indent} runtime, result, env, registry, feature, output_budget,", + f"{indent} {json.dumps(diagnostic + ': result')}, 0);", + ]) + body = "\n".join(" " + line for line in lines) + return f'''{indent}try {{ +{body} +{indent}}} catch (const facebook::jsi::JSError &) {{ +{indent} throw; +{indent}}} catch (const supernote::conversion::Failure &error) {{ +{indent} if (error.kind() == supernote::conversion::FailureKind::TYPE) {{ +{indent} supernote_throw_type_error(runtime, error.what()); +{indent} }} +{indent} if (error.kind() == supernote::conversion::FailureKind::RANGE) {{ +{indent} supernote_throw_range_error(runtime, error.what()); +{indent} }} +{indent} supernote_throw_error(runtime, "RESOURCE_EXHAUSTED", error.what()); +{indent}}} catch (const JvmImplementationFailure &error) {{ +{indent} supernote_throw_error(runtime, "IMPLEMENTATION_ERROR", error.what()); +{indent}}} catch (const std::exception &error) {{ +{indent} supernote_throw_error(runtime, "INTERNAL", error.what()); +{indent}}}''' + + +def _async_host_function( + route: JvmCallableRoute, + *, + diagnostic: str, + plan: JvmRoutePlan, + feature_id: str, + receiver: bool, + indent: str, +) -> str: + if route.suspend: + return _suspend_host_function( + route, + diagnostic=diagnostic, + plan=plan, + feature_id=feature_id, + receiver=receiver, + indent=indent, + ) + native_result = _native(route.result) + state_value = ( + "" + if route.result.kind is SemanticTypeKind.VOID + else f"std::optional<{native_result}> value;" + ) + adapter = "supernote.generated.adapters.Adapter_" + route.adapter_identity.rsplit(".", 1)[-1] + resolved = _route_expression( + key=f"jvm-v3-call:{route.source_declaration_id}", + adapter_class=adapter, + descriptor=route.adapter_descriptor, + ) + offset = 1 if receiver else 0 + worker_lines = [ + "auto feature = implementation_feature;", + "AttachedEnv attached;", + "auto *env = attached.get();", + "if (env == nullptr) throw std::runtime_error(\"cannot attach to JavaVM\");", + "LocalFrame frame(env);", + f"auto resolved = {resolved};", + f"jvalue jvm_arguments[{max(1, len(route.parameters) + offset)}]{{}};", + ] + if receiver: + worker_lines.append("jvm_arguments[0].l = owner.get();") + for index, parameter in enumerate(route.parameters): + assignments = _argument_assignment( + parameter, + f"argument_{index}", + index + offset, + feature_id, + "", + ) + worker_lines.extend( + line.replace("arguments[", "jvm_arguments[") for line in assignments + ) + worker_lines.extend( + _call_and_convert_result( + route.result, + "resolved", + "jvm_arguments", + "result", + plan, + feature_id, + "", + ) + ) + if route.result.kind is not SemanticTypeKind.VOID: + worker_lines.append("state->value.emplace(std::move(result));") + worker_lines.append("state->success = true;") + + completion_lines = [] + if route.result.kind is SemanticTypeKind.VOID: + completion_lines.append( + "supernote_resolve_operation(runtime, operation_id, facebook::jsi::Value::undefined());" + ) + else: + completion_lines.extend([ + "AttachedEnv attached;", + "auto *env = attached.get();", + "if (env == nullptr) throw std::runtime_error(\"cannot attach to JavaVM\");", + "LocalFrame frame(env);", + "auto registry = supernote_v3_jvm_object_registry(runtime);", + "supernote::conversion::Budget result_budget;", + f"auto value = {_to_name(route.result)}(", + " runtime, *state->value, env, registry, completion_feature,", + f" result_budget, {json.dumps(diagnostic + ': result')}, 0);", + "supernote_resolve_operation(runtime, operation_id, std::move(value));", + ]) + + outer_conversions = [ + "if (argument_count != " + str(len(route.parameters)) + ") {", + " supernote_throw_type_error(runtime, " + + json.dumps(diagnostic + ": wrong argument count") + + ", \"ARITY_MISMATCH\", " + + json.dumps(diagnostic) + + ", " + + json.dumps(str(len(route.parameters)) + " arguments") + + ", std::to_string(argument_count) + \" arguments\");", + "}", + "AttachedEnv attached;", + "auto *env = attached.get();", + "if (env == nullptr) throw std::runtime_error(\"cannot attach to JavaVM\");", + "LocalFrame frame(env);", + "supernote::conversion::Budget input_budget;", + ] + for index, parameter in enumerate(route.parameters): + outer_conversions.extend([ + f"auto argument_{index} = {_from_name(parameter)}(", + f" runtime, arguments[{index}], env, feature, input_budget,", + f" {json.dumps(diagnostic + ': argument ' + str(index + 1))}, 0);", + ]) + outer_conversions.extend([ + "if (!feature || feature->state() != supernote::runtime::FeatureState::ACTIVE) {", + " supernote_throw_error(runtime, \"FEATURE_CLOSED\", \"feature is closed\");", + "}", + "struct AsyncState {", + " bool success{false};", + f" {state_value}", + " std::string error_code{\"IMPLEMENTATION_ERROR\"};", + " std::string error;", + "};", + "auto state = std::make_shared();", + ]) + retained_types = [ + *(["ManagedJvmRef"] if receiver else []), + *(_native(parameter) for parameter in route.parameters), + ] + retained_values = [ + *(["owner"] if receiver else []), + *(f"argument_{index}" for index, _ in enumerate(route.parameters)), + ] + if retained_types: + outer_conversions.extend([ + "auto retained_input_state = std::make_shared>(", + " " + ", ".join(retained_values) + ");", + ]) + else: + outer_conversions.append( + "auto retained_input_state = std::make_shared>();" + ) + executor_captures = ["feature", "state", "retained_input_state"] + worker_captures = ["operation", "operation_id", "weak_feature", "state"] + if receiver: + executor_captures.append("owner") + worker_captures.append("owner = std::move(owner)") + for index, _ in enumerate(route.parameters): + executor_captures.append(f"argument_{index} = std::move(argument_{index})") + worker_captures.append(f"argument_{index} = std::move(argument_{index})") + worker = "\n".join(" " + line for line in worker_lines) + completion = "\n".join(" " + line for line in completion_lines) + outer = "\n".join(indent + " " + line for line in outer_conversions) + argument_parameter = ( + "const facebook::jsi::Value *arguments" + if route.parameters + else "const facebook::jsi::Value *" + ) + return f'''facebook::jsi::Function::createFromHostFunction( +{indent} runtime, facebook::jsi::PropNameID::forAscii(runtime, {json.dumps(route.public_name)}), +{indent} {len(route.parameters)}, +{indent} [feature{', owner' if receiver else ''}](facebook::jsi::Runtime &runtime, +{indent} const facebook::jsi::Value &, {argument_parameter}, +{indent} std::size_t argument_count) mutable -> facebook::jsi::Value {{ +{indent} try {{ +{outer} +{indent} auto executor = facebook::jsi::Function::createFromHostFunction( +{indent} runtime, +{indent} facebook::jsi::PropNameID::forAscii(runtime, "SupernoteAsyncExecutor"), 2, +{indent} [{', '.join(executor_captures)}](facebook::jsi::Runtime &runtime, +{indent} const facebook::jsi::Value &, +{indent} const facebook::jsi::Value *continuation_arguments, +{indent} std::size_t continuation_count) mutable -> facebook::jsi::Value {{ +{indent} if (continuation_count != 2 || +{indent} !continuation_arguments[0].isObject() || +{indent} !continuation_arguments[1].isObject()) {{ +{indent} throw facebook::jsi::JSError( +{indent} runtime, "Promise supplied invalid continuation functions"); +{indent} }} +{indent} auto operation = feature->accept_factory( +{indent} [](supernote::runtime::SessionId operation_id) {{ +{indent} return [operation_id](void *runtime_pointer) {{ +{indent} auto &runtime = *static_cast(runtime_pointer); +{indent} supernote_reject_operation( +{indent} runtime, operation_id, "FEATURE_CLOSED", +{indent} "feature closed before async completion"); +{indent} }}; +{indent} }}); +{indent} if (!operation) {{ +{indent} supernote_reject_new_promise( +{indent} runtime, continuation_arguments[1], "FEATURE_CLOSED", +{indent} "feature is closed"); +{indent} return facebook::jsi::Value::undefined(); +{indent} }} +{indent} operation->set_retained_state(retained_input_state); +{indent} const auto operation_id = operation->id(); +{indent} supernote_register_continuation( +{indent} runtime, operation_id, continuation_arguments[0], +{indent} continuation_arguments[1]); +{indent} std::weak_ptr weak_feature = feature; +{indent} auto work = supernote::runtime::process_services().workers().submit( +{indent} [{', '.join(worker_captures)}]( +{indent} supernote::runtime::CancellationToken executor_cancel) mutable {{ +{indent} if (executor_cancel.is_cancelled() || +{indent} operation->cancellation_token().is_cancelled()) return; +{indent} auto implementation_feature = weak_feature.lock(); +{indent} if (!implementation_feature) return; +{indent} supernote::runtime::FeatureCallScope feature_call_scope( +{indent} implementation_feature); +{indent} try {{ +{worker} +{indent} }} catch (const JvmImplementationFailure &error) {{ +{indent} state->error_code = "IMPLEMENTATION_ERROR"; +{indent} state->error = error.what(); +{indent} }} catch (const std::exception &error) {{ +{indent} state->error_code = "INTERNAL"; +{indent} state->error = error.what(); +{indent} }} catch (...) {{ +{indent} state->error_code = "INTERNAL"; +{indent} state->error = "unknown JVM route failure"; +{indent} }} +{indent} if (executor_cancel.is_cancelled() || +{indent} operation->cancellation_token().is_cancelled()) return; +{indent} auto completion_feature = weak_feature.lock(); +{indent} if (!completion_feature) return; +{indent} completion_feature->schedule_completion( +{indent} operation, +{indent} [state, operation_id, completion_feature](void *runtime_pointer) {{ +{indent} auto &runtime = *static_cast(runtime_pointer); +{indent} if (!state->success) {{ +{indent} supernote_reject_operation( +{indent} runtime, operation_id, state->error_code.c_str(), +{indent} state->error.empty() ? "JVM implementation failed" : state->error); +{indent} return; +{indent} }} +{indent} try {{ +{completion} +{indent} }} catch (const std::exception &error) {{ +{indent} supernote_reject_operation( +{indent} runtime, operation_id, "INTERNAL", error.what()); +{indent} }} +{indent} }}); +{indent} }}); +{indent} operation->set_work(work); +{indent} if (!work.accepted()) {{ +{indent} feature->schedule_completion( +{indent} operation, [operation_id](void *runtime_pointer) {{ +{indent} auto &runtime = *static_cast(runtime_pointer); +{indent} supernote_reject_operation( +{indent} runtime, operation_id, "RESOURCE_EXHAUSTED", +{indent} "Supernote worker queue is full"); +{indent} }}); +{indent} }} +{indent} return facebook::jsi::Value::undefined(); +{indent} }}); +{indent} auto promise = runtime.global().getPropertyAsFunction(runtime, "Promise"); +{indent} const facebook::jsi::Value executor_argument(std::move(executor)); +{indent} return promise.callAsConstructor( +{indent} runtime, &executor_argument, static_cast(1)); +{indent} }} catch (const facebook::jsi::JSError &) {{ +{indent} throw; +{indent} }} catch (const std::exception &error) {{ +{indent} supernote_throw_error(runtime, "INTERNAL", error.what()); +{indent} }} +{indent} }})''' + + +def _suspend_host_function( + route: JvmCallableRoute, + *, + diagnostic: str, + plan: JvmRoutePlan, + feature_id: str, + receiver: bool, + indent: str, +) -> str: + native_result = _native(route.result) + state_value = ( + "" + if route.result.kind is SemanticTypeKind.VOID + else f"std::optional<{native_result}> value;" + ) + adapter = ( + "supernote.generated.adapters.Adapter_" + + route.adapter_identity.rsplit(".", 1)[-1] + ) + resolved = _route_expression( + key=f"jvm-v3-call:{route.source_declaration_id}", + adapter_class=adapter, + descriptor=route.adapter_descriptor, + ) + cancel_resolved = _route_expression( + key="jvm-v3-coroutine-cancel", + adapter_class="supernote.generated.runtime.SupernoteCoroutineBridge", + descriptor="(Lkotlinx/coroutines/Job;)V", + method="cancel", + ) + offset = 1 if receiver else 0 + argument_count = len(route.parameters) + offset + 1 + launch_lines = [ + "auto feature = implementation_feature;", + "AttachedEnv attached;", + "auto *env = attached.get();", + "if (env == nullptr) throw std::runtime_error(\"cannot attach to JavaVM\");", + "LocalFrame frame(env);", + f"auto resolved = {resolved};", + f"auto cancel_resolved = {cancel_resolved};", + f"jvalue jvm_arguments[{max(1, argument_count)}]{{}};", + ] + if receiver: + launch_lines.append("jvm_arguments[0].l = owner.get();") + for index, parameter in enumerate(route.parameters): + assignments = _argument_assignment( + parameter, + f"argument_{index}", + index + offset, + feature_id, + "", + ) + launch_lines.extend( + line.replace("arguments[", "jvm_arguments[") for line in assignments + ) + launch_lines.extend([ + f"jvm_arguments[{argument_count - 1}].j = static_cast(completion_id);", + "auto local_job = env->CallStaticObjectMethodA(", + " static_cast(resolved->adapter_class.get()),", + " resolved->method, jvm_arguments);", + "if (env->ExceptionCheck()) require_no_implementation_exception(env);", + "if (local_job == nullptr) {", + " throw std::runtime_error(\"cannot launch generated Kotlin coroutine adapter\");", + "}", + "auto job = retain_global(env, local_job);", + "operation->set_cancel_hook([completion_id, job, cancel_resolved] {", + " supernote::runtime::process_services().discard_jvm_async_completion(completion_id);", + " try {", + " AttachedEnv attached;", + " auto *env = attached.get();", + " if (env == nullptr) return;", + " LocalFrame frame(env);", + " jvalue arguments[1]{}; arguments[0].l = static_cast(job.get());", + " env->CallStaticVoidMethodA(", + " static_cast(cancel_resolved->adapter_class.get()),", + " cancel_resolved->method, arguments);", + " clear_exception(env);", + " } catch (...) {}", + "});", + ]) + decoded = [] + if route.result.kind is SemanticTypeKind.VOID: + decoded.append("state->success = true;") + else: + decoded.extend([ + "auto *env = static_cast(environment);", + "auto object = static_cast(result);", + "if (env == nullptr) {", + " throw std::runtime_error(\"Kotlin coroutine result has no JNI environment\");", + "}", + ]) + if route.result.kind is not SemanticTypeKind.NULLABLE: + decoded.extend([ + "if (object == nullptr) {", + " throw std::runtime_error(\"Kotlin coroutine returned null\");", + "}", + ]) + decoded.append("LocalFrame frame(env);") + decoded.extend( + line.strip() + for line in _read_jobject( + route.result, + "object", + plan, + feature_id, + "", + "decoded_result", + ) + ) + decoded.extend([ + "state->value.emplace(std::move(decoded_result));", + "state->success = true;", + ]) + completion_lines = [] + if route.result.kind is SemanticTypeKind.VOID: + completion_lines.append( + "supernote_resolve_operation(runtime, operation_id, facebook::jsi::Value::undefined());" + ) + else: + completion_lines.extend([ + "AttachedEnv attached;", + "auto *env = attached.get();", + "if (env == nullptr) throw std::runtime_error(\"cannot attach to JavaVM\");", + "LocalFrame frame(env);", + "auto registry = supernote_v3_jvm_object_registry(runtime);", + "supernote::conversion::Budget result_budget;", + f"auto value = {_to_name(route.result)}(", + " runtime, *state->value, env, registry, completion_feature,", + f" result_budget, {json.dumps(diagnostic + ': result')}, 0);", + "supernote_resolve_operation(runtime, operation_id, std::move(value));", + ]) + outer = [ + f"if (argument_count != {len(route.parameters)}) {{", + " supernote_throw_type_error(runtime, " + + json.dumps(diagnostic + ": wrong argument count") + + ", \"ARITY_MISMATCH\", " + + json.dumps(diagnostic) + + ", " + + json.dumps(str(len(route.parameters)) + " arguments") + + ", std::to_string(argument_count) + \" arguments\");", + "}", + "AttachedEnv attached;", + "auto *env = attached.get();", + "if (env == nullptr) throw std::runtime_error(\"cannot attach to JavaVM\");", + "LocalFrame frame(env);", + "supernote::conversion::Budget input_budget;", + ] + for index, parameter in enumerate(route.parameters): + outer.extend([ + f"auto argument_{index} = {_from_name(parameter)}(", + f" runtime, arguments[{index}], env, feature, input_budget,", + f" {json.dumps(diagnostic + ': argument ' + str(index + 1))}, 0);", + ]) + outer.extend([ + "if (!feature || feature->state() != supernote::runtime::FeatureState::ACTIVE) {", + " supernote_throw_error(runtime, \"FEATURE_CLOSED\", \"feature is closed\");", + "}", + "struct SuspendState {", + " bool success{false};", + f" {state_value}", + " std::string error_code;", + " std::string error;", + "};", + "auto state = std::make_shared();", + ]) + retained_types = [ + *(["ManagedJvmRef"] if receiver else []), + *(_native(parameter) for parameter in route.parameters), + ] + retained_values = [ + *(["owner"] if receiver else []), + *(f"argument_{index}" for index, _ in enumerate(route.parameters)), + ] + if retained_types: + outer.extend([ + "auto retained_input_state = std::make_shared>(", + " " + ", ".join(retained_values) + ");", + ]) + else: + outer.append("auto retained_input_state = std::make_shared>();") + executor_captures = ["feature", "state", "retained_input_state"] + worker_captures = [ + "operation", + "weak_feature", + "completion_id", + ] + if receiver: + executor_captures.append("owner") + worker_captures.append("owner = std::move(owner)") + for index, _ in enumerate(route.parameters): + executor_captures.append(f"argument_{index} = std::move(argument_{index})") + worker_captures.append(f"argument_{index} = std::move(argument_{index})") + outer_text = "\n".join(indent + " " + line for line in outer) + launch = "\n".join(indent + " " + line for line in launch_lines) + decode = "\n".join(indent + " " + line for line in decoded) + completion = "\n".join( + indent + " " + line + for line in completion_lines + ) + argument_parameter = ( + "const facebook::jsi::Value *arguments" + if route.parameters + else "const facebook::jsi::Value *" + ) + return f'''facebook::jsi::Function::createFromHostFunction( +{indent} runtime, facebook::jsi::PropNameID::forAscii(runtime, {json.dumps(route.public_name)}), +{indent} {len(route.parameters)}, +{indent} [feature{', owner' if receiver else ''}](facebook::jsi::Runtime &runtime, +{indent} const facebook::jsi::Value &, {argument_parameter}, +{indent} std::size_t argument_count) mutable -> facebook::jsi::Value {{ +{indent} try {{ +{outer_text} +{indent} auto executor = facebook::jsi::Function::createFromHostFunction( +{indent} runtime, facebook::jsi::PropNameID::forAscii(runtime, "SupernoteSuspendExecutor"), 2, +{indent} [{', '.join(executor_captures)}](facebook::jsi::Runtime &runtime, +{indent} const facebook::jsi::Value &, +{indent} const facebook::jsi::Value *continuation_arguments, +{indent} std::size_t continuation_count) mutable -> facebook::jsi::Value {{ +{indent} if (continuation_count != 2 || +{indent} !continuation_arguments[0].isObject() || +{indent} !continuation_arguments[1].isObject()) {{ +{indent} throw facebook::jsi::JSError( +{indent} runtime, "Promise supplied invalid continuation functions"); +{indent} }} +{indent} auto operation = feature->accept_factory( +{indent} [](supernote::runtime::SessionId operation_id) {{ +{indent} return [operation_id](void *runtime_pointer) {{ +{indent} auto &runtime = *static_cast(runtime_pointer); +{indent} supernote_reject_operation( +{indent} runtime, operation_id, "FEATURE_CLOSED", +{indent} "feature closed before async completion"); +{indent} }}; +{indent} }}); +{indent} if (!operation) {{ +{indent} supernote_reject_new_promise( +{indent} runtime, continuation_arguments[1], "FEATURE_CLOSED", +{indent} "feature is closed"); +{indent} return facebook::jsi::Value::undefined(); +{indent} }} +{indent} operation->set_retained_state(retained_input_state); +{indent} const auto operation_id = operation->id(); +{indent} supernote_register_continuation( +{indent} runtime, operation_id, continuation_arguments[0], +{indent} continuation_arguments[1]); +{indent} std::weak_ptr weak_feature = feature; +{indent} const auto completion_id = +{indent} supernote::runtime::process_services().register_jvm_async_completion( +{indent} [operation, operation_id, weak_feature, state]( +{indent} void *environment, void *result, +{indent} std::string error_code, std::string error_message) mutable {{ +{indent} if (operation->cancellation_token().is_cancelled()) return; +{indent} if (!error_code.empty()) {{ +{indent} state->error_code = std::move(error_code); +{indent} state->error = std::move(error_message); +{indent} }} else {{ +{indent} try {{ +{decode} +{indent} }} catch (const std::exception &error) {{ +{indent} state->error_code = "INTERNAL"; +{indent} state->error = error.what(); +{indent} }} catch (...) {{ +{indent} state->error_code = "INTERNAL"; +{indent} state->error = "cannot decode Kotlin coroutine result"; +{indent} }} +{indent} }} +{indent} if (operation->cancellation_token().is_cancelled()) return; +{indent} auto completion_feature = weak_feature.lock(); +{indent} if (!completion_feature) return; +{indent} completion_feature->schedule_completion( +{indent} operation, +{indent} [state, operation_id, completion_feature](void *runtime_pointer) {{ +{indent} auto &runtime = *static_cast(runtime_pointer); +{indent} if (!state->success) {{ +{indent} supernote_reject_operation( +{indent} runtime, operation_id, +{indent} state->error_code.empty() ? "INTERNAL" : state->error_code.c_str(), +{indent} state->error.empty() ? "Kotlin coroutine failed" : state->error); +{indent} return; +{indent} }} +{indent} try {{ +{completion} +{indent} }} catch (const std::exception &error) {{ +{indent} supernote_reject_operation( +{indent} runtime, operation_id, "INTERNAL", error.what()); +{indent} }} +{indent} }}); +{indent} }}); +{indent} operation->set_cancel_hook([completion_id] {{ +{indent} supernote::runtime::process_services().discard_jvm_async_completion(completion_id); +{indent} }}); +{indent} auto work = supernote::runtime::process_services().workers().submit( +{indent} [{', '.join(worker_captures)}]( +{indent} supernote::runtime::CancellationToken executor_cancel) mutable {{ +{indent} if (executor_cancel.is_cancelled() || +{indent} operation->cancellation_token().is_cancelled()) return; +{indent} auto implementation_feature = weak_feature.lock(); +{indent} if (!implementation_feature || +{indent} implementation_feature->state() != +{indent} supernote::runtime::FeatureState::ACTIVE) return; +{indent} supernote::runtime::FeatureCallScope feature_call_scope( +{indent} implementation_feature); +{indent} try {{ +{launch} +{indent} }} catch (const JvmImplementationFailure &error) {{ +{indent} supernote::runtime::process_services().complete_jvm_async( +{indent} completion_id, nullptr, nullptr, "IMPLEMENTATION_ERROR", error.what()); +{indent} }} catch (const std::exception &error) {{ +{indent} supernote::runtime::process_services().complete_jvm_async( +{indent} completion_id, nullptr, nullptr, "INTERNAL", error.what()); +{indent} }} catch (...) {{ +{indent} supernote::runtime::process_services().complete_jvm_async( +{indent} completion_id, nullptr, nullptr, "INTERNAL", +{indent} "cannot launch Kotlin coroutine adapter"); +{indent} }} +{indent} }}); +{indent} operation->set_work(work); +{indent} if (!work.accepted()) {{ +{indent} supernote::runtime::process_services().complete_jvm_async( +{indent} completion_id, nullptr, nullptr, "RESOURCE_EXHAUSTED", +{indent} "Supernote worker queue is full"); +{indent} }} +{indent} return facebook::jsi::Value::undefined(); +{indent} }}); +{indent} auto promise = runtime.global().getPropertyAsFunction(runtime, "Promise"); +{indent} const facebook::jsi::Value executor_argument(std::move(executor)); +{indent} return promise.callAsConstructor( +{indent} runtime, &executor_argument, static_cast(1)); +{indent} }} catch (const facebook::jsi::JSError &) {{ +{indent} throw; +{indent} }} catch (const std::exception &error) {{ +{indent} supernote_throw_error(runtime, "INTERNAL", error.what()); +{indent} }} +{indent} }})''' + + +def _jvm_preflight_host_function( + route: JvmCallableRoute, + *, + diagnostic: str, + name: str, + check: bool, + indent: str, +) -> str: + lines = [] + if check: + lines.extend([ + f"{indent} if (argument_count != {len(route.parameters)}) {{", + f"{indent} auto error = supernote_make_builtin_error(", + f"{indent} runtime, \"TypeError\", {json.dumps(diagnostic + ': wrong argument count')},", + f"{indent} \"ARITY_MISMATCH\", {json.dumps(diagnostic)},", + f"{indent} {json.dumps(str(len(route.parameters)) + ' arguments')},", + f"{indent} std::to_string(argument_count) + \" arguments\");", + f"{indent} return supernote_validation_failure(runtime, std::move(error));", + f"{indent} }}", + ]) + else: + lines.append( + f"{indent} if (argument_count != {len(route.parameters)}) return facebook::jsi::Value(false);" + ) + lines.extend([ + f"{indent} try {{", + f"{indent} supernote::conversion::Budget input_budget;", + ]) + for index, semantic in enumerate(route.parameters): + lines.extend([ + f"{indent} {_validate_name(semantic)}(", + f"{indent} runtime, arguments[{index}], input_budget,", + f"{indent} {json.dumps(diagnostic + '.argument[' + str(index) + ']')}, 1);", + ]) + lines.append( + f"{indent} return " + + ("supernote_validation_success(runtime);" if check else "facebook::jsi::Value(true);") + ) + lines.extend([ + f"{indent} }} catch (const facebook::jsi::JSError &error) {{", + ( + f"{indent} return supernote_validation_failure(\n" + f"{indent} runtime, facebook::jsi::Value(runtime, error.value()));" + if check + else f"{indent} return facebook::jsi::Value(false);" + ), + f"{indent} }} catch (const supernote::conversion::Failure &failure) {{", + f"{indent} if (failure.kind() == supernote::conversion::FailureKind::ALLOCATION) {{", + f"{indent} supernote_throw_error(runtime, \"RESOURCE_EXHAUSTED\", failure.what());", + f"{indent} }}", + ]) + if check: + lines.extend([ + f"{indent} const bool range =", + f"{indent} failure.kind() == supernote::conversion::FailureKind::RANGE;", + f"{indent} auto error = supernote_make_builtin_error(", + f"{indent} runtime, range ? \"RangeError\" : \"TypeError\", failure.what(),", + f"{indent} range ? \"LIMIT_EXCEEDED\" : \"TYPE_MISMATCH\",", + f"{indent} failure.path(), \"within generated conversion limits\", \"rejected\");", + f"{indent} return supernote_validation_failure(runtime, std::move(error));", + ]) + else: + lines.append(f"{indent} return facebook::jsi::Value(false);") + lines.extend([ + f"{indent} }} catch (const std::exception &error) {{", + f"{indent} supernote_throw_error(runtime, \"INTERNAL\", error.what());", + f"{indent} }}", + ]) + argument_parameter = ( + "const facebook::jsi::Value *arguments" + if route.parameters + else "const facebook::jsi::Value *" + ) + return ( + "facebook::jsi::Function::createFromHostFunction(\n" + f"{indent} runtime, facebook::jsi::PropNameID::forAscii(runtime, {json.dumps(name)}),\n" + f"{indent} {len(route.parameters)},\n" + f"{indent} [](facebook::jsi::Runtime &runtime, const facebook::jsi::Value &,\n" + f"{indent} {argument_parameter}, std::size_t argument_count) -> facebook::jsi::Value {{\n" + + "\n".join(lines) + + f"\n{indent} }})" + ) + + +def _with_jvm_preflight( + function: str, + route: JvmCallableRoute, + *, + diagnostic: str, + name: str, + indent: str, +) -> str: + accepts = _jvm_preflight_host_function( + route, diagnostic=diagnostic, name=name + ".accepts", check=False, indent=indent + ) + check = _jvm_preflight_host_function( + route, diagnostic=diagnostic, name=name + ".checkArguments", check=True, indent=indent + ) + return ( + "supernote_attach_preflight(\n" + f"{indent} runtime,\n" + f"{indent} {function},\n" + f"{indent} {accepts},\n" + f"{indent} {check})" + ) + + +def _wrapper( + plan: JvmRoutePlan, + item: JvmObjectRoute, + index: int, + module_name: str, + feature_id: str, +) -> str: + method_rows = [] + for route in item.methods: + if route.static: + continue + if route.execution is ExecutionMode.ASYNC: + function = _async_host_function( + route, + diagnostic=f"{module_name}.{item.named_type.public_name}.{route.public_name}", + plan=plan, + feature_id=feature_id, + receiver=True, + indent=" ", + ) + function = _with_jvm_preflight( + function, + route, + diagnostic=f"{module_name}.{item.named_type.public_name}.{route.public_name}", + name=route.public_name, + indent=" ", + ) + method_rows.append(f''' if (property == {json.dumps(route.public_name)}) {{ + auto owner = owner_; + auto feature = feature_; + return facebook::jsi::Value({function}); + }}''') + continue + body = _callable_body( + route, + diagnostic=f"{module_name}.{item.named_type.public_name}.{route.public_name}", + plan=plan, + feature_id=feature_id, + instance=True, + context=False, + indent=" ", + ) + main_function = f'''facebook::jsi::Function::createFromHostFunction( + runtime, facebook::jsi::PropNameID::forAscii(runtime, {json.dumps(route.public_name)}), + {len(route.parameters)}, + [owner, feature, registry](facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &, + const facebook::jsi::Value *arguments, + std::size_t argument_count) -> facebook::jsi::Value {{ +{body} + }})''' + function = _with_jvm_preflight( + main_function, + route, + diagnostic=f"{module_name}.{item.named_type.public_name}.{route.public_name}", + name=route.public_name, + indent=" ", + ) + method_rows.append(f''' if (property == {json.dumps(route.public_name)}) {{ + auto owner = owner_; + auto feature = feature_; + auto registry = registry_; + return facebook::jsi::Value({function}); + }}''') + for field_index, field in enumerate(item.fields): + adapter = "supernote.generated.adapters.Adapter_" + field.accessor_identity.rsplit(".", 1)[-1] + getter = _route_expression( + key=f"jvm-v3-field-get:{field.source_declaration_id}", + adapter_class=adapter, + descriptor=field.getter_descriptor, + method="get", + ).replace("feature", "feature_") + get_lines = [ + " AttachedEnv attached;", + " auto *env = attached.get();", + " if (env == nullptr) throw std::runtime_error(\"cannot attach to JavaVM\");", + " LocalFrame frame(env);", + f" auto route = {getter};", + " jvalue arguments[1]{};", + " arguments[0].l = owner_.get();", + ] + get_lines.extend( + _call_and_convert_result( + field.semantic_type, + "route", + "arguments", + "result", + plan, + feature_id, + " ", + ) + ) + get_lines.extend([ + " supernote::conversion::Budget budget;", + f" return {_to_name(field.semantic_type)}(", + " runtime, result, env, registry_, feature_, budget,", + f" {json.dumps(module_name + '.' + item.named_type.public_name + '.' + field.public_name)}, 0);", + ]) + method_rows.append( + f" if (property == {json.dumps(field.public_name)}) {{\n" + + "\n".join(get_lines) + + "\n }" + ) + property_names = [ + route.public_name for route in item.methods if not route.static + ] + [field.public_name for field in item.fields] + names = "\n".join( + " names.push_back(facebook::jsi::PropNameID::forAscii(runtime, " + + json.dumps(name) + + "));" + for name in property_names + ) + setters = [] + for field in item.fields: + if not field.mutable: + continue + adapter = "supernote.generated.adapters.Adapter_" + field.accessor_identity.rsplit(".", 1)[-1] + setter = _route_expression( + key=f"jvm-v3-field-set:{field.source_declaration_id}", + adapter_class=adapter, + descriptor=field.setter_descriptor or "", + method="set", + ).replace("feature", "feature_") + rows = [ + f" if (property == {json.dumps(field.public_name)}) {{", + " AttachedEnv attached;", + " auto *env = attached.get();", + " if (env == nullptr) throw std::runtime_error(\"cannot attach to JavaVM\");", + " LocalFrame frame(env);", + " supernote::conversion::Budget budget;", + f" auto converted = {_from_name(field.semantic_type)}(", + " runtime, value, env, feature_, budget,", + f" {json.dumps(module_name + '.' + item.named_type.public_name + '.' + field.public_name)}, 0);", + f" auto route = {setter};", + " jvalue arguments[2]{};", + " arguments[0].l = owner_.get();", + ] + rows.extend( + _argument_assignment( + field.semantic_type, + "converted", + 1, + feature_id, + " ", + ) + ) + rows.extend([ + " env->CallStaticVoidMethodA(", + " static_cast(route->adapter_class.get()),", + " route->method, arguments);", + " require_no_implementation_exception(env);", + " return;", + " }", + ]) + setters.append("\n".join(rows)) + get_rows = "\n".join( + " " + line for line in "\n".join(method_rows).splitlines() + ) + set_rows = "\n".join( + " " + line for line in "\n".join(setters).splitlines() + ) + return f'''class GeneratedV3JvmObject{index}HostObject final + : public JvmObjectHandleBase {{ + public: + GeneratedV3JvmObject{index}HostObject( + ManagedJvmRef owner, + std::shared_ptr feature, + std::shared_ptr registry) + : owner_(std::move(owner)), + feature_(std::move(feature)), + registry_(std::move(registry)) {{}} + + std::string_view type_id() const noexcept override {{ return owner_.type_id(); }} + ManagedJvmRef managed_ref() const override {{ return owner_; }} + + facebook::jsi::Value get( + facebook::jsi::Runtime &runtime, + const facebook::jsi::PropNameID &name) override {{ + try {{ + const auto property = name.utf8(runtime); +{get_rows} + return facebook::jsi::Value::undefined(); + }} catch (const facebook::jsi::JSError &) {{ + throw; + }} catch (const supernote::conversion::Failure &error) {{ + if (error.kind() == supernote::conversion::FailureKind::TYPE) {{ + supernote_throw_type_error(runtime, error.what()); + }} + if (error.kind() == supernote::conversion::FailureKind::RANGE) {{ + supernote_throw_range_error(runtime, error.what()); + }} + supernote_throw_error(runtime, "RESOURCE_EXHAUSTED", error.what()); + }} catch (const JvmImplementationFailure &error) {{ + supernote_throw_error(runtime, "IMPLEMENTATION_ERROR", error.what()); + }} catch (const std::exception &error) {{ + supernote_throw_error(runtime, "INTERNAL", error.what()); + }} + }} + + void set( + facebook::jsi::Runtime &runtime, + const facebook::jsi::PropNameID &name, + const facebook::jsi::Value &value) override {{ + try {{ + const auto property = name.utf8(runtime); +{set_rows} + }} catch (const facebook::jsi::JSError &) {{ + throw; + }} catch (const supernote::conversion::Failure &error) {{ + if (error.kind() == supernote::conversion::FailureKind::TYPE) {{ + supernote_throw_type_error(runtime, error.what()); + }} + if (error.kind() == supernote::conversion::FailureKind::RANGE) {{ + supernote_throw_range_error(runtime, error.what()); + }} + supernote_throw_error(runtime, "RESOURCE_EXHAUSTED", error.what()); + }} catch (const JvmImplementationFailure &error) {{ + supernote_throw_error(runtime, "IMPLEMENTATION_ERROR", error.what()); + }} catch (const std::exception &error) {{ + supernote_throw_error(runtime, "INTERNAL", error.what()); + }} + }} + + std::vector getPropertyNames( + facebook::jsi::Runtime &runtime) override {{ + std::vector names; + names.reserve({len(property_names)}); +{names} + return names; + }} + + private: + ManagedJvmRef owner_; + std::shared_ptr feature_; + std::shared_ptr registry_; +}};''' + + +def _wrap_declarations(plan: JvmRoutePlan) -> str: + return "\n".join( + f"facebook::jsi::Object supernote_v3_wrap_jvm_object_{index}(\n" + " facebook::jsi::Runtime &runtime, JNIEnv *env,\n" + " const std::shared_ptr ®istry,\n" + " const std::shared_ptr &feature,\n" + " const ManagedJvmRef &value);" + for index, _ in enumerate(plan.objects) + ) + + +def _wrap_definitions(plan: JvmRoutePlan) -> str: + rows = [] + for index, item in enumerate(plan.objects): + rows.append(f'''facebook::jsi::Object supernote_v3_wrap_jvm_object_{index}( + facebook::jsi::Runtime &runtime, JNIEnv *env, + const std::shared_ptr ®istry, + const std::shared_ptr &feature, + const ManagedJvmRef &value) {{ + return registry->wrap( + runtime, env, {json.dumps(item.named_type.type_id)}, value.get(), + supernote_v3_jvm_identity_hash(env, feature, value.get()), + value.global_ref(), + [feature, registry](ManagedJvmRef managed) {{ + return std::make_shared( + std::move(managed), feature, registry); + }}); +}}''') + return "\n\n".join(rows) + + +def _identity_helper(feature_id: str) -> str: + route = _helper_route( + feature_id, "identityHash", "(Ljava/lang/Object;)I" + ) + return f'''std::shared_ptr supernote_v3_jvm_object_registry( + facebook::jsi::Runtime &runtime) {{ + auto feature_registry = runtime.global().getPropertyAsObject( + runtime, kFeatureRegistryGlobal); + auto exports = feature_registry.getPropertyAsObject(runtime, kFeatureId); + auto owner_object = exports.getPropertyAsObject( + runtime, kJvmObjectRegistryProperty); + if (!owner_object.isHostObject(runtime)) {{ + throw std::runtime_error("JVM object registry is unavailable"); + }} + return owner_object + .getHostObject(runtime)->registry(); +}} + +std::shared_ptr supernote_v3_jvm_route( + const std::shared_ptr &feature, + const char *key, + const char *adapter_class, + const char *descriptor, + const char *method) {{ + auto route = feature->service(key, [=] {{ + return std::make_shared(adapter_class, descriptor, method); + }}); + return route->get(feature); +}} + +jint supernote_v3_jvm_identity_hash( + JNIEnv *env, + const std::shared_ptr &feature, + jobject value) {{ + auto route = {route}; + jvalue arguments[1]{{}}; + arguments[0].l = value; + auto result = env->CallStaticIntMethodA( + static_cast(route->adapter_class.get()), route->method, arguments); + require_no_implementation_exception(env); + return result; +}}''' + + +def _jvm_type_guard_host_function( + semantic: SemanticType, + *, + diagnostic: str, + name: str, + check: bool, + indent: str, +) -> str: + lines = [] + if check: + lines.extend([ + f"{indent} if (argument_count != 1) {{", + f"{indent} auto error = supernote_make_builtin_error(", + f"{indent} runtime, \"TypeError\", {json.dumps(diagnostic + ': expected one value')},", + f"{indent} \"ARITY_MISMATCH\", {json.dumps(diagnostic)}, \"1 argument\",", + f"{indent} std::to_string(argument_count) + \" arguments\");", + f"{indent} return supernote_validation_failure(runtime, std::move(error));", + f"{indent} }}", + ]) + else: + lines.append(f"{indent} if (argument_count != 1) return facebook::jsi::Value(false);") + lines.extend([ + f"{indent} try {{", + f"{indent} supernote::conversion::Budget input_budget;", + f"{indent} {_validate_name(semantic)}(", + f"{indent} runtime, arguments[0], input_budget, {json.dumps(diagnostic)}, 1);", + f"{indent} return " + ( + "supernote_validation_success(runtime);" + if check + else "facebook::jsi::Value(true);" + ), + f"{indent} }} catch (const facebook::jsi::JSError &error) {{", + ( + f"{indent} return supernote_validation_failure(\n" + f"{indent} runtime, facebook::jsi::Value(runtime, error.value()));" + if check + else f"{indent} return facebook::jsi::Value(false);" + ), + f"{indent} }} catch (const supernote::conversion::Failure &failure) {{", + f"{indent} if (failure.kind() == supernote::conversion::FailureKind::ALLOCATION) {{", + f"{indent} supernote_throw_error(runtime, \"RESOURCE_EXHAUSTED\", failure.what());", + f"{indent} }}", + ]) + if check: + lines.extend([ + f"{indent} const bool range =", + f"{indent} failure.kind() == supernote::conversion::FailureKind::RANGE;", + f"{indent} auto error = supernote_make_builtin_error(", + f"{indent} runtime, range ? \"RangeError\" : \"TypeError\", failure.what(),", + f"{indent} range ? \"LIMIT_EXCEEDED\" : \"TYPE_MISMATCH\",", + f"{indent} failure.path(), \"valid declared value\", \"rejected\");", + f"{indent} return supernote_validation_failure(runtime, std::move(error));", + ]) + else: + lines.append(f"{indent} return facebook::jsi::Value(false);") + lines.extend([ + f"{indent} }} catch (const std::exception &error) {{", + f"{indent} supernote_throw_error(runtime, \"INTERNAL\", error.what());", + f"{indent} }}", + ]) + return ( + "facebook::jsi::Function::createFromHostFunction(\n" + f"{indent} runtime, facebook::jsi::PropNameID::forAscii(runtime, {json.dumps(name)}), 1,\n" + f"{indent} [](facebook::jsi::Runtime &runtime, const facebook::jsi::Value &,\n" + f"{indent} const facebook::jsi::Value *arguments, std::size_t argument_count) -> facebook::jsi::Value {{\n" + + "\n".join(lines) + + f"\n{indent} }})" + ) + + +def _jvm_copied_type_registration( + semantic: SemanticType, + *, + public_name: str, + module_name: str, +) -> str: + diagnostic = f"{module_name}.{public_name}" + is_type = _jvm_type_guard_host_function( + semantic, diagnostic=diagnostic, name="is", check=False, indent=" " + ) + check_type = _jvm_type_guard_host_function( + semantic, diagnostic=diagnostic, name="check", check=True, indent=" " + ) + return f''' {{ + auto existing_type = exports.getProperty(runtime, {json.dumps(public_name)}); + facebook::jsi::Object object_type = existing_type.isObject() + ? existing_type.getObject(runtime) + : facebook::jsi::Object(runtime); + auto is_type = {is_type}; + object_type.setProperty(runtime, "is", std::move(is_type)); + auto check_type = {check_type}; + object_type.setProperty(runtime, "check", std::move(check_type)); + exports.setProperty(runtime, {json.dumps(public_name)}, std::move(object_type)); + }}''' + + +def _jvm_object_info_registration(plan: JvmRoutePlan) -> str: + branches = [] + for item in plan.objects: + branches.extend([ + f" if (type_id == {json.dumps(item.named_type.type_id)}) {{", + " facebook::jsi::Object result(runtime);", + f" result.setProperty(runtime, \"type\", {json.dumps(item.named_type.public_name)});", + " result.setProperty(runtime, \"originFamily\", \"jvm\");", + " return facebook::jsi::Value(std::move(result));", + " }", + ]) + return f''' {{ + auto inspect = facebook::jsi::Function::createFromHostFunction( + runtime, facebook::jsi::PropNameID::forAscii( + runtime, "__supernoteJvmObjectInfo"), 1, + [](facebook::jsi::Runtime &runtime, const facebook::jsi::Value &, + const facebook::jsi::Value *arguments, + std::size_t argument_count) -> facebook::jsi::Value {{ + if (argument_count != 1) return facebook::jsi::Value::undefined(); + auto type_id = jvm_object_type_id(runtime, arguments[0]); + if (type_id.empty()) return facebook::jsi::Value::undefined(); +{chr(10).join(branches)} + return facebook::jsi::Value::undefined(); + }}); + exports.setProperty( + runtime, "__supernoteJvmObjectInfo", std::move(inspect)); + }}''' + + +def _registration( + plan: JvmRoutePlan, + item: JvmObjectRoute, + index: int, + module_name: str, + feature_id: str, +) -> str: + functions = [] + if item.constructor is not None: + functions.append(("create", item.constructor, False, True)) + functions.extend( + (route.public_name, route, False, False) + for route in item.methods + if route.static + ) + semantic = SemanticType.object_ref(item.named_type.type_id) + is_type = _jvm_type_guard_host_function( + semantic, + diagnostic=f"{module_name}.{item.named_type.public_name}", + name="is", + check=False, + indent=" ", + ) + check_type = _jvm_type_guard_host_function( + semantic, + diagnostic=f"{module_name}.{item.named_type.public_name}", + name="check", + check=True, + indent=" ", + ) + rows = [ + " {", + f" auto existing_type = exports.getProperty(runtime, {json.dumps(item.named_type.public_name)});", + " facebook::jsi::Object object_type = existing_type.isObject()", + " ? existing_type.getObject(runtime)", + " : facebook::jsi::Object(runtime);", + f" auto is_type = {is_type};", + " object_type.setProperty(runtime, \"is\", std::move(is_type));", + f" auto check_type = {check_type};", + " object_type.setProperty(runtime, \"check\", std::move(check_type));", + ] + for public_name, route, instance, constructor in functions: + if route.execution is ExecutionMode.ASYNC: + function = _async_host_function( + route, + diagnostic=f"{module_name}.{item.named_type.public_name}.{public_name}", + plan=plan, + feature_id=feature_id, + receiver=False, + indent=" ", + ) + function = _with_jvm_preflight( + function, + route, + diagnostic=f"{module_name}.{item.named_type.public_name}.{public_name}", + name=public_name, + indent=" ", + ) + rows.extend([ + " {", + " auto feature = feature_session;", + f" auto function = {function};", + f" object_type.setProperty(runtime, {json.dumps(public_name)}, std::move(function));", + " }", + ]) + continue + body = _callable_body( + route, + diagnostic=f"{module_name}.{item.named_type.public_name}.{public_name}", + plan=plan, + feature_id=feature_id, + instance=instance, + context=constructor, + indent=" ", + ) + main_function = "\n".join([ + "facebook::jsi::Function::createFromHostFunction(", + f" runtime, facebook::jsi::PropNameID::forAscii(runtime, {json.dumps(public_name)}),", + f" {len(route.parameters)},", + " [feature, registry](facebook::jsi::Runtime &runtime,", + " const facebook::jsi::Value &,", + " const facebook::jsi::Value *arguments,", + " std::size_t argument_count) -> facebook::jsi::Value {", + body, + " })", + ]) + function = _with_jvm_preflight( + main_function, + route, + diagnostic=f"{module_name}.{item.named_type.public_name}.{public_name}", + name=public_name, + indent=" ", + ) + rows.extend([ + " {", + " auto feature = feature_session;", + " auto registry = object_registry;", + f" auto function = {function};", + f" object_type.setProperty(runtime, {json.dumps(public_name)}, std::move(function));", + " }", + ]) + rows.extend([ + f" exports.setProperty(runtime, {json.dumps(item.named_type.public_name)}, std::move(object_type));", + " }", + ]) + return "\n".join(rows) + + +def _contains_composite(semantic: SemanticType) -> bool: + return semantic.kind not in {SemanticTypeKind.VOID, SemanticTypeKind.SCALAR} + + +def _function_registration( + route: JvmCallableRoute, + plan: JvmRoutePlan, + module_name: str, + feature_id: str, +) -> str: + if route.execution is ExecutionMode.ASYNC: + function = _async_host_function( + route, + diagnostic=f"{module_name}.{route.public_name}", + plan=plan, + feature_id=feature_id, + receiver=False, + indent=" ", + ) + function = _with_jvm_preflight( + function, + route, + diagnostic=f"{module_name}.{route.public_name}", + name=route.public_name, + indent=" ", + ) + return f''' {{ + auto feature = feature_session; + auto function = {function}; + exports.setProperty(runtime, {json.dumps(route.public_name)}, std::move(function)); + }}''' + body = _callable_body( + route, + diagnostic=f"{module_name}.{route.public_name}", + plan=plan, + feature_id=feature_id, + instance=False, + context=False, + indent=" ", + ) + main_function = f'''facebook::jsi::Function::createFromHostFunction( + runtime, facebook::jsi::PropNameID::forAscii(runtime, {json.dumps(route.public_name)}), + {len(route.parameters)}, + [feature, registry](facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &, + const facebook::jsi::Value *arguments, + std::size_t argument_count) -> facebook::jsi::Value {{ +{body} + }})''' + function = _with_jvm_preflight( + main_function, + route, + diagnostic=f"{module_name}.{route.public_name}", + name=route.public_name, + indent=" ", + ) + return f''' {{ + auto feature = feature_session; + auto registry = object_registry; + auto function = {function}; + exports.setProperty(runtime, {json.dumps(route.public_name)}, std::move(function)); + }}''' + + +def render_jvm_object_bindings( + plan: JvmRoutePlan, + *, + feature_id: str, + module_name: str, +) -> tuple[tuple[str, ...], tuple[str, ...]]: + object_functions = tuple( + route for route in plan.functions + if _contains_composite(route.result) + or any(_contains_composite(item) for item in route.parameters) + ) + if not plan.objects and not object_functions: + return (), () + roots = [] + for item in plan.objects: + if item.constructor is not None: + roots.extend(item.constructor.parameters) + roots.append(item.constructor.result) + for method in item.methods: + roots.extend(method.parameters) + roots.append(method.result) + roots.extend(field.semantic_type for field in item.fields) + for route in object_functions: + roots.extend(route.parameters) + roots.append(route.result) + types = _collect_types(roots, plan) + wrappers = ( + _identity_helper(feature_id), + _wrap_declarations(plan), + "\n\n".join(_prototype(item) for item in types), + "\n\n".join( + _from_definition(item, plan, feature_id) for item in types + ), + "\n\n".join( + _validate_definition(item, plan) for item in types + ), + "\n\n".join( + _to_definition(item, plan, feature_id) for item in types + ), + *( + _wrapper(plan, item, index, module_name, feature_id) + for index, item in enumerate(plan.objects) + ), + _wrap_definitions(plan), + ) + registrations = tuple( + _function_registration(route, plan, module_name, feature_id) + for route in object_functions + ) + tuple( + filter(None, ( + _registration( + plan, item, index, module_name, feature_id + ) + for index, item in enumerate(plan.objects) + )) + ) + converted_named_types = { + (item.kind, item.type_id) + for item in types + if item.type_id is not None + } + registrations += tuple( + _jvm_copied_type_registration( + SemanticType.value_ref(item.named_type.type_id), + public_name=item.named_type.public_name, + module_name=module_name, + ) + for item in plan.values + if (SemanticTypeKind.VALUE_REF, item.named_type.type_id) + in converted_named_types + ) + registrations += tuple( + _jvm_copied_type_registration( + SemanticType.enum_ref(item.named_type.type_id), + public_name=item.named_type.public_name, + module_name=module_name, + ) + for item in plan.enums + if (SemanticTypeKind.ENUM_REF, item.named_type.type_id) + in converted_named_types + ) + if plan.objects: + registrations += (_jvm_object_info_registration(plan),) + return tuple(filter(None, wrappers)), registrations + + +__all__ = ["render_jvm_object_bindings"] diff --git a/src/supernote_module_generator/jvm_object_runtime_codegen.py b/src/supernote_module_generator/jvm_object_runtime_codegen.py new file mode 100644 index 0000000..f6b54f9 --- /dev/null +++ b/src/supernote_module_generator/jvm_object_runtime_codegen.py @@ -0,0 +1,226 @@ +"""Render the V3 JVM nominal-object handle and per-runtime identity registry.""" +from __future__ import annotations + + +def render_jvm_object_runtime() -> str: + # This source is inserted after AttachedEnv, clear_exception, and + # retain_global in the generated JVM bridge translation unit. + return r''' +std::shared_ptr retain_weak_global(JNIEnv *env, jobject value) { + if (env == nullptr || value == nullptr) { + throw std::runtime_error("cannot weakly retain a null JVM object"); + } + auto weak = env->NewWeakGlobalRef(value); + if (weak == nullptr) { + clear_exception(env); + throw std::runtime_error("cannot allocate a JNI weak global reference"); + } + auto cleanup = supernote::runtime::process_services().cleanup(); + return std::shared_ptr(weak, [cleanup](void *raw) { + auto release = [raw] { + AttachedEnv attached; + if (auto *env = attached.get()) { + env->DeleteWeakGlobalRef(static_cast(raw)); + } + }; + if (!cleanup || !cleanup->submit(release)) release(); + }); +} + +class ManagedJvmRef final { + public: + ManagedJvmRef() = default; + ManagedJvmRef(std::string type_id, std::shared_ptr global) + : type_id_(std::move(type_id)), global_(std::move(global)) { + if (type_id_.empty() || !global_) { + throw std::invalid_argument( + "a JVM managed reference requires nominal identity and a global reference"); + } + } + + explicit operator bool() const noexcept { return static_cast(global_); } + std::string_view type_id() const noexcept { return type_id_; } + jobject get() const noexcept { return static_cast(global_.get()); } + const std::shared_ptr &global_ref() const noexcept { return global_; } + + private: + std::string type_id_; + std::shared_ptr global_; +}; + +class ManagedJvmValue final { + public: + ManagedJvmValue() = default; + explicit ManagedJvmValue(std::shared_ptr global) + : global_(std::move(global)) {} + + explicit operator bool() const noexcept { return static_cast(global_); } + jobject get() const noexcept { return static_cast(global_.get()); } + const std::shared_ptr &global_ref() const noexcept { return global_; } + + private: + std::shared_ptr global_; +}; + +class JvmObjectHandleBase : public facebook::jsi::HostObject { + public: + ~JvmObjectHandleBase() override = default; + virtual std::string_view type_id() const noexcept = 0; + virtual ManagedJvmRef managed_ref() const = 0; +}; + +class JvmObjectRegistry final + : public std::enable_shared_from_this { + public: + JvmObjectRegistry() = default; + JvmObjectRegistry(const JvmObjectRegistry &) = delete; + JvmObjectRegistry &operator=(const JvmObjectRegistry &) = delete; + + template + facebook::jsi::Object wrap( + facebook::jsi::Runtime &runtime, + JNIEnv *env, + std::string_view type_id, + jobject instance, + jint identity_hash, + std::shared_ptr strong_global, + Factory &&factory) { + assert_runtime(runtime); + if (env == nullptr || instance == nullptr || !strong_global || type_id.empty()) { + throw std::invalid_argument( + "a JVM object result requires an environment, type, and live instance"); + } + const auto hash = identity_hash; + for (auto current = entries_.begin(); current != entries_.end();) { + const auto weak = static_cast(current->weak_global.get()); + const bool native_dead = + weak == nullptr || env->IsSameObject(weak, nullptr) == JNI_TRUE; + if (env->ExceptionCheck()) { + clear_exception(env); + throw std::runtime_error("cannot inspect JVM weak object identity"); + } + if (native_dead) { + current = entries_.erase(current); + continue; + } + if (current->identity_hash != hash || current->type_id != type_id || + env->IsSameObject(weak, instance) != JNI_TRUE) { + if (env->ExceptionCheck()) { + clear_exception(env); + throw std::runtime_error("cannot compare JVM object identity"); + } + ++current; + continue; + } + auto locked = current->javascript.lock(runtime); + if (locked.isObject()) return locked.getObject(runtime); + current = entries_.erase(current); + break; + } + + auto weak_global = retain_weak_global(env, instance); + ManagedJvmRef managed(std::string(type_id), std::move(strong_global)); + auto host = std::invoke( + std::forward(factory), std::move(managed)); + static_assert( + std::is_convertible_v>); + auto object = facebook::jsi::Object::createFromHostObject( + runtime, std::move(host)); + entries_.emplace_back( + std::string(type_id), hash, std::move(weak_global), + facebook::jsi::WeakObject(runtime, object)); + return object; + } + + void purge(facebook::jsi::Runtime &runtime, JNIEnv *env) { + assert_runtime(runtime); + if (env == nullptr) throw std::invalid_argument("JNIEnv is required"); + for (auto current = entries_.begin(); current != entries_.end();) { + const auto weak = static_cast(current->weak_global.get()); + const bool native_dead = + weak == nullptr || env->IsSameObject(weak, nullptr) == JNI_TRUE; + if (env->ExceptionCheck()) { + clear_exception(env); + throw std::runtime_error("cannot inspect JVM weak object identity"); + } + if (native_dead || !current->javascript.lock(runtime).isObject()) { + current = entries_.erase(current); + } else { + ++current; + } + } + } + + std::size_t size_for_testing() const noexcept { return entries_.size(); } + + private: + struct Entry final { + Entry( + std::string type_id, + jint identity_hash, + std::shared_ptr weak_global, + facebook::jsi::WeakObject javascript) + : type_id(std::move(type_id)), + identity_hash(identity_hash), + weak_global(std::move(weak_global)), + javascript(std::move(javascript)) {} + std::string type_id; + jint identity_hash; + std::shared_ptr weak_global; + facebook::jsi::WeakObject javascript; + }; + + void assert_runtime(facebook::jsi::Runtime &runtime) { + if (runtime_ == nullptr) { + runtime_ = &runtime; + } else if (runtime_ != &runtime) { + throw std::logic_error( + "a JVM object registry cannot cross JavaScript runtimes"); + } + } + + facebook::jsi::Runtime *runtime_ = nullptr; + std::list entries_; +}; + +class JvmObjectRegistryOwner final : public facebook::jsi::HostObject { + public: + explicit JvmObjectRegistryOwner(std::shared_ptr registry) + : registry_(std::move(registry)) { + if (!registry_) throw std::invalid_argument("JVM object registry is required"); + } + + const std::shared_ptr ®istry() const noexcept { + return registry_; + } + + private: + std::shared_ptr registry_; +}; + +std::string jvm_object_type_id( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value) { + if (!value.isObject()) return {}; + auto object = value.getObject(runtime); + if (!object.isHostObject(runtime)) return {}; + return std::string( + object.getHostObject(runtime)->type_id()); +} + +ManagedJvmRef try_extract_jvm_object( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value, + std::string_view expected_type_id) { + if (!value.isObject()) return {}; + auto object = value.getObject(runtime); + if (!object.isHostObject(runtime)) return {}; + auto handle = object.getHostObject(runtime); + if (handle->type_id() != expected_type_id) return {}; + return handle->managed_ref(); +} +''' + + +__all__ = ["render_jvm_object_runtime"] diff --git a/src/supernote_module_generator/jvm_projection.py b/src/supernote_module_generator/jvm_projection.py index 93063b5..8696040 100644 --- a/src/supernote_module_generator/jvm_projection.py +++ b/src/supernote_module_generator/jvm_projection.py @@ -1,27 +1,38 @@ -"""Project authoritative KSP/JVM source facts into common V2 semantics.""" +"""Project authoritative KSP/JVM source facts into common V3 semantics.""" from __future__ import annotations -from typing import Iterable +from dataclasses import dataclass +from typing import Iterable, Optional from .semantic import ( BindingCapabilities, BindingKind, + BackendFamily, DeclarationRole, + MemberScope, SemanticApi, SemanticBinding, SemanticClass, SemanticClassKind, SemanticConstructor, + SemanticEnumDeclaration, + SemanticField, + SemanticObjectDeclaration, SemanticParameter, + SemanticProjection, SemanticType, + SemanticValueDeclaration, + semantic_type_id, ) from .source_models import ( JvmConstructorSource, JvmDeclarationSource, JvmLanguage, + JvmFieldSource, JvmOwnerForm, JvmOwnerSource, JvmParameterSource, + JvmTypeSource, ) @@ -49,6 +60,42 @@ class JvmProjectionError(ValueError): "java.lang.String": SemanticType.STRING, "byte[]": SemanticType.BYTES, } +_JAVA_BOXED_TYPES = { + "java.lang.Boolean": SemanticType.BOOL, + "java.lang.Integer": SemanticType.INT32, + "java.lang.Long": SemanticType.INT64, + "java.lang.Float": SemanticType.FLOAT32, + "java.lang.Double": SemanticType.FLOAT64, +} + + +@dataclass(frozen=True) +class _JvmNamedType: + kind: str + type_id: str + public_name: str + + +class _JvmTypeRegistry: + def __init__(self, feature_id: str, owners: tuple[JvmOwnerSource, ...]) -> None: + self.feature_id = feature_id + self.values: dict[str, _JvmNamedType] = {} + for owner in owners: + if not (owner.intent.declares_object or owner.intent.declares_value): + continue + kind = ( + "enum" + if owner.enum_constants + else "object" if owner.intent.declares_object else "value" + ) + self.values[owner.owner_class] = _JvmNamedType( + kind, + semantic_type_id(feature_id, owner.source_name), + owner.source_name, + ) + + def resolve(self, name: str) -> Optional[_JvmNamedType]: + return self.values.get(name) def canonical_jvm_type( @@ -58,32 +105,93 @@ def canonical_jvm_type( nullable: bool, result: bool, source: JvmDeclarationSource | JvmConstructorSource, + arguments: tuple[JvmTypeSource, ...] = (), + registry: Optional[_JvmTypeRegistry] = None, + generic_argument: bool = False, ) -> SemanticType: - if nullable: - raise _error(source, "nullable marked JVM values are deferred") + if nullable and spelling in {"kotlin.Unit", "void"}: + raise _error(source, "void/Unit cannot be nullable") + if spelling in {"kotlin.collections.List", "java.util.List"}: + if len(arguments) != 1: + raise _error(source, "List requires exactly one invariant type argument") + element = canonical_jvm_type( + arguments[0].jvm_type, + language=language, + nullable=arguments[0].nullable, + result=False, + source=source, + arguments=arguments[0].arguments, + registry=registry, + generic_argument=True, + ) + semantic = SemanticType.array(element) + return SemanticType.nullable(semantic) if nullable else semantic table = _KOTLIN_TYPES if language is JvmLanguage.KOTLIN else _JAVA_TYPES semantic = table.get(spelling) + if language is JvmLanguage.JAVA: + boxed = _JAVA_BOXED_TYPES.get(spelling) + if generic_argument or nullable: + if boxed is not None: + semantic = boxed + elif spelling in {"boolean", "int", "long", "float", "double"}: + raise _error( + source, + f"Java {spelling} must use its boxed reference spelling in " + "nullable or generic positions", + ) + elif boxed is not None: + raise _error( + source, + f"Java direct non-null scalar {spelling!r} must use its primitive spelling", + ) + if semantic is None and registry is not None: + named = registry.resolve(spelling) + if named is not None: + if named.kind == "object": + semantic = SemanticType.object_ref(named.type_id) + elif named.kind == "value": + semantic = SemanticType.value_ref(named.type_id) + else: + semantic = SemanticType.enum_ref(named.type_id) if semantic is None: accepted = ", ".join(table) raise _error( source, f"unsupported marked {language.value} type {spelling!r}; " - f"use one canonical V2 type ({accepted})", + f"use one canonical V3 type ({accepted})", ) if semantic is SemanticType.VOID and not result: raise _error(source, "void/Unit is valid only as a marked result") - return semantic + return SemanticType.nullable(semantic) if nullable else semantic -def project_jvm_owners(owners: Iterable[JvmOwnerSource]) -> SemanticApi: +def project_jvm_owners( + owners: Iterable[JvmOwnerSource], + *, + feature_id: str = "supernote:feature:legacy", +) -> SemanticApi: + owner_sources = tuple(owners) + registry = _JvmTypeRegistry(feature_id, owner_sources) functions: list[SemanticBinding] = [] classes: list[SemanticClass] = [] - for owner in owners: + declarations = [] + for owner in owner_sources: if owner.provenance.language != owner.language.value: raise _error(owner, "JVM owner provenance language does not match") + if owner.intent.declares_object: + declarations.append(_project_jvm_object(owner, registry, feature_id)) + continue + if owner.intent.declares_value: + if owner.enum_constants: + declarations.append(_project_jvm_enum(owner, feature_id)) + else: + declarations.append(_project_jvm_value(owner, registry, feature_id)) + continue if owner.intent.role is DeclarationRole.ORDINARY: _validate_ordinary_owner_route(owner) - functions.extend(_project_function(owner, item) for item in owner.declarations) + functions.extend( + _project_function(owner, item, registry) for item in owner.declarations + ) continue if owner.visibility != "public": raise _error(owner, "a marked JVM class must be public") @@ -115,13 +223,14 @@ def project_jvm_owners(owners: Iterable[JvmOwnerSource]) -> SemanticApi: methods=methods, ) ) - return SemanticApi(tuple(functions), tuple(classes)) + return SemanticApi(tuple(functions), tuple(classes), tuple(declarations)) def _parameters( source: JvmDeclarationSource | JvmConstructorSource, language: JvmLanguage, parameters: tuple[JvmParameterSource, ...], + registry: Optional[_JvmTypeRegistry] = None, ) -> tuple[SemanticParameter, ...]: result = [] for item in parameters: @@ -138,15 +247,204 @@ def _parameters( nullable=item.nullable, result=False, source=source, + arguments=item.type_arguments, + registry=registry, ), ) ) return tuple(result) +def _selected_jvm_object_constructor( + owner: JvmOwnerSource, + registry: _JvmTypeRegistry, +) -> Optional[SemanticConstructor]: + selected = [item for item in owner.constructors if item.selected] + if len(selected) > 1: + raise _error(owner, "an object may select at most one SupernoteConstructor") + if not selected: + return None + constructor = selected[0] + if constructor.visibility != "public": + raise _error(constructor, "SupernoteConstructor must select a public constructor") + return SemanticConstructor( + constructor.provenance, + _parameters( + constructor, + owner.language, + constructor.parameters, + registry, + ), + ) + + +def _jvm_field( + owner: JvmOwnerSource, + source: JvmFieldSource, + owner_id: str, + registry: _JvmTypeRegistry, +) -> SemanticField: + if source.owner_declaration_id != owner.provenance.declaration_id: + raise _error(source, "JVM field owner identity does not match its type") + if source.visibility != "public": + raise _error(source, "a generated JVM field/property must be public") + if source.is_static: + raise _error(source, "static generated fields are unsupported") + if source.intent.role is not DeclarationRole.EXPORTED: + raise _error(source, "generated fields require SupernotePluginExport") + semantic = canonical_jvm_type( + source.type.jvm_type, + language=owner.language, + nullable=source.type.nullable, + result=False, + source=source, + arguments=source.type.arguments, + registry=registry, + ) + return SemanticField( + f"{owner_id}:field:{source.name}", + owner_id, + source.name, + semantic, + source.provenance, + source.mutable, + ) + + +def _object_method( + owner: JvmOwnerSource, + source: JvmDeclarationSource, + owner_id: str, + registry: _JvmTypeRegistry, +) -> SemanticBinding: + if source.visibility != "public": + raise _error(source, "a marked JVM object method must be public") + return SemanticBinding( + f"supernote:binding:{source.provenance.declaration_id}", + BindingKind.OBJECT_METHOD, + source.jvm_name, + BindingCapabilities.for_role(source.intent.role), + source.intent.execution, + _parameters(source, owner.language, source.parameters, registry), + canonical_jvm_type( + source.result_jvm_type, + language=owner.language, + nullable=source.result_nullable, + result=True, + source=source, + arguments=source.result_type_arguments, + registry=registry, + ), + source.provenance, + owner_id, + owner.source_name, + MemberScope.STATIC if source.is_static else MemberScope.INSTANCE, + ) + + +def _validate_jvm_bridge_type(owner: JvmOwnerSource) -> None: + if owner.visibility != "public": + raise _error(owner, "a marked JVM type must be public") + if owner.type_parameter_count: + raise _error(owner, "generic marked JVM types are unsupported") + if owner.supertypes: + raise _error(owner, "inheritance and interfaces on marked JVM types are unsupported") + if owner.language is JvmLanguage.JAVA and not owner.is_final: + raise _error(owner, "marked Java object/value classes must be final") + + +def _project_jvm_object( + owner: JvmOwnerSource, + registry: _JvmTypeRegistry, + feature_id: str, +) -> SemanticObjectDeclaration: + _validate_jvm_bridge_type(owner) + if owner.form is not JvmOwnerForm.CLASS: + raise _error(owner, "SupernotePluginObject requires a normal class") + owner_id = semantic_type_id(feature_id, owner.source_name) + return SemanticObjectDeclaration( + feature_id, + owner_id, + owner.source_name, + SemanticProjection(BackendFamily.JVM, owner.provenance), + _selected_jvm_object_constructor(owner, registry), + tuple( + _object_method(owner, item, owner_id, registry) + for item in owner.declarations + if item.intent.role is not DeclarationRole.ORDINARY + ), + tuple(_jvm_field(owner, item, owner_id, registry) for item in owner.fields), + ) + + +def _project_jvm_value( + owner: JvmOwnerSource, + registry: _JvmTypeRegistry, + feature_id: str, +) -> SemanticValueDeclaration: + _validate_jvm_bridge_type(owner) + if owner.language is JvmLanguage.KOTLIN and not owner.is_data: + raise _error(owner, "SupernotePluginValue requires a Kotlin data class") + if owner.language is JvmLanguage.JAVA and not (owner.is_record or owner.is_final): + raise _error(owner, "Java values require a record or supported final class") + if any(item.selected for item in owner.constructors): + raise _error(owner, "SupernoteConstructor cannot mark a value type") + if any(item.intent.role is not DeclarationRole.ORDINARY for item in owner.declarations): + raise _error(owner, "value types expose fields/properties, not generated methods") + owner_id = semantic_type_id(feature_id, owner.source_name) + fields = tuple( + _jvm_field(owner, item, owner_id, registry) for item in owner.fields + ) + if owner.language is JvmLanguage.JAVA: + if any(item.mutable for item in owner.fields): + raise _error(owner, "Java value fields/record components must be final") + eligible = [ + item for item in owner.constructors if item.visibility == "public" + ] + if len(eligible) != 1: + raise _error( + owner, + "a Java value requires exactly one public constructor matching " + "its ordered fields/record components", + ) + parameters = _parameters( + eligible[0], owner.language, eligible[0].parameters, registry + ) + expected = tuple((item.name, item.type) for item in fields) + actual = tuple((item.name, item.type) for item in parameters) + if actual != expected: + raise _error( + eligible[0], + "Java value constructor parameters must match the ordered " + "field/component names and types exactly", + ) + return SemanticValueDeclaration( + feature_id, + owner_id, + owner.source_name, + fields, + (SemanticProjection(BackendFamily.JVM, owner.provenance),), + ) + + +def _project_jvm_enum( + owner: JvmOwnerSource, + feature_id: str, +) -> SemanticEnumDeclaration: + _validate_jvm_bridge_type(owner) + return SemanticEnumDeclaration( + feature_id, + semantic_type_id(feature_id, owner.source_name), + owner.source_name, + owner.enum_constants, + (SemanticProjection(BackendFamily.JVM, owner.provenance),), + ) + + def _project_function( owner: JvmOwnerSource, source: JvmDeclarationSource, + registry: Optional[_JvmTypeRegistry] = None, ) -> SemanticBinding: if source.intent.role is DeclarationRole.ORDINARY: raise _error(source, "ordinary JVM declarations do not become bindings") @@ -158,13 +456,15 @@ def _project_function( name=source.jvm_name, capabilities=BindingCapabilities.for_role(source.intent.role), execution=source.intent.execution, - parameters=_parameters(source, owner.language, source.parameters), + parameters=_parameters(source, owner.language, source.parameters, registry), result=canonical_jvm_type( source.result_jvm_type, language=owner.language, nullable=source.result_nullable, result=True, source=source, + arguments=source.result_type_arguments, + registry=registry, ), source=source.provenance, ) diff --git a/src/supernote_module_generator/jvm_routes.py b/src/supernote_module_generator/jvm_routes.py new file mode 100644 index 0000000..e708089 --- /dev/null +++ b/src/supernote_module_generator/jvm_routes.py @@ -0,0 +1,530 @@ +"""Source-backed JVM routes for the V3 semantic object model. + +KSP records exact JVM owners and deterministic adapter identities. This module +joins those compiler facts back to the backend-neutral semantic API and derives +the descriptors of the generated Kotlin adapter surface. Code generation must +consume this plan instead of guessing JVM classes from public JavaScript names. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Optional, Tuple + +from .jvm_manifest import jvm_field_accessor_identity +from .semantic import ( + BackendFamily, + ExecutionMode, + MemberScope, + SemanticApi, + SemanticBinding, + SemanticConstructor, + SemanticField, + SemanticObjectDeclaration, + SemanticValueDeclaration, + SemanticModelError, + validate_semantic_route, +) +from .semantic_types import ScalarKind, SemanticType, SemanticTypeKind +from .source_models import ( + JvmConstructorSource, + JvmDeclarationSource, + JvmFieldSource, + JvmLanguage, + JvmOwnerSource, +) + + +class JvmRouteError(ValueError): + """Raised when projected semantics and KSP source facts disagree.""" + + +@dataclass(frozen=True) +class JvmNamedTypeRoute: + type_id: str + public_name: str + owner_class: str + language: JvmLanguage + kind: SemanticTypeKind + source_declaration_id: str + + +@dataclass(frozen=True) +class JvmCallableRoute: + source_declaration_id: str + public_name: str + adapter_identity: str + adapter_descriptor: str + parameters: Tuple[SemanticType, ...] + result: SemanticType + execution: ExecutionMode + owner_type_id: Optional[str] + static: bool + suspend: bool + + +@dataclass(frozen=True) +class JvmFieldRoute: + source_declaration_id: str + field_id: str + public_name: str + accessor_identity: str + getter_descriptor: str + setter_descriptor: Optional[str] + semantic_type: SemanticType + mutable: bool + + +@dataclass(frozen=True) +class JvmObjectRoute: + named_type: JvmNamedTypeRoute + constructor: Optional[JvmCallableRoute] + methods: Tuple[JvmCallableRoute, ...] + fields: Tuple[JvmFieldRoute, ...] + + +@dataclass(frozen=True) +class JvmValueRoute: + named_type: JvmNamedTypeRoute + constructor: JvmCallableRoute + constructor_fields: Tuple[JvmFieldRoute, ...] + fields: Tuple[JvmFieldRoute, ...] + + +@dataclass(frozen=True) +class JvmEnumRoute: + named_type: JvmNamedTypeRoute + constants: Tuple[str, ...] + + +@dataclass(frozen=True) +class JvmRoutePlan: + functions: Tuple[JvmCallableRoute, ...] + objects: Tuple[JvmObjectRoute, ...] + values: Tuple[JvmValueRoute, ...] + enums: Tuple[JvmEnumRoute, ...] + named_types: Tuple[JvmNamedTypeRoute, ...] + + @property + def named_types_by_id(self) -> dict[str, JvmNamedTypeRoute]: + return {item.type_id: item for item in self.named_types} + + +_BOXED_DESCRIPTOR = { + ScalarKind.BOOL: "Ljava/lang/Boolean;", + ScalarKind.INT32: "Ljava/lang/Integer;", + ScalarKind.INT64: "Ljava/lang/Long;", + ScalarKind.FLOAT32: "Ljava/lang/Float;", + ScalarKind.FLOAT64: "Ljava/lang/Double;", +} + + +def _adapter_descriptor_for_type( + semantic: SemanticType, + named: dict[str, JvmNamedTypeRoute], +) -> str: + if semantic.kind is SemanticTypeKind.VOID: + return "V" + if semantic.kind is SemanticTypeKind.NULLABLE: + assert semantic.element is not None + child = semantic.element + if child.kind is SemanticTypeKind.SCALAR and child.scalar in _BOXED_DESCRIPTOR: + return _BOXED_DESCRIPTOR[child.scalar] + return _adapter_descriptor_for_type(child, named) + if semantic.kind is SemanticTypeKind.ARRAY: + return "Ljava/util/List;" + if semantic.kind is SemanticTypeKind.SCALAR: + return { + ScalarKind.BOOL: "Z", + ScalarKind.INT32: "I", + ScalarKind.INT64: "J", + ScalarKind.FLOAT32: "F", + ScalarKind.FLOAT64: "D", + ScalarKind.STRING: "[B", + ScalarKind.BYTES: "[B", + }[semantic.scalar] + assert semantic.type_id is not None + try: + route = named[semantic.type_id] + except KeyError as exc: + raise JvmRouteError( + f"missing JVM projection for semantic type {semantic.type_id!r}" + ) from exc + return f"L{route.owner_class.replace('.', '/')};" + + +def _adapter_class(identity: str) -> str: + return "supernote.generated.adapters.Adapter_" + identity.rsplit(".", 1)[-1] + + +def _callable( + semantic: SemanticBinding | SemanticConstructor, + source: JvmDeclarationSource | JvmConstructorSource, + *, + named: dict[str, JvmNamedTypeRoute], + owner: JvmNamedTypeRoute, + public_name: str, + result: SemanticType, + static: bool, + suspend: bool, +) -> JvmCallableRoute: + if semantic.source.declaration_id != source.provenance.declaration_id: + raise JvmRouteError("semantic and JVM callable source identities disagree") + parameter_types = tuple(item.type for item in semantic.parameters) + descriptor_parameters = [] + if not isinstance(source, JvmConstructorSource) and not static: + descriptor_parameters.append( + f"L{owner.owner_class.replace('.', '/')};" + ) + elif isinstance(source, JvmConstructorSource): + descriptor_parameters.append( + "Lcom/facebook/react/bridge/ReactApplicationContext;" + ) + descriptor_parameters.extend( + _adapter_descriptor_for_type(item, named) for item in parameter_types + ) + if suspend: + descriptor_parameters.append("J") + result_descriptor = "Lkotlinx/coroutines/Job;" + else: + result_descriptor = _adapter_descriptor_for_type(result, named) + return JvmCallableRoute( + source.provenance.declaration_id, + public_name, + source.adapter_identity, + f"({''.join(descriptor_parameters)}){result_descriptor}", + parameter_types, + result, + ( + semantic.execution + if isinstance(semantic, SemanticBinding) + else ExecutionMode.SYNC + ), + owner.type_id, + static, + suspend, + ) + + +def _field( + semantic: SemanticField, + source: JvmFieldSource, + owner: JvmNamedTypeRoute, + named: dict[str, JvmNamedTypeRoute], + *, + copied_projection: bool = False, +) -> JvmFieldRoute: + if copied_projection: + if semantic.name != source.name: + raise JvmRouteError("semantic and JVM copied field names disagree") + elif semantic.source.declaration_id != source.provenance.declaration_id: + raise JvmRouteError("semantic and JVM field source identities disagree") + if not copied_projection and semantic.mutable != source.mutable: + raise JvmRouteError("semantic and JVM field mutability disagree") + expected_accessor = jvm_field_accessor_identity(source.provenance.declaration_id) + if source.accessor_identity != expected_accessor: + raise JvmRouteError("JVM field accessor identity is not deterministic") + owner_descriptor = f"L{owner.owner_class.replace('.', '/')};" + value_descriptor = _adapter_descriptor_for_type(semantic.type, named) + return JvmFieldRoute( + source.provenance.declaration_id, + semantic.field_id, + semantic.name, + source.accessor_identity, + f"({owner_descriptor}){value_descriptor}", + f"({owner_descriptor}{value_descriptor})V" if semantic.mutable else None, + semantic.type, + source.mutable, + ) + + +def _value_constructor( + declaration: SemanticValueDeclaration, + source: JvmOwnerSource, + owner: JvmNamedTypeRoute, + named: dict[str, JvmNamedTypeRoute], +) -> tuple[JvmCallableRoute, tuple[str, ...]]: + expected = {item.name: item.type for item in declaration.fields} + eligible = [] + for constructor in source.constructors: + visible = tuple(item for item in constructor.parameters if item.injected is None) + if constructor.visibility != "public" or len(visible) != len(expected): + continue + if set(item.name for item in visible) != set(expected): + continue + eligible.append(constructor) + if len(eligible) != 1: + raise JvmRouteError( + f"JVM value {declaration.name!r} requires one public field-order constructor" + ) + constructor = eligible[0] + field_by_name = {item.name: item for item in declaration.fields} + ordered_fields = tuple( + field_by_name[item.name] + for item in constructor.parameters + if item.injected is None + ) + parameter_types = tuple(item.type for item in ordered_fields) + descriptor = ( + "(Lcom/facebook/react/bridge/ReactApplicationContext;" + + "".join(_adapter_descriptor_for_type(item, named) for item in parameter_types) + + f")L{owner.owner_class.replace('.', '/')};" + ) + return JvmCallableRoute( + constructor.provenance.declaration_id, + "createValue", + constructor.adapter_identity, + descriptor, + parameter_types, + SemanticType.value_ref(declaration.type_id), + ExecutionMode.SYNC, + owner.type_id, + True, + False, + ), tuple(item.name for item in ordered_fields) + + +def plan_jvm_routes( + api: SemanticApi, + owners: Iterable[JvmOwnerSource], +) -> JvmRoutePlan: + """Join marked JVM declarations to their exact generated adapter routes.""" + + owner_sources = tuple(owners) + source_by_id = { + item.provenance.declaration_id: item for item in owner_sources + } + declarations_by_id = { + declaration.provenance.declaration_id: (owner, declaration) + for owner in owner_sources + for declaration in owner.declarations + } + + def validate_type(value: SemanticType, source) -> None: + try: + validate_semantic_route( + api, + value, + BackendFamily.JVM, + BackendFamily.JVM, + source, + source, + ) + except SemanticModelError as exc: + raise JvmRouteError(str(exc)) from exc + + def validate_binding(binding: SemanticBinding) -> None: + for parameter in binding.parameters: + validate_type(parameter.type, binding.source) + validate_type(binding.result, binding.source) + named_routes = [] + declarations_with_sources = [] + for declaration in api.declarations: + projections = [ + item for item in declaration.projections + if item.backend is BackendFamily.JVM + ] + if not projections: + continue + if len(projections) != 1: + raise JvmRouteError( + f"type {declaration.name!r} has multiple JVM projections" + ) + projection = projections[0] + try: + source = source_by_id[projection.source.declaration_id] + except KeyError as exc: + raise JvmRouteError( + f"missing JVM owner source {projection.source.declaration_id!r}" + ) from exc + kind = { + "enum": SemanticTypeKind.ENUM_REF, + "value": SemanticTypeKind.VALUE_REF, + "object": SemanticTypeKind.OBJECT_REF, + }[declaration.kind.value] + named = JvmNamedTypeRoute( + declaration.type_id, + declaration.name, + source.owner_class, + source.language, + kind, + source.provenance.declaration_id, + ) + named_routes.append(named) + declarations_with_sources.append((declaration, source, named)) + + named = {item.type_id: item for item in named_routes} + function_routes = [] + for binding in api.functions: + if binding.source.language not in {"kotlin", "java"}: + continue + validate_binding(binding) + if not binding.capabilities.javascript_public: + # Hidden JVM routes are emitted through the typed C++ internal + # facade, never registered as JavaScript functions. + continue + try: + source_owner, source = declarations_by_id[ + binding.source.declaration_id + ] + except KeyError as exc: + raise JvmRouteError( + f"missing JVM function source {binding.source.declaration_id!r}" + ) from exc + if source_owner.form.value == "class" and not source.is_static: + # Scalar implementation-owner methods remain on the retained V2 + # service route. Composite owner setup is added with cross-family + # copied routes in Phase 7. + continue + synthetic_owner = JvmNamedTypeRoute( + "jvm:implementation-owner:" + source_owner.provenance.declaration_id, + source_owner.source_name, + source_owner.owner_class, + source_owner.language, + SemanticTypeKind.OBJECT_REF, + source_owner.provenance.declaration_id, + ) + function_routes.append( + _callable( + binding, + source, + named=named, + owner=synthetic_owner, + public_name=binding.name, + result=binding.result, + static=True, + suspend=source.is_suspend, + ) + ) + objects = [] + values = [] + enums = [] + for declaration, source, owner_route in declarations_with_sources: + if declaration.kind.value == "enum": + constants = tuple(declaration.constants) + if constants != tuple(source.enum_constants): + raise JvmRouteError( + f"semantic and JVM enum constants disagree for {declaration.name!r}" + ) + enums.append(JvmEnumRoute(owner_route, constants)) + continue + copied_value = isinstance(declaration, SemanticValueDeclaration) + source_fields = { + (item.name if copied_value else item.provenance.declaration_id): item + for item in source.fields + } + fields = [] + for field in declaration.fields: + try: + source_field = source_fields[ + field.name if copied_value else field.source.declaration_id + ] + except KeyError as exc: + raise JvmRouteError( + f"missing JVM field source {field.source.declaration_id!r}" + ) from exc + fields.append( + _field( + field, + source_field, + owner_route, + named, + copied_projection=copied_value, + ) + ) + if isinstance(declaration, SemanticValueDeclaration): + constructor, constructor_field_names = _value_constructor( + declaration, source, owner_route, named + ) + by_name = {item.public_name: item for item in fields} + values.append( + JvmValueRoute( + owner_route, + constructor, + tuple(by_name[name] for name in constructor_field_names), + tuple(fields), + ) + ) + continue + if not isinstance(declaration, SemanticObjectDeclaration): + continue + source_constructors = { + item.provenance.declaration_id: item for item in source.constructors + } + constructor_route = None + if declaration.constructor is not None: + for parameter in declaration.constructor.parameters: + validate_type(parameter.type, declaration.constructor.source) + try: + constructor_source = source_constructors[ + declaration.constructor.source.declaration_id + ] + except KeyError as exc: + raise JvmRouteError( + f"missing JVM constructor source for {declaration.name!r}" + ) from exc + constructor_route = _callable( + declaration.constructor, + constructor_source, + named=named, + owner=owner_route, + public_name="create", + result=SemanticType.object_ref(declaration.type_id), + static=True, + suspend=False, + ) + source_methods = { + item.provenance.declaration_id: item for item in source.declarations + } + methods = [] + for method in declaration.methods: + validate_binding(method) + try: + method_source = source_methods[method.source.declaration_id] + except KeyError as exc: + raise JvmRouteError( + f"missing JVM method source {method.source.declaration_id!r}" + ) from exc + static = method.member_scope is MemberScope.STATIC + if static != method_source.is_static: + raise JvmRouteError("semantic and JVM method scopes disagree") + methods.append( + _callable( + method, + method_source, + named=named, + owner=owner_route, + public_name=method.name, + result=method.result, + static=static, + suspend=method_source.is_suspend, + ) + ) + objects.append( + JvmObjectRoute( + owner_route, + constructor_route, + tuple(methods), + tuple(fields), + ) + ) + + return JvmRoutePlan( + tuple(function_routes), + tuple(objects), + tuple(values), + tuple(enums), + tuple(named_routes), + ) + + +__all__ = [ + "JvmCallableRoute", + "JvmEnumRoute", + "JvmFieldRoute", + "JvmNamedTypeRoute", + "JvmObjectRoute", + "JvmRouteError", + "JvmRoutePlan", + "JvmValueRoute", + "plan_jvm_routes", +] diff --git a/src/supernote_module_generator/lowering.py b/src/supernote_module_generator/lowering.py index 6f3500c..3febe26 100644 --- a/src/supernote_module_generator/lowering.py +++ b/src/supernote_module_generator/lowering.py @@ -3,9 +3,11 @@ from dataclasses import dataclass from enum import Enum -from typing import Union +from typing import Optional, Union +from .conversion import BindingConversionPlan from .semantic import BindingKind, ExecutionMode, SemanticBinding +from .semantic_types import SemanticTypeKind from .source_models import ( CppFunctionSource, CppMethodSource, @@ -62,6 +64,7 @@ class LoweringPlan: route: RouteKind scheduling: SchedulingKind data: RouteData + conversion: Optional[BindingConversionPlan] = None def __post_init__(self) -> None: if not self.binding_id or not self.source_declaration_id: @@ -128,6 +131,21 @@ def validate_binding(self, binding: SemanticBinding) -> None: f"route {self.route.value!r} cannot implement binding kind " f"{binding.kind.value!r}" ) + if self.conversion is None: + semantic_types = [item.type for item in binding.parameters] + semantic_types.append(binding.result) + if any( + item.kind not in {SemanticTypeKind.VOID, SemanticTypeKind.SCALAR} + for item in semantic_types + ): + raise LoweringError( + "recursive V3 routes require the shared binding conversion plan" + ) + else: + try: + self.conversion.validate_binding(binding) + except ValueError as exc: + raise LoweringError(str(exc)) from exc def validate_source( self, diff --git a/src/supernote_module_generator/plugin_build_integration.py b/src/supernote_module_generator/plugin_build_integration.py index 0442818..272d3c1 100644 --- a/src/supernote_module_generator/plugin_build_integration.py +++ b/src/supernote_module_generator/plugin_build_integration.py @@ -1,4 +1,4 @@ -"""Idempotent parent wiring for the one plugin-level V2 runtime component.""" +"""Idempotent parent wiring for the one plugin-level V3 runtime component.""" from __future__ import annotations import os @@ -9,13 +9,21 @@ from .errors import ConfigurationError -PROJECT_NAME = "supernote-v2-runtime" -ANNOTATIONS_PROJECT = "supernote-v2-annotations" -PROCESSOR_PROJECT = "supernote-v2-processor" -START = "// supernote-module-v2-runtime" -END = "// end supernote-module-v2-runtime" -PACKAGE_START = "// supernote-module-v2-package" -PACKAGE_END = "// end supernote-module-v2-package" +PROJECT_NAME = "supernote-v3-runtime" +ANNOTATIONS_PROJECT = "supernote-v3-annotations" +PROCESSOR_PROJECT = "supernote-v3-processor" +START = "// supernote-module-v3-runtime" +END = "// end supernote-module-v3-runtime" +PACKAGE_START = "// supernote-module-v3-package" +PACKAGE_END = "// end supernote-module-v3-package" +LEGACY_MARKERS = ( + "// supernote-module-v2-runtime", + "// supernote-module-v2-package", +) +LEGACY_BLOCKS = ( + ("// supernote-module-v2-runtime", "// end supernote-module-v2-runtime"), + ("// supernote-module-v2-package", "// end supernote-module-v2-package"), +) def integration_files(plugin_root: Path) -> tuple[Path, Path]: @@ -60,19 +68,23 @@ def set_runtime_wiring(plugin_root: Path, *, enabled: bool) -> tuple[Path, Path] } if application is not None: originals[application] = application.read_text(encoding="utf-8") + cleaned = { + path: _remove_legacy_v2_blocks(content) + for path, content in originals.items() + } desired = { settings: _replace_block( - originals[settings], + cleaned[settings], _settings_block(settings.suffix == ".kts") if enabled else None, ), app_build: _replace_block( - originals[app_build], + cleaned[app_build], _dependency_block(app_build.suffix == ".kts") if enabled else None, ), } if application is not None: desired[application] = _replace_package_registration( - originals[application], enabled=enabled, kotlin=application.suffix == ".kt" + cleaned[application], enabled=enabled, kotlin=application.suffix == ".kt" ) changed: list[Path] = [] try: @@ -93,16 +105,25 @@ def verify_runtime_wiring( *, enabled: bool, allow_missing_package: bool = False, + allow_legacy_v2: bool = False, ) -> None: settings, app_build = integration_files(plugin_root) + inspected = { + settings: settings.read_text(encoding="utf-8"), + app_build: app_build.read_text(encoding="utf-8"), + } + application = _application_file(plugin_root) + if application is not None: + inspected[application] = application.read_text(encoding="utf-8") + if not allow_legacy_v2: + _reject_legacy_v2_wiring(inspected) for path in (settings, app_build): count = path.read_text(encoding="utf-8").count(START) expected = 1 if enabled else 0 if count != expected: raise ConfigurationError( - f"{path} contains {count} V2 runtime blocks; expected {expected}" + f"{path} contains {count} V3 runtime blocks; expected {expected}" ) - application = _application_file(plugin_root) if application is not None: count = application.read_text(encoding="utf-8").count(PACKAGE_START) expected = 1 if enabled else 0 @@ -110,16 +131,16 @@ def verify_runtime_wiring( enabled and allow_missing_package and count == 0 ): raise ConfigurationError( - f"{application} contains {count} V2 package blocks; " + f"{application} contains {count} V3 package blocks; " f"expected {expected}" ) def _settings_block(kotlin: bool) -> str: projects = ( - (PROJECT_NAME, ".supernote-module/v2-runtime"), - (ANNOTATIONS_PROJECT, ".supernote-module/v2-runtime/annotations"), - (PROCESSOR_PROJECT, ".supernote-module/v2-runtime/processor"), + (PROJECT_NAME, ".supernote-module/v3-runtime"), + (ANNOTATIONS_PROJECT, ".supernote-module/v3-runtime/annotations"), + (PROCESSOR_PROJECT, ".supernote-module/v3-runtime/processor"), ) if kotlin: body = "\n".join( @@ -150,7 +171,7 @@ def _replace_block(content: str, replacement: str | None) -> str: ) matches = list(pattern.finditer(content)) if len(matches) > 1: - raise ConfigurationError("Android build contains duplicate V2 runtime blocks") + raise ConfigurationError("Android build contains duplicate V3 runtime blocks") if replacement is None: updated = pattern.sub("\n", content) return updated.rstrip() + "\n" @@ -184,7 +205,7 @@ def _replace_package_registration( ) matches = list(pattern.finditer(content)) if len(matches) > 1: - raise ConfigurationError("MainApplication has duplicate V2 package blocks") + raise ConfigurationError("MainApplication has duplicate V3 package blocks") cleaned = pattern.sub("", content) if not enabled: return cleaned @@ -197,7 +218,7 @@ def _replace_package_registration( indent = anchor.group("indent") + " " block = ( f"\n{indent}{PACKAGE_START}\n" - f"{indent}add(supernote.generated.runtime.SupernoteV2Package())\n" + f"{indent}add(supernote.generated.runtime.SupernoteV3Package())\n" f"{indent}{PACKAGE_END}" ) return cleaned[: anchor.end()] + block + cleaned[anchor.end() :] @@ -212,7 +233,7 @@ def _replace_package_registration( indent = anchor.group("indent") block = ( f"\n{indent}{PACKAGE_START}\n" - f"{indent}packages.add(new supernote.generated.runtime.SupernoteV2Package());\n" + f"{indent}packages.add(new supernote.generated.runtime.SupernoteV3Package());\n" f"{indent}{PACKAGE_END}" ) return cleaned[: anchor.end()] + block + cleaned[anchor.end() :] @@ -229,3 +250,39 @@ def _atomic_write(path: Path, content: str) -> None: os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) + + +def _reject_legacy_v2_wiring(files: dict[Path, str]) -> None: + for path, content in files.items(): + if any(marker in content for marker in LEGACY_MARKERS): + raise ConfigurationError( + f"{path} contains stale V2 runtime wiring; V3 does not read or " + "convert V2 generated state" + ) + + +def _remove_legacy_v2_blocks(content: str) -> str: + """Remove only complete generator-owned V2 marker blocks.""" + + updated = content + for start, end in LEGACY_BLOCKS: + start_count = updated.count(start) + end_count = updated.count(end) + if start_count != end_count or start_count > 1: + raise ConfigurationError( + "Android source contains malformed or duplicate stale V2 wiring" + ) + if start_count == 0: + continue + pattern = re.compile( + r"(?:\n)?^[ \t]*" + + re.escape(start) + + r"[ \t]*\n.*?^[ \t]*" + + re.escape(end) + + r"[ \t]*(?:\n)?", + re.DOTALL | re.MULTILINE, + ) + updated, count = pattern.subn("\n", updated) + if count != 1: + raise ConfigurationError("Android source contains malformed stale V2 wiring") + return updated.rstrip() + "\n" diff --git a/src/supernote_module_generator/plugin_runtime_codegen.py b/src/supernote_module_generator/plugin_runtime_codegen.py index 220eb5a..602529c 100644 --- a/src/supernote_module_generator/plugin_runtime_codegen.py +++ b/src/supernote_module_generator/plugin_runtime_codegen.py @@ -1,4 +1,4 @@ -"""Generate the one plugin-level V2 runtime/build component.""" +"""Generate the one plugin-level V3 runtime/build component.""" from __future__ import annotations import json @@ -8,11 +8,20 @@ import tempfile import uuid +from .conversion_codegen import ( + render_cpp_conversion_kernel, + render_jvm_conversion_kernel, +) +from .cpp_object_runtime_codegen import render_cpp_object_runtime from .feature_model import PluginRuntimeRegistry from .templates import render +from .v3_schemas import ( + GENERATED_OWNERSHIP_KIND, + GENERATED_OWNERSHIP_SCHEMA_VERSION, +) -RUNTIME_RELATIVE_ROOT = Path("android/.supernote-module/v2-runtime") +RUNTIME_RELATIVE_ROOT = Path("android/.supernote-module/v3-runtime") def generated_runtime_files(registry: PluginRuntimeRegistry) -> dict[str, str]: @@ -30,7 +39,10 @@ def generated_runtime_files(registry: PluginRuntimeRegistry) -> dict[str, str]: feature_rows = " // No generated features are currently registered." component = registry.component_name registration_component = f"{component}_registration" - registrar_property = f"supernote.v2.registrar.{component}.v1" + source_property = f"supernote.v3.source.{component}.v1" + generation_count_property = f"supernote.v3.generations.{component}.v1" + load_request_property = f"supernote.v3.load-request.{component}.v1" + registrar_property = f"supernote.v3.registrar.{component}.v2" jvm_roots = [ f"local_modules/{entry.feature.npm_name}/{entry.feature.roots.jvm}" for entry in registry.features @@ -51,6 +63,29 @@ def generated_runtime_files(registry: PluginRuntimeRegistry) -> dict[str, str]: f' "${{CMAKE_CURRENT_LIST_DIR}}/../../../{path}"' for path in native_roots ) + consumer_keep_rules = "\n".join( + f"-keep class {entry.feature.android_namespace}.** {{ *; }}" + for entry in registry.features + ) + if consumer_keep_rules: + consumer_keep_rules += "\n" + consumer_keep_rules = ( + "# Generated by supernote_module_generator. Do not edit.\n" + "# JNI resolves these classes and members by generated binary names.\n" + "# PluginHost casts the generated package through this shared ABI name.\n" + "-keep interface com.facebook.react.ReactPackage { *; }\n" + "# The runtime deliberately calls PluginHost's parent-loaded SoLoader API.\n" + "-keep class com.facebook.soloader.SoLoader { *; }\n" + "-keep class com.facebook.soloader.SoSource { *; }\n" + "-keep class com.facebook.soloader.DirectorySoSource { *; }\n" + "# PluginHost's parent ClassLoader can supply these shared Kotlin ABI classes.\n" + "-keep class kotlin.** { *; }\n" + "-keep class kotlinx.coroutines.** { *; }\n" + "# JNI descriptors include coroutine Job and declared feature types.\n" + "-keep,includedescriptorclasses class supernote.generated.runtime.** { *; }\n" + "-keep,includedescriptorclasses class supernote.generated.adapters.** { *; }\n" + + consumer_keep_rules + ) ksp_root_args = "\n".join( " arg(" + repr(f"supernoteFeatureRoot_{index:08d}") @@ -113,9 +148,25 @@ def generated_runtime_files(registry: PluginRuntimeRegistry) -> dict[str, str]: find_package(ReactAndroid REQUIRED CONFIG) find_package(fbjni REQUIRED CONFIG) target_compile_features({component} PRIVATE cxx_std_23) -set_target_properties({component} PROPERTIES C_STANDARD 23 C_STANDARD_REQUIRED YES) +set_target_properties( + {component} PROPERTIES + C_STANDARD 23 + C_STANDARD_REQUIRED YES + C_VISIBILITY_PRESET hidden + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN YES) +# JNI and JSI provide many inline C++ member functions as weak definitions. +# A reloadable plugin DSO must bind those definitions to itself; otherwise the +# Android linker may interpose a definition from an older plugin DSO and leave +# a stale function address behind when that DSO is unloaded. +target_link_options({component} PRIVATE "-Wl,-Bsymbolic-functions") +if(SUPERNOTE_V3_WEAK_OBJECT_PROBE) + target_compile_definitions( + {component} PRIVATE SUPERNOTE_V3_WEAK_OBJECT_PROBE=1) +endif() target_include_directories( {component} PRIVATE ${{SUPERNOTE_NATIVE_ROOTS}} + "${{CMAKE_CURRENT_LIST_DIR}}/../../../local_modules" "${{CMAKE_CURRENT_LIST_DIR}}/src" "${{CMAKE_CURRENT_LIST_DIR}}/include") target_link_libraries( @@ -129,6 +180,7 @@ def generated_runtime_files(registry: PluginRuntimeRegistry) -> dict[str, str]: {registration_component} PROPERTIES C_STANDARD 23 C_STANDARD_REQUIRED YES) +target_link_libraries({registration_component} PRIVATE dl log) """ gradle = f"""// Generated by supernote_module_generator. Do not edit. plugins {{ @@ -145,9 +197,14 @@ def supernoteNativeRoots = [ {native_root_rows} ] def supernoteIsWindows = System.getProperty('os.name').toLowerCase().contains('windows') +def supernoteWeakObjectProbe = providers + .gradleProperty('supernoteV3WeakObjectProbe') + .map {{ it.toBoolean() }} + .orElse(false) + .get() def supernoteWindowsBuildRoot = new File( System.getProperty('java.io.tmpdir'), - 'supernote-v2/{component}', + 'supernote-v3/{component}', ) if (supernoteIsWindows) {{ layout.buildDirectory.set(new File(supernoteWindowsBuildRoot, 'gradle')) @@ -160,6 +217,7 @@ def supernoteWindowsBuildRoot = new File( defaultConfig {{ minSdk rootProject.ext.minSdkVersion + consumerProguardFiles 'consumer-rules.pro' ndk {{ abiFilters 'arm64-v8a' }} @@ -168,6 +226,7 @@ def supernoteWindowsBuildRoot = new File( arguments( '-DANDROID_STL=c++_shared', "-DSUPERNOTE_GENERATED_ROOT=${{layout.buildDirectory.dir('generated/supernote').get().asFile.absolutePath}}", + "-DSUPERNOTE_V3_WEAK_OBJECT_PROBE=${{supernoteWeakObjectProbe ? 1 : 0}}", ) }} }} @@ -180,7 +239,7 @@ def supernoteWindowsBuildRoot = new File( buildStagingDirectory( supernoteIsWindows ? new File(supernoteWindowsBuildRoot, 'cxx') - : file("${{rootProject.projectDir}}/.cxx/snv2") + : file("${{rootProject.projectDir}}/.cxx/snv3") ) }} }} @@ -216,8 +275,9 @@ def supernoteWindowsBuildRoot = new File( dependencies {{ implementation('com.facebook.react:react-android') implementation('org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1') - compileOnly project(':supernote-v2-annotations') - ksp project(':supernote-v2-processor') + implementation('org.jspecify:jspecify:1.0.0') + compileOnly project(':supernote-v3-annotations') + ksp project(':supernote-v3-processor') }} ksp {{ @@ -272,9 +332,10 @@ def commonTask = tasks.register("generateSupernote${{buildVariant}}Semantics", E dependsOn tasks.named("ksp${{buildVariant}}Kotlin") }} }} + def cmakeBuildType = buildVariant == 'Release' ? 'RelWithDebInfo' : buildVariant tasks.matching {{ task -> task.name == "externalNativeBuild${{buildVariant}}" || - task.name == "configureCMake${{buildVariant}}[arm64-v8a]" + task.name == "configureCMake${{cmakeBuildType}}[arm64-v8a]" }}.configureEach {{ dependsOn commonTask }} }} """ @@ -293,7 +354,7 @@ def commonTask = tasks.register("generateSupernote${{buildVariant}}Semantics", E } dependencies { - implementation project(':supernote-v2-annotations') + implementation project(':supernote-v3-annotations') implementation 'com.google.devtools.ksp:symbol-processing-api:2.0.21-1.0.28' } @@ -470,6 +531,7 @@ class ManagedRef { T *get() const noexcept { return value_.get(); } T &operator*() const noexcept { return *value_; } T *operator->() const noexcept { return value_.get(); } + const std::shared_ptr &shared_ref() const noexcept { return value_; } explicit operator bool() const noexcept { return static_cast(value_); } void reset() noexcept { @@ -542,6 +604,7 @@ class PendingOperation { CancellationToken cancellation_token() const noexcept; void set_work(BoundedExecutor::WorkHandle work) noexcept; void set_cancel_hook(std::function hook) noexcept; + void set_retained_state(std::shared_ptr state) noexcept; std::shared_ptr take_internal_completion() noexcept; private: @@ -556,6 +619,7 @@ class PendingOperation { std::mutex mutex_; BoundedExecutor::WorkHandle work_; std::function cancel_hook_; + std::shared_ptr retained_state_; std::shared_ptr internal_completion_; TeardownRejection rejection_; friend class FeatureSession; @@ -1193,6 +1257,11 @@ class Result final { } } +void PendingOperation::set_retained_state(std::shared_ptr state) noexcept { + std::lock_guard lock(mutex_); + retained_state_ = std::move(state); +} + std::shared_ptr PendingOperation::take_internal_completion() noexcept { std::lock_guard lock(mutex_); return std::move(internal_completion_); @@ -1253,6 +1322,7 @@ class Result final { } std::shared_ptr FeatureSession::runtime() const noexcept { + std::lock_guard lock(mutex_); return runtime_.lock(); } @@ -1290,9 +1360,14 @@ class Result final { bool FeatureSession::schedule_completion( const std::shared_ptr &operation, RuntimeSession::JsTask completion) noexcept { - if (!operation || !completion || state() != FeatureState::ACTIVE) return false; - auto runtime = runtime_.lock(); - if (!runtime || !runtime->active()) return false; + if (!operation || !completion) return false; + std::shared_ptr runtime; + { + std::lock_guard lock(mutex_); + if (state() != FeatureState::ACTIVE) return false; + runtime = runtime_.lock(); + if (!runtime || !runtime->active()) return false; + } auto weak = weak_from_this(); const auto expected_feature = id_; return runtime->schedule( @@ -1331,13 +1406,14 @@ class Result final { void FeatureSession::close_runtime() noexcept { close(true); } void FeatureSession::close(bool runtime_teardown) noexcept { + std::shared_ptr runtime; { std::lock_guard lock(mutex_); if (state() != FeatureState::ACTIVE) return; state_.store(FeatureState::CLOSING, std::memory_order_release); + runtime = runtime_.lock(); } - auto runtime = runtime_.lock(); if (!runtime_teardown && runtime) runtime->remove_feature(id_); for (;;) { std::shared_ptr operation; @@ -1377,8 +1453,11 @@ class Result final { } defer_release(std::move(service)); } - runtime_.reset(); - state_.store(FeatureState::INACTIVE, std::memory_order_release); + { + std::lock_guard lock(mutex_); + runtime_.reset(); + state_.store(FeatureState::INACTIVE, std::memory_order_release); + } } void FeatureSession::erase_pending(SessionId operation_id) noexcept { @@ -1481,23 +1560,41 @@ class Result final { registration_bridge = """// Generated by supernote_module_generator. Do not edit. #include +#include +#include #include -typedef jboolean (*SupernoteRuntimeRegistrar)(JNIEnv *, jobject); +typedef jboolean (*SupernoteRuntimeRegistrar)(JNIEnv *, jobject, jstring); JNIEXPORT jboolean JNICALL -Java_supernote_generated_runtime_SupernoteV2NativeRegistrationBridge_nativeRegister( +Java_supernote_generated_runtime_SupernoteV3NativeRegistrationBridge_nativeRegister( JNIEnv *env, jobject bridge, jlong registrar_address, + jstring generation_identity, jobject class_loader) { if (env == NULL || bridge == NULL || registrar_address == 0 || - class_loader == NULL) { + generation_identity == NULL || class_loader == NULL) { return JNI_FALSE; } SupernoteRuntimeRegistrar registrar = (SupernoteRuntimeRegistrar)(uintptr_t)registrar_address; - return registrar(env, class_loader); + Dl_info registrar_info; + if (dladdr((void *)registrar, ®istrar_info) == 0 || + registrar_info.dli_fbase == NULL) { + __android_log_print( + ANDROID_LOG_ERROR, "SupernoteV3Registration", + "published runtime registrar is no longer mapped"); + return JNI_FALSE; + } + const jboolean registered = + registrar(env, class_loader, generation_identity); + if (registered != JNI_TRUE) { + __android_log_print( + ANDROID_LOG_ERROR, "SupernoteV3Registration", + "current runtime registrar rejected the plugin ClassLoader"); + } + return registered; } """ bootstrap = f"""// Generated by supernote_module_generator. Do not edit. @@ -1512,6 +1609,7 @@ class Result final { #include #include #include +#include #include #include #include @@ -1526,17 +1624,83 @@ class Result final { }} extern "C" __attribute__((visibility("hidden"))) jboolean -{component}_register_natives(JNIEnv *env, jobject class_loader); +{component}_register_natives( + JNIEnv *env, jobject class_loader, jstring generation_identity); namespace {{ -constexpr char kLogTag[] = "SupernoteV2Runtime"; +constexpr char kLogTag[] = "SupernoteV3Runtime"; +constexpr char kLoadRequestProperty[] = {json.dumps(load_request_property)}; constexpr char kRegistrarProperty[] = {json.dumps(registrar_property)}; +std::string g_generation_identity; std::mutex g_mutex; std::unordered_map> g_sessions; +#if defined(SUPERNOTE_V3_WEAK_OBJECT_PROBE) +constexpr char kWeakProbeGlobal[] = "__supernoteV3Phase0WeakObjectProbe"; +constexpr char kWeakProbeTargetGlobal[] = + "__supernoteV3Phase0WeakObjectProbeTarget"; + +class WeakObjectProbeHost final : public facebook::jsi::HostObject {{ + public: + WeakObjectProbeHost( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Object &target) {{ + weak_.emplace(runtime, target); + if (!lock_and_verify(runtime)) {{ + throw std::runtime_error("JSI WeakObject could not lock its live target"); + }} + __android_log_print( + ANDROID_LOG_INFO, kLogTag, + "phase0 weak object created and locked on JS thread"); + }} + + ~WeakObjectProbeHost() override {{ + weak_.reset(); + __android_log_print( + ANDROID_LOG_INFO, kLogTag, + "phase0 weak object destroyed by runtime-managed HostObject teardown"); + }} + + facebook::jsi::Value get( + facebook::jsi::Runtime &runtime, + const facebook::jsi::PropNameID &name) override {{ + if (name.utf8(runtime) != "locked") {{ + return facebook::jsi::Value::undefined(); + }} + const auto locked = lock_and_verify(runtime); + __android_log_print( + locked ? ANDROID_LOG_INFO : ANDROID_LOG_ERROR, kLogTag, + "phase0 weak object JS-thread lock %s", + locked ? "passed" : "failed"); + return facebook::jsi::Value(locked); + }} + + private: + bool lock_and_verify(facebook::jsi::Runtime &runtime) const {{ + if (!weak_) return false; + auto value = weak_->lock(runtime); + if (!value.isObject()) return false; + auto token = value.asObject(runtime).getProperty(runtime, "token"); + return token.isNumber() && token.asNumber() == 42.0; + }} + + std::optional weak_; +}}; + +void install_weak_object_probe(facebook::jsi::Runtime &runtime) {{ + facebook::jsi::Object target(runtime); + target.setProperty(runtime, "token", 42.0); + auto probe = facebook::jsi::Object::createFromHostObject( + runtime, std::make_shared(runtime, target)); + runtime.global().setProperty(runtime, kWeakProbeGlobal, std::move(probe)); + runtime.global().setProperty( + runtime, kWeakProbeTargetGlobal, std::move(target)); +}} +#endif + class AttachedEnv {{ public: explicit AttachedEnv(JavaVM *vm) : vm_(vm) {{ @@ -1568,15 +1732,38 @@ class AttachedEnv {{ bool publish_runtime_registrar(JNIEnv *env) {{ auto system_class = env->FindClass("java/lang/System"); + auto get_property = system_class == nullptr + ? nullptr + : env->GetStaticMethodID( + system_class, "getProperty", + "(Ljava/lang/String;)Ljava/lang/String;"); auto set_property = system_class == nullptr ? nullptr : env->GetStaticMethodID( system_class, "setProperty", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"); - auto key = env->NewStringUTF(kRegistrarProperty); - const auto address = std::to_string( + auto request_key = env->NewStringUTF(kLoadRequestProperty); + auto request = get_property == nullptr || request_key == nullptr + ? nullptr + : static_cast(env->CallStaticObjectMethod( + system_class, get_property, request_key)); + if (env->ExceptionCheck() || request == nullptr) {{ + clear_exception(env, "read runtime generation request"); + return false; + }} + const char *request_chars = env->GetStringUTFChars(request, nullptr); + if (request_chars == nullptr) {{ + clear_exception(env, "copy runtime generation request"); + return false; + }} + g_generation_identity.assign(request_chars); + env->ReleaseStringUTFChars(request, request_chars); + if (g_generation_identity.empty()) return false; + + const auto publication = g_generation_identity + ":" + std::to_string( reinterpret_cast(&{component}_register_natives)); - auto value = env->NewStringUTF(address.c_str()); + auto key = env->NewStringUTF(kRegistrarProperty); + auto value = env->NewStringUTF(publication.c_str()); if (set_property == nullptr || key == nullptr || value == nullptr) {{ clear_exception(env, "prepare runtime registrar publication"); return false; @@ -1671,7 +1858,7 @@ class AttachedEnv {{ }} // namespace extern "C" JNIEXPORT jlong JNICALL -Java_supernote_generated_runtime_SupernoteV2Module_nativeInstall( +Java_supernote_generated_runtime_SupernoteV3Module_nativeInstall( JNIEnv *env, jobject module, jlong runtime_pointer, jobject class_loader, jobject platform_context, jobject call_invoker_holder) {{ if (env == nullptr || module == nullptr || class_loader == nullptr || @@ -1723,6 +1910,9 @@ class AttachedEnv {{ auto *runtime = reinterpret_cast( static_cast(runtime_pointer)); supernote::generated::install_plugin_bindings(*runtime, session); +#if defined(SUPERNOTE_V3_WEAK_OBJECT_PROBE) + install_weak_object_probe(*runtime); +#endif const auto session_id = session->id(); {{ std::lock_guard lock(g_mutex); @@ -1745,7 +1935,7 @@ class AttachedEnv {{ }} extern "C" JNIEXPORT void JNICALL -Java_supernote_generated_runtime_SupernoteV2Module_nativeInvalidate( +Java_supernote_generated_runtime_SupernoteV3Module_nativeInvalidate( JNIEnv *, jobject, jlong session_id) {{ std::shared_ptr session; {{ @@ -1762,8 +1952,24 @@ class AttachedEnv {{ }} extern "C" __attribute__((visibility("hidden"))) jboolean -{component}_register_natives(JNIEnv *env, jobject class_loader) {{ - if (env == nullptr || class_loader == nullptr) return JNI_FALSE; +{component}_register_natives( + JNIEnv *env, jobject class_loader, jstring generation_identity) {{ + if (env == nullptr || class_loader == nullptr || + generation_identity == nullptr) return JNI_FALSE; + const char *identity_chars = + env->GetStringUTFChars(generation_identity, nullptr); + if (identity_chars == nullptr) {{ + clear_exception(env, "read requested runtime generation identity"); + return JNI_FALSE; + }} + const bool generation_matches = g_generation_identity == identity_chars; + env->ReleaseStringUTFChars(generation_identity, identity_chars); + if (!generation_matches) {{ + __android_log_print( + ANDROID_LOG_ERROR, kLogTag, + "generated runtime generation identity mismatch"); + return JNI_FALSE; + }} auto loader_class = env->GetObjectClass(class_loader); auto load_class = loader_class == nullptr ? nullptr @@ -1771,7 +1977,7 @@ class AttachedEnv {{ loader_class, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;"); auto module_name = env->NewStringUTF( - "supernote.generated.runtime.SupernoteV2Module"); + "supernote.generated.runtime.SupernoteV3Module"); auto module_class = load_class == nullptr || module_name == nullptr ? nullptr : env->CallObjectMethod(class_loader, load_class, module_name); @@ -1787,11 +1993,11 @@ class AttachedEnv {{ "Lcom/facebook/react/turbomodule/core/interfaces/" "CallInvokerHolder;)J"), reinterpret_cast( - &Java_supernote_generated_runtime_SupernoteV2Module_nativeInstall)}}, + &Java_supernote_generated_runtime_SupernoteV3Module_nativeInstall)}}, {{const_cast("nativeInvalidate"), const_cast("(J)V"), reinterpret_cast( - &Java_supernote_generated_runtime_SupernoteV2Module_nativeInvalidate)}}, + &Java_supernote_generated_runtime_SupernoteV3Module_nativeInvalidate)}}, }}; if (env->RegisterNatives( static_cast(module_class), methods, @@ -1818,8 +2024,8 @@ class AttachedEnv {{ }} """ ownership = { - "schema_version": 1, - "kind": "supernote_plugin_runtime_ownership", + "schema_version": GENERATED_OWNERSHIP_SCHEMA_VERSION, + "kind": GENERATED_OWNERSHIP_KIND, "component_name": component, "generator_version": registry.generator_version, "generated_files": [ @@ -1829,32 +2035,48 @@ class AttachedEnv {{ "common_support/__init__.py", "common_support/binding_codegen.py", "common_support/cpp_projection.py", + "common_support/cpp_routes.py", + "common_support/cpp_object_binding_codegen.py", + "common_support/cross_family_codegen.py", + "common_support/conversion.py", "common_support/jvm_manifest.py", "common_support/jvm_codegen.py", + "common_support/jvm_object_binding_codegen.py", + "common_support/jvm_object_runtime_codegen.py", "common_support/jvm_projection.py", + "common_support/jvm_routes.py", "common_support/internal_codegen.py", "common_support/lowering.py", + "common_support/reachability.py", "common_support/semantic.py", + "common_support/semantic_types.py", + "common_support/v3_schemas.py", "common_support/source_models.py", "common_support/typescript_codegen.py", + "consumer-rules.pro", "annotations/build.gradle", "annotations/src/main/java/supernote/generated/annotations/SupernotePluginAsync.java", "annotations/src/main/java/supernote/generated/annotations/SupernoteConstructor.java", "annotations/src/main/java/supernote/generated/annotations/SupernotePluginExport.java", "annotations/src/main/java/supernote/generated/annotations/SupernotePluginInternal.java", + "annotations/src/main/java/supernote/generated/annotations/SupernotePluginObject.java", + "annotations/src/main/java/supernote/generated/annotations/SupernotePluginValue.java", "feature-registry.json", "ownership.json", - "src/main/java/supernote/generated/runtime/SupernoteV2Module.kt", + "src/main/java/supernote/generated/runtime/SupernoteV3Module.kt", "src/main/java/supernote/generated/runtime/SupernoteCoroutineBridge.kt", + "src/main/java/supernote/generated/runtime/SupernoteConversionBudget.kt", "src/feature_registry.cpp", "src/feature_registry.hpp", "src/runtime_services.cpp", "src/runtime_services.hpp", "include/supernote/runtime.hpp", + "include/supernote/conversion.hpp", + "include/supernote/cpp_objects.hpp", "src/runtime_bootstrap.cpp", "src/runtime_registration_bridge.c", "processor/build.gradle", - "processor/src/main/kotlin/supernote/generated/processor/SupernoteV2Processor.kt", + "processor/src/main/kotlin/supernote/generated/processor/SupernoteV3Processor.kt", "processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider", ], } @@ -1868,43 +2090,62 @@ class AttachedEnv {{ "common_support/__init__.py": "", "common_support/binding_codegen.py": _support_source("binding_codegen.py"), "common_support/cpp_projection.py": _support_source("cpp_projection.py"), + "common_support/cpp_routes.py": _support_source("cpp_routes.py"), + "common_support/cpp_object_binding_codegen.py": _support_source("cpp_object_binding_codegen.py"), + "common_support/cross_family_codegen.py": _support_source("cross_family_codegen.py"), + "common_support/conversion.py": _support_source("conversion.py"), "common_support/jvm_manifest.py": _support_source("jvm_manifest.py"), "common_support/jvm_codegen.py": _support_source("jvm_codegen.py"), + "common_support/jvm_object_binding_codegen.py": _support_source("jvm_object_binding_codegen.py"), + "common_support/jvm_object_runtime_codegen.py": _support_source("jvm_object_runtime_codegen.py"), "common_support/jvm_projection.py": _support_source("jvm_projection.py"), + "common_support/jvm_routes.py": _support_source("jvm_routes.py"), "common_support/internal_codegen.py": _support_source("internal_codegen.py"), "common_support/lowering.py": _support_source("lowering.py"), + "common_support/reachability.py": _support_source("reachability.py"), "common_support/semantic.py": _support_source("semantic.py"), + "common_support/semantic_types.py": _support_source("semantic_types.py"), + "common_support/v3_schemas.py": _support_source("v3_schemas.py"), "common_support/source_models.py": _support_source("source_models.py"), "common_support/typescript_codegen.py": _support_source("typescript_codegen.py"), + "consumer-rules.pro": consumer_keep_rules, "annotations/build.gradle": annotations_gradle, "annotations/src/main/java/supernote/generated/annotations/SupernotePluginAsync.java": render("v2.SupernotePluginAsync.java.tmpl", {}), "annotations/src/main/java/supernote/generated/annotations/SupernoteConstructor.java": render("v2.SupernoteConstructor.java.tmpl", {}), "annotations/src/main/java/supernote/generated/annotations/SupernotePluginExport.java": render("v2.SupernotePluginExport.java.tmpl", {}), "annotations/src/main/java/supernote/generated/annotations/SupernotePluginInternal.java": render("v2.SupernotePluginInternal.java.tmpl", {}), + "annotations/src/main/java/supernote/generated/annotations/SupernotePluginObject.java": render("v3.SupernotePluginObject.java.tmpl", {}), + "annotations/src/main/java/supernote/generated/annotations/SupernotePluginValue.java": render("v3.SupernotePluginValue.java.tmpl", {}), "feature-registry.json": ( json.dumps(registry.manifest(), indent=2, sort_keys=True) + "\n" ), "ownership.json": json.dumps(ownership, indent=2, sort_keys=True) + "\n", - "src/main/java/supernote/generated/runtime/SupernoteV2Module.kt": render( + "src/main/java/supernote/generated/runtime/SupernoteV3Module.kt": render( "v2.SupernoteV2Module.kt.tmpl", { "NATIVE_LIBRARY_NAME": component, "NATIVE_REGISTRATION_LIBRARY_NAME": registration_component, + "NATIVE_SOURCE_PROPERTY": source_property, + "NATIVE_GENERATION_COUNT_PROPERTY": generation_count_property, + "NATIVE_LOAD_REQUEST_PROPERTY": load_request_property, "NATIVE_REGISTRAR_PROPERTY": registrar_property, }, ), "src/main/java/supernote/generated/runtime/SupernoteCoroutineBridge.kt": render( "v2.SupernoteCoroutineBridge.kt.tmpl", {} ), + "src/main/java/supernote/generated/runtime/SupernoteConversionBudget.kt": render_jvm_conversion_kernel(), "src/feature_registry.cpp": registry_source, "src/feature_registry.hpp": header, "src/runtime_services.cpp": services, "src/runtime_services.hpp": services_header, "include/supernote/runtime.hpp": public_runtime_header, + "include/supernote/conversion.hpp": render_cpp_conversion_kernel(), + "include/supernote/cpp_objects.hpp": render_cpp_object_runtime(), "src/runtime_bootstrap.cpp": bootstrap, "src/runtime_registration_bridge.c": registration_bridge, "processor/build.gradle": processor_gradle, - "processor/src/main/kotlin/supernote/generated/processor/SupernoteV2Processor.kt": render( + "processor/src/main/kotlin/supernote/generated/processor/SupernoteV3Processor.kt": render( "v2.SupernoteV2Processor.kt.tmpl", {"GENERATOR_VERSION": registry.generator_version}, ), diff --git a/src/supernote_module_generator/project.py b/src/supernote_module_generator/project.py index 68a3fa3..7442b18 100644 --- a/src/supernote_module_generator/project.py +++ b/src/supernote_module_generator/project.py @@ -18,6 +18,10 @@ from .validation import package_path, validate_config LOCAL_MODULES_DIR = "local_modules" +TEMPLATE_BUILD_SCRIPTS = ( + Path("scripts/buildPlugin.sh"), + Path("scripts/buildPlugin.ps1"), +) def ensure_within_plugin(root: Path, target: Path) -> Path: @@ -39,18 +43,30 @@ def ensure_tree_within_plugin(root: Path, tree: Path) -> None: ensure_within_plugin(root, target) +def template_build_script(root: Path) -> Optional[Path]: + """Return the official template's pre-build identity marker, if present.""" + return next( + (root / relative for relative in TEMPLATE_BUILD_SCRIPTS if (root / relative).is_file()), + None, + ) + + def resolve_plugin_root(path: Path) -> Path: root = path.expanduser().resolve() + manifest = root / "PluginConfig.json" + prebuild_marker = template_build_script(root) markers = ( - (root / "PluginConfig.json").is_file(), + manifest.is_file() or prebuild_marker is not None, (root / "package.json").is_file(), (root / "android").is_dir(), any((root / "android" / name).is_file() for name in ("settings.gradle", "settings.gradle.kts")), ) if not all(markers): raise ConfigurationError(f"not a Supernote plugin: {root}") + identity_marker = manifest if manifest.is_file() else prebuild_marker + assert identity_marker is not None for marker in ( - root / "PluginConfig.json", + identity_marker, root / "package.json", root / "android", android_settings(root), diff --git a/src/supernote_module_generator/reachability.py b/src/supernote_module_generator/reachability.py new file mode 100644 index 0000000..6ef3199 --- /dev/null +++ b/src/supernote_module_generator/reachability.py @@ -0,0 +1,294 @@ +"""Compute the JavaScript-public V3 type graph from common semantics.""" +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from typing import Deque, Dict, Iterable, Tuple + +from .semantic import ( + MemberScope, + SemanticApi, + SemanticBinding, + SemanticDeclaration, + SemanticEnumDeclaration, + SemanticModelError, + SemanticObjectDeclaration, + SemanticValueDeclaration, +) +from .semantic_types import SemanticType, SemanticTypeKind + + +class PublicReachabilityError(SemanticModelError): + """Raised when explicit public intent cannot form one valid JS surface.""" + + +@dataclass(frozen=True) +class PublicApi: + """Closed public view consumed by TypeScript and later runtime lowerings.""" + + functions: Tuple[SemanticBinding, ...] + declarations: Tuple[SemanticDeclaration, ...] + object_namespaces: frozenset[str] + object_instances: frozenset[str] + + def declaration(self, type_id: str) -> SemanticDeclaration: + for item in self.declarations: + if item.type_id == type_id: + return item + raise KeyError(type_id) + + +def compute_public_api( + api: SemanticApi, + *, + feature_name: str | None = None, +) -> PublicApi: + """Return the deterministic transitive public surface for ``api``. + + Static object methods and marked constructors are roots. Instance members + become graph edges only after an ObjectRef (or constructor) makes their + receiver type reachable. + """ + + declarations: Dict[str, SemanticDeclaration] = { + item.type_id: item for item in api.declarations + } + public_functions = tuple( + sorted( + ( + item + for item in api.functions + if item.capabilities.javascript_public + ), + key=lambda item: (item.name, item.binding_id), + ) + ) + reachable: set[str] = set() + object_namespaces: set[str] = set() + object_instances: set[str] = set() + expanded_values: set[str] = set() + expanded_objects: set[str] = set() + pending: Deque[SemanticType] = deque() + + for binding in public_functions: + _queue_binding(pending, binding) + + for declaration in api.declarations: + if not isinstance(declaration, SemanticObjectDeclaration): + continue + static_methods = tuple( + method + for method in declaration.methods + if method.capabilities.javascript_public + and method.member_scope is MemberScope.STATIC + ) + if declaration.constructor is not None or static_methods: + object_namespaces.add(declaration.type_id) + reachable.add(declaration.type_id) + if declaration.constructor is not None: + object_instances.add(declaration.type_id) + pending.append(SemanticType.object_ref(declaration.type_id)) + pending.extend(parameter.type for parameter in declaration.constructor.parameters) + for method in static_methods: + _queue_binding(pending, method) + + while pending: + semantic_type = pending.popleft() + if semantic_type.kind in {SemanticTypeKind.ARRAY, SemanticTypeKind.NULLABLE}: + assert semantic_type.element is not None + pending.append(semantic_type.element) + continue + if semantic_type.type_id is None: + continue + declaration = declarations[semantic_type.type_id] + reachable.add(declaration.type_id) + if isinstance(declaration, SemanticValueDeclaration): + if declaration.type_id in expanded_values: + continue + expanded_values.add(declaration.type_id) + pending.extend(field.type for field in declaration.fields) + elif isinstance(declaration, SemanticObjectDeclaration): + object_instances.add(declaration.type_id) + + # Expanding object members is delayed until an ObjectRef or constructor + # reaches the instance. A namespace-only static API is insufficient. + while True: + unexpanded = sorted(object_instances - expanded_objects) + if not unexpanded: + break + type_id = unexpanded[0] + expanded_objects.add(type_id) + declaration = declarations[type_id] + assert isinstance(declaration, SemanticObjectDeclaration) + for method in declaration.methods: + if ( + method.capabilities.javascript_public + and method.member_scope is MemberScope.INSTANCE + ): + _queue_binding(pending, method) + pending.extend(field.type for field in declaration.fields) + + _reject_unreachable_members(api, reachable, object_instances) + public_declarations = tuple( + sorted( + (item for item in api.declarations if item.type_id in reachable), + key=lambda item: (item.name, item.type_id), + ) + ) + public = PublicApi( + functions=public_functions, + declarations=public_declarations, + object_namespaces=frozenset(object_namespaces), + object_instances=frozenset(object_instances), + ) + _validate_public_names(public, feature_name=feature_name) + return public + + +def _queue_binding(pending: Deque[SemanticType], binding: SemanticBinding) -> None: + pending.extend(parameter.type for parameter in binding.parameters) + pending.append(binding.result) + + +def _reject_unreachable_members( + api: SemanticApi, + reachable: set[str], + object_instances: set[str], +) -> None: + for declaration in api.declarations: + if isinstance(declaration, SemanticValueDeclaration): + if declaration.type_id not in reachable and declaration.fields: + field = declaration.fields[0] + raise PublicReachabilityError( + f"{field.source.location}: exported value field " + f"{declaration.name}.{field.name} is unreachable from every " + "public function, method, or constructor" + ) + continue + if not isinstance(declaration, SemanticObjectDeclaration): + continue + if declaration.type_id in object_instances: + continue + instance_methods = tuple( + method + for method in declaration.methods + if method.capabilities.javascript_public + and method.member_scope is MemberScope.INSTANCE + ) + if instance_methods: + method = instance_methods[0] + raise PublicReachabilityError( + f"{method.source.location}: exported instance method " + f"{declaration.name}.{method.name} is unreachable because no public " + f"root produces or constructs {declaration.name}" + ) + if declaration.fields: + field = declaration.fields[0] + raise PublicReachabilityError( + f"{field.source.location}: exported object field " + f"{declaration.name}.{field.name} is unreachable because no public " + f"root produces or constructs {declaration.name}" + ) + + +def _validate_public_names(public: PublicApi, *, feature_name: str | None) -> None: + declarations = {item.type_id: item for item in public.declarations} + feature_names: Dict[str, str] = {} + function_names: Dict[str, str] = {} + for binding in public.functions: + _claim(feature_names, binding.name, binding.source.location, "feature root") + function_names[binding.name] = binding.source.location + # Every reachable named type owns a runtime companion on the feature root. + # This includes returned-only objects and copied value/enum declarations, + # not only object types with constructors or static methods. + for declaration in public.declarations: + _claim( + feature_names, + declaration.name, + _declaration_location(declaration), + "feature root", + ) + + generated_type_names = { + "SupernoteError": "generated TypeScript error class", + "SupernoteErrorCode": "generated TypeScript error-code type", + "SupernoteValidationReason": "generated validation-reason type", + "SupernoteValidationDetails": "generated validation-details interface", + "SupernoteTypeError": "generated validation error type", + "SupernoteRangeError": "generated validation error type", + "SupernoteValidationResult": "generated validation-result type", + "SupernoteCallable": "generated callable interface", + "SupernoteTypeCompanion": "generated type-companion interface", + "SupernoteFeatureStatus": "generated feature-status type", + "SupernoteNativeObjectInfo": "generated native-object information interface", + } + if feature_name is not None: + generated_type_names[f"{feature_name}Feature"] = ( + "generated TypeScript feature interface" + ) + for declaration in public.declarations: + root_location = function_names.get(declaration.name) + if root_location is not None: + raise PublicReachabilityError( + f"{_declaration_location(declaration)}: reachable type name " + f"{declaration.name!r} collides with a feature-root property " + f"declared at {root_location}" + ) + previous = generated_type_names.get(declaration.name) + if previous is not None: + raise PublicReachabilityError( + f"{_declaration_location(declaration)}: reachable type name " + f"{declaration.name!r} collides with {previous}" + ) + generated_type_names[declaration.name] = _declaration_location(declaration) + + for type_id in sorted(public.object_namespaces): + declaration = declarations[type_id] + assert isinstance(declaration, SemanticObjectDeclaration) + namespace: Dict[str, str] = {} + _claim( + namespace, + "is", + _declaration_location(declaration), + f"{declaration.name} type namespace", + ) + _claim( + namespace, + "check", + _declaration_location(declaration), + f"{declaration.name} type namespace", + ) + if declaration.constructor is not None: + _claim( + namespace, + "create", + declaration.constructor.source.location, + f"{declaration.name} type namespace", + ) + for method in declaration.methods: + if ( + method.capabilities.javascript_public + and method.member_scope is MemberScope.STATIC + ): + _claim( + namespace, + method.name, + method.source.location, + f"{declaration.name} type namespace", + ) + + +def _claim(claims: Dict[str, str], name: str, location: str, namespace: str) -> None: + previous = claims.get(name) + if previous is not None: + raise PublicReachabilityError( + f"{location}: public name {name!r} collides in the {namespace}; " + f"first declared at {previous}" + ) + claims[name] = location + + +def _declaration_location(declaration: SemanticDeclaration) -> str: + if isinstance(declaration, SemanticObjectDeclaration): + return declaration.projection.source.location + return declaration.projections[0].source.location diff --git a/src/supernote_module_generator/semantic.py b/src/supernote_module_generator/semantic.py index ce9d8d6..e3f6ff2 100644 --- a/src/supernote_module_generator/semantic.py +++ b/src/supernote_module_generator/semantic.py @@ -1,4 +1,4 @@ -"""Backend-neutral Supernote API semantics for V2. +"""Backend-neutral Supernote API semantics for V3. The records in this module answer what a Supernote API means. They contain no JNI descriptors, C++ include paths, adapter symbols, or generated source text. @@ -10,10 +10,20 @@ from dataclasses import dataclass, field from enum import Enum import re -from typing import Any, Dict, Iterable, Optional, Tuple +from typing import Any, Dict, Iterable, Optional, Tuple, Union + +from .v3_schemas import ( + SEMANTIC_MANIFEST_KIND, + SEMANTIC_MANIFEST_SCHEMA_VERSION, +) +from .semantic_types import ( + ScalarKind, + SemanticType, + SemanticTypeError, + SemanticTypeKind, + semantic_type_from_manifest, +) - -SEMANTIC_MANIFEST_SCHEMA_VERSION = 1 _IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") @@ -21,17 +31,6 @@ class SemanticModelError(ValueError): """Raised when a backend-neutral API invariant is violated.""" -class SemanticType(str, Enum): - VOID = "void" - BOOL = "bool" - INT32 = "int32" - INT64 = "int64" - FLOAT32 = "float32" - FLOAT64 = "float64" - STRING = "string" - BYTES = "bytes" - - class BindingKind(str, Enum): FUNCTION = "function" OBJECT_METHOD = "object_method" @@ -54,6 +53,23 @@ class DeclarationRole(str, Enum): EXPORTED = "exported" +class BackendFamily(str, Enum): + CPP = "cpp" + JVM = "jvm" + + +class MemberScope(str, Enum): + TOP_LEVEL = "top_level" + INSTANCE = "instance" + STATIC = "static" + + +class SemanticDeclarationKind(str, Enum): + ENUM = "enum" + VALUE = "value" + OBJECT = "object" + + @dataclass(frozen=True) class BindingCapabilities: """Independent generated reachability and JavaScript publication flags.""" @@ -115,6 +131,90 @@ def manifest(self) -> Dict[str, object]: "column": self.column, } + @property + def location(self) -> str: + return f"{self.path}:{self.line}:{self.column}" + + +def semantic_type_id(feature_id: str, public_name: str) -> str: + """Create the stable identity shared by every backend projection.""" + + if not feature_id: + raise SemanticModelError("feature identity cannot be empty") + _validate_identifier(public_name, "public semantic type") + return f"{feature_id}:type:{public_name}" + + +@dataclass(frozen=True) +class SemanticProjection: + """One implementation-family capability for a logical declaration.""" + + backend: BackendFamily + source: SourceProvenance + + def __post_init__(self) -> None: + if not isinstance(self.backend, BackendFamily): + raise SemanticModelError(f"unknown backend family {self.backend!r}") + expected = ( + {"cpp"} + if self.backend is BackendFamily.CPP + else {"kotlin", "java"} + ) + if self.source.language not in expected: + raise SemanticModelError( + f"{self.source.location}: {self.source.language!r} source cannot " + f"provide a {self.backend.value!r} projection" + ) + + def manifest(self) -> Dict[str, object]: + return {"backend": self.backend.value, "source": self.source.manifest()} + + +@dataclass(frozen=True) +class SemanticField: + field_id: str + owner_id: str + name: str + type: SemanticType + source: SourceProvenance + mutable: bool + capabilities: BindingCapabilities = field( + default_factory=lambda: BindingCapabilities.for_role( + DeclarationRole.EXPORTED + ) + ) + scope: MemberScope = MemberScope.INSTANCE + required: bool = True + + def __post_init__(self) -> None: + if not self.field_id or not self.owner_id: + raise SemanticModelError("field and owner identities cannot be empty") + _validate_identifier(self.name, "semantic field") + if not isinstance(self.type, SemanticType): + raise SemanticModelError("semantic field type must be SemanticType") + if self.type.is_void: + raise SemanticModelError("void is invalid as a field type") + if not self.capabilities.javascript_public: + raise SemanticModelError("semantic fields must be explicitly exported") + if self.scope is not MemberScope.INSTANCE: + raise SemanticModelError("static bridge fields are not supported") + if not self.required: + raise SemanticModelError("optional/missing fields are not supported") + + def manifest(self) -> Dict[str, object]: + return { + "field_id": self.field_id, + "owner_id": self.owner_id, + "name": self.name, + "type": self.type.manifest(), + "mutable": self.mutable, + "required": self.required, + "routable": self.capabilities.routable, + "javascript_public": self.capabilities.javascript_public, + "scope": self.scope.value, + "source": self.source.manifest(), + } + @dataclass(frozen=True) class SemanticParameter: @@ -123,11 +223,13 @@ class SemanticParameter: def __post_init__(self) -> None: _validate_identifier(self.name, "semantic parameter") - if self.type is SemanticType.VOID: + if not isinstance(self.type, SemanticType): + raise SemanticModelError("semantic parameter type must be SemanticType") + if self.type.is_void: raise SemanticModelError("void is valid only as a result type") - def manifest(self) -> Dict[str, str]: - return {"name": self.name, "type": self.type.value} + def manifest(self) -> Dict[str, object]: + return {"name": self.name, "type": self.type.manifest()} @dataclass(frozen=True) @@ -149,6 +251,7 @@ class SemanticBinding: source: SourceProvenance owner_id: Optional[str] = None owner_name: Optional[str] = None + member_scope: Optional[MemberScope] = None def __post_init__(self) -> None: if not self.binding_id: @@ -157,6 +260,8 @@ def __post_init__(self) -> None: raise SemanticModelError( "ordinary declarations do not become semantic bindings" ) + if not isinstance(self.result, SemanticType): + raise SemanticModelError("semantic result type must be SemanticType") _validate_identifier(self.name, "semantic binding") _reject_duplicates( (parameter.name for parameter in self.parameters), @@ -178,6 +283,18 @@ def __post_init__(self) -> None: raise SemanticModelError("semantic owner identity cannot be empty") if self.owner_name is not None: _validate_identifier(self.owner_name, "semantic owner") + inferred_scope = ( + MemberScope.TOP_LEVEL + if self.kind is BindingKind.FUNCTION + else MemberScope.INSTANCE + ) + if self.member_scope is None: + object.__setattr__(self, "member_scope", inferred_scope) + elif self.kind is BindingKind.FUNCTION: + if self.member_scope is not MemberScope.TOP_LEVEL: + raise SemanticModelError("top-level functions cannot be instance/static") + elif self.member_scope is MemberScope.TOP_LEVEL: + raise SemanticModelError("methods cannot have top-level scope") def manifest(self) -> Dict[str, object]: value: Dict[str, object] = { @@ -189,7 +306,8 @@ def manifest(self) -> Dict[str, object]: "javascript_public": self.capabilities.javascript_public, "execution": self.execution.value, "parameters": [parameter.manifest() for parameter in self.parameters], - "result": self.result.value, + "result": self.result.manifest(), + "member_scope": self.member_scope.value, "source": self.source.manifest(), } if self.owner_id is not None: @@ -305,10 +423,150 @@ def manifest(self) -> Dict[str, object]: } +@dataclass(frozen=True) +class SemanticEnumDeclaration: + feature_id: str + type_id: str + name: str + constants: Tuple[str, ...] + projections: Tuple[SemanticProjection, ...] + + kind = SemanticDeclarationKind.ENUM + + def __post_init__(self) -> None: + _validate_declaration_header(self.feature_id, self.type_id, self.name) + if not self.constants: + raise SemanticModelError(f"enum {self.name!r} must declare a constant") + for constant in self.constants: + _validate_identifier(constant, f"enum {self.name!r} constant") + _reject_duplicates(self.constants, f"enum constant on {self.name!r}") + _validate_projections(self.name, self.projections) + + def manifest(self) -> Dict[str, object]: + return { + "kind": self.kind.value, + "feature_id": self.feature_id, + "type_id": self.type_id, + "name": self.name, + "constants": list(self.constants), + "projections": _projection_manifests(self.projections), + } + + +@dataclass(frozen=True) +class SemanticValueDeclaration: + feature_id: str + type_id: str + name: str + fields: Tuple[SemanticField, ...] + projections: Tuple[SemanticProjection, ...] + + kind = SemanticDeclarationKind.VALUE + + def __post_init__(self) -> None: + _validate_declaration_header(self.feature_id, self.type_id, self.name) + if not self.fields: + raise SemanticModelError(f"value {self.name!r} must declare a field") + _validate_fields(self.type_id, self.name, self.fields) + _validate_projections(self.name, self.projections) + + def manifest(self) -> Dict[str, object]: + return { + "kind": self.kind.value, + "feature_id": self.feature_id, + "type_id": self.type_id, + "name": self.name, + "fields": [item.manifest() for item in self.fields], + "projections": _projection_manifests(self.projections), + } + + +@dataclass(frozen=True) +class SemanticObjectDeclaration: + feature_id: str + type_id: str + name: str + projection: SemanticProjection + constructor: Optional[SemanticConstructor] = None + methods: Tuple[SemanticBinding, ...] = field(default_factory=tuple) + fields: Tuple[SemanticField, ...] = field(default_factory=tuple) + + kind = SemanticDeclarationKind.OBJECT + + def __post_init__(self) -> None: + _validate_declaration_header(self.feature_id, self.type_id, self.name) + _validate_fields(self.type_id, self.name, self.fields) + owned_sources = [item.source for item in self.fields] + if self.constructor is not None: + owned_sources.append(self.constructor.source) + binding_ids = [] + source_ids = [] + static_names = [] + instance_names = [] + for method in self.methods: + if method.kind is not BindingKind.OBJECT_METHOD: + raise SemanticModelError( + f"object member {method.name!r} must have object_method kind" + ) + if method.owner_id != self.type_id or method.owner_name != self.name: + raise SemanticModelError( + f"method {method.name!r} does not belong to object {self.name!r}" + ) + binding_ids.append(method.binding_id) + source_ids.append(method.source.declaration_id) + if method.member_scope is MemberScope.STATIC: + static_names.append(method.name) + else: + instance_names.append(method.name) + owned_sources.append(method.source) + for source in owned_sources: + if _backend_for_language(source.language) is not self.projection.backend: + raise SemanticModelError( + f"{source.location}: object member source backend disagrees with " + f"the {self.projection.backend.value} object projection" + ) + _reject_duplicates(binding_ids, f"method binding identity on {self.name!r}") + _reject_duplicates(source_ids, f"method source identity on {self.name!r}") + _reject_duplicates(static_names, f"static method name on {self.name!r}") + _reject_duplicates( + instance_names + [item.name for item in self.fields], + f"instance member name on {self.name!r}", + ) + + @property + def projections(self) -> Tuple[SemanticProjection, ...]: + return (self.projection,) + + def manifest(self) -> Dict[str, object]: + return { + "kind": self.kind.value, + "feature_id": self.feature_id, + "type_id": self.type_id, + "name": self.name, + "projection": self.projection.manifest(), + "constructor": ( + self.constructor.manifest() if self.constructor is not None else None + ), + "methods": [ + item.manifest() + for item in sorted(self.methods, key=lambda value: value.binding_id) + ], + "fields": [item.manifest() for item in self.fields], + } + + +SemanticDeclaration = Union[ + SemanticEnumDeclaration, + SemanticValueDeclaration, + SemanticObjectDeclaration, +] + + @dataclass(frozen=True) class SemanticApi: functions: Tuple[SemanticBinding, ...] = field(default_factory=tuple) classes: Tuple[SemanticClass, ...] = field(default_factory=tuple) + declarations: Tuple[SemanticDeclaration, ...] = field(default_factory=tuple) def __post_init__(self) -> None: for binding in self.functions: @@ -320,9 +578,19 @@ def __post_init__(self) -> None: all_bindings = list(self.functions) for item in self.classes: all_bindings.extend(item.methods) + for item in self.declarations: + if isinstance(item, SemanticObjectDeclaration): + all_bindings.extend(item.methods) semantic_ids = [binding.binding_id for binding in all_bindings] semantic_ids.extend(item.class_id for item in self.classes) + semantic_ids.extend(item.type_id for item in self.declarations) + semantic_ids.extend( + field.field_id + for item in self.declarations + if isinstance(item, (SemanticValueDeclaration, SemanticObjectDeclaration)) + for field in item.fields + ) _reject_duplicates(semantic_ids, "semantic identity") source_ids = [binding.source.declaration_id for binding in all_bindings] @@ -330,6 +598,16 @@ def __post_init__(self) -> None: source_ids.extend( item.constructor.source.declaration_id for item in self.classes ) + for item in self.declarations: + source_ids.extend( + projection.source.declaration_id for projection in item.projections + ) + if isinstance(item, SemanticObjectDeclaration): + if item.constructor is not None: + source_ids.append(item.constructor.source.declaration_id) + source_ids.extend(field.source.declaration_id for field in item.fields) + elif isinstance(item, SemanticValueDeclaration): + source_ids.extend(field.source.declaration_id for field in item.fields) _reject_duplicates(source_ids, "source declaration identity") public_names = [ @@ -343,10 +621,13 @@ def __post_init__(self) -> None: if item.capabilities.javascript_public ) _reject_duplicates(public_names, "JavaScript-public top-level name") + _validate_named_references(self) + _validate_value_cycles(self) def manifest(self) -> Dict[str, object]: return { "schema_version": SEMANTIC_MANIFEST_SCHEMA_VERSION, + "kind": SEMANTIC_MANIFEST_KIND, "functions": [ binding.manifest() for binding in sorted( @@ -357,6 +638,10 @@ def manifest(self) -> Dict[str, object]: item.manifest() for item in sorted(self.classes, key=lambda value: value.class_id) ], + "types": [ + item.manifest() + for item in sorted(self.declarations, key=lambda value: value.type_id) + ], } @@ -377,13 +662,19 @@ def semantic_api_from_manifest(raw: object) -> SemanticApi: """Read the strict backend-neutral manifest used between build stages.""" value = _manifest_object(raw, "semantic manifest") - _manifest_keys(value, {"schema_version", "functions", "classes"}, "semantic manifest") + _manifest_keys( + value, + {"schema_version", "kind", "functions", "classes", "types"}, + "semantic manifest", + ) schema = _manifest_int(value["schema_version"], "schema_version") if schema != SEMANTIC_MANIFEST_SCHEMA_VERSION: raise SemanticModelError( f"incompatible semantic manifest schema {schema}; " f"expected {SEMANTIC_MANIFEST_SCHEMA_VERSION}" ) + if _manifest_string(value["kind"], "kind") != SEMANTIC_MANIFEST_KIND: + raise SemanticModelError("semantic manifest kind is invalid") functions = tuple( _binding_from_manifest(item, f"functions[{index}]") for index, item in enumerate(_manifest_list(value["functions"], "functions")) @@ -392,15 +683,31 @@ def semantic_api_from_manifest(raw: object) -> SemanticApi: _class_from_manifest(item, f"classes[{index}]") for index, item in enumerate(_manifest_list(value["classes"], "classes")) ) - return SemanticApi(functions, classes) + declarations = tuple( + _declaration_from_manifest(item, f"types[{index}]") + for index, item in enumerate(_manifest_list(value["types"], "types")) + ) + return SemanticApi(functions, classes, declarations) def merge_semantic_apis(*apis: SemanticApi) -> SemanticApi: """Merge language frontends and re-run all common identity/name checks.""" + declarations: Dict[str, SemanticDeclaration] = {} + for declaration in ( + item for api in apis for item in api.declarations + ): + previous = declarations.get(declaration.type_id) + if previous is None: + declarations[declaration.type_id] = declaration + continue + declarations[declaration.type_id] = _merge_declarations( + previous, declaration + ) return SemanticApi( tuple(binding for api in apis for binding in api.functions), tuple(item for api in apis for item in api.classes), + tuple(declarations.values()), ) @@ -409,6 +716,7 @@ def _binding_from_manifest(raw: object, label: str) -> SemanticBinding: required = { "binding_id", "source_declaration_id", "kind", "name", "routable", "javascript_public", "execution", "parameters", "result", "source", + "member_scope", } optional = {"owner_id", "owner"} actual = set(value) @@ -444,9 +752,7 @@ def _binding_from_manifest(raw: object, label: str) -> SemanticBinding: _manifest_list(value["parameters"], f"{label}.parameters") ) ), - result=SemanticType( - _manifest_string(value["result"], f"{label}.result") - ), + result=_semantic_type_from_manifest(value["result"], f"{label}.result"), source=source, owner_id=( _manifest_string(value["owner_id"], f"{label}.owner_id") @@ -458,6 +764,9 @@ def _binding_from_manifest(raw: object, label: str) -> SemanticBinding: if has_owner else None ), + member_scope=MemberScope( + _manifest_string(value["member_scope"], f"{label}.member_scope") + ), ) except ValueError as exc: raise SemanticModelError(f"{label}: {exc}") from exc @@ -539,12 +848,439 @@ def _parameter_from_manifest(raw: object, label: str) -> SemanticParameter: try: return SemanticParameter( _manifest_string(value["name"], f"{label}.name"), - SemanticType(_manifest_string(value["type"], f"{label}.type")), + _semantic_type_from_manifest(value["type"], f"{label}.type"), ) except ValueError as exc: raise SemanticModelError(f"{label}: {exc}") from exc +def _declaration_from_manifest(raw: object, label: str) -> SemanticDeclaration: + value = _manifest_object(raw, label) + kind_text = _manifest_string(value.get("kind"), f"{label}.kind") + try: + kind = SemanticDeclarationKind(kind_text) + except ValueError as exc: + raise SemanticModelError(f"{label}.kind is invalid: {kind_text!r}") from exc + common = {"kind", "feature_id", "type_id", "name"} + feature_id = _manifest_string(value.get("feature_id"), f"{label}.feature_id") + type_id = _manifest_string(value.get("type_id"), f"{label}.type_id") + name = _manifest_string(value.get("name"), f"{label}.name") + if kind is SemanticDeclarationKind.ENUM: + _manifest_keys(value, common | {"constants", "projections"}, label) + return SemanticEnumDeclaration( + feature_id, + type_id, + name, + tuple( + _manifest_string(item, f"{label}.constants[{index}]") + for index, item in enumerate( + _manifest_list(value["constants"], f"{label}.constants") + ) + ), + _projections_from_manifest(value["projections"], f"{label}.projections"), + ) + if kind is SemanticDeclarationKind.VALUE: + _manifest_keys(value, common | {"fields", "projections"}, label) + return SemanticValueDeclaration( + feature_id, + type_id, + name, + tuple( + _field_from_manifest(item, f"{label}.fields[{index}]") + for index, item in enumerate( + _manifest_list(value["fields"], f"{label}.fields") + ) + ), + _projections_from_manifest(value["projections"], f"{label}.projections"), + ) + _manifest_keys( + value, + common | {"projection", "constructor", "methods", "fields"}, + label, + ) + constructor_raw = value["constructor"] + return SemanticObjectDeclaration( + feature_id, + type_id, + name, + _projection_from_manifest(value["projection"], f"{label}.projection"), + ( + None + if constructor_raw is None + else _constructor_from_manifest(constructor_raw, f"{label}.constructor") + ), + tuple( + _binding_from_manifest(item, f"{label}.methods[{index}]") + for index, item in enumerate( + _manifest_list(value["methods"], f"{label}.methods") + ) + ), + tuple( + _field_from_manifest(item, f"{label}.fields[{index}]") + for index, item in enumerate( + _manifest_list(value["fields"], f"{label}.fields") + ) + ), + ) + + +def _constructor_from_manifest(raw: object, label: str) -> SemanticConstructor: + value = _manifest_object(raw, label) + _manifest_keys(value, {"source_declaration_id", "parameters", "source"}, label) + source = _source_from_manifest(value["source"], f"{label}.source") + if _manifest_string( + value["source_declaration_id"], f"{label}.source_declaration_id" + ) != source.declaration_id: + raise SemanticModelError(f"{label} source declaration identity disagrees") + return SemanticConstructor( + source, + tuple( + _parameter_from_manifest(item, f"{label}.parameters[{index}]") + for index, item in enumerate( + _manifest_list(value["parameters"], f"{label}.parameters") + ) + ), + ) + + +def _projection_from_manifest(raw: object, label: str) -> SemanticProjection: + value = _manifest_object(raw, label) + _manifest_keys(value, {"backend", "source"}, label) + try: + return SemanticProjection( + BackendFamily(_manifest_string(value["backend"], f"{label}.backend")), + _source_from_manifest(value["source"], f"{label}.source"), + ) + except ValueError as exc: + raise SemanticModelError(f"{label}: {exc}") from exc + + +def _projections_from_manifest(raw: object, label: str) -> Tuple[SemanticProjection, ...]: + return tuple( + _projection_from_manifest(item, f"{label}[{index}]") + for index, item in enumerate(_manifest_list(raw, label)) + ) + + +def _field_from_manifest(raw: object, label: str) -> SemanticField: + value = _manifest_object(raw, label) + _manifest_keys( + value, + { + "field_id", "owner_id", "name", "type", "mutable", "required", + "routable", "javascript_public", "scope", "source", + }, + label, + ) + try: + return SemanticField( + _manifest_string(value["field_id"], f"{label}.field_id"), + _manifest_string(value["owner_id"], f"{label}.owner_id"), + _manifest_string(value["name"], f"{label}.name"), + _semantic_type_from_manifest(value["type"], f"{label}.type"), + _source_from_manifest(value["source"], f"{label}.source"), + _manifest_bool(value["mutable"], f"{label}.mutable"), + BindingCapabilities( + _manifest_bool(value["routable"], f"{label}.routable"), + _manifest_bool( + value["javascript_public"], f"{label}.javascript_public" + ), + ), + MemberScope(_manifest_string(value["scope"], f"{label}.scope")), + _manifest_bool(value["required"], f"{label}.required"), + ) + except ValueError as exc: + raise SemanticModelError(f"{label}: {exc}") from exc + + +def _semantic_type_from_manifest(raw: object, label: str) -> SemanticType: + try: + return semantic_type_from_manifest(raw, label) + except SemanticTypeError as exc: + raise SemanticModelError(str(exc)) from exc + + +def _validate_declaration_header(feature_id: str, type_id: str, name: str) -> None: + if not feature_id: + raise SemanticModelError("feature identity cannot be empty") + _validate_identifier(name, "semantic declaration") + expected = semantic_type_id(feature_id, name) + if type_id != expected: + raise SemanticModelError( + f"semantic type {name!r} must use stable identity {expected!r}, " + f"not {type_id!r}" + ) + + +def _backend_for_language(language: str) -> BackendFamily: + if language == "cpp": + return BackendFamily.CPP + if language in {"kotlin", "java"}: + return BackendFamily.JVM + raise SemanticModelError(f"unsupported semantic source language {language!r}") + + +def _validate_projections( + name: str, projections: Tuple[SemanticProjection, ...] +) -> None: + if not projections: + raise SemanticModelError(f"semantic type {name!r} needs a backend projection") + _reject_duplicates( + (item.backend.value for item in projections), + f"backend projection on {name!r}", + ) + + +def _projection_manifests( + projections: Tuple[SemanticProjection, ...] +) -> list[Dict[str, object]]: + return [ + item.manifest() + for item in sorted(projections, key=lambda value: value.backend.value) + ] + + +def _validate_fields( + owner_id: str, owner_name: str, fields: Tuple[SemanticField, ...] +) -> None: + for item in fields: + if item.owner_id != owner_id: + raise SemanticModelError( + f"field {item.name!r} does not belong to {owner_name!r}" + ) + _reject_duplicates( + (item.field_id for item in fields), f"field identity on {owner_name!r}" + ) + _reject_duplicates( + (item.name for item in fields), f"field name on {owner_name!r}" + ) + + +def _walk_type(value: SemanticType) -> Iterable[SemanticType]: + yield value + if value.element is not None: + yield from _walk_type(value.element) + + +def _binding_types(binding: SemanticBinding) -> Iterable[SemanticType]: + for parameter in binding.parameters: + yield parameter.type + yield binding.result + + +def _all_api_types(api: SemanticApi) -> Iterable[SemanticType]: + for binding in api.functions: + yield from _binding_types(binding) + for legacy in api.classes: + for parameter in legacy.constructor.parameters: + yield parameter.type + for method in legacy.methods: + yield from _binding_types(method) + for declaration in api.declarations: + if isinstance(declaration, (SemanticValueDeclaration, SemanticObjectDeclaration)): + for item in declaration.fields: + yield item.type + if isinstance(declaration, SemanticObjectDeclaration): + if declaration.constructor is not None: + for parameter in declaration.constructor.parameters: + yield parameter.type + for method in declaration.methods: + yield from _binding_types(method) + + +def _validate_named_references(api: SemanticApi) -> None: + declarations = {item.type_id: item for item in api.declarations} + expected = { + SemanticTypeKind.ENUM_REF: SemanticEnumDeclaration, + SemanticTypeKind.VALUE_REF: SemanticValueDeclaration, + SemanticTypeKind.OBJECT_REF: SemanticObjectDeclaration, + } + for root in _all_api_types(api): + for item in _walk_type(root): + declaration_class = expected.get(item.kind) + if declaration_class is None: + continue + declaration = declarations.get(item.type_id) + if declaration is None: + raise SemanticModelError( + f"unknown semantic {item.kind.value} type ID {item.type_id!r}" + ) + if not isinstance(declaration, declaration_class): + raise SemanticModelError( + f"nominal reference {item.type_id!r} has kind " + f"{item.kind.value!r}, but the declaration is " + f"{declaration.kind.value!r}" + ) + + +def _value_dependencies(declaration: SemanticValueDeclaration) -> set[str]: + return { + item.type_id + for field in declaration.fields + for item in _walk_type(field.type) + if item.kind is SemanticTypeKind.VALUE_REF and item.type_id is not None + } + + +def _validate_value_cycles(api: SemanticApi) -> None: + values = { + item.type_id: item + for item in api.declarations + if isinstance(item, SemanticValueDeclaration) + } + visiting: list[str] = [] + complete: set[str] = set() + + def visit(type_id: str) -> None: + if type_id in complete: + return + if type_id in visiting: + start = visiting.index(type_id) + cycle = visiting[start:] + [type_id] + raise SemanticModelError( + "recursive value declaration cycle: " + " -> ".join(cycle) + ) + visiting.append(type_id) + for dependency in sorted(_value_dependencies(values[type_id])): + visit(dependency) + visiting.pop() + complete.add(type_id) + + for type_id in sorted(values): + visit(type_id) + + +def _declaration_schema(declaration: SemanticDeclaration) -> object: + if isinstance(declaration, SemanticEnumDeclaration): + return (declaration.kind, declaration.name, declaration.constants) + if isinstance(declaration, SemanticValueDeclaration): + return ( + declaration.kind, + declaration.name, + tuple( + (field.field_id, field.name, field.type, field.required) + for field in declaration.fields + ), + ) + return (declaration.kind, declaration.name) + + +def _merge_declarations( + first: SemanticDeclaration, second: SemanticDeclaration +) -> SemanticDeclaration: + first_source = first.projections[0].source + second_source = second.projections[0].source + locations = f"{first_source.location} and {second_source.location}" + if isinstance(first, SemanticObjectDeclaration) or isinstance( + second, SemanticObjectDeclaration + ): + raise SemanticModelError( + f"native object {first.type_id!r} has duplicate declarations at {locations}; " + "object projections do not merge across backend families" + ) + if type(first) is not type(second) or _declaration_schema(first) != _declaration_schema(second): + raise SemanticModelError( + f"logical type {first.type_id!r} has mismatched projections at {locations}" + ) + existing = {item.backend: item for item in first.projections} + for projection in second.projections: + duplicate = existing.get(projection.backend) + if duplicate is not None: + raise SemanticModelError( + f"logical type {first.type_id!r} has duplicate " + f"{projection.backend.value} projections at " + f"{duplicate.source.location} and {projection.source.location}" + ) + existing[projection.backend] = projection + projections = tuple( + existing[key] for key in sorted(existing, key=lambda item: item.value) + ) + if isinstance(first, SemanticEnumDeclaration): + return SemanticEnumDeclaration( + first.feature_id, first.type_id, first.name, first.constants, projections + ) + assert isinstance(first, SemanticValueDeclaration) + return SemanticValueDeclaration( + first.feature_id, first.type_id, first.name, first.fields, projections + ) + + +def validate_semantic_route( + api: SemanticApi, + value_type: SemanticType, + source_backend: BackendFamily, + target_backend: BackendFamily, + source: SourceProvenance, + target: SourceProvenance, +) -> None: + """Validate backend capabilities for one semantic value on a route.""" + + declarations = {item.type_id: item for item in api.declarations} + checked_values: set[tuple[str, BackendFamily, BackendFamily]] = set() + + def fail(message: str, *, declaration=None, position: str = "value") -> None: + declared_at = "" + if declaration is not None: + locations = ", ".join( + projection.source.location for projection in declaration.projections + ) + declared_at = f"; logical type is declared at {locations}" + raise SemanticModelError( + f"{message} at {position}{declared_at}; route endpoints are " + f"{source.location} and {target.location}" + ) + + def check(item: SemanticType, position: str) -> None: + if item.kind is SemanticTypeKind.ARRAY: + assert item.element is not None + check(item.element, position + "[]") + return + if item.kind is SemanticTypeKind.NULLABLE: + assert item.element is not None + check(item.element, position + "?") + return + if item.type_id is None: + return + declaration = declarations[item.type_id] + available = {projection.backend for projection in declaration.projections} + if item.kind is SemanticTypeKind.OBJECT_REF: + if source_backend is not target_backend: + fail( + f"native object {declaration.name!r} cannot cross " + f"{source_backend.value}->{target_backend.value}; cross-family " + "object proxies are deferred in current V3", + declaration=declaration, + position=position, + ) + if source_backend not in available: + fail( + f"native object {declaration.name!r} has no " + f"{source_backend.value} projection", + declaration=declaration, + position=position, + ) + return + required = {source_backend, target_backend} + missing = required - available + if missing: + fail( + f"copied type {declaration.name!r} is missing " + + ", ".join(sorted(item.value for item in missing)) + + " projection capability", + declaration=declaration, + position=position, + ) + if isinstance(declaration, SemanticValueDeclaration): + key = (declaration.type_id, source_backend, target_backend) + if key in checked_values: + return + checked_values.add(key) + for field in declaration.fields: + check(field.type, position + "." + field.name) + + check(value_type, "value") + + def _source_from_manifest(raw: object, label: str) -> SourceProvenance: value = _manifest_object(raw, label) _manifest_keys( diff --git a/src/supernote_module_generator/semantic_types.py b/src/supernote_module_generator/semantic_types.py new file mode 100644 index 0000000..cf7c03d --- /dev/null +++ b/src/supernote_module_generator/semantic_types.py @@ -0,0 +1,219 @@ +"""Recursive, backend-neutral V3 semantic types. + +Source spellings and lowering ownership never appear here. Named references +carry only the stable logical type identity that JavaScript and TypeScript use. +""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any, ClassVar, Dict, Optional + + +class SemanticTypeError(ValueError): + """Raised when a recursive semantic type is impossible.""" + + +class SemanticTypeKind(str, Enum): + VOID = "void" + SCALAR = "scalar" + ENUM_REF = "enum_ref" + VALUE_REF = "value_ref" + OBJECT_REF = "object_ref" + ARRAY = "array" + NULLABLE = "nullable" + + +class ScalarKind(str, Enum): + BOOL = "bool" + INT32 = "int32" + INT64 = "int64" + FLOAT32 = "float32" + FLOAT64 = "float64" + STRING = "string" + BYTES = "bytes" + + +@dataclass(frozen=True) +class SemanticType: + """One node in the immutable D-027 semantic type algebra.""" + + kind: SemanticTypeKind + scalar: Optional[ScalarKind] = None + type_id: Optional[str] = None + element: Optional["SemanticType"] = None + + VOID: ClassVar["SemanticType"] + BOOL: ClassVar["SemanticType"] + INT32: ClassVar["SemanticType"] + INT64: ClassVar["SemanticType"] + FLOAT32: ClassVar["SemanticType"] + FLOAT64: ClassVar["SemanticType"] + STRING: ClassVar["SemanticType"] + BYTES: ClassVar["SemanticType"] + + def __post_init__(self) -> None: + if not isinstance(self.kind, SemanticTypeKind): + raise SemanticTypeError(f"unknown semantic type kind {self.kind!r}") + if self.kind is SemanticTypeKind.SCALAR: + if not isinstance(self.scalar, ScalarKind): + raise SemanticTypeError("a scalar semantic type requires a scalar kind") + if self.type_id is not None or self.element is not None: + raise SemanticTypeError("a scalar semantic type forbids reference payload") + return + if self.kind in { + SemanticTypeKind.ENUM_REF, + SemanticTypeKind.VALUE_REF, + SemanticTypeKind.OBJECT_REF, + }: + if not isinstance(self.type_id, str) or not self.type_id: + raise SemanticTypeError("a named semantic reference requires a type ID") + if self.scalar is not None or self.element is not None: + raise SemanticTypeError("a named semantic reference has only a type ID") + return + if self.kind in {SemanticTypeKind.ARRAY, SemanticTypeKind.NULLABLE}: + if not isinstance(self.element, SemanticType): + raise SemanticTypeError("a semantic wrapper requires an element type") + if self.scalar is not None or self.type_id is not None: + raise SemanticTypeError("a semantic wrapper has only an element type") + if self.element.is_void: + raise SemanticTypeError("void cannot be nested in a semantic type") + if ( + self.kind is SemanticTypeKind.NULLABLE + and self.element.kind is SemanticTypeKind.NULLABLE + ): + raise SemanticTypeError("nested nullable semantic types are forbidden") + return + if self.scalar is not None or self.type_id is not None or self.element is not None: + raise SemanticTypeError("void has no semantic type payload") + + @classmethod + def enum_ref(cls, type_id: str) -> "SemanticType": + return cls(SemanticTypeKind.ENUM_REF, type_id=type_id) + + @classmethod + def value_ref(cls, type_id: str) -> "SemanticType": + return cls(SemanticTypeKind.VALUE_REF, type_id=type_id) + + @classmethod + def object_ref(cls, type_id: str) -> "SemanticType": + return cls(SemanticTypeKind.OBJECT_REF, type_id=type_id) + + @classmethod + def array(cls, element: "SemanticType") -> "SemanticType": + return cls(SemanticTypeKind.ARRAY, element=element) + + @classmethod + def nullable(cls, inner: "SemanticType") -> "SemanticType": + return cls(SemanticTypeKind.NULLABLE, element=inner) + + @property + def is_void(self) -> bool: + return self.kind is SemanticTypeKind.VOID + + @property + def value(self) -> str: + """Stable short label retained for scalar-oriented lowering code.""" + + return self.scalar.value if self.scalar is not None else self.kind.value + + def manifest(self) -> Dict[str, object]: + if self.kind is SemanticTypeKind.VOID: + return {"kind": self.kind.value} + if self.kind is SemanticTypeKind.SCALAR: + assert self.scalar is not None + return {"kind": self.kind.value, "name": self.scalar.value} + if self.type_id is not None: + return {"kind": self.kind.value, "type_id": self.type_id} + assert self.element is not None + key = "element" if self.kind is SemanticTypeKind.ARRAY else "inner" + return {"kind": self.kind.value, key: self.element.manifest()} + + +SemanticType.VOID = SemanticType(SemanticTypeKind.VOID) +SemanticType.BOOL = SemanticType(SemanticTypeKind.SCALAR, ScalarKind.BOOL) +SemanticType.INT32 = SemanticType(SemanticTypeKind.SCALAR, ScalarKind.INT32) +SemanticType.INT64 = SemanticType(SemanticTypeKind.SCALAR, ScalarKind.INT64) +SemanticType.FLOAT32 = SemanticType(SemanticTypeKind.SCALAR, ScalarKind.FLOAT32) +SemanticType.FLOAT64 = SemanticType(SemanticTypeKind.SCALAR, ScalarKind.FLOAT64) +SemanticType.STRING = SemanticType(SemanticTypeKind.SCALAR, ScalarKind.STRING) +SemanticType.BYTES = SemanticType(SemanticTypeKind.SCALAR, ScalarKind.BYTES) + +_SCALAR_TYPES = { + value.scalar: value + for value in ( + SemanticType.BOOL, + SemanticType.INT32, + SemanticType.INT64, + SemanticType.FLOAT32, + SemanticType.FLOAT64, + SemanticType.STRING, + SemanticType.BYTES, + ) +} + + +def semantic_type_from_manifest(raw: object, label: str = "semantic type") -> SemanticType: + if not isinstance(raw, dict): + raise SemanticTypeError(f"{label} must be an object") + kind_value = raw.get("kind") + if not isinstance(kind_value, str) or not kind_value: + raise SemanticTypeError(f"{label}.kind must be a non-empty string") + try: + kind = SemanticTypeKind(kind_value) + except ValueError as exc: + raise SemanticTypeError(f"{label}.kind is invalid: {kind_value!r}") from exc + + expected = {"kind"} + if kind is SemanticTypeKind.SCALAR: + expected.add("name") + elif kind in { + SemanticTypeKind.ENUM_REF, + SemanticTypeKind.VALUE_REF, + SemanticTypeKind.OBJECT_REF, + }: + expected.add("type_id") + elif kind is SemanticTypeKind.ARRAY: + expected.add("element") + elif kind is SemanticTypeKind.NULLABLE: + expected.add("inner") + _require_keys(raw, expected, label) + + try: + if kind is SemanticTypeKind.VOID: + return SemanticType.VOID + if kind is SemanticTypeKind.SCALAR: + name = raw["name"] + if not isinstance(name, str): + raise SemanticTypeError(f"{label}.name must be a string") + return _SCALAR_TYPES[ScalarKind(name)] + if kind in { + SemanticTypeKind.ENUM_REF, + SemanticTypeKind.VALUE_REF, + SemanticTypeKind.OBJECT_REF, + }: + type_id = raw["type_id"] + if not isinstance(type_id, str) or not type_id: + raise SemanticTypeError(f"{label}.type_id must be a non-empty string") + return SemanticType(kind, type_id=type_id) + key = "element" if kind is SemanticTypeKind.ARRAY else "inner" + return SemanticType( + kind, + element=semantic_type_from_manifest(raw[key], f"{label}.{key}"), + ) + except (KeyError, ValueError) as exc: + raise SemanticTypeError(f"{label} is invalid: {exc}") from exc + + +def _require_keys(value: Dict[str, Any], expected: set[str], label: str) -> None: + actual = set(value) + if actual == expected: + return + missing = sorted(expected - actual) + extra = sorted(actual - expected) + details = [] + if missing: + details.append("missing " + ", ".join(missing)) + if extra: + details.append("unknown " + ", ".join(extra)) + raise SemanticTypeError(f"{label} has invalid fields: {'; '.join(details)}") diff --git a/src/supernote_module_generator/source_models.py b/src/supernote_module_generator/source_models.py index 451fb8a..4e96c19 100644 --- a/src/supernote_module_generator/source_models.py +++ b/src/supernote_module_generator/source_models.py @@ -1,4 +1,4 @@ -"""Language-specific declaration facts retained by V2 frontends.""" +"""Language-specific declaration facts retained by V3 frontends.""" from __future__ import annotations from dataclasses import dataclass, field @@ -13,6 +13,8 @@ class SourceModelError(ValueError): class SupernoteMarker(str, Enum): + OBJECT = "SupernotePluginObject" + VALUE = "SupernotePluginValue" EXPORT = "SupernotePluginExport" INTERNAL = "SupernotePluginInternal" ASYNC = "SupernotePluginAsync" @@ -22,7 +24,9 @@ class SupernoteMarker(str, Enum): class DeclarationTarget(str, Enum): FUNCTION = "function" CLASS = "class" + ENUM = "enum" METHOD = "method" + FIELD = "field" CONSTRUCTOR = "constructor" @@ -55,17 +59,43 @@ def __post_init__(self) -> None: "SupernotePluginExport and SupernotePluginInternal cannot mark one declaration" ) + type_markers = {SupernoteMarker.OBJECT, SupernoteMarker.VALUE} + reachability = {SupernoteMarker.EXPORT, SupernoteMarker.INTERNAL} if self.target is DeclarationTarget.CONSTRUCTOR: invalid = marker_set - {SupernoteMarker.CONSTRUCTOR} if invalid: raise SourceModelError( - "constructors accept only SupernoteConstructor in initial V2" + "constructors accept only SupernoteConstructor in initial V3" + ) + elif self.target is DeclarationTarget.CLASS: + if marker_set and marker_set not in ( + {SupernoteMarker.OBJECT}, + {SupernoteMarker.VALUE}, + ): + raise SourceModelError( + "classes require exactly one of SupernotePluginObject or " + "SupernotePluginValue; reachability markers belong on members" + ) + elif self.target is DeclarationTarget.ENUM: + if marker_set != {SupernoteMarker.VALUE}: + raise SourceModelError( + "a generated string enum requires exactly SupernotePluginValue" + ) + elif self.target is DeclarationTarget.FIELD: + if marker_set and marker_set != {SupernoteMarker.EXPORT}: + raise SourceModelError( + "generated fields accept only SupernotePluginExport" ) else: if SupernoteMarker.CONSTRUCTOR in marker_set: raise SourceModelError( "SupernoteConstructor is valid only on a constructor" ) + if marker_set & type_markers: + raise SourceModelError( + "SupernotePluginObject and SupernotePluginValue are valid only " + "on type declarations" + ) if ( SupernoteMarker.ASYNC in marker_set and SupernoteMarker.EXPORT not in marker_set @@ -74,8 +104,11 @@ def __post_init__(self) -> None: raise SourceModelError( "SupernotePluginAsync requires SupernotePluginExport or SupernotePluginInternal" ) - if self.target is DeclarationTarget.CLASS and SupernoteMarker.ASYNC in marker_set: - raise SourceModelError("SupernotePluginAsync cannot mark a class") + if marker_set and not marker_set & reachability: + raise SourceModelError( + "generated functions and methods require " + "SupernotePluginExport or SupernotePluginInternal" + ) @classmethod def from_markers( @@ -121,6 +154,14 @@ def execution(self) -> ExecutionMode: def selects_constructor(self) -> bool: return SupernoteMarker.CONSTRUCTOR in self.marker_set + @property + def declares_object(self) -> bool: + return SupernoteMarker.OBJECT in self.marker_set + + @property + def declares_value(self) -> bool: + return SupernoteMarker.VALUE in self.marker_set + @dataclass(frozen=True) class CppParameterSource: @@ -137,6 +178,7 @@ class CppFunctionSource: intent: SourceIntent noexcept: bool = False definition_offset: int = -1 + namespace: Tuple[str, ...] = field(default_factory=tuple) def __post_init__(self) -> None: if self.intent.target is not DeclarationTarget.FUNCTION: @@ -173,6 +215,7 @@ class CppMethodSource: access: str const: bool = False noexcept: bool = False + static: bool = False def __post_init__(self) -> None: if self.intent.target is not DeclarationTarget.METHOD: @@ -188,6 +231,8 @@ class CppClassSource: constructors: Tuple[CppConstructorSource, ...] methods: Tuple[CppMethodSource, ...] declaration_kind: str = "class" + fields: Tuple["CppFieldSource", ...] = field(default_factory=tuple) + namespace: Tuple[str, ...] = field(default_factory=tuple) def __post_init__(self) -> None: if self.intent.target is not DeclarationTarget.CLASS: @@ -195,6 +240,43 @@ def __post_init__(self) -> None: if self.declaration_kind not in {"class", "struct"}: raise SourceModelError("a C++ class source kind must be class or struct") + @property + def qualified_name(self) -> str: + return "::".join((*self.namespace, self.cpp_name)) + + +@dataclass(frozen=True) +class CppFieldSource: + provenance: SourceProvenance + cpp_name: str + type_spelling: str + intent: SourceIntent + access: str + mutable: bool + static: bool = False + + def __post_init__(self) -> None: + if self.intent.target is not DeclarationTarget.FIELD: + raise SourceModelError("a C++ field requires field source intent") + + +@dataclass(frozen=True) +class CppEnumSource: + provenance: SourceProvenance + cpp_name: str + include: str + intent: SourceIntent + constants: Tuple[str, ...] + namespace: Tuple[str, ...] = field(default_factory=tuple) + + def __post_init__(self) -> None: + if self.intent.target is not DeclarationTarget.ENUM: + raise SourceModelError("a C++ enum requires enum source intent") + + @property + def qualified_name(self) -> str: + return "::".join((*self.namespace, self.cpp_name)) + class JvmLanguage(str, Enum): KOTLIN = "kotlin" @@ -219,6 +301,22 @@ class JvmParameterSource: name: str nullable: bool = False injected: Optional[JvmInjectedDependency] = None + type_arguments: Tuple["JvmTypeSource", ...] = field(default_factory=tuple) + + @property + def type_source(self) -> "JvmTypeSource": + return JvmTypeSource(self.jvm_type, self.nullable, self.type_arguments) + + +@dataclass(frozen=True) +class JvmTypeSource: + jvm_type: str + nullable: bool = False + arguments: Tuple["JvmTypeSource", ...] = field(default_factory=tuple) + + def __post_init__(self) -> None: + if not self.jvm_type: + raise SourceModelError("a JVM type spelling cannot be empty") @dataclass(frozen=True) @@ -257,6 +355,15 @@ class JvmDeclarationSource: language: JvmLanguage is_suspend: bool = False is_static: bool = False + result_type_arguments: Tuple[JvmTypeSource, ...] = field(default_factory=tuple) + + @property + def result_type_source(self) -> JvmTypeSource: + return JvmTypeSource( + self.result_jvm_type, + self.result_nullable, + self.result_type_arguments, + ) def __post_init__(self) -> None: if self.intent.target not in { @@ -291,6 +398,13 @@ class JvmOwnerSource: constructors: Tuple[JvmConstructorSource, ...] declarations: Tuple[JvmDeclarationSource, ...] visibility: str = "public" + fields: Tuple["JvmFieldSource", ...] = field(default_factory=tuple) + enum_constants: Tuple[str, ...] = field(default_factory=tuple) + is_data: bool = False + is_record: bool = False + is_final: bool = True + type_parameter_count: int = 0 + supertypes: Tuple[str, ...] = field(default_factory=tuple) def __post_init__(self) -> None: if self.intent.target is not DeclarationTarget.CLASS: @@ -347,3 +461,31 @@ def __post_init__(self) -> None: raise SourceModelError( "a JVM declaration language must match its containing owner" ) + for source_field in self.fields: + if source_field.owner_declaration_id != self.provenance.declaration_id: + raise SourceModelError( + f"JVM field {source_field.name!r} does not reference its owner" + ) + if source_field.provenance.language != self.language.value: + raise SourceModelError("a JVM field language must match its owner") + if self.type_parameter_count < 0: + raise SourceModelError("JVM type parameter count cannot be negative") + + +@dataclass(frozen=True) +class JvmFieldSource: + provenance: SourceProvenance + owner_declaration_id: str + name: str + type: JvmTypeSource + intent: SourceIntent + visibility: str + mutable: bool + is_static: bool = False + accessor_identity: str = "" + + def __post_init__(self) -> None: + if self.intent.target is not DeclarationTarget.FIELD: + raise SourceModelError("a JVM field requires field source intent") + if not self.owner_declaration_id: + raise SourceModelError("a JVM field owner identity cannot be empty") diff --git a/src/supernote_module_generator/templates/v2.SupernotePluginExport.java.tmpl b/src/supernote_module_generator/templates/v2.SupernotePluginExport.java.tmpl index 96d8573..460a6c7 100644 --- a/src/supernote_module_generator/templates/v2.SupernotePluginExport.java.tmpl +++ b/src/supernote_module_generator/templates/v2.SupernotePluginExport.java.tmpl @@ -5,6 +5,11 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -@Target({ElementType.TYPE, ElementType.METHOD}) +@Target({ + ElementType.METHOD, + ElementType.FIELD, + ElementType.RECORD_COMPONENT, + ElementType.PARAMETER +}) @Retention(RetentionPolicy.SOURCE) public @interface SupernotePluginExport {} diff --git a/src/supernote_module_generator/templates/v2.SupernotePluginInternal.java.tmpl b/src/supernote_module_generator/templates/v2.SupernotePluginInternal.java.tmpl index a739f26..5483fbe 100644 --- a/src/supernote_module_generator/templates/v2.SupernotePluginInternal.java.tmpl +++ b/src/supernote_module_generator/templates/v2.SupernotePluginInternal.java.tmpl @@ -5,6 +5,6 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -@Target({ElementType.TYPE, ElementType.METHOD}) +@Target(ElementType.METHOD) @Retention(RetentionPolicy.SOURCE) public @interface SupernotePluginInternal {} diff --git a/src/supernote_module_generator/templates/v2.SupernoteV2Module.kt.tmpl b/src/supernote_module_generator/templates/v2.SupernoteV2Module.kt.tmpl index 76acc04..f3feadc 100644 --- a/src/supernote_module_generator/templates/v2.SupernoteV2Module.kt.tmpl +++ b/src/supernote_module_generator/templates/v2.SupernoteV2Module.kt.tmpl @@ -11,14 +11,14 @@ import com.facebook.soloader.SoLoader import dalvik.system.BaseDexClassLoader import java.io.File -class SupernoteV2Module( +class SupernoteV3Module( private val context: ReactApplicationContext, ) : ReactContextBaseJavaModule(context) { private val lifecycleLock = Any() private var lifecycleState = LifecycleState.NEW private var sessionId: Long = 0L - override fun getName(): String = "SupernoteV2Runtime" + override fun getName(): String = "SupernoteV3Runtime" override fun initialize() { super.initialize() @@ -46,7 +46,7 @@ class SupernoteV2Module( abandonPendingInstall() return@runOnJSQueueThread } - val loader = SupernoteV2Module::class.java.classLoader + val loader = SupernoteV3Module::class.java.classLoader if (loader == null) { Log.e(TAG, "plugin ClassLoader is unavailable") abandonPendingInstall() @@ -73,7 +73,7 @@ class SupernoteV2Module( } } ?: return@runOnJSQueueThread if (installed == 0L) { - Log.e(TAG, "V2 runtime installation failed") + Log.e(TAG, "V3 runtime installation failed") } } } @@ -96,7 +96,7 @@ class SupernoteV2Module( } private fun loadNativeLibrary(): Boolean = try { - val pluginClassLoader = SupernoteV2Module::class.java.classLoader + val pluginClassLoader = SupernoteV3Module::class.java.classLoader val libraryPath = (pluginClassLoader as? BaseDexClassLoader) ?.findLibrary("${NATIVE_LIBRARY_NAME}") @@ -104,36 +104,104 @@ class SupernoteV2Module( val registrationLibraryPath = pluginClassLoader.findLibrary("${NATIVE_REGISTRATION_LIBRARY_NAME}") ?: error("Cannot find lib${NATIVE_REGISTRATION_LIBRARY_NAME}.so") - val libraryDirectory = - File(libraryPath).parentFile ?: error("Cannot resolve native directory") val thread = Thread.currentThread() val previous = thread.contextClassLoader + val runtimeDirectory = + File(context.codeCacheDir, "supernote-v3-runtime/${NATIVE_LIBRARY_NAME}") + if (!runtimeDirectory.isDirectory && + !runtimeDirectory.mkdirs() && + !runtimeDirectory.isDirectory) { + error("Cannot create generated V3 runtime directory") + } + val runtimeLoadName = + "${NATIVE_LIBRARY_NAME}_" + + Integer.toHexString(System.identityHashCode(pluginClassLoader)) + + "_" + java.lang.Long.toHexString(System.nanoTime()) + val runtimeCopy = File(runtimeDirectory, "lib$runtimeLoadName.so") try { thread.contextClassLoader = pluginClassLoader SoLoader.loadLibrary("jsi") - if (SoLoader.getLibraryPath(System.mapLibraryName("${NATIVE_LIBRARY_NAME}")) != - libraryPath) { - SoLoader.prependSoSource( - DirectorySoSource( - libraryDirectory, - DirectorySoSource.RESOLVE_DEPENDENCIES, - ), - ) - } - SoLoader.loadLibrary("${NATIVE_LIBRARY_NAME}") - if (!SupernoteV2NativeRegistrationBridge.register( - context, - File(registrationLibraryPath), - pluginClassLoader, - )) { - error("Cannot register generated V2 runtime natives") + File(libraryPath).copyTo(runtimeCopy, overwrite = true) + synchronized(System.getProperties()) { + val sourcePath = runtimeDirectory.canonicalPath + val registeredSource = System.getProperty("${NATIVE_SOURCE_PROPERTY}") + if (registeredSource == null) { + SoLoader.prependSoSource( + DirectorySoSource( + runtimeDirectory, + DirectorySoSource.RESOLVE_DEPENDENCIES, + ), + ) + System.setProperty("${NATIVE_SOURCE_PROPERTY}", sourcePath) + } else if (registeredSource != sourcePath) { + error( + "Generated V3 runtime source mismatch: expected " + + "$registeredSource but found $sourcePath", + ) + } + val retainedGenerations = + System.getProperty("${NATIVE_GENERATION_COUNT_PROPERTY}") + ?.toIntOrNull() + ?: 0 + if (retainedGenerations !in 0 until MAX_RETAINED_GENERATIONS) { + error( + "Generated V3 runtime replacement limit reached; " + + "restart PluginHost before loading another native generation", + ) + } + System.setProperty("${NATIVE_LOAD_REQUEST_PROPERTY}", runtimeLoadName) + System.clearProperty("${NATIVE_REGISTRAR_PROPERTY}") + try { + // The parent-loaded SoLoader resolves PluginHost's JSI/React Native + // dependencies. The source is registered once and each generation + // gets a unique logical name. The hard cap bounds process-retained + // native generations while supporting the 25-cycle stress gate. + SoLoader.loadLibrary(runtimeLoadName) + val publication = + System.getProperty("${NATIVE_REGISTRAR_PROPERTY}") + ?: error("Generated V3 runtime registrar is unavailable") + val separator = publication.indexOf(':') + if (separator <= 0 || separator == publication.lastIndex) { + error("Generated V3 runtime registrar publication is malformed") + } + val publishedGeneration = publication.substring(0, separator) + if (publishedGeneration != runtimeLoadName) { + error( + "Generated V3 runtime generation mismatch: expected " + + "$runtimeLoadName but loaded $publishedGeneration", + ) + } + val registrarAddress = publication.substring(separator + 1).toLongOrNull() + ?: error("Generated V3 runtime registrar address is invalid") + if (registrarAddress == 0L || + !SupernoteV3NativeRegistrationBridge.register( + context, + File(registrationLibraryPath), + registrarAddress, + runtimeLoadName, + pluginClassLoader, + )) { + error("Cannot register generated V3 runtime natives") + } + System.setProperty( + "${NATIVE_GENERATION_COUNT_PROPERTY}", + (retainedGenerations + 1).toString(), + ) + } finally { + if (System.getProperty("${NATIVE_LOAD_REQUEST_PROPERTY}") == runtimeLoadName) { + System.clearProperty("${NATIVE_LOAD_REQUEST_PROPERTY}") + } + } } } finally { thread.contextClassLoader = previous + if (runtimeCopy.exists() && !runtimeCopy.delete()) { + Log.w(TAG, "Cannot delete isolated generated runtime $runtimeCopy") + } } true } catch (failure: Throwable) { - Log.e(TAG, "Cannot load the generated V2 runtime", failure) + Log.e(TAG, "Cannot load the generated V3 runtime", failure) false } @@ -146,7 +214,8 @@ class SupernoteV2Module( private external fun nativeInvalidate(sessionId: Long) private companion object { - const val TAG = "SupernoteV2Runtime" + const val TAG = "SupernoteV3Runtime" + const val MAX_RETAINED_GENERATIONS = 32 } private enum class LifecycleState { @@ -157,22 +226,21 @@ class SupernoteV2Module( } } -private object SupernoteV2NativeRegistrationBridge { +private object SupernoteV3NativeRegistrationBridge { fun register( context: ReactApplicationContext, sourceLibrary: File, + registrarAddress: Long, + generationIdentity: String, classLoader: ClassLoader, ): Boolean { - val registrarAddress = - System.getProperty("${NATIVE_REGISTRAR_PROPERTY}")?.toLongOrNull() - ?: error("Generated V2 runtime registrar is unavailable") if (registrarAddress == 0L) { - error("Generated V2 runtime registrar address is invalid") + error("Generated V3 runtime registrar address is invalid") } val directory = - File(context.codeCacheDir, "supernote-v2-registration/${NATIVE_LIBRARY_NAME}") + File(context.codeCacheDir, "supernote-v3-registration/${NATIVE_LIBRARY_NAME}") if (!directory.isDirectory && !directory.mkdirs() && !directory.isDirectory) { - error("Cannot create generated V2 registration directory") + error("Cannot create generated V3 registration directory") } val bridge = File.createTempFile("${NATIVE_REGISTRATION_LIBRARY_NAME}-", ".so", directory) @@ -181,7 +249,7 @@ private object SupernoteV2NativeRegistrationBridge { // System.load is deliberately invoked from this child-loaded class. The // bridge has no JSI/React Native dependency and contains no runtime state. System.load(bridge.absolutePath) - nativeRegister(registrarAddress, classLoader) + nativeRegister(registrarAddress, generationIdentity, classLoader) } finally { if (bridge.exists() && !bridge.delete()) { Log.w(TAG, "Cannot delete temporary native-registration bridge $bridge") @@ -191,16 +259,17 @@ private object SupernoteV2NativeRegistrationBridge { private external fun nativeRegister( registrarAddress: Long, + generationIdentity: String, classLoader: ClassLoader, ): Boolean - private const val TAG = "SupernoteV2Runtime" + private const val TAG = "SupernoteV3Runtime" } -class SupernoteV2Package : ReactPackage { +class SupernoteV3Package : ReactPackage { override fun createNativeModules( reactContext: ReactApplicationContext, - ): List = listOf(SupernoteV2Module(reactContext)) + ): List = listOf(SupernoteV3Module(reactContext)) override fun createViewManagers( reactContext: ReactApplicationContext, diff --git a/src/supernote_module_generator/templates/v2.SupernoteV2Processor.kt.tmpl b/src/supernote_module_generator/templates/v2.SupernoteV2Processor.kt.tmpl index 7091d65..5d3b11c 100644 --- a/src/supernote_module_generator/templates/v2.SupernoteV2Processor.kt.tmpl +++ b/src/supernote_module_generator/templates/v2.SupernoteV2Processor.kt.tmpl @@ -16,6 +16,8 @@ import com.google.devtools.ksp.symbol.KSDeclaration import com.google.devtools.ksp.symbol.KSFile import com.google.devtools.ksp.symbol.KSFunctionDeclaration import com.google.devtools.ksp.symbol.KSNode +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.KSValueParameter import com.google.devtools.ksp.symbol.KSType import com.google.devtools.ksp.symbol.Modifier import com.google.devtools.ksp.symbol.Nullability @@ -27,9 +29,11 @@ import java.nio.file.Path import java.security.MessageDigest import kotlin.io.path.invariantSeparatorsPathString -private const val manifestSchema = 1 -private const val manifestKind = "supernote_jvm_source_manifest" +private const val manifestSchema = 3 +private const val manifestKind = "supernote_v3_jvm_source_manifest" private val markerOrder = listOf( + "SupernotePluginObject", + "SupernotePluginValue", "SupernotePluginExport", "SupernotePluginInternal", "SupernotePluginAsync", @@ -41,7 +45,7 @@ private val markerNames = markerOrder.associateWith { private class SupernoteSourceDiagnostic : RuntimeException() -class SupernoteV2Processor( +class SupernoteV3Processor( private val environment: SymbolProcessorEnvironment, ) : SymbolProcessor { private var generated = false @@ -87,6 +91,18 @@ class SupernoteV2Processor( symbols.forEach { symbol -> when (symbol) { is KSClassDeclaration -> classSymbols[ownerName(symbol)] = symbol + is KSPropertyDeclaration -> { + val parent = symbol.parentDeclaration as? KSClassDeclaration + ?: fail(symbol, "SupernotePluginExport fields/properties require a containing class") + classSymbols[ownerName(parent)] = parent + } + is KSValueParameter -> { + val constructor = symbol.parent as? KSFunctionDeclaration + ?: fail(symbol, "marked value parameters require a constructor") + val parent = constructor.parentDeclaration as? KSClassDeclaration + ?: fail(symbol, "marked value parameters require a containing class") + classSymbols[ownerName(parent)] = parent + } is KSFunctionDeclaration -> { val parent = symbol.parentDeclaration as? KSClassDeclaration if (symbol.simpleName.asString() == "") { @@ -97,7 +113,7 @@ class SupernoteV2Processor( if (parent != null) classSymbols.putIfAbsent(ownerName(parent), parent) } } - else -> fail(symbol, "Supernote markers may annotate only classes, functions, methods, or constructors") + else -> fail(symbol, "Supernote markers may annotate only supported types, functions, methods, fields/properties, or constructors") } } val owners = linkedMapOf() @@ -133,7 +149,10 @@ class SupernoteV2Processor( "kt", ) OutputStreamWriter(adapters, Charsets.UTF_8).use { writer -> - writer.write(adapterSource(owners.values.sortedBy { it.declarationId })) + writer.write(adapterSource( + owners.values.sortedBy { it.declarationId }, + "Identity_${hash(root.featureId).take(20)}", + )) } } @@ -141,15 +160,82 @@ class SupernoteV2Processor( val language = language(declaration) val owner = ownerName(declaration) val marked = markers(declaration) - if ("SupernotePluginAsync" in marked || "SupernoteConstructor" in marked) { - fail(declaration, "classes accept only SupernotePluginExport or SupernotePluginInternal") + val isEnum = declaration.classKind == ClassKind.ENUM_CLASS + if (marked.isNotEmpty()) { + val valid = if (isEnum) { + marked == setOf("SupernotePluginValue") + } else { + marked == setOf("SupernotePluginObject") || marked == setOf("SupernotePluginValue") + } + if (!valid) fail(declaration, "type declarations require exactly SupernotePluginObject or SupernotePluginValue; enums require SupernotePluginValue") } - validateReachability(declaration, marked) val form = when { declaration.classKind == ClassKind.OBJECT -> "kotlin_object" else -> "class" } - val constructors = if (form == "class") constructors(root, declaration, language, owner) else mutableListOf() + val record = if (language == "java" && marked == setOf("SupernotePluginValue")) { + javaRecordFacts(root, declaration, owner) + } else null + val constructors = record?.constructors + ?: if (form == "class") constructors(root, declaration, language, owner) else mutableListOf() + val parameterMarkers = declaration.primaryConstructor?.parameters + ?.filter { markers(it).isNotEmpty() } + ?.associateBy { it.name?.asString() } + .orEmpty() + val declaredProperties = declaration.getAllProperties().toList() + val fields = when { + record != null -> record.fields + language == "kotlin" && marked == setOf("SupernotePluginValue") -> { + val properties = declaredProperties.associateBy { it.simpleName.asString() } + val parameters = declaration.primaryConstructor?.parameters + ?: fail(declaration, "Kotlin value data class requires a primary constructor") + parameters.map { parameter -> + if (!parameter.isVal && !parameter.isVar) { + fail(parameter, "Kotlin value constructor parameters must be val/var properties") + } + val name = parameter.name?.asString() + ?: fail(parameter, "Kotlin value property requires a name") + val property = properties[name] + ?: fail(parameter, "Kotlin value property $name could not be resolved") + val markerNode = if (markers(property).isNotEmpty()) property else parameter + fieldFact(root, declaration, property, markerNode, language, ownerIdentity(owner)) + }.toMutableList() + } + else -> { + if ( + language == "java" && !isEnum && + marked == setOf("SupernotePluginValue") + ) { + declaredProperties.filterNot(::isStaticProperty).forEach { property -> + if (markers(property).isEmpty()) { + fail(property, "every Java final-class value field requires SupernotePluginExport") + } + } + } + declaredProperties.mapNotNull { property -> + val markerNode = if (markers(property).isNotEmpty()) { + property + } else { + parameterMarkers[property.simpleName.asString()] + } + markerNode?.let { + fieldFact(root, declaration, property, it, language, ownerIdentity(owner)) + } + }.toMutableList() + } + } + val enumConstants = declaration.declarations + .filterIsInstance() + .filter { it.classKind == ClassKind.ENUM_ENTRY } + .map { it.simpleName.asString() } + .toMutableList() + val ignoredSupertypes = setOf( + "kotlin.Any", "java.lang.Object", "java.lang.Enum", "kotlin.Enum", + "java.lang.Record", + ) + val supertypes = declaration.superTypes.map { sourceType(it.resolve(), language) } + .filterNot { it in ignoredSupertypes } + .toMutableList() return OwnerFact( declarationId = ownerIdentity(owner), language = language, @@ -160,6 +246,14 @@ class SupernoteV2Processor( markers = markerFacts(declaration), source = source(root, declaration, ownerIdentity(owner), language), constructors = constructors, + fields = fields, + enumConstants = enumConstants, + isData = Modifier.DATA in declaration.modifiers, + isRecord = record != null, + isFinal = Modifier.OPEN !in declaration.modifiers && Modifier.ABSTRACT !in declaration.modifiers, + typeParameterCount = declaration.typeParameters.size, + supertypes = supertypes, + enumAdapterIdentity = adapterIdentity(ownerIdentity(owner) + "#enum"), ) } @@ -207,7 +301,7 @@ class SupernoteV2Processor( fail(constructor, "constructors accept only SupernoteConstructor") } val parameters = constructor.parameters.mapIndexed { index, parameter -> - parameter(root, constructor, parameter.type.resolve(), parameter.name?.asString() ?: "arg$index", language, true) + parameter(root, parameter, parameter.type.resolve(), parameter.name?.asString() ?: "arg$index", language, true) }.toMutableList() val descriptor = "(" + parameters.joinToString("") { descriptor(it.jvmType) } + ")V" val id = declarationIdentity(ownerName, "", descriptor) @@ -218,6 +312,177 @@ class SupernoteV2Processor( }.toMutableList() } + private fun javaRecordFacts( + root: FeatureRoot, + declaration: KSClassDeclaration, + ownerName: String, + ): JavaRecordFacts? { + val file = declaration.containingFile + ?: fail(declaration, "marked Java record requires a source file") + val text = java.nio.file.Files.readString(java.nio.file.Paths.get(file.filePath)) + val record = Regex("\\brecord\\s+${Regex.escape(declaration.simpleName.asString())}\\s*\\(") + .find(text) ?: return null + val opening = text.indexOf('(', record.range.first) + val closing = matchingJavaDelimiter(text, opening, '(', ')') + ?: fail(declaration, "Java record component list is malformed") + val imports = Regex("(?m)^\\s*import\\s+([A-Za-z_][A-Za-z0-9_.]*)\\s*;") + .findAll(text).associate { match -> + match.groupValues[1].substringAfterLast('.') to match.groupValues[1] + } + val packageName = file.packageName.asString() + val relative = root.relative(file.filePath) + val ownerId = ownerIdentity(ownerName) + val fields = mutableListOf() + val parameters = mutableListOf() + val names = mutableSetOf() + splitJavaComponents(text, opening + 1, closing).forEach { (raw, offset) -> + val annotations = Regex("@[A-Za-z_][A-Za-z0-9_.]*").findAll(raw) + .map { match -> + val name = match.value.removePrefix("@") + if ('.' in name) name else imports[name] ?: name + }.toList() + val exportAnnotation = "supernote.generated.annotations.SupernotePluginExport" + val nullableAnnotation = "org.jspecify.annotations.Nullable" + val unknown = annotations.filterNot { + it == exportAnnotation || it == nullableAnnotation + } + if (unknown.isNotEmpty()) { + fail(declaration, "unsupported Java record component annotation ${unknown.first()}") + } + if (annotations.count { it == exportAnnotation } != 1) { + fail(declaration, "every Java record component requires SupernotePluginExport") + } + val withoutExport = raw.replace( + Regex("@(?:supernote\\.generated\\.annotations\\.)?SupernotePluginExport\\b"), + "", + ).trim() + val nameMatch = Regex("([A-Za-z_][A-Za-z0-9_]*)\\s*$").find(withoutExport) + ?: fail(declaration, "Java record component requires an ordinary name") + val name = nameMatch.groupValues[1] + if (!names.add(name)) fail(declaration, "duplicate Java record component $name") + val typeText = withoutExport.substring(0, nameMatch.range.first).trim() + val type = parseJavaSourceType(declaration, typeText, imports, packageName) + validateValueType(declaration, type, false) + val line = text.take(offset).count { it == '\n' } + 1 + val fieldId = fieldIdentity(ownerName, name) + fields += FieldFact( + fieldId, ownerId, name, type, + listOf(MarkerFact("SupernotePluginExport", line, 1)), + "public", false, false, fieldAccessorIdentity(fieldId), + SourceFact(fieldId, "java", relative, line, 1), + ) + parameters += ParameterFact( + type.jvmType, name, type.nullable, null, type.arguments, + ) + } + if (fields.isEmpty()) fail(declaration, "a Java record value requires components") + val descriptor = "(" + parameters.joinToString("") { descriptor(it.jvmType) } + ")V" + val constructorId = declarationIdentity(ownerName, "", descriptor) + val constructor = ConstructorFact( + constructorId, descriptor, parameters, "public", emptyList(), + adapterIdentity(constructorId), + SourceFact(constructorId, "java", relative, sourceLine(declaration), 1), + ) + return JavaRecordFacts(mutableListOf(constructor), fields) + } + + private fun matchingJavaDelimiter( + text: String, + opening: Int, + open: Char, + close: Char, + ): Int? { + var depth = 0 + for (index in opening until text.length) { + when (text[index]) { + open -> depth += 1 + close -> { + depth -= 1 + if (depth == 0) return index + } + } + } + return null + } + + private fun splitJavaComponents( + text: String, + start: Int, + end: Int, + ): List> { + val result = mutableListOf>() + var componentStart = start + var angleDepth = 0 + var parenDepth = 0 + for (index in start until end) { + when (text[index]) { + '<' -> angleDepth += 1 + '>' -> angleDepth -= 1 + '(' -> parenDepth += 1 + ')' -> parenDepth -= 1 + ',' -> if (angleDepth == 0 && parenDepth == 0) { + val value = text.substring(componentStart, index).trim() + if (value.isNotEmpty()) result += value to componentStart + componentStart = index + 1 + } + } + if (angleDepth < 0 || parenDepth < 0) return emptyList() + } + val finalValue = text.substring(componentStart, end).trim() + if (finalValue.isNotEmpty()) result += finalValue to componentStart + return result + } + + private fun parseJavaSourceType( + node: KSNode, + spelling: String, + imports: Map, + packageName: String, + ): TypeFact { + val nullableUses = Regex("@([A-Za-z_][A-Za-z0-9_.]*)") + .findAll(spelling) + .map { match -> + val name = match.groupValues[1] + if ('.' in name) name else imports[name] ?: name + }.filter { it.substringAfterLast('.') == "Nullable" }.toList() + val unsupportedNullable = nullableUses.firstOrNull { + it != "org.jspecify.annotations.Nullable" + } + if (unsupportedNullable != null) { + fail(node, "Java nullability requires org.jspecify.annotations.Nullable; found $unsupportedNullable") + } + val nullablePattern = Regex("@(?:org\\.jspecify\\.annotations\\.)?Nullable\\b") + val nullable = nullablePattern.containsMatchIn(spelling) + val value = spelling.replace(nullablePattern, "").replace(Regex("\\s+"), "").trim() + val listMatch = Regex("(?:java\\.util\\.)?List<(.+)>").matchEntire(value) + if (listMatch != null) { + return TypeFact( + "java.util.List", + nullable, + listOf(parseJavaSourceType(node, listMatch.groupValues[1], imports, packageName)), + ) + } + val direct = mapOf( + "boolean" to "boolean", "int" to "int", "long" to "long", + "float" to "float", "double" to "double", + "Boolean" to "java.lang.Boolean", "Integer" to "java.lang.Integer", + "Long" to "java.lang.Long", "Float" to "java.lang.Float", + "Double" to "java.lang.Double", "String" to "java.lang.String", + "java.lang.Boolean" to "java.lang.Boolean", + "java.lang.Integer" to "java.lang.Integer", + "java.lang.Long" to "java.lang.Long", + "java.lang.Float" to "java.lang.Float", + "java.lang.Double" to "java.lang.Double", + "java.lang.String" to "java.lang.String", "byte[]" to "byte[]", + )[value] + if (direct != null) return TypeFact(direct, nullable, emptyList()) + if (!Regex("^[A-Za-z_][A-Za-z0-9_.]*$").matches(value)) { + fail(node, "unsupported Java record component type $spelling") + } + val qualified = if ('.' in value) value else imports[value] ?: "$packageName.$value" + return TypeFact(qualified, nullable, emptyList()) + } + private fun declarationFact( root: FeatureRoot, owner: OwnerFact, @@ -230,11 +495,12 @@ class SupernoteV2Processor( } val parameters = function.parameters.mapIndexed { index, value -> if (value.isVararg) fail(function, "vararg is unsupported") - parameter(root, function, value.type.resolve(), value.name?.asString() ?: "arg$index", owner.language, false) + parameter(root, value, value.type.resolve(), value.name?.asString() ?: "arg$index", owner.language, false) }.toMutableList() val returned = function.returnType?.resolve() ?: fail(function, "marked declaration requires an explicit result type") - val result = sourceType(returned, owner.language) - validateValueType(function, result, returned.nullability == Nullability.NULLABLE, true) + val resultFact = typeFact(function, returned, owner.language) + val result = resultFact.jvmType + validateValueType(function, resultFact, true) val suspend = Modifier.SUSPEND in function.modifiers val marked = markers(function) if (suspend && "SupernotePluginAsync" !in marked) { @@ -250,9 +516,10 @@ class SupernoteV2Processor( val id = declarationIdentity(owner.ownerClass, name, jvmDescriptor) return DeclarationFact( id, owner.declarationId, owner.ownerClass, name, jvmDescriptor, - parameters, result, returned.nullability == Nullability.NULLABLE, + parameters, result, resultFact.nullable, markerFacts(function), visibility(function), adapterIdentity(id), owner.language, suspend, isStatic(function), source(root, function, id, owner.language), + resultFact.arguments, ) } @@ -264,21 +531,181 @@ class SupernoteV2Processor( language: String, constructor: Boolean, ): ParameterFact { - val sourceType = sourceType(type, language) - val nullable = type.nullability == Nullability.NULLABLE + val fact = typeFact(owner, type, language) + val sourceType = fact.jvmType + val nullable = fact.nullable val injected = if (constructor) when (sourceType) { "android.content.Context" -> "android.content.Context" "com.facebook.react.bridge.ReactApplicationContext" -> "com.facebook.react.bridge.ReactApplicationContext" else -> null } else null - if (injected == null) validateValueType(owner, sourceType, nullable, false) + if (injected == null) validateValueType(owner, fact, false) if (injected != null && nullable) fail(owner, "runtime-injected dependencies cannot be nullable") - return ParameterFact(sourceType, name, nullable, injected) + return ParameterFact(sourceType, name, nullable, injected, fact.arguments) + } + + private fun fieldFact( + root: FeatureRoot, + owner: KSClassDeclaration, + property: KSPropertyDeclaration, + markerNode: KSAnnotated, + language: String, + ownerId: String, + ): FieldFact { + val marked = markers(markerNode) + if (marked != setOf("SupernotePluginExport")) { + fail(property, "generated fields/properties accept only SupernotePluginExport") + } + if (visibility(property) != "public") fail(property, "generated fields/properties must be public") + if (property.typeParameters.isNotEmpty() || property.extensionReceiver != null) { + fail(property, "generic or extension properties are unsupported") + } + val type = typeFact(property, property.type.resolve(), language) + validateValueType(property, type, false) + val name = property.simpleName.asString() + val id = fieldIdentity(ownerName(owner), name) + return FieldFact( + id, ownerId, name, type, markerFacts(markerNode), visibility(property), + property.isMutable && Modifier.FINAL !in property.modifiers, + property.modifiers.any { it.name == "JAVA_STATIC" || it.name == "STATIC" }, + fieldAccessorIdentity(id), source(root, property, id, language), + ) + } + + private fun typeFact( + node: KSNode, + type: KSType, + language: String, + genericPosition: Boolean = false, + forcedNullable: Boolean = false, + ): TypeFact { + val arguments = type.arguments.map { argument -> + val reference = argument.type ?: fail(node, "star-projected bridge types are unsupported") + val argumentNullable = language == "java" && hasJSpecifyNullable( + node, argument.annotations + reference.annotations, + ) + typeFact(node, reference.resolve(), language, true, argumentNullable) + } + // Evaluate annotations before nullability so an unsupported same-named + // annotation cannot hide behind KSP's NULLABLE short-circuit. + val annotatedNullable = language == "java" && + hasJSpecifyNullable(node, type.annotations) + val nullable = forcedNullable || + type.nullability == Nullability.NULLABLE || annotatedNullable + val fact = TypeFact( + sourceType(type, language, genericPosition, nullable), + nullable, + arguments, + ) + return if (language == "java" && !genericPosition) { + applyJavaSourceNullability(node, fact) + } else fact + } + + private fun applyJavaSourceNullability(node: KSNode, fact: TypeFact): TypeFact { + val spelling = javaSourceTypeSpelling(node) ?: return fact + validateJavaSourceNullable(node, spelling) + fun apply(current: TypeFact, source: String): TypeFact { + if (current.arguments.size != 1) return current + val opening = source.indexOf('<') + val closing = source.lastIndexOf('>') + if (opening < 0 || closing <= opening) return current + val nested = source.substring(opening + 1, closing) + val annotated = Regex("@(?:org\\.jspecify\\.annotations\\.)?Nullable\\b") + .containsMatchIn(nested.substringBefore('<')) + val argument = apply(current.arguments.single(), nested) + return current.copy(arguments = listOf(argument.copy(nullable = argument.nullable || annotated))) + } + return apply(fact, spelling) + } + + private fun hasJSpecifyNullable( + node: KSNode, + annotations: Sequence, + ): Boolean { + val names = annotations.mapNotNull { + it.annotationType.resolve().declaration.qualifiedName?.asString() + }.toList() + val unsupported = names.firstOrNull { + it.substringAfterLast('.') == "Nullable" && + it != "org.jspecify.annotations.Nullable" + } + if (unsupported != null) { + fail(node, "Java nullability requires org.jspecify.annotations.Nullable; found $unsupported") + } + return "org.jspecify.annotations.Nullable" in names + } + + private fun validateJavaSourceNullable(node: KSNode, spelling: String) { + val file = containingFile(node) ?: return + val text = java.nio.file.Files.readString(java.nio.file.Paths.get(file.filePath)) + val imports = Regex("(?m)^\\s*import\\s+([A-Za-z_][A-Za-z0-9_.]*)\\s*;") + .findAll(text).associate { match -> + match.groupValues[1].substringAfterLast('.') to match.groupValues[1] + } + val uses = Regex("@([A-Za-z_][A-Za-z0-9_.]*)") + .findAll(spelling).map { match -> + val name = match.groupValues[1] + if ('.' in name) name else imports[name] ?: name + }.filter { it.substringAfterLast('.') == "Nullable" } + val unsupported = uses.firstOrNull { + it != "org.jspecify.annotations.Nullable" + } + if (unsupported != null) { + fail(node, "Java nullability requires org.jspecify.annotations.Nullable; found $unsupported") + } + } + + private fun javaSourceTypeSpelling(node: KSNode): String? { + val file = containingFile(node) ?: return null + val text = java.nio.file.Files.readString(java.nio.file.Paths.get(file.filePath)) + val line = sourceLine(node) + val offset = text.lineSequence().take(line - 1).sumOf { it.length + 1 } + .coerceAtMost(text.length) + val modifiers = Regex( + "\\b(public|protected|private|static|final|abstract|synchronized|native|transient|volatile)\\b" + ) + val marker = Regex("@(?:supernote\\.generated\\.annotations\\.)?Supernote[A-Za-z0-9_]*\\b") + fun clean(value: String): String = value.replace(modifiers, " ") + .replace(marker, " ").trim() + return when (node) { + is KSValueParameter -> { + val name = node.name?.asString() ?: return null + val opening = text.lastIndexOf('(', offset).takeIf { it >= 0 } ?: return null + val closing = matchingJavaDelimiter(text, opening, '(', ')') ?: return null + splitJavaComponents(text, opening + 1, closing) + .firstOrNull { (value, _) -> Regex("\\b${Regex.escape(name)}\\s*$").containsMatchIn(value) } + ?.first?.replace(Regex("\\b${Regex.escape(name)}\\s*$"), "")?.let(::clean) + } + is KSFunctionDeclaration -> { + val name = node.simpleName.asString() + val opening = text.indexOf('(', offset).takeIf { it >= 0 } ?: return null + val start = maxOf( + text.lastIndexOf(';', offset), text.lastIndexOf('{', offset), + text.lastIndexOf('}', offset), + ) + 1 + val prefix = text.substring(start, opening) + prefix.substringBeforeLast(name).let(::clean) + } + is KSPropertyDeclaration -> { + val name = node.simpleName.asString() + val start = maxOf( + text.lastIndexOf(';', offset), text.lastIndexOf('{', offset), + text.lastIndexOf('}', offset), + ) + 1 + val end = text.indexOf(';', offset).takeIf { it >= 0 } ?: return null + text.substring(start, end).substringBeforeLast(name).let(::clean) + } + else -> null + } } private fun validateFunctionMarkers(function: KSFunctionDeclaration) { val marked = markers(function) if ("SupernoteConstructor" in marked) fail(function, "SupernoteConstructor is valid only on constructors") + if ("SupernotePluginObject" in marked || "SupernotePluginValue" in marked) { + fail(function, "SupernotePluginObject and SupernotePluginValue are valid only on type declarations") + } validateReachability(function, marked) if ("SupernotePluginAsync" in marked && marked.none { it == "SupernotePluginExport" || it == "SupernotePluginInternal" }) { fail(function, "SupernotePluginAsync requires SupernotePluginExport or SupernotePluginInternal") @@ -293,39 +720,44 @@ class SupernoteV2Processor( private fun validateOwner(owner: OwnerFact) { if (owner.visibility != "public") fail(owner.source.path, "JVM implementation owners must be public") - val role = when { - owner.markers.any { it.name == "SupernotePluginExport" } -> "export" - owner.markers.any { it.name == "SupernotePluginInternal" } -> "internal" - else -> "ordinary" - } + val objectType = owner.markers.any { it.name == "SupernotePluginObject" } + val valueType = owner.markers.any { it.name == "SupernotePluginValue" } owner.declarations.forEach { declaration -> val declarationRole = when { declaration.markers.any { it.name == "SupernotePluginExport" } -> "export" declaration.markers.any { it.name == "SupernotePluginInternal" } -> "internal" else -> "ordinary" } - if (role == "internal" && declarationRole != "internal") { - fail(declaration.source.path, "SupernotePluginInternal classes may contain only SupernotePluginInternal generated methods") - } - if (role != "ordinary" && declaration.isStatic) { - fail(declaration.source.path, "static methods are not object/service members") + if (valueType && declarationRole != "ordinary") { + fail(declaration.source.path, "value types expose marked fields/properties, not generated methods") } } - if (role == "ordinary" && owner.language == "java" && owner.declarations.any { it.isStatic }) { + if (!objectType && !valueType && owner.language == "java" && owner.declarations.any { it.isStatic }) { if (!owner.declarations.all { it.isStatic }) { fail(owner.source.path, "Java static and instance feature methods cannot share one owner") } owner.form = "java_static" owner.constructors.clear() } + if (!objectType && owner.constructors.any { constructor -> constructor.markers.any { it.name == "SupernoteConstructor" } }) { + fail(owner.source.path, "SupernoteConstructor is valid only on a SupernotePluginObject class") + } } - private fun validateValueType(node: KSNode, name: String, nullable: Boolean, result: Boolean) { - if (nullable) fail(node, "nullable marked JVM values are deferred") + private fun validateValueType(node: KSNode, type: TypeFact, result: Boolean) { + val name = type.jvmType val kotlinTypes = setOf("kotlin.Unit", "kotlin.Boolean", "kotlin.Int", "kotlin.Long", "kotlin.Float", "kotlin.Double", "kotlin.String", "kotlin.ByteArray") - val javaTypes = setOf("void", "boolean", "int", "long", "float", "double", "java.lang.String", "byte[]") - if (name !in kotlinTypes && name !in javaTypes) fail(node, "unsupported marked JVM type $name") + val javaTypes = setOf("void", "boolean", "int", "long", "float", "double", "java.lang.Boolean", "java.lang.Integer", "java.lang.Long", "java.lang.Float", "java.lang.Double", "java.lang.String", "byte[]") + val listTypes = setOf("kotlin.collections.List", "java.util.List") + if (name in listTypes) { + if (type.arguments.size != 1) fail(node, "List requires exactly one declared type argument") + validateValueType(node, type.arguments.single(), false) + } else if (name !in kotlinTypes && name !in javaTypes) { + // Named object/value/enum declarations are resolved by the common semantic projection. + if (!Regex("^[A-Za-z_][A-Za-z0-9_.]*$").matches(name)) fail(node, "unsupported marked JVM type $name") + } if (!result && (name == "kotlin.Unit" || name == "void")) fail(node, "void/Unit is valid only as a result") + if (type.nullable && (name == "kotlin.Unit" || name == "void")) fail(node, "void/Unit cannot be nullable") } private fun featureRoots(): List { @@ -343,18 +775,24 @@ class SupernoteV2Processor( } } - private fun sourceType(type: KSType, language: String): String { + private fun sourceType( + type: KSType, + language: String, + genericPosition: Boolean = false, + nullable: Boolean = type.nullability == Nullability.NULLABLE, + ): String { val qualified = type.declaration.qualifiedName?.asString() ?: type.toString() if (language == "kotlin") return qualified return when (qualified) { "kotlin.Unit" -> "void" - "kotlin.Boolean" -> "boolean" - "kotlin.Int" -> "int" - "kotlin.Long" -> "long" - "kotlin.Float" -> "float" - "kotlin.Double" -> "double" + "kotlin.Boolean" -> if (genericPosition || nullable || type.nullability != Nullability.NOT_NULL) "java.lang.Boolean" else "boolean" + "kotlin.Int" -> if (genericPosition || nullable || type.nullability != Nullability.NOT_NULL) "java.lang.Integer" else "int" + "kotlin.Long" -> if (genericPosition || nullable || type.nullability != Nullability.NOT_NULL) "java.lang.Long" else "long" + "kotlin.Float" -> if (genericPosition || nullable || type.nullability != Nullability.NOT_NULL) "java.lang.Float" else "float" + "kotlin.Double" -> if (genericPosition || nullable || type.nullability != Nullability.NOT_NULL) "java.lang.Double" else "double" "kotlin.String" -> "java.lang.String" "kotlin.ByteArray" -> "byte[]" + "kotlin.collections.List", "kotlin.collections.MutableList" -> "java.util.List" else -> qualified } } @@ -366,8 +804,14 @@ class SupernoteV2Processor( "kotlin.Long", "long" -> "J" "kotlin.Float", "float" -> "F" "kotlin.Double", "double" -> "D" + "java.lang.Boolean" -> "Ljava/lang/Boolean;" + "java.lang.Integer" -> "Ljava/lang/Integer;" + "java.lang.Long" -> "Ljava/lang/Long;" + "java.lang.Float" -> "Ljava/lang/Float;" + "java.lang.Double" -> "Ljava/lang/Double;" "kotlin.String", "java.lang.String" -> "Ljava/lang/String;" "kotlin.ByteArray", "byte[]" -> "[B" + "kotlin.collections.List", "java.util.List" -> "Ljava/util/List;" else -> "L${type.replace('.', '/')};" } @@ -377,6 +821,9 @@ class SupernoteV2Processor( private fun isStatic(function: KSFunctionDeclaration): Boolean = function.parentDeclaration == null || function.modifiers.any { it.name == "JAVA_STATIC" || it.name == "STATIC" } + private fun isStaticProperty(property: KSPropertyDeclaration): Boolean = + property.modifiers.any { it.name == "JAVA_STATIC" || it.name == "STATIC" } + private fun ownerName(declaration: KSClassDeclaration): String = declaration.qualifiedName?.asString() ?: fail(declaration, "JVM owner requires a qualified name") @@ -410,6 +857,7 @@ class SupernoteV2Processor( private fun containingFile(node: KSNode): KSFile? = when (node) { is KSDeclaration -> node.containingFile + is KSValueParameter -> node.parent?.let(::containingFile) else -> null } @@ -422,16 +870,18 @@ class SupernoteV2Processor( private fun ownerIdentity(owner: String): String = "jvm:$owner" private fun declarationIdentity(owner: String, name: String, descriptor: String): String = "jvm:$owner#$name$descriptor" private fun adapterIdentity(id: String): String = "supernote.jvm.adapter.${hash(id).take(20)}" + private fun fieldIdentity(owner: String, name: String): String = "jvm:$owner#field:$name" + private fun fieldAccessorIdentity(id: String): String = "supernote.jvm.field.${hash(id).take(20)}" private fun hash(value: String): String = MessageDigest.getInstance("SHA-256") .digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } private fun fail(node: KSNode, message: String): Nothing { - environment.logger.error("Supernote V2: $message", node) + environment.logger.error("Supernote V3: $message", node) throw SupernoteSourceDiagnostic() } private fun fail(path: String, message: String): Nothing { - environment.logger.error("Supernote V2: $path: $message") + environment.logger.error("Supernote V3: $path: $message") throw SupernoteSourceDiagnostic() } } @@ -442,24 +892,69 @@ private data class FeatureRoot(val featureId: String, val relative: String, val } private data class MarkerFact(val name: String, val line: Int, val column: Int) private data class SourceFact(val declarationId: String, val language: String, val path: String, val line: Int, val column: Int) -private data class ParameterFact(val jvmType: String, val name: String, val nullable: Boolean, val injected: String?) +private data class TypeFact(val jvmType: String, val nullable: Boolean, val arguments: List) +private data class ParameterFact(val jvmType: String, val name: String, val nullable: Boolean, val injected: String?, val arguments: List) private data class ConstructorFact(val declarationId: String, val descriptor: String, val parameters: MutableList, val visibility: String, val markers: List, val adapterIdentity: String, val source: SourceFact) -private data class DeclarationFact(val declarationId: String, val ownerDeclarationId: String, val ownerClass: String, val name: String, val descriptor: String, val parameters: MutableList, val result: String, val resultNullable: Boolean, val markers: List, val visibility: String, val adapterIdentity: String, val language: String, val suspend: Boolean, val isStatic: Boolean, val source: SourceFact) -private data class OwnerFact(val declarationId: String, val language: String, val ownerClass: String, val sourceName: String, var form: String, val visibility: String, val markers: List, val source: SourceFact, val constructors: MutableList, val declarations: MutableList = mutableListOf()) +private data class DeclarationFact(val declarationId: String, val ownerDeclarationId: String, val ownerClass: String, val name: String, val descriptor: String, val parameters: MutableList, val result: String, val resultNullable: Boolean, val markers: List, val visibility: String, val adapterIdentity: String, val language: String, val suspend: Boolean, val isStatic: Boolean, val source: SourceFact, val resultArguments: List) +private data class FieldFact(val declarationId: String, val ownerDeclarationId: String, val name: String, val type: TypeFact, val markers: List, val visibility: String, val mutable: Boolean, val isStatic: Boolean, val accessorIdentity: String, val source: SourceFact) +private data class JavaRecordFacts(val constructors: MutableList, val fields: MutableList) +private data class OwnerFact(val declarationId: String, val language: String, val ownerClass: String, val sourceName: String, var form: String, val visibility: String, val markers: List, val source: SourceFact, val constructors: MutableList, val declarations: MutableList = mutableListOf(), val fields: MutableList = mutableListOf(), val enumConstants: MutableList = mutableListOf(), val isData: Boolean = false, val isRecord: Boolean = false, val isFinal: Boolean = true, val typeParameterCount: Int = 0, val supertypes: MutableList = mutableListOf(), val enumAdapterIdentity: String = "") -private fun adapterSource(owners: List): String = buildString { +private fun adapterSource( + owners: List, + identityClass: String, +): String = buildString { appendLine("@file:Suppress(\"unused\")") appendLine("package supernote.generated.adapters") appendLine() appendLine("import com.facebook.react.bridge.ReactApplicationContext") appendLine("import supernote.generated.runtime.SupernoteCoroutineBridge") appendLine() + appendLine("object $identityClass {") + appendLine(" @JvmStatic") + appendLine(" fun identityHash(value: Any): Int = System.identityHashCode(value)") + appendLine(" @JvmStatic") + appendLine(" fun newList(): MutableList = mutableListOf()") + appendLine(" @JvmStatic") + appendLine(" fun listAdd(list: MutableList, value: Any?) { list.add(value) }") + appendLine(" @JvmStatic") + appendLine(" fun listSize(list: List<*>): Int = list.size") + appendLine(" @JvmStatic") + appendLine(" fun listGet(list: List<*>, index: Int): Any? = list[index]") + appendLine(" @JvmStatic fun boxBoolean(value: Boolean): Any = value") + appendLine(" @JvmStatic fun boxInt(value: Int): Any = value") + appendLine(" @JvmStatic fun boxLong(value: Long): Any = value") + appendLine(" @JvmStatic fun boxFloat(value: Float): Any = value") + appendLine(" @JvmStatic fun boxDouble(value: Double): Any = value") + appendLine(" @JvmStatic fun unboxBoolean(value: Any): Boolean = value as Boolean") + appendLine(" @JvmStatic fun unboxInt(value: Any): Int = value as Int") + appendLine(" @JvmStatic fun unboxLong(value: Any): Long = value as Long") + appendLine(" @JvmStatic fun unboxFloat(value: Any): Float = value as Float") + appendLine(" @JvmStatic fun unboxDouble(value: Any): Double = value as Double") + appendLine("}") + appendLine() owners.forEach { owner -> + if (owner.enumConstants.isNotEmpty()) { + appendLine("object ${adapterClass(owner.enumAdapterIdentity)} {") + appendLine(" @JvmStatic") + appendLine( + " fun fromName(value: ByteArray): ${kotlinReference(owner.ownerClass)} = " + + "java.lang.Enum.valueOf(${kotlinReference(owner.ownerClass)}::class.java, " + + "value.decodeToString(throwOnInvalidSequence = true))" + ) + appendLine(" @JvmStatic") + appendLine( + " fun name(value: ${kotlinReference(owner.ownerClass)}): ByteArray = " + + "value.name.encodeToByteArray()" + ) + appendLine("}") + appendLine() + } owner.constructors.filter { it.visibility == "public" }.forEach { constructor -> appendLine("object ${adapterClass(constructor.adapterIdentity)} {") val parameters = mutableListOf("context: ReactApplicationContext") parameters += constructor.parameters.filter { it.injected == null } - .mapIndexed { index, parameter -> "arg$index: ${adapterType(parameter.jvmType)}" } + .mapIndexed { index, parameter -> "arg$index: ${adapterType(parameter)}" } appendLine(" @JvmStatic") appendLine(" fun invoke(${parameters.joinToString(", ")}): ${kotlinReference(owner.ownerClass)} =") val arguments = mutableListOf() @@ -468,7 +963,7 @@ private fun adapterSource(owners: List): String = buildString { arguments += if (parameter.injected != null) { "context" } else { - adapterInput(parameter.jvmType, "arg${valueIndex++}") + adapterInput(parameter, "arg${valueIndex++}") } } appendLine(" ${kotlinReference(owner.ownerClass)}(${arguments.joinToString(", ")})") @@ -477,25 +972,32 @@ private fun adapterSource(owners: List): String = buildString { } owner.declarations.forEach { declaration -> appendLine("object ${adapterClass(declaration.adapterIdentity)} {") - val takesOwner = owner.form == "class" + val takesOwner = owner.form == "class" && !declaration.isStatic val parameters = mutableListOf() if (takesOwner) parameters += "owner: ${kotlinReference(owner.ownerClass)}" parameters += declaration.parameters.mapIndexed { index, parameter -> - "arg$index: ${adapterType(parameter.jvmType)}" + "arg$index: ${adapterType(parameter)}" } if (declaration.suspend) parameters += "completionToken: Long" appendLine(" @JvmStatic") val adapterResult = if (declaration.suspend) { "kotlinx.coroutines.Job" } else { - adapterType(declaration.result) + adapterType(TypeFact(declaration.result, declaration.resultNullable, declaration.resultArguments)) } + val sourceResult = TypeFact( + declaration.result, declaration.resultNullable, declaration.resultArguments + ) append(" fun invoke(${parameters.joinToString(", ")}): $adapterResult ") val arguments = declaration.parameters.mapIndexed { index, parameter -> - adapterInput(parameter.jvmType, "arg$index") + adapterInput(parameter, "arg$index") }.joinToString(", ") val target = when (owner.form) { - "class" -> "owner.${kotlinIdentifier(declaration.name)}" + "class" -> if (declaration.isStatic) { + "${kotlinReference(owner.ownerClass)}.${kotlinIdentifier(declaration.name)}" + } else { + "owner.${kotlinIdentifier(declaration.name)}" + } "kotlin_object", "java_static" -> "${kotlinReference(owner.ownerClass)}.${kotlinIdentifier(declaration.name)}" "kotlin_top_level" -> { @@ -511,39 +1013,122 @@ private fun adapterSource(owners: List): String = buildString { when (declaration.result) { "kotlin.Unit", "void" -> appendLine(" $call; null") "kotlin.String", "java.lang.String" -> - appendLine(" $call.encodeToByteArray()") - else -> appendLine(" $call") + appendLine(" $call${if (declaration.resultNullable) "?" else ""}.encodeToByteArray()") + else -> appendLine(" ${adapterOutput(sourceResult, call)}") } appendLine(" }") } else { when (declaration.result) { "kotlin.Unit", "void" -> appendLine("{ $call }") - "kotlin.String", "java.lang.String" -> appendLine("= $call.encodeToByteArray()") - else -> appendLine("= $call") + "kotlin.String", "java.lang.String" -> appendLine("= $call${if (declaration.resultNullable) "?" else ""}.encodeToByteArray()") + else -> appendLine("= ${adapterOutput(sourceResult, call)}") } } appendLine("}") appendLine() } + owner.fields.forEach { field -> + appendLine("object ${adapterClass(field.accessorIdentity)} {") + val fieldType = adapterType(field.type) + val ownerType = kotlinReference(owner.ownerClass) + val property = "owner.${kotlinIdentifier(field.name)}" + appendLine(" @JvmStatic") + appendLine( + " fun get(owner: $ownerType): $fieldType = " + + adapterOutput(field.type, property) + ) + if (field.mutable) { + appendLine(" @JvmStatic") + appendLine(" fun set(owner: $ownerType, value: $fieldType) {") + appendLine(" $property = ${adapterInput(field.type, "value")}") + appendLine(" }") + } + appendLine("}") + appendLine() + } } } private fun adapterClass(identity: String): String = "Adapter_" + identity.substringAfterLast('.') -private fun adapterType(type: String): String = when (type) { +private fun adapterType(parameter: ParameterFact): String = + adapterType(TypeFact(parameter.jvmType, parameter.nullable, parameter.arguments)) + +private fun adapterType(type: TypeFact): String { + val base = when (type.jvmType) { "kotlin.Unit", "void" -> "Unit" "kotlin.Boolean", "boolean" -> "Boolean" "kotlin.Int", "int" -> "Int" "kotlin.Long", "long" -> "Long" "kotlin.Float", "float" -> "Float" "kotlin.Double", "double" -> "Double" + "java.lang.Boolean" -> "Boolean" + "java.lang.Integer" -> "Int" + "java.lang.Long" -> "Long" + "java.lang.Float" -> "Float" + "java.lang.Double" -> "Double" "kotlin.String", "java.lang.String", "kotlin.ByteArray", "byte[]" -> "ByteArray" - else -> error("Unsupported adapter type $type") + "kotlin.collections.List", "java.util.List" -> + "List<${adapterBridgeType(type.arguments.single())}>" + else -> kotlinReference(type.jvmType) + } + return base + if (type.nullable) "?" else "" } -private fun adapterInput(type: String, name: String): String = when (type) { - "kotlin.String", "java.lang.String" -> "$name.decodeToString(throwOnInvalidSequence = true)" +private fun adapterSourceType(type: TypeFact): String { + val base = when (type.jvmType) { + "kotlin.Unit", "void" -> "Unit" + "kotlin.Boolean", "boolean" -> "Boolean" + "kotlin.Int", "int" -> "Int" + "kotlin.Long", "long" -> "Long" + "kotlin.Float", "float" -> "Float" + "kotlin.Double", "double" -> "Double" + "java.lang.Boolean" -> "Boolean" + "java.lang.Integer" -> "Int" + "java.lang.Long" -> "Long" + "java.lang.Float" -> "Float" + "java.lang.Double" -> "Double" + "kotlin.String", "java.lang.String" -> "String" + "kotlin.ByteArray", "byte[]" -> "ByteArray" + "kotlin.collections.List", "java.util.List" -> "List<${adapterSourceType(type.arguments.single())}>" + else -> kotlinReference(type.jvmType) + } + return base + if (type.nullable) "?" else "" +} + +private fun adapterBridgeType(type: TypeFact): String { + val base = when (type.jvmType) { + "kotlin.String", "java.lang.String" -> "ByteArray" + "kotlin.collections.List", "java.util.List" -> + "List<${adapterBridgeType(type.arguments.single())}>" + else -> adapterSourceType(TypeFact(type.jvmType, false, type.arguments)) + } + return base + if (type.nullable) "?" else "" +} + +private fun adapterInput(type: ParameterFact, name: String): String = adapterInput( + TypeFact(type.jvmType, type.nullable, type.arguments), name +) + +private fun adapterInput(type: TypeFact, name: String): String = when (type.jvmType) { + "kotlin.String", "java.lang.String" -> + if (type.nullable) "$name?.decodeToString(throwOnInvalidSequence = true)" + else "$name.decodeToString(throwOnInvalidSequence = true)" + "kotlin.collections.List", "java.util.List" -> { + val mapped = adapterInput(type.arguments.single(), "item") + "$name${if (type.nullable) "?" else ""}.map { item -> $mapped }" + } + else -> name +} + +private fun adapterOutput(type: TypeFact, name: String): String = when (type.jvmType) { + "kotlin.String", "java.lang.String" -> + "$name${if (type.nullable) "?" else ""}.encodeToByteArray()" + "kotlin.collections.List", "java.util.List" -> { + val mapped = adapterOutput(type.arguments.single(), "item") + "$name${if (type.nullable) "?" else ""}.map { item -> $mapped }" + } else -> name } @@ -570,6 +1155,13 @@ private fun ownerJson(value: OwnerFact): Map = linkedMapOf( "markers" to value.markers.map(::markerJson), "constructors" to value.constructors.sortedBy { it.declarationId }.map(::constructorJson), "declarations" to value.declarations.sortedBy { it.declarationId }.map(::declarationJson), + // Field order is schema order for data classes and records. Do not sort it + // by stable identity: constructor lowering depends on the declared order. + "fields" to value.fields.map(::fieldJson), + "enum_constants" to value.enumConstants, + "is_data" to value.isData, "is_record" to value.isRecord, + "is_final" to value.isFinal, "type_parameter_count" to value.typeParameterCount, + "supertypes" to value.supertypes, ) private fun constructorJson(value: ConstructorFact): Map = linkedMapOf( "source" to sourceJson(value.source), "jvm_descriptor" to value.descriptor, @@ -584,13 +1176,22 @@ private fun declarationJson(value: DeclarationFact): Map = linkedM "markers" to value.markers.map(::markerJson), "visibility" to value.visibility, "adapter_identity" to value.adapterIdentity, "language" to value.language, "is_suspend" to value.suspend, "is_static" to value.isStatic, + "result_type_arguments" to value.resultArguments.map(::typeJson), +) +private fun fieldJson(value: FieldFact): Map = linkedMapOf( + "source" to sourceJson(value.source), "owner_declaration_id" to value.ownerDeclarationId, + "name" to value.name, "type" to typeJson(value.type), + "markers" to value.markers.map(::markerJson), "visibility" to value.visibility, + "mutable" to value.mutable, "is_static" to value.isStatic, + "accessor_identity" to value.accessorIdentity, ) private fun sourceJson(value: SourceFact): Map = linkedMapOf( "declaration_id" to value.declarationId, "language" to value.language, "path" to value.path, "line" to value.line, "column" to value.column, ) private fun markerJson(value: MarkerFact): Map = linkedMapOf("name" to value.name, "line" to value.line, "column" to value.column) -private fun parameterJson(value: ParameterFact): Map = linkedMapOf("jvm_type" to value.jvmType, "name" to value.name, "nullable" to value.nullable, "injected" to value.injected) +private fun parameterJson(value: ParameterFact): Map = linkedMapOf("jvm_type" to value.jvmType, "name" to value.name, "nullable" to value.nullable, "injected" to value.injected, "type_arguments" to value.arguments.map(::typeJson)) +private fun typeJson(value: TypeFact): Map = linkedMapOf("jvm_type" to value.jvmType, "nullable" to value.nullable, "arguments" to value.arguments.map(::typeJson)) private fun json(value: Any?): String = when (value) { null -> "null" @@ -604,7 +1205,7 @@ private fun json(value: Any?): String = when (value) { else -> error("Unsupported JSON value ${value::class}") } -class SupernoteV2ProcessorProvider : SymbolProcessorProvider { +class SupernoteV3ProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = - SupernoteV2Processor(environment) + SupernoteV3Processor(environment) } diff --git a/src/supernote_module_generator/templates/v2.common_codegen.py.tmpl b/src/supernote_module_generator/templates/v2.common_codegen.py.tmpl index ee49e47..1991e4e 100644 --- a/src/supernote_module_generator/templates/v2.common_codegen.py.tmpl +++ b/src/supernote_module_generator/templates/v2.common_codegen.py.tmpl @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Generated standalone V2 semantic merge stage. Do not edit.""" +"""Generated standalone V3 semantic merge stage. Do not edit.""" from __future__ import annotations import argparse @@ -9,6 +9,11 @@ import os from pathlib import Path import tempfile +from common_support.conversion import plan_api_conversion +from common_support.cross_family_codegen import ( + CrossFamilyCodegenError, + build_cross_family_renderer, +) from common_support.binding_codegen import ( CodegenError, render_v2_feature_jsi, @@ -100,6 +105,9 @@ def generate( recorded_cpp = semantic_api_from_manifest(cpp_raw) feature_root = plugin_root / "local_modules" / feature["npm_name"] native_root = feature_root / feature["implementation_roots"]["native"] + native_include_prefix = ( + f'{feature["npm_name"]}/{feature["implementation_roots"]["native"]}' + ) cpp = ( scan_cpp_semantic_model( feature_root, module_name=feature["public_name"] @@ -121,16 +129,39 @@ def generate( source_manifest = read_jvm_manifest( manifests[0], expected_feature_id=feature_id ) - jvm = project_jvm_owners(source_manifest.owners) + jvm = project_jvm_owners( + source_manifest.owners, feature_id=source_manifest.feature_id + ) semantic = merge_semantic_apis(cpp, jvm) + cross_family = None + if source_manifest is not None and native_root.is_dir(): + try: + cross_family = build_cross_family_renderer( + feature_root, + semantic, + source_manifest, + feature_id=feature_id, + module_name=feature["public_name"], + ) + except (CrossFamilyCodegenError, ValueError) as exc: + raise RuntimeError(str(exc)) from exc semantic_json = semantic.manifest() + conversion_json = plan_api_conversion(semantic).manifest() encoded = json.dumps( semantic_json, ensure_ascii=False, separators=(",", ":"), sort_keys=True ).encode("utf-8") + conversion_encoded = json.dumps( + conversion_json, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + conversion_digest = hashlib.sha256(conversion_encoded).hexdigest() semantic_registry.append( { "feature_id": feature_id, "semantic_digest": hashlib.sha256(encoded).hexdigest(), + "conversion_digest": conversion_digest, "semantic_api": semantic_json, } ) @@ -138,6 +169,10 @@ def generate( output_root / "semantics" / f"{suffix}.json", json.dumps(semantic_json, indent=2, sort_keys=True) + "\n", ) + _atomic_write( + output_root / "conversions" / f"{suffix}.json", + json.dumps(conversion_json, indent=2, sort_keys=True) + "\n", + ) _atomic_write( feature_root / "index.d.ts", render_typescript(feature["public_name"], semantic), @@ -147,6 +182,8 @@ def generate( feature_root, module_name=feature["public_name"], feature_id=feature_id, + conversion_digest=conversion_digest, + include_prefix=native_include_prefix, ) except CodegenError as exc: raise RuntimeError(str(exc)) from exc @@ -157,6 +194,8 @@ def generate( feature_id=feature_id, jvm_manifest=source_manifest, jvm_semantic=jvm if source_manifest is not None else None, + cross_family=cross_family, + include_prefix=native_include_prefix, ) except CodegenError as exc: raise RuntimeError(str(exc)) from exc @@ -172,6 +211,8 @@ def generate( jvm, feature_id=feature_id, module_name=feature["public_name"], + conversion_digest=conversion_digest, + cross_family=cross_family, ) ) except JvmCodegenError as exc: diff --git a/src/supernote_module_generator/templates/v2.processor.provider.tmpl b/src/supernote_module_generator/templates/v2.processor.provider.tmpl index b89d4e6..a3249f0 100644 --- a/src/supernote_module_generator/templates/v2.processor.provider.tmpl +++ b/src/supernote_module_generator/templates/v2.processor.provider.tmpl @@ -1 +1 @@ -supernote.generated.processor.SupernoteV2ProcessorProvider +supernote.generated.processor.SupernoteV3ProcessorProvider diff --git a/src/supernote_module_generator/templates/v3.SupernotePluginObject.java.tmpl b/src/supernote_module_generator/templates/v3.SupernotePluginObject.java.tmpl new file mode 100644 index 0000000..99c886d --- /dev/null +++ b/src/supernote_module_generator/templates/v3.SupernotePluginObject.java.tmpl @@ -0,0 +1,10 @@ +package supernote.generated.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.SOURCE) +public @interface SupernotePluginObject {} diff --git a/src/supernote_module_generator/templates/v3.SupernotePluginValue.java.tmpl b/src/supernote_module_generator/templates/v3.SupernotePluginValue.java.tmpl new file mode 100644 index 0000000..4f662b3 --- /dev/null +++ b/src/supernote_module_generator/templates/v3.SupernotePluginValue.java.tmpl @@ -0,0 +1,10 @@ +package supernote.generated.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.SOURCE) +public @interface SupernotePluginValue {} diff --git a/src/supernote_module_generator/typescript_codegen.py b/src/supernote_module_generator/typescript_codegen.py index 183d271..ff60326 100644 --- a/src/supernote_module_generator/typescript_codegen.py +++ b/src/supernote_module_generator/typescript_codegen.py @@ -1,38 +1,53 @@ """Generate the public TypeScript contract from common Supernote semantics.""" from __future__ import annotations -from .semantic import ExecutionMode, SemanticApi, SemanticBinding, SemanticType - - -_TYPES = { - SemanticType.VOID: "void", - SemanticType.BOOL: "boolean", - SemanticType.INT32: "number", - SemanticType.INT64: "bigint", - SemanticType.FLOAT32: "number", - SemanticType.FLOAT64: "number", - SemanticType.STRING: "string", - SemanticType.BYTES: "Uint8Array", +from .reachability import PublicApi, compute_public_api +from .semantic import ( + ExecutionMode, + MemberScope, + SemanticApi, + SemanticBinding, + SemanticEnumDeclaration, + SemanticObjectDeclaration, + SemanticValueDeclaration, +) +from .semantic_types import ScalarKind, SemanticType, SemanticTypeKind + + +_SCALARS = { + ScalarKind.BOOL: "boolean", + ScalarKind.INT32: "number", + ScalarKind.INT64: "bigint", + ScalarKind.FLOAT32: "number", + ScalarKind.FLOAT64: "number", + ScalarKind.STRING: "string", + ScalarKind.BYTES: "Uint8Array", } def render_typescript(feature_name: str, api: SemanticApi) -> str: - object_interfaces = [] - object_properties = [] + public = compute_public_api(api, feature_name=feature_name) + declaration_names = {item.type_id: item.name for item in public.declarations} + declarations = [ + _declaration(item, public, declaration_names) + for item in public.declarations + ] + + # Retain the old class records while their Phase 4 replacement tests are + # still useful. V3 declarations never depend on this compatibility path. + legacy_interfaces = [] + legacy_properties = [] for item in api.classes: if not item.capabilities.javascript_public: continue methods = [ - _method(binding) + _method(binding, declaration_names) for binding in item.methods if binding.capabilities.javascript_public ] + parameters = _parameters(item.constructor.parameters, declaration_names) method_body = "\n".join(methods) - parameters = ", ".join( - f"{parameter.name}: {_TYPES[parameter.type]}" - for parameter in item.constructor.parameters - ) - object_interfaces.append( + legacy_interfaces.append( f"export interface {item.name} {{\n" f"{method_body}\n" "}\n\n" @@ -40,17 +55,38 @@ def render_typescript(feature_name: str, api: SemanticApi) -> str: f" create({parameters}): {item.name};\n" "}" ) - object_properties.append(f" {item.name}: {item.name}Factory;") + legacy_properties.append(f" {item.name}: {item.name}Factory;") - functions = [ - _method(binding) - for binding in api.functions - if binding.capabilities.javascript_public - ] - body = "\n".join(object_properties + functions) - object_prefix = ( - "\n\n".join(object_interfaces) + "\n\n" if object_interfaces else "" + root_properties = list(legacy_properties) + by_id = {item.type_id: item for item in public.declarations} + for item in sorted(public.declarations, key=lambda value: (value.name, value.type_id)): + members = [] + if isinstance(item, SemanticObjectDeclaration): + if item.constructor is not None: + members.append( + " create: " + + _callable(item.constructor.parameters, SemanticType.object_ref(item.type_id), declaration_names) + + ";" + ) + members.extend( + _method(method, declaration_names, indent=" ") + for method in sorted(item.methods, key=lambda value: (value.name, value.binding_id)) + if method.capabilities.javascript_public + and method.member_scope is MemberScope.STATIC + ) + namespace_body = "\n".join(members) + suffix = f" & {{\n{namespace_body}\n }}" if members else "" + root_properties.append( + f" {item.name}: SupernoteTypeCompanion<{item.name}>{suffix};" + ) + root_properties.extend( + _method(binding, declaration_names) for binding in public.functions ) + + declaration_prefix = "\n\n".join(legacy_interfaces + declarations) + if declaration_prefix: + declaration_prefix += "\n\n" + body = "\n".join(root_properties) return ( "/* Generated by supernote_module_generator. Do not edit. */\n" "export type SupernoteErrorCode =\n" @@ -62,19 +98,131 @@ def render_typescript(feature_name: str, api: SemanticApi) -> str: "export class SupernoteError extends Error {\n" " readonly code: SupernoteErrorCode;\n" "}\n\n" - f"{object_prefix}" + "export type SupernoteValidationReason =\n" + " | 'ARITY_MISMATCH'\n" + " | 'TYPE_MISMATCH'\n" + " | 'NOMINAL_MISMATCH'\n" + " | 'MISSING_FIELD'\n" + " | 'INVALID_ENUM'\n" + " | 'OUT_OF_RANGE'\n" + " | 'LIMIT_EXCEEDED';\n\n" + "export interface SupernoteValidationDetails {\n" + " readonly reason: SupernoteValidationReason;\n" + " readonly path: string;\n" + " readonly expected: string;\n" + " readonly actual: string;\n" + "}\n\n" + "export type SupernoteTypeError = TypeError & SupernoteValidationDetails;\n" + "export type SupernoteRangeError = RangeError & SupernoteValidationDetails;\n" + "export type SupernoteValidationResult =\n" + " | {readonly ok: true}\n" + " | {readonly ok: false; readonly error: SupernoteTypeError | SupernoteRangeError};\n\n" + "export interface SupernoteCallable {\n" + " (...args: Arguments): Result;\n" + " accepts(...args: unknown[]): boolean;\n" + " checkArguments(...args: unknown[]): SupernoteValidationResult;\n" + "}\n\n" + "export interface SupernoteTypeCompanion {\n" + " is(value: unknown): value is Value;\n" + " check(value: unknown): SupernoteValidationResult;\n" + "}\n\n" + "export type SupernoteFeatureStatus =\n" + " | 'available'\n" + " | 'runtime-unavailable'\n" + " | 'feature-unavailable';\n\n" + "export interface SupernoteNativeObjectInfo {\n" + " readonly type: string;\n" + " readonly originFamily: 'cpp' | 'jvm';\n" + "}\n\n" + "export function isFeatureAvailable(): boolean;\n" + "export function getFeatureStatus(): SupernoteFeatureStatus;\n" + "export function nativeObjectInfo(value: unknown): SupernoteNativeObjectInfo | undefined;\n" + "export function isSupernoteTypeError(value: unknown): value is SupernoteTypeError;\n" + "export function isSupernoteRangeError(value: unknown): value is SupernoteRangeError;\n\n" + f"{declaration_prefix}" f"export interface {feature_name}Feature {{\n{body}\n}}\n\n" f"declare const feature: {feature_name}Feature;\n" "export default feature;\n" ) -def _method(binding: SemanticBinding) -> str: - parameters = ", ".join( - f"{parameter.name}: {_TYPES[parameter.type]}" - for parameter in binding.parameters +def _declaration(item, public: PublicApi, names: dict[str, str]) -> str: + if isinstance(item, SemanticEnumDeclaration): + constants = " | ".join(repr(constant) for constant in item.constants) + return f"export type {item.name} = {constants};" + if isinstance(item, SemanticValueDeclaration): + fields = "\n".join( + f" {field.name}: {_type(field.type, names)};" for field in item.fields + ) + return f"export interface {item.name} {{\n{fields}\n}}" + assert isinstance(item, SemanticObjectDeclaration) + brand = f"__supernoteBrand_{item.name}" + members = [f" readonly [{brand}]: void;"] + if item.type_id in public.object_instances: + for field in item.fields: + readonly = "readonly " if not field.mutable else "" + members.append( + f" {readonly}{field.name}: {_type(field.type, names)};" + ) + members.extend( + _method(method, names) + for method in sorted(item.methods, key=lambda value: (value.name, value.binding_id)) + if method.capabilities.javascript_public + and method.member_scope is MemberScope.INSTANCE + ) + member_body = "\n".join(members) + return ( + f"declare const {brand}: unique symbol;\n" + f"export interface {item.name} {{\n{member_body}\n}}" + ) + + +def _parameters(parameters, names: dict[str, str]) -> str: + return ", ".join( + f"{parameter.name}: {_type(parameter.type, names)}" + for parameter in parameters ) - result = _TYPES[binding.result] + + +def _method( + binding: SemanticBinding, + names: dict[str, str], + *, + indent: str = " ", +) -> str: + result = _type(binding.result, names) if binding.execution is ExecutionMode.ASYNC: result = f"Promise<{result}>" - return f" {binding.name}({parameters}): {result};" + arguments = ", ".join( + f"{parameter.name}: {_type(parameter.type, names)}" + for parameter in binding.parameters + ) + return ( + f"{indent}{binding.name}: " + f"SupernoteCallable<[{arguments}], {result}>;" + ) + + +def _callable(parameters, result: SemanticType, names: dict[str, str]) -> str: + arguments = ", ".join( + f"{parameter.name}: {_type(parameter.type, names)}" + for parameter in parameters + ) + return f"SupernoteCallable<[{arguments}], {_type(result, names)}>" + + +def _type(semantic_type: SemanticType, names: dict[str, str]) -> str: + if semantic_type.kind is SemanticTypeKind.VOID: + return "void" + if semantic_type.kind is SemanticTypeKind.SCALAR: + assert semantic_type.scalar is not None + return _SCALARS[semantic_type.scalar] + if semantic_type.type_id is not None: + return names[semantic_type.type_id] + assert semantic_type.element is not None + inner = _type(semantic_type.element, names) + if semantic_type.kind is SemanticTypeKind.NULLABLE: + return f"{inner} | null" + if semantic_type.element.kind is SemanticTypeKind.NULLABLE: + inner = f"({inner})" + return f"{inner}[]" diff --git a/src/supernote_module_generator/v3_schemas.py b/src/supernote_module_generator/v3_schemas.py new file mode 100644 index 0000000..da66e4f --- /dev/null +++ b/src/supernote_module_generator/v3_schemas.py @@ -0,0 +1,21 @@ +"""Canonical V3 generated-artifact schema identities. + +V3 deliberately has no V2 manifest reader or compatibility mode. Every +generated boundary imports its identity from this module so schema changes are +explicit, reviewable, and cannot drift independently between frontends. +""" + +SEMANTIC_MANIFEST_SCHEMA_VERSION = 3 +SEMANTIC_MANIFEST_KIND = "supernote_v3_semantic_manifest" + +JVM_SOURCE_MANIFEST_SCHEMA_VERSION = 3 +JVM_SOURCE_MANIFEST_KIND = "supernote_v3_jvm_source_manifest" + +FEATURE_MANIFEST_SCHEMA_VERSION = 3 +FEATURE_MANIFEST_KIND = "supernote_v3_feature" + +PLUGIN_REGISTRY_SCHEMA_VERSION = 2 +PLUGIN_REGISTRY_KIND = "supernote_v3_plugin_runtime_registry" + +GENERATED_OWNERSHIP_SCHEMA_VERSION = 2 +GENERATED_OWNERSHIP_KIND = "supernote_v3_plugin_runtime_ownership" diff --git a/src/supernote_module_generator/verification.py b/src/supernote_module_generator/verification.py index 2213827..45cfba3 100644 --- a/src/supernote_module_generator/verification.py +++ b/src/supernote_module_generator/verification.py @@ -69,8 +69,12 @@ def expected_generated_files(module: ManagedModule) -> List[str]: "android/.supernote-module/codegen-config.json", "android/.supernote-module/supernote_codegen/__init__.py", "android/.supernote-module/supernote_codegen/cpp_projection.py", + "android/.supernote-module/supernote_codegen/cpp_routes.py", + "android/.supernote-module/supernote_codegen/cpp_object_binding_codegen.py", + "android/.supernote-module/supernote_codegen/conversion.py", "android/.supernote-module/supernote_codegen/lowering.py", "android/.supernote-module/supernote_codegen/semantic.py", + "android/.supernote-module/supernote_codegen/semantic_types.py", "android/.supernote-module/supernote_codegen/source_models.py", generated_package, ] diff --git a/tests/fixtures/v3_cpp_resolution/accept_exact_qualified_reference.hpp b/tests/fixtures/v3_cpp_resolution/accept_exact_qualified_reference.hpp new file mode 100644 index 0000000..38f4d42 --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/accept_exact_qualified_reference.hpp @@ -0,0 +1,10 @@ +#include + +namespace ink { +// @SupernotePluginObject +class Stroke {}; +} + +// @SupernotePluginExport +std::shared_ptr copy_stroke( + const std::shared_ptr<::ink::Stroke>& stroke); diff --git a/tests/fixtures/v3_cpp_resolution/accept_forward_then_marked_definition.hpp b/tests/fixtures/v3_cpp_resolution/accept_forward_then_marked_definition.hpp new file mode 100644 index 0000000..7b7d62c --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/accept_forward_then_marked_definition.hpp @@ -0,0 +1,6 @@ +namespace ink { +class Stroke; + +// @SupernotePluginObject +class Stroke {}; +} diff --git a/tests/fixtures/v3_cpp_resolution/accept_global_complete.hpp b/tests/fixtures/v3_cpp_resolution/accept_global_complete.hpp new file mode 100644 index 0000000..8119600 --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/accept_global_complete.hpp @@ -0,0 +1,2 @@ +// @SupernotePluginObject +class Stroke {}; diff --git a/tests/fixtures/v3_cpp_resolution/accept_named_namespace.hpp b/tests/fixtures/v3_cpp_resolution/accept_named_namespace.hpp new file mode 100644 index 0000000..6bb3fbd --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/accept_named_namespace.hpp @@ -0,0 +1,4 @@ +namespace ink { +// @SupernotePluginObject +class Stroke {}; +} diff --git a/tests/fixtures/v3_cpp_resolution/accept_nested_namespace.hpp b/tests/fixtures/v3_cpp_resolution/accept_nested_namespace.hpp new file mode 100644 index 0000000..8ec0c2f --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/accept_nested_namespace.hpp @@ -0,0 +1,4 @@ +namespace supernote::ink { +// @SupernotePluginObject +class Stroke {}; +} diff --git a/tests/fixtures/v3_cpp_resolution/accept_unqualified_enclosing.hpp b/tests/fixtures/v3_cpp_resolution/accept_unqualified_enclosing.hpp new file mode 100644 index 0000000..518d5d5 --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/accept_unqualified_enclosing.hpp @@ -0,0 +1,9 @@ +#include + +namespace ink { +// @SupernotePluginObject +class Stroke {}; + +// @SupernotePluginExport +std::shared_ptr copy_stroke(const std::shared_ptr& stroke); +} diff --git a/tests/fixtures/v3_cpp_resolution/cases.json b/tests/fixtures/v3_cpp_resolution/cases.json new file mode 100644 index 0000000..ffdc8fa --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/cases.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "decision": "D-042", + "cases": [ + {"file": "accept_global_complete.hpp", "outcome": "accept", "rule": "global_complete"}, + {"file": "accept_named_namespace.hpp", "outcome": "accept", "rule": "named_namespace_complete"}, + {"file": "accept_nested_namespace.hpp", "outcome": "accept", "rule": "nested_named_namespace_complete"}, + {"file": "accept_exact_qualified_reference.hpp", "outcome": "accept", "rule": "exact_qualified_reference"}, + {"file": "accept_unqualified_enclosing.hpp", "outcome": "accept", "rule": "unique_unqualified_enclosing_reference"}, + {"file": "accept_forward_then_marked_definition.hpp", "outcome": "accept", "rule": "unmarked_forward_then_marked_definition"}, + {"file": "reject_alias_signature.hpp", "outcome": "reject", "rule": "alias_bridge_visible_spelling"}, + {"file": "reject_marked_alias.hpp", "outcome": "reject", "rule": "marked_alias"}, + {"file": "reject_anonymous_namespace.hpp", "outcome": "reject", "rule": "anonymous_namespace_bridge_type"}, + {"file": "reject_nested_type.hpp", "outcome": "reject", "rule": "nested_bridge_declaration"}, + {"file": "reject_ambiguous_unqualified.hpp", "outcome": "reject", "rule": "ambiguous_unqualified_reference"}, + {"file": "reject_forward_only.hpp", "outcome": "reject", "rule": "forward_declaration_only"}, + {"file": "reject_public_name_collision.hpp", "outcome": "reject", "rule": "same_final_public_name_collision"} + ] +} diff --git a/tests/fixtures/v3_cpp_resolution/reject_alias_signature.hpp b/tests/fixtures/v3_cpp_resolution/reject_alias_signature.hpp new file mode 100644 index 0000000..5497466 --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/reject_alias_signature.hpp @@ -0,0 +1,12 @@ +#include + +namespace ink { +// @SupernotePluginObject +class Stroke {}; +} + +using StrokeAlias = ink::Stroke; + +// @SupernotePluginExport +std::shared_ptr copy_stroke( + const std::shared_ptr& stroke); diff --git a/tests/fixtures/v3_cpp_resolution/reject_ambiguous_unqualified.hpp b/tests/fixtures/v3_cpp_resolution/reject_ambiguous_unqualified.hpp new file mode 100644 index 0000000..d89e5fe --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/reject_ambiguous_unqualified.hpp @@ -0,0 +1,16 @@ +#include + +namespace ink_a { +// @SupernotePluginObject +class Stroke {}; +} +namespace ink_b { +// @SupernotePluginObject +class Stroke {}; +} + +using namespace ink_a; +using namespace ink_b; + +// @SupernotePluginExport +std::shared_ptr ambiguous_stroke(); diff --git a/tests/fixtures/v3_cpp_resolution/reject_anonymous_namespace.hpp b/tests/fixtures/v3_cpp_resolution/reject_anonymous_namespace.hpp new file mode 100644 index 0000000..f7a390c --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/reject_anonymous_namespace.hpp @@ -0,0 +1,4 @@ +namespace { +// @SupernotePluginObject +class Stroke {}; +} diff --git a/tests/fixtures/v3_cpp_resolution/reject_forward_only.hpp b/tests/fixtures/v3_cpp_resolution/reject_forward_only.hpp new file mode 100644 index 0000000..a238a70 --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/reject_forward_only.hpp @@ -0,0 +1,2 @@ +// @SupernotePluginObject +class Stroke; diff --git a/tests/fixtures/v3_cpp_resolution/reject_marked_alias.hpp b/tests/fixtures/v3_cpp_resolution/reject_marked_alias.hpp new file mode 100644 index 0000000..159b5b6 --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/reject_marked_alias.hpp @@ -0,0 +1,6 @@ +namespace ink { +class Stroke {}; +} + +// @SupernotePluginObject +using StrokeAlias = ink::Stroke; diff --git a/tests/fixtures/v3_cpp_resolution/reject_nested_type.hpp b/tests/fixtures/v3_cpp_resolution/reject_nested_type.hpp new file mode 100644 index 0000000..8c1c55a --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/reject_nested_type.hpp @@ -0,0 +1,5 @@ +class Owner { + public: + // @SupernotePluginObject + class Stroke {}; +}; diff --git a/tests/fixtures/v3_cpp_resolution/reject_public_name_collision.hpp b/tests/fixtures/v3_cpp_resolution/reject_public_name_collision.hpp new file mode 100644 index 0000000..87f436a --- /dev/null +++ b/tests/fixtures/v3_cpp_resolution/reject_public_name_collision.hpp @@ -0,0 +1,8 @@ +namespace ink_a { +// @SupernotePluginObject +class Stroke {}; +} +namespace ink_b { +// @SupernotePluginObject +class Stroke {}; +} diff --git a/tests/fixtures/v3_typescript/consumer.ts b/tests/fixtures/v3_typescript/consumer.ts new file mode 100644 index 0000000..58bc552 --- /dev/null +++ b/tests/fixtures/v3_typescript/consumer.ts @@ -0,0 +1,68 @@ +import feature, { + Color, + OtherStroke, + Point, + Receipt, + Stroke, + getFeatureStatus, + isFeatureAvailable, + isSupernoteRangeError, + isSupernoteTypeError, + nativeObjectInfo, +} from "./index"; + +const point: Point = { x: 1, tags: ["ink", null], color: "RED" }; +point.x = 2; +const color: Color = point.color; +const stroke = feature.Stroke.create(point); +stroke.label = color; +const id: bigint = stroke.id; +const transformed: Promise = stroke.transform(point); +const maybe: Stroke[] | null = feature.maybe([stroke, null]); +const receipt: Receipt = feature.load(); +const status: string = receipt.status(); +const version: string = feature.Tools.version(); +const acceptsPoint: boolean = feature.Stroke.create.accepts(point); +const checked = feature.maybe.checkArguments([stroke, null]); +if (!checked.ok && (isSupernoteTypeError(checked.error) || isSupernoteRangeError(checked.error))) { + const path: string = checked.error.path; + void path; +} +const unknownValue: unknown = stroke; +if (feature.Receipt.is(unknownValue)) { + unknownValue.status(); +} +const statusValue = getFeatureStatus(); +const available: boolean = isFeatureAvailable(); +const info = nativeObjectInfo(stroke); +if (info) { + const family: "cpp" | "jvm" = info.originFamily; + void family; +} + +// @ts-expect-error native fields mirror source read-only state +stroke.id = 2n; + +// @ts-expect-error native objects are nominal and cannot be object literals +const fakeStroke: Stroke = { id: 1n, label: "fake", transform: async () => stroke }; + +// @ts-expect-error distinct native object declarations are not assignable +const wrongObject: OtherStroke = stroke; + +// @ts-expect-error nullability is explicit +feature.maybe([undefined]); + +// @ts-expect-error enum values are the declared string literals only +const wrongColor: Color = "GREEN"; + +void transformed; +void maybe; +void id; +void status; +void version; +void acceptsPoint; +void statusValue; +void available; +void fakeStroke; +void wrongObject; +void wrongColor; diff --git a/tests/fixtures/v3_typescript/index.d.ts b/tests/fixtures/v3_typescript/index.d.ts new file mode 100644 index 0000000..745dc56 --- /dev/null +++ b/tests/fixtures/v3_typescript/index.d.ts @@ -0,0 +1,112 @@ +/* Generated by supernote_module_generator. Do not edit. */ +export type SupernoteErrorCode = + | 'RESOURCE_EXHAUSTED' + | 'CANCELLED' + | 'FEATURE_CLOSED' + | 'IMPLEMENTATION_ERROR' + | 'INTERNAL'; + +export class SupernoteError extends Error { + readonly code: SupernoteErrorCode; +} + +export type SupernoteValidationReason = + | 'ARITY_MISMATCH' + | 'TYPE_MISMATCH' + | 'NOMINAL_MISMATCH' + | 'MISSING_FIELD' + | 'INVALID_ENUM' + | 'OUT_OF_RANGE' + | 'LIMIT_EXCEEDED'; + +export interface SupernoteValidationDetails { + readonly reason: SupernoteValidationReason; + readonly path: string; + readonly expected: string; + readonly actual: string; +} + +export type SupernoteTypeError = TypeError & SupernoteValidationDetails; +export type SupernoteRangeError = RangeError & SupernoteValidationDetails; +export type SupernoteValidationResult = + | {readonly ok: true} + | {readonly ok: false; readonly error: SupernoteTypeError | SupernoteRangeError}; + +export interface SupernoteCallable { + (...args: Arguments): Result; + accepts(...args: unknown[]): boolean; + checkArguments(...args: unknown[]): SupernoteValidationResult; +} + +export interface SupernoteTypeCompanion { + is(value: unknown): value is Value; + check(value: unknown): SupernoteValidationResult; +} + +export type SupernoteFeatureStatus = + | 'available' + | 'runtime-unavailable' + | 'feature-unavailable'; + +export interface SupernoteNativeObjectInfo { + readonly type: string; + readonly originFamily: 'cpp' | 'jvm'; +} + +export function isFeatureAvailable(): boolean; +export function getFeatureStatus(): SupernoteFeatureStatus; +export function nativeObjectInfo(value: unknown): SupernoteNativeObjectInfo | undefined; +export function isSupernoteTypeError(value: unknown): value is SupernoteTypeError; +export function isSupernoteRangeError(value: unknown): value is SupernoteRangeError; + +export type Color = 'RED' | 'BLUE'; + +declare const __supernoteBrand_OtherStroke: unique symbol; +export interface OtherStroke { + readonly [__supernoteBrand_OtherStroke]: void; +} + +export interface Point { + x: number; + tags: (string | null)[]; + color: Color; +} + +declare const __supernoteBrand_Receipt: unique symbol; +export interface Receipt { + readonly [__supernoteBrand_Receipt]: void; + status: SupernoteCallable<[], string>; +} + +declare const __supernoteBrand_Stroke: unique symbol; +export interface Stroke { + readonly [__supernoteBrand_Stroke]: void; + readonly id: bigint; + label: string; + transform: SupernoteCallable<[offset: Point], Promise>; +} + +declare const __supernoteBrand_Tools: unique symbol; +export interface Tools { + readonly [__supernoteBrand_Tools]: void; +} + +export interface DrawingFeature { + Color: SupernoteTypeCompanion; + OtherStroke: SupernoteTypeCompanion; + Point: SupernoteTypeCompanion; + Receipt: SupernoteTypeCompanion; + Stroke: SupernoteTypeCompanion & { + create: SupernoteCallable<[point: Point], Stroke>; + fromPoints: SupernoteCallable<[points: Point[]], Stroke>; + }; + Tools: SupernoteTypeCompanion & { + version: SupernoteCallable<[], string>; + }; + load: SupernoteCallable<[], Receipt>; + maybe: SupernoteCallable<[strokes: (Stroke | null)[]], Stroke[] | null>; + useOther: SupernoteCallable<[other: OtherStroke], void>; +} + +declare const feature: DrawingFeature; +export default feature; diff --git a/tests/test_binding_codegen.py b/tests/test_binding_codegen.py index 16c8c20..bd63472 100644 --- a/tests/test_binding_codegen.py +++ b/tests/test_binding_codegen.py @@ -15,6 +15,10 @@ ) from supernote_module_generator.source_models import SupernoteMarker +V3_LEGACY_CLASS_MARKER_REMOVED = ( + "superseded by the V3 Object/Value marker contract; concrete C++ " + "object-route coverage returns in Phase 5" +) class BindingCodegenScannerTests(unittest.TestCase): def make_module( @@ -75,6 +79,35 @@ def test_v2_feature_renderer_has_no_one_shot_jni_bootstrap(self): self.assertNotIn("JNI_OnLoad", source) self.assertNotIn("RegisterNatives", source) + def test_v3_feature_renderer_preserves_namespace_for_scalar_function(self): + with tempfile.TemporaryDirectory() as directory: + module = self.make_module( + Path(directory), + backend="jsi", + source=( + "#include \n" + "namespace supernote_feature_LocalTest {\n" + "// @SupernotePluginExport\n" + "std::string greet(std::string name) { return name; }\n" + "}\n" + ), + ) + source = binding_codegen.render_v2_feature_jsi( + module, + module_name="LocalTest", + feature_id="supernote:feature:0123456789abcdef", + ) + + self.assertIn( + "namespace supernote_feature_LocalTest {\n" + "std::string greet(std::string name);\n}", + source, + ) + self.assertIn( + "::supernote_feature_LocalTest::greet(", source + ) + self.assertNotIn("const auto result = greet(", source) + def test_bare_export_noexcept_and_lexer_defenses_are_supported(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module( @@ -201,9 +234,9 @@ def test_marked_pointer_and_reference_returns_report_boundary_type(self): binding_codegen.scan_cpp_semantic_model(module) message = str(raised.exception) - self.assertIn("unsupported return type", message) + self.assertIn(f"{diagnostic} as marked C++ results", message) self.assertIn( - "return one canonical V2 value type by value", + "return one canonical owned V3 type", message, ) self.assertNotIn("expected a C++ function name", message) @@ -251,7 +284,7 @@ def test_rejects_invalid_v2_free_function_marker_combinations(self): ( "alias", '// @SupernotePluginExport(name = "renamed")\n', - "initial V2 markers take no arguments", + "initial V3 markers take no arguments", ), ( "trailing-text", @@ -377,6 +410,7 @@ def test_v2_async_continuations_are_deleted_from_private_map(self): source, ) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_v2_async_object_method_retains_receiver_for_physical_work(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") @@ -420,6 +454,7 @@ class Document { "async C++ object-method lowering is recognized", source ) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_cpp_class_source_and_semantic_models_use_explicit_member_intent(self): source = """// @SupernotePluginExport class Document { @@ -465,6 +500,7 @@ class Document { self.assertFalse(document.methods[1].capabilities.javascript_public) self.assertEqual(ExecutionMode.ASYNC, document.methods[1].execution) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_cpp_class_constructor_selection_and_implicit_default(self): selected = """// @SupernotePluginExport class Document { @@ -498,6 +534,7 @@ class Document { semantic = binding_codegen.scan_cpp_semantic_model(module).classes[0] self.assertEqual((), semantic.constructor.parameters) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_cpp_class_rejects_ambiguous_or_missing_creation_paths(self): cases = ( ( @@ -533,6 +570,7 @@ class Document { ): binding_codegen.scan_cpp_semantic_model(module) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_cpp_internal_class_projects_as_feature_service(self): source = """// @SupernotePluginInternal class IndexService { @@ -554,6 +592,7 @@ class IndexService { self.assertEqual(["rebuild"], [method.name for method in service.methods]) self.assertFalse(service.methods[0].capabilities.javascript_public) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_cpp_class_rejects_invalid_marked_members_and_containment(self): cases = ( ( @@ -627,6 +666,7 @@ class Service { ): binding_codegen.scan_cpp_semantic_model(module) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_cpp_class_and_member_marker_targets_fail_closed(self): cases = ( ( @@ -711,6 +751,7 @@ class Document { ): binding_codegen.scan_cpp_semantic_model(module) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_v2_sync_class_lowers_to_retained_hostobject_machinery(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") @@ -721,6 +762,7 @@ def test_v2_sync_class_lowers_to_retained_hostobject_machinery(self): objects = binding_codegen.scan_bindings(module).objects self.assertEqual(["Page"], [item.js_name for item in objects]) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_lowering_fails_closed_for_routes_not_implemented_yet(self): cases = ( ( @@ -822,11 +864,11 @@ def test_rejects_prefixes_before_and_after_marker(self): def test_rejects_markers_in_c_headers_and_helper_suffixes(self): cases = { - ".c": "direct marked C bindings are unsupported in initial V2", - ".h": "class marker stack must be followed by a complete class", - ".hh": "class marker stack must be followed by a complete class", - ".hpp": "class marker stack must be followed by a complete class", - ".hxx": "class marker stack must be followed by a complete class", + ".c": "direct marked C bindings are unsupported in initial V3", + ".h": "classes require exactly one of SupernotePluginObject or SupernotePluginValue", + ".hh": "classes require exactly one of SupernotePluginObject or SupernotePluginValue", + ".hpp": "classes require exactly one of SupernotePluginObject or SupernotePluginValue", + ".hxx": "classes require exactly one of SupernotePluginObject or SupernotePluginValue", ".inl": "allowed only in .cc, .cpp, or .cxx", ".inc": "allowed only in .cc, .cpp, or .cxx", ".ipp": "allowed only in .cc, .cpp, or .cxx", @@ -1027,6 +1069,7 @@ def test_jsi_initial_numeric_and_bytes_types_generate_checked_conversions(self): self.assertIn("supernote_throw_type_error", generated) self.assertIn("supernote_throw_range_error", generated) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_jsi_hostobject_uses_initial_numeric_and_bytes_conversions(self): source = """#include #include @@ -1244,6 +1287,7 @@ def test_jni_exception_messages_use_real_utf8_java_strings(self): generated, ) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_jsi_object_scans_constructor_methods_and_access_control(self): source = """// @SupernotePluginExport class Counter { @@ -1284,6 +1328,7 @@ class Counter { self.assertTrue(item.methods[1].noexcept) self.assertTrue(item.methods[2].noexcept) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_jsi_object_uses_source_struct_name_and_zero_argument_constructor(self): source = """// @SupernotePluginExport struct NativeDocument { @@ -1303,6 +1348,7 @@ def test_jsi_object_uses_source_struct_name_and_zero_argument_constructor(self): self.assertEqual((), item.constructor.parameters) self.assertEqual(["pageCount"], [method.js_name for method in item.methods]) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_class_default_private_and_struct_default_public(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") @@ -1330,15 +1376,16 @@ class PrivateByDefault { self.assertEqual(1, len(objects[0].constructor.parameters)) self.assertEqual(0, len(objects[1].constructor.parameters)) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_rejects_unsupported_public_method_and_static_method(self): cases = { "unsupported-return": ( "int unsupported();", - "marked method must use one canonical V2 result type", + "marked method must use one canonical V3 result type", ), "unsupported-parameter": ( "double evaluate(int value);", - "argument 1 must use one named canonical V2 value type", + "argument 1 must use one named canonical V3 value type", ), "static": ( "static double evaluate();", @@ -1358,6 +1405,7 @@ def test_object_rejects_unsupported_public_method_and_static_method(self): with self.assertRaisesRegex(binding_codegen.CodegenError, diagnostic): binding_codegen.scan_bindings(module) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_rejects_method_and_constructor_overloads(self): cases = { "method": ( @@ -1383,6 +1431,7 @@ def test_object_rejects_method_and_constructor_overloads(self): with self.assertRaisesRegex(binding_codegen.CodegenError, diagnostic): binding_codegen.scan_bindings(module) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_export_name_collisions_are_rejected(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") @@ -1416,6 +1465,7 @@ class Thing { public: Thing(); }; ): binding_codegen.scan_bindings(module) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_typescript_factory_name_collision_is_rejected(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") @@ -1448,10 +1498,11 @@ class NativeCounter { public: NativeCounter(); }; ) with self.assertRaisesRegex( binding_codegen.CodegenError, - "SupernoteExportObject is removed in V2", + "SupernoteExportObject is removed in V3", ): binding_codegen.scan_bindings(module) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_typescript_module_interface_collision_is_rejected(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") @@ -1468,6 +1519,7 @@ class LocalTestModule { public: LocalTestModule(); }; self.assertIn("generated module interface 'LocalTestModule'", message) self.assertIn("export 'LocalTestModule'", message) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_generated_object_header_includes_are_unique(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") @@ -1495,6 +1547,7 @@ class Third { public: Third(); }; self.assertEqual(1, generated.count('#include "model/Objects.hpp"')) self.assertEqual(1, generated.count('#include "other/Third.hh"')) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_annotation_location_backend_and_malformed_diagnostics(self): cases = ( ( @@ -1546,6 +1599,7 @@ def test_object_marker_lexer_defenses_and_conditional_diagnostic(self): ): binding_codegen.scan_bindings(module) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_rejects_templates_inheritance_and_nested_exports(self): cases = { "template": ( @@ -1571,6 +1625,7 @@ def test_object_rejects_templates_inheritance_and_nested_exports(self): with self.assertRaisesRegex(binding_codegen.CodegenError, diagnostic): binding_codegen.scan_bindings(module) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_ignores_destructor_copy_constructor_and_public_fields(self): source = """// @SupernotePluginExport class Example { @@ -1591,6 +1646,7 @@ class Example { self.assertEqual((), item.constructor.parameters) self.assertEqual(["value"], [method.js_name for method in item.methods]) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_constructor_containing_class_name_is_not_mistaken_for_copy(self): source = """// @SupernotePluginExport class Example { @@ -1614,10 +1670,14 @@ def test_free_function_annotation_in_header_remains_rejected(self): ) with self.assertRaisesRegex( binding_codegen.CodegenError, - re.escape("class marker stack must be followed by a class"), + re.escape( + "classes require exactly one of SupernotePluginObject or " + "SupernotePluginValue" + ), ): binding_codegen.scan_bindings(module) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_manifest_typescript_hostobject_and_lifetime_generation(self): source = """#pragma once #include @@ -1674,6 +1734,7 @@ class Counter { with self.assertRaisesRegex(binding_codegen.CodegenError, "generated bindings are stale"): binding_codegen.generate(module, check=True) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_selected_constructor_drives_generated_factory(self): source = """#include // @SupernotePluginExport @@ -1707,6 +1768,7 @@ def test_modules_without_objects_emit_empty_manifest_array(self): manifest = json.loads((module / "android/build/generated/supernote/exports.json").read_text()) self.assertEqual([], manifest["objects"]) + @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_cli_summary_counts_native_objects_separately(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi", source="") diff --git a/tests/test_doctor_spec.py b/tests/test_doctor_spec.py index 85d400c..0a4b9bc 100644 --- a/tests/test_doctor_spec.py +++ b/tests/test_doctor_spec.py @@ -7,6 +7,9 @@ from pathlib import Path from supernote_module_generator.doctor import DoctorService +from supernote_module_generator.feature_generator import FeatureConfig +from supernote_module_generator.feature_model import StarterFamily +from supernote_module_generator.feature_operations import FeatureOperationService from supernote_module_generator.rendering import Renderer, TerminalCapabilities @@ -104,6 +107,53 @@ def test_doctor_executes_required_probes_and_keeps_selinux_advisory( assert result.doctor.advisory_count >= 1 +def test_doctor_passes_for_plugin_with_typed_cpp_jvm_and_mixed_v3_features( + tmp_path: Path, monkeypatch +): + root = plugin(tmp_path) + (root / "android/app").mkdir() + (root / "android/app/build.gradle").write_text("plugins {}\n", encoding="utf-8") + features = FeatureOperationService(root) + for name, starters in ( + ("typed-cpp", (StarterFamily.NATIVE,)), + ("typed-jvm", (StarterFamily.JVM,)), + ("typed-mixed", (StarterFamily.NATIVE, StarterFamily.JVM)), + ): + features.add( + FeatureConfig( + output=root / "local_modules" / name, + npm_name=name, + package_version="0.1.0", + android_namespace=f"com.example.{name.replace('-', '_')}", + public_name="".join(part.title() for part in name.split("-")), + starters=starters, + ) + ) + typed_cpp = root / "local_modules/typed-cpp/android/src/main/cpp/Typed.hpp" + typed_cpp.write_text( + "// @SupernotePluginValue\n" + "struct Point {\n" + " // @SupernotePluginExport\n" + " double x;\n" + "};\n", + encoding="utf-8", + ) + features.update("typed-cpp") + + install_fake_sdk(tmp_path, monkeypatch) + monkeypatch.setattr( + "supernote_module_generator.doctor.shutil.which", + lambda name: f"/tools/{name}", + ) + + result = DoctorService(root, renderer(), run=successful_run).execute("plugin") + + assert result.exit_code == 0 + assert result.doctor is not None + assert result.doctor.required_passed + assert len(features.records()) == 3 + + def test_windows_doctor_uses_batch_wrapper_and_exe_ndk_compilers( tmp_path: Path, monkeypatch ): diff --git a/tests/test_documentation.py b/tests/test_documentation.py index ae39427..c6cab82 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -147,7 +147,7 @@ def test_wiki_links_use_known_task_pages(): assert page in WIKI_PAGES -def test_root_readme_explains_the_v2_public_model(): +def test_root_readme_explains_the_v3_public_model(): readme = (ROOT / "README.md").read_text(encoding="utf-8") opening = "\n".join(readme.splitlines()[:12]) opening_words = " ".join(opening.split()) @@ -160,11 +160,52 @@ def test_root_readme_explains_the_v2_public_model(): assert "--type native" not in readme assert "SupernotePluginInternal" in readme assert "SupernotePluginAsync" in readme + assert "SupernotePluginObject" in readme + assert "SupernotePluginValue" in readme + assert "returned-only objects" in readme + assert "homogeneous array" in readme + assert "nullable `T`" in readme + assert "does not generate C++/JVM native-object proxies" in readme + assert "no V2 users or migration requirements" in readme assert "C23" in readme and "C++23" in readme assert "--delete-build-files" in readme assert "managed non-JS context" in " ".join(readme.split()) assert "https://docs.supernote.com/" in readme - assert len(readme.splitlines()) < 240 + assert len(readme.splitlines()) < 280 + + +def test_generated_v3_feature_readme_covers_object_value_and_deferral_contracts( + tmp_path: Path, +): + from supernote_module_generator.feature_generator import ( + FeatureConfig, + stage_feature, + ) + from supernote_module_generator.feature_model import StarterFamily + + feature = stage_feature( + FeatureConfig( + output=tmp_path / "typed-feature", + npm_name="typed-feature", + package_version="3.0.0.dev0", + public_name="TypedFeature", + android_namespace="com.example.typed_feature", + starters=(StarterFamily.NATIVE, StarterFamily.JVM), + ) + ) + readme = (feature / "README.md").read_text(encoding="utf-8") + + for contract in ( + "SupernotePluginObject", + "SupernotePluginValue", + "SupernoteConstructor", + "Returned-only objects", + "arrays, and nullable", + "Cross-family native-object proxies", + "structured", + "thread safety", + ): + assert contract in readme @pytest.mark.parametrize( @@ -266,11 +307,13 @@ def test_native_initial_declaration_uses_the_configured_interface_name(tmp_path: assert "$MODULE" not in declarations -def test_repository_docs_contain_architectural_history_not_migration_tooling(): - history = (ROOT / "docs/V1-TO-V2-ARCHITECTURE.md").read_text() - assert "architectural history" in history - assert "not a converter guide" in history - assert "automatic converter" in history +def test_repository_docs_define_v3_architecture_without_migration_tooling(): + architecture = (ROOT / "docs/V3-ARCHITECTURE.md").read_text() + assert "V3 architecture" in architecture + assert "not a V2" in architecture + assert "automatic converter" in architecture + assert "Cross-family object proxies" in architecture + assert not (ROOT / "docs/V1-TO-V2-ARCHITECTURE.md").exists() assert not (ROOT / "docs/Add-a-Feature.md").exists() assert not (ROOT / "UX_REDESIGN_SPECIFICATION.md").exists() assert not (ROOT / "PYPI_README.md").exists() @@ -279,7 +322,7 @@ def test_repository_docs_contain_architectural_history_not_migration_tooling(): ) -def test_release_guide_uses_the_v2_feature_model(): +def test_release_guide_uses_the_language_neutral_feature_model(): guide = (ROOT / "maintainers/releasing.md").read_text(encoding="utf-8") assert "C/C++ starter" in guide assert "Kotlin/Java starter" in guide diff --git a/tests/test_feature_generator.py b/tests/test_feature_generator.py index 1c612c6..ca88bcf 100644 --- a/tests/test_feature_generator.py +++ b/tests/test_feature_generator.py @@ -90,12 +90,17 @@ def test_feature_package_uses_shared_runtime_proxy_and_no_native_package(tmp_pat index = (feature / "index.js").read_text() package = json.loads((feature / "package.json").read_text()) - assert "globalThis.__supernoteV2" in index + assert "globalThis.__supernoteV3" in index assert index.startswith("/* global globalThis */\n") assert "if (property === ERROR_CONSTRUCTOR_PROPERTY) return" not in index assert "{...descriptor, configurable: true}" in index assert '"supernote:feature:' not in index assert "runtime.feature(" in index + assert "export function isFeatureAvailable()" in index + assert "export function getFeatureStatus()" in index + assert "export function nativeObjectInfo(value)" in index + assert "__supernoteCppObjectInfo" in index + assert "__supernoteJvmObjectInfo" in index assert "new Proxy(" in index assert package["main"] == "index.js" assert "react-native" not in package @@ -117,6 +122,14 @@ def test_feature_package_imports_before_runtime_install_and_resolves_lazily( script = f""" const generated = await import('data:text/javascript;base64,{encoded}'); +if (generated.getFeatureStatus() !== 'runtime-unavailable' || + generated.isFeatureAvailable()) {{ + throw new Error('missing runtime availability was reported incorrectly'); +}} +if (generated.nativeObjectInfo({{}}) !== undefined) {{ + throw new Error('object inspection should be absent without a runtime'); +}} + let earlyError; try {{ generated.default.greet; @@ -124,12 +137,21 @@ def test_feature_package_imports_before_runtime_install_and_resolves_lazily( earlyError = error; }} if (!earlyError || earlyError.message !== - 'Document is not installed in the Supernote V2 runtime') {{ + 'Document is not installed in the Supernote V3 runtime') {{ throw new Error(`unexpected early-access result: ${{earlyError}}`); }} -const first = {{firstOnly: 1, greet: name => `first:${{name}}`}}; -globalThis.__supernoteV2 = {{ +const nativeValue = {{native: true}}; +const first = {{ + firstOnly: 1, + greet: name => `first:${{name}}`, + __supernoteCppObjectInfo(value) {{ + return value === nativeValue + ? {{type: 'Stroke', originFamily: 'cpp'}} + : undefined; + }}, +}}; +globalThis.__supernoteV3 = {{ feature(id) {{ if (id !== {json.dumps(feature_id)}) throw new Error(`wrong id: ${{id}}`); return first; @@ -138,6 +160,14 @@ def test_feature_package_imports_before_runtime_install_and_resolves_lazily( if (generated.default.greet('Ada') !== 'first:Ada') {{ throw new Error('feature did not resolve after runtime installation'); }} +if (!generated.isFeatureAvailable() || + generated.getFeatureStatus() !== 'available') {{ + throw new Error('installed feature availability was reported incorrectly'); +}} +const info = generated.nativeObjectInfo(nativeValue); +if (!info || info.type !== 'Stroke' || info.originFamily !== 'cpp') {{ + throw new Error(`unexpected native object information: ${{JSON.stringify(info)}}`); +}} if (first.__supernoteErrorConstructor !== generated.SupernoteError) {{ throw new Error('SupernoteError constructor was not installed on the feature'); }} @@ -158,6 +188,11 @@ def test_feature_package_imports_before_runtime_install_and_resolves_lazily( if (firstKeys.includes('__supernoteErrorConstructor')) {{ throw new Error('internal error constructor leaked through feature keys'); }} +if (firstKeys.includes('__supernoteCppObjectInfo') || + '__supernoteCppObjectInfo' in generated.default || + generated.default.__supernoteCppObjectInfo !== undefined) {{ + throw new Error('internal object inspector leaked through the feature proxy'); +}} const greetDescriptor = Object.getOwnPropertyDescriptor( generated.default, 'greet', @@ -176,7 +211,7 @@ def test_feature_package_imports_before_runtime_install_and_resolves_lazily( }} const second = {{greet: name => `second:${{name}}`, secondOnly: 2}}; -globalThis.__supernoteV2 = {{feature: () => second}}; +globalThis.__supernoteV3 = {{feature: () => second}}; if (generated.default.greet('Ada') !== 'second:Ada') {{ throw new Error('feature wrapper retained a stale runtime binding'); }} @@ -190,6 +225,19 @@ def test_feature_package_imports_before_runtime_install_and_resolves_lazily( if (second.__supernoteErrorConstructor !== generated.SupernoteError) {{ throw new Error('SupernoteError constructor was not installed on replacement'); }} + +const typeError = new TypeError('bad'); +Object.assign(typeError, {{ + reason: 'TYPE_MISMATCH', + path: 'Drawing.stroke', + expected: 'Stroke', + actual: 'object', +}}); +if (!generated.isSupernoteTypeError(typeError) || + generated.isSupernoteRangeError(typeError) || + generated.isSupernoteTypeError(new TypeError('plain'))) {{ + throw new Error('validation error guards returned the wrong result'); +}} """ result = subprocess.run( [node, "--input-type=module", "--eval", script], diff --git a/tests/test_feature_metadata_diagnostics.py b/tests/test_feature_metadata_diagnostics.py index de586c0..153de62 100644 --- a/tests/test_feature_metadata_diagnostics.py +++ b/tests/test_feature_metadata_diagnostics.py @@ -52,7 +52,7 @@ def _feature(root: Path) -> Path: [ ("{", "invalid JSON at line 1"), ({"schema_version": 99}, "unsupported feature manifest schema 99"), - ({"kind": "something_else"}, "kind must be 'supernote_feature'"), + ({"kind": "something_else"}, "kind must be 'supernote_v3_feature'"), ({"public_name": None}, "public_name must be a non-empty string"), ], ) diff --git a/tests/test_feature_model.py b/tests/test_feature_model.py index bd17f56..aa7eb12 100644 --- a/tests/test_feature_model.py +++ b/tests/test_feature_model.py @@ -3,6 +3,7 @@ import pytest from supernote_module_generator.feature_model import ( + FEATURE_MANIFEST_KIND, FEATURE_MANIFEST_SCHEMA_VERSION, FeatureManifest, FeatureModelError, @@ -56,7 +57,7 @@ def test_feature_manifest_is_language_neutral_and_starters_are_bookkeeping(): value = manifest.manifest() assert value["schema_version"] == FEATURE_MANIFEST_SCHEMA_VERSION - assert value["kind"] == "supernote_feature" + assert value["kind"] == FEATURE_MANIFEST_KIND assert value["implementation_roots"] == { "native": "android/src/main/cpp", "jvm": "android/src/main/java", @@ -114,12 +115,12 @@ def test_plugin_registry_has_one_stable_component_and_deterministic_order(): ) left = PluginRuntimeRegistry.create( plugin_id="com.example.plugin", - generator_version="2.0.0.dev0", + generator_version="3.0.0.dev0", features=(second, first), ) right = PluginRuntimeRegistry.create( plugin_id="com.example.plugin", - generator_version="2.0.0.dev0", + generator_version="3.0.0.dev0", features=(first, second), ) @@ -135,10 +136,10 @@ def test_removing_one_registry_entry_preserves_component_and_other_feature(): alpha = FeatureRegistryEntry.create(feature("alpha"), SemanticApi()) beta = FeatureRegistryEntry.create(feature("beta"), SemanticApi()) full = PluginRuntimeRegistry.create( - plugin_id="plugin", generator_version="2.0.0.dev0", features=(alpha, beta) + plugin_id="plugin", generator_version="3.0.0.dev0", features=(alpha, beta) ) reduced = PluginRuntimeRegistry.create( - plugin_id="plugin", generator_version="2.0.0.dev0", features=(beta,) + plugin_id="plugin", generator_version="3.0.0.dev0", features=(beta,) ) assert full.component_name == reduced.component_name @@ -167,6 +168,6 @@ def test_registry_rejects_duplicate_feature_public_identity(): with pytest.raises(FeatureModelError, match="duplicate feature public name"): PluginRuntimeRegistry.create( plugin_id="plugin", - generator_version="2.0.0.dev0", + generator_version="3.0.0.dev0", features=(alpha, duplicate), ) diff --git a/tests/test_feature_operations.py b/tests/test_feature_operations.py index 6ed1b65..dc8a723 100644 --- a/tests/test_feature_operations.py +++ b/tests/test_feature_operations.py @@ -31,7 +31,7 @@ def registry(root: Path) -> dict: return json.loads( ( root - / "android/.supernote-module/v2-runtime/feature-registry.json" + / "android/.supernote-module/v3-runtime/feature-registry.json" ).read_text() ) @@ -54,6 +54,12 @@ def test_add_update_remove_regenerate_one_shared_registry(tmp_path: Path): assert (alpha / "android/src/main/cpp/custom.cpp").is_file() assert registry(root)["component_name"] == component + alpha_source = (alpha / "android/src/main/cpp/feature.cpp").read_text() + beta_source = (beta / "android/src/main/cpp/feature.cpp").read_text() + assert "namespace supernote_feature_Alpha" in alpha_source + assert "namespace supernote_feature_Beta" in beta_source + assert "namespace supernote_feature_Beta" not in alpha_source + service.remove("alpha") assert not alpha.exists() assert beta.exists() @@ -72,7 +78,7 @@ def test_jvm_only_feature_is_scaffolded_for_ksp_without_python_source_parsing( assert created == jvm.output assert not (created / "android/src/main/cpp").exists() assert (created / "android/src/main/java/com/example/jvm/FeatureApi.kt").is_file() - gradle = (root / "android/.supernote-module/v2-runtime/build.gradle").read_text() + gradle = (root / "android/.supernote-module/v3-runtime/build.gradle").read_text() assert "local_modules/jvm/android/src/main/java" in gradle assert "com.google.devtools.ksp" in gradle @@ -81,16 +87,16 @@ def test_removing_last_feature_removes_shared_component_and_wiring(tmp_path: Pat root = plugin(tmp_path) service = FeatureOperationService(root) service.add(config(root, "only")) - assert "supernote-v2-runtime" in ( + assert "supernote-v3-runtime" in ( root / "android/settings.gradle" ).read_text() service.remove("only") - assert not (root / "android/.supernote-module/v2-runtime").exists() - assert "supernote-v2-runtime" not in ( + assert not (root / "android/.supernote-module/v3-runtime").exists() + assert "supernote-v3-runtime" not in ( root / "android/settings.gradle" ).read_text() - assert "supernote-v2-runtime" not in ( + assert "supernote-v3-runtime" not in ( root / "android/app/build.gradle" ).read_text() diff --git a/tests/test_generator.py b/tests/test_generator.py index e545995..d70c08a 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -84,11 +84,16 @@ def test_native_codegen_is_self_contained_and_checkable(tmp_path): codegen_root = module / "android/.supernote-module" support_root = codegen_root / "supernote_codegen" expected_support = { - "__init__.py", - "cpp_projection.py", + "__init__.py", + "conversion.py", + "cpp_projection.py", + "cpp_routes.py", + "cpp_object_binding_codegen.py", "lowering.py", "semantic.py", + "semantic_types.py", "source_models.py", + "v3_schemas.py", } assert {path.name for path in support_root.iterdir()} == expected_support diff --git a/tests/test_internal_codegen.py b/tests/test_internal_codegen.py index c5b1b1d..b4c1c60 100644 --- a/tests/test_internal_codegen.py +++ b/tests/test_internal_codegen.py @@ -20,7 +20,6 @@ def module(tmp_path: Path) -> Path: (source / "internal.hpp").write_text( """#pragma once #include -// @SupernotePluginInternal class IndexService { public: IndexService(); @@ -45,7 +44,10 @@ class IndexService { def test_cpp_internal_facade_is_typed_hidden_and_feature_scoped(tmp_path: Path): root = module(tmp_path) header, source = render_cpp_internal_facade( - root, module_name="Documents", feature_id=FEATURE_ID + root, + module_name="Documents", + feature_id=FEATURE_ID, + include_prefix="documents/android/src/main/cpp", ) assert internal_header_path(FEATURE_ID) == ( @@ -55,11 +57,13 @@ def test_cpp_internal_facade_is_typed_hidden_and_feature_scoped(tmp_path: Path): assert "std::int32_t pageCount(std::int32_t offset);" in header assert "std::function)>" in header assert "struct IndexService final" in header + assert '#include "documents/android/src/main/cpp/internal.hpp"' in source assert "current_feature_session()" in source assert 'feature->service<::IndexService>' in source assert "process_services().workers().submit" in source assert "claim_internal_completion" in source assert "feature->accept({}, std::move(callback))" in source + assert "operation->set_retained_state(retained_input_state)" in source assert "operation->take_internal_completion()" in source worker_capture = source[ source.index("process_services().workers().submit") : diff --git a/tests/test_jvm_manifest_projection.py b/tests/test_jvm_manifest_projection.py index a6d9ccc..742d297 100644 --- a/tests/test_jvm_manifest_projection.py +++ b/tests/test_jvm_manifest_projection.py @@ -1,4 +1,6 @@ +import copy import json +import random from pathlib import Path import pytest @@ -43,6 +45,12 @@ FEATURE_ID = "supernote:feature:0123456789abcdef" +LEGACY_CLASS_MARKER_REMOVED = pytest.mark.skip( + reason=( + "superseded by the V3 Object/Value marker contract; concrete JVM " + "object-route coverage returns in Phase 6" + ) +) def provenance(identity: str, language: JvmLanguage, path: str, line: int): @@ -185,6 +193,53 @@ def test_manifest_round_trip_is_deterministic_versioned_and_backend_specific( assert "jsi" not in json.dumps(raw).lower() +def test_seeded_manifest_mutation_fuzz_rejects_every_invalid_shape(tmp_path: Path): + seed = 0x4A56_4D33 + rng = random.Random(seed) + baseline = JvmSourceManifest( + FEATURE_ID, "2.0.0.dev0", (ordinary_kotlin_owner(),) + ).manifest() + path = tmp_path / "fuzzed-jvm-source.json" + + for iteration in range(2_048): + raw = copy.deepcopy(baseline) + mutation = rng.randrange(12) + if mutation == 0: + raw = [] + elif mutation == 1: + raw.pop("kind") + elif mutation == 2: + raw["unexpected"] = iteration + elif mutation == 3: + raw["schema_version"] = "3" + elif mutation == 4: + raw["kind"] = "guessed-kind" + elif mutation == 5: + raw["feature_id"] = "wrong-feature" + elif mutation == 6: + raw["frontend_version"] = "" + elif mutation == 7: + raw["owners"] = {} + elif mutation == 8: + raw["owners"][0]["unexpected"] = True + elif mutation == 9: + raw["owners"][0]["language"] = "scala" + elif mutation == 10: + raw["owners"][0]["source"]["declaration_id"] = "jvm:wrong.Owner" + else: + raw["owners"][0]["declarations"][0][ + "adapter_identity" + ] = "non-deterministic" + path.write_text(json.dumps(raw), encoding="utf-8") + try: + read_jvm_manifest(path, expected_feature_id=FEATURE_ID) + except JvmManifestError: + continue + pytest.fail( + f"seed={seed} iteration={iteration} accepted invalid manifest: {raw!r}" + ) + + def test_projection_maps_kotlin_suspend_to_common_semantics_without_losing_route_facts(): owner = ordinary_kotlin_owner() api = project_jvm_owners((owner,)) @@ -330,6 +385,7 @@ def test_kotlin_and_java_canonical_type_tables_are_exact(): } +@LEGACY_CLASS_MARKER_REMOVED def test_jvm_export_object_uses_selected_constructor_and_only_marked_members(): owner_name = "com.example.Document" first = constructor( @@ -395,6 +451,7 @@ def test_jvm_export_object_uses_selected_constructor_and_only_marked_members(): assert "std::shared_ptr owner_" in generated +@LEGACY_CLASS_MARKER_REMOVED def test_java_export_object_has_distinct_instance_and_worker_async_routes(): owner_name = "com.example.JavaDocument" selected = constructor( @@ -453,6 +510,7 @@ def test_java_export_object_has_distinct_instance_and_worker_async_routes(): assert "auto owner = owner_" in generated +@LEGACY_CLASS_MARKER_REMOVED def test_blocking_jvm_async_object_method_retains_global_receiver(): owner_name = "com.example.Document" load = declaration( @@ -492,6 +550,7 @@ def test_blocking_jvm_async_object_method_retains_global_receiver(): assert "CallStaticObjectMethodA" in generated +@LEGACY_CLASS_MARKER_REMOVED def test_suspend_jvm_object_method_retains_receiver_until_job_finishes(): owner_name = "com.example.Document" load = declaration( @@ -531,6 +590,7 @@ def test_suspend_jvm_object_method_retains_receiver_until_job_finishes(): assert "operation->set_cancel_hook" in generated +@LEGACY_CLASS_MARKER_REMOVED def test_internal_jvm_class_is_a_hidden_feature_service(): owner_name = "com.example.IndexService" method = declaration( @@ -659,6 +719,7 @@ def test_internal_jvm_functions_share_cpp_facade_across_sync_worker_and_suspend( assert "claim_internal_completion" in generated assert generated.count("feature->accept({}, std::move(callback))") == 2 assert "operation->take_internal_completion()" in generated + assert "operation->set_retained_state(retained_input_state)" in generated assert "[operation, weak_feature, callback" not in generated assert "deliver_internal_callback" in generated assert "FeatureCallScope" in generated @@ -696,10 +757,17 @@ def test_manifest_rejects_incompatible_or_guessed_boundary_data( @pytest.mark.parametrize( - "unsupported", - ["kotlin.Int?", "kotlin.collections.List", "java.lang.Integer", "java.nio.ByteBuffer"], + ("unsupported", "diagnostic"), + [ + ("kotlin.Int?", "unsupported marked"), + ("kotlin.collections.List", "List requires exactly one"), + ("java.lang.Integer", "primitive spelling"), + ("java.nio.ByteBuffer", "unsupported marked"), + ], ) -def test_projection_rejects_noncanonical_jvm_types(unsupported: str): +def test_projection_rejects_noncanonical_jvm_types( + unsupported: str, diagnostic: str +): owner = ordinary_kotlin_owner() language = ( JvmLanguage.JAVA if unsupported.startswith("java.") else JvmLanguage.KOTLIN @@ -724,7 +792,7 @@ def test_projection_rejects_noncanonical_jvm_types(unsupported: str): (), (source,), ) - with pytest.raises(JvmProjectionError, match="unsupported marked"): + with pytest.raises(JvmProjectionError, match=diagnostic): project_jvm_owners((bad_owner,)) diff --git a/tests/test_operations_spec.py b/tests/test_operations_spec.py index 915b296..15dab66 100644 --- a/tests/test_operations_spec.py +++ b/tests/test_operations_spec.py @@ -85,7 +85,7 @@ def test_add_scaffolds_selected_families_without_backend_metadata( assert (feature / "android/src/main/cpp/feature.cpp").is_file() is native kotlin = feature / "android/src/main/java/com/example/document/FeatureApi.kt" assert kotlin.is_file() is jvm - assert "supernote-v2-runtime" in (root / "android/settings.gradle").read_text() + assert "supernote-v3-runtime" in (root / "android/settings.gradle").read_text() @pytest.mark.parametrize( @@ -173,6 +173,105 @@ def test_update_preserves_both_source_roots_and_deleted_starter(tmp_path: Path): assert not starter.exists() +def test_v3_typed_cpp_jvm_and_mixed_features_complete_public_cli_lifecycle( + tmp_path: Path, make_directory_symlink +): + root = plugin(tmp_path) + configurations = { + "typed-cpp": ("cpp",), + "typed-jvm": ("kotlin",), + "typed-mixed": ("cpp", "kotlin"), + } + for name, starters in configurations.items(): + arguments = ["add", name] + for starter in starters: + arguments.extend(("--starter", starter)) + arguments.extend(("--skip-install", "--yes")) + code, _, stderr = invoke(root, arguments) + assert code == 0, stderr + + cpp_source = root / "local_modules/typed-cpp/android/src/main/cpp/Types.hpp" + cpp_source.write_text( + "// @SupernotePluginValue\n" + "struct Point {\n" + " // @SupernotePluginExport\n" + " double x;\n" + "};\n" + "// @SupernotePluginObject\n" + "class Stroke {\n" + "public:\n" + " // @SupernotePluginExport\n" + " double length() const;\n" + "};\n", + encoding="utf-8", + ) + mixed_cpp = root / "local_modules/typed-mixed/android/src/main/cpp/Types.hpp" + mixed_cpp.write_text( + "// @SupernotePluginValue\n" + "struct NativeSize {\n" + " // @SupernotePluginExport\n" + " double width;\n" + "};\n", + encoding="utf-8", + ) + jvm_source = ( + root + / "local_modules/typed-jvm/android/src/main/java/com/example/typed_jvm/Types.kt" + ) + jvm_source.write_text( + "package com.example.typed_jvm\n\n" + "import supernote.generated.annotations.SupernotePluginExport\n" + "import supernote.generated.annotations.SupernotePluginObject\n" + "import supernote.generated.annotations.SupernotePluginValue\n\n" + "@SupernotePluginValue\n" + "data class Point(@field:SupernotePluginExport val x: Double)\n\n" + "@SupernotePluginObject\n" + "class Stroke {\n" + " @SupernotePluginExport fun length(): Double = 1.0\n" + "}\n", + encoding="utf-8", + ) + mixed_jvm = ( + root + / "local_modules/typed-mixed/android/src/main/java/com/example/typed_mixed/Types.kt" + ) + mixed_jvm.write_text( + "package com.example.typed_mixed\n\n" + "import supernote.generated.annotations.SupernotePluginExport\n" + "import supernote.generated.annotations.SupernotePluginValue\n\n" + "@SupernotePluginValue\n" + "data class JvmSize(@field:SupernotePluginExport val height: Double)\n", + encoding="utf-8", + ) + owned_sources = (cpp_source, mixed_cpp, jvm_source, mixed_jvm) + source_bytes = {path: path.read_bytes() for path in owned_sources} + + for name in configurations: + code, _, stderr = invoke(root, ["update", name, "--skip-install", "--yes"]) + assert code == 0, stderr + assert {path: path.read_bytes() for path in owned_sources} == source_bytes + + links = root / "node_modules" + links.mkdir() + for name in configurations: + make_directory_symlink(links / name, root / "local_modules" / name) + code, _, stderr = invoke(root, ["validate", "--all"]) + assert code == 0, stderr + + for command in (None, "add", "update", "validate", "remove", "doctor"): + arguments = ["--help"] if command is None else ["help", command] + code, stdout, stderr = invoke(root, arguments) + assert code == 0, stderr + assert "Supernote Module Generator" in stdout + + for name in configurations: + code, _, stderr = invoke( + root, ["remove", name, "--skip-install", "--yes"] + ) + assert code == 0, stderr + assert not (root / "android/.supernote-module/v3-runtime").exists() + + @pytest.mark.parametrize("option", ["--skip-install", "--package-manager=npm"]) def test_update_rejects_dependency_options_when_refresh_is_not_required( tmp_path: Path, option: str, make_directory_symlink @@ -218,13 +317,79 @@ def test_add_postcondition_failure_rolls_back_feature_runtime_and_parent( assert code == 1 assert "structural postconditions" in stderr assert not (root / "local_modules/broken").exists() - assert not (root / "android/.supernote-module/v2-runtime").exists() + assert not (root / "android/.supernote-module/v3-runtime").exists() assert not (root / "local_modules").exists() assert not (root / "android/.supernote-module").exists() for path, content in originals.items(): assert path.read_bytes() == content +def test_add_replaces_stale_v2_generated_runtime_transactionally( + tmp_path: Path, monkeypatch +): + root = plugin(tmp_path) + legacy_runtime = root / "android/.supernote-module/v2-runtime" + legacy_runtime.mkdir(parents=True) + (legacy_runtime / "generated-proof.txt").write_text("v2\n", encoding="utf-8") + settings = root / "android/settings.gradle" + app_build = root / "android/app/build.gradle" + settings.write_text( + settings.read_text() + + "// supernote-module-v2-runtime\nlegacy settings\n" + "// end supernote-module-v2-runtime\n" + "include ':user-library'\n", + encoding="utf-8", + ) + app_build.write_text( + app_build.read_text() + + "// supernote-module-v2-runtime\nlegacy dependency\n" + "// end supernote-module-v2-runtime\n" + "dependencies { implementation project(':user-library') }\n", + encoding="utf-8", + ) + + code, _, stderr = invoke( + root, ["add", "typed", "--starter", "cpp", "--skip-install", "--yes"] + ) + + assert code == 0, stderr + assert not legacy_runtime.exists() + assert "supernote-module-v2" not in settings.read_text() + assert "supernote-module-v2" not in app_build.read_text() + assert "include ':user-library'" in settings.read_text() + assert "project(':user-library')" in app_build.read_text() + assert (root / "android/.supernote-module/v3-runtime").is_dir() + + # A later public-operation failure restores the exact old generated state. + assert invoke( + root, ["remove", "typed", "--skip-install", "--yes"] + )[0] == 0 + legacy_runtime.mkdir(parents=True) + proof = legacy_runtime / "generated-proof.txt" + proof.write_text("v2 rollback\n", encoding="utf-8") + settings.write_text( + settings.read_text() + + "// supernote-module-v2-runtime\nlegacy settings\n" + "// end supernote-module-v2-runtime\n", + encoding="utf-8", + ) + before_settings = settings.read_bytes() + before_proof = proof.read_bytes() + monkeypatch.setattr( + "supernote_module_generator.feature_operations.FeatureOperationService.verify_generated_state", + lambda self: ["forced structural failure"], + ) + + code, _, _ = invoke( + root, ["add", "broken", "--starter", "cpp", "--skip-install", "--yes"] + ) + + assert code == 1 + assert settings.read_bytes() == before_settings + assert proof.read_bytes() == before_proof + assert not (root / "android/.supernote-module/v3-runtime").exists() + + @pytest.mark.skipif( os.name == "nt", reason="POSIX fake npm; byte-decoding behavior has a platform-neutral subprocess test", @@ -284,7 +449,7 @@ def test_remove_dependency_failure_restores_feature_runtime_and_parent( ["add", "safe", "--starter", "cpp", "--skip-install", "--yes"], )[0] == 0 feature = root / "local_modules/safe" - runtime_before = (root / "android/.supernote-module/v2-runtime/feature-registry.json").read_bytes() + runtime_before = (root / "android/.supernote-module/v3-runtime/feature-registry.json").read_bytes() attempts = 0 @@ -302,7 +467,7 @@ def fail_once(self, command, *, phase): assert "forced install failure" in stderr assert feature.is_dir() assert ( - root / "android/.supernote-module/v2-runtime/feature-registry.json" + root / "android/.supernote-module/v3-runtime/feature-registry.json" ).read_bytes() == runtime_before assert json.loads((root / "package.json").read_text())["dependencies"]["safe"] @@ -343,7 +508,7 @@ def fail_dependency(self, invocation, *, phase): assert code == (130 if interrupted else 1), stderr assert application.read_bytes() == before expected_marker_count = 0 if command == "add" else 1 - assert application.read_text().count("supernote-module-v2-package") == ( + assert application.read_text().count("supernote-module-v3-package") == ( expected_marker_count * 2 ) assert not (root / ".supernote-module-transaction.json").exists() @@ -384,8 +549,8 @@ def test_empty_validation_rejects_leftover_v2_runtime_and_package_wiring( code, _, stderr = invoke(root, ["validate", "--all"]) assert code == 1 - assert "V2 runtime blocks; expected 0" in stderr - assert application.read_text().count("supernote-module-v2-package") == 2 + assert "V3 runtime blocks; expected 0" in stderr + assert application.read_text().count("supernote-module-v3-package") == 2 def test_empty_validation_rejects_leftover_package_registration_alone( @@ -402,8 +567,8 @@ def test_empty_validation_rejects_leftover_package_registration_alone( code, _, stderr = invoke(root, ["validate", "--all"]) assert code == 1 - assert "V2 package blocks; expected 0" in stderr - assert application.read_text().count("supernote-module-v2-package") == 2 + assert "V3 package blocks; expected 0" in stderr + assert application.read_text().count("supernote-module-v3-package") == 2 def test_feature_validation_rejects_missing_main_application_registration( @@ -427,8 +592,8 @@ def test_feature_validation_rejects_missing_main_application_registration( code, _, stderr = invoke(root, ["validate", "safe"]) assert code == 1 - assert "V2 package blocks; expected 1" in stderr - assert "supernote-module-v2-package" not in application.read_text() + assert "V3 package blocks; expected 1" in stderr + assert "supernote-module-v3-package" not in application.read_text() def test_remove_preserves_build_outputs_unless_cleanup_is_explicit(tmp_path: Path): @@ -483,7 +648,7 @@ def test_remove_all_is_explicit_and_removes_every_feature(tmp_path: Path): assert "2 features" in stdout assert not (root / "local_modules/one").exists() assert not (root / "local_modules/two").exists() - assert not (root / "android/.supernote-module/v2-runtime").exists() + assert not (root / "android/.supernote-module/v3-runtime").exists() def test_package_manager_precedence_for_noninteractive_add(tmp_path: Path): diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 92beccf..5236fa1 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -22,6 +22,7 @@ def test_setup_cfg_is_the_single_metadata_source(): ROOT / "src/supernote_module_generator/__init__.py" ).read_text(encoding="utf-8") assert "name = supernote-module-generator" in setup + assert "author = Ziv-Ink" in setup assert "Generate typed C/C++ and Kotlin/Java features for existing Supernote plugins" in setup assert "url = https://github.com/Ziv-Ink/supernote-module-generator" in setup assert "PyPI = https://pypi.org/project/supernote-module-generator/" in setup @@ -42,6 +43,7 @@ def test_release_license_and_manifest_are_present(): assert "recursive-include maintainers *.md" in manifest assert "recursive-include architecture *.md" in manifest assert "recursive-include tests" in manifest + assert "recursive-include tests/fixtures *" in manifest assert "recursive-include src/supernote_module_generator/templates *" in manifest diff --git a/tests/test_plugin_build_integration.py b/tests/test_plugin_build_integration.py index 284a8d8..afb41c3 100644 --- a/tests/test_plugin_build_integration.py +++ b/tests/test_plugin_build_integration.py @@ -29,14 +29,14 @@ def test_wires_one_plugin_runtime_project_idempotently(tmp_path: Path, kotlin: b set_runtime_wiring(tmp_path, enabled=False) verify_runtime_wiring(tmp_path, enabled=False) - assert "supernote-v2-runtime" not in settings.read_text() - assert "supernote-v2-runtime" not in app.read_text() + assert "supernote-v3-runtime" not in settings.read_text() + assert "supernote-v3-runtime" not in app.read_text() def test_duplicate_runtime_blocks_are_rejected(tmp_path: Path): android = tmp_path / "android" (android / "app").mkdir(parents=True) - block = "// supernote-module-v2-runtime\nx\n// end supernote-module-v2-runtime\n" + block = "// supernote-module-v3-runtime\nx\n// end supernote-module-v3-runtime\n" (android / "settings.gradle").write_text(block + block) (android / "app/build.gradle").write_text("plugins {}\n") with pytest.raises(ConfigurationError, match="duplicate"): @@ -70,9 +70,63 @@ def test_registers_generated_react_package_idempotently( first = source.read_text() set_runtime_wiring(tmp_path, enabled=True) assert source.read_text() == first - assert first.count("SupernoteV2Package") == 1 + assert first.count("SupernoteV3Package") == 1 verify_runtime_wiring(tmp_path, enabled=True) set_runtime_wiring(tmp_path, enabled=False) - assert "SupernoteV2Package" not in source.read_text() + assert "SupernoteV3Package" not in source.read_text() verify_runtime_wiring(tmp_path, enabled=False) + + +def test_complete_stale_v2_wiring_is_removed_without_touching_user_source( + tmp_path: Path, +): + android = tmp_path / "android" + (android / "app").mkdir(parents=True) + (android / "settings.gradle").write_text( + "rootProject.name = 'fixture'\n" + "// supernote-module-v2-runtime\nlegacy\n" + "// end supernote-module-v2-runtime\n" + "include ':user-library'\n" + ) + (android / "app/build.gradle").write_text( + "plugins {}\n" + "// supernote-module-v2-runtime\nlegacy dependency\n" + "// end supernote-module-v2-runtime\n" + "dependencies { implementation project(':user-library') }\n" + ) + application = android / "app/src/main/java/com/example/MainApplication.kt" + application.parent.mkdir(parents=True) + application.write_text( + "fun getPackages() =\n" + " PackageList(this).packages.apply {\n" + " // supernote-module-v2-package\n" + " add(supernote.generated.runtime.SupernoteV2Package())\n" + " // end supernote-module-v2-package\n" + " add(UserPackage())\n" + " }\n" + ) + + set_runtime_wiring(tmp_path, enabled=True) + + assert "supernote-module-v2" not in (android / "settings.gradle").read_text() + assert "include ':user-library'" in (android / "settings.gradle").read_text() + assert "supernote-module-v2" not in (android / "app/build.gradle").read_text() + assert "project(':user-library')" in (android / "app/build.gradle").read_text() + assert "SupernoteV2Package" not in application.read_text() + assert "add(UserPackage())" in application.read_text() + verify_runtime_wiring(tmp_path, enabled=True) + + +def test_malformed_stale_v2_wiring_is_rejected_without_mutation(tmp_path: Path): + android = tmp_path / "android" + (android / "app").mkdir(parents=True) + settings = android / "settings.gradle" + settings.write_text("// supernote-module-v2-runtime\nlegacy\n") + (android / "app/build.gradle").write_text("plugins {}\n") + before = settings.read_bytes() + + with pytest.raises(ConfigurationError, match="malformed"): + set_runtime_wiring(tmp_path, enabled=True) + + assert settings.read_bytes() == before diff --git a/tests/test_plugin_runtime_codegen.py b/tests/test_plugin_runtime_codegen.py index 01618ae..659fe28 100644 --- a/tests/test_plugin_runtime_codegen.py +++ b/tests/test_plugin_runtime_codegen.py @@ -36,7 +36,7 @@ def entry(name: str) -> FeatureRegistryEntry: def registry(*names: str) -> PluginRuntimeRegistry: return PluginRuntimeRegistry.create( plugin_id="com.example.plugin", - generator_version="2.0.0.dev0", + generator_version="3.0.0.dev0", features=(entry(name) for name in names), ) @@ -82,6 +82,19 @@ def test_ksp_feature_roots_use_one_compiler_option_per_feature(tmp_path: Path): ) assert "\\tlocal_modules/" in option + cmake = (generated / "CMakeLists.txt").read_text() + assert '"${CMAKE_CURRENT_LIST_DIR}/../../../local_modules"' in cmake + + +def test_common_codegen_skips_cross_family_renderer_for_jvm_only_feature( + tmp_path: Path, +): + generated = generate_plugin_runtime(tmp_path, registry("jvm")) + common_codegen = (generated / "common_codegen.py").read_text() + + assert "if source_manifest is not None and native_root.is_dir():" in common_codegen + assert "include_prefix=native_include_prefix" in common_codegen + def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Path): runtime_registry = registry("alpha", "beta") @@ -94,9 +107,10 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat public_header = (generated / "include/supernote/runtime.hpp").read_text() source = (generated / "src/feature_registry.cpp").read_text() gradle = (generated / "build.gradle").read_text() + consumer_rules = (generated / "consumer-rules.pro").read_text() processor = ( generated - / "processor/src/main/kotlin/supernote/generated/processor/SupernoteV2Processor.kt" + / "processor/src/main/kotlin/supernote/generated/processor/SupernoteV3Processor.kt" ).read_text() bootstrap = (generated / "src/runtime_bootstrap.cpp").read_text() registration_bridge = ( @@ -104,7 +118,7 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat ).read_text() module = ( generated - / "src/main/java/supernote/generated/runtime/SupernoteV2Module.kt" + / "src/main/java/supernote/generated/runtime/SupernoteV3Module.kt" ).read_text() coroutine_bridge = ( generated @@ -114,8 +128,16 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat assert cmake.count(f"add_library({component} SHARED") == 1 assert cmake.count(f"add_library({registration_component} SHARED") == 1 assert '"${SUPERNOTE_NATIVE_ROOT}/*.c"' in cmake - assert "C_STANDARD 23 C_STANDARD_REQUIRED YES" in cmake + assert "C_STANDARD 23" in cmake + assert "C_STANDARD_REQUIRED YES" in cmake assert "target_compile_features" in cmake and "cxx_std_23" in cmake + assert "C_VISIBILITY_PRESET hidden" in cmake + assert "CXX_VISIBILITY_PRESET hidden" in cmake + assert "VISIBILITY_INLINES_HIDDEN YES" in cmake + assert 'target_link_options' in cmake + assert '"-Wl,-Bsymbolic-functions"' in cmake + assert "if(SUPERNOTE_V3_WEAK_OBJECT_PROBE)" in cmake + assert "SUPERNOTE_V3_WEAK_OBJECT_PROBE=1" in cmake assert "runtime_services.cpp" in cmake assert "feature_registry.cpp" in cmake assert "local_modules/@local/alpha/android/src/main/cpp" in cmake @@ -130,6 +152,7 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat assert services.count("static ProcessServices services") == 1 assert "class FeatureCallScope" in services_header assert "claim_internal_completion" in services_header + assert "set_retained_state" in services_header assert "thread_local std::weak_ptr" in services assert "enum class ErrorCode" in public_header assert "class Result final" in public_header @@ -138,6 +161,24 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat assert '"Beta"' in source assert gradle.count("com.android.library") == 1 assert "jniLibs.excludes" in gradle + assert "org.jspecify:jspecify:1.0.0" in gradle + assert "consumerProguardFiles 'consumer-rules.pro'" in gradle + assert "-keep interface com.facebook.react.ReactPackage { *; }" in consumer_rules + assert "-keep class com.facebook.soloader.SoLoader { *; }" in consumer_rules + assert "-keep class com.facebook.soloader.SoSource { *; }" in consumer_rules + assert "-keep class com.facebook.soloader.DirectorySoSource { *; }" in consumer_rules + assert "-keep class kotlin.** { *; }" in consumer_rules + assert "-keep class kotlinx.coroutines.** { *; }" in consumer_rules + assert ( + "-keep,includedescriptorclasses class supernote.generated.runtime.** { *; }" + in consumer_rules + ) + assert ( + "-keep,includedescriptorclasses class supernote.generated.adapters.** { *; }" + in consumer_rules + ) + assert "-keep class com.example.alpha.** { *; }" in consumer_rules + assert "-keep class com.example.beta.** { *; }" in consumer_rules assert "**/libjsi.so" in gradle assert "**/libreactnative.so" in gradle assert "local_modules/@local/alpha/android/src/main/java" in gradle @@ -149,14 +190,18 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat assert "\\tlocal_modules/@local/alpha/android/src/main/java" in gradle assert "\\tlocal_modules/@local/beta/android/src/main/java" in gradle assert "supernoteNativeRoots.findAll { it.isDirectory() }" in gradle + assert "gradleProperty('supernoteV3WeakObjectProbe')" in gradle + assert "-DSUPERNOTE_V3_WEAK_OBJECT_PROBE=" in gradle assert "def supernoteIsWindows" in gradle - assert "'supernote-v2/sn_supernote_runtime_" in gradle + assert "'supernote-v3/sn_supernote_runtime_" in gradle assert "layout.buildDirectory.set(new File(supernoteWindowsBuildRoot, 'gradle'))" in gradle assert "new File(supernoteWindowsBuildRoot, 'cxx')" in gradle - assert 'file("${rootProject.projectDir}/.cxx/snv2")' in gradle + assert 'file("${rootProject.projectDir}/.cxx/snv3")' in gradle assert "-DSUPERNOTE_GENERATED_ROOT=${layout.buildDirectory.dir('generated/supernote').get().asFile.absolutePath}" in gradle assert "'--build-root'" in gradle assert "layout.buildDirectory.get().asFile.absolutePath" in gradle + assert "buildVariant == 'Release' ? 'RelWithDebInfo' : buildVariant" in gradle + assert '"configureCMake${cmakeBuildType}[arm64-v8a]"' in gradle assert 'file(TO_CMAKE_PATH "${SUPERNOTE_GENERATED_ROOT}"' in cmake assert '"${SUPERNOTE_GENERATED_ROOT}/${SUPERNOTE_VARIANT}/jni/*.cpp"' in cmake assert "? ['py', '-3']" in gradle @@ -170,6 +215,9 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat assert "schema_version" in processor assert "getSymbolsWithAnnotation" in processor assert "Kotlin suspend requires explicit SupernotePluginAsync" in processor + assert "org.jspecify.annotations.Nullable" in processor + assert "androidx.annotation.Nullable" not in processor + assert "Java nullability requires org.jspecify.annotations.Nullable" in processor assert "ReactMethod" not in processor assert "TypeScript" not in processor assert "nativeInstall" in bootstrap @@ -177,7 +225,8 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat assert "nativeRunJsTask" not in bootstrap assert "RegisterNatives" in bootstrap assert "register_coroutine_bridge(env, class_loader)" in bootstrap - assert "GetStringUTFChars" not in bootstrap + assert "GetStringUTFChars(request, nullptr)" in bootstrap + assert "GetStringUTFChars(generation_identity, nullptr)" in bootstrap assert "GetByteArrayRegion" in bootstrap assert 'const_cast("(JLjava/lang/Object;[BZ)V")' in bootstrap assert "failureMessageUtf8: ByteArray?" in coroutine_bridge @@ -186,9 +235,16 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat assert "AttachCurrentThread" in bootstrap assert "DetachCurrentThread" in bootstrap assert f"{component}_register_natives" in bootstrap - assert "publish_runtime_registrar(env)" in bootstrap - assert f"supernote.v2.registrar.{component}.v1" in bootstrap - assert "supernote.generated.runtime.SupernoteV2Module" in bootstrap + assert "retain_runtime_mapping" not in bootstrap + assert "g_runtime_mapping" not in bootstrap + jni_on_load = bootstrap.index('extern "C" JNIEXPORT jint JNICALL JNI_OnLoad') + jni_on_load_end = bootstrap.index("\n}\n", jni_on_load) + 3 + jni_on_load_body = bootstrap[jni_on_load:jni_on_load_end] + assert "return publish_runtime_registrar(env)" in jni_on_load_body + assert f"supernote.v3.load-request.{component}.v1" in bootstrap + assert f"supernote.v3.registrar.{component}.v2" in bootstrap + assert "generated runtime generation identity mismatch" in bootstrap + assert "supernote.generated.runtime.SupernoteV3Module" in bootstrap assert "(JLjava/lang/ClassLoader;" in bootstrap assert "Lcom/facebook/react/bridge/ReactApplicationContext;" in bootstrap assert "CallInvokerHolder;)J" in bootstrap @@ -197,8 +253,13 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat assert "runOnJSQueueThread" not in bootstrap assert 'const_cast("nativeInvalidate")' in bootstrap assert "JNI_OnLoad" in bootstrap - assert "RegisterNatives" not in bootstrap[bootstrap.index("JNI_OnLoad") :] + assert "RegisterNatives" not in jni_on_load_body assert "install_plugin_bindings" in bootstrap + assert "class WeakObjectProbeHost" in bootstrap + assert "std::optional" in bootstrap + assert "weak_->lock(runtime)" in bootstrap + assert "weak_.reset()" in bootstrap + assert "install_weak_object_probe(*runtime)" in bootstrap assert "runOnJSQueueThread" in module assert "context.jsCallInvokerHolder" in module assert "nativeInstall(runtimePointer, loader, context, callInvoker)" in module @@ -220,17 +281,41 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat assert "sessionId.also { sessionId = 0L }" in invalidate_guard assert "nativeRunJsTask" not in module assert f'findLibrary("{registration_component}")' in module - assert "SupernoteV2NativeRegistrationBridge.register" in module + assert "SupernoteV3NativeRegistrationBridge.register" in module + assert f'File(context.codeCacheDir, "supernote-v3-runtime/{component}")' in module + assert 'Integer.toHexString(System.identityHashCode(pluginClassLoader))' in module + assert 'java.lang.Long.toHexString(System.nanoTime())' in module + assert 'File(runtimeDirectory, "lib$runtimeLoadName.so")' in module + assert "DirectorySoSource.RESOLVE_DEPENDENCIES" in module + assert "SoLoader.prependSoSource" in module + assert "SoLoader.loadLibrary(runtimeLoadName)" in module + assert "System.load(runtimeCopy.absolutePath)" not in module + assert "synchronized(System.getProperties())" in module + assert "publishedGeneration != runtimeLoadName" in module + assert f"supernote.v3.source.{component}.v1" in module + assert f"supernote.v3.generations.{component}.v1" in module + assert "MAX_RETAINED_GENERATIONS = 32" in module + assert "retainedGenerations !in 0 until MAX_RETAINED_GENERATIONS" in module + assert "restart PluginHost before loading another native generation" in module + assert "runtimeCopy.delete()" in module + assert f'SoLoader.loadLibrary("{component}")' not in module assert "File.createTempFile" in module assert "System.load(bridge.absolutePath)" in module assert "bridge.delete()" in module - assert f"supernote.v2.registrar.{component}.v1" in module - assert "nativeRegister(registrarAddress, classLoader)" in module + assert f"supernote.v3.load-request.{component}.v1" in module + assert f"supernote.v3.registrar.{component}.v2" in module + assert ( + "nativeRegister(registrarAddress, generationIdentity, classLoader)" + in module + ) assert "SupernoteRuntimeRegistrar" in registration_bridge assert "nativeRegister" in registration_bridge + assert "dladdr((void *)registrar, ®istrar_info)" in registration_bridge + assert "SupernoteV3Registration" in registration_bridge + assert "published runtime registrar is no longer mapped" in registration_bridge assert "jsi" not in registration_bridge assert "RuntimeSession" not in registration_bridge - assert "class SupernoteV2Package" in module + assert "class SupernoteV3Package" in module assert ( generated / "annotations/src/main/java/supernote/generated/annotations/SupernotePluginExport.java" @@ -351,6 +436,11 @@ def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( auto operation = feature->accept( [&](void *) { ++rejected; }); if (!operation || operation->cancellation_token().is_cancelled()) return 1; + auto retained_state = std::make_shared(77); + std::weak_ptr retained_weak = retained_state; + operation->set_retained_state(retained_state); + retained_state.reset(); + if (retained_weak.expired()) return 29; operation->set_cancel_hook([&] { ++cancelled; }); if (!feature->schedule_completion( operation, [&](void *) { ++resolved; })) return 2; @@ -361,8 +451,90 @@ def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( if (operation->winner() != OperationWinner::CANCELLED_BY_FEATURE || !operation->cancellation_token().is_cancelled() || cancelled != 1) return 4; - + if (retained_weak.expired()) return 30; js_queue.clear(); + operation.reset(); + if (!retained_weak.expired()) return 31; + + for (int iteration = 0; iteration < 1000; ++iteration) { + std::atomic callbacks{0}; + std::atomic race_cancelled{0}; + std::atomic go{false}; + int race_runtime_pointer = iteration + 1; + auto race_runtime = RuntimeSession::create( + [&](RuntimeSession::JsTask task) { + task(&race_runtime_pointer); + }); + auto race_feature = FeatureSession::create(race_runtime, cleanup); + auto race_operation = race_feature->accept( + [&](void *) { ++callbacks; }); + race_operation->set_cancel_hook([&] { ++race_cancelled; }); + std::thread completing([&] { + while (!go.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + race_feature->schedule_completion( + race_operation, [&](void *) { ++callbacks; }); + }); + std::thread closing([&] { + while (!go.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + race_feature->close_feature(); + }); + go.store(true, std::memory_order_release); + completing.join(); + closing.join(); + const auto winner = race_operation->winner(); + if (callbacks != 1) return 32; + if (winner == OperationWinner::COMPLETING) { + if (race_cancelled != 0) return 33; + } else if (winner == OperationWinner::CANCELLED_BY_FEATURE) { + if (race_cancelled != 1) return 34; + } else { + return 35; + } + race_runtime->invalidate(); + } + + for (int iteration = 0; iteration < 1000; ++iteration) { + std::atomic completed{0}; + std::atomic race_cancelled{0}; + std::atomic go{false}; + int race_runtime_pointer = iteration + 1; + auto race_runtime = RuntimeSession::create( + [&](RuntimeSession::JsTask task) { + task(&race_runtime_pointer); + }); + auto race_feature = FeatureSession::create(race_runtime, cleanup); + auto race_operation = race_feature->accept({}); + race_operation->set_cancel_hook([&] { ++race_cancelled; }); + std::thread completing([&] { + while (!go.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + race_feature->schedule_completion( + race_operation, [&](void *) { ++completed; }); + }); + std::thread closing([&] { + while (!go.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + race_runtime->invalidate(); + }); + go.store(true, std::memory_order_release); + completing.join(); + closing.join(); + const auto winner = race_operation->winner(); + if (winner == OperationWinner::COMPLETING) { + if (completed != 1 || race_cancelled != 0) return 36; + } else if (winner == OperationWinner::CANCELLED_BY_RUNTIME) { + if (completed != 0 || race_cancelled != 1) return 37; + } else { + return 38; + } + } + auto replacement = RuntimeSession::create( [&](RuntimeSession::JsTask task) { js_queue.push_back(std::move(task)); @@ -533,11 +705,17 @@ def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( "runtime_contract.exe" if os.name == "nt" else "runtime_contract" ) thread_flags = [] if os.name == "nt" else ["-pthread"] + sanitizer_flags = ( + ["-fsanitize=thread", "-g"] + if os.environ.get("SUPERNOTE_V3_TSAN") == "1" + else [] + ) compiled = subprocess.run( [ compiler, "-std=c++23", *thread_flags, + *sanitizer_flags, str(generated / "src/runtime_services.cpp"), str(harness), "-I", @@ -551,7 +729,7 @@ def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( ) assert compiled.returncode == 0, compiled.stderr executed = subprocess.run( - [str(executable)], capture_output=True, text=True, check=False, timeout=10 + [str(executable)], capture_output=True, text=True, check=False, timeout=60 ) assert executed.returncode == 0, executed.stderr @@ -740,7 +918,7 @@ def test_common_codegen_emits_real_cpp_jsi_route(tmp_path: Path): tmp_path, PluginRuntimeRegistry.create( plugin_id="com.example.plugin", - generator_version="2.0.0.dev0", + generator_version="3.0.0.dev0", features=(FeatureRegistryEntry.create(feature, api),), ), ) @@ -773,8 +951,8 @@ def test_common_codegen_emits_real_cpp_jsi_route(tmp_path: Path): assert "JNI_OnLoad" not in source bootstrap = (jni / "plugin_bindings.cpp").read_text() assert "install_plugin_bindings" in bootstrap - assert "__supernoteV2FeatureRegistry_" in bootstrap - assert '"__supernoteV2"' in bootstrap + assert "__supernoteV3FeatureRegistry_" in bootstrap + assert '"__supernoteV3"' in bootstrap def test_common_codegen_emits_hidden_cpp_internal_facade(tmp_path: Path): @@ -789,7 +967,6 @@ def test_common_codegen_emits_hidden_cpp_internal_facade(tmp_path: Path): (cpp / "documents.hpp").write_text( """#pragma once #include -// @SupernotePluginInternal class IndexService { public: IndexService(); @@ -811,7 +988,7 @@ class IndexService { tmp_path, PluginRuntimeRegistry.create( plugin_id="com.example.plugin", - generator_version="2.0.0.dev0", + generator_version="3.0.0.dev0", features=(FeatureRegistryEntry.create(feature, api),), ), ) diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..cd89e0a --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from supernote_module_generator.errors import ConfigurationError +from supernote_module_generator.project import resolve_plugin_root + + +def _write_project_structure(root: Path) -> None: + (root / "android").mkdir(parents=True) + (root / "package.json").write_text( + '{"name":"fixture","dependencies":{"sn-plugin-lib":"^0.1.19"}}\n', + encoding="utf-8", + ) + (root / "android/settings.gradle").write_text( + "include ':app'\n", + encoding="utf-8", + ) + + +def test_resolves_built_plugin_with_manifest(tmp_path: Path) -> None: + _write_project_structure(tmp_path) + (tmp_path / "PluginConfig.json").write_text("{}\n", encoding="utf-8") + + assert resolve_plugin_root(tmp_path) == tmp_path.resolve() + + +@pytest.mark.parametrize("script_name", ["buildPlugin.sh", "buildPlugin.ps1"]) +def test_resolves_fresh_official_template_before_manifest_is_generated( + tmp_path: Path, + script_name: str, +) -> None: + _write_project_structure(tmp_path) + scripts = tmp_path / "scripts" + scripts.mkdir() + (scripts / script_name).write_text("# template build script\n", encoding="utf-8") + + assert not (tmp_path / "PluginConfig.json").exists() + assert resolve_plugin_root(tmp_path) == tmp_path.resolve() + + +def test_rejects_generic_react_native_project_without_plugin_identity( + tmp_path: Path, +) -> None: + _write_project_structure(tmp_path) + + with pytest.raises(ConfigurationError, match="not a Supernote plugin"): + resolve_plugin_root(tmp_path) + + +def test_rejects_prebuild_marker_symlink_that_escapes_plugin( + tmp_path: Path, +) -> None: + project = tmp_path / "project" + _write_project_structure(project) + external = tmp_path / "external-buildPlugin.sh" + external.write_text("# external\n", encoding="utf-8") + scripts = project / "scripts" + scripts.mkdir() + (scripts / "buildPlugin.sh").symlink_to(external) + + with pytest.raises(ConfigurationError, match="target resolves outside"): + resolve_plugin_root(project) diff --git a/tests/test_typescript_codegen.py b/tests/test_typescript_codegen.py index 2ce5d05..1b70477 100644 --- a/tests/test_typescript_codegen.py +++ b/tests/test_typescript_codegen.py @@ -55,7 +55,7 @@ def test_typescript_uses_only_public_common_semantics_and_exact_value_mappings() ) text = render_typescript("Document", SemanticApi((public, internal))) - assert "load(value: bigint): Promise;" in text + assert "load: SupernoteCallable<[value: bigint], Promise>;" in text assert "hidden" not in text assert "export class SupernoteError extends Error" in text assert 'readonly code: SupernoteErrorCode;' in text @@ -100,7 +100,7 @@ def test_typescript_generates_public_object_factory_and_explicit_members_only(): text = render_typescript("Feature", SemanticApi(classes=(item,))) assert "export interface Document {" in text - assert "pageCount(value: bigint): number;" in text + assert "pageCount: SupernoteCallable<[value: bigint], number>;" in text assert "rebuild" not in text assert "create(path: string): Document;" in text assert "Document: DocumentFactory;" in text diff --git a/tests/test_v2_models.py b/tests/test_v2_models.py index 9bc3646..c344bf3 100644 --- a/tests/test_v2_models.py +++ b/tests/test_v2_models.py @@ -111,7 +111,19 @@ def object_method( def test_initial_semantic_types_are_exact_and_stable(): - assert [item.value for item in SemanticType] == [ + assert [ + item.value + for item in ( + SemanticType.VOID, + SemanticType.BOOL, + SemanticType.INT32, + SemanticType.INT64, + SemanticType.FLOAT32, + SemanticType.FLOAT64, + SemanticType.STRING, + SemanticType.BYTES, + ) + ] == [ "void", "bool", "int32", @@ -175,7 +187,7 @@ def test_source_intent_validates_composable_source_located_markers_and_targets() ( DeclarationTarget.CLASS, (SupernoteMarker.EXPORT, SupernoteMarker.ASYNC), - "cannot mark a class", + "classes require exactly one", ), ( DeclarationTarget.FUNCTION, @@ -269,8 +281,10 @@ def test_semantic_api_is_backend_neutral_deterministic_and_validated(): source, ) manifest = SemanticApi((binding,)).manifest() - assert manifest["schema_version"] == 1 + assert manifest["schema_version"] == 3 + assert manifest["kind"] == "supernote_v3_semantic_manifest" assert manifest["classes"] == [] + assert manifest["types"] == [] assert manifest["functions"][0]["binding_id"] == "api:function:loadPage" assert manifest["functions"][0]["source_declaration_id"] == source.declaration_id assert "jniDescriptor" not in manifest["functions"][0] @@ -451,7 +465,7 @@ def test_jvm_source_model_keeps_owner_constructor_adapter_and_injection_facts(): "com.example.DocumentApi", "DocumentApi", JvmOwnerForm.CLASS, - intent(DeclarationTarget.CLASS, SupernoteMarker.INTERNAL), + intent(DeclarationTarget.CLASS), (constructor,), (declaration,), ) @@ -512,7 +526,7 @@ def test_jvm_source_model_rejects_impossible_suspend_and_owner_forms(): "Example", "Example", JvmOwnerForm.KOTLIN_OBJECT, - intent(DeclarationTarget.CLASS, SupernoteMarker.INTERNAL), + intent(DeclarationTarget.CLASS), (), (), ) diff --git a/tests/test_v3_cpp_resolution_contract.py b/tests/test_v3_cpp_resolution_contract.py new file mode 100644 index 0000000..51d8b93 --- /dev/null +++ b/tests/test_v3_cpp_resolution_contract.py @@ -0,0 +1,39 @@ +import json +from pathlib import Path + + +FIXTURES = Path(__file__).parent / "fixtures/v3_cpp_resolution" + + +def test_d042_resolution_fixture_inventory_is_closed_and_deterministic(): + manifest = json.loads((FIXTURES / "cases.json").read_text(encoding="utf-8")) + + assert manifest["schema_version"] == 1 + assert manifest["decision"] == "D-042" + cases = manifest["cases"] + assert len({case["file"] for case in cases}) == len(cases) + assert len({case["rule"] for case in cases}) == len(cases) + assert {case["outcome"] for case in cases} == {"accept", "reject"} + assert {path.name for path in FIXTURES.glob("*.hpp")} == { + case["file"] for case in cases + } + for case in cases: + source = (FIXTURES / case["file"]).read_text(encoding="utf-8") + assert "// @SupernotePluginObject" in source + + +def test_d042_rejection_contract_covers_every_deferred_resolution_form(): + manifest = json.loads((FIXTURES / "cases.json").read_text(encoding="utf-8")) + rejected = { + case["rule"] for case in manifest["cases"] if case["outcome"] == "reject" + } + + assert rejected == { + "alias_bridge_visible_spelling", + "marked_alias", + "anonymous_namespace_bridge_type", + "nested_bridge_declaration", + "ambiguous_unqualified_reference", + "forward_declaration_only", + "same_final_public_name_collision", + } diff --git a/tests/test_v3_phase0_schemas.py b/tests/test_v3_phase0_schemas.py new file mode 100644 index 0000000..ec8ef02 --- /dev/null +++ b/tests/test_v3_phase0_schemas.py @@ -0,0 +1,106 @@ +import json +from dataclasses import replace + +import pytest + +from supernote_module_generator.feature_model import ( + FeatureManifest, + FeatureModelError, + PluginRuntimeRegistry, +) +from supernote_module_generator.jvm_manifest import ( + JvmManifestError, + JvmSourceManifest, + read_jvm_manifest, +) +from supernote_module_generator.plugin_runtime_codegen import generated_runtime_files +from supernote_module_generator.semantic import ( + SemanticApi, + SemanticModelError, + semantic_api_from_manifest, +) +from supernote_module_generator.v3_schemas import ( + FEATURE_MANIFEST_KIND, + FEATURE_MANIFEST_SCHEMA_VERSION, + GENERATED_OWNERSHIP_KIND, + GENERATED_OWNERSHIP_SCHEMA_VERSION, + JVM_SOURCE_MANIFEST_KIND, + JVM_SOURCE_MANIFEST_SCHEMA_VERSION, + PLUGIN_REGISTRY_KIND, + PLUGIN_REGISTRY_SCHEMA_VERSION, + SEMANTIC_MANIFEST_KIND, + SEMANTIC_MANIFEST_SCHEMA_VERSION, +) + + +def test_every_phase0_generated_boundary_has_an_explicit_v3_identity(): + assert ( + SEMANTIC_MANIFEST_SCHEMA_VERSION, + JVM_SOURCE_MANIFEST_SCHEMA_VERSION, + FEATURE_MANIFEST_SCHEMA_VERSION, + PLUGIN_REGISTRY_SCHEMA_VERSION, + GENERATED_OWNERSHIP_SCHEMA_VERSION, + ) == (3, 3, 3, 2, 2) + assert { + SEMANTIC_MANIFEST_KIND, + JVM_SOURCE_MANIFEST_KIND, + FEATURE_MANIFEST_KIND, + PLUGIN_REGISTRY_KIND, + GENERATED_OWNERSHIP_KIND, + } == { + "supernote_v3_semantic_manifest", + "supernote_v3_jvm_source_manifest", + "supernote_v3_feature", + "supernote_v3_plugin_runtime_registry", + "supernote_v3_plugin_runtime_ownership", + } + + +def test_v2_schema_versions_are_rejected_instead_of_converted(tmp_path): + semantic = SemanticApi().manifest() + for stale_schema in (1, 2, 99): + semantic["schema_version"] = stale_schema + with pytest.raises(SemanticModelError, match="incompatible semantic manifest"): + semantic_api_from_manifest(semantic) + + jvm = JvmSourceManifest("supernote:feature:phase0", "3.0.0.dev0", ()) + raw = jvm.manifest() + raw["schema_version"] = 1 + path = tmp_path / "jvm.json" + path.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(JvmManifestError, match="incompatible JVM manifest"): + read_jvm_manifest(path) + + feature = FeatureManifest.create( + npm_name="phase0", + public_name="Phase0", + android_namespace="com.example.phase0", + ) + with pytest.raises(FeatureModelError, match="unsupported feature manifest"): + replace(feature, schema_version=2) + + registry = PluginRuntimeRegistry.create( + plugin_id="phase0", + generator_version="3.0.0.dev0", + features=(), + ) + with pytest.raises(FeatureModelError, match="unsupported plugin registry"): + replace(registry, schema_version=1) + + +def test_generated_registry_and_ownership_use_only_v3_schemas(): + registry = PluginRuntimeRegistry.create( + plugin_id="phase0", + generator_version="3.0.0.dev0", + features=(), + ) + files = generated_runtime_files(registry) + registry_json = json.loads(files["feature-registry.json"]) + ownership_json = json.loads(files["ownership.json"]) + + assert registry_json["schema_version"] == PLUGIN_REGISTRY_SCHEMA_VERSION + assert registry_json["kind"] == PLUGIN_REGISTRY_KIND + assert ownership_json["schema_version"] == GENERATED_OWNERSHIP_SCHEMA_VERSION + assert ownership_json["kind"] == GENERATED_OWNERSHIP_KIND + assert "supernote_v2" not in files["feature-registry.json"] + assert "supernote_v2" not in files["ownership.json"] diff --git a/tests/test_v3_phase2_frontends.py b/tests/test_v3_phase2_frontends.py new file mode 100644 index 0000000..5ad3876 --- /dev/null +++ b/tests/test_v3_phase2_frontends.py @@ -0,0 +1,798 @@ +import json +from pathlib import Path +import random + +import pytest + +from supernote_module_generator import binding_codegen +from supernote_module_generator.cpp_projection import CppProjectionError +from supernote_module_generator.jvm_manifest import ( + JvmSourceManifest, + jvm_adapter_identity, + jvm_declaration_identity, + jvm_field_accessor_identity, + jvm_field_identity, + jvm_owner_identity, + read_jvm_manifest, + write_jvm_manifest, +) +from supernote_module_generator.jvm_projection import ( + JvmProjectionError, + project_jvm_owners, +) +from supernote_module_generator.semantic import ( + BindingKind, + MemberScope, + SemanticDeclarationKind, + SemanticType, + SourceProvenance, + merge_semantic_apis, +) +from supernote_module_generator.source_models import ( + DeclarationTarget, + JvmConstructorSource, + JvmDeclarationSource, + JvmFieldSource, + JvmLanguage, + JvmOwnerForm, + JvmOwnerSource, + JvmParameterSource, + JvmTypeSource, + SourceIntent, + SourceModelError, + SupernoteMarker, +) + + +FEATURE_ID = "supernote:feature:0123456789abcdef" + + +def intent(target: DeclarationTarget, *markers: SupernoteMarker) -> SourceIntent: + return SourceIntent.from_markers(target, markers, first_line=3) + + +def cpp_module(tmp_path: Path, header: str) -> Path: + root = tmp_path / "feature" + native = root / "android/src/main/cpp" + native.mkdir(parents=True) + (native / "feature.cpp").write_text("", encoding="utf-8") + (native / "model.hpp").write_text(header, encoding="utf-8") + config = root / "android/.supernote-module/codegen-config.json" + config.parent.mkdir(parents=True) + config.write_text( + json.dumps({"backend": "jsi", "module_name": "Phase2"}), + encoding="utf-8", + ) + (root / ".supernote-module.json").write_text( + json.dumps({"feature_id": FEATURE_ID}), encoding="utf-8" + ) + return root + + +def test_seeded_cpp_parser_mutations_terminate_and_are_deterministic(tmp_path): + seed = 0xC23F_0220 + rng = random.Random(seed) + root = cpp_module( + tmp_path, + """namespace drawing { +// SupernotePluginValue +struct Point { int32_t x; int32_t y; }; + +// SupernotePluginObject +class Stroke { + public: + // SupernotePluginConstructor + Stroke(Point origin); + // SupernotePluginExport + Point origin() const; +}; +} // namespace drawing +""", + ) + path = root / "android/src/main/cpp/model.hpp" + baseline = path.read_text(encoding="utf-8") + insertions = ( + "/*", + "*/", + 'R\"tag(', + ')tag\"', + "#if 0\n", + "#endif\n", + "{", + "}", + ";", + "// SupernotePluginObject\n", + "// SupernotePluginValue\n", + "// SupernotePluginExport\n", + "\x00", + ) + + for iteration in range(1_024): + source = baseline + for _ in range(1 + rng.randrange(4)): + operation = rng.randrange(4) + start = rng.randrange(len(source) + 1) + if operation == 0: + source = source[:start] + elif operation == 1: + source = source[:start] + rng.choice(insertions) + source[start:] + elif operation == 2 and source: + end = min(len(source), start + rng.randrange(1, 25)) + source = source[:start] + source[end:] + else: + source = source.replace("SupernotePlugin", "SupernotePluginX", 1) + path.write_text(source, encoding="utf-8") + try: + first = binding_codegen.scan_cpp_semantic_model(root) + first_result = ("ok", first.manifest()) + except binding_codegen.CodegenError as exc: + first_result = ("error", str(exc)) + + # Periodic replay makes determinism part of the retained campaign without + # doubling the cost of every mutation. + if iteration % 32 == 0: + try: + second = binding_codegen.scan_cpp_semantic_model(root) + second_result = ("ok", second.manifest()) + except binding_codegen.CodegenError as exc: + second_result = ("error", str(exc)) + assert second_result == first_result, (seed, iteration, source) + + +@pytest.mark.parametrize( + ("target", "markers"), + [ + (DeclarationTarget.CLASS, ()), + (DeclarationTarget.CLASS, (SupernoteMarker.OBJECT,)), + (DeclarationTarget.CLASS, (SupernoteMarker.VALUE,)), + (DeclarationTarget.ENUM, (SupernoteMarker.VALUE,)), + (DeclarationTarget.FIELD, (SupernoteMarker.EXPORT,)), + (DeclarationTarget.CONSTRUCTOR, (SupernoteMarker.CONSTRUCTOR,)), + (DeclarationTarget.FUNCTION, (SupernoteMarker.EXPORT,)), + ( + DeclarationTarget.METHOD, + (SupernoteMarker.INTERNAL, SupernoteMarker.ASYNC), + ), + ], +) +def test_closed_marker_matrix_accepts_only_declared_compositions(target, markers): + assert intent(target, *markers).marker_set == frozenset(markers) + + +@pytest.mark.parametrize( + ("target", "markers"), + [ + (DeclarationTarget.CLASS, (SupernoteMarker.EXPORT,)), + (DeclarationTarget.CLASS, (SupernoteMarker.OBJECT, SupernoteMarker.VALUE)), + (DeclarationTarget.ENUM, (SupernoteMarker.EXPORT,)), + (DeclarationTarget.FIELD, (SupernoteMarker.INTERNAL,)), + (DeclarationTarget.CONSTRUCTOR, (SupernoteMarker.EXPORT,)), + (DeclarationTarget.FUNCTION, (SupernoteMarker.OBJECT,)), + (DeclarationTarget.METHOD, (SupernoteMarker.ASYNC,)), + ( + DeclarationTarget.FUNCTION, + (SupernoteMarker.EXPORT, SupernoteMarker.INTERNAL), + ), + ], +) +def test_closed_marker_matrix_rejects_every_cross_target_composition(target, markers): + with pytest.raises(SourceModelError): + intent(target, *markers) + + +def test_cpp_frontend_projects_values_enums_objects_and_unmarked_owners(tmp_path): + root = cpp_module( + tmp_path, + """namespace drawing { +// @SupernotePluginValue +enum class Color { Red, Green, Blue }; + +// @SupernotePluginValue +struct Point { + // @SupernotePluginExport + double x; + // @SupernotePluginExport + double y; +}; + +// @SupernotePluginObject +class Stroke { +public: + Stroke(); + // @SupernoteConstructor + explicit Stroke(std::vector points); + // @SupernotePluginExport + static std::shared_ptr empty(); + // @SupernotePluginExport + bool intersects(const Stroke& other) const; + // @SupernotePluginExport + std::vector> samples() const; +}; + +class Api { +public: + // @SupernotePluginExport + static std::shared_ptr load(Point point); +}; +} +""", + ) + api = binding_codegen.scan_cpp_semantic_model(root) + + assert [item.name for item in api.functions] == ["load"] + assert api.functions[0].result.kind.value == "object_ref" + by_name = {item.name: item for item in api.declarations} + assert by_name["Color"].kind is SemanticDeclarationKind.ENUM + assert by_name["Color"].constants == ("Red", "Green", "Blue") + assert [field.name for field in by_name["Point"].fields] == ["x", "y"] + stroke = by_name["Stroke"] + assert stroke.constructor.parameters[0].type == SemanticType.array( + SemanticType.value_ref(by_name["Point"].type_id) + ) + methods = {item.name: item for item in stroke.methods} + assert methods["empty"].member_scope is MemberScope.STATIC + assert methods["intersects"].member_scope is MemberScope.INSTANCE + assert methods["intersects"].parameters[0].type == SemanticType.object_ref( + stroke.type_id + ) + assert methods["samples"].result == SemanticType.array( + SemanticType.nullable(SemanticType.value_ref(by_name["Point"].type_id)) + ) + + +@pytest.mark.parametrize( + ("source", "diagnostic"), + [ + ( + "// @SupernotePluginObject\nclass Bad : public Base {};", + "inheritance is not supported", + ), + ( + "// @SupernotePluginValue\nusing Bad = int;", + "followed by a class or struct", + ), + ( + "namespace {\n// @SupernotePluginValue\nstruct Bad {\n" + "// @SupernotePluginExport\ndouble x;\n};\n}", + "brace depth", + ), + ( + "// @SupernotePluginValue\nenum class Bad { A = 1 };", + "comma-separated source constant names", + ), + ], +) +def test_cpp_frontend_rejects_deferred_or_ambiguous_declarations( + tmp_path, source, diagnostic +): + root = cpp_module(tmp_path, source) + with pytest.raises(binding_codegen.CodegenError, match=diagnostic): + binding_codegen.scan_cpp_semantic_model(root) + + +def source(identity: str, language: JvmLanguage, path: str, line: int = 1): + return SourceProvenance(identity, language.value, path, line) + + +def constructor( + owner: str, + language: JvmLanguage, + descriptor: str, + parameters: tuple[JvmParameterSource, ...], + *markers: SupernoteMarker, +) -> JvmConstructorSource: + identity = jvm_declaration_identity(owner, "", descriptor) + return JvmConstructorSource( + source(identity, language, "Model.kt"), + descriptor, + parameters, + "public", + intent(DeclarationTarget.CONSTRUCTOR, *markers), + jvm_adapter_identity(identity), + ) + + +def field( + owner: str, + language: JvmLanguage, + name: str, + type_: JvmTypeSource, + *, + mutable: bool = False, +) -> JvmFieldSource: + identity = jvm_field_identity(owner, name) + return JvmFieldSource( + source(identity, language, "Model.kt"), + jvm_owner_identity(owner), + name, + type_, + intent(DeclarationTarget.FIELD, SupernoteMarker.EXPORT), + "public", + mutable, + False, + jvm_field_accessor_identity(identity), + ) + + +def method( + owner: str, + language: JvmLanguage, + name: str, + descriptor: str, + parameters: tuple[JvmParameterSource, ...], + result: JvmTypeSource, + *, + static: bool = False, + target: DeclarationTarget = DeclarationTarget.METHOD, +) -> JvmDeclarationSource: + identity = jvm_declaration_identity(owner, name, descriptor) + return JvmDeclarationSource( + source(identity, language, "Model.kt", 8), + jvm_owner_identity(owner), + owner, + name, + descriptor, + parameters, + result.jvm_type, + result.nullable, + intent(target, SupernoteMarker.EXPORT), + "public", + jvm_adapter_identity(identity), + language, + False, + static, + result.arguments, + ) + + +def projected_java_result(type_: JvmTypeSource) -> SemanticType: + owner_name = "com.example.JavaMatrix" + declaration = method( + owner_name, + JvmLanguage.JAVA, + "route", + "()Ljava/lang/Object;", + (), + type_, + static=True, + target=DeclarationTarget.FUNCTION, + ) + owner = JvmOwnerSource( + source(jvm_owner_identity(owner_name), JvmLanguage.JAVA, "JavaMatrix.java"), + JvmLanguage.JAVA, + owner_name, + "JavaMatrix", + JvmOwnerForm.JAVA_STATIC, + intent(DeclarationTarget.CLASS), + (), + (declaration,), + ) + return project_jvm_owners((owner,), feature_id=FEATURE_ID).functions[0].result + + +def test_jvm_frontend_projects_recursive_kotlin_object_and_value(): + point_name = "com.example.Point" + stroke_name = "com.example.Stroke" + point = JvmOwnerSource( + source(jvm_owner_identity(point_name), JvmLanguage.KOTLIN, "Point.kt"), + JvmLanguage.KOTLIN, + point_name, + "Point", + JvmOwnerForm.CLASS, + intent(DeclarationTarget.CLASS, SupernoteMarker.VALUE), + ( + constructor( + point_name, + JvmLanguage.KOTLIN, + "(DD)V", + ( + JvmParameterSource("kotlin.Double", "x"), + JvmParameterSource("kotlin.Double", "y"), + ), + ), + ), + (), + fields=( + field( + point_name, + JvmLanguage.KOTLIN, + "x", + JvmTypeSource("kotlin.Double"), + mutable=True, + ), + field( + point_name, + JvmLanguage.KOTLIN, + "y", + JvmTypeSource("kotlin.Double"), + mutable=True, + ), + ), + is_data=True, + ) + points = JvmTypeSource( + "kotlin.collections.List", + arguments=(JvmTypeSource(point_name),), + ) + stroke = JvmOwnerSource( + source(jvm_owner_identity(stroke_name), JvmLanguage.KOTLIN, "Stroke.kt"), + JvmLanguage.KOTLIN, + stroke_name, + "Stroke", + JvmOwnerForm.CLASS, + intent(DeclarationTarget.CLASS, SupernoteMarker.OBJECT), + ( + constructor( + stroke_name, + JvmLanguage.KOTLIN, + "(Ljava/util/List;)V", + ( + JvmParameterSource( + points.jvm_type, + "points", + type_arguments=points.arguments, + ), + ), + SupernoteMarker.CONSTRUCTOR, + ), + ), + ( + method( + stroke_name, + JvmLanguage.KOTLIN, + "empty", + "()Lcom/example/Stroke;", + (), + JvmTypeSource(stroke_name), + static=True, + ), + ), + fields=( + field( + stroke_name, + JvmLanguage.KOTLIN, + "label", + JvmTypeSource("kotlin.String", nullable=True), + mutable=True, + ), + ), + ) + api = project_jvm_owners((point, stroke), feature_id=FEATURE_ID) + by_name = {item.name: item for item in api.declarations} + assert by_name["Stroke"].constructor.parameters[0].type == SemanticType.array( + SemanticType.value_ref(by_name["Point"].type_id) + ) + assert by_name["Stroke"].fields[0].type == SemanticType.nullable( + SemanticType.STRING + ) + assert by_name["Stroke"].methods[0].kind is BindingKind.OBJECT_METHOD + assert by_name["Stroke"].methods[0].member_scope is MemberScope.STATIC + + +@pytest.mark.parametrize( + ("spelling", "nullable", "arguments", "expected"), + [ + ("int", False, (), SemanticType.INT32), + ( + "java.lang.Integer", + True, + (), + SemanticType.nullable(SemanticType.INT32), + ), + ( + "java.util.List", + False, + (JvmTypeSource("java.lang.Integer"),), + SemanticType.array(SemanticType.INT32), + ), + ( + "java.util.List", + True, + (JvmTypeSource("java.lang.Integer", nullable=True),), + SemanticType.nullable( + SemanticType.array(SemanticType.nullable(SemanticType.INT32)) + ), + ), + ], +) +def test_java_direct_boxed_and_nested_type_use_matrix( + spelling, nullable, arguments, expected +): + owner_name = "com.example.Api" + declaration = method( + owner_name, + JvmLanguage.JAVA, + "route", + "()V", + (), + JvmTypeSource(spelling, nullable, arguments), + static=True, + target=DeclarationTarget.FUNCTION, + ) + owner = JvmOwnerSource( + source(jvm_owner_identity(owner_name), JvmLanguage.JAVA, "Api.java"), + JvmLanguage.JAVA, + owner_name, + "Api", + JvmOwnerForm.JAVA_STATIC, + intent(DeclarationTarget.CLASS), + (), + (declaration,), + ) + assert project_jvm_owners((owner,), feature_id=FEATURE_ID).functions[0].result == expected + + +@pytest.mark.parametrize( + ("direct", "boxed", "semantic"), + [ + ("boolean", "java.lang.Boolean", SemanticType.BOOL), + ("int", "java.lang.Integer", SemanticType.INT32), + ("long", "java.lang.Long", SemanticType.INT64), + ("float", "java.lang.Float", SemanticType.FLOAT32), + ("double", "java.lang.Double", SemanticType.FLOAT64), + ("java.lang.String", "java.lang.String", SemanticType.STRING), + ("byte[]", "byte[]", SemanticType.BYTES), + ], +) +def test_every_java_scalar_supports_direct_nullable_list_and_nested_nullable_forms( + direct, boxed, semantic +): + assert projected_java_result(JvmTypeSource(direct)) == semantic + assert projected_java_result( + JvmTypeSource(boxed, nullable=True) + ) == SemanticType.nullable(semantic) + assert projected_java_result( + JvmTypeSource( + "java.util.List", + arguments=(JvmTypeSource(boxed),), + ) + ) == SemanticType.array(semantic) + assert projected_java_result( + JvmTypeSource( + "java.util.List", + nullable=True, + arguments=(JvmTypeSource(boxed, nullable=True),), + ) + ) == SemanticType.nullable( + SemanticType.array(SemanticType.nullable(semantic)) + ) + + +@pytest.mark.parametrize( + ("changes", "diagnostic"), + [ + ({"type_parameter_count": 1}, "generic marked JVM types"), + ({"supertypes": ("com.example.Base",)}, "inheritance and interfaces"), + ({"is_final": False}, "must be final"), + ], +) +def test_marked_java_types_reject_deferred_type_forms(changes, diagnostic): + owner_name = "com.example.Stroke" + values = dict( + provenance=source( + jvm_owner_identity(owner_name), JvmLanguage.JAVA, "Stroke.java", 12 + ), + language=JvmLanguage.JAVA, + owner_class=owner_name, + source_name="Stroke", + form=JvmOwnerForm.CLASS, + intent=intent(DeclarationTarget.CLASS, SupernoteMarker.OBJECT), + constructors=(), + declarations=(), + ) + values.update(changes) + owner = JvmOwnerSource(**values) + with pytest.raises(JvmProjectionError, match=diagnostic) as raised: + project_jvm_owners((owner,), feature_id=FEATURE_ID) + assert "Stroke.java:12:1" in str(raised.value) + + +def test_jvm_object_rejects_multiple_selected_constructors_with_source_location(): + owner_name = "com.example.Stroke" + selected = constructor( + owner_name, + JvmLanguage.KOTLIN, + "()V", + (), + SupernoteMarker.CONSTRUCTOR, + ) + second = constructor( + owner_name, + JvmLanguage.KOTLIN, + "(I)V", + (JvmParameterSource("kotlin.Int", "size"),), + SupernoteMarker.CONSTRUCTOR, + ) + owner = JvmOwnerSource( + source(jvm_owner_identity(owner_name), JvmLanguage.KOTLIN, "Stroke.kt", 7), + JvmLanguage.KOTLIN, + owner_name, + "Stroke", + JvmOwnerForm.CLASS, + intent(DeclarationTarget.CLASS, SupernoteMarker.OBJECT), + (selected, second), + (), + ) + with pytest.raises(JvmProjectionError, match="at most one") as raised: + project_jvm_owners((owner,), feature_id=FEATURE_ID) + assert "Stroke.kt:7:1" in str(raised.value) + + +def test_jvm_object_rejects_inaccessible_marked_members(): + owner_name = "com.example.Stroke" + inaccessible = method( + owner_name, + JvmLanguage.KOTLIN, + "hidden", + "()Z", + (), + JvmTypeSource("kotlin.Boolean"), + ) + inaccessible = JvmDeclarationSource( + inaccessible.provenance, + inaccessible.owner_declaration_id, + inaccessible.owner_class, + inaccessible.jvm_name, + inaccessible.jvm_descriptor, + inaccessible.parameters, + inaccessible.result_jvm_type, + inaccessible.result_nullable, + inaccessible.intent, + "private", + inaccessible.adapter_identity, + inaccessible.language, + inaccessible.is_suspend, + inaccessible.is_static, + inaccessible.result_type_arguments, + ) + owner = JvmOwnerSource( + source(jvm_owner_identity(owner_name), JvmLanguage.KOTLIN, "Stroke.kt"), + JvmLanguage.KOTLIN, + owner_name, + "Stroke", + JvmOwnerForm.CLASS, + intent(DeclarationTarget.CLASS, SupernoteMarker.OBJECT), + (), + (inaccessible,), + ) + with pytest.raises(JvmProjectionError, match="must be public") as raised: + project_jvm_owners((owner,), feature_id=FEATURE_ID) + assert "Model.kt:8:1" in str(raised.value) + + +@pytest.mark.parametrize( + ("spelling", "nullable", "arguments", "diagnostic"), + [ + ("java.lang.Integer", False, (), "primitive spelling"), + ("int", True, (), "boxed reference spelling"), + ("java.util.List", False, (), "exactly one"), + ], +) +def test_java_boxing_matrix_rejects_noncanonical_forms( + spelling, nullable, arguments, diagnostic +): + owner_name = "com.example.Api" + declaration = method( + owner_name, + JvmLanguage.JAVA, + "route", + "()V", + (), + JvmTypeSource(spelling, nullable, arguments), + static=True, + target=DeclarationTarget.FUNCTION, + ) + owner = JvmOwnerSource( + source(jvm_owner_identity(owner_name), JvmLanguage.JAVA, "Api.java"), + JvmLanguage.JAVA, + owner_name, + "Api", + JvmOwnerForm.JAVA_STATIC, + intent(DeclarationTarget.CLASS), + (), + (declaration,), + ) + with pytest.raises(JvmProjectionError, match=diagnostic): + project_jvm_owners((owner,), feature_id=FEATURE_ID) + + +def test_jvm_manifest_round_trips_recursive_fields_and_type_arguments(tmp_path): + owner_name = "com.example.Points" + list_type = JvmTypeSource( + "java.util.List", + arguments=(JvmTypeSource("java.lang.Integer", nullable=True),), + ) + owner = JvmOwnerSource( + source(jvm_owner_identity(owner_name), JvmLanguage.JAVA, "Points.java"), + JvmLanguage.JAVA, + owner_name, + "Points", + JvmOwnerForm.CLASS, + intent(DeclarationTarget.CLASS, SupernoteMarker.VALUE), + ( + constructor( + owner_name, + JvmLanguage.JAVA, + "(Ljava/util/List;)V", + ( + JvmParameterSource( + list_type.jvm_type, + "values", + type_arguments=list_type.arguments, + ), + ), + ), + ), + (), + fields=(field(owner_name, JvmLanguage.JAVA, "values", list_type),), + is_record=True, + ) + manifest = JvmSourceManifest(FEATURE_ID, "3.0.0.dev0", (owner,)) + path = tmp_path / "jvm.json" + write_jvm_manifest(path, manifest) + assert read_jvm_manifest(path) == manifest + + +def test_cross_frontend_enum_and_value_projections_merge_exactly(tmp_path): + root = cpp_module( + tmp_path, + """// @SupernotePluginValue +enum class Color { Red, Green }; +// @SupernotePluginValue +struct Point { +// @SupernotePluginExport +double x; +// @SupernotePluginExport +double y; +}; +""", + ) + cpp = binding_codegen.scan_cpp_semantic_model(root) + color_name = "com.example.Color" + point_name = "com.example.Point" + color = JvmOwnerSource( + source(jvm_owner_identity(color_name), JvmLanguage.KOTLIN, "Color.kt"), + JvmLanguage.KOTLIN, + color_name, + "Color", + JvmOwnerForm.CLASS, + intent(DeclarationTarget.CLASS, SupernoteMarker.VALUE), + (), + (), + enum_constants=("Red", "Green"), + ) + point = JvmOwnerSource( + source(jvm_owner_identity(point_name), JvmLanguage.KOTLIN, "Point.kt"), + JvmLanguage.KOTLIN, + point_name, + "Point", + JvmOwnerForm.CLASS, + intent(DeclarationTarget.CLASS, SupernoteMarker.VALUE), + ( + constructor( + point_name, + JvmLanguage.KOTLIN, + "(DD)V", + ( + JvmParameterSource("kotlin.Double", "x"), + JvmParameterSource("kotlin.Double", "y"), + ), + ), + ), + (), + fields=( + field( + point_name, JvmLanguage.KOTLIN, "x", + JvmTypeSource("kotlin.Double"), mutable=True, + ), + field( + point_name, JvmLanguage.KOTLIN, "y", + JvmTypeSource("kotlin.Double"), mutable=True, + ), + ), + is_data=True, + ) + jvm = project_jvm_owners((color, point), feature_id=FEATURE_ID) + merged = merge_semantic_apis(cpp, jvm) + assert len(merged.declarations) == 2 + assert all(len(item.projections) == 2 for item in merged.declarations) diff --git a/tests/test_v3_phase3_reachability_typescript.py b/tests/test_v3_phase3_reachability_typescript.py new file mode 100644 index 0000000..fade7ab --- /dev/null +++ b/tests/test_v3_phase3_reachability_typescript.py @@ -0,0 +1,426 @@ +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +import shutil +import subprocess + +import pytest + +from supernote_module_generator.reachability import ( + PublicReachabilityError, + compute_public_api, +) +from supernote_module_generator.semantic import ( + BackendFamily, + BindingCapabilities, + BindingKind, + DeclarationRole, + ExecutionMode, + MemberScope, + SemanticApi, + SemanticBinding, + SemanticConstructor, + SemanticEnumDeclaration, + SemanticField, + SemanticObjectDeclaration, + SemanticParameter, + SemanticProjection, + SemanticType, + SemanticValueDeclaration, + SourceProvenance, + semantic_type_id, +) +from supernote_module_generator.typescript_codegen import render_typescript + + +FEATURE = "supernote:feature:phase3" + + +def source(identity: str, language: str = "cpp", line: int = 1) -> SourceProvenance: + return SourceProvenance(identity, language, f"{identity}.{language}", line) + + +def projection( + identity: str, + backend: BackendFamily = BackendFamily.CPP, + language: str | None = None, +): + if language is None: + language = "cpp" if backend is BackendFamily.CPP else "kotlin" + return SemanticProjection(backend, source(identity, language)) + + +def field( + owner_id: str, + name: str, + semantic_type: SemanticType, + *, + mutable: bool = False, + language: str = "cpp", +) -> SemanticField: + return SemanticField( + f"{owner_id}:field:{name}", + owner_id, + name, + semantic_type, + source(f"{name}-field", language), + mutable, + ) + + +def method( + owner_id: str, + owner_name: str, + name: str, + *, + result: SemanticType = SemanticType.VOID, + parameters: tuple[SemanticParameter, ...] = (), + scope: MemberScope = MemberScope.INSTANCE, + execution: ExecutionMode = ExecutionMode.SYNC, + language: str = "cpp", +) -> SemanticBinding: + return SemanticBinding( + f"{owner_id}:method:{scope.value}:{name}", + BindingKind.OBJECT_METHOD, + name, + BindingCapabilities.for_role(DeclarationRole.EXPORTED), + execution, + parameters, + result, + source(f"{owner_name}-{scope.value}-{name}", language), + owner_id, + owner_name, + scope, + ) + + +def function( + name: str, + *, + result: SemanticType = SemanticType.VOID, + parameters: tuple[SemanticParameter, ...] = (), + execution: ExecutionMode = ExecutionMode.SYNC, +) -> SemanticBinding: + return SemanticBinding( + f"binding:{name}", + BindingKind.FUNCTION, + name, + BindingCapabilities.for_role(DeclarationRole.EXPORTED), + execution, + parameters, + result, + source(f"function-{name}"), + ) + + +def phase3_api( + backend: BackendFamily = BackendFamily.CPP, + *, + jvm_language: str = "kotlin", +) -> SemanticApi: + language = "cpp" if backend is BackendFamily.CPP else jvm_language + point_id = semantic_type_id(FEATURE, "Point") + color_id = semantic_type_id(FEATURE, "Color") + stroke_id = semantic_type_id(FEATURE, "Stroke") + other_id = semantic_type_id(FEATURE, "OtherStroke") + receipt_id = semantic_type_id(FEATURE, "Receipt") + tools_id = semantic_type_id(FEATURE, "Tools") + hidden_id = semantic_type_id(FEATURE, "Hidden") + + point = SemanticValueDeclaration( + FEATURE, + point_id, + "Point", + ( + field(point_id, "x", SemanticType.FLOAT64, language=language), + field( + point_id, + "tags", + SemanticType.array(SemanticType.nullable(SemanticType.STRING)), + language=language, + ), + field( + point_id, + "color", + SemanticType.enum_ref(color_id), + language=language, + ), + ), + (projection("point", backend, language),), + ) + color = SemanticEnumDeclaration( + FEATURE, + color_id, + "Color", + ("RED", "BLUE"), + (projection("color", backend, language),), + ) + stroke = SemanticObjectDeclaration( + FEATURE, + stroke_id, + "Stroke", + projection("stroke", backend, language), + SemanticConstructor( + source("stroke-constructor", "cpp" if backend is BackendFamily.CPP else "kotlin"), + (SemanticParameter("point", SemanticType.value_ref(point_id)),), + ), + ( + method( + stroke_id, + "Stroke", + "fromPoints", + result=SemanticType.object_ref(stroke_id), + parameters=( + SemanticParameter( + "points", SemanticType.array(SemanticType.value_ref(point_id)) + ), + ), + scope=MemberScope.STATIC, + language=language, + ), + method( + stroke_id, + "Stroke", + "transform", + result=SemanticType.object_ref(stroke_id), + parameters=(SemanticParameter("offset", SemanticType.value_ref(point_id)),), + execution=ExecutionMode.ASYNC, + language=language, + ), + ), + ( + field(stroke_id, "id", SemanticType.INT64, language=language), + field( + stroke_id, + "label", + SemanticType.STRING, + mutable=True, + language=language, + ), + ), + ) + other = SemanticObjectDeclaration( + FEATURE, other_id, "OtherStroke", projection("other", backend, language) + ) + receipt = SemanticObjectDeclaration( + FEATURE, + receipt_id, + "Receipt", + projection("receipt", backend, language), + methods=( + method( + receipt_id, + "Receipt", + "status", + result=SemanticType.STRING, + language=language, + ), + ), + ) + tools = SemanticObjectDeclaration( + FEATURE, + tools_id, + "Tools", + projection("tools", backend, language), + methods=( + method( + tools_id, + "Tools", + "version", + result=SemanticType.STRING, + scope=MemberScope.STATIC, + language=language, + ), + ), + ) + hidden = SemanticObjectDeclaration( + FEATURE, hidden_id, "Hidden", projection("hidden", backend, language) + ) + functions = ( + function( + "useOther", + parameters=(SemanticParameter("other", SemanticType.object_ref(other_id)),), + ), + function("load", result=SemanticType.object_ref(receipt_id)), + function( + "maybe", + parameters=( + SemanticParameter( + "strokes", + SemanticType.array(SemanticType.nullable(SemanticType.object_ref(stroke_id))), + ), + ), + result=SemanticType.nullable( + SemanticType.array(SemanticType.object_ref(stroke_id)) + ), + ), + ) + return SemanticApi( + functions=functions, + declarations=(hidden, tools, receipt, other, stroke, color, point), + ) + + +def test_public_graph_distinguishes_static_namespaces_instances_and_hidden_types(): + public = compute_public_api(phase3_api()) + names = {item.name for item in public.declarations} + by_name = {item.name: item.type_id for item in public.declarations} + + assert names == {"Point", "Color", "Stroke", "OtherStroke", "Receipt", "Tools"} + assert "Hidden" not in names + assert public.object_namespaces == frozenset( + {by_name["Stroke"], by_name["Tools"]} + ) + assert public.object_instances == frozenset( + {by_name["Stroke"], by_name["OtherStroke"], by_name["Receipt"]} + ) + + +def test_typescript_emits_recursive_structural_nominal_and_member_contracts(): + text = render_typescript("Drawing", phase3_api()) + + assert "export type Color = 'RED' | 'BLUE';" in text + assert "export interface Point {" in text + assert " x: number;" in text + assert " tags: (string | null)[];" in text + assert " color: Color;" in text + assert "readonly x" not in text + assert "declare const __supernoteBrand_Stroke: unique symbol;" in text + assert "readonly [__supernoteBrand_Stroke]: void;" in text + assert " readonly id: bigint;" in text + assert " label: string;" in text + assert "transform: SupernoteCallable<[offset: Point], Promise>;" in text + assert "Stroke: SupernoteTypeCompanion & {" in text + assert "create: SupernoteCallable<[point: Point], Stroke>;" in text + assert "fromPoints: SupernoteCallable<[points: Point[]], Stroke>;" in text + assert "Tools: SupernoteTypeCompanion & {" in text + assert "version: SupernoteCallable<[], string>;" in text + assert "Receipt: SupernoteTypeCompanion;" in text + assert "status: SupernoteCallable<[], string>;" in text + assert "maybe: SupernoteCallable<[strokes: (Stroke | null)[]], Stroke[] | null>;" in text + assert "Hidden" not in text + + +def test_static_root_does_not_make_instance_members_reachable(): + object_id = semantic_type_id(FEATURE, "Parser") + item = SemanticObjectDeclaration( + FEATURE, + object_id, + "Parser", + projection("parser"), + methods=( + method( + object_id, + "Parser", + "version", + result=SemanticType.STRING, + scope=MemberScope.STATIC, + ), + method(object_id, "Parser", "parse", result=SemanticType.STRING), + ), + ) + with pytest.raises( + PublicReachabilityError, + match=r"Parser-instance-parse\.cpp:1:1.*Parser\.parse.*unreachable", + ): + compute_public_api(SemanticApi(declarations=(item,))) + + +def test_exported_value_field_without_a_public_type_path_is_an_error(): + point_id = semantic_type_id(FEATURE, "UnusedPoint") + item = SemanticValueDeclaration( + FEATURE, + point_id, + "UnusedPoint", + (field(point_id, "x", SemanticType.FLOAT64),), + (projection("unused-point"),), + ) + with pytest.raises(PublicReachabilityError, match=r"x-field\.cpp:1:1.*unreachable"): + compute_public_api(SemanticApi(declarations=(item,))) + + +def test_static_and_instance_names_are_separate_but_create_and_root_collisions_fail(): + object_id = semantic_type_id(FEATURE, "Codec") + same_names = SemanticObjectDeclaration( + FEATURE, + object_id, + "Codec", + projection("codec"), + SemanticConstructor(source("codec-constructor")), + ( + method(object_id, "Codec", "parse", scope=MemberScope.STATIC), + method(object_id, "Codec", "parse"), + ), + ) + text = render_typescript("Codecs", SemanticApi(declarations=(same_names,))) + assert text.count("parse: SupernoteCallable<[], void>;") == 2 + + create_collision = replace( + same_names, + methods=(method(object_id, "Codec", "create", scope=MemberScope.STATIC),), + ) + with pytest.raises(PublicReachabilityError, match=r"create.*Codec type namespace"): + compute_public_api(SemanticApi(declarations=(create_collision,))) + + returned_only = replace(same_names, constructor=None, methods=()) + with pytest.raises(PublicReachabilityError, match=r"Codec.*feature root"): + compute_public_api( + SemanticApi( + functions=(function("Codec", result=SemanticType.object_ref(object_id)),), + declarations=(returned_only,), + ) + ) + + +@pytest.mark.parametrize("reserved", ["SupernoteError", "SupernoteErrorCode", "DrawingFeature"]) +def test_reachable_types_cannot_collide_with_generated_types(reserved: str): + object_id = semantic_type_id(FEATURE, reserved) + item = SemanticObjectDeclaration( + FEATURE, + object_id, + reserved, + projection(f"reserved-{reserved}"), + SemanticConstructor(source(f"reserved-{reserved}-constructor")), + ) + with pytest.raises(PublicReachabilityError, match=r"collides with generated TypeScript"): + render_typescript("Drawing", SemanticApi(declarations=(item,))) + + +def test_equivalent_cpp_and_jvm_semantics_generate_identical_public_typescript(): + cpp = render_typescript("Drawing", phase3_api(BackendFamily.CPP)) + kotlin = render_typescript("Drawing", phase3_api(BackendFamily.JVM)) + java = render_typescript( + "Drawing", phase3_api(BackendFamily.JVM, jvm_language="java") + ) + assert cpp == kotlin == java + + +def test_generated_contract_and_expect_error_fixture_pass_real_tsc(tmp_path: Path): + tsc = shutil.which("tsc") + if tsc is None: + pytest.skip("TypeScript compiler is unavailable") + fixture_root = Path(__file__).parent / "fixtures/v3_typescript" + generated = render_typescript("Drawing", phase3_api()) + assert generated == (fixture_root / "index.d.ts").read_text(encoding="utf-8") + (tmp_path / "index.d.ts").write_text(generated, encoding="utf-8") + fixture = fixture_root / "consumer.ts" + (tmp_path / "consumer.ts").write_text(fixture.read_text(encoding="utf-8"), encoding="utf-8") + completed = subprocess.run( + [ + tsc, + "--noEmit", + "--strict", + "--target", + "ES2020", + "--moduleResolution", + "node", + "consumer.ts", + ], + cwd=tmp_path, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + assert completed.returncode == 0, completed.stdout diff --git a/tests/test_v3_phase4_conversion.py b/tests/test_v3_phase4_conversion.py new file mode 100644 index 0000000..344c7ba --- /dev/null +++ b/tests/test_v3_phase4_conversion.py @@ -0,0 +1,529 @@ +from __future__ import annotations + +from collections.abc import Iterator, Mapping +from dataclasses import replace +import math +import random + +import pytest + +from supernote_module_generator.conversion import ( + ARRAY_HOLE, + UNDEFINED, + AllocationFaultInjector, + ConversionAllocationError, + ConversionBudget, + ConversionDirection, + ConversionLimits, + ConversionNodeKind, + ConversionPlanError, + ConversionRangeError, + ConversionTypeError, + JsBigInt, + JsUint8Array, + NativeObjectToken, + accept_transactionally, + assign_transactionally, + construct_transactionally, + invoke_transactionally, + plan_api_conversion, + plan_binding_conversion, + prepare_arguments, + prepare_result, +) +from supernote_module_generator.lowering import ( + CppFunctionRoute, + JvmMethodRoute, + LoweringError, + LoweringPlan, + RouteKind, + SchedulingKind, +) +from supernote_module_generator.semantic import ( + BackendFamily, + BindingCapabilities, + BindingKind, + DeclarationRole, + ExecutionMode, + SemanticApi, + SemanticBinding, + SemanticConstructor, + SemanticEnumDeclaration, + SemanticField, + SemanticObjectDeclaration, + SemanticParameter, + SemanticProjection, + SemanticType, + SemanticValueDeclaration, + SourceProvenance, + semantic_type_id, +) + + +FEATURE = "supernote:feature:conversion" + + +def source(identity: str) -> SourceProvenance: + return SourceProvenance(identity, "cpp", f"{identity}.hpp", 7) + + +def projection(identity: str) -> SemanticProjection: + return SemanticProjection(BackendFamily.CPP, source(identity)) + + +def field(owner: str, name: str, semantic_type: SemanticType) -> SemanticField: + return SemanticField( + f"{owner}:field:{name}", owner, name, semantic_type, source(f"field-{name}"), False + ) + + +def conversion_api(*, limits: ConversionLimits | None = None): + color_id = semantic_type_id(FEATURE, "Color") + point_id = semantic_type_id(FEATURE, "Point") + stroke_id = semantic_type_id(FEATURE, "Stroke") + payload_id = semantic_type_id(FEATURE, "Payload") + color = SemanticEnumDeclaration( + FEATURE, color_id, "Color", ("RED", "BLUE"), (projection("color"),) + ) + point = SemanticValueDeclaration( + FEATURE, + point_id, + "Point", + ( + field(point_id, "name", SemanticType.STRING), + field(point_id, "color", SemanticType.enum_ref(color_id)), + field( + point_id, + "samples", + SemanticType.array(SemanticType.nullable(SemanticType.INT32)), + ), + field(point_id, "blob", SemanticType.BYTES), + ), + (projection("point"),), + ) + stroke = SemanticObjectDeclaration( + FEATURE, stroke_id, "Stroke", projection("stroke") + ) + payload = SemanticValueDeclaration( + FEATURE, + payload_id, + "Payload", + ( + field(payload_id, "points", SemanticType.array(SemanticType.value_ref(point_id))), + field( + payload_id, + "owner", + SemanticType.nullable(SemanticType.object_ref(stroke_id)), + ), + ), + (projection("payload"),), + ) + binding = SemanticBinding( + "binding:convert", + BindingKind.FUNCTION, + "convert", + BindingCapabilities.for_role(DeclarationRole.EXPORTED), + ExecutionMode.SYNC, + ( + SemanticParameter("payload", SemanticType.value_ref(payload_id)), + SemanticParameter("revision", SemanticType.INT64), + ), + SemanticType.value_ref(payload_id), + source("convert"), + ) + api = SemanticApi(functions=(binding,), declarations=(payload, stroke, point, color)) + return api, binding, plan_binding_conversion( + api, binding, limits=limits or ConversionLimits() + ) + + +def point(name: str = "ink", *, extra: object = 42) -> dict[str, object]: + return { + "name": name, + "color": "RED", + "samples": [1, None, 3], + "blob": JsUint8Array(b"PREFIX-visible-SUFFIX", 7, 7), + "ignored": extra, + } + + +def test_plan_is_recursive_deterministic_and_contains_one_limits_contract(): + _, binding, plan = conversion_api() + assert plan.binding_id == binding.binding_id + payload = plan.parameters[0].node + assert payload.kind is ConversionNodeKind.VALUE + assert [item.name for item in payload.fields] == ["points", "owner"] + points = payload.fields[0].node + assert points.kind is ConversionNodeKind.ARRAY + assert points.element.kind is ConversionNodeKind.VALUE + samples = points.element.fields[2].node + assert samples.kind is ConversionNodeKind.ARRAY + assert samples.element.kind is ConversionNodeKind.NULLABLE + assert plan.manifest() == conversion_api()[2].manifest() + assert plan.manifest()["limits"] == ConversionLimits().manifest() + + +def test_cpp_and_jvm_recursive_lowerings_require_and_share_the_same_plan(): + _, binding, conversion = conversion_api() + without = LoweringPlan( + binding.binding_id, + binding.source.declaration_id, + RouteKind.DIRECT_CPP_FUNCTION, + SchedulingKind.INLINE, + CppFunctionRoute("convert"), + ) + with pytest.raises(LoweringError, match="shared binding conversion plan"): + without.validate_binding(binding) + + cpp = LoweringPlan( + binding.binding_id, + binding.source.declaration_id, + RouteKind.DIRECT_CPP_FUNCTION, + SchedulingKind.INLINE, + CppFunctionRoute("convert"), + conversion, + ) + jvm = LoweringPlan( + binding.binding_id, + binding.source.declaration_id, + RouteKind.JVM_FUNCTION, + SchedulingKind.INLINE, + JvmMethodRoute("FeatureApi", "convert", "()V", "adapter"), + conversion, + ) + cpp.validate_binding(binding) + jvm.validate_binding(binding) + assert cpp.conversion.manifest() == jvm.conversion.manifest() + + +def test_lowering_rejects_a_conversion_plan_for_another_semantic_signature(): + _, binding, conversion = conversion_api() + route = LoweringPlan( + binding.binding_id, + binding.source.declaration_id, + RouteKind.DIRECT_CPP_FUNCTION, + SchedulingKind.INLINE, + CppFunctionRoute("convert"), + replace(conversion, result_type=SemanticType.STRING), + ) + with pytest.raises(LoweringError, match="signature disagrees"): + route.validate_binding(binding) + + +def test_api_plan_covers_free_methods_constructors_and_live_fields(): + api, binding, _ = conversion_api() + stroke = next( + item for item in api.declarations if isinstance(item, SemanticObjectDeclaration) + ) + payload_id = semantic_type_id(FEATURE, "Payload") + method = SemanticBinding( + "binding:stroke-transform", + BindingKind.OBJECT_METHOD, + "transform", + BindingCapabilities.for_role(DeclarationRole.EXPORTED), + ExecutionMode.SYNC, + (SemanticParameter("payload", SemanticType.value_ref(payload_id)),), + SemanticType.object_ref(stroke.type_id), + source("stroke-transform"), + stroke.type_id, + stroke.name, + ) + live_field = SemanticField( + f"{stroke.type_id}:field:payload", + stroke.type_id, + "payload", + SemanticType.value_ref(payload_id), + source("stroke-payload"), + True, + ) + extended = SemanticObjectDeclaration( + stroke.feature_id, + stroke.type_id, + stroke.name, + stroke.projection, + SemanticConstructor( + source("stroke-constructor"), + (SemanticParameter("payload", SemanticType.value_ref(payload_id)),), + ), + (method,), + (live_field,), + ) + planned_api = SemanticApi( + functions=api.functions, + declarations=tuple( + extended if item is stroke else item for item in api.declarations + ), + ) + planned = plan_api_conversion(planned_api) + assert {item.binding_id for item in planned.bindings} == { + binding.binding_id, + method.binding_id, + } + assert [item.type_id for item in planned.constructors] == [stroke.type_id] + assert any(item.field_id == live_field.field_id for item in planned.fields) + + +def test_input_snapshot_copies_only_visible_bytes_and_retains_nested_object_leaves(): + _, _, plan = conversion_api() + token = NativeObjectToken( + semantic_type_id(FEATURE, "Stroke"), "cpp", object() + ) + source_point = point() + arguments = [{"points": [source_point], "owner": token}, JsBigInt(9)] + prepared = prepare_arguments(plan, arguments, public_path="Drawing.convert") + + payload = prepared.values[0] + assert payload["points"][0]["blob"] == b"visible" + assert payload["points"][0]["samples"] == (1, None, 3) + assert payload["owner"] is token + assert prepared.values[1] == 9 + assert prepared.retained_objects == (token,) + source_point["samples"].append(99) + assert payload["points"][0]["samples"] == (1, None, 3) + + +class DeclaredOnlyMapping(Mapping[str, object]): + def __init__(self, values: dict[str, object]): + self.values = values + self.reads: list[str] = [] + + def __getitem__(self, key: str) -> object: + self.reads.append(key) + if key == "ignored": + raise AssertionError("undeclared property was inspected") + return self.values[key] + + def __iter__(self) -> Iterator[str]: + raise AssertionError("value conversion enumerated JavaScript properties") + + def __len__(self) -> int: + raise AssertionError("value conversion measured the dynamic object") + + +def test_declared_value_conversion_never_enumerates_or_reads_unknown_properties(): + _, _, plan = conversion_api() + wrapped_point = DeclaredOnlyMapping(point()) + payload = DeclaredOnlyMapping({"points": [wrapped_point], "owner": None}) + prepared = prepare_arguments( + plan, + [payload, JsBigInt(1)], + public_path="Drawing.convert", + ) + assert set(payload.reads) == {"points", "owner"} + assert set(wrapped_point.reads) == {"name", "color", "samples", "blob"} + assert prepared.values[0]["points"][0]["name"] == "ink" + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + (lambda value: value["points"][0].pop("name"), r"points\[0\]\.name.*missing"), + (lambda value: value["points"][0].__setitem__("color", "GREEN"), r"color.*Color"), + (lambda value: value["points"][0]["samples"].__setitem__(1, UNDEFINED), r"samples\[1\].*undefined"), + (lambda value: value["points"][0]["samples"].__setitem__(1, ARRAY_HOLE), r"samples\[1\].*hole"), + (lambda value: value.__setitem__("owner", NativeObjectToken(semantic_type_id(FEATURE, "Other"), "cpp", object())), r"owner.*Stroke"), + ], +) +def test_nested_type_failures_have_exact_field_and_index_paths(mutation, message): + _, _, plan = conversion_api() + value = {"points": [point()], "owner": None} + mutation(value) + with pytest.raises(ConversionTypeError, match=message): + prepare_arguments(plan, [value, JsBigInt(1)], public_path="Drawing.convert") + + +def test_dense_arrays_reject_holes_even_when_elements_are_nullable(): + _, _, plan = conversion_api() + value = {"points": [point()], "owner": None} + value["points"][0]["samples"] = [None, ARRAY_HOLE] + with pytest.raises(ConversionTypeError, match=r"samples\[1\].*hole"): + prepare_arguments(plan, [value, JsBigInt(1)], public_path="Drawing.convert") + + +def test_output_conversion_creates_fresh_containers_bigints_and_uint8arrays(): + _, _, plan = conversion_api() + token = NativeObjectToken(semantic_type_id(FEATURE, "Stroke"), "cpp", object()) + native_point = { + "name": "ink", + "color": "BLUE", + "samples": (1, None, 3), + "blob": bytearray(b"abc"), + } + native = {"points": (native_point,), "owner": token} + prepared = prepare_result(plan, native, public_path="Drawing.convert") + assert prepared.value is not native + assert prepared.value["points"] is not native["points"] + assert prepared.value["points"][0] is not native_point + assert prepared.value["points"][0]["samples"] == [1, None, 3] + assert prepared.value["points"][0]["blob"] == JsUint8Array(b"abc") + assert prepared.retained_objects == (token,) + + +def test_numeric_and_utf8_rules_preserve_v2_visible_behavior(): + _, _, plan = conversion_api() + value = {"points": [point("שלום")], "owner": None} + prepared = prepare_arguments(plan, [value, JsBigInt(-(1 << 63))], public_path="x") + assert prepared.values[0]["points"][0]["name"] == "שלום" + assert prepared.values[1] == -(1 << 63) + + value["points"][0]["samples"] = [1.5] + with pytest.raises(ConversionRangeError, match="finite and integral"): + prepare_arguments(plan, [value, JsBigInt(0)], public_path="x") + with pytest.raises(ConversionRangeError, match="out of range"): + prepare_arguments(plan, [{"points": [point()], "owner": None}, JsBigInt(1 << 63)], public_path="x") + + # Existing floating-point behavior permits non-finite JS numbers while + # still rejecting finite float32 overflow. + float_api = SemanticApi() + for number in (math.inf, -math.inf, math.nan): + node = plan_binding_conversion( + float_api, + SemanticBinding( + f"float:{number}", BindingKind.FUNCTION, "f", + BindingCapabilities.for_role(DeclarationRole.EXPORTED), + ExecutionMode.SYNC, (SemanticParameter("x", SemanticType.FLOAT32),), + SemanticType.VOID, source(f"float-{number}"), + ), + ) + assert math.isnan(prepare_arguments(node, [number], public_path="f").values[0]) if math.isnan(number) else prepare_arguments(node, [number], public_path="f").values[0] == number + + +@pytest.mark.parametrize( + ("limits", "change", "message"), + [ + (ConversionLimits(max_depth=3), lambda value: None, "depth exceeds"), + (ConversionLimits(max_array_length=1), lambda value: value["points"].append(point()), "array length"), + (ConversionLimits(max_visited_nodes=4), lambda value: None, "visits exceed"), + (ConversionLimits(max_string_bytes=3), lambda value: value["points"][0].__setitem__("name", "four"), "UTF-8 string"), + (ConversionLimits(max_byte_buffer_bytes=2), lambda value: None, "byte buffer"), + (ConversionLimits(max_temporary_bytes=16), lambda value: None, "temporary allocation"), + ], +) +def test_every_conversion_budget_fails_closed_with_a_range_error(limits, change, message): + _, _, plan = conversion_api(limits=limits) + value = {"points": [point()], "owner": None} + change(value) + with pytest.raises(ConversionRangeError, match=message): + prepare_arguments(plan, [value, JsBigInt(1)], public_path="Drawing.convert") + + +def test_budget_counter_addition_is_overflow_safe(): + budget = ConversionBudget(ConversionLimits()) + budget.temporary_bytes = budget._MAX_COUNTER + with pytest.raises(ConversionRangeError, match="counter overflow"): + budget.reserve(1, "Drawing.convert.argument[0]") + + +@pytest.mark.parametrize("fail_after", range(7)) +def test_allocation_failure_before_completion_never_invokes_or_accepts_work(fail_after): + _, _, plan = conversion_api() + value = {"points": [point()], "owner": None} + calls: list[object] = [] + injector = AllocationFaultInjector(fail_after=fail_after) + with pytest.raises(ConversionAllocationError): + accept_transactionally( + plan, + [value, JsBigInt(1)], + calls.append, + public_path="Drawing.convert", + injector=injector, + ) + assert calls == [] + + +@pytest.mark.parametrize("fail_after", range(7)) +def test_allocation_failure_never_invokes_sync_code_or_constructor(fail_after): + api, _, plan = conversion_api() + value = {"points": [point()], "owner": None} + sync_calls: list[object] = [] + with pytest.raises(ConversionAllocationError): + invoke_transactionally( + plan, + [value, JsBigInt(1)], + lambda *arguments: sync_calls.append(arguments), + public_path="Drawing.convert", + injector=AllocationFaultInjector(fail_after=fail_after), + ) + assert sync_calls == [] + + payload_id = semantic_type_id(FEATURE, "Payload") + constructor_plan = plan_api_conversion( + SemanticApi( + declarations=tuple( + SemanticObjectDeclaration( + item.feature_id, + item.type_id, + item.name, + item.projection, + SemanticConstructor( + source("constructor"), + (SemanticParameter("payload", SemanticType.value_ref(payload_id)),), + ), + ) + if isinstance(item, SemanticObjectDeclaration) + else item + for item in api.declarations + ) + ) + ).constructors[0] + constructor_calls: list[object] = [] + with pytest.raises(ConversionAllocationError): + construct_transactionally( + constructor_plan, + [value], + lambda *arguments: constructor_calls.append(arguments), + public_path="Stroke.create", + injector=AllocationFaultInjector(fail_after=fail_after), + ) + assert constructor_calls == [] + + +def test_conversion_limits_fit_the_shared_signed_64_bit_counter(): + with pytest.raises(ConversionPlanError, match="signed 64-bit"): + ConversionLimits(max_temporary_bytes=1 << 63) + + +def test_field_assignment_occurs_once_only_after_complete_validation(): + api, _, plan = conversion_api() + payload_node = plan.parameters[0].node + state = {"value": "unchanged", "sets": 0} + + def setter(value): + state["sets"] += 1 + state["value"] = value + + invalid = {"points": [point()], "owner": UNDEFINED} + with pytest.raises(ConversionTypeError): + assign_transactionally( + payload_node, invalid, setter, public_path="Stroke.payload" + ) + assert state == {"value": "unchanged", "sets": 0} + + valid = {"points": [point()], "owner": None} + assigned = assign_transactionally( + payload_node, valid, setter, public_path="Stroke.payload" + ) + assert state["sets"] == 1 + assert state["value"] == assigned.value + assert state["value"] is not valid + assert api.declarations + + +def test_seeded_nested_array_fuzz_never_loses_the_failing_path(): + _, _, plan = conversion_api() + rng = random.Random(0xC0A7) + for iteration in range(10_000): + length = rng.randrange(0, 8) + samples: list[object] = [rng.randrange(-(1 << 31), 1 << 31) for _ in range(length)] + bad_index = None + if samples and rng.random() < 0.7: + bad_index = rng.randrange(len(samples)) + samples[bad_index] = rng.choice([UNDEFINED, ARRAY_HOLE, "wrong", 1.5]) + value = {"points": [point()], "owner": None} + value["points"][0]["samples"] = samples + if bad_index is None: + prepare_arguments(plan, [value, JsBigInt(iteration)], public_path="fuzz") + else: + with pytest.raises((ConversionTypeError, ConversionRangeError)) as raised: + prepare_arguments(plan, [value, JsBigInt(iteration)], public_path="fuzz") + assert f"samples[{bad_index}]" in str(raised.value) diff --git a/tests/test_v3_phase4_generated_kernels.py b/tests/test_v3_phase4_generated_kernels.py new file mode 100644 index 0000000..3783dd3 --- /dev/null +++ b/tests/test_v3_phase4_generated_kernels.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + +from supernote_module_generator.conversion import DEFAULT_CONVERSION_LIMITS +from supernote_module_generator.conversion_codegen import ( + render_cpp_conversion_kernel, + render_jvm_conversion_kernel, +) +from supernote_module_generator.binding_codegen import render_v2_feature_jsi +from supernote_module_generator.feature_model import PluginRuntimeRegistry +from supernote_module_generator.jvm_codegen import render_jvm_feature_jsi +from supernote_module_generator.jvm_manifest import JvmSourceManifest +from supernote_module_generator.plugin_runtime_codegen import generated_runtime_files +from supernote_module_generator.semantic import SemanticApi + + +def test_plugin_runtime_contains_one_cpp_and_jvm_limits_contract(): + registry = PluginRuntimeRegistry.create( + plugin_id="phase4-kernel", + generator_version="3.0.0.dev0", + features=(), + ) + files = generated_runtime_files(registry) + cpp = files["include/supernote/conversion.hpp"] + kotlin = files[ + "src/main/java/supernote/generated/runtime/SupernoteConversionBudget.kt" + ] + for value in DEFAULT_CONVERSION_LIMITS.manifest().values(): + assert str(value) in cpp + assert str(value) in kotlin + ownership = files["ownership.json"] + assert "include/supernote/conversion.hpp" in ownership + assert "SupernoteConversionBudget.kt" in ownership + + +def test_v3_feature_translation_units_include_shared_conversion_kernel( + tmp_path: Path, +): + digest = "a" * 64 + feature_id = "supernote:feature:0123456789abcdef" + cpp = render_v2_feature_jsi( + tmp_path, + module_name="Drawing", + feature_id=feature_id, + conversion_digest=digest, + ) + jvm = render_jvm_feature_jsi( + JvmSourceManifest(feature_id, "3.0.0.dev0", ()), + SemanticApi(), + feature_id=feature_id, + module_name="Drawing", + conversion_digest=digest, + ) + expected = ( + "// Supernote V3 conversion plan SHA-256: " + digest + "\n" + "#include \n" + ) + assert cpp.startswith(expected + "#include \n") + assert jvm.startswith(expected) + + +def test_generated_cpp23_kernel_passes_strict_sanitized_bounded_fuzz(tmp_path: Path): + compiler = shutil.which("clang++") + if compiler is None: + pytest.skip("clang++ is unavailable") + header = tmp_path / "conversion.hpp" + source = tmp_path / "harness.cpp" + binary = tmp_path / "harness" + header.write_text(render_cpp_conversion_kernel(), encoding="utf-8") + source.write_text( + r'''#include "conversion.hpp" + +#include +#include +#include + +int main() { + using namespace supernote::conversion; + if (field_path("root", "field") != "root.field") return 1; + if (index_path("root.items", 17) != "root.items[17]") return 2; + + std::uint64_t state = 0x5A17U; + for (std::uint64_t round = 0; round < 1000; ++round) { + Budget budget; + for (std::uint64_t index = 0; index < 128; ++index) { + state = state * 6364136223846793005ULL + 1442695040888963407ULL; + const auto depth = state % Limits::max_depth + 1; + const auto path = index_path("fuzz", index); + budget.visit(path, depth); + budget.check_array_length(path, state % (Limits::max_array_length + 1)); + budget.check_string_bytes(path, state % (Limits::max_string_bytes + 1)); + budget.check_byte_buffer(path, state % (Limits::max_byte_buffer_bytes + 1)); + budget.reserve(path, state % 32); + } + } + + AllocationGate gate(2); + Budget injected(&gate); + injected.reserve("input.a", 1); + injected.reserve("input.b", 1); + try { + injected.reserve("input.c", 1); + return 3; + } catch (const Failure &failure) { + if (failure.kind() != FailureKind::ALLOCATION || + failure.path() != "input.c") return 4; + } + + try { + Budget budget; + budget.reserve("huge", Limits::max_temporary_bytes + 1); + return 5; + } catch (const Failure &failure) { + if (failure.kind() != FailureKind::RANGE || failure.path() != "huge") return 6; + } + try { + Budget budget; + budget.reserve("counter", static_cast(INT64_MAX)); + budget.reserve("counter", 1); + return 7; + } catch (const Failure &failure) { + if (failure.kind() != FailureKind::RANGE || failure.path() != "counter") return 8; + } + std::cout << "CPP_CONVERSION_KERNEL_PASS\n"; + return 0; +} +''', + encoding="utf-8", + ) + compile_result = subprocess.run( + [ + compiler, + "-std=c++23", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + "-fsanitize=address,undefined", + "-fno-omit-frame-pointer", + str(source), + "-o", + str(binary), + ], + cwd=tmp_path, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + assert compile_result.returncode == 0, compile_result.stdout + environment = dict(os.environ) + environment["ASAN_OPTIONS"] = "detect_leaks=0:halt_on_error=1" + run_result = subprocess.run( + [str(binary)], + cwd=tmp_path, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + assert run_result.returncode == 0, run_result.stdout + assert "CPP_CONVERSION_KERNEL_PASS" in run_result.stdout + + +def test_generated_kotlin_kernel_compiles_and_runs_failure_harness(tmp_path: Path): + kotlinc = shutil.which("kotlinc") + java = shutil.which("java") + if kotlinc is None or java is None: + pytest.skip("Kotlin/JVM compiler is unavailable") + kernel = tmp_path / "SupernoteConversionBudget.kt" + harness = tmp_path / "Harness.kt" + jar = tmp_path / "harness.jar" + kernel.write_text(render_jvm_conversion_kernel(), encoding="utf-8") + harness.write_text( + '''package supernote.generated.runtime + +fun main() { + check(conversionFieldPath("root", "field") == "root.field") + check(conversionIndexPath("root.items", 17) == "root.items[17]") + val budget = SupernoteConversionBudget() + repeat(10000) { index -> + budget.visit(conversionIndexPath("fuzz", index.toLong()), 2) + budget.reserve("fuzz", 1) + } + val gate = SupernoteAllocationGate(1) + val injected = SupernoteConversionBudget(gate) + injected.reserve("input.a", 1) + try { + injected.reserve("input.b", 1) + error("allocation failure was not injected") + } catch (failure: SupernoteConversionFailure) { + check(failure.kind == SupernoteConversionFailureKind.ALLOCATION) + check(failure.path == "input.b") + } + println("JVM_CONVERSION_KERNEL_PASS") +} +''', + encoding="utf-8", + ) + compile_result = subprocess.run( + [kotlinc, str(kernel), str(harness), "-include-runtime", "-d", str(jar)], + cwd=tmp_path, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + assert compile_result.returncode == 0, compile_result.stdout + run_result = subprocess.run( + [java, "-jar", str(jar)], + cwd=tmp_path, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + assert run_result.returncode == 0, run_result.stdout + assert "JVM_CONVERSION_KERNEL_PASS" in run_result.stdout diff --git a/tests/test_v3_phase5_cpp_object_runtime.py b/tests/test_v3_phase5_cpp_object_runtime.py new file mode 100644 index 0000000..79aba3c --- /dev/null +++ b/tests/test_v3_phase5_cpp_object_runtime.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + +from supernote_module_generator.cpp_object_runtime_codegen import ( + render_cpp_object_runtime, +) +from supernote_module_generator.feature_model import PluginRuntimeRegistry +from supernote_module_generator.plugin_runtime_codegen import generated_runtime_files + + +def test_plugin_runtime_owns_nominal_cpp_object_header(): + files = generated_runtime_files( + PluginRuntimeRegistry.create( + plugin_id="phase5-cpp-objects", + generator_version="3.0.0.dev0", + features=(), + ) + ) + header = files["include/supernote/cpp_objects.hpp"] + assert "class CppObjectHandleBase" in header + assert "class CppObjectHandle" in header + assert "class CppObjectRegistry" in header + assert "class ManagedAnyRef" in header + assert "facebook::jsi::WeakObject" in header + assert "std::owner_less>" in header + assert "reinterpret_cast" not in header + assert "include/supernote/cpp_objects.hpp" in files["ownership.json"] + + +def test_cpp_object_registry_identity_aliasing_and_nominal_extraction(tmp_path: Path): + compiler = shutil.which("clang++") or shutil.which("c++") + if compiler is None: + pytest.skip("a C++23 compiler is unavailable") + + (tmp_path / "jsi").mkdir() + (tmp_path / "jsi/jsi.h").write_text( + r'''#pragma once +#include +#include + +namespace facebook::jsi { +class Runtime {}; +class HostObject { + public: + virtual ~HostObject() = default; +}; +struct ObjectState { + explicit ObjectState(std::shared_ptr value) + : host(std::move(value)) {} + std::shared_ptr host; +}; +class Object; +class Value { + public: + Value() = default; + explicit Value(std::shared_ptr state) : state_(std::move(state)) {} + bool isObject() const { return static_cast(state_); } + Object getObject(Runtime &) const; + protected: + std::shared_ptr state_; + friend class Object; + friend class WeakObject; +}; +class Object : public Value { + public: + Object() = default; + explicit Object(std::shared_ptr state) : Value(std::move(state)) {} + static Object createFromHostObject( + Runtime &, std::shared_ptr host) { + return Object(std::make_shared(std::move(host))); + } + template + bool isHostObject(Runtime &) const { + return state_ && std::dynamic_pointer_cast(state_->host); + } + template + std::shared_ptr getHostObject(Runtime &) const { + return std::dynamic_pointer_cast(state_->host); + } + const void *identity() const { return state_.get(); } +}; +inline Object Value::getObject(Runtime &) const { return Object(state_); } +class WeakObject { + public: + WeakObject(Runtime &, const Object &object) : state_(object.state_) {} + WeakObject(WeakObject &&) = default; + WeakObject &operator=(WeakObject &&) = default; + Value lock(Runtime &) const { return Value(state_.lock()); } + private: + std::weak_ptr state_; +}; +} // namespace facebook::jsi +''', + encoding="utf-8", + ) + (tmp_path / "runtime_services.hpp").write_text( + r'''#pragma once +#include +#include +#include +#include +namespace supernote::runtime { +class DeferredDestruction { + public: + template + bool submit(Cleanup &&cleanup) { + queued_.emplace_back(std::forward(cleanup)); + return true; + } + void drain() { + auto queued = std::move(queued_); + for (auto &cleanup : queued) cleanup(); + } + private: + std::vector> queued_; +}; +template +class ManagedRef { + public: + ManagedRef() = default; + ManagedRef(std::shared_ptr value, std::shared_ptr) + : value_(std::move(value)) {} + T *get() const { return value_.get(); } + explicit operator bool() const { return static_cast(value_); } + const std::shared_ptr &shared_ref() const { return value_; } + private: + std::shared_ptr value_; +}; +} // namespace supernote::runtime +''', + encoding="utf-8", + ) + (tmp_path / "cpp_objects.hpp").write_text( + render_cpp_object_runtime(), encoding="utf-8" + ) + (tmp_path / "harness.cpp").write_text( + r'''#include "cpp_objects.hpp" + +#include +#include + +namespace { +struct Native { int value = 7; }; +struct Other { int value = 9; }; +struct Owner { Native first; Native second; }; +struct Tracked { + explicit Tracked(int *destroyed) : destroyed(destroyed) {} + ~Tracked() { ++*destroyed; } + int *destroyed; +}; + +class NativeHost final + : public supernote::runtime::CppObjectHandle { + public: + using CppObjectHandle::CppObjectHandle; +}; +class OtherHost final + : public supernote::runtime::CppObjectHandle { + public: + using CppObjectHandle::CppObjectHandle; +}; +} // namespace + +int main() { + using namespace supernote::runtime; + constexpr char kNativeType[] = "supernote:type:native"; + facebook::jsi::Runtime runtime; + auto cleanup = std::make_shared(); + auto registry = std::make_shared(cleanup); + auto native = std::make_shared(); + + const void *first_identity = nullptr; + { + auto first = registry->wrap( + runtime, kNativeType, native, + [](ManagedRef value) { + return std::make_shared( + "supernote:type:native", std::move(value)); + }); + auto second = registry->wrap( + runtime, kNativeType, native, + [](ManagedRef value) { + return std::make_shared( + "supernote:type:native", std::move(value)); + }); + if (first.identity() != second.identity()) return 1; + first_identity = first.identity(); + auto extracted = try_extract_cpp_object( + runtime, first, kNativeType); + if (!extracted || extracted.shared_ref() != native) return 2; + if (try_extract_cpp_object(runtime, first, "wrong")) return 3; + if (try_extract_cpp_object(runtime, first, kNativeType)) return 4; + if (cpp_object_type_id(runtime, first) != kNativeType) return 5; + } + + auto replacement = registry->wrap( + runtime, kNativeType, native, + [](ManagedRef value) { + return std::make_shared( + "supernote:type:native", std::move(value)); + }); + if (replacement.identity() == first_identity) return 6; + if (registry->size_for_testing() != 1) return 7; + + for (int iteration = 0; iteration < 10000; ++iteration) { + auto exposure = registry->wrap( + runtime, kNativeType, native, + [](ManagedRef value) { + return std::make_shared( + "supernote:type:native", std::move(value)); + }); + if (exposure.identity() != replacement.identity()) return 100; + } + + auto gc_native = std::make_shared(); + for (int iteration = 0; iteration < 1000; ++iteration) { + const void *collected_identity = nullptr; + { + auto collected = registry->wrap( + runtime, "supernote:type:gc", gc_native, + [](ManagedRef value) { + return std::make_shared( + "supernote:type:gc", std::move(value)); + }); + collected_identity = collected.identity(); + } + auto reexposed = registry->wrap( + runtime, "supernote:type:gc", gc_native, + [](ManagedRef value) { + return std::make_shared( + "supernote:type:gc", std::move(value)); + }); + if (reexposed.identity() == collected_identity) return 101; + } + + auto owner = std::make_shared(); + std::shared_ptr first_alias(owner, &owner->first); + std::shared_ptr same_alias(owner, &owner->first); + std::shared_ptr other_alias(owner, &owner->second); + auto identity = CppObjectIdentity::from(kNativeType, first_alias); + if (!identity.matches(kNativeType, same_alias)) return 8; + if (identity.matches(kNativeType, other_alias)) return 9; + std::shared_ptr foreign_owner( + &owner->first, [](Native *) {}); + if (identity.matches(kNativeType, foreign_owner)) return 10; + if (identity.matches("another-type", same_alias)) return 11; + + auto alias_object = registry->wrap( + runtime, "supernote:type:alias", first_alias, + [](ManagedRef value) { + return std::make_shared( + "supernote:type:alias", std::move(value)); + }); + bool conflicting_owner_rejected = false; + try { + (void)registry->wrap( + runtime, "supernote:type:alias", foreign_owner, + [](ManagedRef value) { + return std::make_shared( + "supernote:type:alias", std::move(value)); + }); + } catch (const std::logic_error &) { + conflicting_owner_rejected = true; + } + if (!conflicting_owner_rejected || !alias_object.isObject()) return 12; + + auto other = std::make_shared(); + auto other_object = registry->wrap( + runtime, "supernote:type:other", other, + [](ManagedRef value) { + return std::make_shared( + "supernote:type:other", std::move(value)); + }); + if (!try_extract_cpp_object( + runtime, other_object, "supernote:type:other")) return 13; + + int destroyed = 0; + auto tracked = std::make_shared(&destroyed); + ManagedAnyRef retained(tracked, cleanup); + tracked.reset(); + retained.reset(); + if (destroyed != 0) return 14; + cleanup->drain(); + if (destroyed != 1) return 15; + return 0; +} +''', + encoding="utf-8", + ) + + binary = tmp_path / "harness" + compiled = subprocess.run( + [ + compiler, + "-std=c++23", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + "-fsanitize=address,undefined", + "-fno-omit-frame-pointer", + "-I", + str(tmp_path), + str(tmp_path / "harness.cpp"), + "-o", + str(binary), + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + assert compiled.returncode == 0, compiled.stdout + environment = dict(os.environ) + environment["ASAN_OPTIONS"] = "detect_leaks=0:halt_on_error=1" + executed = subprocess.run( + [str(binary)], + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + assert executed.returncode == 0, executed.stdout diff --git a/tests/test_v3_phase5_cpp_routes.py b/tests/test_v3_phase5_cpp_routes.py new file mode 100644 index 0000000..68d676d --- /dev/null +++ b/tests/test_v3_phase5_cpp_routes.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from supernote_module_generator import binding_codegen +from supernote_module_generator.cpp_routes import ( + CppCallableKind, + CppObjectPassing, + CppRouteError, + plan_cpp_routes, +) +from supernote_module_generator.semantic_types import SemanticTypeKind + + +def _module(tmp_path: Path, source: str) -> Path: + root = tmp_path / "drawing" + cpp = root / "android/src/main/cpp" + cpp.mkdir(parents=True) + (root / ".supernote-module.json").write_text( + json.dumps({"feature_id": "supernote:feature:0123456789abcdef"}), + encoding="utf-8", + ) + (cpp / "drawing.hpp").write_text(source, encoding="utf-8") + (cpp / "functions.cpp").write_text( + """#include +namespace drawing { +class Stroke; +// @SupernotePluginExport +std::shared_ptr select(std::shared_ptr stroke) { return stroke; } +} +""", + encoding="utf-8", + ) + return root + + +def _plan(tmp_path: Path): + root = _module( + tmp_path, + """#include +#include +#include +namespace drawing { +// @SupernotePluginValue +struct Point { + // @SupernotePluginExport + double x; + // @SupernotePluginExport + double y; +}; + +// @SupernotePluginValue +enum class Color { Red, Blue }; + +// @SupernotePluginObject +class Stroke { +public: + // @SupernoteConstructor + explicit Stroke(std::vector points); + // @SupernotePluginExport + static std::shared_ptr empty(); + // @SupernotePluginExport + bool mutableBorrow(Stroke& other); + // @SupernotePluginExport + bool constBorrow(const Stroke& other) const; + // @SupernotePluginExport + bool sharedValue(std::shared_ptr other); + // @SupernotePluginExport + bool sharedRef(const std::shared_ptr& other) const; + // @SupernotePluginExport + std::vector> children() const; + // @SupernotePluginExport + // @SupernotePluginAsync + std::vector> asyncChildren( + std::vector> children) const; + // @SupernotePluginExport + std::shared_ptr child; +}; + +} +""", + ) + api = binding_codegen.scan_cpp_semantic_model(root, module_name="Drawing") + functions = binding_codegen.scan_cpp_source_model(root, module_name="Drawing") + classes = binding_codegen.scan_cpp_class_source_model(root, module_name="Drawing") + enums = binding_codegen.scan_cpp_enum_source_model(root, module_name="Drawing") + return plan_cpp_routes(api, functions, classes, enums), api + + +def test_cpp_routes_preserve_exact_nominal_types_and_object_passing(tmp_path: Path): + plan, api = _plan(tmp_path) + by_name = {item.public_name: item for item in plan.named_types} + stroke_semantic = next(item for item in api.declarations if item.name == "Stroke") + + assert by_name["Stroke"].type_id == stroke_semantic.type_id + assert by_name["Stroke"].cpp_type == "::drawing::Stroke" + assert by_name["Stroke"].kind is SemanticTypeKind.OBJECT_REF + assert by_name["Point"].kind is SemanticTypeKind.VALUE_REF + assert by_name["Color"].kind is SemanticTypeKind.ENUM_REF + assert plan.values[0].named_type.public_name == "Point" + assert [field.cpp_name for field in plan.values[0].fields] == ["x", "y"] + assert plan.enums[0].constants == ("Red", "Blue") + + object_route = plan.objects[0] + assert object_route.constructor is not None + assert object_route.constructor.kind is CppCallableKind.CONSTRUCTOR + assert object_route.constructor.result.type_id == stroke_semantic.type_id + assert object_route.constructor.parameters[0].object_passing is None + + methods = {item.public_name: item for item in object_route.methods} + assert methods["empty"].kind is CppCallableKind.STATIC_METHOD + assert methods["mutableBorrow"].parameters[0].object_passing is ( + CppObjectPassing.BORROWED_MUTABLE + ) + assert methods["constBorrow"].parameters[0].object_passing is ( + CppObjectPassing.BORROWED_CONST + ) + assert methods["sharedValue"].parameters[0].object_passing is ( + CppObjectPassing.SHARED_VALUE + ) + assert methods["sharedRef"].parameters[0].object_passing is ( + CppObjectPassing.SHARED_CONST_REF + ) + assert methods["children"].result.kind is SemanticTypeKind.ARRAY + assert methods["children"].result.element.kind is SemanticTypeKind.OBJECT_REF + assert methods["asyncChildren"].execution.value == "async" + + assert object_route.fields[0].cpp_spelling == "std::shared_ptr" + assert object_route.fields[0].mutable + assert plan.functions[0].cpp_name == "::drawing::select" + assert plan.functions[0].parameters[0].object_passing is ( + CppObjectPassing.SHARED_VALUE + ) + assert plan.functions[0].result.type_id == stroke_semantic.type_id + + +def test_cpp_route_plan_rejects_stale_or_mismatched_source(tmp_path: Path): + plan, api = _plan(tmp_path) + assert plan.objects + with pytest.raises(CppRouteError, match="missing C\\+\\+ function source"): + plan_cpp_routes(api, (), (), ()) + + +def test_feature_renderer_emits_nominal_object_routes_through_registry(tmp_path: Path): + root = _module( + tmp_path, + """#include +namespace drawing { +// @SupernotePluginObject +class Stroke { +public: + // @SupernoteConstructor + Stroke(); + // @SupernotePluginExport + static std::shared_ptr empty(); + // @SupernotePluginExport + bool intersects(const Stroke& other) const; + // @SupernotePluginExport + std::shared_ptr child; +}; +} +""", + ) + + source = binding_codegen.render_v2_feature_jsi( + root, + module_name="Drawing", + feature_id="supernote:feature:0123456789abcdef", + conversion_digest="a" * 64, + include_prefix="typed-cpp/android/src/main/cpp", + ) + + assert '#include "typed-cpp/android/src/main/cpp/drawing.hpp"' in source + assert "CppObjectHandle<::drawing::Stroke>" in source + assert "CppObjectRegistry" in source + assert "try_extract_cpp_object<" in source + assert '"supernote:feature:0123456789abcdef:type:Stroke"' in source + assert "supernote_wrap_v3_object_0" in source + assert "std::make_shared<::drawing::Stroke>" in source + assert "native_instance->intersects(*supernote_input_0)" in source + assert "this->managed_ref()->child = supernote_input_0" in source + assert "namespace drawing {\nstd::shared_ptr select" in source + assert "supernote_attach_preflight" in source + assert 'PropNameID::forAscii(runtime, "select.accepts")' in source + assert 'PropNameID::forAscii(runtime, "select.checkArguments")' in source + assert 'object_type.setProperty(runtime, "is"' in source + assert 'object_type.setProperty(runtime, "check"' in source + assert '"NOMINAL_MISMATCH", path, "Stroke"' in source + assert '"__supernoteCppObjectInfo"' in source + accepts = source.index('PropNameID::forAscii(runtime, "select.accepts")') + check = source.index('PropNameID::forAscii(runtime, "select.checkArguments")') + assert "::drawing::select(" not in source[accepts:check] + + +def test_feature_renderer_emits_recursive_values_arrays_nullable_enums_and_scalars( + tmp_path: Path, +): + root = _module( + tmp_path, + """#include +#include +#include +#include +#include +#include +namespace drawing { +// @SupernotePluginValue +enum class Color { Red, Blue }; + +// @SupernotePluginValue +struct Point { + // @SupernotePluginExport + std::int32_t x; + // @SupernotePluginExport + std::optional label; + // @SupernotePluginExport + Color color; +}; + +// @SupernotePluginObject +class Stroke { +public: + // @SupernoteConstructor + explicit Stroke(std::vector points); + // @SupernotePluginExport + std::vector points() const; + // @SupernotePluginExport + std::vector bytes; + // @SupernotePluginExport + std::int64_t revision; +}; +} +""", + ) + + source = binding_codegen.render_v2_feature_jsi( + root, + module_name="Drawing", + feature_id="supernote:feature:0123456789abcdef", + conversion_digest="b" * 64, + ) + + assert "object.isArray(runtime)" in source + assert "supernote::conversion::field_path" in source + assert "supernote::conversion::index_path" in source + assert "std::optional" in source + assert "::drawing::Color::Red" in source + assert "const auto bigint = value.getBigInt(runtime)" in source + assert "bigint.isInt64(runtime)" in source + assert "BigInt::fromInt64" in source + assert "supernote_copy_uint8_array" in source + assert "supernote_make_uint8_array" in source + assert "supernote_v3_throw_conversion_failure" in source + assert "supernote_validate_js_" in source + assert 'exports.setProperty(runtime, "Point"' in source + assert 'exports.setProperty(runtime, "Color"' in source + assert '"INVALID_ENUM"' in source + + +def test_recursive_copied_free_function_uses_v3_renderer_without_object_leaf( + tmp_path: Path, +): + root = tmp_path / "copied-function" + cpp = root / "android/src/main/cpp" + cpp.mkdir(parents=True) + (root / ".supernote-module.json").write_text( + json.dumps({"feature_id": "supernote:feature:0123456789abcdef"}), + encoding="utf-8", + ) + (cpp / "point.hpp").write_text( + """namespace drawing { +// @SupernotePluginValue +struct Point { + // @SupernotePluginExport + double x; + // @SupernotePluginExport + double y; +}; +} +""", + encoding="utf-8", + ) + (cpp / "point.cpp").write_text( + """#include "point.hpp" +namespace drawing { +// @SupernotePluginExport +Point echoPoint(Point point) { return point; } +} +""", + encoding="utf-8", + ) + + source = binding_codegen.render_v2_feature_jsi( + root, + module_name="Drawing", + feature_id="supernote:feature:0123456789abcdef", + conversion_digest="d" * 64, + ) + + assert 'exports.setProperty(runtime, "echoPoint"' in source + assert "::drawing::Point supernote_v3_from_js_" in source + assert "facebook::jsi::Value supernote_v3_to_js_" in source + + +def test_feature_renderer_emits_async_object_retention_and_js_thread_wrapping( + tmp_path: Path, +): + root = _module( + tmp_path, + """#include +#include +namespace drawing { +// @SupernotePluginObject +class Stroke { +public: + // @SupernoteConstructor + Stroke(); + // @SupernotePluginExport + // @SupernotePluginAsync + std::vector> echoLater( + std::vector> strokes) const; +}; +} +""", + ) + + source = binding_codegen.render_v2_feature_jsi( + root, + module_name="Drawing", + feature_id="supernote:feature:0123456789abcdef", + conversion_digest="c" * 64, + ) + + assert "supernote_register_continuation" in source + assert "retained_objects = std::move(retained_objects)" in source + assert "retained_input_state = std::make_sharedset_retained_state(retained_input_state)" in source + assert "retained_result" in source + assert "process_services().workers().submit" in source + assert "supernote_v3_object_registry(runtime)" in source + assert "schedule_completion" in source + + argument = source.index("auto supernote_input_0 =") + retained = source.index("auto retained_input_state =", argument) + accepted = source.index("accept_factory", retained) + attached = source.index("set_retained_state", accepted) + queued = source.index("workers().submit", attached) + scheduled = source.index("schedule_completion", attached) + js_identity_lookup = source.index( + "supernote_v3_object_registry(runtime)", scheduled + ) + assert argument < retained < accepted < attached < queued + assert queued < scheduled < js_identity_lookup + worker_capture = source[queued : source.index("mutable {", queued)] + assert "facebook::jsi::Runtime" not in worker_capture + assert "supernote_v3_object_registry" not in worker_capture + completion = source[scheduled:js_identity_lookup] + assert "void *runtime_pointer" in completion diff --git a/tests/test_v3_phase6_jvm_routes.py b/tests/test_v3_phase6_jvm_routes.py new file mode 100644 index 0000000..5e1b080 --- /dev/null +++ b/tests/test_v3_phase6_jvm_routes.py @@ -0,0 +1,432 @@ +from supernote_module_generator.jvm_manifest import ( + JvmSourceManifest, + jvm_adapter_identity, + jvm_declaration_identity, + jvm_field_accessor_identity, + jvm_field_identity, + jvm_owner_identity, +) +from supernote_module_generator.jvm_codegen import render_jvm_feature_jsi +from supernote_module_generator.jvm_projection import project_jvm_owners +from supernote_module_generator.jvm_object_runtime_codegen import ( + render_jvm_object_runtime, +) +from supernote_module_generator.jvm_routes import plan_jvm_routes +from supernote_module_generator.semantic import SourceProvenance +from supernote_module_generator.semantic_types import SemanticTypeKind +from supernote_module_generator.source_models import ( + DeclarationTarget, + JvmConstructorSource, + JvmDeclarationSource, + JvmFieldSource, + JvmLanguage, + JvmOwnerForm, + JvmOwnerSource, + JvmParameterSource, + JvmTypeSource, + SourceIntent, + SupernoteMarker, +) + + +FEATURE = "supernote:feature:6666666666666666" + + +def _intent(target, *markers): + return SourceIntent.from_markers(target, tuple(markers)) + + +def _source(identity, language, path="Model.kt"): + return SourceProvenance(identity, language.value, path, 1) + + +def _constructor(owner, language, descriptor, parameters, *markers): + identity = jvm_declaration_identity(owner, "", descriptor) + return JvmConstructorSource( + _source(identity, language), + descriptor, + parameters, + "public", + _intent(DeclarationTarget.CONSTRUCTOR, *markers), + jvm_adapter_identity(identity), + ) + + +def _field(owner, language, name, type_, mutable=False): + identity = jvm_field_identity(owner, name) + return JvmFieldSource( + _source(identity, language), + jvm_owner_identity(owner), + name, + type_, + _intent(DeclarationTarget.FIELD, SupernoteMarker.EXPORT), + "public", + mutable, + False, + jvm_field_accessor_identity(identity), + ) + + +def _method( + owner, + language, + name, + descriptor, + parameters, + result, + *, + static=False, + async_=False, + suspend=False, +): + identity = jvm_declaration_identity(owner, name, descriptor) + markers = [SupernoteMarker.EXPORT] + if async_: + markers.append(SupernoteMarker.ASYNC) + return JvmDeclarationSource( + _source(identity, language), + jvm_owner_identity(owner), + owner, + name, + descriptor, + parameters, + result.jvm_type, + result.nullable, + _intent(DeclarationTarget.METHOD, *markers), + "public", + jvm_adapter_identity(identity), + language, + suspend, + static, + result.arguments, + ) + + +def _matrix(): + language = JvmLanguage.KOTLIN + point_name = "com.example.Point" + stroke_name = "com.example.Stroke" + color_name = "com.example.Color" + point = JvmOwnerSource( + _source(jvm_owner_identity(point_name), language), + language, + point_name, + "Point", + JvmOwnerForm.CLASS, + _intent(DeclarationTarget.CLASS, SupernoteMarker.VALUE), + ( + _constructor( + point_name, + language, + "(DLjava/lang/Long;)V", + ( + JvmParameterSource("kotlin.Double", "x"), + JvmParameterSource("kotlin.Long", "tag", nullable=True), + ), + ), + ), + (), + fields=( + _field(point_name, language, "x", JvmTypeSource("kotlin.Double")), + _field( + point_name, + language, + "tag", + JvmTypeSource("kotlin.Long", nullable=True), + ), + ), + is_data=True, + ) + color = JvmOwnerSource( + _source(jvm_owner_identity(color_name), language), + language, + color_name, + "Color", + JvmOwnerForm.CLASS, + _intent(DeclarationTarget.CLASS, SupernoteMarker.VALUE), + (), + (), + enum_constants=("RED", "BLUE"), + ) + strokes = JvmTypeSource( + "kotlin.collections.List", + arguments=(JvmTypeSource(stroke_name, nullable=True),), + ) + stroke = JvmOwnerSource( + _source(jvm_owner_identity(stroke_name), language), + language, + stroke_name, + "Stroke", + JvmOwnerForm.CLASS, + _intent(DeclarationTarget.CLASS, SupernoteMarker.OBJECT), + ( + _constructor( + stroke_name, + language, + "(Lcom/example/Point;)V", + (JvmParameterSource(point_name, "point"),), + SupernoteMarker.CONSTRUCTOR, + ), + ), + ( + _method( + stroke_name, + language, + "echoAll", + "(Ljava/util/List;)Ljava/util/List;", + ( + JvmParameterSource( + strokes.jvm_type, + "values", + type_arguments=strokes.arguments, + ), + ), + strokes, + async_=True, + ), + _method( + stroke_name, + language, + "empty", + "()Lcom/example/Stroke;", + (), + JvmTypeSource(stroke_name), + static=True, + ), + ), + fields=( + _field( + stroke_name, + language, + "color", + JvmTypeSource(color_name), + mutable=True, + ), + _field( + stroke_name, + language, + "peer", + JvmTypeSource(stroke_name, nullable=True), + mutable=True, + ), + ), + ) + return point, color, stroke + + +def test_jvm_route_plan_uses_exact_adapter_descriptors_and_nominal_types(): + owners = _matrix() + api = project_jvm_owners(owners, feature_id=FEATURE) + plan = plan_jvm_routes(api, owners) + + assert [item.named_type.public_name for item in plan.values] == ["Point"] + assert [item.named_type.public_name for item in plan.enums] == ["Color"] + assert [item.named_type.public_name for item in plan.objects] == ["Stroke"] + point = plan.values[0] + assert point.constructor.adapter_descriptor == ( + "(Lcom/facebook/react/bridge/ReactApplicationContext;" + "DLjava/lang/Long;)Lcom/example/Point;" + ) + assert point.fields[1].getter_descriptor == ( + "(Lcom/example/Point;)Ljava/lang/Long;" + ) + assert point.fields[1].setter_descriptor is None + + stroke = plan.objects[0] + assert stroke.named_type.kind is SemanticTypeKind.OBJECT_REF + assert stroke.constructor.adapter_descriptor == ( + "(Lcom/facebook/react/bridge/ReactApplicationContext;" + "Lcom/example/Point;)Lcom/example/Stroke;" + ) + assert stroke.methods[0].adapter_descriptor == ( + "(Lcom/example/Stroke;Ljava/util/List;)Ljava/util/List;" + ) + assert stroke.methods[1].adapter_descriptor == "()Lcom/example/Stroke;" + assert stroke.fields[0].setter_descriptor == ( + "(Lcom/example/Stroke;Lcom/example/Color;)V" + ) + assert stroke.fields[1].getter_descriptor == ( + "(Lcom/example/Stroke;)Lcom/example/Stroke;" + ) + + +def test_jvm_suspend_adapter_descriptor_retains_completion_token(): + owner_name = "com.example.Worker" + language = JvmLanguage.KOTLIN + owner = JvmOwnerSource( + _source(jvm_owner_identity(owner_name), language), + language, + owner_name, + "Worker", + JvmOwnerForm.CLASS, + _intent(DeclarationTarget.CLASS, SupernoteMarker.OBJECT), + (), + ( + _method( + owner_name, + language, + "later", + "()Lcom/example/Worker;", + (), + JvmTypeSource(owner_name), + async_=True, + suspend=True, + ), + ), + ) + api = project_jvm_owners((owner,), feature_id=FEATURE) + route = plan_jvm_routes(api, (owner,)).objects[0].methods[0] + assert route.adapter_descriptor == ( + "(Lcom/example/Worker;J)Lkotlinx/coroutines/Job;" + ) + assert route.suspend + generated = render_jvm_feature_jsi( + JvmSourceManifest(FEATURE, "3.0.0.dev0", (owner,)), + api, + feature_id=FEATURE, + module_name="Drawing", + ) + assert "SupernoteSuspendExecutor" in generated + assert "register_jvm_async_completion" in generated + assert "supernote_v3_jvm_to_js_" in generated + assert "operation->set_retained_state(retained_input_state)" in generated + assert "JvmObjectHandleBase" in generated + + +def test_jvm_suspend_nullable_result_accepts_a_null_jobject(): + owner_name = "com.example.Worker" + language = JvmLanguage.KOTLIN + owner = JvmOwnerSource( + _source(jvm_owner_identity(owner_name), language), + language, + owner_name, + "Worker", + JvmOwnerForm.CLASS, + _intent(DeclarationTarget.CLASS, SupernoteMarker.OBJECT), + (), + ( + _method( + owner_name, + language, + "maybeLater", + "(Lcom/example/Worker;)Lcom/example/Worker;", + (JvmParameterSource(owner_name, "other", nullable=True),), + JvmTypeSource(owner_name, nullable=True), + async_=True, + suspend=True, + ), + ), + ) + api = project_jvm_owners((owner,), feature_id=FEATURE) + generated = render_jvm_feature_jsi( + JvmSourceManifest(FEATURE, "3.0.0.dev0", (owner,)), + api, + feature_id=FEATURE, + module_name="Drawing", + ) + + decode_start = generated.index( + "auto *env = static_cast(environment);" + ) + decode_end = generated.index("state->success = true;", decode_start) + decode = generated[decode_start:decode_end] + assert "Kotlin coroutine result has no JNI environment" in decode + assert "if (object == nullptr)" not in decode + assert "decoded_result = object == nullptr" in decode + assert "? ManagedJvmValue{}" in decode + + argument = generated.index("auto argument_0 =") + retained = generated.index("auto retained_input_state =", argument) + accepted = generated.index("accept_factory", retained) + attached = generated.index("set_retained_state", accepted) + queued = generated.index("workers().submit", attached) + scheduled = generated.index("schedule_completion", attached) + js_identity_lookup = generated.index( + "supernote_v3_jvm_object_registry(runtime)", scheduled + ) + assert argument < retained < accepted < attached < queued + assert attached < scheduled < js_identity_lookup < queued + worker = generated[queued : generated.index("operation->set_work(work)", queued)] + assert "facebook::jsi::Runtime" not in worker + assert "supernote_v3_jvm_object_registry" not in worker + + +def test_jvm_identity_registry_uses_weak_globals_hash_buckets_and_is_same_object(): + source = render_jvm_object_runtime() + + assert "NewWeakGlobalRef" in source + assert "DeleteWeakGlobalRef" in source + assert "jint identity_hash" in source + assert "current->identity_hash != hash" in source + assert "env->IsSameObject(weak, instance) != JNI_TRUE" in source + assert "env->IsSameObject(weak, nullptr) == JNI_TRUE" in source + assert "facebook::jsi::WeakObject" in source + assert "a JVM object registry cannot cross JavaScript runtimes" in source + assert "std::shared_ptr strong_global" in source + assert "ManagedJvmRef managed" in source + assert "cleanup->submit(release)" in source + + +def test_generated_ksp_processor_emits_live_field_accessors(): + from pathlib import Path + + template = Path( + "src/supernote_module_generator/templates/" + "v2.SupernoteV2Processor.kt.tmpl" + ).read_text(encoding="utf-8") + assert "owner.fields.forEach { field ->" in template + assert 'fun get(owner: $ownerType): $fieldType' in template + assert 'fun set(owner: $ownerType, value: $fieldType)' in template + assert "adapterOutput(field.type, property)" in template + assert 'owner.${kotlinIdentifier(field.name)}' in template + assert "System.identityHashCode(value)" in template + assert '"Identity_${hash(root.featureId).take(20)}"' in template + assert '"List<${adapterBridgeType(type.arguments.single())}>"' in template + assert '"$name${if (type.nullable) "?" else ""}.map { item -> $mapped }"' in template + + +def test_jvm_object_codegen_emits_nominal_wrappers_converters_and_registry(): + owners = _matrix() + api = project_jvm_owners(owners, feature_id=FEATURE) + generated = render_jvm_feature_jsi( + JvmSourceManifest(FEATURE, "3.0.0.dev0", owners), + api, + feature_id=FEATURE, + module_name="Drawing", + ) + + assert "class GeneratedV3JvmObject0HostObject final" in generated + assert ": public JvmObjectHandleBase" in generated + assert "try_extract_jvm_object" in generated + assert "supernote_v3_wrap_jvm_object_0" in generated + assert "std::make_shared()" in generated + assert "__supernoteV3JvmObjectRegistry_2cfbc9ce6375" in generated + assert "jvm-v3-value-constructor:" in generated + assert "jvm-v3-enum-from:" in generated + assert "jvm-v3-field-get:" in generated + assert "jvm-v3-field-set:" in generated + assert "listAdd" in generated + assert "listGet" in generated + assert "decodeString" not in generated + assert "identityHash" in generated + assert "IsSameObject" in generated + assert "process_services().workers().submit" in generated + assert "supernote_v3_jvm_object_registry(runtime)" in generated + assert "argument_0 = std::move(argument_0)" in generated + assert "retained_input_state = std::make_sharedset_retained_state(retained_input_state)" in generated + assert "schedule_completion" in generated + assert 'exports.setProperty(runtime, "Stroke"' in generated + assert "supernote_attach_preflight" in generated + assert '"echoAll.accepts"' in generated + assert '"echoAll.checkArguments"' in generated + assert 'object_type.setProperty(runtime, "is"' in generated + assert 'object_type.setProperty(runtime, "check"' in generated + assert '"NOMINAL_MISMATCH", path, "Stroke"' in generated + assert '"__supernoteJvmObjectInfo"' in generated + assert 'exports.setProperty(runtime, "Point"' in generated + assert 'exports.setProperty(runtime, "Color"' in generated + assert "supernote_v3_jvm_validate_js_" in generated + assert generated.count( + 'supernote_throw_error(runtime, "IMPLEMENTATION_ERROR", error.what())' + ) >= 3 diff --git a/tests/test_v3_phase7_cross_family.py b/tests/test_v3_phase7_cross_family.py new file mode 100644 index 0000000..90679c0 --- /dev/null +++ b/tests/test_v3_phase7_cross_family.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from supernote_module_generator import binding_codegen +from supernote_module_generator.cross_family_codegen import build_cross_family_renderer +from supernote_module_generator.internal_codegen import render_cpp_internal_facade +from supernote_module_generator.jvm_codegen import ( + _internal_suspend_decode, + render_jvm_feature_jsi, +) +from supernote_module_generator.jvm_manifest import ( + JvmSourceManifest, + jvm_adapter_identity, + jvm_declaration_identity, + jvm_field_accessor_identity, + jvm_field_identity, + jvm_owner_identity, +) +from supernote_module_generator.jvm_projection import project_jvm_owners +from supernote_module_generator.semantic import ( + SemanticType, + SourceProvenance, + merge_semantic_apis, +) +from supernote_module_generator.source_models import ( + DeclarationTarget, + JvmConstructorSource, + JvmDeclarationSource, + JvmFieldSource, + JvmLanguage, + JvmOwnerForm, + JvmOwnerSource, + JvmParameterSource, + JvmTypeSource, + SourceIntent, + SupernoteMarker, +) + + +FEATURE = "supernote:feature:7777777777777777" +PACKAGE = "com.example.cross" + + +def _provenance(identity: str, line: int) -> SourceProvenance: + return SourceProvenance(identity, "kotlin", "Cross.kt", line, 1) + + +def _intent(target: DeclarationTarget, *markers: SupernoteMarker) -> SourceIntent: + return SourceIntent.from_markers(target, markers) + + +def _field(owner: str, name: str, type_: JvmTypeSource, line: int) -> JvmFieldSource: + identity = jvm_field_identity(owner, name) + return JvmFieldSource( + _provenance(identity, line), + jvm_owner_identity(owner), + name, + type_, + _intent(DeclarationTarget.FIELD, SupernoteMarker.EXPORT), + "public", + False, + False, + jvm_field_accessor_identity(identity), + ) + + +def _jvm_manifest(*, suspend: bool = False) -> JvmSourceManifest: + mode_owner = f"{PACKAGE}.Mode" + payload_owner = f"{PACKAGE}.Payload" + api_owner = f"{PACKAGE}.CrossKt" + mode = JvmOwnerSource( + _provenance(jvm_owner_identity(mode_owner), 3), + JvmLanguage.KOTLIN, + mode_owner, + "Mode", + JvmOwnerForm.CLASS, + _intent(DeclarationTarget.CLASS, SupernoteMarker.VALUE), + (), + (), + enum_constants=("One", "Two"), + ) + field_types = ( + ("count", JvmTypeSource("kotlin.Int")), + ("text", JvmTypeSource("kotlin.String")), + ("bytes", JvmTypeSource("kotlin.ByteArray")), + ("mode", JvmTypeSource(mode_owner)), + ( + "tags", + JvmTypeSource( + "kotlin.collections.List", + arguments=(JvmTypeSource("kotlin.String", nullable=True),), + ), + ), + ("score", JvmTypeSource("kotlin.Double", nullable=True)), + ) + constructor_id = jvm_declaration_identity( + payload_owner, + "", + f"(ILjava/lang/String;[BL{mode_owner.replace('.', '/')};Ljava/util/List;Ljava/lang/Double;)V", + ) + constructor = JvmConstructorSource( + _provenance(constructor_id, 8), + f"(ILjava/lang/String;[BL{mode_owner.replace('.', '/')};Ljava/util/List;Ljava/lang/Double;)V", + tuple( + JvmParameterSource(value.jvm_type, name, value.nullable, type_arguments=value.arguments) + for name, value in field_types + ), + "public", + _intent(DeclarationTarget.CONSTRUCTOR), + jvm_adapter_identity(constructor_id), + ) + payload = JvmOwnerSource( + _provenance(jvm_owner_identity(payload_owner), 7), + JvmLanguage.KOTLIN, + payload_owner, + "Payload", + JvmOwnerForm.CLASS, + _intent(DeclarationTarget.CLASS, SupernoteMarker.VALUE), + (constructor,), + (), + fields=tuple( + _field(payload_owner, name, value, 9 + index) + for index, (name, value) in enumerate(field_types) + ), + is_data=True, + ) + route_id = jvm_declaration_identity( + api_owner, + "roundTrip", + f"(L{payload_owner.replace('.', '/')};)L{payload_owner.replace('.', '/')};", + ) + route = JvmDeclarationSource( + _provenance(route_id, 20), + jvm_owner_identity(api_owner), + api_owner, + "roundTrip", + f"(L{payload_owner.replace('.', '/')};)L{payload_owner.replace('.', '/')};", + (JvmParameterSource(payload_owner, "payload"),), + payload_owner, + False, + _intent( + DeclarationTarget.FUNCTION, + SupernoteMarker.INTERNAL, + *([SupernoteMarker.ASYNC] if suspend else []), + ), + "public", + jvm_adapter_identity(route_id), + JvmLanguage.KOTLIN, + suspend, + True, + ) + api = JvmOwnerSource( + _provenance(jvm_owner_identity(api_owner), 19), + JvmLanguage.KOTLIN, + api_owner, + "CrossKt", + JvmOwnerForm.KOTLIN_TOP_LEVEL, + _intent(DeclarationTarget.CLASS), + (), + (route,), + ) + return JvmSourceManifest(FEATURE, "3.0.0.dev0", (mode, payload, api)) + + +def _module(tmp_path: Path) -> Path: + root = tmp_path / "cross" + cpp = root / "android/src/main/cpp" + cpp.mkdir(parents=True) + (root / ".supernote-module.json").write_text( + json.dumps({"feature_id": FEATURE}), encoding="utf-8" + ) + (cpp / "Cross.hpp").write_text( + """#pragma once +#include +#include +#include +#include +#include +namespace cross { +// @SupernotePluginValue +enum class Mode { One, Two }; +// @SupernotePluginValue +struct Payload { + // @SupernotePluginExport + std::int32_t count; + // @SupernotePluginExport + std::string text; + // @SupernotePluginExport + std::vector bytes; + // @SupernotePluginExport + Mode mode; + // @SupernotePluginExport + std::vector> tags; + // @SupernotePluginExport + std::optional score; +}; +} +""", + encoding="utf-8", + ) + return root + + +def test_copied_cross_family_codegen_is_typed_recursive_and_hidden(tmp_path: Path): + root = _module(tmp_path) + cpp = binding_codegen.scan_cpp_semantic_model(root, module_name="Cross") + manifest = _jvm_manifest() + jvm = project_jvm_owners(manifest.owners, feature_id=FEATURE) + semantic = merge_semantic_apis(cpp, jvm) + renderer = build_cross_family_renderer( + root, + semantic, + manifest, + feature_id=FEATURE, + module_name="Cross", + ) + + helpers = renderer.render_helpers() + binding = next(item for item in semantic.functions if item.name == "roundTrip") + invocation = renderer.worker_invocation(binding, False) + header, internal = render_cpp_internal_facade( + root, + module_name="Cross", + feature_id=FEATURE, + jvm_manifest=manifest, + jvm_semantic=jvm, + cross_family=renderer, + ) + generated_jvm = render_jvm_feature_jsi( + manifest, + jvm, + feature_id=FEATURE, + module_name="Cross", + cross_family=renderer, + ) + + assert "::cross::Payload roundTrip(::cross::Payload payload);" in header + assert "supernote_v3_cross_to_jvm_" in helpers + assert "supernote_v3_cross_from_jvm_" in helpers + assert "check_array_length" in helpers + assert "check_string_bytes" in helpers + assert "check_byte_buffer" in helpers + assert "std::optional" in helpers + assert "::cross::Mode::One" in helpers + assert "return ::cross::Payload{" in helpers + assert "jvm_arguments[0].l = cross_argument_0" in invocation + assert "cross_budget" in invocation + worker = generated_jvm[ + generated_jvm.index("internal_function_0(") : + generated_jvm.index("void register_jvm_feature") + ] + assert "feature.reset();" not in worker + assert 'exports.setProperty(runtime, "roundTrip"' not in generated_jvm + + copied_section = helpers + invocation + for forbidden in ( + "uintptr_t", + "reinterpret_castset_retained_state(retained_input_state)" in generated + assert "feature closed before coroutine result conversion" in generated + + +def test_nullable_cross_family_suspend_result_accepts_null_before_conversion(): + class Renderer: + @staticmethod + def suspend_result_expression(*_args, **_kwargs): + return "decode_nullable(result)" + + decode = _internal_suspend_decode( + SemanticType.nullable(SemanticType.value_ref(f"{FEATURE}:type:Payload")), + "Result>", + Renderer(), + ) + + assert "Kotlin coroutine result has no JNI environment" in decode + assert "result == nullptr" not in decode + assert "decode_nullable(result)" in decode diff --git a/tests/test_v3_semantic_model.py b/tests/test_v3_semantic_model.py new file mode 100644 index 0000000..5f7d097 --- /dev/null +++ b/tests/test_v3_semantic_model.py @@ -0,0 +1,596 @@ +from __future__ import annotations + +import random + +import pytest + +from supernote_module_generator.feature_model import ( + FeatureRequirements, + ImplementationFamily, +) +from supernote_module_generator.semantic import ( + BackendFamily, + BindingCapabilities, + BindingKind, + DeclarationRole, + ExecutionMode, + MemberScope, + SemanticApi, + SemanticBinding, + SemanticConstructor, + SemanticEnumDeclaration, + SemanticField, + SemanticModelError, + SemanticObjectDeclaration, + SemanticParameter, + SemanticProjection, + SemanticType, + SemanticValueDeclaration, + SourceProvenance, + merge_semantic_apis, + semantic_api_from_manifest, + semantic_type_id, + validate_semantic_route, +) +from supernote_module_generator.semantic_types import ( + SemanticTypeError, + SemanticTypeKind, + semantic_type_from_manifest, +) + + +FEATURE = "supernote:feature:geometry" + + +@pytest.mark.parametrize( + ("value", "manifest"), + [ + (SemanticType.VOID, {"kind": "void"}), + (SemanticType.BOOL, {"kind": "scalar", "name": "bool"}), + (SemanticType.INT32, {"kind": "scalar", "name": "int32"}), + (SemanticType.INT64, {"kind": "scalar", "name": "int64"}), + (SemanticType.FLOAT32, {"kind": "scalar", "name": "float32"}), + (SemanticType.FLOAT64, {"kind": "scalar", "name": "float64"}), + (SemanticType.STRING, {"kind": "scalar", "name": "string"}), + (SemanticType.BYTES, {"kind": "scalar", "name": "bytes"}), + ], +) +def test_every_base_type_has_an_exact_manifest(value, manifest): + assert value.manifest() == manifest + assert semantic_type_from_manifest(manifest) is value + + +@pytest.mark.parametrize( + "leaf", + [ + SemanticType.BOOL, + SemanticType.INT32, + SemanticType.INT64, + SemanticType.FLOAT32, + SemanticType.FLOAT64, + SemanticType.STRING, + SemanticType.BYTES, + SemanticType.enum_ref("feature:type:Enum"), + SemanticType.value_ref("feature:type:Value"), + SemanticType.object_ref("feature:type:Object"), + ], +) +@pytest.mark.parametrize("wrapper", [SemanticType.array, SemanticType.nullable]) +def test_every_non_void_family_is_legal_in_each_direct_wrapper(leaf, wrapper): + wrapped = wrapper(leaf) + assert semantic_type_from_manifest(wrapped.manifest()) == wrapped + + +def source(identity: str, language: str = "cpp", line: int = 10) -> SourceProvenance: + suffix = "hpp" if language == "cpp" else "kt" + return SourceProvenance(identity, language, f"src/{identity}.{suffix}", line) + + +def projection(identity: str, backend: BackendFamily) -> SemanticProjection: + language = "cpp" if backend is BackendFamily.CPP else "kotlin" + return SemanticProjection(backend, source(identity, language)) + + +def field( + owner: str, + name: str, + value_type: SemanticType, + identity: str, + *, + language: str = "cpp", + mutable: bool = False, +) -> SemanticField: + return SemanticField( + f"{owner}:field:{name}", + owner, + name, + value_type, + source(identity, language), + mutable, + ) + + +def value_declaration( + name: str, + backend: BackendFamily, + fields: tuple[SemanticField, ...], + identity: str, +) -> SemanticValueDeclaration: + type_id = semantic_type_id(FEATURE, name) + return SemanticValueDeclaration( + FEATURE, + type_id, + name, + fields, + (projection(identity, backend),), + ) + + +def test_recursive_type_algebra_is_immutable_structured_and_strict(): + point_id = semantic_type_id(FEATURE, "Point") + value = SemanticType.nullable( + SemanticType.array(SemanticType.value_ref(point_id)) + ) + assert value.manifest() == { + "kind": "nullable", + "inner": { + "kind": "array", + "element": {"kind": "value_ref", "type_id": point_id}, + }, + } + assert semantic_type_from_manifest(value.manifest()) == value + assert semantic_type_from_manifest({"kind": "scalar", "name": "int32"}) \ + is SemanticType.INT32 + + with pytest.raises(SemanticTypeError, match="void cannot be nested"): + SemanticType.array(SemanticType.VOID) + with pytest.raises(SemanticTypeError, match="nested nullable"): + SemanticType.nullable(SemanticType.nullable(SemanticType.STRING)) + with pytest.raises(SemanticTypeError, match="invalid fields"): + semantic_type_from_manifest({"kind": "void", "name": "void"}) + with pytest.raises(SemanticTypeError, match="kind is invalid"): + semantic_type_from_manifest({"kind": "dynamic"}) + + +def test_logical_ids_and_manifests_are_language_neutral_and_deterministic(): + point_id = semantic_type_id(FEATURE, "Point") + point = value_declaration( + "Point", + BackendFamily.CPP, + ( + field(point_id, "x", SemanticType.FLOAT64, "cpp-point-x"), + field(point_id, "y", SemanticType.FLOAT64, "cpp-point-y"), + ), + "cpp-point", + ) + api = SemanticApi(declarations=(point,)) + manifest = api.manifest() + assert manifest["schema_version"] == 3 + assert manifest["types"][0]["type_id"] == point_id + assert manifest["types"][0]["fields"][0]["type"] == { + "kind": "scalar", + "name": "float64", + } + assert "descriptor" not in repr(manifest).lower() + assert "adapter" not in repr(manifest).lower() + assert semantic_api_from_manifest(manifest).manifest() == manifest + + with pytest.raises(SemanticModelError, match="stable identity"): + SemanticValueDeclaration( + FEATURE, "cpp::Point", "Point", point.fields, point.projections + ) + + +def test_exact_cpp_and_jvm_value_and_enum_projections_merge(): + point_id = semantic_type_id(FEATURE, "Point") + cpp_fields = ( + field(point_id, "x", SemanticType.FLOAT64, "cpp-x"), + field(point_id, "y", SemanticType.FLOAT64, "cpp-y"), + ) + jvm_fields = ( + field(point_id, "x", SemanticType.FLOAT64, "jvm-x", language="kotlin"), + field(point_id, "y", SemanticType.FLOAT64, "jvm-y", language="kotlin"), + ) + cpp = value_declaration("Point", BackendFamily.CPP, cpp_fields, "cpp-point") + jvm = value_declaration("Point", BackendFamily.JVM, jvm_fields, "jvm-point") + merged = merge_semantic_apis( + SemanticApi(declarations=(jvm,)), SemanticApi(declarations=(cpp,)) + ) + assert [item.backend for item in merged.declarations[0].projections] == [ + BackendFamily.CPP, + BackendFamily.JVM, + ] + assert [item["backend"] for item in merged.manifest()["types"][0]["projections"]] == [ + "cpp", + "jvm", + ] + + enum_id = semantic_type_id(FEATURE, "Color") + enum_cpp = SemanticEnumDeclaration( + FEATURE, enum_id, "Color", ("RED", "BLUE"), (projection("cpp-color", BackendFamily.CPP),) + ) + enum_jvm = SemanticEnumDeclaration( + FEATURE, enum_id, "Color", ("RED", "BLUE"), (projection("jvm-color", BackendFamily.JVM),) + ) + assert len( + merge_semantic_apis( + SemanticApi(declarations=(enum_cpp,)), + SemanticApi(declarations=(enum_jvm,)), + ).declarations[0].projections + ) == 2 + + +def test_copied_value_projection_merge_ignores_source_storage_mutability(): + point_id = semantic_type_id(FEATURE, "Point") + cpp = value_declaration( + "Point", + BackendFamily.CPP, + (field(point_id, "x", SemanticType.FLOAT64, "cpp-x", mutable=True),), + "cpp-point", + ) + jvm = value_declaration( + "Point", + BackendFamily.JVM, + ( + field( + point_id, + "x", + SemanticType.FLOAT64, + "jvm-x", + language="kotlin", + mutable=False, + ), + ), + "jvm-point", + ) + merged = merge_semantic_apis( + SemanticApi(declarations=(cpp,)), SemanticApi(declarations=(jvm,)) + ) + assert len(merged.declarations[0].projections) == 2 + + +def test_projection_merge_reports_both_sources_for_every_conflict(): + enum_id = semantic_type_id(FEATURE, "Color") + cpp = SemanticEnumDeclaration( + FEATURE, enum_id, "Color", ("RED", "BLUE"), (projection("cpp-color", BackendFamily.CPP),) + ) + duplicate = SemanticEnumDeclaration( + FEATURE, enum_id, "Color", ("RED", "BLUE"), (projection("cpp-color-2", BackendFamily.CPP),) + ) + mismatch = SemanticEnumDeclaration( + FEATURE, enum_id, "Color", ("BLUE", "RED"), (projection("jvm-color", BackendFamily.JVM),) + ) + with pytest.raises(SemanticModelError, match=r"cpp-color.*cpp-color-2"): + merge_semantic_apis( + SemanticApi(declarations=(cpp,)), SemanticApi(declarations=(duplicate,)) + ) + with pytest.raises(SemanticModelError, match=r"cpp-color.*jvm-color"): + merge_semantic_apis( + SemanticApi(declarations=(cpp,)), SemanticApi(declarations=(mismatch,)) + ) + + point_id = semantic_type_id(FEATURE, "Point") + point_cpp = value_declaration( + "Point", + BackendFamily.CPP, + ( + field(point_id, "x", SemanticType.FLOAT64, "point-cpp-x"), + field(point_id, "y", SemanticType.FLOAT64, "point-cpp-y"), + ), + "point-cpp", + ) + point_jvm = value_declaration( + "Point", + BackendFamily.JVM, + ( + field(point_id, "x", SemanticType.FLOAT32, "point-jvm-x", language="java"), + field(point_id, "y", SemanticType.FLOAT64, "point-jvm-y", language="java"), + ), + "point-jvm", + ) + with pytest.raises(SemanticModelError, match=r"point-cpp.*point-jvm"): + merge_semantic_apis( + SemanticApi(declarations=(point_cpp,)), + SemanticApi(declarations=(point_jvm,)), + ) + + +def test_optional_object_construction_member_scope_fields_and_exact_nominality(): + stroke_id = semantic_type_id(FEATURE, "Stroke") + label = field( + stroke_id, "label", SemanticType.STRING, "stroke-label", mutable=True + ) + static_factory = SemanticBinding( + f"{stroke_id}:method:load", + BindingKind.OBJECT_METHOD, + "load", + BindingCapabilities.for_role(DeclarationRole.EXPORTED), + ExecutionMode.SYNC, + (SemanticParameter("path", SemanticType.STRING),), + SemanticType.object_ref(stroke_id), + source("stroke-load"), + stroke_id, + "Stroke", + MemberScope.STATIC, + ) + returned_only = SemanticObjectDeclaration( + FEATURE, + stroke_id, + "Stroke", + projection("stroke", BackendFamily.CPP), + None, + (static_factory,), + (label,), + ) + api = SemanticApi(declarations=(returned_only,)) + assert api.manifest()["types"][0]["constructor"] is None + assert api.manifest()["types"][0]["methods"][0]["member_scope"] == "static" + assert api.manifest()["types"][0]["fields"][0]["mutable"] is True + assert FeatureRequirements.from_semantic_api(api).families == ( + ImplementationFamily.NATIVE, + ) + + constructed = SemanticObjectDeclaration( + FEATURE, + stroke_id, + "Stroke", + projection("stroke-2", BackendFamily.CPP), + SemanticConstructor(source("stroke-constructor")), + ) + assert constructed.constructor is not None + + fake_value = SemanticValueDeclaration( + FEATURE, + semantic_type_id(FEATURE, "Point"), + "Point", + (field(semantic_type_id(FEATURE, "Point"), "x", SemanticType.FLOAT64, "point-x"),), + (projection("point", BackendFamily.CPP),), + ) + wrong_ref = SemanticBinding( + "binding:wrong", + BindingKind.FUNCTION, + "wrong", + BindingCapabilities.for_role(DeclarationRole.EXPORTED), + ExecutionMode.SYNC, + (), + SemanticType.object_ref(fake_value.type_id), + source("wrong"), + ) + with pytest.raises(SemanticModelError, match="nominal reference"): + SemanticApi(functions=(wrong_ref,), declarations=(fake_value,)) + + +def test_unknown_references_value_cycles_duplicates_and_strict_manifest_fail(): + missing = SemanticBinding( + "binding:missing", + BindingKind.FUNCTION, + "missing", + BindingCapabilities.for_role(DeclarationRole.EXPORTED), + ExecutionMode.SYNC, + (), + SemanticType.value_ref("missing:type"), + source("missing"), + ) + with pytest.raises(SemanticModelError, match="unknown semantic value_ref"): + SemanticApi(functions=(missing,)) + + a_id = semantic_type_id(FEATURE, "A") + b_id = semantic_type_id(FEATURE, "B") + a = value_declaration( + "A", BackendFamily.CPP, (field(a_id, "b", SemanticType.value_ref(b_id), "a-b"),), "a" + ) + b = value_declaration( + "B", BackendFamily.CPP, (field(b_id, "a", SemanticType.value_ref(a_id), "b-a"),), "b" + ) + with pytest.raises(SemanticModelError, match="recursive value declaration cycle"): + SemanticApi(declarations=(a, b)) + + raw = SemanticApi().manifest() + raw["types"] = [{"kind": "value"}] + with pytest.raises(SemanticModelError, match="feature_id"): + semantic_api_from_manifest(raw) + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"value_type": SemanticType.VOID}, "void is invalid"), + ({"scope": MemberScope.STATIC}, "static bridge fields"), + ({"required": False}, "optional/missing fields"), + ( + { + "capabilities": BindingCapabilities.for_role( + DeclarationRole.INTERNAL + ) + }, + "explicitly exported", + ), + ], +) +def test_field_contract_rejects_every_forbidden_semantic_shape(changes, message): + owner = semantic_type_id(FEATURE, "Owner") + arguments = { + "field_id": f"{owner}:field:value", + "owner_id": owner, + "name": "value", + "type": changes.get("value_type", SemanticType.INT32), + "source": source("owner-value"), + "mutable": False, + "scope": changes.get("scope", MemberScope.INSTANCE), + "required": changes.get("required", True), + "capabilities": changes.get( + "capabilities", + BindingCapabilities.for_role(DeclarationRole.EXPORTED), + ), + } + with pytest.raises(SemanticModelError, match=message): + SemanticField(**arguments) + + +def test_separately_constructed_void_is_still_forbidden_as_an_input(): + with pytest.raises(SemanticModelError, match="void is valid only"): + SemanticParameter("bad", SemanticType(SemanticTypeKind.VOID)) + + +def test_route_capabilities_recurse_and_reject_cross_family_object_leaves(): + stroke_id = semantic_type_id(FEATURE, "Stroke") + payload_id = semantic_type_id(FEATURE, "Payload") + stroke = SemanticObjectDeclaration( + FEATURE, stroke_id, "Stroke", projection("stroke", BackendFamily.CPP) + ) + payload_cpp = value_declaration( + "Payload", + BackendFamily.CPP, + (field(payload_id, "stroke", SemanticType.object_ref(stroke_id), "payload-stroke"),), + "payload-cpp", + ) + api = SemanticApi(declarations=(stroke, payload_cpp)) + cpp_end = source("cpp-route") + jvm_end = source("jvm-route", "java") + with pytest.raises( + SemanticModelError, + match=( + r"cannot cross cpp->jvm; cross-family object proxies are deferred " + r"in current V3 at value\[\].*stroke.hpp:10.*cpp-route.*jvm-route" + ), + ): + validate_semantic_route( + api, + SemanticType.array(SemanticType.object_ref(stroke_id)), + BackendFamily.CPP, + BackendFamily.JVM, + cpp_end, + jvm_end, + ) + validate_semantic_route( + api, + SemanticType.value_ref(payload_id), + BackendFamily.CPP, + BackendFamily.CPP, + cpp_end, + cpp_end, + ) + + with pytest.raises(SemanticModelError, match=r"missing jvm.*cpp-route.*jvm-route"): + validate_semantic_route( + api, + SemanticType.value_ref(payload_id), + BackendFamily.CPP, + BackendFamily.JVM, + cpp_end, + jvm_end, + ) + + +@pytest.mark.parametrize( + ("routed_type", "position"), + [ + (lambda object_type, _payload: object_type, r"value"), + (lambda object_type, _payload: SemanticType.nullable(object_type), r"value\?"), + (lambda object_type, _payload: SemanticType.array(object_type), r"value\[\]"), + ( + lambda _object_type, payload: SemanticType.value_ref(payload), + r"value\.stroke\[\]\?", + ), + ], +) +def test_cross_family_object_rejection_names_every_nested_position( + routed_type, position +): + stroke_id = semantic_type_id(FEATURE, "Stroke") + payload_id = semantic_type_id(FEATURE, "Payload") + object_type = SemanticType.object_ref(stroke_id) + stroke = SemanticObjectDeclaration( + FEATURE, + stroke_id, + "Stroke", + projection("stroke-native", BackendFamily.CPP), + ) + payload = SemanticValueDeclaration( + FEATURE, + payload_id, + "Payload", + ( + field( + payload_id, + "stroke", + SemanticType.array(SemanticType.nullable(object_type)), + "payload-stroke", + ), + ), + ( + projection("payload-cpp", BackendFamily.CPP), + projection("payload-jvm", BackendFamily.JVM), + ), + ) + api = SemanticApi(declarations=(stroke, payload)) + + with pytest.raises( + SemanticModelError, + match=( + rf"native object 'Stroke' cannot cross cpp->jvm; cross-family object " + rf"proxies are deferred in current V3 at {position}.*" + rf"stroke-native.hpp:10.*cpp-route.hpp:10.*jvm-route.kt:10" + ), + ): + validate_semantic_route( + api, + routed_type(object_type, payload_id), + BackendFamily.CPP, + BackendFamily.JVM, + source("cpp-route"), + source("jvm-route", "kotlin"), + ) + + +def test_jvm_family_accepts_objects_shared_between_kotlin_and_java_routes(): + stroke_id = semantic_type_id(FEATURE, "Stroke") + stroke = SemanticObjectDeclaration( + FEATURE, + stroke_id, + "Stroke", + projection("stroke-jvm", BackendFamily.JVM), + ) + validate_semantic_route( + SemanticApi(declarations=(stroke,)), + SemanticType.array(SemanticType.nullable(SemanticType.object_ref(stroke_id))), + BackendFamily.JVM, + BackendFamily.JVM, + source("kotlin-route", "kotlin"), + source("java-route", "java"), + ) + + +def test_seeded_property_graphs_round_trip_and_invalid_wrappers_fail(): + rng = random.Random(0x5A17) + leaves = [ + SemanticType.BOOL, + SemanticType.INT32, + SemanticType.INT64, + SemanticType.FLOAT32, + SemanticType.FLOAT64, + SemanticType.STRING, + SemanticType.BYTES, + ] + for _ in range(10_000): + value = rng.choice(leaves) + for _ in range(rng.randrange(0, 7)): + value = ( + SemanticType.array(value) + if rng.randrange(2) == 0 or value.kind is SemanticTypeKind.NULLABLE + else SemanticType.nullable(value) + ) + assert semantic_type_from_manifest(value.manifest()) == value + + invalid = [ + {"kind": "array", "element": {"kind": "void"}}, + { + "kind": "nullable", + "inner": {"kind": "nullable", "inner": {"kind": "scalar", "name": "bool"}}, + }, + {"kind": "object_ref", "type_id": ""}, + {"kind": "scalar", "name": "uint32"}, + ] + for raw in invalid: + with pytest.raises(SemanticTypeError): + semantic_type_from_manifest(raw) From bb4e4414946de5b3d009b0efda5c7e0473aab862 Mon Sep 17 00:00:00 2001 From: s Date: Fri, 21 Aug 2026 13:32:47 +0300 Subject: [PATCH 2/3] test: use supported TypeScript module resolution --- tests/test_v3_phase3_reachability_typescript.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_v3_phase3_reachability_typescript.py b/tests/test_v3_phase3_reachability_typescript.py index fade7ab..0abf9b3 100644 --- a/tests/test_v3_phase3_reachability_typescript.py +++ b/tests/test_v3_phase3_reachability_typescript.py @@ -413,8 +413,10 @@ def test_generated_contract_and_expect_error_fixture_pass_real_tsc(tmp_path: Pat "--strict", "--target", "ES2020", + "--module", + "Node16", "--moduleResolution", - "node", + "Node16", "consumer.ts", ], cwd=tmp_path, From bfdf7a87bff948fc17275e62ba8c55b8f128ee7e Mon Sep 17 00:00:00 2001 From: s Date: Sat, 22 Aug 2026 00:40:29 +0300 Subject: [PATCH 3/3] feat: complete V3 native object lifecycle --- .gitignore | 4 + docs/V3-ARCHITECTURE.md | 6 + .../final-artifacts-20260822/README.md | 25 + .../evidence/robustness-host-results.txt | 9 + .../README.md | 21 + .../mixed-soak-results.jsonl | 12 + .../pending-lifecycle-results.jsonl | 6 + .../resource-summary.txt | 22 + .../reload-stress-results.jsonl | 27 + .../thread-retention-summary.txt | 33 + .../reload-stress-results.jsonl | 27 + .../thread-lifecycle-summary.txt | 34 + .../2026-08-21T12-41-03Z/results.jsonl | 39 ++ .../results/2026-08-21T12-41-03Z/summary.md | 196 ++++++ .../jvm_object_binding_codegen.py | 8 +- src/supernote_module_generator/jvm_routes.py | 7 + .../plugin_runtime_codegen.py | 114 +++- tests/test_binding_codegen.py | 593 +++++++----------- tests/test_jvm_manifest_projection.py | 95 +-- tests/test_plugin_runtime_codegen.py | 16 + 20 files changed, 810 insertions(+), 484 deletions(-) create mode 100644 plugin-testing/results/2026-08-21T12-41-03Z/evidence/final-artifacts-20260822/README.md create mode 100644 plugin-testing/results/2026-08-21T12-41-03Z/evidence/robustness-host-results.txt create mode 100644 plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-final-device-campaigns-20260821T155301Z/README.md create mode 100644 plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-final-device-campaigns-20260821T155301Z/mixed-soak-results.jsonl create mode 100644 plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-final-device-campaigns-20260821T155301Z/pending-lifecycle-results.jsonl create mode 100644 plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-identity-lifetime-stress-20260821T134924Z/resource-summary.txt create mode 100644 plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-stress-20260821T140705Z/reload-stress-results.jsonl create mode 100644 plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-stress-20260821T140705Z/thread-retention-summary.txt create mode 100644 plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-thread-fix-20260821T143347Z/reload-stress-results.jsonl create mode 100644 plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-thread-fix-20260821T143347Z/thread-lifecycle-summary.txt create mode 100644 plugin-testing/results/2026-08-21T12-41-03Z/results.jsonl create mode 100644 plugin-testing/results/2026-08-21T12-41-03Z/summary.md diff --git a/.gitignore b/.gitignore index 0f4ded7..f428d0a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ __pycache__/ *.egg-info/ build/ dist/ + +# Device-test fixtures, plans, generated Android outputs, and raw diagnostics +# stay local. Curated timestamped records are added explicitly after review. +plugin-testing/* diff --git a/docs/V3-ARCHITECTURE.md b/docs/V3-ARCHITECTURE.md index d37d3ef..2358c3d 100644 --- a/docs/V3-ARCHITECTURE.md +++ b/docs/V3-ARCHITECTURE.md @@ -97,6 +97,12 @@ supported Kotlin `suspend` implementations use the generated coroutine route. Once accepted, both use the same pending-operation, exactly-once completion, error, cancellation, and teardown lifecycle. +Worker and deferred-destruction services start lazily. When the final session +for one generated runtime generation is invalidated, that generation explicitly +stops and joins its workers, clears pending JVM completions, and drains cleanup. +This cleanup does not depend on the native library's static destructor because +PluginHost may retain loaded generations in one process. + Feature-only teardown rejects pending Promises while the runtime is healthy. Runtime teardown performs no JSI work and drops later completions. Physical work is cooperatively cancelled, never forcibly terminated, and teardown never waits diff --git a/plugin-testing/results/2026-08-21T12-41-03Z/evidence/final-artifacts-20260822/README.md b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/final-artifacts-20260822/README.md new file mode 100644 index 0000000..65c5a1f --- /dev/null +++ b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/final-artifacts-20260822/README.md @@ -0,0 +1,25 @@ +# Final generator and artifact verification + +Source suite: + +- `python3 -m pytest -q`: 571 passed in 14.32 seconds. + +Exact isolated artifacts: + +| Artifact | SHA-256 | Offline installed suite | +| --- | --- | --- | +| `supernote_module_generator-3.0.0.dev0-py3-none-any.whl` | `2681ba5afe39ef3b6d6e33077e1e04455624a3d223fd16881252d7d21254220b` | 571 passed in 14.68 seconds | +| `supernote-module-generator-3.0.0.dev0.tar.gz` | `3854e7c86caaafa8ef932dacf60731bbd5132a9a8bb1ce75ed5995c9089bb669` | 571 passed in 14.91 seconds | + +Both artifacts were built into an initially empty isolated directory using the +project's setuptools PEP 517 backend. `twine check` accepted both. Each was +installed without network access or dependencies, reported `supernote-module +3.0.0.dev0`, and imported from its own virtual environment's `site-packages`. +The sdist environment used the locally installed `wheel` build dependency via +system site packages because a plain new Python 3.9 venv omits that standard +build dependency; the generated package itself was installed from the exact +sdist and took precedence over the host's unrelated V2 installation. + +Archive checks confirmed the V3 runtime/object generator sources, object/value +annotation templates, JSI module template, console-script entry point, and +runtime regression tests are present in the relevant artifacts. diff --git a/plugin-testing/results/2026-08-21T12-41-03Z/evidence/robustness-host-results.txt b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/robustness-host-results.txt new file mode 100644 index 0000000..279aa09 --- /dev/null +++ b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/robustness-host-results.txt @@ -0,0 +1,9 @@ +Retained deterministic fuzz and ASan/UBSan campaign: + command: python3 -m pytest -q -rs tests/test_v3_phase2_frontends.py tests/test_jvm_manifest_projection.py tests/test_v3_semantic_model.py tests/test_v3_phase4_conversion.py tests/test_v3_phase4_generated_kernels.py tests/test_v3_phase5_cpp_object_runtime.py + result: 155 passed in 4.95s + skips: 0 + +ThreadSanitizer runtime teardown/cancellation campaign: + command: env SUPERNOTE_V3_TSAN=1 python3 -m pytest -q -rs tests/test_plugin_runtime_codegen.py::test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts + result: 1 passed in 1.91s + skips: 0 diff --git a/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-final-device-campaigns-20260821T155301Z/README.md b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-final-device-campaigns-20260821T155301Z/README.md new file mode 100644 index 0000000..0b855a3 --- /dev/null +++ b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-final-device-campaigns-20260821T155301Z/README.md @@ -0,0 +1,21 @@ +# Final V3 device campaigns + +Device: `SN100C10004301` +PluginHost PID: `19801` + +- `mixed-soak-results.jsonl` retains one invalid 6m53s attempt caused by the + host-side unbounded `adb logcat` read, followed by the passing 30m run + `20260821151557566`. The passing run completed 1,721 mixed C++/JVM async + waves, preserved identity, and emitted `V3_MIXED_SOAK_PASS`. +- `pending-lifecycle-results.jsonl` retains one invalid pre-scenario attempt + with the same host-side log-buffer limit, followed by passing run + `20260821154939409`. Close, application switch, sleep/wake, and bundle + replacement passed; the replacement case observed zero old-work completion + markers. +- `final-diagnostics/` is the post-campaign fixture diagnostic snapshot. Its + V3-specific log lines contain the mixed-soak pass marker, the three + resolvable lifecycle markers, and the replacement-ready marker. No V3 + failure marker appears. + +The harness now reads a bounded recent log window and restores the ordinary +fixture `App.tsx` and bundle in `finally` after each campaign. diff --git a/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-final-device-campaigns-20260821T155301Z/mixed-soak-results.jsonl b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-final-device-campaigns-20260821T155301Z/mixed-soak-results.jsonl new file mode 100644 index 0000000..b691015 --- /dev/null +++ b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-final-device-campaigns-20260821T155301Z/mixed-soak-results.jsonl @@ -0,0 +1,12 @@ +{"kind":"mixed_soak_start","run_id":"20260821150804632","duration_minutes":30,"resources":{"pid":"19801","threads":51,"vmRssKb":244688}} +{"kind":"mixed_soak_checkpoint","run_id":"20260821150804632","elapsed_ms":21229,"resources":{"pid":"19801","threads":51,"vmRssKb":244636}} +{"kind":"mixed_soak_checkpoint","run_id":"20260821150804632","elapsed_ms":331515,"resources":{"pid":"19801","threads":49,"vmRssKb":246700}} +{"kind":"mixed_soak_complete","run_id":"20260821150804632","status":"fail","elapsed_ms":412744,"resources":{"pid":"19801","threads":49,"vmRssKb":264500}} +{"kind":"mixed_soak_start","run_id":"20260821151557566","duration_minutes":30,"resources":{"pid":"19801","threads":50,"vmRssKb":281540}} +{"kind":"mixed_soak_checkpoint","run_id":"20260821151557566","elapsed_ms":23221,"resources":{"pid":"19801","threads":50,"vmRssKb":281540}} +{"kind":"mixed_soak_checkpoint","run_id":"20260821151557566","elapsed_ms":336155,"resources":{"pid":"19801","threads":49,"vmRssKb":282764}} +{"kind":"mixed_soak_checkpoint","run_id":"20260821151557566","elapsed_ms":648981,"resources":{"pid":"19801","threads":49,"vmRssKb":281928}} +{"kind":"mixed_soak_checkpoint","run_id":"20260821151557566","elapsed_ms":930713,"resources":{"pid":"19801","threads":49,"vmRssKb":281808}} +{"kind":"mixed_soak_checkpoint","run_id":"20260821151557566","elapsed_ms":1243805,"resources":{"pid":"19801","threads":49,"vmRssKb":282364}} +{"kind":"mixed_soak_checkpoint","run_id":"20260821151557566","elapsed_ms":1525671,"resources":{"pid":"19801","threads":49,"vmRssKb":282408}} +{"kind":"mixed_soak_complete","run_id":"20260821151557566","status":"pass","elapsed_ms":1860336,"resources":{"pid":"19801","threads":49,"vmRssKb":299972}} diff --git a/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-final-device-campaigns-20260821T155301Z/pending-lifecycle-results.jsonl b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-final-device-campaigns-20260821T155301Z/pending-lifecycle-results.jsonl new file mode 100644 index 0000000..0ac2b51 --- /dev/null +++ b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-final-device-campaigns-20260821T155301Z/pending-lifecycle-results.jsonl @@ -0,0 +1,6 @@ +{"kind":"pending_lifecycle_complete","run_id":"20260821154712633","status":"fail","resources":{"pid":"19801","threads":49,"vmRssKb":339788}} +{"kind":"pending_lifecycle_case","run_id":"20260821154939409","scenario":"close","status":"pass","before":{"pid":"19801","threads":50,"vmRssKb":359396},"after":{"pid":"19801","threads":49,"vmRssKb":359392}} +{"kind":"pending_lifecycle_case","run_id":"20260821154939409","scenario":"switch","status":"pass","before":{"pid":"19801","threads":50,"vmRssKb":376804},"after":{"pid":"19801","threads":49,"vmRssKb":376804}} +{"kind":"pending_lifecycle_case","run_id":"20260821154939409","scenario":"sleep-wake","status":"pass","before":{"pid":"19801","threads":50,"vmRssKb":397592},"after":{"pid":"19801","threads":49,"vmRssKb":397588}} +{"kind":"pending_lifecycle_case","run_id":"20260821154939409","scenario":"replacement","status":"pass","before":{"pid":"19801","threads":50,"vmRssKb":416196},"after":{"pid":"19801","threads":44,"vmRssKb":436156},"old_completion_markers":0} +{"kind":"pending_lifecycle_complete","run_id":"20260821154939409","status":"pass","resources":{"pid":"19801","threads":49,"vmRssKb":432032}} diff --git a/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-identity-lifetime-stress-20260821T134924Z/resource-summary.txt b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-identity-lifetime-stress-20260821T134924Z/resource-summary.txt new file mode 100644 index 0000000..5810a93 --- /dev/null +++ b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-identity-lifetime-stress-20260821T134924Z/resource-summary.txt @@ -0,0 +1,22 @@ +V3 full-capability identity/lifetime stress resource summary +Device: SN100C10004301 +PluginHost PID: 7126 before and after + +Before: + PSS: 111866 KB + RSS: 219716 KB (dumpsys meminfo) + Threads: 55 + +After all completion markers: + PSS: 127654 KB + RSS: 235852 KB (dumpsys meminfo) + VmRSS: 229440 KB (/proc/7126/status) + Threads: 54 + +Delta: + PSS: +15788 KB + RSS: +16136 KB + Threads: -1 + +One before/after sample is not sufficient to classify a leak. The workload +completed in the same PID with zero PluginHost/runtime error-pattern lines. diff --git a/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-stress-20260821T140705Z/reload-stress-results.jsonl b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-stress-20260821T140705Z/reload-stress-results.jsonl new file mode 100644 index 0000000..033601a --- /dev/null +++ b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-stress-20260821T140705Z/reload-stress-results.jsonl @@ -0,0 +1,27 @@ +{"kind":"reload_stress_start","cycles":25,"pluginHostPid":"8822","startedAt":"2026-08-21T13:54:51.353Z","resources":{"threads":55,"vmRssKb":288012}} +{"kind":"reload_stress_cycle","cycle":1,"revision":2,"marker":"V3_RELOAD_PROBE_PASS bundle=2 native=1","pluginHostPid":"8822","durationMs":17895,"resources":{"threads":61,"vmRssKb":309124}} +{"kind":"reload_stress_cycle","cycle":2,"revision":3,"marker":"V3_RELOAD_PROBE_PASS bundle=3 native=1","pluginHostPid":"8822","durationMs":18333} +{"kind":"reload_stress_cycle","cycle":3,"revision":4,"marker":"V3_RELOAD_PROBE_PASS bundle=4 native=1","pluginHostPid":"8822","durationMs":18788} +{"kind":"reload_stress_cycle","cycle":4,"revision":5,"marker":"V3_RELOAD_PROBE_PASS bundle=5 native=1","pluginHostPid":"8822","durationMs":19225} +{"kind":"reload_stress_cycle","cycle":5,"revision":6,"marker":"V3_RELOAD_PROBE_PASS bundle=6 native=1","pluginHostPid":"8822","durationMs":19555,"resources":{"threads":81,"vmRssKb":397368}} +{"kind":"reload_stress_cycle","cycle":6,"revision":7,"marker":"V3_RELOAD_PROBE_PASS bundle=7 native=1","pluginHostPid":"8822","durationMs":20097} +{"kind":"reload_stress_cycle","cycle":7,"revision":8,"marker":"V3_RELOAD_PROBE_PASS bundle=8 native=1","pluginHostPid":"8822","durationMs":20451} +{"kind":"reload_stress_cycle","cycle":8,"revision":9,"marker":"V3_RELOAD_PROBE_PASS bundle=9 native=1","pluginHostPid":"8822","durationMs":21048} +{"kind":"reload_stress_cycle","cycle":9,"revision":10,"marker":"V3_RELOAD_PROBE_PASS bundle=10 native=1","pluginHostPid":"8822","durationMs":21378} +{"kind":"reload_stress_cycle","cycle":10,"revision":11,"marker":"V3_RELOAD_PROBE_PASS bundle=11 native=1","pluginHostPid":"8822","durationMs":21779,"resources":{"threads":106,"vmRssKb":280632}} +{"kind":"reload_stress_cycle","cycle":11,"revision":12,"marker":"V3_RELOAD_PROBE_PASS bundle=12 native=1","pluginHostPid":"8822","durationMs":22113} +{"kind":"reload_stress_cycle","cycle":12,"revision":13,"marker":"V3_RELOAD_PROBE_PASS bundle=13 native=1","pluginHostPid":"8822","durationMs":22664} +{"kind":"reload_stress_cycle","cycle":13,"revision":14,"marker":"V3_RELOAD_PROBE_PASS bundle=14 native=1","pluginHostPid":"8822","durationMs":23076} +{"kind":"reload_stress_cycle","cycle":14,"revision":15,"marker":"V3_RELOAD_PROBE_PASS bundle=15 native=1","pluginHostPid":"8822","durationMs":23465} +{"kind":"reload_stress_cycle","cycle":15,"revision":16,"marker":"V3_RELOAD_PROBE_PASS bundle=16 native=1","pluginHostPid":"8822","durationMs":23917,"resources":{"threads":131,"vmRssKb":340860}} +{"kind":"reload_stress_cycle","cycle":16,"revision":17,"marker":"V3_RELOAD_PROBE_PASS bundle=17 native=1","pluginHostPid":"8822","durationMs":24238} +{"kind":"reload_stress_cycle","cycle":17,"revision":18,"marker":"V3_RELOAD_PROBE_PASS bundle=18 native=1","pluginHostPid":"8822","durationMs":24706} +{"kind":"reload_stress_cycle","cycle":18,"revision":19,"marker":"V3_RELOAD_PROBE_PASS bundle=19 native=1","pluginHostPid":"8822","durationMs":25194} +{"kind":"reload_stress_cycle","cycle":19,"revision":20,"marker":"V3_RELOAD_PROBE_PASS bundle=20 native=1","pluginHostPid":"8822","durationMs":25532} +{"kind":"reload_stress_cycle","cycle":20,"revision":21,"marker":"V3_RELOAD_PROBE_PASS bundle=21 native=1","pluginHostPid":"8822","durationMs":26046,"resources":{"threads":156,"vmRssKb":301424}} +{"kind":"reload_stress_cycle","cycle":21,"revision":22,"marker":"V3_RELOAD_PROBE_PASS bundle=22 native=1","pluginHostPid":"8822","durationMs":26589} +{"kind":"reload_stress_cycle","cycle":22,"revision":23,"marker":"V3_RELOAD_PROBE_PASS bundle=23 native=1","pluginHostPid":"8822","durationMs":26863} +{"kind":"reload_stress_cycle","cycle":23,"revision":24,"marker":"V3_RELOAD_PROBE_PASS bundle=24 native=1","pluginHostPid":"8822","durationMs":27004} +{"kind":"reload_stress_cycle","cycle":24,"revision":25,"marker":"V3_RELOAD_PROBE_PASS bundle=25 native=1","pluginHostPid":"8822","durationMs":26729} +{"kind":"reload_stress_cycle","cycle":25,"revision":26,"marker":"V3_RELOAD_PROBE_PASS bundle=26 native=1","pluginHostPid":"8822","durationMs":26844,"resources":{"threads":181,"vmRssKb":263432}} +{"kind":"reload_stress_complete","cycles":25,"pluginHostPid":"8822","startedAt":"2026-08-21T13:54:51.353Z","completedAt":"2026-08-21T14:04:25.355Z"} diff --git a/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-stress-20260821T140705Z/thread-retention-summary.txt b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-stress-20260821T140705Z/thread-retention-summary.txt new file mode 100644 index 0000000..f7d369c --- /dev/null +++ b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-stress-20260821T140705Z/thread-retention-summary.txt @@ -0,0 +1,33 @@ +V3 reload-probe 25-replacement resource summary +Device: SN100C10004301 +PluginHost PID: 8822 for all cycles +Package SHA-256: 249702b701d938bdb6dbb470788179aa005747e4b845c56952c4e6708271fdc0 + +Checkpoint Threads VmRSS KB +Start 55 288012 +Cycle 1 61 309124 +Cycle 5 81 397368 +Cycle 10 106 280632 +Cycle 15 131 340860 +Cycle 20 156 301424 +Cycle 25 181 263432 +30 seconds idle 181 263432 + +All 25 unique JavaScript markers passed through bundle revision 26, native +generation remained 1, and PluginHost PID remained 8822. RSS fluctuated and +finished below the starting sample, but threads grew by 126 and did not drain +after 30 seconds. The final thread inventory contained 115 mqt_native_modu +threads plus repeated numbered worker threads. + +An unchanged-bundle reopen after the test produced MenuEvent in the existing +React context and did not create another React instance; the run helper timed +out only because an already-mounted plugin does not emit a new Running line. +Threads fell from 181 to 180 during that comparison. This separates the +retention from ordinary reopen behavior and ties it to repeated bundle +replacement plus React-instance creation. + +Later source inspection and a fixed device rerun reclassified this as generated +V3 runtime ownership. Each retained runtime DSO eagerly started four executor +threads and one deferred-destruction thread, matching the observed five-thread +increment per generation. Evidence for the fixed rerun is in +`../v3-reload-thread-fix-20260821T143347Z`. diff --git a/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-thread-fix-20260821T143347Z/reload-stress-results.jsonl b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-thread-fix-20260821T143347Z/reload-stress-results.jsonl new file mode 100644 index 0000000..4ea8859 --- /dev/null +++ b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-thread-fix-20260821T143347Z/reload-stress-results.jsonl @@ -0,0 +1,27 @@ +{"kind":"reload_stress_start","cycles":25,"pluginHostPid":"14490","startedAt":"2026-08-21T14:24:13.426Z","resources":{"threads":39,"vmRssKb":241800}} +{"kind":"reload_stress_cycle","cycle":1,"revision":2,"marker":"V3_RELOAD_PROBE_PASS bundle=2 native=1","pluginHostPid":"14490","durationMs":17490,"resources":{"threads":40,"vmRssKb":264932}} +{"kind":"reload_stress_cycle","cycle":2,"revision":3,"marker":"V3_RELOAD_PROBE_PASS bundle=3 native=1","pluginHostPid":"14490","durationMs":17717} +{"kind":"reload_stress_cycle","cycle":3,"revision":4,"marker":"V3_RELOAD_PROBE_PASS bundle=4 native=1","pluginHostPid":"14490","durationMs":18108} +{"kind":"reload_stress_cycle","cycle":4,"revision":5,"marker":"V3_RELOAD_PROBE_PASS bundle=5 native=1","pluginHostPid":"14490","durationMs":18573} +{"kind":"reload_stress_cycle","cycle":5,"revision":6,"marker":"V3_RELOAD_PROBE_PASS bundle=6 native=1","pluginHostPid":"14490","durationMs":18989,"resources":{"threads":41,"vmRssKb":351464}} +{"kind":"reload_stress_cycle","cycle":6,"revision":7,"marker":"V3_RELOAD_PROBE_PASS bundle=7 native=1","pluginHostPid":"14490","durationMs":19395} +{"kind":"reload_stress_cycle","cycle":7,"revision":8,"marker":"V3_RELOAD_PROBE_PASS bundle=8 native=1","pluginHostPid":"14490","durationMs":19713} +{"kind":"reload_stress_cycle","cycle":8,"revision":9,"marker":"V3_RELOAD_PROBE_PASS bundle=9 native=1","pluginHostPid":"14490","durationMs":20176} +{"kind":"reload_stress_cycle","cycle":9,"revision":10,"marker":"V3_RELOAD_PROBE_PASS bundle=10 native=1","pluginHostPid":"14490","durationMs":20695} +{"kind":"reload_stress_cycle","cycle":10,"revision":11,"marker":"V3_RELOAD_PROBE_PASS bundle=11 native=1","pluginHostPid":"14490","durationMs":21078,"resources":{"threads":41,"vmRssKb":257848}} +{"kind":"reload_stress_cycle","cycle":11,"revision":12,"marker":"V3_RELOAD_PROBE_PASS bundle=12 native=1","pluginHostPid":"14490","durationMs":21530} +{"kind":"reload_stress_cycle","cycle":12,"revision":13,"marker":"V3_RELOAD_PROBE_PASS bundle=13 native=1","pluginHostPid":"14490","durationMs":22043} +{"kind":"reload_stress_cycle","cycle":13,"revision":14,"marker":"V3_RELOAD_PROBE_PASS bundle=14 native=1","pluginHostPid":"14490","durationMs":22388} +{"kind":"reload_stress_cycle","cycle":14,"revision":15,"marker":"V3_RELOAD_PROBE_PASS bundle=15 native=1","pluginHostPid":"14490","durationMs":22725} +{"kind":"reload_stress_cycle","cycle":15,"revision":16,"marker":"V3_RELOAD_PROBE_PASS bundle=16 native=1","pluginHostPid":"14490","durationMs":23137,"resources":{"threads":41,"vmRssKb":318420}} +{"kind":"reload_stress_cycle","cycle":16,"revision":17,"marker":"V3_RELOAD_PROBE_PASS bundle=17 native=1","pluginHostPid":"14490","durationMs":23672} +{"kind":"reload_stress_cycle","cycle":17,"revision":18,"marker":"V3_RELOAD_PROBE_PASS bundle=18 native=1","pluginHostPid":"14490","durationMs":24010} +{"kind":"reload_stress_cycle","cycle":18,"revision":19,"marker":"V3_RELOAD_PROBE_PASS bundle=19 native=1","pluginHostPid":"14490","durationMs":24468} +{"kind":"reload_stress_cycle","cycle":19,"revision":20,"marker":"V3_RELOAD_PROBE_PASS bundle=20 native=1","pluginHostPid":"14490","durationMs":24814} +{"kind":"reload_stress_cycle","cycle":20,"revision":21,"marker":"V3_RELOAD_PROBE_PASS bundle=21 native=1","pluginHostPid":"14490","durationMs":25251,"resources":{"threads":41,"vmRssKb":315932}} +{"kind":"reload_stress_cycle","cycle":21,"revision":22,"marker":"V3_RELOAD_PROBE_PASS bundle=22 native=1","pluginHostPid":"14490","durationMs":25757} +{"kind":"reload_stress_cycle","cycle":22,"revision":23,"marker":"V3_RELOAD_PROBE_PASS bundle=23 native=1","pluginHostPid":"14490","durationMs":26084} +{"kind":"reload_stress_cycle","cycle":23,"revision":24,"marker":"V3_RELOAD_PROBE_PASS bundle=24 native=1","pluginHostPid":"14490","durationMs":26693} +{"kind":"reload_stress_cycle","cycle":24,"revision":25,"marker":"V3_RELOAD_PROBE_PASS bundle=25 native=1","pluginHostPid":"14490","durationMs":26691} +{"kind":"reload_stress_cycle","cycle":25,"revision":26,"marker":"V3_RELOAD_PROBE_PASS bundle=26 native=1","pluginHostPid":"14490","durationMs":26738,"resources":{"threads":41,"vmRssKb":260756}} +{"kind":"reload_stress_complete","cycles":25,"pluginHostPid":"14490","startedAt":"2026-08-21T14:24:13.426Z","completedAt":"2026-08-21T14:33:31.783Z"} diff --git a/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-thread-fix-20260821T143347Z/thread-lifecycle-summary.txt b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-thread-fix-20260821T143347Z/thread-lifecycle-summary.txt new file mode 100644 index 0000000..9a26cdf --- /dev/null +++ b/plugin-testing/results/2026-08-21T12-41-03Z/evidence/v3-reload-thread-fix-20260821T143347Z/thread-lifecycle-summary.txt @@ -0,0 +1,34 @@ +V3 reload-probe worker lifecycle fix verification +Device: SN100C10004301 +PluginHost PID: 14490 for the baseline, all 25 cycles, and idle check +Package SHA-256: f12e76ecf9e370337460677f69e04e5f7ad8b4a4f6193d144d2ee2aad456f400 +Generated runtime: 7a1b606af789 + +Checkpoint Threads VmRSS KB +Start 39 241800 +Cycle 1 40 264932 +Cycle 5 41 351464 +Cycle 10 41 257848 +Cycle 15 41 318420 +Cycle 20 41 315932 +Cycle 25 41 260756 +Delayed idle 40 260732 + +All 25 unique JavaScript markers passed through bundle revision 26, native +generation remained 1, and PluginHost PID remained 14490. The diagnostic log +contains 26 pass markers (the initial launch plus 25 replacements), exactly 25 +"stopped process services for runtime generation" markers, and zero matched +PluginHost/runtime error lines. + +The original generated runtime eagerly created one four-thread BoundedExecutor +and one DeferredDestruction thread in every uniquely loaded runtime generation. +Those per-DSO static services could not reach their C++ static destructor while +PluginHost retained the generation, explaining the measured five-thread linear +increment. The fix makes both services lazy and explicitly shuts down the last +session's process services from nativeInvalidate. The fixed device run changed +from 39 to 41 threads during the stress and settled at 40 after idle, compared +with the pre-fix change from 55 to 181 that remained at 181 after idle. + +The remaining one-thread difference is ordinary PluginHost/React fluctuation: +the final inventory contains two active React contexts, not repeated groups of +V3 worker threads. RSS fluctuated and is not classified as a leak by this test. diff --git a/plugin-testing/results/2026-08-21T12-41-03Z/results.jsonl b/plugin-testing/results/2026-08-21T12-41-03Z/results.jsonl new file mode 100644 index 0000000..bf70cc7 --- /dev/null +++ b/plugin-testing/results/2026-08-21T12-41-03Z/results.jsonl @@ -0,0 +1,39 @@ +{"kind":"suite","name":"generator-pytest","status":"pass","tests":571,"duration_seconds":14.55} +{"kind":"suite","name":"focused-generator-validation","status":"pass","plugins":6} +{"kind":"suite","name":"focused-typescript","status":"pass","plugins":6} +{"kind":"suite","name":"focused-jest","status":"pass","plugins":6,"tests":24} +{"kind":"suite","name":"focused-lint","status":"pass_with_warnings","plugins":6,"errors":0} +{"kind":"suite","name":"focused-debug-builds","status":"pass","plugins":6} +{"kind":"suite","name":"focused-package-verification","status":"pass","plugins":6} +{"kind":"device","serial":"SN100C10004301","model":"Supernote_Nomad","status":"connected"} +{"kind":"device_event","serial":"SN100C10004301","action":"reboot_for_fresh_pluginhost","battery_percent":100,"status":"adb_disconnected_after_bounded_wait","plugins_deployed":0} +{"kind":"device_plugin","plugin":"v3-minimal-legacy","plugin_id":"v3legacyprobe001","package_sha256":"ba02a02e759cf267673a616cf8c89a3dcf148c3eaba3533f0073a650442c7e62","runtime":"092a75f1d622","pluginhost_pid":"1346","status":"pass","markers":["V3_MINIMAL_LEGACY_SYNC_PASS","V3_MINIMAL_LEGACY_ASYNC_PASS"],"error_pattern_lines":0,"evidence":"evidence/v3-minimal-legacy-20260821T125013Z"} +{"kind":"device_event","serial":"SN100C10004301","action":"recover_pluginhost","status":"success_with_workflow_defect","next_plugin":"v3-cpp-objects","npm_command":"npm run recover","npm_status":"failed_missing_required_confirmation_argument","fallback_command":"bash scripts/recoverPluginhost.sh --yes","owner":"template-workflow","notes":"The official recovery implementation succeeded, but the public npm wrapper did not forward its mandatory --yes argument; this is not an npm workflow pass."} +{"kind":"device_plugin","plugin":"v3-cpp-objects","plugin_id":"v3cppobject00001","package_sha256":"5481181b8efd1cd2fc600b324473b0e127ca81cf93957c7ed875ea138d554130","runtime":"b64dcf248c5b","pluginhost_pid":"2177","status":"pass","markers":["V3_CPP_OBJECT_SYNC_PASS","V3_CPP_OBJECT_ASYNC_PASS"],"error_pattern_lines":0,"evidence":"evidence/v3-cpp-objects-20260821T125812Z"} +{"kind":"device_event","serial":"SN100C10004301","action":"npm_run_recover","status":"blocked_environment","actual":"ADB server could not bind its local smart socket inside the managed sandbox","owner":"environment","next_plugin":"v3-jvm-objects"} +{"kind":"device_event","serial":"SN100C10004301","action":"npm_run_recover","status":"pass","actual":"The corrected npm wrapper supplied the mandatory confirmation flag and recovery completed with authorized ADB access","owner":"template-workflow","next_plugin":"v3-jvm-objects"} +{"kind":"device_plugin","plugin":"v3-jvm-objects","plugin_id":"v3jvmobject00001","package_sha256":"c4efcf2b70c65f014e0be6ce51dd139cfc637430dc26286526681ab0c1e95738","runtime":"4c3c0cd6e9fb","pluginhost_pid":"3091","status":"pass","commands":["npm run recover","npm run deploy","npm run run","npm run diagnostics"],"markers":["V3_JVM_OBJECT_SYNC_PASS","V3_JVM_OBJECT_ASYNC_PASS"],"error_pattern_lines":0,"evidence":"evidence/v3-jvm-objects-20260821T130557Z"} +{"kind":"device_plugin","plugin":"v3-cross-family-values","plugin_id":"v3crossvalue0001","package_sha256":"2a3ec9c662f10e6ff251ae7f5fefa1b3ec03faa7f89ecac0788b690670d2890a","runtime":"a08c75047e65","pluginhost_pid":"3605","status":"pass","commands":["npm run recover","npm run deploy","npm run run","npm run diagnostics"],"markers":["V3_CROSS_FAMILY_VALUE_SYNC_PASS","V3_CROSS_FAMILY_VALUE_ASYNC_PASS"],"error_pattern_lines":0,"evidence":"evidence/v3-cross-family-values-20260821T130816Z"} +{"kind":"device_plugin","plugin":"v3-async-lifecycle","plugin_id":"v3lifecycle00001","package_sha256":"4d105aebd60f1a5bfc73eee8f09acf9e8c4968f6d4bc86bd5be4996f4f87593c","runtime":"efdbd4c4ca93","pluginhost_pid":"4099","status":"pass","commands":["npm run recover","npm run deploy","npm run run","npm run diagnostics"],"markers":["V3_ASYNC_LIFECYCLE_FAILURE_PASS","V3_ASYNC_LIFECYCLE_RETENTION_PASS"],"error_pattern_lines":0,"evidence":"evidence/v3-async-lifecycle-20260821T131032Z"} +{"kind":"device_plugin","plugin":"v3-reload-probe","plugin_id":"v3reloadprobe001","package_sha256":"09823cc017922b4eff7174b2073cc4960fdaf6a55003bf55f409ba4f30a035b1","runtime":"7a1b606af789","pluginhost_pid_before":"5553","pluginhost_pid_after":"5553","status":"pass","commands":["npm run recover","npm run deploy","npm run run","npm run diagnostics","npm run send","npm run run","npm run diagnostics","npx tsc --noEmit"],"markers":["V3_RELOAD_PROBE_PASS bundle=1 native=1","V3_RELOAD_PROBE_PASS bundle=2 native=1"],"error_pattern_lines":0,"evidence":["evidence/v3-reload-probe-baseline-20260821T132532Z","evidence/v3-reload-probe-replaced-20260821T132637Z"],"notes":"JavaScript-only replacement preserved native generation and PluginHost PID; fixture App.tsx restored to bundle revision 1 after evidence capture."} +{"kind":"suite","name":"full-capability-host","plugin":"v3-full-capability","status":"pass_with_warnings","typescript":"pass","jest_tests":1,"lint_errors":0,"lint_warnings":12,"commands":["npx tsc --noEmit","npm test -- --runInBand --no-watchman","npm run lint"]} +{"kind":"device_plugin","plugin":"v3-full-capability","plugin_id":"boy5g4q1a0fz6umz","package_sha256":"097d08d46ec15df7460dd7d07f96db6db4b0936c234363667fe73ac21124c193","runtime":"7e3e22e4e1b4","pluginhost_pid":"6419","status":"pass","commands":["npm run recover","npm run deploy","npm run run","npm run diagnostics","npm run diagnostics"],"markers":["V3_PHASE0_RECORD_PASS 20+22=42","V3_PHASE0_WEAK_LOCK_PASS","V3_PHASE5_CPP_OBJECT_PASS","V3_PHASE5_CPP_COMPOSITE_PASS","V3_PHASE7_CROSS_FAMILY_PASS","V3_PHASE6_JVM_GC_PASS","V3_PHASE10_SAFE_INSPECTION_PASS","V3_PHASE6_JVM_OBJECT_PASS","V3_PHASE8_JVM_SUSPEND_NULLABLE_PASS","V3_PHASE8_JVM_SUSPEND_FAILURE_PASS","V3_PHASE8_JVM_SUSPEND_COMPOSITE_PASS","V3_PHASE6_JVM_ASYNC_PASS","V3_PHASE6_JVM_ASYNC_FAILURE_PASS","V3_PHASE5_CPP_ASYNC_PASS","V3_PHASE8_JVM_SUSPEND_TEARDOWN_NORMAL_PASS"],"long_suspend_seconds":15.02,"error_pattern_lines":0,"evidence":"evidence/v3-full-capability-20260821T132958Z","notes":"The first diagnostic snapshot preceded the intentional 15-second suspend completion; the second retained snapshot contains all markers in the same PID."} +{"kind":"device_plugin","plugin":"v3-full-capability-minified","plugin_id":"boy5g4q1a0fz6umz","package_sha256":"6f5eb9c2eed8139aa2adb193bd1f42dac1d5cd624ac625b203452eb8221d3671","runtime":"7e3e22e4e1b4","build_type":"release_minified_r8","pluginhost_pid":"7126","status":"pass","commands":["npm run recover","npm run phase0:release","npm run deploy:release","npm run run","npm run diagnostics","npm run diagnostics"],"marker_count":15,"markers":["V3_PHASE0_RECORD_PASS 20+22=42","V3_PHASE0_WEAK_LOCK_PASS","V3_PHASE5_CPP_OBJECT_PASS","V3_PHASE5_CPP_COMPOSITE_PASS","V3_PHASE7_CROSS_FAMILY_PASS","V3_PHASE6_JVM_GC_PASS","V3_PHASE10_SAFE_INSPECTION_PASS","V3_PHASE6_JVM_OBJECT_PASS","V3_PHASE8_JVM_SUSPEND_NULLABLE_PASS","V3_PHASE8_JVM_SUSPEND_FAILURE_PASS","V3_PHASE8_JVM_SUSPEND_COMPOSITE_PASS","V3_PHASE6_JVM_ASYNC_PASS","V3_PHASE6_JVM_ASYNC_FAILURE_PASS","V3_PHASE5_CPP_ASYNC_PASS","V3_PHASE8_JVM_SUSPEND_TEARDOWN_NORMAL_PASS"],"long_suspend_seconds":15.02,"error_pattern_lines":0,"evidence":"evidence/v3-full-capability-minified-20260821T133402Z","notes":"deploy:release installed the verified minified package without rebuilding debug; the second snapshot captured the intentionally delayed completion marker."} +{"kind":"suite","name":"clean-room-installed-artifacts-initial","status":"blocked_harness","wheel_passed":568,"sdist_passed":568,"wheel_failed":3,"sdist_failed":3,"owner":"harness","actual":"The same three tests required repository src files that the initial harness intentionally omitted; installed imports and the other 568 tests succeeded for both artifacts."} +{"kind":"suite","name":"clean-room-wheel","status":"pass","version":"3.0.0.dev0","sha256":"70689f712c6a3811d0a22556185b916446d1bf0dec302cb94d4a7581be7548fb","offline_install":"pass","import_origin":"wheel-venv/lib/python3.9/site-packages","cli":"pass","pip_check":"pass","tests":571,"duration_seconds":13.68,"pytest_pythonpath":"disabled","evidence":"evidence/distributions/clean-room-results.txt"} +{"kind":"suite","name":"clean-room-sdist","status":"pass","version":"3.0.0.dev0","sha256":"d49fde74abbfaa4c9720a28311b5a9f75e4d699c85f09d9e1340b636bbe3689b","offline_install":"pass","import_origin":"sdist-venv/lib/python3.9/site-packages","cli":"pass","pip_check":"pass","tests":571,"duration_seconds":13.95,"pytest_pythonpath":"disabled","evidence":"evidence/distributions/clean-room-results.txt"} +{"kind":"suite","name":"retained-fuzz-asan-ubsan","status":"pass","tests":155,"duration_seconds":4.95,"skips":0,"coverage":["cpp-parser-fuzz","jvm-manifest-fuzz","semantic-graph-fuzz","conversion-fuzz","generated-kernel-asan-ubsan","cpp-object-runtime-asan-ubsan"],"evidence":"evidence/robustness-host-results.txt"} +{"kind":"suite","name":"runtime-tsan","status":"pass","tests":1,"duration_seconds":1.91,"skips":0,"environment":{"SUPERNOTE_V3_TSAN":"1"},"coverage":["session-cancellation","completion-races","teardown","deferred-destruction"],"evidence":"evidence/robustness-host-results.txt"} +{"kind":"device_stress","name":"v3-native-object-identity-lifetime","plugin":"v3-full-capability-minified","pluginhost_pid":"7126","status":"pass","commands":["npx tsc --noEmit","npm test -- --runInBand --watchman=false","npm run send","npm run run","npm run diagnostics"],"cpp_identity_reexposures":10000,"jvm_identity_reexposures":10000,"jvm_gc_retention_cycles":1000,"temporary_object_cycles_per_family":1000,"async_operations_per_family":64,"markers":["V3_IDENTITY_STRESS_SYNC_PASS cpp=10000 jvm=10000 jvmGc=1000 temporary=1000","V3_IDENTITY_STRESS_ASYNC_PASS cpp=64 jvm=64"],"error_pattern_lines":0,"resources":{"before_pss_kb":111866,"after_pss_kb":127654,"before_rss_kb":219716,"after_rss_kb":235852,"before_threads":55,"after_threads":54},"evidence":"evidence/v3-identity-lifetime-stress-20260821T134924Z","notes":"Temporary stress source was restored and TypeScript/Jest passed afterward; a single resource delta is not classified as a leak."} +{"kind":"harness_attempt","name":"reload-stress-send-without-run","status":"blocked_harness","completed_cycles":0,"owner":"harness","actual":"npm run send replaced JavaScript but did not launch it, so the unique marker could not execute; PluginHost stayed healthy and diagnostics contained zero error-pattern lines.","evidence":"evidence/v3-reload-stress-20260821T140705Z/20260821T135404Z"} +{"kind":"device_stress","name":"v3-reload-25-functional","plugin":"v3-reload-probe","plugin_id":"v3reloadprobe001","package_sha256":"249702b701d938bdb6dbb470788179aa005747e4b845c56952c4e6708271fdc0","runtime":"7a1b606af789","pluginhost_pid":"8822","status":"pass","cycles":25,"commands":["npm run recover","npm run deploy","npm run run","npm run diagnostics","npm run test:reload-stress","npm run diagnostics","npx tsc --noEmit","npm test -- --runInBand --watchman=false"],"first_marker":"V3_RELOAD_PROBE_PASS bundle=2 native=1","last_marker":"V3_RELOAD_PROBE_PASS bundle=26 native=1","error_pattern_lines":0,"evidence":"evidence/v3-reload-stress-20260821T140705Z","notes":"Every replacement used npm run send followed by npm run run; App.tsx was restored by the runner and verified afterward."} +{"kind":"device_stress","name":"v3-reload-bounded-generations","plugin":"v3-reload-probe","pluginhost_pid":"8822","status":"fail","owner":"generated-runtime","threads_before":55,"threads_after":181,"threads_after_30s_idle":181,"mqt_native_module_threads_after":115,"vmrss_before_kb":288012,"vmrss_after_kb":263432,"evidence":"evidence/v3-reload-stress-20260821T140705Z/thread-retention-summary.txt","notes":"Pre-fix evidence. Each uniquely loaded V3 runtime DSO eagerly created four executor threads and one deferred-destruction thread; retained generations prevented static destruction, matching the observed five-thread increment per replacement."} +{"kind":"suite","name":"runtime-thread-lifecycle-regression","status":"pass","focused_tests":11,"related_tests":99,"full_generator_tests":571,"full_generator_duration_seconds":13.06,"tsan_tests":1,"tsan_duration_seconds":1.89,"coverage":["lazy-executor-start","lazy-deferred-destruction-start","last-session-shutdown","worker-join","cleanup-drain","session-cancellation"]} +{"kind":"device_stress","name":"v3-reload-thread-lifecycle-fixed","plugin":"v3-reload-probe","plugin_id":"v3reloadprobe001","package_sha256":"f12e76ecf9e370337460677f69e04e5f7ad8b4a4f6193d144d2ee2aad456f400","runtime":"7a1b606af789","pluginhost_pid":"14490","status":"pass","cycles":25,"commands":["npm run recover","npm run deploy","npm run run","npm run diagnostics","npm run test:reload-stress","npm run diagnostics","npx tsc --noEmit","npm test -- --runInBand --no-watchman","npm run lint"],"first_marker":"V3_RELOAD_PROBE_PASS bundle=2 native=1","last_marker":"V3_RELOAD_PROBE_PASS bundle=26 native=1","marker_count":26,"service_shutdown_markers":25,"error_pattern_lines":0,"threads_before":39,"threads_after":41,"threads_after_idle":40,"vmrss_before_kb":241800,"vmrss_after_kb":260756,"vmrss_after_idle_kb":260732,"evidence":"evidence/v3-reload-thread-fix-20260821T143347Z","notes":"All replacements remained in one PID. App.tsx was restored to bundle revision 1. TypeScript and Jest passed; lint exited zero with three existing warnings."} +{"kind":"harness_attempt","name":"v3-mixed-soak-unbounded-logcat","status":"invalid_harness","owner":"test-harness","actual":"The first run reached 6m53s of successful V3 workload but its host-side full logcat read exceeded Node's output buffer. No V3 failure marker occurred; the runner restored the fixture. The corrected bounded-log run is recorded separately.","evidence":"evidence/v3-final-device-campaigns-20260821T155301Z/mixed-soak-results.jsonl"} +{"kind":"device_soak","name":"v3-mixed-native-object-30m","plugin":"v3-async-lifecycle","plugin_id":"v3lifecycle00001","pluginhost_pid":"19801","status":"pass","run_id":"20260821151557566","duration_ms":1860336,"waves":1721,"coverage":["C++ object construction and identity","JVM object construction and identity","parallel C++ and JVM async retention","expected backend type failures"],"resources":{"threads_before":50,"threads_after":49,"vmrss_before_kb":281540,"vmrss_after_kb":299972},"marker":"V3_MIXED_SOAK_PASS run=20260821151557566 waves=1721 elapsedMs=1800436","v3_error_markers":0,"evidence":"evidence/v3-final-device-campaigns-20260821T155301Z"} +{"kind":"harness_attempt","name":"v3-pending-lifecycle-unbounded-logcat","status":"invalid_harness","owner":"test-harness","actual":"The first lifecycle invocation hit the same host-side full logcat output-buffer limit before the first scenario result. The runner restored the fixture. The corrected bounded-log matrix is recorded separately.","evidence":"evidence/v3-final-device-campaigns-20260821T155301Z/pending-lifecycle-results.jsonl"} +{"kind":"device_lifecycle_matrix","name":"v3-pending-native-object-work","plugin":"v3-async-lifecycle","plugin_id":"v3lifecycle00001","pluginhost_pid":"19801","status":"pass","run_id":"20260821154939409","cases":["close","switch","sleep-wake","bundle-replacement"],"result":"close, application switch, and sleep/wake resolved both pending C++ and JVM calls with identity preserved; replacement emitted no old-work completion marker.","replacement_old_completion_markers":0,"v3_error_markers":0,"evidence":"evidence/v3-final-device-campaigns-20260821T155301Z"} +{"kind":"suite","name":"final-generator-source-suite","status":"pass","tests":571,"duration_seconds":14.32,"command":"python3 -m pytest -q"} +{"kind":"distribution","name":"final-wheel","status":"pass","version":"3.0.0.dev0","sha256":"2681ba5afe39ef3b6d6e33077e1e04455624a3d223fd16881252d7d21254220b","twine_check":"pass","offline_install":"pass","import_origin":"wheel-venv/lib/python3.9/site-packages","cli_version":"pass","cli_help":"pass","pip_check":"pass","installed_suite_tests":571,"installed_suite_duration_seconds":14.68,"pytest_pythonpath":"disabled","evidence":"evidence/final-artifacts-20260822"} +{"kind":"distribution","name":"final-sdist","status":"pass","version":"3.0.0.dev0","sha256":"3854e7c86caaafa8ef932dacf60731bbd5132a9a8bb1ce75ed5995c9089bb669","twine_check":"pass","offline_install":"pass","import_origin":"sdist-venv-offline/lib/python3.9/site-packages","cli_version":"pass","cli_help":"pass","pip_check":"pass","installed_suite_tests":571,"installed_suite_duration_seconds":14.91,"pytest_pythonpath":"disabled","evidence":"evidence/final-artifacts-20260822","notes":"The clean sdist environment used the local wheel build dependency through system site packages; the installed V3 package path was verified."} diff --git a/plugin-testing/results/2026-08-21T12-41-03Z/summary.md b/plugin-testing/results/2026-08-21T12-41-03Z/summary.md new file mode 100644 index 0000000..27f24a0 --- /dev/null +++ b/plugin-testing/results/2026-08-21T12-41-03Z/summary.md @@ -0,0 +1,196 @@ +# V3 focused-plugin test run: 2026-08-21T12-41-03Z + +Status: focused device lane and final source/artifact/stress gates complete. + +## Checkout + +- Branch: `feature/v3-native-objects` +- Base commit: `bb4e4414946de5b3d009b0efda5c7e0473aab862` +- Local V3 implementation and test-fixture changes are intentionally present. +- The earlier combined-plugin result is superseded and contributes no pass to + this run. + +## Fresh host baseline + +| Check | Result | +| --- | --- | +| Generator suite | PASS: 571 tests in 14.55 seconds | +| Focused generator validation | PASS: 6 of 6 plugins, one independent feature each | +| Focused TypeScript | PASS: 6 of 6 plugins | +| Focused Jest | PASS: 24 tests across 6 plugins | +| Focused lint | PASS with warnings only: no errors | +| Generated Gradle semantic projection | PASS: 6 of 6 plugins | +| Debug Android/NDK package build | PASS: 6 of 6 plugins | +| Official package verification | PASS: 6 of 6 plugins | + +## Verified packages + +| Plugin | Plugin ID | SHA-256 | Generated runtime | +| --- | --- | --- | --- | +| `v3-minimal-legacy` | `v3legacyprobe001` | `ba02a02e759cf267673a616cf8c89a3dcf148c3eaba3533f0073a650442c7e62` | `092a75f1d622` | +| `v3-cpp-objects` | `v3cppobject00001` | `5481181b8efd1cd2fc600b324473b0e127ca81cf93957c7ed875ea138d554130` | `b64dcf248c5b` | +| `v3-jvm-objects` | `v3jvmobject00001` | `bd87dcbd706bc5a7b851feb7dbe047384e469f122414ec121a789fef44c4d39a` | `4c3c0cd6e9fb` | +| `v3-cross-family-values` | `v3crossvalue0001` | `81f18251649866a54ec8b6493d75d1feadd1a4c82c8cc56b8b2a40246783e933` | `a08c75047e65` | +| `v3-async-lifecycle` | `v3lifecycle00001` | `228a50cb1c9a7571a6391123d8a680762cf02a69c6e90bbd5048ff0a024ae54b` | `efdbd4c4ca93` | +| `v3-reload-probe` | `v3reloadprobe001` | `cc5c339aa774fbac46bcdacbc405bfb1e6cb0908c082cf887e393d3cefcc84e5` | `7a1b606af789` | + +## Device lane + +- Device: `SN100C10004301` (`Supernote_Nomad`), connected over USB. +- Device operations are serialized and use each official fixture script. +- Recovery is reserved for an isolated clean-state lane or an observed + PluginHost crash/restart loop, with diagnostics collected first. +- Clean-start action: device reboot requested at 100% charge. The device briefly + reappeared during boot and then remained absent from `adb devices` for the + initial bounded wait window. It later reconnected as the same authorized + serial, and testing continued without clearing PluginHost data. +- `v3-minimal-legacy`: PASS in fresh PluginHost PID `1346`. Official deploy, + run, and diagnostics completed; `V3_MINIMAL_LEGACY_SYNC_PASS` and + `V3_MINIMAL_LEGACY_ASYNC_PASS` were present, with zero PluginHost/runtime + error-pattern lines. Evidence: + `evidence/v3-minimal-legacy-20260821T125013Z`. +- `v3-cpp-objects`: PASS in recovered PluginHost PID `2177`. C++ object + parameters/returns, identity, values, fields, inspection, preflight, + returned-only objects, nominal rejection, and async identity reached + `V3_CPP_OBJECT_SYNC_PASS` and `V3_CPP_OBJECT_ASYNC_PASS`; diagnostics found + zero PluginHost/runtime error-pattern lines. Evidence: + `evidence/v3-cpp-objects-20260821T125812Z`. +- Recovery before this lane used the official recovery script directly after + `npm run recover` failed to supply the script's mandatory `--yes`. This is + recorded as a template npm-wrapper defect and is not counted as a successful + npm recovery workflow. +- The fixture npm wrappers were then corrected to bind the mandatory recovery + confirmation flags. `npm run recover` passed end to end before the next lane; + its first sandboxed attempt was blocked from starting ADB, and the authorized + retry succeeded without rebooting the tablet. +- `v3-jvm-objects`: PASS in isolated PluginHost PID `3091`. Kotlin and Java + objects, parameters/returns, identity, fields, records, inspection, + preflight, nominal rejection, and async identity reached + `V3_JVM_OBJECT_SYNC_PASS` and `V3_JVM_OBJECT_ASYNC_PASS`; diagnostics found + zero PluginHost/runtime error-pattern lines. Deployed package SHA-256: + `c4efcf2b70c65f014e0be6ce51dd139cfc637430dc26286526681ab0c1e95738`. + Evidence: `evidence/v3-jvm-objects-20260821T130557Z`. +- `v3-cross-family-values`: PASS in isolated PluginHost PID `3605`. Declared + typed values copied across the internal C++/JVM boundary and the fixture + reached `V3_CROSS_FAMILY_VALUE_SYNC_PASS` and + `V3_CROSS_FAMILY_VALUE_ASYNC_PASS`; diagnostics found zero + PluginHost/runtime error-pattern lines. Deployed package SHA-256: + `2a3ec9c662f10e6ff251ae7f5fefa1b3ec03faa7f89ecac0788b690670d2890a`. + Evidence: `evidence/v3-cross-family-values-20260821T130816Z`. +- `v3-async-lifecycle`: PASS in isolated PluginHost PID `4099`. Expected async + failure handling and native-object retention through accepted async work + reached `V3_ASYNC_LIFECYCLE_FAILURE_PASS` and + `V3_ASYNC_LIFECYCLE_RETENTION_PASS`; diagnostics found zero + PluginHost/runtime error-pattern lines. Deployed package SHA-256: + `4d105aebd60f1a5bfc73eee8f09acf9e8c4968f6d4bc86bd5be4996f4f87593c`. + Evidence: `evidence/v3-async-lifecycle-20260821T131032Z`. +- `v3-reload-probe`: PASS in PluginHost PID `5553`. The installed baseline + emitted `V3_RELOAD_PROBE_PASS bundle=1 native=1`; a JavaScript-only + `npm run send` then emitted `bundle=2 native=1` without reinstalling the + native package or changing the PluginHost PID. Both diagnostics scans found + zero PluginHost/runtime error-pattern lines. Baseline deployed package + SHA-256: `09823cc017922b4eff7174b2073cc4960fdaf6a55003bf55f409ba4f30a035b1`. + Evidence: `evidence/v3-reload-probe-baseline-20260821T132532Z` and + `evidence/v3-reload-probe-replaced-20260821T132637Z`. The fixture source was + restored to bundle revision 1 and TypeScript passed afterward. +- `v3-full-capability`: PASS in isolated PluginHost PID `6419`. All 15 expected + phase markers passed across records, weak locking, C++ objects/composites, + copied cross-family values, JVM GC/objects, safe inspection, C++ async, JVM + async/failure, and JVM suspend composite/nullable/failure/lifecycle routes. + The intentionally long suspend route completed after 15.02 seconds in the + same PID. Both diagnostic captures found zero PluginHost/runtime + error-pattern lines. Deployed package SHA-256: + `097d08d46ec15df7460dd7d07f96db6db4b0936c234363667fe73ac21124c193`. + TypeScript and Jest passed; lint exited zero with 12 warnings. Evidence: + `evidence/v3-full-capability-20260821T132958Z`. +- `v3-full-capability` minified release: PASS in isolated PluginHost PID + `7126`. `npm run phase0:release` completed R8/resource minification and + package verification; `npm run deploy:release` installed that exact package + without rebuilding debug. All 15 phase markers passed, including the + intentional 15.02-second suspend route, and diagnostics found zero + PluginHost/runtime error-pattern lines. Package SHA-256: + `6f5eb9c2eed8139aa2adb193bd1f42dac1d5cd624ac625b203452eb8221d3671`. + Evidence: `evidence/v3-full-capability-minified-20260821T133402Z`. +- Clean-room distributions: PASS. A local-source snapshot produced wheel + SHA-256 `70689f712c6a3811d0a22556185b916446d1bf0dec302cb94d4a7581be7548fb` + and sdist SHA-256 + `d49fde74abbfaa4c9720a28311b5a9f75e4d699c85f09d9e1340b636bbe3689b`. + Both installed offline into separate virtual environments, resolved imports + from their installed `site-packages`, exposed CLI version/help, passed + `pip check`, and passed all 571 tests with pytest source-path injection + disabled (wheel 13.68 seconds; sdist 13.95 seconds). The first attempt + reached 568 passes for each artifact and exposed a harness setup omission: + three source-inspection tests required a read-only `src/` fixture. That + attempt is retained rather than overwritten. Evidence: + `evidence/distributions/clean-room-results.txt` and the retained artifacts. +- Retained host robustness campaigns: PASS. The deterministic C++ parser, JVM + manifest, semantic graph, nested conversion, generated-kernel, and C++ object + runtime campaigns—including ASan/UBSan harnesses—passed 155 tests in 4.95 + seconds with no skips. The generated runtime teardown/cancellation contract + separately passed under ThreadSanitizer in 1.91 seconds. Evidence: + `evidence/robustness-host-results.txt`. +- Native-object identity/lifetime stress: PASS in unchanged minified PluginHost + PID `7126`. The device completed 10,000 identity re-exposures per backend, + 1,000 forced JVM GC-retention cycles, 1,000 temporary-object cycles per + backend, and 64 concurrent async identity operations per backend. Both stress + markers passed with zero error-pattern lines. Threads changed from 55 to 54; + PSS changed from 111,866 KB to 127,654 KB and RSS from 219,716 KB to 235,852 + KB. The single memory delta is retained as evidence but is not by itself a + leak conclusion. The temporary workload was removed and TypeScript/Jest + passed afterward. Evidence: + `evidence/v3-identity-lifetime-stress-20260821T134924Z`. +- Twenty-five JavaScript bundle replacements, pre-fix: FUNCTIONAL PASS, + BOUNDED-LIFETIME FAIL. All 25 unique markers (`bundle=2` through `bundle=26`) executed with + native generation 1 and unchanged PluginHost PID `8822`; diagnostics found + zero runtime error-pattern lines. However, threads increased from 55 to 181 + and remained at 181 after 30 seconds idle, including 115 retained + `mqt_native_modu` threads. RSS fluctuated and ended below its starting sample. + An ordinary reopen of the already-mounted unchanged bundle did not create a + new React instance or add threads, tying the retention to bundle replacement + and new runtime generations. Source inspection then identified the generated + V3 runtime as the owner: every retained runtime DSO eagerly started a + four-thread executor and one deferred-destruction thread, exactly matching + the observed five-thread increment per generation. The first automation + attempt is retained separately as a + harness block because `npm run send` correctly updated JavaScript without + launching it; the corrected runner uses `send` then `run` per cycle and + restores `App.tsx`. Evidence: + `evidence/v3-reload-stress-20260821T140705Z`. +- Generated runtime thread-lifecycle fix: PASS on host and device. The executor + and deferred-destruction services now start lazily, and invalidating the last + session explicitly shuts down the runtime generation's services. Focused + regression tests passed 11/11, the related suite passed 99/99, the full + generator suite passed 571/571 in 13.06 seconds, and the teardown contract + passed under ThreadSanitizer. A fresh 25-replacement device rerun stayed in + PluginHost PID `14490`, emitted all 26 expected pass markers and exactly 25 + service-shutdown markers, and produced zero runtime error-pattern lines. + Threads changed from 39 to 41 at cycle 25 and settled at 40 after idle, + compared with the pre-fix 55 to 181 result. TypeScript and four Jest tests + passed after the runner restored revision 1; lint exited zero with three + existing warnings. Evidence: + `evidence/v3-reload-thread-fix-20260821T143347Z`. +- Final device lifecycle campaigns: PASS in PluginHost PID `19801`. The + non-reload 30-minute mixed C++/JVM native-object soak completed 1,721 waves + with stable threads (50 to 49) and no V3 failure marker. The pending-work + matrix also passed close, application switch, sleep/wake, and bundle + replacement. The first attempt for each campaign is retained as an invalid + host-harness attempt: an unbounded `adb logcat` read exceeded Node's output + buffer before a V3 result; the bounded-log reruns are the passing evidence. + Evidence: `evidence/v3-final-device-campaigns-20260821T155301Z`. +- Final generator/artifact gate: PASS. The source suite passed 571/571 in + 14.32 seconds. An isolated exact wheel + (`2681ba5afe39ef3b6d6e33077e1e04455624a3d223fd16881252d7d21254220b`) + and sdist + (`3854e7c86caaafa8ef932dacf60731bbd5132a9a8bb1ce75ed5995c9089bb669`) + both passed `twine check`, offline installation, CLI version/help, and the + full 571-test suite while importing from their respective virtual + environment `site-packages` (14.68 and 14.91 seconds). Evidence: + `evidence/final-artifacts-20260822`. + +## Scope boundary + +- This record includes the focused fixture lanes, full generator/artifact + gates, reload-lifecycle regression, and the final mixed-object and + pending-work device campaigns described above. +- A broader fresh-process device matrix, beyond the isolated Nomad lanes in + this record, was not part of this campaign and is not claimed here. diff --git a/src/supernote_module_generator/jvm_object_binding_codegen.py b/src/supernote_module_generator/jvm_object_binding_codegen.py index 6015498..f59a3a7 100644 --- a/src/supernote_module_generator/jvm_object_binding_codegen.py +++ b/src/supernote_module_generator/jvm_object_binding_codegen.py @@ -1789,6 +1789,8 @@ def _wrapper( ) -> str: method_rows = [] for route in item.methods: + if not route.javascript_public: + continue if route.static: continue if route.execution is ExecutionMode.ASYNC: @@ -1884,7 +1886,9 @@ def _wrapper( + "\n }" ) property_names = [ - route.public_name for route in item.methods if not route.static + route.public_name + for route in item.methods + if route.javascript_public and not route.static ] + [field.public_name for field in item.fields] names = "\n".join( " names.push_back(facebook::jsi::PropNameID::forAscii(runtime, " @@ -2231,7 +2235,7 @@ def _registration( functions.extend( (route.public_name, route, False, False) for route in item.methods - if route.static + if route.javascript_public and route.static ) semantic = SemanticType.object_ref(item.named_type.type_id) is_type = _jvm_type_guard_host_function( diff --git a/src/supernote_module_generator/jvm_routes.py b/src/supernote_module_generator/jvm_routes.py index e708089..9c5e3f4 100644 --- a/src/supernote_module_generator/jvm_routes.py +++ b/src/supernote_module_generator/jvm_routes.py @@ -60,6 +60,7 @@ class JvmCallableRoute: owner_type_id: Optional[str] static: bool suspend: bool + javascript_public: bool @dataclass(frozen=True) @@ -202,6 +203,11 @@ def _callable( owner.type_id, static, suspend, + ( + semantic.capabilities.javascript_public + if isinstance(semantic, SemanticBinding) + else True + ), ) @@ -280,6 +286,7 @@ def _value_constructor( owner.type_id, True, False, + True, ), tuple(item.name for item in ordered_fields) diff --git a/src/supernote_module_generator/plugin_runtime_codegen.py b/src/supernote_module_generator/plugin_runtime_codegen.py index 602529c..5d3e8f2 100644 --- a/src/supernote_module_generator/plugin_runtime_codegen.py +++ b/src/supernote_module_generator/plugin_runtime_codegen.py @@ -472,10 +472,12 @@ class WorkHandle { WorkHandle submit(Task task); void set_thread_initializer(ThreadInitializer initializer); + std::size_t thread_count() const noexcept; void shutdown() noexcept; private: struct State; + void ensure_started(); std::shared_ptr state_; }; @@ -496,6 +498,7 @@ class DeferredDestruction { return false; } } + std::size_t thread_count() const noexcept; void drain_and_shutdown() noexcept; private: @@ -783,6 +786,8 @@ class ProcessServices { void complete_jvm_async(SessionId completion_id, void *environment, void *result, std::string error_code, std::string failure) noexcept; + std::size_t thread_count() const noexcept; + void shutdown() noexcept; private: BoundedExecutor workers_; @@ -921,12 +926,15 @@ class Result final { Task task; }; - explicit State(std::size_t capacity) : capacity(capacity) {} + State(std::size_t worker_count, std::size_t capacity) + : worker_count(worker_count), capacity(capacity) {} std::mutex mutex; std::condition_variable ready; std::deque queue; std::vector workers; + std::size_t worker_count; std::size_t capacity; + std::atomic live_workers{0}; std::uint64_t next_id = 1; ThreadInitializer thread_initializer; bool stopped = false; @@ -951,12 +959,23 @@ class Result final { BoundedExecutor::BoundedExecutor(std::size_t worker_count, std::size_t queue_capacity) - : state_(std::make_shared(queue_capacity)) { + : state_(std::make_shared(worker_count, queue_capacity)) { if (worker_count == 0 || queue_capacity == 0) { throw std::invalid_argument("executor size and capacity must be positive"); } - for (std::size_t index = 0; index < worker_count; ++index) { +} + +void BoundedExecutor::ensure_started() { + auto state = state_; + std::lock_guard lock(state->mutex); + if (state->stopped) { + throw std::runtime_error("executor is stopped"); + } + if (!state->workers.empty()) return; + state->workers.reserve(state->worker_count); + for (std::size_t index = 0; index < state->worker_count; ++index) { state_->workers.emplace_back([state = state_] { + state->live_workers.fetch_add(1, std::memory_order_acq_rel); bool thread_initialized = false; std::function thread_cleanup; while (true) { @@ -1000,6 +1019,7 @@ class Result final { if (thread_cleanup) { try { thread_cleanup(); } catch (...) {} } + state->live_workers.fetch_sub(1, std::memory_order_acq_rel); }); } } @@ -1008,6 +1028,7 @@ class Result final { BoundedExecutor::WorkHandle BoundedExecutor::submit(Task task) { if (!task) return {}; + ensure_started(); auto control = std::make_shared(); auto state = state_; { @@ -1049,6 +1070,13 @@ class Result final { state->thread_initializer = std::move(initializer); } +std::size_t BoundedExecutor::thread_count() const noexcept { + auto state = state_; + return state + ? state->live_workers.load(std::memory_order_acquire) + : 0; +} + void BoundedExecutor::shutdown() noexcept { auto state = state_; if (!state) return; @@ -1071,30 +1099,11 @@ class Result final { std::condition_variable ready; std::deque> queue; std::thread worker; + std::atomic live_workers{0}; bool stopping = false; }; -DeferredDestruction::DeferredDestruction() : state_(std::make_shared()) { - state_->worker = std::thread([state = state_] { - while (true) { - std::function cleanup; - { - std::unique_lock lock(state->mutex); - state->ready.wait(lock, [&] { - return state->stopping || !state->queue.empty(); - }); - if (state->queue.empty() && state->stopping) return; - cleanup = std::move(state->queue.front()); - state->queue.pop_front(); - } - try { - cleanup(); - } catch (...) { - // Destructors/cleanup never cross into JSI and never stop the facility. - } - } - }); -} +DeferredDestruction::DeferredDestruction() : state_(std::make_shared()) {} DeferredDestruction::~DeferredDestruction() { drain_and_shutdown(); } @@ -1105,6 +1114,29 @@ class Result final { { std::lock_guard lock(state->mutex); if (state->stopping) return false; + if (!state->worker.joinable()) { + state->worker = std::thread([state] { + state->live_workers.fetch_add(1, std::memory_order_acq_rel); + while (true) { + std::function queued_cleanup; + { + std::unique_lock worker_lock(state->mutex); + state->ready.wait(worker_lock, [&] { + return state->stopping || !state->queue.empty(); + }); + if (state->queue.empty() && state->stopping) break; + queued_cleanup = std::move(state->queue.front()); + state->queue.pop_front(); + } + try { + queued_cleanup(); + } catch (...) { + // Cleanup never crosses into JSI and never stops the facility. + } + } + state->live_workers.fetch_sub(1, std::memory_order_acq_rel); + }); + } state->queue.push_back(std::move(cleanup)); } state->ready.notify_one(); @@ -1114,6 +1146,13 @@ class Result final { } } +std::size_t DeferredDestruction::thread_count() const noexcept { + auto state = state_; + return state + ? state->live_workers.load(std::memory_order_acquire) + : 0; +} + void DeferredDestruction::drain_and_shutdown() noexcept { auto state = state_; if (!state) return; @@ -1488,10 +1527,7 @@ class Result final { 256), cleanup_(std::make_shared()) {} -ProcessServices::~ProcessServices() { - workers_.shutdown(); - cleanup_->drain_and_shutdown(); -} +ProcessServices::~ProcessServices() { shutdown(); } BoundedExecutor &ProcessServices::workers() noexcept { return workers_; } @@ -1550,6 +1586,20 @@ class Result final { } } +std::size_t ProcessServices::thread_count() const noexcept { + return workers_.thread_count() + cleanup_->thread_count(); +} + +void ProcessServices::shutdown() noexcept { + workers_.shutdown(); + { + std::lock_guard lock(jvm_async_mutex_); + jvm_async_completions_.clear(); + } + cleanup_->drain_and_shutdown(); + java_vm_.store(nullptr, std::memory_order_release); +} + ProcessServices &process_services() noexcept { static ProcessServices services; return services; @@ -1938,17 +1988,25 @@ class AttachedEnv {{ Java_supernote_generated_runtime_SupernoteV3Module_nativeInvalidate( JNIEnv *, jobject, jlong session_id) {{ std::shared_ptr session; + bool last_session = false; {{ std::lock_guard lock(g_mutex); auto found = g_sessions.find(static_cast(session_id)); if (found == g_sessions.end()) return; session = std::move(found->second); g_sessions.erase(found); + last_session = g_sessions.empty(); }} __android_log_print( ANDROID_LOG_INFO, kLogTag, "invalidating runtime session %llu", static_cast(session_id)); session->invalidate(); + if (last_session) {{ + supernote::runtime::process_services().shutdown(); + __android_log_print( + ANDROID_LOG_INFO, kLogTag, + "stopped process services for runtime generation"); + }} }} extern "C" __attribute__((visibility("hidden"))) jboolean diff --git a/tests/test_binding_codegen.py b/tests/test_binding_codegen.py index bd63472..1018e8c 100644 --- a/tests/test_binding_codegen.py +++ b/tests/test_binding_codegen.py @@ -1,5 +1,3 @@ -from contextlib import redirect_stdout -import io import json from pathlib import Path import re @@ -10,16 +8,13 @@ from supernote_module_generator.semantic import ( DeclarationRole, ExecutionMode, - SemanticClassKind, + MemberScope, + SemanticDeclarationKind, SemanticType, ) +from supernote_module_generator.typescript_codegen import render_typescript from supernote_module_generator.source_models import SupernoteMarker -V3_LEGACY_CLASS_MARKER_REMOVED = ( - "superseded by the V3 Object/Value marker contract; concrete C++ " - "object-route coverage returns in Phase 5" -) - class BindingCodegenScannerTests(unittest.TestCase): def make_module( self, @@ -410,8 +405,7 @@ def test_v2_async_continuations_are_deleted_from_private_map(self): source, ) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) - def test_v2_async_object_method_retains_receiver_for_physical_work(self): + def test_v3_async_object_method_retains_receiver_for_physical_work(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header( @@ -419,7 +413,7 @@ def test_v2_async_object_method_retains_receiver_for_physical_work(self): """#include #include #include -// @SupernotePluginExport +// @SupernotePluginObject class Document { public: Document(); @@ -438,27 +432,22 @@ class Document { self.assertIn('getPropertyAsFunction(runtime, "Promise")', source) self.assertIn( - "supernote::runtime::ManagedRef instance_", source + "supernote::runtime::ManagedRef<::Document> instance", source ) self.assertIn( "supernote::runtime::process_services().cleanup()", source ) - self.assertIn("auto operation_receiver = native_instance", source) - self.assertIn( - "operation_receiver = std::move(operation_receiver)", source - ) - self.assertIn("operation_receiver->load(supernote_input_0)", source) - self.assertIn("weak_feature = feature_session_", source) - self.assertIn("feature_session->accept_factory", source) - self.assertNotIn( - "async C++ object-method lowering is recognized", source - ) + self.assertIn("native_instance = this->managed_ref()", source) + self.assertIn("retained_input_state = std::make_sharedload(supernote_input_0)", source) + self.assertIn("operation->set_retained_state(retained_input_state)", source) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_cpp_class_source_and_semantic_models_use_explicit_member_intent(self): - source = """// @SupernotePluginExport + source = """// @SupernotePluginObject class Document { public: + // @SupernoteConstructor Document(std::string path); // @SupernotePluginExport @@ -481,7 +470,7 @@ class Document { self.assertEqual(1, len(classes)) item = classes[0] self.assertEqual("Document", item.cpp_name) - self.assertEqual(DeclarationRole.EXPORTED, item.intent.role) + self.assertEqual(DeclarationRole.ORDINARY, item.intent.role) self.assertEqual(1, len(item.constructors)) self.assertEqual("std::string", item.constructors[0].parameters[0].type_spelling) self.assertEqual(["pageCount", "rebuild"], [method.cpp_name for method in item.methods]) @@ -491,18 +480,17 @@ class Document { self.assertEqual(ExecutionMode.ASYNC, item.methods[1].intent.execution) semantic = binding_codegen.scan_cpp_semantic_model(module) - self.assertEqual(1, len(semantic.classes)) - document = semantic.classes[0] - self.assertEqual(SemanticClassKind.JS_OBJECT, document.kind) + self.assertEqual(1, len(semantic.declarations)) + document = semantic.declarations[0] + self.assertEqual(SemanticDeclarationKind.OBJECT, document.kind) self.assertEqual(SemanticType.STRING, document.constructor.parameters[0].type) self.assertEqual(["pageCount", "rebuild"], [method.name for method in document.methods]) self.assertTrue(document.methods[0].capabilities.javascript_public) self.assertFalse(document.methods[1].capabilities.javascript_public) self.assertEqual(ExecutionMode.ASYNC, document.methods[1].execution) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_cpp_class_constructor_selection_and_implicit_default(self): - selected = """// @SupernotePluginExport + selected = """// @SupernotePluginObject class Document { public: Document(std::string path); @@ -517,10 +505,10 @@ class Document { self.assertTrue(item.constructors[1].selected) self.assertTrue(item.constructors[1].explicit) self.assertTrue(item.constructors[1].noexcept) - semantic = binding_codegen.scan_cpp_semantic_model(module).classes[0] + semantic = binding_codegen.scan_cpp_semantic_model(module).declarations[0] self.assertEqual(SemanticType.INT64, semantic.constructor.parameters[0].type) - implicit = """// @SupernotePluginExport + implicit = """// @SupernotePluginObject struct Page { // @SupernotePluginExport void refresh(); @@ -531,33 +519,34 @@ class Document { self.write_object_header(module, implicit) item = binding_codegen.scan_cpp_class_source_model(module)[0] self.assertTrue(item.constructors[0].implicit) - semantic = binding_codegen.scan_cpp_semantic_model(module).classes[0] - self.assertEqual((), semantic.constructor.parameters) + semantic = binding_codegen.scan_cpp_semantic_model(module).declarations[0] + self.assertIsNone(semantic.constructor) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_cpp_class_rejects_ambiguous_or_missing_creation_paths(self): cases = ( ( "ambiguous", - """// @SupernotePluginExport + """// @SupernotePluginObject class Document { public: + // @SupernoteConstructor Document(std::string path); + // @SupernoteConstructor Document(std::int64_t handle); }; """, - "multiple eligible constructors require exactly one " - "SupernoteConstructor selection", + "an object may select at most one SupernoteConstructor", ), ( "missing", - """// @SupernotePluginExport + """// @SupernotePluginObject class Document { private: + // @SupernoteConstructor Document(); }; """, - "requires at least one eligible public constructor", + "SupernoteConstructor must mark a public constructor", ), ) for name, source, diagnostic in cases: @@ -570,45 +559,11 @@ class Document { ): binding_codegen.scan_cpp_semantic_model(module) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) - def test_cpp_internal_class_projects_as_feature_service(self): - source = """// @SupernotePluginInternal -class IndexService { -public: - IndexService(); - - // @SupernotePluginInternal - std::int32_t rebuild(); - - void ordinaryHelper(); -}; -""" - with tempfile.TemporaryDirectory() as directory: - module = self.make_module(Path(directory), backend="jsi") - self.write_object_header(module, source) - service = binding_codegen.scan_cpp_semantic_model(module).classes[0] - self.assertEqual(SemanticClassKind.INTERNAL_SERVICE, service.kind) - self.assertFalse(service.capabilities.javascript_public) - self.assertEqual(["rebuild"], [method.name for method in service.methods]) - self.assertFalse(service.methods[0].capabilities.javascript_public) - - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_cpp_class_rejects_invalid_marked_members_and_containment(self): cases = ( - ( - "unmarked-class", - """class Document { -public: - Document(); - // @SupernotePluginExport - void refresh(); -}; -""", - "requires a marked top-level", - ), ( "private-method", - """// @SupernotePluginExport + """// @SupernotePluginObject class Document { public: Document(); @@ -619,42 +574,6 @@ class Document { """, "generated method must be public", ), - ( - "field", - """// @SupernotePluginExport -class Document { -public: - Document(); - // @SupernotePluginExport - std::int32_t pageCount; -}; -""", - "properties, fields", - ), - ( - "static", - """// @SupernotePluginExport -class Document { -public: - Document(); - // @SupernotePluginExport - static void refresh(); -}; -""", - "static methods are deferred", - ), - ( - "export-on-internal-service", - """// @SupernotePluginInternal -class Service { -public: - Service(); - // @SupernotePluginExport - void refresh(); -}; -""", - "may contain only SupernotePluginInternal", - ), ) for name, source, diagnostic in cases: with self.subTest(name=name), tempfile.TemporaryDirectory() as directory: @@ -666,35 +585,34 @@ class Service { ): binding_codegen.scan_cpp_semantic_model(module) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_cpp_class_and_member_marker_targets_fail_closed(self): cases = ( ( - "class-role-conflict", - """// @SupernotePluginExport + "object-with-reachability-marker", + """// @SupernotePluginObject // @SupernotePluginInternal class Document { public: Document(); }; """, - "SupernotePluginExport and SupernotePluginInternal cannot mark one declaration", + "classes require exactly one of SupernotePluginObject or SupernotePluginValue", ), ( "async-class", - """// @SupernotePluginExport + """// @SupernotePluginObject // @SupernotePluginAsync class Document { public: Document(); }; """, - "SupernotePluginAsync cannot mark a class", + "classes require exactly one of SupernotePluginObject or SupernotePluginValue", ), ( "constructor-on-class", """// @SupernoteConstructor class Document { public: Document(); }; """, - "SupernoteConstructor is valid only on a constructor", + "classes require exactly one of SupernotePluginObject or SupernotePluginValue", ), ( "async-only-method", - """// @SupernotePluginExport + """// @SupernotePluginObject class Document { public: Document(); @@ -706,7 +624,7 @@ class Document { ), ( "constructor-on-method", - """// @SupernotePluginExport + """// @SupernotePluginObject class Document { public: Document(); @@ -716,20 +634,9 @@ class Document { """, "SupernoteConstructor is valid only on a constructor", ), - ( - "constructor-on-service", - """// @SupernotePluginInternal -class Service { -public: - // @SupernoteConstructor - Service(); -}; -""", - "SupernoteConstructor does not apply to a SupernotePluginInternal", - ), ( "two-selected-constructors", - """// @SupernotePluginExport + """// @SupernotePluginObject class Document { public: // @SupernoteConstructor @@ -738,7 +645,7 @@ class Document { Document(std::int64_t handle); }; """, - "multiple eligible constructors require exactly one", + "an object may select at most one SupernoteConstructor", ), ) for name, source, diagnostic in cases: @@ -751,61 +658,15 @@ class Document { ): binding_codegen.scan_cpp_semantic_model(module) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) - def test_v2_sync_class_lowers_to_retained_hostobject_machinery(self): + def test_v3_sync_class_lowers_to_retained_hostobject_machinery(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header( module, - "// @SupernotePluginExport\nclass Page { public: Page(); };\n", + "// @SupernotePluginObject\nclass Page { public: Page(); };\n", ) - objects = binding_codegen.scan_bindings(module).objects - self.assertEqual(["Page"], [item.js_name for item in objects]) - - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) - def test_object_lowering_fails_closed_for_routes_not_implemented_yet(self): - cases = ( - ( - "service", - "// @SupernotePluginInternal\n" - "class Service { public: Service(); };\n", - "FeatureSession service route is not implemented yet", - ), - ( - "internal-method", - """// @SupernotePluginExport -class Page { -public: - Page(); - // @SupernotePluginInternal - void rebuild(); -}; -""", - "receiver-aware internal route is not implemented yet", - ), - ( - "async-method", - """// @SupernotePluginExport -class Page { -public: - Page(); - // @SupernotePluginExport - // @SupernotePluginAsync - void refresh(); -}; -""", - "async HostObject lowering is not implemented yet", - ), - ) - for name, source, diagnostic in cases: - with self.subTest(name=name), tempfile.TemporaryDirectory() as directory: - module = self.make_module(Path(directory), backend="jsi") - self.write_object_header(module, source) - with self.assertRaisesRegex( - binding_codegen.CodegenError, - re.escape(diagnostic), - ): - binding_codegen.scan_bindings(module) + declarations = binding_codegen.scan_cpp_semantic_model(module).declarations + self.assertEqual(["Page"], [item.name for item in declarations]) def test_rejects_marker_in_preprocessor_conditional(self): with tempfile.TemporaryDirectory() as directory: @@ -1069,14 +930,14 @@ def test_jsi_initial_numeric_and_bytes_types_generate_checked_conversions(self): self.assertIn("supernote_throw_type_error", generated) self.assertIn("supernote_throw_range_error", generated) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_jsi_hostobject_uses_initial_numeric_and_bytes_conversions(self): source = """#include #include #include -// @SupernotePluginExport +// @SupernotePluginObject class Page { public: + // @SupernoteConstructor Page(std::int64_t handle, std::vector seed); // @SupernotePluginExport std::vector render(std::int32_t page, float scale); @@ -1085,23 +946,24 @@ class Page { with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header(module, source) - binding_codegen.generate(module) - generated = ( - module - / "android/build/generated/supernote/jni/generated_bindings.cpp" - ).read_text(encoding="utf-8") - declarations = (module / "index.d.ts").read_text(encoding="utf-8") + api = binding_codegen.scan_cpp_semantic_model(module) + generated = binding_codegen.render_v2_feature_jsi( + module, + module_name="LocalTest", + feature_id="supernote:feature:0123456789abcdef", + ) + declarations = render_typescript("LocalTest", api) self.assertIn( - "create(handle: bigint, seed: Uint8Array): Page;", + "create: SupernoteCallable<[handle: bigint, seed: Uint8Array], Page>;", declarations, ) self.assertIn( - "render(page: number, scale: number): Uint8Array;", + "render: SupernoteCallable<[page: number, scale: number], Uint8Array>;", declarations, ) - self.assertIn("std::make_shared", generated) - self.assertIn("asBigInt(runtime).asInt64(runtime)", generated) + self.assertIn("std::make_shared<::Page>", generated) + self.assertIn("bigint.asInt64(runtime)", generated) self.assertIn("native_instance->render(", generated) self.assertIn("supernote_make_uint8_array(runtime", generated) @@ -1287,9 +1149,8 @@ def test_jni_exception_messages_use_real_utf8_java_strings(self): generated, ) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_jsi_object_scans_constructor_methods_and_access_control(self): - source = """// @SupernotePluginExport + source = """// @SupernotePluginObject class Counter { public: Counter(bool enabled, double initial, std::string label); @@ -1311,26 +1172,26 @@ class Counter { with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header(module, source) - bindings = binding_codegen.scan_bindings(module) - self.assertEqual(["add"], [item.js_name for item in bindings.exports]) - self.assertEqual(["Counter"], [item.js_name for item in bindings.objects]) - item = bindings.objects[0] + exports = binding_codegen.scan_sources(module) + objects = binding_codegen.scan_cpp_class_source_model(module) + self.assertEqual(["add"], [item.js_name for item in exports]) + self.assertEqual(["Counter"], [item.cpp_name for item in objects]) + item = objects[0] self.assertEqual( ["bool", "double", "std::string"], - [parameter.cpp_type for parameter in item.constructor.parameters], + [parameter.type_spelling for parameter in item.constructors[0].parameters], ) self.assertEqual( ["enabled", "value", "label", "increment"], - [method.js_name for method in item.methods], + [method.cpp_name for method in item.methods], ) self.assertTrue(item.methods[0].const) self.assertTrue(item.methods[1].const) self.assertTrue(item.methods[1].noexcept) self.assertTrue(item.methods[2].noexcept) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_jsi_object_uses_source_struct_name_and_zero_argument_constructor(self): - source = """// @SupernotePluginExport + source = """// @SupernotePluginObject struct NativeDocument { NativeDocument(); // @SupernotePluginExport @@ -1342,19 +1203,17 @@ def test_jsi_object_uses_source_struct_name_and_zero_argument_constructor(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header(module, source, relative="NativeDocument.hxx") - item = binding_codegen.scan_bindings(module).objects[0] + item = binding_codegen.scan_cpp_class_source_model(module)[0] self.assertEqual("NativeDocument", item.cpp_name) - self.assertEqual("NativeDocument", item.js_name) - self.assertEqual((), item.constructor.parameters) - self.assertEqual(["pageCount"], [method.js_name for method in item.methods]) + self.assertEqual((), item.constructors[0].parameters) + self.assertEqual(["pageCount"], [method.cpp_name for method in item.methods]) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_class_default_private_and_struct_default_public(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header( module, - """// @SupernotePluginExport + """// @SupernotePluginObject class PrivateByDefault { PrivateByDefault(); public: @@ -1362,7 +1221,7 @@ class PrivateByDefault { // @SupernotePluginExport double value(); }; -// @SupernotePluginExport +// @SupernotePluginObject struct PublicByDefault { PublicByDefault(); // @SupernotePluginExport @@ -1371,25 +1230,24 @@ class PrivateByDefault { """, relative="access.hh", ) - objects = binding_codegen.scan_bindings(module).objects - self.assertEqual(["PrivateByDefault", "PublicByDefault"], [item.js_name for item in objects]) - self.assertEqual(1, len(objects[0].constructor.parameters)) - self.assertEqual(0, len(objects[1].constructor.parameters)) + objects = binding_codegen.scan_cpp_class_source_model(module) + self.assertEqual(["PrivateByDefault", "PublicByDefault"], [item.cpp_name for item in objects]) + self.assertEqual( + ["private", "public"], + [item.access for item in objects[0].constructors], + ) + self.assertEqual(1, len(objects[0].constructors[1].parameters)) + self.assertEqual(0, len(objects[1].constructors[0].parameters)) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) - def test_object_rejects_unsupported_public_method_and_static_method(self): + def test_object_rejects_unsupported_types_and_accepts_static_methods(self): cases = { "unsupported-return": ( "int unsupported();", - "marked method must use one canonical V3 result type", + "unsupported marked C\\+\\+ type 'int'", ), "unsupported-parameter": ( "double evaluate(int value);", - "argument 1 must use one named canonical V3 value type", - ), - "static": ( - "static double evaluate();", - "static methods are deferred", + "unsupported marked C\\+\\+ type 'int'", ), } for name, (method, diagnostic) in cases.items(): @@ -1397,15 +1255,24 @@ def test_object_rejects_unsupported_public_method_and_static_method(self): module = self.make_module(Path(directory), backend="jsi") self.write_object_header( module, - "// @SupernotePluginExport\nclass Example {\npublic:\n" + "// @SupernotePluginObject\nclass Example {\npublic:\n" " Example();\n" f" // @SupernotePluginExport\n {method}\n" "};\n", ) with self.assertRaisesRegex(binding_codegen.CodegenError, diagnostic): - binding_codegen.scan_bindings(module) + binding_codegen.scan_cpp_semantic_model(module) + + with tempfile.TemporaryDirectory() as directory: + module = self.make_module(Path(directory), backend="jsi") + self.write_object_header( + module, + "// @SupernotePluginObject\nclass Example {\npublic:\n" + " // @SupernotePluginExport\n static double evaluate();\n};\n", + ) + item = binding_codegen.scan_cpp_semantic_model(module).declarations[0] + self.assertEqual(MemberScope.STATIC, item.methods[0].member_scope) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_rejects_method_and_constructor_overloads(self): cases = { "method": ( @@ -1415,8 +1282,9 @@ def test_object_rejects_method_and_constructor_overloads(self): "duplicate generated method name 'value'", ), "constructor": ( - "Example();\n Example(double value);", - "multiple eligible constructors require exactly one", + "// @SupernoteConstructor\n Example();\n" + " // @SupernoteConstructor\n Example(double value);", + "an object may select at most one SupernoteConstructor", ), } for name, (members, diagnostic) in cases.items(): @@ -1424,68 +1292,82 @@ def test_object_rejects_method_and_constructor_overloads(self): module = self.make_module(Path(directory), backend="jsi") self.write_object_header( module, - "// @SupernotePluginExport\nclass Example {\npublic:\n " + "// @SupernotePluginObject\nclass Example {\npublic:\n " + members + "\n};\n", ) with self.assertRaisesRegex(binding_codegen.CodegenError, diagnostic): - binding_codegen.scan_bindings(module) + binding_codegen.scan_cpp_semantic_model(module) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_export_name_collisions_are_rejected(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header( module, - """// @SupernotePluginExport -class add { public: add(); }; + """// @SupernotePluginObject +class add { +public: + // @SupernoteConstructor + add(); +}; """, ) - with self.assertRaisesRegex(binding_codegen.CodegenError, "collides with free-function"): - binding_codegen.scan_bindings(module) + api = binding_codegen.scan_cpp_semantic_model(module) + with self.assertRaisesRegex(ValueError, "collid|duplicate"): + render_typescript("LocalTest", api) with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header( module, - """// @SupernotePluginExport -class Thing { public: Thing(); }; + """// @SupernotePluginObject +class Thing { +public: + // @SupernoteConstructor + Thing(); +}; """, relative="First.hpp", ) self.write_object_header( module, - """// @SupernotePluginExport -class Thing { public: Thing(); }; + """// @SupernotePluginObject +class Thing { +public: + // @SupernoteConstructor + Thing(); +}; """, relative="Second.hpp", ) with self.assertRaisesRegex( binding_codegen.CodegenError, - re.escape("duplicate exported C++ object name"), + re.escape("duplicate marked C++ type definition"), ): - binding_codegen.scan_bindings(module) + binding_codegen.scan_cpp_semantic_model(module) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) - def test_object_typescript_factory_name_collision_is_rejected(self): + def test_v3_object_named_factory_does_not_collide_with_another_object(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header( module, - """// @SupernotePluginExport -class Counter { public: Counter(); }; -// @SupernotePluginExport -class CounterFactory { public: CounterFactory(); }; + """// @SupernotePluginObject +class Counter { +public: + // @SupernoteConstructor + Counter(); +}; +// @SupernotePluginObject +class CounterFactory { +public: + // @SupernoteConstructor + CounterFactory(); +}; """, ) - with self.assertRaises(binding_codegen.CodegenError) as raised: - binding_codegen.scan_bindings(module) - message = str(raised.exception) - self.assertIn("generated TypeScript name 'CounterFactory'", message) - self.assertIn("object export 'Counter'", message) - self.assertIn("export 'CounterFactory'", message) - relative_header = str(Path("model/Counter.hpp")) - self.assertIn(f"{relative_header}:1", message) - self.assertIn(f"{relative_header}:3", message) + api = binding_codegen.scan_cpp_semantic_model(module) + declarations = render_typescript("LocalTest", api) + self.assertIn("export interface Counter {", declarations) + self.assertIn("export interface CounterFactory {", declarations) def test_v1_object_marker_and_alias_syntax_are_rejected(self): with tempfile.TemporaryDirectory() as directory: @@ -1502,120 +1384,110 @@ class NativeCounter { public: NativeCounter(); }; ): binding_codegen.scan_bindings(module) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) - def test_object_typescript_module_interface_collision_is_rejected(self): + def test_v3_object_name_no_longer_collides_with_legacy_module_interface(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header( module, - """// @SupernotePluginExport -class LocalTestModule { public: LocalTestModule(); }; + """// @SupernotePluginObject +class LocalTestModule { +public: + // @SupernoteConstructor + LocalTestModule(); +}; """, ) - with self.assertRaises(binding_codegen.CodegenError) as raised: - binding_codegen.scan_bindings(module) - message = str(raised.exception) - self.assertIn("generated TypeScript name 'LocalTestModule'", message) - self.assertIn("generated module interface 'LocalTestModule'", message) - self.assertIn("export 'LocalTestModule'", message) + api = binding_codegen.scan_cpp_semantic_model(module) + declarations = render_typescript("LocalTest", api) + self.assertIn("export interface LocalTestModule {", declarations) + self.assertIn("export interface LocalTestFeature {", declarations) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_generated_object_header_includes_are_unique(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header( module, - """// @SupernotePluginExport + """// @SupernotePluginObject class First { public: First(); }; -// @SupernotePluginExport +// @SupernotePluginObject class Second { public: Second(); }; """, relative="model/Objects.hpp", ) self.write_object_header( module, - """// @SupernotePluginExport + """// @SupernotePluginObject class Third { public: Third(); }; """, relative="other/Third.hh", ) - binding_codegen.generate(module) - generated = ( - module - / "android/build/generated/supernote/jni/generated_bindings.cpp" - ).read_text(encoding="utf-8") + generated = binding_codegen.render_v2_feature_jsi( + module, + module_name="LocalTest", + feature_id="supernote:feature:0123456789abcdef", + ) self.assertEqual(1, generated.count('#include "model/Objects.hpp"')) self.assertEqual(1, generated.count('#include "other/Third.hh"')) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_annotation_location_backend_and_malformed_diagnostics(self): cases = ( ( - "jsi", "Counter.cpp", - "// @SupernotePluginExport\nclass Counter { public: Counter(); };\n", - "supported top-level function definition", - ), - ( - "jni", - "Counter.hpp", - "// @SupernotePluginExport\nclass Counter { public: Counter(); };\n", - "require the JSI frontend", + "// @SupernotePluginObject\nclass Counter { public: Counter(); };\n", + "SupernotePluginObject and SupernotePluginValue are valid only on type declarations", ), ( - "jsi", "Counter.hpp", - "// @SupernotePluginExport(bad)\nclass Counter { public: Counter(); };\n", + "// @SupernotePluginObject(bad)\nclass Counter { public: Counter(); };\n", "malformed Supernote marker", ), ) - for backend, relative, source, diagnostic in cases: - with self.subTest(backend=backend, relative=relative), tempfile.TemporaryDirectory() as directory: - module = self.make_module(Path(directory), backend=backend) + for relative, source, diagnostic in cases: + with self.subTest(relative=relative), tempfile.TemporaryDirectory() as directory: + module = self.make_module(Path(directory), backend="jsi") self.write_object_header(module, source, relative=relative) with self.assertRaisesRegex(binding_codegen.CodegenError, diagnostic): - binding_codegen.scan_bindings(module) + binding_codegen.scan_cpp_semantic_model(module) def test_object_marker_lexer_defenses_and_conditional_diagnostic(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header( module, - 'const char *text = "// @SupernotePluginExport";\n' - "/* // @SupernotePluginExport */\n" - "// Documentation mentions @SupernotePluginExport here.\n", + 'const char *text = "// @SupernotePluginObject";\n' + "/* // @SupernotePluginObject */\n" + "// Documentation mentions @SupernotePluginObject here.\n", ) - self.assertEqual((), binding_codegen.scan_bindings(module).objects) + self.assertEqual([], binding_codegen.scan_cpp_class_source_model(module)) with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header( module, - "#if 0\n// @SupernotePluginExport\n" + "#if 0\n// @SupernotePluginObject\n" "class Hidden { public: Hidden(); };\n#endif\n", ) with self.assertRaisesRegex( binding_codegen.CodegenError, "preprocessor conditional", ): - binding_codegen.scan_bindings(module) + binding_codegen.scan_cpp_semantic_model(module) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_rejects_templates_inheritance_and_nested_exports(self): cases = { "template": ( - "template \n// @SupernotePluginExport\n" + "template \n// @SupernotePluginObject\n" "class Example { public: Example(); };\n", "declaration prefix before the class marker", ), "inheritance": ( - "// @SupernotePluginExport\n" + "// @SupernotePluginObject\n" "class Example : public Base { public: Example(); };\n", "inheritance is not supported", ), "nested": ( - "class Outer {\n// @SupernotePluginExport\n" + "class Outer {\n// @SupernotePluginObject\n" "class Example { public: Example(); };\n};\n", - "requires a marked top-level", + "marked C\\+\\+ types must be at global or named-namespace brace depth", ), } for name, (source, diagnostic) in cases.items(): @@ -1623,11 +1495,10 @@ def test_object_rejects_templates_inheritance_and_nested_exports(self): module = self.make_module(Path(directory), backend="jsi") self.write_object_header(module, source) with self.assertRaisesRegex(binding_codegen.CodegenError, diagnostic): - binding_codegen.scan_bindings(module) + binding_codegen.scan_cpp_semantic_model(module) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_ignores_destructor_copy_constructor_and_public_fields(self): - source = """// @SupernotePluginExport + source = """// @SupernotePluginObject class Example { public: Example(); @@ -1642,13 +1513,12 @@ class Example { with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header(module, source) - item = binding_codegen.scan_bindings(module).objects[0] - self.assertEqual((), item.constructor.parameters) - self.assertEqual(["value"], [method.js_name for method in item.methods]) + item = binding_codegen.scan_cpp_class_source_model(module)[0] + self.assertTrue(item.constructors[0].implicit is False) + self.assertEqual(["value"], [method.cpp_name for method in item.methods]) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_constructor_containing_class_name_is_not_mistaken_for_copy(self): - source = """// @SupernotePluginExport + source = """// @SupernotePluginObject class Example { public: Example(); @@ -1658,8 +1528,12 @@ class Example { with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header(module, source) - item = binding_codegen.scan_bindings(module).objects[0] - self.assertEqual((), item.constructor.parameters) + item = binding_codegen.scan_cpp_class_source_model(module)[0] + self.assertEqual(2, len(item.constructors)) + self.assertEqual( + "std::vector&", + item.constructors[1].parameters[0].type_spelling, + ) def test_free_function_annotation_in_header_remains_rejected(self): with tempfile.TemporaryDirectory() as directory: @@ -1677,13 +1551,13 @@ def test_free_function_annotation_in_header_remains_rejected(self): ): binding_codegen.scan_bindings(module) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_object_manifest_typescript_hostobject_and_lifetime_generation(self): source = """#pragma once #include -// @SupernotePluginExport +// @SupernotePluginObject class Counter { public: + // @SupernoteConstructor Counter(double initial); // @SupernotePluginExport double value() const noexcept; @@ -1697,47 +1571,36 @@ class Counter { with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header(module, source) - binding_codegen.generate(module) - manifest = json.loads((module / "android/build/generated/supernote/exports.json").read_text()) - declarations = (module / "index.d.ts").read_text() - generated = (module / "android/build/generated/supernote/jni/generated_bindings.cpp").read_text() - self.assertEqual("Counter", manifest["objects"][0]["cpp_name"]) - self.assertEqual("double", manifest["objects"][0]["constructor"]["parameters"][0]["type"]) - self.assertTrue(manifest["objects"][0]["methods"][0]["const"]) + api = binding_codegen.scan_cpp_semantic_model(module) + manifest = api.manifest() + declarations = render_typescript("LocalTest", api) + generated = binding_codegen.render_v2_feature_jsi( + module, + module_name="LocalTest", + feature_id="supernote:feature:0123456789abcdef", + ) + counter = manifest["types"][0] + self.assertEqual("Counter", counter["name"]) + self.assertEqual("float64", counter["constructor"]["parameters"][0]["type"]["name"]) self.assertIn("export interface Counter {", declarations) - self.assertIn("value(): number;", declarations) - self.assertIn("create(initial: number): Counter;", declarations) - self.assertIn("Counter: CounterFactory;", declarations) + self.assertIn("value: SupernoteCallable<[], number>;", declarations) + self.assertIn("create: SupernoteCallable<[initial: number], Counter>;", declarations) + self.assertIn("Counter: SupernoteTypeCompanion", declarations) self.assertIn('#include "model/Counter.hpp"', generated) - self.assertIn("public facebook::jsi::HostObject", generated) - self.assertIn("std::shared_ptr instance_", generated) - self.assertIn("std::make_shared", generated) + self.assertIn("public supernote::runtime::CppObjectHandle<::Counter>", generated) + self.assertIn("ManagedRef<::Counter>", generated) + self.assertIn("std::make_shared<::Counter>", generated) self.assertIn("Object::createFromHostObject", generated) self.assertIn("getPropertyNames", generated) self.assertIn("properties.push_back", generated) - self.assertNotIn("return {\n", generated) self.assertIn('property_name == "increment"', generated) - self.assertIn("[native_instance = std::move(native_instance)]", generated) - self.assertNotIn("[this]", generated) - self.assertNotIn("this->", generated) - self.assertIn("LocalTest.Counter.increment: expected 1 argument", generated) + self.assertIn("native_instance->increment", generated) self.assertNotIn("resetInternalCache", declarations) self.assertNotIn('property_name == "resetInternalCache"', generated) - self.assertEqual( - ["add"], - [ - item.js_name - for item in binding_codegen.generate(module, check=True) - ], - ) - self.write_object_header(module, source.replace("increment", "increase")) - with self.assertRaisesRegex(binding_codegen.CodegenError, "generated bindings are stale"): - binding_codegen.generate(module, check=True) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) def test_selected_constructor_drives_generated_factory(self): source = """#include -// @SupernotePluginExport +// @SupernotePluginObject class Document { public: Document(double handle); @@ -1748,18 +1611,16 @@ class Document { with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory), backend="jsi") self.write_object_header(module, source) - binding_codegen.generate(module) - declarations = (module / "index.d.ts").read_text() - generated = ( - module - / "android/build/generated/supernote/jni/generated_bindings.cpp" - ).read_text() - self.assertIn("create(path: string): Document;", declarations) - self.assertIn( - "std::make_shared(arguments[0].asString(runtime).utf8(runtime))", - generated, + api = binding_codegen.scan_cpp_semantic_model(module) + declarations = render_typescript("LocalTest", api) + generated = binding_codegen.render_v2_feature_jsi( + module, + module_name="LocalTest", + feature_id="supernote:feature:0123456789abcdef", ) - self.assertNotIn("create(handle: number): Document;", declarations) + self.assertIn("create: SupernoteCallable<[path: string], Document>;", declarations) + self.assertIn("std::make_shared<::Document>", generated) + self.assertNotIn("[handle: number]", declarations) def test_modules_without_objects_emit_empty_manifest_array(self): with tempfile.TemporaryDirectory() as directory: @@ -1768,25 +1629,5 @@ def test_modules_without_objects_emit_empty_manifest_array(self): manifest = json.loads((module / "android/build/generated/supernote/exports.json").read_text()) self.assertEqual([], manifest["objects"]) - @unittest.skip(V3_LEGACY_CLASS_MARKER_REMOVED) - def test_cli_summary_counts_native_objects_separately(self): - with tempfile.TemporaryDirectory() as directory: - module = self.make_module(Path(directory), backend="jsi", source="") - self.write_object_header( - module, - """// @SupernotePluginExport -class Counter { public: Counter(); }; -""", - ) - output = io.StringIO() - with redirect_stdout(output): - result = binding_codegen.main(["--module-root", str(module)]) - self.assertEqual(0, result) - self.assertIn( - "Generated 0 free-function exports and 1 native-object exports", - output.getvalue(), - ) - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_jvm_manifest_projection.py b/tests/test_jvm_manifest_projection.py index 742d297..4924aed 100644 --- a/tests/test_jvm_manifest_projection.py +++ b/tests/test_jvm_manifest_projection.py @@ -25,7 +25,6 @@ from supernote_module_generator.semantic import ( DeclarationRole, ExecutionMode, - SemanticClassKind, SemanticModelError, SemanticType, SourceProvenance, @@ -45,14 +44,6 @@ FEATURE_ID = "supernote:feature:0123456789abcdef" -LEGACY_CLASS_MARKER_REMOVED = pytest.mark.skip( - reason=( - "superseded by the V3 Object/Value marker contract; concrete JVM " - "object-route coverage returns in Phase 6" - ) -) - - def provenance(identity: str, language: JvmLanguage, path: str, line: int): return SourceProvenance(identity, language.value, path, line, 1) @@ -385,7 +376,6 @@ def test_kotlin_and_java_canonical_type_tables_are_exact(): } -@LEGACY_CLASS_MARKER_REMOVED def test_jvm_export_object_uses_selected_constructor_and_only_marked_members(): owner_name = "com.example.Document" first = constructor( @@ -427,13 +417,13 @@ def test_jvm_export_object_uses_selected_constructor_and_only_marked_members(): owner_name, "Document", JvmOwnerForm.CLASS, - intent(DeclarationTarget.CLASS, SupernoteMarker.EXPORT), + intent(DeclarationTarget.CLASS, SupernoteMarker.OBJECT), (first, selected), (method, hidden), ) - semantic = project_jvm_owners((owner,)).classes[0] + semantic = project_jvm_owners((owner,)).declarations[0] - assert semantic.kind is SemanticClassKind.JS_OBJECT + assert semantic.kind.value == "object" assert semantic.constructor.parameters[0].type is SemanticType.STRING assert [item.name for item in semantic.methods] == ["pageCount", "hiddenCache"] assert semantic.methods[1].capabilities.role is DeclarationRole.INTERNAL @@ -443,15 +433,14 @@ def test_jvm_export_object_uses_selected_constructor_and_only_marked_members(): feature_id=FEATURE_ID, module_name="Documents", ) - assert "GeneratedJvmObject0HostObject" in generated + assert "GeneratedV3JvmObject0HostObject" in generated assert "Object::createFromHostObject" in generated assert 'property == "pageCount"' in generated assert 'property == "hiddenCache"' not in generated assert "method_route_1_" not in generated - assert "std::shared_ptr owner_" in generated + assert "ManagedJvmRef managed_" in generated -@LEGACY_CLASS_MARKER_REMOVED def test_java_export_object_has_distinct_instance_and_worker_async_routes(): owner_name = "com.example.JavaDocument" selected = constructor( @@ -459,6 +448,7 @@ def test_java_export_object_has_distinct_instance_and_worker_async_routes(): JvmLanguage.JAVA, "(J)V", (JvmParameterSource("long", "handle"),), + SupernoteMarker.CONSTRUCTOR, ) value = declaration( owner_name, @@ -489,7 +479,7 @@ def test_java_export_object_has_distinct_instance_and_worker_async_routes(): owner_name, "JavaDocument", JvmOwnerForm.CLASS, - intent(DeclarationTarget.CLASS, SupernoteMarker.EXPORT), + intent(DeclarationTarget.CLASS, SupernoteMarker.OBJECT), (selected,), (value, load), ) @@ -501,16 +491,15 @@ def test_java_export_object_has_distinct_instance_and_worker_async_routes(): module_name="Documents", ) - assert semantic.classes[0].kind is SemanticClassKind.JS_OBJECT + assert semantic.declarations[0].kind.value == "object" assert "Object::createFromHostObject" in generated assert "CallStaticObjectMethodA" in generated assert 'property == "value"' in generated assert 'property == "load"' in generated assert "process_services().workers().submit" in generated - assert "auto owner = owner_" in generated + assert "auto owner = owner_;" in generated -@LEGACY_CLASS_MARKER_REMOVED def test_blocking_jvm_async_object_method_retains_global_receiver(): owner_name = "com.example.Document" load = declaration( @@ -530,7 +519,7 @@ def test_blocking_jvm_async_object_method_retains_global_receiver(): owner_name, "Document", JvmOwnerForm.CLASS, - intent(DeclarationTarget.CLASS, SupernoteMarker.EXPORT), + intent(DeclarationTarget.CLASS, SupernoteMarker.OBJECT), (constructor(owner_name, JvmLanguage.KOTLIN),), (load,), ) @@ -543,14 +532,14 @@ def test_blocking_jvm_async_object_method_retains_global_receiver(): assert 'getPropertyAsFunction(runtime, "Promise")' in generated assert "auto owner = owner_;" in generated - assert "auto invoke = [route, owner]" in generated + assert "retained_input_state = std::make_sharedset_retained_state(retained_input_state)" in generated assert "process_services().workers().submit" in generated assert "jvm_arguments[0].l" in generated - assert "owner->value.get()" in generated + assert "owner.get()" in generated assert "CallStaticObjectMethodA" in generated -@LEGACY_CLASS_MARKER_REMOVED def test_suspend_jvm_object_method_retains_receiver_until_job_finishes(): owner_name = "com.example.Document" load = declaration( @@ -571,7 +560,7 @@ def test_suspend_jvm_object_method_retains_receiver_until_job_finishes(): owner_name, "Document", JvmOwnerForm.CLASS, - intent(DeclarationTarget.CLASS, SupernoteMarker.EXPORT), + intent(DeclarationTarget.CLASS, SupernoteMarker.OBJECT), (constructor(owner_name, JvmLanguage.KOTLIN),), (load,), ) @@ -584,63 +573,13 @@ def test_suspend_jvm_object_method_retains_receiver_until_job_finishes(): assert 'property == "loadPage"' in generated assert "auto owner = owner_;" in generated - assert "operation, weak_feature, route, cancel_route, completion_id, owner" in generated - assert "owner->value.get()" in generated + assert "completion_id" in generated + assert "retained_input_state = std::make_sharedset_cancel_hook" in generated -@LEGACY_CLASS_MARKER_REMOVED -def test_internal_jvm_class_is_a_hidden_feature_service(): - owner_name = "com.example.IndexService" - method = declaration( - owner_name, - JvmLanguage.JAVA, - "rebuild", - "()V", - (), - "void", - SupernoteMarker.INTERNAL, - target=DeclarationTarget.METHOD, - ) - owner = JvmOwnerSource( - provenance(jvm_owner_identity(owner_name), JvmLanguage.JAVA, "IndexService.java", 2), - JvmLanguage.JAVA, - owner_name, - "IndexService", - JvmOwnerForm.CLASS, - intent(DeclarationTarget.CLASS, SupernoteMarker.INTERNAL), - (constructor(owner_name, JvmLanguage.JAVA),), - (method,), - ) - semantic = project_jvm_owners((owner,)).classes[0] - - assert semantic.kind is SemanticClassKind.INTERNAL_SERVICE - assert semantic.capabilities.javascript_public is False - assert semantic.methods[0].capabilities.role is DeclarationRole.INTERNAL - manifest = JvmSourceManifest(FEATURE_ID, "2.0.0.dev0", (owner,)) - api = project_jvm_owners((owner,)) - generated = render_jvm_feature_jsi( - manifest, - api, - feature_id=FEATURE_ID, - module_name="Documents", - ) - header, _ = render_cpp_internal_facade( - Path("/does/not/need/native/sources"), - module_name="Documents", - feature_id=FEATURE_ID, - jvm_manifest=manifest, - jvm_semantic=api, - ) - - assert "struct IndexService final" in header - assert "static void rebuild();" in header - assert "IndexService::rebuild" in generated - assert "feature->service" in generated - assert 'exports.setProperty(runtime, "rebuild"' not in generated - - def test_internal_jvm_functions_share_cpp_facade_across_sync_worker_and_suspend(): owner_name = "com.example.FeatureApiKt" value = JvmParameterSource("kotlin.Int", "page") diff --git a/tests/test_plugin_runtime_codegen.py b/tests/test_plugin_runtime_codegen.py index 659fe28..5e83bdf 100644 --- a/tests/test_plugin_runtime_codegen.py +++ b/tests/test_plugin_runtime_codegen.py @@ -150,6 +150,10 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat assert "runtime_bootstrap.cpp" in cmake assert "runtime_registration_bridge.c" in cmake assert services.count("static ProcessServices services") == 1 + assert "void BoundedExecutor::ensure_started()" in services + assert "DeferredDestruction::DeferredDestruction()" in services + assert "ProcessServices::thread_count() const noexcept" in services + assert "ProcessServices::shutdown() noexcept" in services assert "class FeatureCallScope" in services_header assert "claim_internal_completion" in services_header assert "set_retained_state" in services_header @@ -222,6 +226,9 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat assert "TypeScript" not in processor assert "nativeInstall" in bootstrap assert "nativeInvalidate" in bootstrap + assert "last_session = g_sessions.empty()" in bootstrap + assert "process_services().shutdown()" in bootstrap + assert "stopped process services for runtime generation" in bootstrap assert "nativeRunJsTask" not in bootstrap assert "RegisterNatives" in bootstrap assert "register_coroutine_bridge(env, class_loader)" in bootstrap @@ -429,6 +436,7 @@ def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( }, std::make_shared(7)); auto cleanup = std::make_shared(); + if (cleanup->thread_count() != 0) return 39; auto feature = FeatureSession::create(runtime, cleanup); std::atomic resolved{0}; std::atomic rejected{0}; @@ -584,6 +592,7 @@ def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( detached_runtime->invalidate(); BoundedExecutor executor(1, 2); + if (executor.thread_count() != 0) return 40; std::atomic worker_initialized{0}; std::atomic worker_cleaned{0}; executor.set_thread_initializer([&] { @@ -605,6 +614,7 @@ def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( std::unique_lock lock(mutex); ready.wait(lock, [&] { return started; }); } + if (executor.thread_count() != 1) return 41; auto second = executor.submit( [&](CancellationToken) { second_ran = true; }); if (!first.accepted() || !second.accepted() || !second.cancel()) return 7; @@ -616,8 +626,10 @@ def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( executor.shutdown(); if (second_ran || !second.token().is_cancelled()) return 8; if (worker_initialized != 1 || worker_cleaned != 1) return 20; + if (executor.thread_count() != 0) return 42; std::atomic jvm_completions{0}; + if (process_services().thread_count() != 0) return 43; auto completion_id = process_services().register_jvm_async_completion( [&](void *, void *, std::string code, std::string message) { if (code == "IMPLEMENTATION_ERROR" && message == "failed") { @@ -656,6 +668,7 @@ def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( if (destroyed_future.wait_for(std::chrono::seconds(2)) != std::future_status::ready) return 10; if (destroyed_future.get() == releasing_thread) return 11; + if (cleanup->thread_count() != 1) return 44; std::promise callback_destroyed; auto callback_destroyed_future = callback_destroyed.get_future(); @@ -695,6 +708,9 @@ def test_generated_runtime_enforces_session_cancellation_and_cleanup_contracts( std::future_status::ready) return 21; allow_blocking_release.set_value(); cleanup->drain_and_shutdown(); + if (cleanup->thread_count() != 0) return 45; + process_services().shutdown(); + if (process_services().thread_count() != 0) return 46; return 0; } """