diff --git a/.gitattributes b/.gitattributes
index 9dec809..bdab6cb 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,3 +2,6 @@
/AGENTS.md export-ignore
/CLAUDE.md export-ignore
/.github export-ignore
+/phpunit.xml export-ignore
+/phpstan.neon export-ignore
+/pint.json export-ignore
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..029e094
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+version: 2
+updates:
+ - package-ecosystem: composer
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..ff27d52
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,14 @@
+## What changes and why
+
+Describe the problem and the smallest complete solution.
+
+## Evidence
+
+List the commands run and their results. For native changes, include the real
+compiled app, crash/relaunch results and screenshots, identifying the platform
+and whether it was a simulator or physical device. CI does not prove native
+crash capture.
+
+## Compatibility
+
+Describe public API, event format or minimum-version changes, if any.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..9ef56bc
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,65 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ COMPOSER_ROOT_VERSION: 0.2.x-dev
+
+jobs:
+ quality:
+ name: Quality
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ persist-credentials: false
+ - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2
+ with:
+ php-version: '8.4'
+ extensions: mbstring, pdo_sqlite
+ tools: composer:v2
+ coverage: none
+ - run: composer validate --strict
+ - run: composer update --no-interaction --prefer-dist --no-progress
+ - run: composer audit
+ - run: composer test:lint
+ - run: composer test:types
+ - run: composer test:type-coverage
+
+ tests:
+ name: Tests (PHP ${{ matrix.php }}, Laravel ${{ matrix.laravel }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.4', '8.5']
+ laravel: ['12', '13']
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ persist-credentials: false
+ - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: mbstring, pdo_sqlite
+ tools: composer:v2
+ coverage: none
+ - name: Select Laravel version
+ env:
+ LARAVEL_VERSION: ${{ matrix.laravel }}
+ run: composer require --dev "orchestra/testbench:^$((LARAVEL_VERSION - 2)).0" --no-update --no-interaction
+ - run: composer update --no-interaction --prefer-dist --no-progress
+ - run: composer test:unit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7bcc21a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/vendor/
+/composer.lock
+/.phpunit.cache/
+/.phpunit.result.cache
+/.phpstan-cache/
+/.php-cs-fixer.cache
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..0d1e3c2
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,58 @@
+# Contributing
+
+Use PHP 8.4 or newer and Composer 2. Open an issue before a substantial feature
+or event-format change. Include SDK, Laravel and NativePHP versions, a minimal
+reproduction and redacted logs. Never publish credentials or customer reports.
+Report vulnerabilities privately to a repository maintainer.
+
+Shared capture, redaction and delivery belong in `native-core`. Platform hooks
+belong in `native-desktop` or `native-mobile`. Keep changes focused, classes
+final, types explicit and files within 300 lines. Reporting must never replace
+the original application error.
+
+## Run the checks
+
+```sh
+COMPOSER_ROOT_VERSION=0.2.x-dev composer install
+composer test
+```
+
+The root version identifies the development checkout as part of the 0.2 line.
+Composer will resolve a lock file locally; libraries do not commit it. Tests use
+Orchestra Testbench and fake HTTP, without the private server, production keys
+or a running NativePHP app. `composer test` runs Pint, PHPStan, 100% PHP type coverage and Pest. GitHub
+also validates Composer metadata, audits dependencies and runs Pest on PHP 8.4
+and 8.5 with Laravel 12 and 13.
+
+For one Laravel version, select its matching Testbench before installing:
+
+```sh
+composer require --dev 'orchestra/testbench:^10.0' --no-update
+COMPOSER_ROOT_VERSION=0.2.x-dev composer update
+composer test
+```
+
+Testbench 10 tests Laravel 12; Testbench 11 tests Laravel 13. Do not commit a local matrix selection to `composer.json`.
+
+The runtime dependency constraint still accepts Laravel 11, but CI does not
+verify it: Composer blocks its current releases due to security advisories.
+No advisory ignores or disabled security gates are used.
+
+Native reporting changes also require a compiled Release app, a real crash and
+relaunch, receiver assertions and screenshots. Identify the platform and
+whether evidence comes from a simulator or physical device. PHP CI does not
+compile Swift or Kotlin or establish native crash capture.
+
+## Submit a pull request
+
+Explain the failing behavior, the smallest complete fix and the commands you
+actually ran. Include a regression test, compatibility changes and remaining
+limits. Use a branch and open a pull request against `main`. Merging requires
+one approving review from another maintainer, resolved conversations and all
+required CI checks passing against the latest `main`. New commits dismiss old
+approvals. Force pushes and deletion of `main` are blocked.
+
+Workflow actions are pinned to full commit hashes and updated by Dependabot.
+Keep workflow permissions read-only and never expose secrets to forked code or
+use `pull_request_target` to execute a contributor's checkout. Published `v*`
+tags cannot be rewritten or deleted. Fix a release with a new version.
diff --git a/composer.json b/composer.json
index 5969e82..dfe3156 100644
--- a/composer.json
+++ b/composer.json
@@ -43,5 +43,36 @@
"docs": "https://native.watch/docs/install-mobile",
"issues": "https://github.com/zinxan/native.mobile/issues",
"source": "https://github.com/zinxan/native.mobile"
+ },
+ "require-dev": {
+ "larastan/larastan": "^3.9",
+ "laravel/pint": "^1.27",
+ "orchestra/testbench": "^10.0|^11.0",
+ "pestphp/pest": "^3.8|^4.0|^5.0",
+ "pestphp/pest-plugin-laravel": "^3.0|^4.0|^5.0",
+ "pestphp/pest-plugin-type-coverage": "^3.0|^4.0|^5.0"
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "ZXN\\NativeWatch\\Mobile\\Tests\\": "tests/"
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "pestphp/pest-plugin": true
+ },
+ "sort-packages": true
+ },
+ "scripts": {
+ "test": [
+ "@test:lint",
+ "@test:types",
+ "@test:type-coverage",
+ "@test:unit"
+ ],
+ "test:lint": "pint --test",
+ "test:types": "phpstan analyse --memory-limit=1G",
+ "test:unit": "pest --compact",
+ "test:type-coverage": "pest --type-coverage --min=100"
}
}
diff --git a/docs/review/sdk-ci.md b/docs/review/sdk-ci.md
new file mode 100644
index 0000000..05eccee
--- /dev/null
+++ b/docs/review/sdk-ci.md
@@ -0,0 +1,30 @@
+# Standalone SDK CI evidence
+
+Verified on 3 November 2026 with PHP 8.4.27 and Laravel 13.
+
+- `composer validate --strict`: passed.
+- `composer audit`: no advisories or abandoned dependencies.
+- `composer test`: Pint and PHPStan level 7 passed, 100% PHP type coverage,
+ 40 Pest tests and 115 assertions passed.
+- `git diff --check`: passed.
+
+The workflow repeats Pest across PHP 8.4/8.5 and Laravel 12/13. Its Quality
+job validates Composer, audits dependencies and runs lint, static analysis and
+type coverage. Every job runs for every pull request, without path filters.
+Both Actions are pinned to full commit hashes; checkout credentials are not
+persisted, the token is read-only and the workflow uses no repository secrets.
+
+The public suite runs against Orchestra Testbench rather than the private
+NativeWatch server. Server acceptance and end-to-end native crash behavior
+still require their respective integration checks. This CI does not compile
+or execute Swift, Kotlin, an iOS simulator or an Android device.
+
+The first standalone run passed 39 tests and failed the subprocess compatibility
+check because it loaded the private server bootstrap. It now starts a fresh
+Testbench application in the subprocess and still checks that no NativePHP
+runtime is installed. All 40 existing tests execute, without skips.
+
+The initial GitHub Laravel 11 matrix jobs were blocked by Composer security
+advisories, including PKSA-b41t-wbzk-9q51 and PKSA-d5tc-s1qs-h781. CI covers
+Laravel 12 and 13 without disabling advisory blocking. Runtime constraints are
+unchanged, but this pipeline does not establish Laravel 11 compatibility.
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..d621d98
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,14 @@
+includes:
+ - vendor/larastan/larastan/extension.neon
+
+parameters:
+ level: 7
+ paths:
+ - src
+ - tests/Fixtures/NativeUiTypeCheck.php
+ tmpDir: .phpstan-cache
+ dynamicConstantNames:
+ - FILTER_FLAG_GLOBAL_RANGE
+ scanFiles:
+ - stubs/nativephp.php
+ - stubs/native-ui.stub
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..7efc43c
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ tests
+
+
+
+ src
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..d4c8c61
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,58 @@
+{
+ "preset": "laravel",
+ "rules": {
+ "array_push": true,
+ "backtick_to_shell_exec": true,
+ "date_time_immutable": true,
+ "declare_strict_types": true,
+ "lowercase_keywords": true,
+ "lowercase_static_reference": true,
+ "final_class": true,
+ "final_internal_class": true,
+ "final_public_method_for_abstract_class": true,
+ "fully_qualified_strict_types": true,
+ "global_namespace_import": {
+ "import_classes": true,
+ "import_constants": true,
+ "import_functions": true
+ },
+ "mb_str_functions": true,
+ "modernize_types_casting": true,
+ "new_with_parentheses": false,
+ "no_superfluous_elseif": true,
+ "no_useless_else": true,
+ "no_multiple_statements_per_line": true,
+ "ordered_class_elements": {
+ "order": [
+ "use_trait",
+ "case",
+ "constant",
+ "constant_public",
+ "constant_protected",
+ "constant_private",
+ "property_public",
+ "property_protected",
+ "property_private",
+ "construct",
+ "destruct",
+ "magic",
+ "phpunit",
+ "method_abstract",
+ "method_public_static",
+ "method_public",
+ "method_protected_static",
+ "method_protected",
+ "method_private_static",
+ "method_private"
+ ],
+ "sort_algorithm": "none"
+ },
+ "ordered_interfaces": true,
+ "ordered_traits": true,
+ "protected_to_private": true,
+ "self_accessor": true,
+ "self_static_accessor": true,
+ "strict_comparison": true,
+ "visibility_required": true
+ }
+}
diff --git a/tests/NativeUiCompatibilityTest.php b/tests/NativeUiCompatibilityTest.php
index 225e4f3..57f97a7 100644
--- a/tests/NativeUiCompatibilityTest.php
+++ b/tests/NativeUiCompatibilityTest.php
@@ -19,15 +19,15 @@
}
}, true, true);
if (class_exists('Native\\Mobile\\Runtime') || class_exists('Native\\Mobile\\Edge\\NativeComponent')) exit(1);
-$app = require $argv[3];
+$app = Orchestra\Testbench\Foundation\Application::create();
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$app->make('config')->set([
'nativewatch.enabled' => true,
'nativewatch.key' => 'nw_compatibility_test',
'nativewatch.url' => 'https://zxguard.test',
'nativewatch.require_consent' => true,
- 'nativewatch.consent.path' => $argv[4].'/missing-consent.json',
- 'nativewatch.outbox.path' => $argv[4].'/outbox',
+ 'nativewatch.consent.path' => $argv[3].'/missing-consent.json',
+ 'nativewatch.outbox.path' => $argv[3].'/outbox',
]);
$app->register(ZXN\NativeWatch\Mobile\MobileServiceProvider::class);
if (! trait_exists(ZXN\NativeWatch\Mobile\Concerns\ReportsNativeUiErrors::class)) exit(2);
@@ -36,9 +36,8 @@
$process = new Process([
PHP_BINARY, '-r', $script,
- base_path('vendor/autoload.php'),
+ dirname(__DIR__).'/vendor/autoload.php',
dirname(__DIR__).'/src',
- base_path('bootstrap/app.php'),
temporaryOutbox(),
], base_path(), ['JUMP_BRIDGE_PORT' => false]);
$process->mustRun();
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..a2b50f7
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,55 @@
+extend(TestCase::class)->in(__DIR__);
+
+function temporaryOutbox(): string
+{
+ $path = sys_get_temp_dir().'/nativewatch-tests/'.bin2hex(random_bytes(6));
+ mkdir($path, 0755, true);
+
+ return $path;
+}
+
+/**
+ * Get the activity entries that the SDK sent, after the app finished its work.
+ *
+ * @return list>
+ */
+function sentActivities(?string $signal = null): array
+{
+ app()->terminate();
+
+ $entries = [];
+
+ foreach (Illuminate\Support\Facades\Http::recorded() as [$request]) {
+ if (str_ends_with($request->url(), '/api/v1/activities')) {
+ array_push($entries, ...$request['entries']);
+ }
+ }
+
+ return array_values(array_filter($entries, fn (array $entry): bool => $signal === null || $entry['signal'] === $signal));
+}
+
+/**
+ * Get the events that the SDK sent, after the app finished its work.
+ *
+ * @return list>
+ */
+function sentOccurrences(): array
+{
+ app()->terminate();
+
+ $events = [];
+
+ foreach (Illuminate\Support\Facades\Http::recorded() as [$request]) {
+ if (str_ends_with($request->url(), '/api/v1/occurrences')) {
+ $events[] = $request->data();
+ }
+ }
+
+ return $events;
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..b266f0f
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,26 @@
+set([
+ 'app.key' => 'base64:'.base64_encode(str_repeat('a', 32)),
+ 'cache.default' => 'array',
+ 'database.default' => 'testing',
+ 'database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:'],
+ 'mail.default' => 'array',
+ 'queue.default' => 'sync',
+ 'session.driver' => 'array',
+ 'nativewatch.key' => null,
+ ]);
+ }
+}