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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ This file records user-visible changes to the generator. Documentation on the
default branch may be newer than the latest published package; released users
should also check their installed version with `supernote-module --version`.

## 2.0.2 - 2026-08-15

- Make Add and Remove rollback restore Android application integration exactly,
including interrupted or dependency-install failure paths.
- Serialize generator operations per plugin and report a clear busy error when
another operation is already changing the same plugin.
- Validate leftover generated wiring even when a plugin currently has no V2
features.
- Accept the conventional `--` option terminator and reject malformed feature
metadata, escaping managed symlinks, and unsupported marked C++ boundaries
with focused preflight diagnostics.
- Keep expected KSP source errors concise instead of appending processor stack
traces.
- Preserve Kotlin/Java exception messages through synchronous and asynchronous
JSI routes while retaining the stable `SupernoteError` contract.
- Expand executable coverage for the runtime-lazy feature Proxy, including
runtime replacement and reflection behavior.

## 2.0.1 - 2026-08-14

- Make generated feature imports safe during ordinary JavaScript module
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ 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
runtime/build component shared by all features.

V2 is the current stable architecture. Version `2.0.1` makes ordinary static
feature imports safe before the JSI runtime is installed; 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.
V2 is the current stable architecture. Version `2.0.2` keeps ordinary static
feature imports runtime-safe and improves transactional recovery, concurrent
CLI operation handling, source/metadata diagnostics, and Kotlin/Java failure
messages. 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.

## Install

Expand Down
2 changes: 1 addition & 1 deletion src/supernote_module_generator/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Safe generator for local native code modules in Supernote React Native plugins."""

__version__ = "2.0.1"
__version__ = "2.0.2"
25 changes: 20 additions & 5 deletions src/supernote_module_generator/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,23 @@ def _split_option(token: str) -> Tuple[str, Optional[str]]:

def _command_index(arguments: List[str]) -> Tuple[Optional[int], Optional[str]]:
index = 0
options_ended = False
while index < len(arguments):
token, attached = _split_option(arguments[index])
if token in GLOBAL_BOOLEANS:
raw = arguments[index]
if raw == "--" and not options_ended:
options_ended = True
index += 1
continue
token, attached = _split_option(raw)
if not options_ended and token in GLOBAL_BOOLEANS:
if attached is not None:
raise ConfigurationError(f'unknown option "{arguments[index]}"')
raise ConfigurationError(f'unknown option "{raw}"')
index += 1
continue
if token.startswith("-"):
if not options_ended and token.startswith("-"):
# A command-specific option cannot validly precede the command.
raise ConfigurationError(f'unknown option "{token}"')
return index, token
return index, raw
return None, None


Expand All @@ -130,13 +136,22 @@ def parse_arguments(arguments: List[str]) -> ParsedArguments:
booleans: Set[str] = set()
globals_seen: Set[str] = set()
positionals: List[str] = []
options_ended = False

index = 0
while index < len(arguments):
if command_index is not None and index == command_index:
index += 1
continue
raw = arguments[index]
if raw == "--" and not options_ended:
options_ended = True
index += 1
continue
if options_ended:
positionals.append(raw)
index += 1
continue
option, attached = _split_option(raw)
if option in GLOBAL_BOOLEANS:
if attached is not None:
Expand Down
31 changes: 28 additions & 3 deletions src/supernote_module_generator/binding_codegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,23 @@ def _parse_function_source(
)
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(
Expand Down Expand Up @@ -3984,6 +4001,8 @@ def _jsi_async_host_function(
worker_captures_extra: tuple[str, ...] = (),
worker_prelude: str = "",
release_feature_before_execution: bool = True,
implementation_name: str = "C++",
implementation_exception_type: str | None = None,
) -> str:
expected_parameters = ", ".join(
_jsi_expected_type(parameter.cpp_type) + f" {parameter.name}"
Expand Down Expand Up @@ -4077,6 +4096,12 @@ def _jsi_async_host_function(
if release_feature_before_execution
else ""
)
implementation_exception_catch = (
f" }} catch (const {implementation_exception_type} &error) {{\n"
" state->error = error.what();\n"
if implementation_exception_type
else ""
)
return f'''Function::createFromHostFunction(
runtime,
PropNameID::forAscii(runtime, {json.dumps(js_name)}),
Expand Down Expand Up @@ -4141,10 +4166,10 @@ def _jsi_async_host_function(
{feature_release}
try {{
{execution}
}} catch (const std::exception &error) {{
{implementation_exception_catch} }} catch (const std::exception &error) {{
state->error = error.what();
}} catch (...) {{
state->error = "unknown C++ implementation failure";
state->error = {json.dumps("unknown " + implementation_name + " implementation failure")};
}}
if (executor_cancel.is_cancelled() ||
operation->cancellation_token().is_cancelled()) return;
Expand All @@ -4159,7 +4184,7 @@ def _jsi_async_host_function(
supernote_reject_operation(
runtime, operation_id, "IMPLEMENTATION_ERROR",
state->error.empty()
? "C++ implementation failed"
? {json.dumps(implementation_name + " implementation failed")}
: state->error);
return;
}}
Expand Down
80 changes: 64 additions & 16 deletions src/supernote_module_generator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from .arguments import COMMANDS, ParsedArguments, parse_arguments
from .doctor import DoctorService
from .feature_cli_operations import FeatureCliOperationService
from .feature_workflows import FeatureDecisionCollector
from .feature_workflows import FeatureDecisionCollector, FeatureValidateDecisions
from .errors import ConfigurationError, GeneratorError, OperationCancelled, PartialFailure
from .helptext import help_for
from .interaction import (
Expand All @@ -32,6 +32,7 @@
SubprocessError,
)
from .naming import infer_android_namespace, infer_javascript_name
from .operation_lock import plugin_operation_lock
from .project import managed_modules, resolve_plugin_root
from .rendering import Renderer, TerminalCapabilities
from .transaction import recover_pending
Expand Down Expand Up @@ -380,27 +381,56 @@ def _run_command(
command = parsed.command or "unknown"
interactive = _interactive_for(parsed, renderer)
interaction = Interaction(renderer, stdin=stdin) if interactive else None
startup_warnings = []

if command == "doctor":
try:
valid_root = resolve_plugin_root(cwd)
except ConfigurationError:
valid_root = None
if valid_root is not None:
if valid_root is None:
collector = FeatureDecisionCollector(
cwd.resolve(),
parsed,
interaction,
launched_from_menu=launched_from_menu,
)
return DoctorService(cwd, renderer).execute(collector.doctor_scope())
with plugin_operation_lock(valid_root):
startup_warnings = _recover(valid_root, command, renderer)
collector = FeatureDecisionCollector(
valid_root or cwd.resolve(),
collector = FeatureDecisionCollector(
valid_root,
parsed,
interaction,
launched_from_menu=launched_from_menu,
)
result = DoctorService(cwd, renderer).execute(collector.doctor_scope())
result.warnings.extend(
warning for warning in startup_warnings if warning is not None
)
return result

root = resolve_plugin_root(cwd)
with plugin_operation_lock(root):
return _run_feature_command(
parsed,
interaction,
renderer,
root=root,
stdin=stdin,
interaction=interaction,
launched_from_menu=launched_from_menu,
)
scope = collector.doctor_scope()
result = DoctorService(cwd, renderer).execute(scope)
result.warnings.extend(warning for warning in startup_warnings if warning is not None)
return result

root = resolve_plugin_root(cwd)

def _run_feature_command(
parsed: ParsedArguments,
renderer: Renderer,
*,
root: Path,
stdin: IO[str],
interaction: Interaction | None,
launched_from_menu: bool,
) -> CommandResult:
command = parsed.command or "unknown"
startup_warnings = _recover(root, command, renderer)
collector = FeatureDecisionCollector(
root,
Expand All @@ -427,6 +457,16 @@ def _run_command(
elif command == "validate":
decisions = collector.validate()
if decisions is None:
structural_issues = service.features.verify_generated_state()
if structural_issues:
decisions = FeatureValidateDecisions((), True, False)
result = service.validate(decisions)
result.warnings = [
*(warning for warning in startup_warnings if warning is not None),
*collector.warnings,
*result.warnings,
]
return result
if renderer.mode == "json":
return CommandResult("validate", metadata={"empty": True})
empty = "No features were found in this plugin."
Expand Down Expand Up @@ -511,13 +551,17 @@ def _interactive_loop(
return result.exit_code

try:
startup = recover_pending(
root,
reconcile=lambda invocation: _startup_reconcile(root, invocation),
)
with plugin_operation_lock(root):
startup = recover_pending(
root,
reconcile=lambda invocation: _startup_reconcile(root, invocation),
)
except PartialFailure as exc:
renderer.render(_startup_failure("menu", exc))
return 3
except GeneratorError as exc:
renderer.render(_exception_result("menu", exc, renderer.debug))
return exc.exit_code
if startup.rollback.status == "partial":
result = CommandResult(
"menu",
Expand Down Expand Up @@ -630,7 +674,11 @@ def _main(
return 0
if parsed.command is None:
if renderer.capabilities.interactive and parsed.output_mode != "json":
return _interactive_loop(renderer, cwd=cwd, stdin=stdin)
try:
return _interactive_loop(renderer, cwd=cwd, stdin=stdin)
except (InterruptRequested, KeyboardInterrupt):
print("Operation cancelled.", file=stdout)
return 130
result = _usage_result(
"unknown",
"no command was provided",
Expand Down
8 changes: 2 additions & 6 deletions src/supernote_module_generator/feature_cli_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
validate_package_name,
validate_package_version,
)
from .plugin_build_integration import integration_files
from .plugin_build_integration import integration_mutation_files
from .plugin_runtime_codegen import RUNTIME_RELATIVE_ROOT
from .project import (
dependency_link_path,
Expand Down Expand Up @@ -260,8 +260,6 @@ def _build_output_paths(self) -> tuple[Path, ...]:

def validate(self, decisions: FeatureValidateDecisions) -> CommandResult:
records = [self.features.find_record(name) for name in decisions.package_names]
if not records:
return CommandResult("validate", metadata={"empty": True})
validation = self._validate_records(records, dependency_requested=True)
build_error: SubprocessError | None = None
if decisions.build:
Expand Down Expand Up @@ -334,11 +332,9 @@ def _validate_add(self, decisions: FeatureAddDecisions) -> None:
def _snapshot_operation(
self, transaction: Transaction, feature_paths: Iterable[Path]
) -> None:
settings, app_build = integration_files(self.root)
paths = [
*parent_mutation_targets(self.root),
settings,
app_build,
*integration_mutation_files(self.root),
self.root / RUNTIME_RELATIVE_ROOT,
*feature_paths,
]
Expand Down
Loading