diff --git a/CHANGELOG.md b/CHANGELOG.md index 92a3f7c..f820b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index a4ceb92..f920594 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/supernote_module_generator/__init__.py b/src/supernote_module_generator/__init__.py index 6d5c0aa..f3514c3 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.1" +__version__ = "2.0.2" diff --git a/src/supernote_module_generator/arguments.py b/src/supernote_module_generator/arguments.py index 29960de..33f7d99 100644 --- a/src/supernote_module_generator/arguments.py +++ b/src/supernote_module_generator/arguments.py @@ -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 @@ -130,6 +136,7 @@ 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): @@ -137,6 +144,14 @@ def parse_arguments(arguments: List[str]) -> ParsedArguments: 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: diff --git a/src/supernote_module_generator/binding_codegen.py b/src/supernote_module_generator/binding_codegen.py index 34d982b..a3975a8 100644 --- a/src/supernote_module_generator/binding_codegen.py +++ b/src/supernote_module_generator/binding_codegen.py @@ -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( @@ -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}" @@ -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)}), @@ -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; @@ -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; }} diff --git a/src/supernote_module_generator/cli.py b/src/supernote_module_generator/cli.py index da3fd05..f1b77cb 100644 --- a/src/supernote_module_generator/cli.py +++ b/src/supernote_module_generator/cli.py @@ -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 ( @@ -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 @@ -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, @@ -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." @@ -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", @@ -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", diff --git a/src/supernote_module_generator/feature_cli_operations.py b/src/supernote_module_generator/feature_cli_operations.py index 2d14636..12ef23d 100644 --- a/src/supernote_module_generator/feature_cli_operations.py +++ b/src/supernote_module_generator/feature_cli_operations.py @@ -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, @@ -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: @@ -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, ] diff --git a/src/supernote_module_generator/feature_operations.py b/src/supernote_module_generator/feature_operations.py index db458f8..32718e7 100644 --- a/src/supernote_module_generator/feature_operations.py +++ b/src/supernote_module_generator/feature_operations.py @@ -9,9 +9,10 @@ from dataclasses import dataclass from . import __version__, binding_codegen -from .errors import ConfigurationError +from .errors import ConfigurationError, GeneratorError from .feature_generator import FeatureConfig, stage_feature from .feature_model import ( + FeatureModelError, FeatureManifest, FeatureRegistryEntry, ImplementationRoots, @@ -32,6 +33,20 @@ class FeatureOperationError(ConfigurationError): pass +class FeatureMetadataError(GeneratorError): + """An existing generator-owned feature manifest is corrupt or unsupported.""" + + kind = "invalid_metadata" + phase = "preflight" + + +class FeatureSourceError(GeneratorError): + """A marked user declaration cannot be represented by V2 bindings.""" + + kind = "invalid_source" + phase = "preflight" + + @dataclass(frozen=True) class FeatureRecord: path: Path @@ -203,18 +218,73 @@ def find(self, npm_name: str) -> Path: raise FeatureOperationError(f"feature not found: {npm_name}") def feature_paths(self) -> list[Path]: + if self.features_root.is_symlink(): + self._reject_escaping_feature_links() if not self.features_root.is_dir(): return [] + self._reject_escaping_feature_links() result = [] for metadata in sorted(self.features_root.rglob(".supernote-module.json")): relative = metadata.relative_to(self.features_root) if any(part.startswith(".") for part in relative.parts[:-1]): continue - value = json.loads(metadata.read_text(encoding="utf-8")) - if value.get("kind") == "supernote_feature": - result.append(metadata.parent) + self._reject_escaping_managed_path(metadata) + read_feature_manifest(metadata.parent) + result.append(metadata.parent) return result + def _reject_escaping_feature_links(self) -> None: + """Reject managed package-root links without policing user source links.""" + + candidates = [self.features_root] + if not self.features_root.is_symlink(): + try: + children = sorted(self.features_root.iterdir()) + except OSError as exc: + raise FeatureMetadataError( + f"managed feature directory could not be read:\n\n" + f"{self.features_root}: {exc}" + ) from exc + candidates.extend( + child for child in children if not child.name.startswith(".") + ) + for scope in children: + if ( + scope.name.startswith("@") + and scope.is_dir() + and not scope.is_symlink() + ): + try: + candidates.extend( + child + for child in sorted(scope.iterdir()) + if not child.name.startswith(".") + ) + except OSError as exc: + raise FeatureMetadataError( + f"managed feature scope could not be read:\n\n{scope}: {exc}" + ) from exc + canonical_root = self.root.resolve() + for candidate in candidates: + self._reject_escaping_managed_path(candidate, canonical_root) + + def _reject_escaping_managed_path( + self, + candidate: Path, + canonical_root: Path | None = None, + ) -> None: + if not candidate.is_symlink(): + return + canonical = candidate.resolve(strict=False) + try: + canonical.relative_to(canonical_root or self.root.resolve()) + except ValueError as exc: + raise ConfigurationError( + "target resolves outside the Supernote plugin:\n\n" + f"managed feature path {candidate}\n" + f"resolves to {canonical}" + ) from exc + def records(self) -> list[FeatureRecord]: return [read_feature_record(path) for path in self.feature_paths()] @@ -277,13 +347,16 @@ def _entries( for path in paths: manifest = read_feature_manifest(path) native_root = path / manifest.roots.native - semantic = ( - binding_codegen.scan_cpp_semantic_model( - path, module_name=manifest.public_name + try: + semantic = ( + binding_codegen.scan_cpp_semantic_model( + path, module_name=manifest.public_name + ) + if native_root.is_dir() + else SemanticApi() ) - if native_root.is_dir() - else SemanticApi() - ) + except binding_codegen.CodegenError as exc: + raise FeatureSourceError(str(exc)) from exc entries.append(FeatureRegistryEntry.create(manifest, semantic)) return tuple(entries) @@ -340,29 +413,108 @@ def _finalize(backup: Path | None) -> None: def read_feature_manifest(path: Path) -> FeatureManifest: - raw = json.loads((path / ".supernote-module.json").read_text(encoding="utf-8")) - roots = raw["implementation_roots"] - return FeatureManifest( - feature_id=str(raw["feature_id"]), - npm_name=str(raw["npm_name"]), - public_name=str(raw["public_name"]), - android_namespace=str(raw["android_namespace"]), - roots=ImplementationRoots(str(roots["native"]), str(roots["jvm"])), - starter_files=tuple(str(item) for item in raw.get("starter_files", ())), - schema_version=int(raw["schema_version"]), - ) + metadata = path / ".supernote-module.json" + raw = _read_feature_metadata(metadata) + try: + if "implementation_roots" not in raw: + raise TypeError("implementation_roots is required") + roots = raw["implementation_roots"] + if not isinstance(roots, dict): + raise TypeError("implementation_roots must be an object") + starter_files = raw.get("starter_files", ()) + if not isinstance(starter_files, list): + raise TypeError("starter_files must be an array") + return FeatureManifest( + feature_id=_required_string(raw, "feature_id"), + npm_name=_required_string(raw, "npm_name"), + public_name=_required_string(raw, "public_name"), + android_namespace=_required_string(raw, "android_namespace"), + roots=ImplementationRoots( + _required_string(roots, "native"), + _required_string(roots, "jvm"), + ), + starter_files=tuple( + _array_string(starter_files, index) + for index in range(len(starter_files)) + ), + schema_version=_required_integer(raw, "schema_version"), + ) + except FeatureMetadataError: + raise + except (FeatureModelError, KeyError, TypeError, ValueError) as exc: + raise _invalid_feature_metadata(metadata, str(exc)) from exc def read_feature_record(path: Path) -> FeatureRecord: - raw = json.loads((path / ".supernote-module.json").read_text(encoding="utf-8")) - return FeatureRecord( - path.resolve(), - read_feature_manifest(path), - str(raw["package_version"]), - str(raw.get("description", "")), + metadata = path / ".supernote-module.json" + raw = _read_feature_metadata(metadata) + try: + package_version = _required_string(raw, "package_version") + description = raw.get("description", "") + if not isinstance(description, str): + raise TypeError("description must be a string") + return FeatureRecord( + path.resolve(), + read_feature_manifest(path), + package_version, + description, + ) + except FeatureMetadataError: + raise + except (KeyError, TypeError, ValueError) as exc: + raise _invalid_feature_metadata(metadata, str(exc)) from exc + + +def _read_feature_metadata(metadata: Path) -> dict[str, object]: + try: + value = json.loads(metadata.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + reason = f"invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}" + raise _invalid_feature_metadata(metadata, reason) from exc + except OSError as exc: + raise _invalid_feature_metadata(metadata, f"could not read file: {exc}") from exc + 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": + raise _invalid_feature_metadata( + metadata, + f"kind must be 'supernote_feature', got {kind!r}", + ) + return value + + +def _invalid_feature_metadata(metadata: Path, reason: str) -> FeatureMetadataError: + return FeatureMetadataError( + f"feature metadata is invalid or unsupported:\n\n{metadata}: {reason}" ) +def _required_string(value: dict[str, object], name: str) -> str: + if name not in value: + raise TypeError(f"{name} is required") + item = value[name] + if not isinstance(item, str) or not item: + raise TypeError(f"{name} must be a non-empty string") + return item + + +def _required_integer(value: dict[str, object], name: str) -> int: + if name not in value: + raise TypeError(f"{name} is required") + item = value[name] + if not isinstance(item, int) or isinstance(item, bool): + raise TypeError(f"{name} must be an integer") + return item + + +def _array_string(value: list[object], index: int) -> str: + item = value[index] + if not isinstance(item, str): + raise TypeError(f"starter_files[{index}] must be a string") + return item + + def _starter_families(files: tuple[str, ...]) -> tuple[StarterFamily, ...]: values = [] if any(path.startswith("android/src/main/cpp/") for path in files): diff --git a/src/supernote_module_generator/jvm_codegen.py b/src/supernote_module_generator/jvm_codegen.py index f67abd2..6977105 100644 --- a/src/supernote_module_generator/jvm_codegen.py +++ b/src/supernote_module_generator/jvm_codegen.py @@ -250,10 +250,67 @@ class LocalFrame {{ JNIEnv *env_; }}; +class LocalReference {{ + public: + LocalReference(JNIEnv *env, jobject value) : env_(env), value_(value) {{}} + LocalReference(const LocalReference &) = delete; + LocalReference &operator=(const LocalReference &) = delete; + ~LocalReference() {{ + if (env_ != nullptr && value_ != nullptr) env_->DeleteLocalRef(value_); + }} + jobject get() const noexcept {{ return value_; }} + + private: + JNIEnv *env_; + jobject value_; +}}; + void clear_exception(JNIEnv *env) {{ if (env != nullptr && env->ExceptionCheck()) env->ExceptionClear(); }} +std::string implementation_exception_message( + JNIEnv *env, jthrowable failure) {{ + if (env == nullptr || failure == nullptr) return {{}}; + LocalReference failure_class(env, env->GetObjectClass(failure)); + if (env->ExceptionCheck() || failure_class.get() == nullptr) {{ + clear_exception(env); + return {{}}; + }} + auto get_message = env->GetMethodID( + static_cast(failure_class.get()), + "getMessage", "()Ljava/lang/String;"); + if (env->ExceptionCheck() || get_message == nullptr) {{ + clear_exception(env); + return {{}}; + }} + LocalReference message( + env, env->CallObjectMethod(failure, get_message)); + if (env->ExceptionCheck() || message.get() == nullptr) {{ + clear_exception(env); + return {{}}; + }} + const char *utf8 = env->GetStringUTFChars( + static_cast(message.get()), nullptr); + if (env->ExceptionCheck() || utf8 == nullptr) {{ + clear_exception(env); + return {{}}; + }} + std::string result; + try {{ + result.assign(utf8); + }} catch (...) {{ + env->ReleaseStringUTFChars( + static_cast(message.get()), utf8); + clear_exception(env); + throw; + }} + env->ReleaseStringUTFChars( + static_cast(message.get()), utf8); + if (env->ExceptionCheck()) clear_exception(env); + return result; +}} + std::shared_ptr retain_global(JNIEnv *env, jobject value) {{ if (env == nullptr || value == nullptr) {{ throw std::runtime_error("cannot retain a null JVM object"); @@ -384,8 +441,14 @@ class LazyJvmRoute {{ void require_no_implementation_exception(JNIEnv *env) {{ if (!env->ExceptionCheck()) return; + LocalReference failure(env, env->ExceptionOccurred()); env->ExceptionClear(); - throw JvmImplementationFailure("Kotlin/Java implementation failed"); + auto message = implementation_exception_message( + env, static_cast(failure.get())); + throw JvmImplementationFailure( + message.empty() + ? "Kotlin/Java implementation failed" + : "Kotlin/Java implementation failed: " + message); }} {chr(10).join(object_wrappers)} @@ -1175,6 +1238,8 @@ def _render_async_function( " supernote::runtime::FeatureState::ACTIVE) return;" ), release_feature_before_execution=False, + implementation_name="Kotlin/Java", + implementation_exception_type="JvmImplementationFailure", ) return ( " {\n" @@ -1566,6 +1631,8 @@ def _render_async_object_method( " supernote::runtime::FeatureState::ACTIVE) return;" ), release_feature_before_execution=False, + implementation_name="Kotlin/Java", + implementation_exception_type="JvmImplementationFailure", ) return f''' if (property == {json.dumps(method.name)}) {{ auto route = {route_name}_; diff --git a/src/supernote_module_generator/operation_lock.py b/src/supernote_module_generator/operation_lock.py new file mode 100644 index 0000000..0d64517 --- /dev/null +++ b/src/supernote_module_generator/operation_lock.py @@ -0,0 +1,45 @@ +"""Non-blocking plugin-root command serialization.""" +from __future__ import annotations + +from contextlib import contextmanager +import fcntl +import os +from pathlib import Path +from typing import Iterator + +from .errors import ConfigurationError, FilesystemError + + +class PluginBusyError(ConfigurationError): + """Another generator process currently owns this plugin.""" + + kind = "plugin_busy" + phase = "preflight" + + +@contextmanager +def plugin_operation_lock(plugin_root: Path) -> Iterator[None]: + """Lock the plugin directory without adding a user-visible lock file.""" + + root = plugin_root.resolve() + try: + descriptor = os.open(root, os.O_RDONLY) + except OSError as exc: + raise FilesystemError(f"Could not open the plugin directory: {root}") from exc + acquired = False + try: + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + acquired = True + except BlockingIOError as exc: + raise PluginBusyError( + "Another supernote-module command is already running for this plugin. " + "Wait for it to finish and try again." + ) from exc + yield + finally: + try: + if acquired: + fcntl.flock(descriptor, fcntl.LOCK_UN) + finally: + os.close(descriptor) diff --git a/src/supernote_module_generator/plugin_build_integration.py b/src/supernote_module_generator/plugin_build_integration.py index 2e7212a..0442818 100644 --- a/src/supernote_module_generator/plugin_build_integration.py +++ b/src/supernote_module_generator/plugin_build_integration.py @@ -37,6 +37,18 @@ def integration_files(plugin_root: Path) -> tuple[Path, Path]: return settings, app_build +def integration_mutation_files(plugin_root: Path) -> tuple[Path, ...]: + """Return every user-owned Android file runtime wiring may change.""" + + settings, app_build = integration_files(plugin_root) + application = _application_file(plugin_root) + return ( + (settings, app_build, application) + if application is not None + else (settings, app_build) + ) + + def set_runtime_wiring(plugin_root: Path, *, enabled: bool) -> tuple[Path, Path]: """Wire or unwire exactly one generated Android library atomically.""" diff --git a/src/supernote_module_generator/templates/v2.SupernoteV2Processor.kt.tmpl b/src/supernote_module_generator/templates/v2.SupernoteV2Processor.kt.tmpl index 0c276f1..75651b8 100644 --- a/src/supernote_module_generator/templates/v2.SupernoteV2Processor.kt.tmpl +++ b/src/supernote_module_generator/templates/v2.SupernoteV2Processor.kt.tmpl @@ -39,6 +39,8 @@ private val markerNames = markerOrder.associateWith { "supernote.generated.annotations.$it" } +private class SupernoteSourceDiagnostic : RuntimeException() + class SupernoteV2Processor( private val environment: SymbolProcessorEnvironment, ) : SymbolProcessor { @@ -46,6 +48,18 @@ class SupernoteV2Processor( override fun process(resolver: Resolver): List { if (generated) return emptyList() + try { + return processMarkedDeclarations(resolver) + } catch (_: SupernoteSourceDiagnostic) { + // The source-located error has already been reported through KSP. Do + // not let an expected user declaration error look like a processor + // crash by escaping into Gradle's exception reporting. + generated = true + return emptyList() + } + } + + private fun processMarkedDeclarations(resolver: Resolver): List { val symbols = linkedMapOf() markerNames.values.forEach { annotation -> resolver.getSymbolsWithAnnotation(annotation).forEach { symbol -> @@ -408,12 +422,12 @@ class SupernoteV2Processor( private fun fail(node: KSNode, message: String): Nothing { environment.logger.error("Supernote V2: $message", node) - throw IllegalArgumentException(message) + throw SupernoteSourceDiagnostic() } private fun fail(path: String, message: String): Nothing { environment.logger.error("Supernote V2: $path: $message") - throw IllegalArgumentException(message) + throw SupernoteSourceDiagnostic() } } diff --git a/tests/test_arguments.py b/tests/test_arguments.py index f3a0b2f..28353ff 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -51,6 +51,50 @@ def test_legacy_command_forms_and_options_are_rejected(legacy: str): parse_arguments([legacy]) +def test_double_dash_ends_option_parsing_for_command_positionals(): + parsed = parse_arguments(["add", "--", "--help"]) + + assert parsed.command == "add" + assert parsed.positional == "--help" + assert not parsed.show_help + + +def test_double_dash_can_precede_the_command(): + parsed = parse_arguments(["--", "help", "add"]) + + assert parsed.command == "help" + assert parsed.positional == "add" + + +def test_options_after_double_dash_are_not_applied(): + parsed = parse_arguments(["validate", "--", "--all"]) + + assert parsed.positional == "--all" + assert not parsed.has("all") + + +def test_double_dash_positional_still_passes_normal_package_validation(tmp_path: Path): + (tmp_path / "android/app").mkdir(parents=True) + (tmp_path / "PluginConfig.json").write_text("{}\n", encoding="utf-8") + (tmp_path / "package.json").write_text( + '{"name":"fixture","dependencies":{}}\n', encoding="utf-8" + ) + (tmp_path / "android/settings.gradle").write_text( + "include ':app'\n", encoding="utf-8" + ) + (tmp_path / "android/app/build.gradle").write_text( + "plugins {}\n", encoding="utf-8" + ) + + code, _, stderr = invoke( + ["add", "--yes", "--skip-install", "--", "--help"], tmp_path + ) + + assert code == 2 + assert 'invalid package name "--help"' in stderr + assert not (tmp_path / "local_modules").exists() + + def test_all_and_module_are_mutually_exclusive(): with pytest.raises(ConfigurationError, match="--all cannot"): parse_arguments(["validate", "local-math", "--all"]) diff --git a/tests/test_binding_codegen.py b/tests/test_binding_codegen.py index 08f47e1..4723abd 100644 --- a/tests/test_binding_codegen.py +++ b/tests/test_binding_codegen.py @@ -177,6 +177,37 @@ def test_cpp_source_model_ignores_ordinary_public_code(self): binding_codegen.scan_cpp_semantic_model(module).functions, ) + def test_marked_pointer_and_reference_returns_report_boundary_type(self): + cases = ( + ("pointer", "std::int32_t *", "raw pointers are not supported"), + ("reference", "std::string &", "references are not supported"), + ) + for name, result, diagnostic in cases: + with self.subTest(name=name): + directory_context = tempfile.TemporaryDirectory() + self.addCleanup(directory_context.cleanup) + directory = directory_context.name + module = self.make_module( + Path(directory), + source=( + "// @SupernotePluginExport\n" + f"{result} invalid() {{ throw 1; }}\n" + ), + ) + with self.assertRaisesRegex( + binding_codegen.CodegenError, + diagnostic, + ) as raised: + binding_codegen.scan_cpp_semantic_model(module) + + message = str(raised.exception) + self.assertIn("unsupported return type", message) + self.assertIn( + "return one canonical V2 value type by value", + message, + ) + self.assertNotIn("expected a C++ function name", message) + def test_cpp_source_identity_does_not_depend_on_blank_lines(self): with tempfile.TemporaryDirectory() as directory: module = self.make_module(Path(directory)) diff --git a/tests/test_feature_generator.py b/tests/test_feature_generator.py index 646eb74..5af0477 100644 --- a/tests/test_feature_generator.py +++ b/tests/test_feature_generator.py @@ -124,7 +124,7 @@ def test_feature_package_imports_before_runtime_install_and_resolves_lazily( throw new Error(`unexpected early-access result: ${{earlyError}}`); }} -const first = {{greet: name => `first:${{name}}`}}; +const first = {{firstOnly: 1, greet: name => `first:${{name}}`}}; globalThis.__supernoteV2 = {{ feature(id) {{ if (id !== {json.dumps(feature_id)}) throw new Error(`wrong id: ${{id}}`); @@ -137,12 +137,55 @@ def test_feature_package_imports_before_runtime_install_and_resolves_lazily( if (first.__supernoteErrorConstructor !== generated.SupernoteError) {{ throw new Error('SupernoteError constructor was not installed on the feature'); }} +if (Object.prototype.propertyIsEnumerable.call( + first, + '__supernoteErrorConstructor', + )) {{ + throw new Error('SupernoteError constructor was enumerable on the feature'); +}} + +if (!('greet' in generated.default) || 'missing' in generated.default) {{ + throw new Error('feature membership did not reflect the current feature'); +}} +const firstKeys = Reflect.ownKeys(generated.default); +if (!firstKeys.includes('greet') || !firstKeys.includes('firstOnly')) {{ + throw new Error(`feature keys were not forwarded: ${{firstKeys}}`); +}} +if (firstKeys.includes('__supernoteErrorConstructor')) {{ + throw new Error('internal error constructor leaked through feature keys'); +}} +const greetDescriptor = Object.getOwnPropertyDescriptor( + generated.default, + 'greet', +); +if (!greetDescriptor || greetDescriptor.value !== first.greet || + greetDescriptor.configurable !== true) {{ + throw new Error('feature property descriptor was not forwarded safely'); +}} +if (generated.default.__supernoteErrorConstructor !== undefined || + '__supernoteErrorConstructor' in generated.default || + Object.getOwnPropertyDescriptor( + generated.default, + '__supernoteErrorConstructor', + ) !== undefined) {{ + throw new Error('internal error constructor was visible through the proxy'); +}} -const second = {{greet: name => `second:${{name}}`}}; +const second = {{greet: name => `second:${{name}}`, secondOnly: 2}}; globalThis.__supernoteV2 = {{feature: () => second}}; if (generated.default.greet('Ada') !== 'second:Ada') {{ throw new Error('feature wrapper retained a stale runtime binding'); }} +if ('firstOnly' in generated.default || !('secondOnly' in generated.default)) {{ + throw new Error('feature membership retained a stale runtime binding'); +}} +const secondKeys = Reflect.ownKeys(generated.default); +if (secondKeys.includes('firstOnly') || !secondKeys.includes('secondOnly')) {{ + throw new Error(`feature keys retained a stale runtime binding: ${{secondKeys}}`); +}} +if (second.__supernoteErrorConstructor !== generated.SupernoteError) {{ + throw new Error('SupernoteError constructor was not installed on replacement'); +}} """ 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 new file mode 100644 index 0000000..bebab85 --- /dev/null +++ b/tests/test_feature_metadata_diagnostics.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest + +from supernote_module_generator.cli import main + + +def _plugin(tmp_path: Path) -> Path: + (tmp_path / "android/app").mkdir(parents=True) + (tmp_path / "PluginConfig.json").write_text("{}\n", encoding="utf-8") + (tmp_path / "package.json").write_text( + json.dumps({"name": "fixture", "dependencies": {}}) + "\n", + encoding="utf-8", + ) + (tmp_path / "android/settings.gradle").write_text( + "include ':app'\n", encoding="utf-8" + ) + (tmp_path / "android/app/build.gradle").write_text( + "plugins {}\n", encoding="utf-8" + ) + return tmp_path + + +def _invoke(root: Path, arguments: list[str]) -> tuple[int, str, str]: + stdout = io.StringIO() + stderr = io.StringIO() + code = main( + arguments, + stdin=io.StringIO(), + stdout=stdout, + stderr=stderr, + cwd=root, + ) + return code, stdout.getvalue(), stderr.getvalue() + + +def _feature(root: Path) -> Path: + code, _, stderr = _invoke( + root, + ["add", "safe", "--starter", "cpp", "--skip-install", "--yes"], + ) + assert code == 0, stderr + return root / "local_modules/safe" + + +@pytest.mark.parametrize( + ("replacement", "expected"), + [ + ("{", "invalid JSON at line 1"), + ({"schema_version": 99}, "unsupported feature manifest schema 99"), + ({"kind": "something_else"}, "kind must be 'supernote_feature'"), + ({"public_name": None}, "public_name must be a non-empty string"), + ], +) +def test_invalid_feature_metadata_names_file_and_reports_preflight_error( + tmp_path: Path, + replacement: str | dict[str, object], + expected: str, +): + root = _plugin(tmp_path) + feature = _feature(root) + metadata = feature / ".supernote-module.json" + if isinstance(replacement, str): + metadata.write_text(replacement, encoding="utf-8") + else: + value = json.loads(metadata.read_text(encoding="utf-8")) + value.update(replacement) + metadata.write_text(json.dumps(value) + "\n", encoding="utf-8") + + code, _, stderr = _invoke(root, ["validate", "--all"]) + + assert code == 1 + assert str(metadata) in stderr + assert expected in stderr + assert "Internal error" not in stderr + assert "report the resulting traceback" not in stderr + + +def test_wrong_kind_json_result_is_not_silently_treated_as_no_features(tmp_path: Path): + root = _plugin(tmp_path) + metadata = _feature(root) / ".supernote-module.json" + value = json.loads(metadata.read_text(encoding="utf-8")) + value["kind"] = "legacy_module" + metadata.write_text(json.dumps(value) + "\n", encoding="utf-8") + stdout = io.StringIO() + + code = main( + ["--json", "validate", "--all"], + stdin=io.StringIO(), + stdout=stdout, + stderr=io.StringIO(), + cwd=root, + ) + + result = json.loads(stdout.getvalue()) + assert code == 1 + assert result["error"]["kind"] == "invalid_metadata" + assert result["error"]["phase"] == "preflight" + assert str(metadata) in result["error"]["message"] + + +def test_escaping_managed_feature_symlink_is_rejected_without_following_it( + tmp_path: Path, +): + plugin_root = tmp_path / "plugin" + plugin_root.mkdir() + root = _plugin(plugin_root) + feature = _feature(root) + outside = tmp_path / "outside-feature" + feature.rename(outside) + feature.symlink_to(outside, target_is_directory=True) + sentinel = outside / "sentinel.txt" + sentinel.write_text("outside stays untouched\n", encoding="utf-8") + + code, _, stderr = _invoke(root, ["update", "safe", "--skip-install", "--yes"]) + + assert code == 2 + assert "target resolves outside the Supernote plugin" in stderr + assert f"managed feature path {feature}" in stderr + assert str(outside) in stderr + assert sentinel.read_text(encoding="utf-8") == "outside stays untouched\n" + + +def test_marked_cpp_boundary_error_has_source_preflight_classification(tmp_path: Path): + root = _plugin(tmp_path) + feature = _feature(root) + source = feature / "android/src/main/cpp/Safe.cpp" + source.write_text( + "// @SupernotePluginExport\n" + "std::int32_t * invalid() { return nullptr; }\n", + encoding="utf-8", + ) + stdout = io.StringIO() + + code = main( + ["--json", "update", "safe", "--skip-install", "--yes"], + stdin=io.StringIO(), + stdout=stdout, + stderr=io.StringIO(), + cwd=root, + ) + + result = json.loads(stdout.getvalue()) + assert code == 1 + assert result["error"]["kind"] == "invalid_source" + assert result["error"]["phase"] == "preflight" + assert "android/src/main/cpp/Safe.cpp:2" in result["error"]["message"] + assert "raw pointers are not supported" in result["error"]["message"] diff --git a/tests/test_guided_spec.py b/tests/test_guided_spec.py index 88da7c5..518f6c9 100644 --- a/tests/test_guided_spec.py +++ b/tests/test_guided_spec.py @@ -12,6 +12,11 @@ def isatty(self) -> bool: return True +class InterruptingTty(TtyStringIO): + def readline(self, *args, **kwargs) -> str: + raise KeyboardInterrupt + + class BrokenOutput(io.StringIO): def write(self, value: str) -> int: raise BrokenPipeError @@ -81,6 +86,20 @@ def test_plain_main_menu_exit_has_no_final_output(tmp_path: Path): assert "Supernote Module Generator" in stderr.getvalue() +def test_root_interactive_interrupt_exits_cleanly(tmp_path: Path): + root = plugin(tmp_path) + stdin = InterruptingTty() + stdout = TtyStringIO() + stderr = TtyStringIO() + + code = main([], stdin=stdin, stdout=stdout, stderr=stderr, cwd=root) + + assert code == 130 + assert stdout.getvalue() == "Operation cancelled.\n" + assert "Traceback" not in stdout.getvalue() + assert "Traceback" not in stderr.getvalue() + + def test_guided_add_suggestions_are_editable_without_a_customize_gate(tmp_path: Path): root = plugin(tmp_path) stdin = TtyStringIO( diff --git a/tests/test_jvm_manifest_projection.py b/tests/test_jvm_manifest_projection.py index 8a017e9..a6d9ccc 100644 --- a/tests/test_jvm_manifest_projection.py +++ b/tests/test_jvm_manifest_projection.py @@ -284,6 +284,16 @@ def test_blocking_jvm_async_route_uses_shared_worker_and_owned_values(): module_name="Document", ) + assert '"unknown Kotlin/Java implementation failure"' in source + assert '"Kotlin/Java implementation failed"' in source + assert "implementation_exception_message" in source + assert "catch (const JvmImplementationFailure &error)" in source + assert '"getMessage"' in source + assert "class LocalReference" in source + assert "DeleteLocalRef" in source + assert "ExceptionOccurred" in source + assert '"unknown C++ implementation failure"' not in source + assert 'getPropertyAsFunction(runtime, "Promise")' in source assert "process_services().workers().submit" in source assert "auto implementation_feature = weak_feature.lock()" in source diff --git a/tests/test_operation_lock.py b/tests/test_operation_lock.py new file mode 100644 index 0000000..ea9fce5 --- /dev/null +++ b/tests/test_operation_lock.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import io +import json +from pathlib import Path +import threading + +import pytest + +from supernote_module_generator.cli import main +from supernote_module_generator.feature_cli_operations import FeatureCliOperationService +from supernote_module_generator.operation_lock import ( + PluginBusyError, + plugin_operation_lock, +) +from supernote_module_generator.transaction import JOURNAL_NAME, Transaction + + +def plugin(tmp_path: Path) -> Path: + (tmp_path / "android/app").mkdir(parents=True) + (tmp_path / "PluginConfig.json").write_text("{}\n", encoding="utf-8") + (tmp_path / "package.json").write_text( + json.dumps({"name": "fixture", "dependencies": {}}) + "\n", + encoding="utf-8", + ) + (tmp_path / "android/settings.gradle").write_text( + "include ':app'\n", encoding="utf-8" + ) + (tmp_path / "android/app/build.gradle").write_text( + "plugins {}\n", encoding="utf-8" + ) + return tmp_path + + +def invoke(root: Path, arguments: list[str]): + stdout = io.StringIO() + stderr = io.StringIO() + code = main(arguments, stdin=io.StringIO(), stdout=stdout, stderr=stderr, cwd=root) + return code, stdout.getvalue(), stderr.getvalue() + + +def test_plugin_directory_lock_is_nonblocking_and_leaves_no_artifact(tmp_path: Path): + root = plugin(tmp_path) + + with plugin_operation_lock(root): + with pytest.raises(PluginBusyError, match="already running"): + with plugin_operation_lock(root): + pass + + assert not list(root.glob("*lock*")) + + +def test_overlapping_cli_command_fails_cleanly_before_mutation(tmp_path: Path): + root = plugin(tmp_path) + before = (root / "package.json").read_bytes() + + with plugin_operation_lock(root): + code, _, stderr = invoke( + root, + ["add", "blocked", "--starter", "cpp", "--skip-install", "--yes"], + ) + + assert code == 2 + assert "Another supernote-module command is already running" in stderr + assert (root / "package.json").read_bytes() == before + assert not (root / "local_modules/blocked").exists() + assert not (root / JOURNAL_NAME).exists() + assert not list(root.glob(".supernote-module-transaction-*")) + + +def test_two_overlapping_add_commands_have_one_clean_winner( + tmp_path: Path, monkeypatch +): + root = plugin(tmp_path) + entered = threading.Event() + release = threading.Event() + original = FeatureCliOperationService._validate_add + + def blocking_preflight(self, decisions): + original(self, decisions) + if decisions.package_name == "first": + entered.set() + assert release.wait(timeout=5) + + monkeypatch.setattr(FeatureCliOperationService, "_validate_add", blocking_preflight) + first_result = [] + first = threading.Thread( + target=lambda: first_result.append( + invoke( + root, + ["add", "first", "--starter", "cpp", "--skip-install", "--yes"], + ) + ) + ) + first.start() + assert entered.wait(timeout=5) + try: + second = invoke( + root, + ["add", "second", "--starter", "cpp", "--skip-install", "--yes"], + ) + finally: + release.set() + first.join(timeout=5) + + assert not first.is_alive() + assert first_result and first_result[0][0] == 0 + assert second[0] == 2 + assert "Another supernote-module command is already running" in second[2] + assert (root / "local_modules/first").is_dir() + assert not (root / "local_modules/second").exists() + assert not (root / JOURNAL_NAME).exists() + assert not list(root.glob(".supernote-module-transaction-*")) + + +def test_busy_command_does_not_recover_an_active_transaction(tmp_path: Path): + root = plugin(tmp_path) + package = root / "package.json" + transaction = Transaction(root, "add", ["active"]) + transaction.snapshot([package]) + package.write_text('{"active":true}\n', encoding="utf-8") + transaction.mark_write() + + with plugin_operation_lock(root): + code, _, stderr = invoke(root, ["validate", "--all"]) + + assert code == 2 + assert "Another supernote-module command is already running" in stderr + assert package.read_text(encoding="utf-8") == '{"active":true}\n' + assert (root / JOURNAL_NAME).is_file() + assert transaction.rollback().status == "completed" diff --git a/tests/test_operations_spec.py b/tests/test_operations_spec.py index 8d6591f..2326500 100644 --- a/tests/test_operations_spec.py +++ b/tests/test_operations_spec.py @@ -11,6 +11,8 @@ from supernote_module_generator.errors import SubprocessFailure from supernote_module_generator.feature_cli_operations import FeatureCliOperationService from supernote_module_generator.feature_workflows import FeatureDecisionCollector +from supernote_module_generator.plugin_build_integration import set_runtime_wiring +from supernote_module_generator.transaction import Transaction, recover_pending def plugin(tmp_path: Path, *, npm_lock: bool = False, yarn_lock: bool = False) -> Path: @@ -40,6 +42,22 @@ def invoke(root: Path, arguments: list[str]): return code, stdout.getvalue(), stderr.getvalue() +def main_application(root: Path) -> Path: + source = ( + root + / "android/app/src/main/java/com/example/fixture/MainApplication.kt" + ) + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text( + "fun getPackages() =\n" + " PackageList(this).packages.apply {\n" + " add(ExistingPackage())\n" + " }\n", + encoding="utf-8", + ) + return source + + @pytest.mark.parametrize( ("arguments", "native", "jvm"), [ @@ -235,6 +253,130 @@ def fail_once(self, command, *, phase): assert json.loads((root / "package.json").read_text())["dependencies"]["safe"] +@pytest.mark.parametrize("command", ["add", "remove"]) +@pytest.mark.parametrize("interrupted", [False, True]) +def test_failed_or_interrupted_dependency_refresh_exactly_restores_main_application( + tmp_path: Path, monkeypatch, command: str, interrupted: bool +): + root = plugin(tmp_path, npm_lock=True) + application = main_application(root) + if command == "remove": + assert invoke( + root, + ["add", "safe", "--starter", "cpp", "--skip-install", "--yes"], + )[0] == 0 + before = application.read_bytes() + + monkeypatch.setattr( + FeatureCliOperationService, "_health_check_manager", lambda *args: None + ) + + def fail_dependency(self, invocation, *, phase): + if interrupted: + raise KeyboardInterrupt + raise SubprocessFailure("forced install failure", phase=phase) + + monkeypatch.setattr(FeatureCliOperationService, "_run", fail_dependency) + monkeypatch.setattr(FeatureCliOperationService, "_reconcile", lambda *args: True) + arguments = ( + ["add", "safe", "--starter", "cpp", "--package-manager", "npm", "--yes"] + if command == "add" + else ["remove", "safe", "--package-manager", "npm", "--yes"] + ) + + code, _, stderr = invoke(root, arguments) + + 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") == ( + expected_marker_count * 2 + ) + assert not (root / ".supernote-module-transaction.json").exists() + assert not list(root.glob(".supernote-module-transaction-*")) + + +def test_partial_then_startup_recovery_preserves_restored_main_application( + tmp_path: Path, +): + root = plugin(tmp_path) + application = main_application(root) + before = application.read_bytes() + service = FeatureCliOperationService(root, renderer=None) # type: ignore[arg-type] + + transaction = Transaction(root, "add", ["safe"]) + service._snapshot_operation(transaction, [root / "local_modules/safe"]) + set_runtime_wiring(root, enabled=True) + transaction.mark_external(["npm", "install"]) + + first = transaction.rollback(reconcile=lambda _: False) + assert first.status == "partial" + assert application.read_bytes() == before + assert (root / ".supernote-module-transaction.json").is_file() + + outcome = recover_pending(root, reconcile=lambda _: True) + + assert outcome.rollback.status == "completed" + assert application.read_bytes() == before + + +def test_empty_validation_rejects_leftover_v2_runtime_and_package_wiring( + tmp_path: Path, +): + root = plugin(tmp_path) + application = main_application(root) + set_runtime_wiring(root, enabled=True) + + 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 + + +def test_empty_validation_rejects_leftover_package_registration_alone( + tmp_path: Path, +): + root = plugin(tmp_path) + application = main_application(root) + settings = (root / "android/settings.gradle").read_bytes() + app_build = (root / "android/app/build.gradle").read_bytes() + set_runtime_wiring(root, enabled=True) + (root / "android/settings.gradle").write_bytes(settings) + (root / "android/app/build.gradle").write_bytes(app_build) + + 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 + + +def test_feature_validation_rejects_missing_main_application_registration( + tmp_path: Path, +): + root = plugin(tmp_path) + application = main_application(root) + assert invoke( + root, + ["add", "safe", "--starter", "cpp", "--skip-install", "--yes"], + )[0] == 0 + settings = (root / "android/settings.gradle").read_bytes() + app_build = (root / "android/app/build.gradle").read_bytes() + set_runtime_wiring(root, enabled=False) + (root / "android/settings.gradle").write_bytes(settings) + (root / "android/app/build.gradle").write_bytes(app_build) + link = root / "node_modules/safe" + link.parent.mkdir() + link.symlink_to(root / "local_modules/safe", target_is_directory=True) + + 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() + + def test_remove_preserves_build_outputs_unless_cleanup_is_explicit(tmp_path: Path): root = plugin(tmp_path) for name in ("preserve", "cleanup"): diff --git a/tests/test_plugin_runtime_codegen.py b/tests/test_plugin_runtime_codegen.py index bcda8c1..2e5c52f 100644 --- a/tests/test_plugin_runtime_codegen.py +++ b/tests/test_plugin_runtime_codegen.py @@ -98,6 +98,9 @@ def test_generates_one_compiled_runtime_component_for_all_features(tmp_path: Pat assert "local_modules/@local/alpha/android/src/main/java" in gradle assert "local_modules/@local/beta/android/src/main/java" in gradle assert "supernoteFeatureRoots" in gradle + assert "catch (_: SupernoteSourceDiagnostic)" in processor + assert "throw SupernoteSourceDiagnostic()" in processor + assert "throw IllegalArgumentException(message)" not in processor assert "schema_version" in processor assert "getSymbolsWithAnnotation" in processor assert "Kotlin suspend requires explicit SupernotePluginAsync" in processor