Merge pull request #1537 from selfxyz/staging

Release to Production - 2025-12-28
This commit is contained in:
Justin Hernandez
2025-12-28 16:45:44 -08:00
committed by GitHub
42 changed files with 3728 additions and 1883 deletions

View File

@@ -136,8 +136,9 @@ app/android/android-passport-nfc-reader/app/src/main/assets/tessdata/
# Development & Testing
# ========================================
# Test coverage
# Test coverage (but allow docs/coverage for docstring reports)
**/coverage/
!docs/coverage/
**/.nyc_output/
# Test files (optional - you might want AI to see tests)
@@ -261,6 +262,9 @@ circuits/ptau/
!metro.config.*
!tamagui.config.ts
# Allow docstring coverage reports (tracked in git for coverage tracking)
!docs/coverage/*.json
# Ensure source code is accessible
!**/*.ts
!**/*.tsx

10
.github/actionlint.yaml vendored Normal file
View File

@@ -0,0 +1,10 @@
# Actionlint configuration to register custom runner labels
# This prevents actionlint warnings about unrecognized runner labels
# Custom runner labels used in this repository
labels:
# Namespace-managed Apple Silicon runners
- namespace-profile-apple-silicon-6cpu
# High-memory runners for circuit compilation
- "128ram"

View File

@@ -59,7 +59,7 @@ runs:
# Configure Yarn
corepack enable
yarn set version 4.6.0
yarn set version 4.12.0
echo "📦 Installing JavaScript dependencies with strict lock file..."
if ! yarn install --immutable --inline-builds; then

View File

@@ -15,7 +15,7 @@ runs:
shell: bash
run: |
corepack enable
corepack prepare yarn@4.6.0 --activate
corepack prepare yarn@4.12.0 --activate
# Ensure we're using the correct version
yarn --version

View File

@@ -42,7 +42,7 @@ jobs:
- name: Setup Corepack
run: |
corepack enable
corepack prepare yarn@4.6.0 --activate
corepack prepare yarn@4.12.0 --activate
- name: Restore build artifacts
id: build-cache
uses: actions/cache/restore@v4
@@ -71,7 +71,7 @@ jobs:
- name: Setup Corepack
run: |
corepack enable
corepack prepare yarn@4.6.0 --activate
corepack prepare yarn@4.12.0 --activate
- name: Restore build artifacts
id: build-cache
uses: actions/cache/restore@v4
@@ -100,7 +100,7 @@ jobs:
- name: Setup Corepack
run: |
corepack enable
corepack prepare yarn@4.6.0 --activate
corepack prepare yarn@4.12.0 --activate
- name: Restore build artifacts
id: build-cache
uses: actions/cache/restore@v4

View File

@@ -8,7 +8,21 @@ env:
NODE_ENV: "production"
on:
push:
branches:
- dev
- staging
- main
paths:
- "app/**"
- "packages/mobile-sdk-alpha/**"
- ".github/workflows/mobile-bundle-analysis.yml"
- ".github/actions/**"
pull_request:
branches:
- dev
- staging
- main
paths:
- "app/**"
- "packages/mobile-sdk-alpha/**"
@@ -18,7 +32,7 @@ on:
jobs:
analyze-android:
runs-on: macos-latest-large
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Read and sanitize Node.js version
@@ -83,7 +97,9 @@ jobs:
working-directory: ./app
analyze-ios:
runs-on: macos-latest-large
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
# runs-on: macos-latest-large
runs-on: namespace-profile-apple-silicon-6cpu
steps:
- uses: actions/checkout@v6
- name: Read and sanitize Node.js version
@@ -122,6 +138,21 @@ jobs:
with:
path: app/ios/Pods
lockfile: app/ios/Podfile.lock
- name: Check Java installation
run: |
echo "INSTALL_JAVA=false" >> "$GITHUB_ENV"
if command -v java &> /dev/null && java -version &> /dev/null; then
echo "Java already installed: $(java -version 2>&1 | head -n 1)"
else
echo "Java not found or not working, will install..."
echo "INSTALL_JAVA=true" >> "$GITHUB_ENV"
fi
- name: Setup Java environment
if: env.INSTALL_JAVA == 'true'
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token

View File

@@ -58,8 +58,8 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Activate Yarn 4.6.0
run: corepack prepare yarn@4.6.0 --activate
- name: Activate Yarn 4.12.0
run: corepack prepare yarn@4.12.0 --activate
- name: Cache Yarn
uses: ./.github/actions/cache-yarn
with:
@@ -116,8 +116,8 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Activate Yarn 4.6.0
run: corepack prepare yarn@4.6.0 --activate
- name: Activate Yarn 4.12.0
run: corepack prepare yarn@4.12.0 --activate
- name: Cache Yarn
uses: ./.github/actions/cache-yarn
with:
@@ -203,7 +203,10 @@ jobs:
yarn test:ci
working-directory: ./app
build-ios:
runs-on: macos-latest-large
# This is mostly covered in mobile-e2e.yml so we don't need to run it here frequently
if: github.event_name == 'workflow_dispatch'
# runs-on: macos-latest-large
runs-on: namespace-profile-apple-silicon-6cpu
needs: build-deps
timeout-minutes: 60
env:
@@ -231,8 +234,8 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Activate Yarn 4.6.0
run: corepack prepare yarn@4.6.0 --activate
- name: Activate Yarn 4.12.0
run: corepack prepare yarn@4.12.0 --activate
- name: Set up Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
@@ -251,6 +254,21 @@ jobs:
echo "Xcode path:"
xcode-select -p
- name: Check Java installation
run: |
echo "INSTALL_JAVA=false" >> "$GITHUB_ENV"
if command -v java &> /dev/null && java -version &> /dev/null; then
echo "Java already installed: $(java -version 2>&1 | head -n 1)"
else
echo "Java not found or not working, will install..."
echo "INSTALL_JAVA=true" >> "$GITHUB_ENV"
fi
- name: Setup Java environment
if: env.INSTALL_JAVA == 'true'
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
@@ -267,8 +285,7 @@ jobs:
- name: Cache Ruby gems
uses: ./.github/actions/cache-bundler
with:
# TODO(jcortejoso): Confirm the path of the bundle cache
path: app/ios/vendor/bundle
path: app/vendor/bundle
lock-file: app/Gemfile.lock
cache-version: ${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-ruby${{ env.RUBY_VERSION }}
- name: Cache Pods
@@ -297,6 +314,14 @@ jobs:
key: ${{ runner.os }}-xcode-index-${{ env.XCODE_VERSION }}-${{ hashFiles('app/ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-xcode-index-${{ env.XCODE_VERSION }}-
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token
id: github-token
with:
app-id: ${{ vars.GH_WORKFLOWS_CROSS_ACCESS_ID }}
private-key: ${{ secrets.GH_WORKFLOWS_CROSS_ACCESS_KEY }}
configure-netrc: "true"
- name: Install Mobile Dependencies
uses: ./.github/actions/yarn-install
- name: Cache Built Dependencies
@@ -306,6 +331,8 @@ jobs:
cache-version: ${{ env.GH_CACHE_VERSION }}-${{ env.NODE_VERSION_SANITIZED }}
- name: Build dependencies (cache miss)
# if: steps.built-deps.outputs.cache-hit != 'true'
env:
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token || '' }}
run: |
echo "Cache miss for built dependencies. Building now..."
yarn workspace @selfxyz/mobile-app run build:deps
@@ -315,14 +342,6 @@ jobs:
bundle config set --local path 'vendor/bundle'
bundle install --jobs 4 --retry 3
working-directory: ./app
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token
id: github-token
with:
app-id: ${{ vars.GH_WORKFLOWS_CROSS_ACCESS_ID }}
private-key: ${{ secrets.GH_WORKFLOWS_CROSS_ACCESS_KEY }}
configure-netrc: "true"
- name: Install iOS Dependencies
uses: nick-fields/retry@v3
with:
@@ -405,6 +424,8 @@ jobs:
build-android:
runs-on: ubuntu-latest
needs: build-deps
# This is mostly covered in mobile-e2e.yml so we don't need to run it here frequently
if: github.event_name == 'workflow_dispatch'
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
@@ -427,8 +448,8 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Activate Yarn 4.6.0
run: corepack prepare yarn@4.6.0 --activate
- name: Activate Yarn 4.12.0
run: corepack prepare yarn@4.12.0 --activate
- name: Cache Yarn
uses: ./.github/actions/cache-yarn
with:

View File

@@ -265,7 +265,8 @@ jobs:
build-ios:
needs: [bump-version]
runs-on: macos-latest-large
# runs-on: macos-latest-large
runs-on: namespace-profile-apple-silicon-6cpu
permissions:
contents: read
actions: write
@@ -430,6 +431,21 @@ jobs:
fi
echo "✅ Lock files exist"
- name: Check Java installation
run: |
echo "INSTALL_JAVA=false" >> "$GITHUB_ENV"
if command -v java &> /dev/null && java -version &> /dev/null; then
echo "Java already installed: $(java -version 2>&1 | head -n 1)"
else
echo "Java not found or not working, will install..."
echo "INSTALL_JAVA=true" >> "$GITHUB_ENV"
fi
- name: Setup Java environment
if: env.INSTALL_JAVA == 'true'
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token

View File

@@ -17,6 +17,15 @@ env:
MAESTRO_VERSION: 1.41.0
on:
push:
branches:
- dev
- staging
- main
paths:
- "app/**"
- "packages/mobile-sdk-alpha/**"
- ".github/workflows/mobile-e2e.yml"
pull_request:
branches:
- dev
@@ -26,6 +35,7 @@ on:
- "app/**"
- "packages/mobile-sdk-alpha/**"
- ".github/workflows/mobile-e2e.yml"
workflow_dispatch:
jobs:
android-build-test:
@@ -55,7 +65,7 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable
- run: corepack prepare yarn@4.6.0 --activate
- run: corepack prepare yarn@4.12.0 --activate
- name: Compute .yarnrc.yml hash
id: yarnrc-hash
uses: ./.github/actions/yarnrc-hash
@@ -136,9 +146,9 @@ jobs:
- name: Build dependencies (outside emulator)
run: |
echo "Building dependencies..."
# Ensure Yarn 4.6.0 is active
# Ensure Yarn 4.12.0 is active
corepack enable
corepack prepare yarn@4.6.0 --activate
corepack prepare yarn@4.12.0 --activate
yarn workspace @selfxyz/mobile-app run build:deps || { echo "❌ Dependency build failed"; exit 1; }
echo "✅ Dependencies built successfully"
- name: Setup Android private modules
@@ -229,7 +239,8 @@ jobs:
e2e-ios:
timeout-minutes: 120
runs-on: macos-latest-large
# runs-on: macos-latest-large
runs-on: namespace-profile-apple-silicon-6cpu
concurrency:
group: ${{ github.workflow }}-ios-${{ github.ref }}
cancel-in-progress: true
@@ -258,7 +269,7 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable
- run: corepack prepare yarn@4.6.0 --activate
- run: corepack prepare yarn@4.12.0 --activate
- name: Compute .yarnrc.yml hash
id: yarnrc-hash
uses: ./.github/actions/yarnrc-hash
@@ -273,6 +284,21 @@ jobs:
- name: Toggle Yarn hardened mode for trusted PRs
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV
- name: Check Java installation
run: |
echo "INSTALL_JAVA=false" >> "$GITHUB_ENV"
if command -v java &> /dev/null && java -version &> /dev/null; then
echo "Java already installed: $(java -version 2>&1 | head -n 1)"
else
echo "Java not found or not working, will install..."
echo "INSTALL_JAVA=true" >> "$GITHUB_ENV"
fi
- name: Setup Java environment
if: env.INSTALL_JAVA == 'true'
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token
@@ -360,7 +386,19 @@ jobs:
- name: Verify iOS Runtime
run: |
echo "📱 Verifying iOS Runtime availability..."
echo "Available iOS runtimes:"
# Check simctl availability (simctl without args returns non-zero, so check if tool exists)
SIMCTL_PATH=$(xcrun -f simctl 2>/dev/null || echo "")
if [ -z "$SIMCTL_PATH" ] || [ ! -f "$SIMCTL_PATH" ]; then
echo "❌ simctl binary not found"
exit 1
fi
# Ensure simulator directories exist (required for Namespace runners)
mkdir -p "$HOME/Library/Developer/CoreSimulator/Devices"
mkdir -p "$HOME/Library/Developer/CoreSimulator/Caches"
echo "📱 Available iOS runtimes:"
xcrun simctl list runtimes | grep iOS
- name: Build dependencies (outside main flow)
run: |
@@ -383,6 +421,9 @@ jobs:
run: |
echo "Setting up iOS Simulator..."
# Ensure simulator directories exist
mkdir -p "$HOME/Library/Developer/CoreSimulator/Devices"
# First, check what simulators are actually available
echo "Available simulators:"
xcrun simctl list devices available || {
@@ -527,12 +568,16 @@ jobs:
fi
echo "Verifying app installation..."
if xcrun simctl get_app_container "$SIMULATOR_ID" "$IOS_BUNDLE_ID" app >/dev/null 2>&1; then
echo "✅ App successfully installed"
# get_app_container may fail with NSPOSIXErrorDomain if app isn't ready yet - handle gracefully
APP_CONTAINER_OUTPUT=$(xcrun simctl get_app_container "$SIMULATOR_ID" "$IOS_BUNDLE_ID" app 2>&1) || APP_CONTAINER_EXIT=$?
if [ -z "${APP_CONTAINER_EXIT:-}" ] && [ -n "$APP_CONTAINER_OUTPUT" ]; then
echo "✅ App successfully installed at: $APP_CONTAINER_OUTPUT"
else
echo " App installation verification failed"
exit 1
echo "⚠️ App installation verification returned exit code ${APP_CONTAINER_EXIT:-unknown} (may be expected)"
# Check if app appears in installed apps list as fallback
xcrun simctl listapps "$SIMULATOR_ID" 2>/dev/null | grep -i "$IOS_BUNDLE_ID" || echo "App not found in installed apps list"
fi
unset APP_CONTAINER_OUTPUT APP_CONTAINER_EXIT
echo "🚀 Testing app launch capability..."
xcrun simctl launch "$SIMULATOR_ID" "$IOS_BUNDLE_ID" || {
@@ -541,15 +586,46 @@ jobs:
echo "⏰ Checking simulator readiness..."
sleep 10
# Probe container as readiness check instead of listapps
# Final readiness check (suppress errors to avoid annotations)
xcrun simctl get_app_container "$SIMULATOR_ID" "$IOS_BUNDLE_ID" app >/dev/null 2>&1 || sleep 5
echo "Running Maestro tests..."
echo "🎭 Running Maestro tests..."
echo "Starting test execution..."
maestro test app/tests/e2e/launch.ios.flow.yaml --format junit --output app/maestro-results.xml || {
echo "Maestro test failed, but continuing to upload results..."
# Verify Maestro test file exists
if [ ! -f "app/tests/e2e/launch.ios.flow.yaml" ]; then
echo "❌ Maestro test file not found: app/tests/e2e/launch.ios.flow.yaml"
exit 1
}
fi
# Run Maestro with error handling for cleanup issues
# Note: Maestro may show NSPOSIXErrorDomain code=3 errors during cleanup when
# terminating the test runner app that's already terminated. This is harmless.
MAESTRO_OUTPUT=$(maestro test app/tests/e2e/launch.ios.flow.yaml --format junit --output app/maestro-results.xml 2>&1)
MAESTRO_EXIT_CODE=$?
# Check if tests actually passed (ignore cleanup errors)
if echo "$MAESTRO_OUTPUT" | grep -q "Flow Passed"; then
echo "✅ Maestro tests passed"
# Suppress harmless cleanup errors (NSPOSIXErrorDomain code=3)
if [ $MAESTRO_EXIT_CODE -ne 0 ] && echo "$MAESTRO_OUTPUT" | grep -q "NSPOSIXErrorDomain.*code=3.*terminate"; then
echo "⚠️ Maestro cleanup warning (harmless): Test runner termination error"
elif [ $MAESTRO_EXIT_CODE -ne 0 ]; then
echo "❌ Maestro test failed with exit code: $MAESTRO_EXIT_CODE"
exit 1
fi
elif echo "$MAESTRO_OUTPUT" | grep -q "Flow Failed"; then
echo "❌ Maestro tests failed"
exit 1
elif [ $MAESTRO_EXIT_CODE -ne 0 ]; then
# Check results file if exit code is non-zero
if [ -f "app/maestro-results.xml" ] && ! grep -q "<failure" app/maestro-results.xml; then
echo "✅ Tests passed (cleanup error caused non-zero exit)"
else
echo "❌ Maestro test failed"
exit 1
fi
fi
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4

View File

@@ -19,6 +19,15 @@ env:
E2E_TESTING: 1
on:
push:
branches:
- dev
- staging
- main
paths:
- "packages/mobile-sdk-demo/**"
- "packages/mobile-sdk-alpha/**"
- ".github/workflows/mobile-sdk-demo-e2e.yml"
pull_request:
branches:
- dev
@@ -28,6 +37,7 @@ on:
- "packages/mobile-sdk-demo/**"
- "packages/mobile-sdk-alpha/**"
- ".github/workflows/mobile-sdk-demo-e2e.yml"
workflow_dispatch:
jobs:
android-e2e:
@@ -58,7 +68,7 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable
- run: corepack prepare yarn@4.6.0 --activate
- run: corepack prepare yarn@4.12.0 --activate
- name: Compute .yarnrc.yml hash
id: yarnrc-hash
uses: ./.github/actions/yarnrc-hash
@@ -202,7 +212,11 @@ jobs:
ios-e2e:
timeout-minutes: 60
runs-on: macos-latest-large
# runs-on: macos-latest-large
runs-on: namespace-profile-apple-silicon-6cpu
if: |
github.event_name == 'workflow_dispatch' ||
github.event_name == 'push'
name: iOS E2E Tests Demo App
concurrency:
group: ${{ github.workflow }}-ios-${{ github.ref }}
@@ -229,7 +243,7 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable
- run: corepack prepare yarn@4.6.0 --activate
- run: corepack prepare yarn@4.12.0 --activate
- name: Compute .yarnrc.yml hash
id: yarnrc-hash
uses: ./.github/actions/yarnrc-hash
@@ -244,6 +258,21 @@ jobs:
- name: Toggle Yarn hardened mode for trusted PRs
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV
- name: Check Java installation
run: |
echo "INSTALL_JAVA=false" >> "$GITHUB_ENV"
if command -v java &> /dev/null && java -version &> /dev/null; then
echo "Java already installed: $(java -version 2>&1 | head -n 1)"
else
echo "Java not found or not working, will install..."
echo "INSTALL_JAVA=true" >> "$GITHUB_ENV"
fi
- name: Setup Java environment
if: env.INSTALL_JAVA == 'true'
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Generate token for self repositories
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
uses: ./.github/actions/generate-github-token

View File

@@ -22,8 +22,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1194.0)
aws-sdk-core (3.239.2)
aws-partitions (1.1198.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -34,7 +34,7 @@ GEM
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.206.0)
aws-sdk-s3 (1.209.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -43,7 +43,7 @@ GEM
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (3.3.1)
bigdecimal (4.0.1)
claide (1.1.0)
cocoapods (1.16.2)
addressable (~> 2.8)
@@ -118,7 +118,7 @@ GEM
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
@@ -219,7 +219,7 @@ GEM
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
i18n (1.14.7)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.18.0)
@@ -229,7 +229,8 @@ GEM
mini_magick (4.13.2)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.27.0)
minitest (6.0.0)
prism (~> 1.5)
molinillo (0.8.0)
multi_json (1.18.0)
multipart-post (2.4.1)
@@ -244,6 +245,7 @@ GEM
optparse (0.8.1)
os (1.1.4)
plist (3.7.2)
prism (1.7.0)
public_suffix (4.0.7)
racc (1.8.1)
rake (13.3.1)

View File

@@ -135,7 +135,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 121
versionName "2.9.5"
versionName "2.9.7"
manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp']
externalNativeBuild {
cmake {

View File

@@ -0,0 +1,14 @@
# Mobile app docstring style guide
Docstrings for the React Native app live alongside the source in `app/src`. We follow [TSDoc](https://tsdoc.org) conventions so that typed tooling can generate consistent API documentation.
## Authoring guidelines
- Document every exported component, hook, utility, or type alias with a leading `/** ... */` block written in the imperative mood.
- Include `@param`, `@returns`, and `@remarks` tags when they improve clarity, especially for side-effects or platform-specific behaviour.
- Keep examples concise. Prefer inline code blocks for short snippets and use fenced blocks only when you need multiple lines.
- Mention platform differences explicitly (for example, “iOS only”) so consumers understand the scope of the implementation.
## Coverage expectations
Docstring coverage can be checked locally by running `yarn docstrings:app` (or `yarn docstrings` for both app and SDK). The reports generate JSON snapshots in `docs/coverage/*.json` that can be committed to track progress over time. Coverage targets are not enforced—treat the reports as guardrails to identify documentation gaps.

View File

@@ -396,7 +396,7 @@ The workflow consists of parallel jobs for each platform:
#### `build-ios` Job
Runs on `macos-latest-large` and performs the following steps:
Runs on `namespace-profile-apple-silicon-6cpu` and performs the following steps:
1. Sets up the environment (Node.js, Ruby, CocoaPods)
2. Processes iOS secrets and certificates
3. Runs appropriate Fastlane lane based on branch

View File

@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.9.5</string>
<string>2.9.7</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -2131,7 +2131,7 @@ PODS:
- ReactCommon/turbomodule/core
- Sentry/HybridSDK (= 8.53.2)
- Yoga
- RNSVG (15.15.0):
- RNSVG (15.14.0):
- DoubleConversion
- glog
- hermes-engine
@@ -2151,9 +2151,9 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNSVG/common (= 15.15.0)
- RNSVG/common (= 15.14.0)
- Yoga
- RNSVG/common (15.15.0):
- RNSVG/common (15.14.0):
- DoubleConversion
- glog
- hermes-engine
@@ -2635,7 +2635,7 @@ SPEC CHECKSUMS:
RNReactNativeHapticFeedback: e526ac4a7ca9fb23c7843ea4fd7d823166054c73
RNScreens: 806e1449a8ec63c2a4e4cf8a63cc80203ccda9b8
RNSentry: 6ad982be2c8e32dab912afb4132b6a0d88484ea0
RNSVG: 39476f26bbbe72ffe6194c6fc8f6acd588087957
RNSVG: e1cf5a9a5aa12c69f2ec47031defbd87ae7fb697
segment-analytics-react-native: a0c29c75ede1989118b50cac96b9495ea5c91a1d
Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748

View File

@@ -546,7 +546,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
MARKETING_VERSION = 2.9.5;
MARKETING_VERSION = 2.9.7;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -686,7 +686,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
MARKETING_VERSION = 2.9.5;
MARKETING_VERSION = 2.9.7;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",

View File

@@ -34,9 +34,8 @@ const config = {
],
transformer: {
babelTransformerPath: require.resolve(
'react-native-svg-transformer/react-native',
),
babelTransformerPath:
require.resolve('react-native-svg-transformer/react-native'),
disableImportExportTransform: true,
inlineRequires: true,
},

View File

@@ -1,6 +1,6 @@
{
"name": "@selfxyz/mobile-app",
"version": "2.9.5",
"version": "2.9.7",
"private": true,
"type": "module",
"scripts": {
@@ -162,8 +162,8 @@
"react-native-safe-area-context": "^5.6.1",
"react-native-screens": "4.15.3",
"react-native-sqlite-storage": "^6.0.1",
"react-native-svg": "^15.14.0",
"react-native-svg-web": "^1.0.9",
"react-native-svg": "15.14.0",
"react-native-svg-web": "1.0.9",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "^0.19.0",
"react-native-webview": "^13.16.0",
@@ -237,7 +237,7 @@
"vite": "^7.0.0",
"vite-plugin-svgr": "^4.5.0"
},
"packageManager": "yarn@4.6.0",
"packageManager": "yarn@4.12.0",
"engines": {
"node": ">=22 <23"
}

View File

@@ -35,8 +35,10 @@ const ModalBackDrop = styled(View, {
height: '100%',
});
export interface ModalNavigationParams
extends Omit<ModalParams, 'onButtonPress' | 'onModalDismiss'> {
export interface ModalNavigationParams extends Omit<
ModalParams,
'onButtonPress' | 'onModalDismiss'
> {
callbackId: number;
}

View File

@@ -273,6 +273,8 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
const navigation =
useNavigation() as NativeStackScreenProps<RootStackParamList>['navigation'];
const subscribedTopics = useSettingStore(state => state.subscribedTopics);
const loggingSeverity = useSettingStore(state => state.loggingSeverity);
const setLoggingSeverity = useSettingStore(state => state.setLoggingSeverity);
const [hasNotificationPermission, setHasNotificationPermission] =
useState(false);
const paddingBottom = useSafeBottomPadding(20);
@@ -668,6 +670,45 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
</YStack>
</ParameterSection>
<ParameterSection
icon={<BugIcon />}
title="Log Level"
description="Configure logging verbosity"
>
<YStack gap="$2">
{(['debug', 'info', 'warn', 'error'] as const).map(level => (
<Button
key={level}
backgroundColor={
loggingSeverity === level ? '$green9' : slate200
}
borderRadius="$2"
height="$5"
onPress={() => {
setLoggingSeverity(level);
}}
flexDirection="row"
justifyContent="space-between"
paddingHorizontal="$4"
pressStyle={{
opacity: 0.8,
scale: 0.98,
}}
>
<Text
color={loggingSeverity === level ? white : slate600}
fontSize="$5"
fontFamily={dinot}
fontWeight="600"
>
{level.toUpperCase()}
</Text>
{loggingSeverity === level && <Check color={white} size={20} />}
</Button>
))}
</YStack>
</ParameterSection>
<ParameterSection
icon={<WarningIcon color={yellow500} />}
title="Danger Zone"

View File

@@ -46,7 +46,10 @@ import {
setDefaultDocumentTypeIfNeeded,
usePassport,
} from '@/providers/passportDataProvider';
import { getPointsAddress } from '@/services/points';
import {
getPointsAddress,
getWhiteListedDisclosureAddresses,
} from '@/services/points';
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
import { ProofStatus } from '@/stores/proofTypes';
import {
@@ -64,6 +67,7 @@ const ProveScreen: React.FC = () => {
const { useProvingStore, useSelfAppStore } = selfClient;
const selectedApp = useSelfAppStore(state => state.selfApp);
const selectedAppRef = useRef<typeof selectedApp>(null);
const processedSessionsRef = useRef<Set<string>>(new Set());
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
const [scrollViewContentHeight, setScrollViewContentHeight] = useState(0);
@@ -167,16 +171,41 @@ const ProveScreen: React.FC = () => {
return;
}
const enhanceApp = async () => {
const address = await getPointsAddress();
const sessionId = selectedApp.sessionId;
// Only update if still the same session
if (selectedAppRef.current?.sessionId === selectedApp.sessionId) {
console.log('enhancing app with points address', address);
selfClient.getSelfAppState().setSelfApp({
...selectedApp,
selfDefinedData: address.toLowerCase(),
});
if (processedSessionsRef.current.has(sessionId)) {
return;
}
const enhanceApp = async () => {
const currentSessionId = sessionId;
try {
const address = await getPointsAddress();
const whitelistedAddresses = await getWhiteListedDisclosureAddresses();
const isWhitelisted = whitelistedAddresses.some(
contract =>
contract.contract_address.toLowerCase() === address.toLowerCase(),
);
const currentApp = selfClient.getSelfAppState().selfApp;
if (currentApp?.sessionId === currentSessionId) {
if (isWhitelisted) {
console.log(
'enhancing app with whitelisted points address',
address,
);
selfClient.getSelfAppState().setSelfApp({
...currentApp,
selfDefinedData: address.toLowerCase(),
});
}
}
processedSessionsRef.current.add(currentSessionId);
} catch (error) {
console.error('Failed enhancing app:', error);
}
};

View File

@@ -12,13 +12,17 @@ import {
import { interceptConsole } from '@/services/logging/logger/consoleInterceptor';
import { lokiTransport } from '@/services/logging/logger/lokiTransport';
import { setupNativeLoggerBridge } from '@/services/logging/logger/nativeLoggerBridge';
import { useSettingStore } from '@/stores/settingStore';
// Read initial logging severity from settings store
const initialSeverity = useSettingStore.getState().loggingSeverity;
const defaultConfig: configLoggerType<
transportFunctionType<object> | transportFunctionType<object>[],
defLvlType
> = {
enabled: __DEV__ ? false : true,
severity: __DEV__ ? 'debug' : 'warn', //TODO configure this using remote-config
severity: initialSeverity,
transport: [lokiTransport as unknown as transportFunctionType<object>],
transportOptions: {
colors: {
@@ -52,6 +56,37 @@ const DocumentLogger = Logger.extend('DOCUMENT');
//Native Modules
const NfcLogger = Logger.extend('NFC');
// Collect all extended loggers for severity updates
const extendedLoggers = [
AppLogger,
NotificationLogger,
AuthLogger,
PassportLogger,
ProofLogger,
SettingsLogger,
BackupLogger,
MockDataLogger,
DocumentLogger,
NfcLogger,
];
// Subscribe to settings store changes to update logger severity dynamically
// Extended loggers are independent instances, so we need to update each one
// Note: Dynamically created loggers (e.g., in nativeLoggerBridge for unknown categories)
// will inherit the severity at creation time but won't receive runtime updates
let previousSeverity = initialSeverity;
useSettingStore.subscribe(state => {
if (state.loggingSeverity !== previousSeverity) {
Logger.setSeverity(state.loggingSeverity);
// Update all extended loggers since they don't inherit runtime changes
// Extended loggers have setSeverity at runtime, even if not in type definition
extendedLoggers.forEach(extLogger => {
(extLogger as typeof Logger).setSeverity(state.loggingSeverity);
});
previousSeverity = state.loggingSeverity;
}
});
// Initialize console interceptor to route console logs to Loki
interceptConsole(AppLogger);

View File

@@ -132,9 +132,8 @@ export const getWhiteListedDisclosureAddresses = async (): Promise<
export const hasUserAnIdentityDocumentRegistered =
async (): Promise<boolean> => {
try {
const { loadDocumentCatalogDirectlyFromKeychain } = await import(
'@/providers/passportDataProvider'
);
const { loadDocumentCatalogDirectlyFromKeychain } =
await import('@/providers/passportDataProvider');
const catalog = await loadDocumentCatalogDirectlyFromKeychain();
return catalog.documents.some(doc => doc.isRegistered === true);

View File

@@ -129,12 +129,10 @@ export const usePointEventStore = create<PointEventState>()((set, get) => ({
loadDisclosureEvents: async () => {
try {
const { getDisclosurePointEvents } = await import(
'@/services/points/getEvents'
);
const { useProofHistoryStore } = await import(
'@/stores/proofHistoryStore'
);
const { getDisclosurePointEvents } =
await import('@/services/points/getEvents');
const { useProofHistoryStore } =
await import('@/stores/proofHistoryStore');
await useProofHistoryStore.getState().initDatabase();
const disclosureEvents = await getDisclosurePointEvents();
const existingEvents = get().events.filter(e => e.type !== 'disclosure');

View File

@@ -6,6 +6,8 @@ import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
type LoggingSeverity = 'debug' | 'info' | 'warn' | 'error';
interface PersistedSettingsState {
addSubscribedTopic: (topic: string) => void;
biometricsAvailable: boolean;
@@ -19,6 +21,7 @@ interface PersistedSettingsState {
homeScreenViewCount: number;
incrementHomeScreenViewCount: () => void;
isDevMode: boolean;
loggingSeverity: LoggingSeverity;
pointsAddress: string | null;
removeSubscribedTopic: (topic: string) => void;
resetBackupForPoints: () => void;
@@ -29,6 +32,7 @@ interface PersistedSettingsState {
setFcmToken: (token: string | null) => void;
setHasViewedRecoveryPhrase: (viewed: boolean) => void;
setKeychainMigrationCompleted: () => void;
setLoggingSeverity: (severity: LoggingSeverity) => void;
setPointsAddress: (address: string | null) => void;
setSubscribedTopics: (topics: string[]) => void;
setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void;
@@ -97,6 +101,10 @@ export const useSettingStore = create<SettingsState>()(
setDevModeOn: () => set({ isDevMode: true }),
setDevModeOff: () => set({ isDevMode: false }),
loggingSeverity: __DEV__ ? 'debug' : 'warn',
setLoggingSeverity: (severity: LoggingSeverity) =>
set({ loggingSeverity: severity }),
hasCompletedKeychainMigration: false,
setKeychainMigrationCompleted: () =>
set({ hasCompletedKeychainMigration: true }),

View File

@@ -0,0 +1,206 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
/**
* @jest-environment node
*/
import type { LoggingSeverity } from '@/stores/settingStore';
import { useSettingStore } from '@/stores/settingStore';
// Track individual logger instances to verify they all get updated
// Must be prefixed with 'mock' to be accessible in jest.mock()
const mockLoggerInstances = new Map<string, { setSeverity: jest.Mock }>();
const mockRootSetSeverity = jest.fn();
// Mock react-native-logs
jest.mock('react-native-logs', () => ({
logger: {
createLogger: jest.fn(() => ({
setSeverity: mockRootSetSeverity,
extend: jest.fn((name: string) => {
const mockSetSeverity = jest.fn();
mockLoggerInstances.set(name, { setSeverity: mockSetSeverity });
return {
setSeverity: mockSetSeverity,
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
extend: jest.fn(),
};
}),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
})),
},
}));
// Mock the loki transport
jest.mock('@/services/logging/logger/lokiTransport', () => ({
lokiTransport: jest.fn(),
cleanupLokiTransport: jest.fn(),
}));
// Mock the console interceptor
jest.mock('@/services/logging/logger/consoleInterceptor', () => ({
interceptConsole: jest.fn(),
}));
// Mock the native logger bridge
jest.mock('@/services/logging/logger/nativeLoggerBridge', () => ({
setupNativeLoggerBridge: jest.fn(),
cleanup: jest.fn(),
}));
describe('Logging Service - Severity Updates', () => {
// All extended logger names that should be created
const expectedLoggers = [
'APP',
'NOTIFICATION',
'AUTH',
'PASSPORT',
'PROOF',
'SETTINGS',
'BACKUP',
'MOCK_DATA',
'DOCUMENT',
'NFC',
];
beforeAll(async () => {
// Import the logging module once before all tests
// This triggers the creation of all loggers and sets up the subscription
await import('@/services/logging');
});
beforeEach(() => {
jest.clearAllMocks();
// Note: Don't clear mockLoggerInstances as the loggers are created only once during module import
// Reset store to default state
useSettingStore.setState({
loggingSeverity: 'warn',
});
});
it('should create all expected extended loggers', () => {
// Verify all expected loggers were created during module import
expect(mockLoggerInstances.size).toBe(expectedLoggers.length);
expectedLoggers.forEach(name => {
expect(mockLoggerInstances.has(name)).toBe(true);
});
});
it('should update severity on root logger and all extended loggers when settings change', async () => {
// Clear any calls from initialization
mockRootSetSeverity.mockClear();
mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear());
// Change the logging severity in the store
useSettingStore.getState().setLoggingSeverity('debug');
// Wait for the subscription to fire
await new Promise(resolve => setTimeout(resolve, 10));
// Verify root logger was updated
expect(mockRootSetSeverity).toHaveBeenCalledTimes(1);
expect(mockRootSetSeverity).toHaveBeenCalledWith('debug');
// Verify each extended logger was updated
mockLoggerInstances.forEach(logger => {
expect(logger.setSeverity).toHaveBeenCalledTimes(1);
expect(logger.setSeverity).toHaveBeenCalledWith('debug');
});
});
it('should update each specific extended logger individually', async () => {
mockRootSetSeverity.mockClear();
mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear());
useSettingStore.getState().setLoggingSeverity('info');
await new Promise(resolve => setTimeout(resolve, 10));
// Verify specific loggers by name
const specificLoggers = ['APP', 'NFC', 'PASSPORT', 'PROOF'];
specificLoggers.forEach(loggerName => {
const logger = mockLoggerInstances.get(loggerName);
expect(logger).toBeDefined();
expect(logger?.setSeverity).toHaveBeenCalledWith('info');
});
});
it('should update severity for all severity levels', async () => {
const severityLevels: LoggingSeverity[] = [
'debug',
'info',
'warn',
'error',
];
for (const level of severityLevels) {
mockRootSetSeverity.mockClear();
mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear());
useSettingStore.getState().setLoggingSeverity(level);
await new Promise(resolve => setTimeout(resolve, 10));
// Verify root logger
expect(mockRootSetSeverity).toHaveBeenCalledWith(level);
// Verify all extended loggers
mockLoggerInstances.forEach(logger => {
expect(logger.setSeverity).toHaveBeenCalledWith(level);
});
}
});
it('should not call setSeverity if severity has not changed', async () => {
mockRootSetSeverity.mockClear();
mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear());
// Get current severity
const currentSeverity = useSettingStore.getState().loggingSeverity;
// Set to the same severity
useSettingStore.getState().setLoggingSeverity(currentSeverity);
await new Promise(resolve => setTimeout(resolve, 10));
// Should not call setSeverity on root logger
expect(mockRootSetSeverity).not.toHaveBeenCalled();
// Should not call setSeverity on any extended logger
mockLoggerInstances.forEach(logger => {
expect(logger.setSeverity).not.toHaveBeenCalled();
});
});
it('should handle rapid severity changes correctly', async () => {
mockRootSetSeverity.mockClear();
mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear());
// Rapidly change severity multiple times
useSettingStore.getState().setLoggingSeverity('debug');
useSettingStore.getState().setLoggingSeverity('info');
useSettingStore.getState().setLoggingSeverity('warn');
useSettingStore.getState().setLoggingSeverity('error');
// Wait for all subscriptions to fire
await new Promise(resolve => setTimeout(resolve, 50));
// Should have been called 4 times (once per change)
expect(mockRootSetSeverity).toHaveBeenCalledTimes(4);
// The last call should be 'error'
expect(mockRootSetSeverity).toHaveBeenLastCalledWith('error');
// Each extended logger should also have been called 4 times
mockLoggerInstances.forEach(logger => {
expect(logger.setSeverity).toHaveBeenCalledTimes(4);
expect(logger.setSeverity).toHaveBeenLastCalledWith('error');
});
});
});

View File

@@ -1,10 +1,10 @@
{
"ios": {
"build": 194,
"lastDeployed": "2025-12-14T22:52:48.122Z"
"build": 197,
"lastDeployed": "2025-12-25T18:27:37.342Z"
},
"android": {
"build": 126,
"lastDeployed": "2025-12-14T22:52:48.122Z"
"build": 127,
"lastDeployed": "2025-12-17T16:13:30.256Z"
}
}

View File

@@ -86,10 +86,10 @@
"prettier": "^3.5.3",
"ts-mocha": "^10.0.0",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.20.3",
"tsx": "^4.21.0",
"typescript": "^5.9.2"
},
"packageManager": "yarn@4.6.0",
"packageManager": "yarn@4.12.0",
"engines": {
"node": ">=22 <23"
}

View File

@@ -708,7 +708,7 @@
"typescript": "^5.9.2",
"vitest": "^2.1.8"
},
"packageManager": "yarn@4.6.0",
"packageManager": "yarn@4.12.0",
"engines": {
"node": ">=22 <23"
}

View File

@@ -133,7 +133,7 @@
"typechain": "^8.3.2",
"typescript": "^5.9.2"
},
"packageManager": "yarn@4.6.0",
"packageManager": "yarn@4.12.0",
"engines": {
"node": ">=22 <23"
}

214
docs/coverage/app.json Normal file
View File

@@ -0,0 +1,214 @@
{
"generatedAt": "2025-12-25T18:56:55.583Z",
"label": "Mobile App",
"totals": {
"exports": 497,
"documented": 75,
"undocumented": 422,
"coverage": 15.09
},
"undocumentedTotal": 422,
"undocumentedSampled": 50,
"undocumented": [
{
"file": "app/src/assets/animations/loader.ts",
"symbol": "loadMiscAnimation"
},
{
"file": "app/src/assets/animations/loader.ts",
"symbol": "loadPassportAnimation"
},
{
"file": "app/src/components/BackupDocumentationLink.tsx",
"symbol": "default export"
},
{
"file": "app/src/components/Disclosures.tsx",
"symbol": "default (local: Disclosures)"
},
{
"file": "app/src/components/ErrorBoundary.tsx",
"symbol": "default export"
},
{
"file": "app/src/components/FeedbackModal.tsx",
"symbol": "default export"
},
{
"file": "app/src/components/FeedbackModalScreen.tsx",
"symbol": "FeedbackModalScreenParams"
},
{
"file": "app/src/components/FeedbackModalScreen.tsx",
"symbol": "default export"
},
{
"file": "app/src/components/homescreen/IdCard.tsx",
"symbol": "default export"
},
{
"file": "app/src/components/homescreen/SvgXmlWrapper.native.tsx",
"symbol": "SvgXml"
},
{
"file": "app/src/components/homescreen/SvgXmlWrapper.native.tsx",
"symbol": "default export"
},
{
"file": "app/src/components/homescreen/SvgXmlWrapper.web.tsx",
"symbol": "SvgXml"
},
{
"file": "app/src/components/homescreen/SvgXmlWrapper.web.tsx",
"symbol": "default export"
},
{
"file": "app/src/components/LoadingUI.tsx",
"symbol": "default export"
},
{
"file": "app/src/components/Mnemonic.tsx",
"symbol": "default export"
},
{
"file": "app/src/components/native/PassportCamera.tsx",
"symbol": "PassportCameraProps"
},
{
"file": "app/src/components/native/PassportCamera.tsx",
"symbol": "PassportCamera"
},
{
"file": "app/src/components/native/PassportCamera.web.tsx",
"symbol": "PassportCameraProps"
},
{
"file": "app/src/components/native/PassportCamera.web.tsx",
"symbol": "PassportCamera"
},
{
"file": "app/src/components/native/QRCodeScanner.tsx",
"symbol": "QRCodeScannerViewProps"
},
{
"file": "app/src/components/native/QRCodeScanner.tsx",
"symbol": "QRCodeScannerView"
},
{
"file": "app/src/components/native/QRCodeScanner.web.tsx",
"symbol": "QRCodeScannerViewProps"
},
{
"file": "app/src/components/native/QRCodeScanner.web.tsx",
"symbol": "QRCodeScannerView"
},
{
"file": "app/src/components/native/QRCodeScanner.web.tsx",
"symbol": "default export"
},
{
"file": "app/src/components/native/RCTFragment.tsx",
"symbol": "FragmentProps"
},
{
"file": "app/src/components/native/RCTFragment.tsx",
"symbol": "RCTFragmentViewManagerProps"
},
{
"file": "app/src/components/native/RCTFragment.tsx",
"symbol": "RCTFragment"
},
{
"file": "app/src/components/navbar/AadhaarNavBar.tsx",
"symbol": "AadhaarNavBar"
},
{
"file": "app/src/components/navbar/BaseNavBar.tsx",
"symbol": "LeftAction"
},
{
"file": "app/src/components/navbar/BaseNavBar.tsx",
"symbol": "RightAction"
},
{
"file": "app/src/components/navbar/BaseNavBar.tsx",
"symbol": "NavBar"
},
{
"file": "app/src/components/navbar/DefaultNavBar.tsx",
"symbol": "DefaultNavBar"
},
{
"file": "app/src/components/navbar/DocumentFlowNavBar.tsx",
"symbol": "DocumentFlowNavBar"
},
{
"file": "app/src/components/navbar/HeadlessNavForEuclid.tsx",
"symbol": "HeadlessNavForEuclid"
},
{
"file": "app/src/components/navbar/HomeNavBar.tsx",
"symbol": "HomeNavBar"
},
{
"file": "app/src/components/navbar/IdDetailsNavBar.tsx",
"symbol": "IdDetailsNavBar"
},
{
"file": "app/src/components/navbar/Points.tsx",
"symbol": "default export"
},
{
"file": "app/src/components/navbar/PointsNavBar.tsx",
"symbol": "PointsNavBar"
},
{
"file": "app/src/components/navbar/WebViewNavBar.tsx",
"symbol": "WebViewNavBarProps"
},
{
"file": "app/src/components/navbar/WebViewNavBar.tsx",
"symbol": "WebViewNavBar"
},
{
"file": "app/src/components/PointHistoryList.tsx",
"symbol": "PointHistoryListProps"
},
{
"file": "app/src/components/PointHistoryList.tsx",
"symbol": "PointHistoryList"
},
{
"file": "app/src/components/PointHistoryList.tsx",
"symbol": "default export"
},
{
"file": "app/src/components/referral/CopyReferralButton.tsx",
"symbol": "CopyReferralButtonProps"
},
{
"file": "app/src/components/referral/CopyReferralButton.tsx",
"symbol": "CopyReferralButton"
},
{
"file": "app/src/components/referral/ReferralHeader.tsx",
"symbol": "ReferralHeaderProps"
},
{
"file": "app/src/components/referral/ReferralHeader.tsx",
"symbol": "ReferralHeader"
},
{
"file": "app/src/components/referral/ReferralInfo.tsx",
"symbol": "ReferralInfoProps"
},
{
"file": "app/src/components/referral/ReferralInfo.tsx",
"symbol": "ReferralInfo"
},
{
"file": "app/src/components/referral/ShareButton.tsx",
"symbol": "ShareButtonProps"
}
]
}

214
docs/coverage/sdk.json Normal file
View File

@@ -0,0 +1,214 @@
{
"generatedAt": "2025-12-25T18:56:56.987Z",
"label": "Mobile SDK Alpha",
"totals": {
"exports": 234,
"documented": 77,
"undocumented": 157,
"coverage": 32.91
},
"undocumentedTotal": 157,
"undocumentedSampled": 50,
"undocumented": [
{
"file": "packages/mobile-sdk-alpha/src/adapters/react-native/nfc-scanner.ts",
"symbol": "reactNativeScannerAdapter"
},
{
"file": "packages/mobile-sdk-alpha/src/adapters/web/shims.ts",
"symbol": "webNFCScannerShim"
},
{
"file": "packages/mobile-sdk-alpha/src/bridge/nativeEvents.native.ts",
"symbol": "addListener"
},
{
"file": "packages/mobile-sdk-alpha/src/bridge/nativeEvents.native.ts",
"symbol": "removeListener"
},
{
"file": "packages/mobile-sdk-alpha/src/bridge/nativeEvents.ts",
"symbol": "EventHandler"
},
{
"file": "packages/mobile-sdk-alpha/src/bridge/nativeEvents.ts",
"symbol": "NativeEventBridge"
},
{
"file": "packages/mobile-sdk-alpha/src/bridge/nativeEvents.ts",
"symbol": "addListener"
},
{
"file": "packages/mobile-sdk-alpha/src/bridge/nativeEvents.ts",
"symbol": "removeListener"
},
{
"file": "packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx",
"symbol": "ButtonProps"
},
{
"file": "packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx",
"symbol": "default (local: AbstractButton)"
},
{
"file": "packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx",
"symbol": "HeldPrimaryButtonProveScreen"
},
{
"file": "packages/mobile-sdk-alpha/src/components/buttons/pressedStyle.tsx",
"symbol": "pressedStyle"
},
{
"file": "packages/mobile-sdk-alpha/src/components/buttons/PrimaryButton.tsx",
"symbol": "PrimaryButton"
},
{
"file": "packages/mobile-sdk-alpha/src/components/buttons/PrimaryButtonLongHold.shared.ts",
"symbol": "HeldPrimaryButtonProps"
},
{
"file": "packages/mobile-sdk-alpha/src/components/buttons/PrimaryButtonLongHold.shared.ts",
"symbol": "RGBA"
},
{
"file": "packages/mobile-sdk-alpha/src/components/buttons/PrimaryButtonLongHold.shared.ts",
"symbol": "ACTION_TIMER"
},
{
"file": "packages/mobile-sdk-alpha/src/components/buttons/PrimaryButtonLongHold.shared.ts",
"symbol": "COLORS"
},
{
"file": "packages/mobile-sdk-alpha/src/components/buttons/PrimaryButtonLongHold.tsx",
"symbol": "HeldPrimaryButton"
},
{
"file": "packages/mobile-sdk-alpha/src/components/buttons/PrimaryButtonLongHold.web.tsx",
"symbol": "HeldPrimaryButton"
},
{
"file": "packages/mobile-sdk-alpha/src/components/buttons/SecondaryButton.tsx",
"symbol": "SecondaryButton"
},
{
"file": "packages/mobile-sdk-alpha/src/components/ButtonsContainer.tsx",
"symbol": "default export"
},
{
"file": "packages/mobile-sdk-alpha/src/components/flag/RoundFlag.tsx",
"symbol": "RoundFlag"
},
{
"file": "packages/mobile-sdk-alpha/src/components/layout/Button.tsx",
"symbol": "Button"
},
{
"file": "packages/mobile-sdk-alpha/src/components/layout/Text.tsx",
"symbol": "Text"
},
{
"file": "packages/mobile-sdk-alpha/src/components/layout/View.tsx",
"symbol": "ViewProps"
},
{
"file": "packages/mobile-sdk-alpha/src/components/layout/View.tsx",
"symbol": "View"
},
{
"file": "packages/mobile-sdk-alpha/src/components/layout/XStack.tsx",
"symbol": "XStack"
},
{
"file": "packages/mobile-sdk-alpha/src/components/layout/YStack.tsx",
"symbol": "YStack"
},
{
"file": "packages/mobile-sdk-alpha/src/components/MRZScannerView.tsx",
"symbol": "MRZScannerViewProps"
},
{
"file": "packages/mobile-sdk-alpha/src/components/MRZScannerView.tsx",
"symbol": "MRZScannerView"
},
{
"file": "packages/mobile-sdk-alpha/src/components/MRZScannerView.tsx",
"symbol": "SelfMRZScannerModule"
},
{
"file": "packages/mobile-sdk-alpha/src/components/RCTFragment.tsx",
"symbol": "FragmentProps"
},
{
"file": "packages/mobile-sdk-alpha/src/components/RCTFragment.tsx",
"symbol": "RCTFragmentViewManagerProps"
},
{
"file": "packages/mobile-sdk-alpha/src/components/RCTFragment.tsx",
"symbol": "RCTFragment"
},
{
"file": "packages/mobile-sdk-alpha/src/components/screens/NFCScannerScreen.tsx",
"symbol": "NFCScannerScreen"
},
{
"file": "packages/mobile-sdk-alpha/src/components/screens/PassportCameraScreen.tsx",
"symbol": "PassportCameraScreen"
},
{
"file": "packages/mobile-sdk-alpha/src/components/screens/QRCodeScreen.tsx",
"symbol": "QRCodeScreen"
},
{
"file": "packages/mobile-sdk-alpha/src/components/TextsContainer.tsx",
"symbol": "default export"
},
{
"file": "packages/mobile-sdk-alpha/src/components/typography/Additional.tsx",
"symbol": "default export"
},
{
"file": "packages/mobile-sdk-alpha/src/components/typography/BodyText.tsx",
"symbol": "BodyText"
},
{
"file": "packages/mobile-sdk-alpha/src/components/typography/Caption.tsx",
"symbol": "Caption"
},
{
"file": "packages/mobile-sdk-alpha/src/components/typography/Caution.tsx",
"symbol": "default export"
},
{
"file": "packages/mobile-sdk-alpha/src/components/typography/Description.tsx",
"symbol": "default export"
},
{
"file": "packages/mobile-sdk-alpha/src/components/typography/DescriptionTitle.tsx",
"symbol": "DescriptionTitle"
},
{
"file": "packages/mobile-sdk-alpha/src/components/typography/styles.ts",
"symbol": "typography"
},
{
"file": "packages/mobile-sdk-alpha/src/components/typography/SubHeader.tsx",
"symbol": "SubHeader"
},
{
"file": "packages/mobile-sdk-alpha/src/components/typography/Title.tsx",
"symbol": "Title"
},
{
"file": "packages/mobile-sdk-alpha/src/config/defaults.ts",
"symbol": "defaultConfig"
},
{
"file": "packages/mobile-sdk-alpha/src/config/merge.ts",
"symbol": "mergeConfig"
},
{
"file": "packages/mobile-sdk-alpha/src/constants/analytics.ts",
"symbol": "AadhaarEvents"
}
]
}

View File

@@ -1,5 +1,14 @@
# Self App Development Patterns
## Docstring coverage workflow
- Run `yarn docstrings` to check documentation coverage for both the mobile app and SDK. This generates `docs/coverage/app.json` and `docs/coverage/sdk.json` so you can diff coverage changes in version control.
- Run `yarn docstrings:app` to check only the mobile app exports.
- Run `yarn docstrings:sdk` to focus on `@selfxyz/mobile-sdk-alpha` only.
- Add `--details` to any command when you want a full per-file JSON breakdown for ad-hoc analysis—the default snapshots include only top-level totals and a small sample of undocumented exports to keep the tracked files compact.
Run the docstring reports locally before committing to track coverage changes. The reports are advisory—use them to identify documentation gaps but they won't block builds.
## React Native Architecture
### Navigation System

View File

@@ -17,6 +17,9 @@
"build:demo": "yarn workspace mobile-sdk-demo build",
"build:mobile-sdk": "yarn workspace @selfxyz/mobile-sdk-alpha build",
"check:versions": "node scripts/check-package-versions.mjs",
"docstrings": "yarn docstrings:app && yarn docstrings:sdk",
"docstrings:app": "yarn tsx scripts/docstring-report.ts \"app/src/**/*.{ts,tsx}\" --label \"Mobile App\" --write-report docs/coverage/app.json",
"docstrings:sdk": "yarn tsx scripts/docstring-report.ts \"packages/mobile-sdk-alpha/src/**/*.{ts,tsx}\" --label \"Mobile SDK Alpha\" --write-report docs/coverage/sdk.json",
"demo:mobile": "yarn build:mobile-sdk && yarn build:demo && yarn workspace mobile-sdk-demo start",
"format": "SKIP_BUILD_DEPS=1 yarn format:root && yarn format:github && SKIP_BUILD_DEPS=1 yarn workspaces foreach --parallel -i --all --exclude self-workspace-root run format",
"format:github": "yarn prettier --parser yaml --write .github/**/*.yml --single-quote false",
@@ -65,9 +68,10 @@
"knip": "^5.63.1",
"patch-package": "^8.0.0",
"prettier": "^3.6.2",
"tsx": "^4.21.0",
"typescript": "^5.9.2"
},
"packageManager": "yarn@4.6.0",
"packageManager": "yarn@4.12.0",
"engines": {
"node": ">=22 <23"
}

View File

@@ -0,0 +1,14 @@
# Mobile SDK docstring style guide
All exported APIs from `packages/mobile-sdk-alpha/src` must carry TSDoc-compliant comments so integrators can rely on generated documentation and in-editor hints.
## Authoring guidelines
- Start each docstring with a one-line summary that describes the intent of the API in the imperative mood.
- Describe complex parameter shapes with `@param` tags and consider linking to shared types with `{@link ...}` when the name alone is ambiguous.
- Capture platform nuances (for example, “Android only”) and error semantics in the main description or an `@remarks` block.
- Prefer examples that demonstrate the supported developer experience (React Native, Expo, etc.) and keep them short enough to scan quickly.
## Coverage expectations
`yarn docstrings:sdk` (or `yarn docstrings` for both app and SDK) surfaces the current coverage numbers in `docs/coverage/*.json`. The reports can be committed to track progress over time. Coverage thresholds are advisory—use the reports to plan follow-up work even when you need to land code without full documentation.

View File

@@ -198,7 +198,7 @@
"react-native-svg": "*",
"react-native-webview": "^13.16.0"
},
"packageManager": "yarn@4.6.0",
"packageManager": "yarn@4.12.0",
"publishConfig": {
"access": "restricted"
}

View File

@@ -52,9 +52,9 @@ if [ -d "$MOBILE_SDK_NATIVE" ]; then
rm -f "dist/android/mobile-sdk-alpha-release.aar"
fi
# Update submodule to latest
echo "🔄 Updating submodule to latest..."
git submodule update --init --recursive mobile-sdk-native
# Setup and update submodule using the setup script
echo "🔄 Setting up and updating submodule..."
node scripts/setup-native-source.cjs
# Navigate to android directory
cd android

View File

@@ -9,6 +9,7 @@ const path = require('path');
// Constants
const SCRIPT_DIR = __dirname;
const SDK_DIR = path.dirname(SCRIPT_DIR);
const REPO_ROOT = path.resolve(SDK_DIR, '../../');
const PRIVATE_MODULE_PATH = path.join(SDK_DIR, 'mobile-sdk-native');
const GITHUB_ORG = 'selfxyz';
@@ -34,10 +35,10 @@ function log(message, type = 'info') {
console.log(`${prefix} ${message}`);
}
function runCommand(command, options = {}) {
function runCommand(command, options = {}, cwd = SDK_DIR) {
const defaultOptions = {
stdio: isDryRun ? 'pipe' : 'inherit',
cwd: SDK_DIR,
cwd: cwd,
encoding: 'utf8',
...options,
};
@@ -112,18 +113,98 @@ function setupSubmodule() {
}
try {
// Check if submodule already exists
if (fs.existsSync(PRIVATE_MODULE_PATH)) {
log('Submodule already exists, updating...', 'info');
runCommand(`git submodule update --init --recursive mobile-sdk-native`);
// Check if submodule is registered in .gitmodules (at repo root)
const gitmodulesPath = path.join(REPO_ROOT, '.gitmodules');
const gitmodulesExists = fs.existsSync(gitmodulesPath);
const gitmodulesContent = gitmodulesExists ? fs.readFileSync(gitmodulesPath, 'utf8') : '';
const isSubmoduleRegistered =
gitmodulesExists && gitmodulesContent.includes('[submodule "packages/mobile-sdk-alpha/mobile-sdk-native"]');
if (process.env.DEBUG_SETUP === 'true') {
log(`Environment: CI=${isCI}, hasAppToken=${!!appToken}, hasRepoToken=${!!repoToken}`, 'info');
log(`Submodule registered: ${isSubmoduleRegistered}`, 'info');
}
// Check if submodule directory exists and has content
const submoduleExists = fs.existsSync(PRIVATE_MODULE_PATH);
let submoduleHasContent = false;
try {
submoduleHasContent = submoduleExists && fs.readdirSync(PRIVATE_MODULE_PATH).length > 0;
} catch {
// Directory might not be readable, treat as empty
submoduleHasContent = false;
}
log(`Submodule directory exists: ${submoduleExists}, has content: ${submoduleHasContent}`, 'info');
// If submodule is registered, update its URL first (important for CI where we switch from SSH to HTTPS)
if (isSubmoduleRegistered) {
log(`Submodule is registered, updating URL from SSH to HTTPS...`, 'info');
log(`Target URL: ${submoduleUrl}`, 'info');
// Update submodule URL using git submodule set-url (Git 2.25+)
try {
const setUrlResult = runCommand(
`git submodule set-url packages/mobile-sdk-alpha/mobile-sdk-native "${submoduleUrl}"`,
{ stdio: 'pipe' },
REPO_ROOT,
);
log('Updated submodule URL using git submodule set-url', 'success');
log(`Command result: ${setUrlResult}`, 'info');
} catch (error) {
log(`git submodule set-url failed: ${error.message}`, 'warning');
// Fallback: Update .gitmodules file directly
try {
let gitmodulesContent = fs.readFileSync(gitmodulesPath, 'utf8');
log(`Current .gitmodules content:\n${gitmodulesContent}`, 'info');
// Replace the URL for mobile-sdk-native submodule
const oldContent = gitmodulesContent;
gitmodulesContent = gitmodulesContent.replace(
/(\[submodule\s+"packages\/mobile-sdk-alpha\/mobile-sdk-native"\]\s+path\s*=\s*packages\/mobile-sdk-alpha\/mobile-sdk-native\s+url\s*=\s*)[^\s]+/,
`$1${submoduleUrl}`,
);
if (oldContent !== gitmodulesContent) {
fs.writeFileSync(gitmodulesPath, gitmodulesContent, 'utf8');
log('Updated .gitmodules with new submodule URL', 'success');
log(`New .gitmodules content:\n${gitmodulesContent}`, 'info');
} else {
log('No changes made to .gitmodules - regex may not match', 'warning');
}
} catch (fallbackError) {
log(`Could not update .gitmodules: ${fallbackError.message}`, 'error');
}
}
}
// If directory exists but is empty, remove it so we can re-initialize
if (submoduleExists && !submoduleHasContent) {
log('Submodule directory exists but is empty, removing...', 'info');
runCommand(`rm -rf "${path.relative(REPO_ROOT, PRIVATE_MODULE_PATH)}"`, { stdio: 'pipe' }, REPO_ROOT);
}
if (isSubmoduleRegistered) {
// Submodule is registered, update/init it
log('Updating and initializing submodule...', 'info');
try {
const updateResult = runCommand(
`git submodule update --init --recursive packages/mobile-sdk-alpha/mobile-sdk-native`,
{},
REPO_ROOT,
);
log(`Submodule update completed: ${updateResult}`, 'success');
} catch (error) {
log(`Submodule update failed: ${error.message}`, 'error');
throw error;
}
} else {
// Add submodule
const addCommand = `git submodule add -b ${BRANCH} "${submoduleUrl}" mobile-sdk-native`;
// Submodule not registered, add it
log('Adding submodule...', 'info');
const addCommand = `git submodule add -b ${BRANCH} "${submoduleUrl}" packages/mobile-sdk-alpha/mobile-sdk-native`;
if (isCI && (appToken || repoToken)) {
// Security: Run command silently to avoid token exposure in logs
runCommand(addCommand, { stdio: 'pipe' });
runCommand(addCommand, { stdio: 'pipe' }, REPO_ROOT);
} else {
runCommand(addCommand);
runCommand(addCommand, {}, REPO_ROOT);
}
}

View File

@@ -47,10 +47,7 @@ interface PressableViewProps {
}
export interface ViewProps
extends Omit<RNViewProps, 'hitSlop'>,
SpacingProps,
Omit<ViewStyle, keyof SpacingProps>,
PressableViewProps {}
extends Omit<RNViewProps, 'hitSlop'>, SpacingProps, Omit<ViewStyle, keyof SpacingProps>, PressableViewProps {}
const sizeTokens: Record<string, number> = {
$0: 0,

804
scripts/docstring-report.ts Normal file
View File

@@ -0,0 +1,804 @@
import path from 'node:path';
import process from 'node:process';
import fs from 'node:fs/promises';
import { glob } from 'node:fs/promises';
import * as ts from 'typescript';
interface CliOptions {
patterns: string[];
writeReport?: string;
label?: string;
includeDetails: boolean;
}
interface ExportEntry {
localName: string;
kinds: Set<string>;
exportedAs: Set<string>;
documented: boolean;
exported: boolean;
}
interface FileExportSummary {
filePath: string;
relativePath: string;
totalExports: number;
documentedExports: number;
coverage: number;
missing: string[];
}
interface JsonReport {
generatedAt: string;
label?: string;
totals: {
exports: number;
documented: number;
undocumented: number;
coverage: number;
};
undocumentedTotal: number;
undocumentedSampled: number;
undocumented: UndocumentedEntry[];
files?: JsonReportFile[];
}
interface JsonReportFile {
file: string;
exports: number;
documented: number;
undocumented: number;
coverage: number;
missing: string[];
}
interface UndocumentedEntry {
file: string;
symbol: string;
}
const DEFAULT_PATTERNS = [
'app/src/**/*.{ts,tsx}',
'packages/mobile-sdk-alpha/src/**/*.{ts,tsx}',
];
async function main(): Promise<void> {
try {
const options = parseArgs(process.argv.slice(2));
const root = process.cwd();
const files = await resolveFiles(options.patterns, root);
if (files.length === 0) {
console.log('No source files matched the provided patterns.');
if (options.writeReport) {
await writeJsonReport(options.writeReport, {
generatedAt: new Date().toISOString(),
label: options.label,
totals: { exports: 0, documented: 0, undocumented: 0, coverage: 100 },
undocumentedTotal: 0,
undocumentedSampled: 0,
undocumented: [],
});
}
return;
}
const summaries: FileExportSummary[] = [];
const failedFiles: Array<{ path: string; error: string }> = [];
for (const filePath of files) {
try {
const summary = await analyzeFile(filePath, root);
if (summary.totalExports > 0) {
summaries.push(summary);
}
} catch (error) {
const relativePath = path.relative(root, filePath);
const errorMessage =
error instanceof Error ? error.message : String(error);
failedFiles.push({ path: relativePath, error: errorMessage });
console.error(`Failed to analyze ${relativePath}: ${errorMessage}`);
}
}
if (summaries.length === 0) {
console.log('No exported declarations were found in the selected files.');
if (options.writeReport) {
await writeJsonReport(options.writeReport, {
generatedAt: new Date().toISOString(),
label: options.label,
totals: { exports: 0, documented: 0, undocumented: 0, coverage: 100 },
undocumentedTotal: 0,
undocumentedSampled: 0,
undocumented: [],
});
}
return;
}
summaries.sort((a, b) => {
if (a.coverage === b.coverage) {
return a.relativePath.localeCompare(b.relativePath);
}
return a.coverage - b.coverage;
});
const totalExports = summaries.reduce(
(sum, file) => sum + file.totalExports,
0,
);
const documentedExports = summaries.reduce(
(sum, file) => sum + file.documentedExports,
0,
);
const overallCoverage =
totalExports === 0 ? 1 : documentedExports / totalExports;
printTable(summaries, options.label);
printSummary(totalExports, documentedExports, overallCoverage);
printUndocumentedHighlights(summaries);
if (failedFiles.length > 0) {
console.log();
console.log(`Failed to analyze ${failedFiles.length} file(s):`);
for (const failure of failedFiles) {
console.log(` ${failure.path}: ${failure.error}`);
}
}
if (options.writeReport) {
const missingEntries = summaries.flatMap(file =>
file.missing.map<UndocumentedEntry>(symbol => ({
file: file.relativePath,
symbol,
})),
);
const maxUndocumentedEntries = options.includeDetails
? missingEntries.length
: Math.min(50, missingEntries.length);
const files = options.includeDetails
? summaries
.filter(file => file.missing.length > 0)
.map<JsonReportFile>(file => ({
file: file.relativePath,
exports: file.totalExports,
documented: file.documentedExports,
undocumented: file.totalExports - file.documentedExports,
coverage: Number((file.coverage * 100).toFixed(2)),
missing: file.missing,
}))
: undefined;
const report: JsonReport = {
generatedAt: new Date().toISOString(),
label: options.label,
totals: {
exports: totalExports,
documented: documentedExports,
undocumented: totalExports - documentedExports,
coverage: Number((overallCoverage * 100).toFixed(2)),
},
undocumentedTotal: missingEntries.length,
undocumentedSampled: maxUndocumentedEntries,
undocumented: missingEntries.slice(0, maxUndocumentedEntries),
...(files ? { files } : {}),
};
await writeJsonReport(options.writeReport, report);
}
} catch (error) {
console.error('Failed to generate docstring report.');
if (error instanceof Error) {
console.error(error.message);
} else {
console.error(error);
}
process.exitCode = 1;
}
}
function parseArgs(args: string[]): CliOptions {
const patterns: string[] = [];
let writeReport: string | undefined;
let label: string | undefined;
let includeDetails = false;
const expectValue = (flag: string, value: string | undefined): string => {
if (!value) {
throw new Error(`Missing value for ${flag}`);
}
return value;
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--help' || arg === '-h') {
printUsage();
process.exit(0);
}
if (arg === '--write-report' || arg.startsWith('--write-report=')) {
if (arg.includes('=')) {
writeReport = arg.split('=')[1] ?? '';
if (!writeReport) {
throw new Error('Missing value for --write-report');
}
} else {
index += 1;
writeReport = expectValue('--write-report', args[index]);
}
continue;
}
if (arg === '--label' || arg.startsWith('--label=')) {
if (arg.includes('=')) {
label = arg.split('=')[1] ?? '';
} else {
index += 1;
label = expectValue('--label', args[index]);
}
continue;
}
if (arg.startsWith('--')) {
if (arg === '--details') {
includeDetails = true;
continue;
}
throw new Error(`Unknown option: ${arg}`);
}
patterns.push(arg);
}
if (patterns.length === 0) {
patterns.push(...DEFAULT_PATTERNS);
}
return { patterns, writeReport, label, includeDetails };
}
function printUsage(): void {
const usage = `Usage: docstring-report [pattern ...] [--write-report <path>] [--label <name>] [--details]
Examples:
yarn tsx scripts/docstring-report.ts
yarn tsx scripts/docstring-report.ts \"app/src/**/*.{ts,tsx}\"
yarn tsx scripts/docstring-report.ts \"app/src/**/*.{ts,tsx}\" --label \"Mobile App\" --write-report docs/coverage/app.json --details`;
console.log(usage);
}
async function resolveFiles(
patterns: string[],
root: string,
): Promise<string[]> {
const files = new Set<string>();
for (const pattern of patterns) {
for await (const match of glob(pattern, {
cwd: root,
// Exclude dotfiles and dot-directories
exclude: (name: string) => path.basename(name).startsWith('.'),
})) {
const resolved = path.resolve(root, String(match));
// Skip directories (glob may return them despite file extension patterns)
try {
const stat = await fs.stat(resolved);
if (stat.isDirectory()) {
continue;
}
} catch {
// File doesn't exist or can't be accessed, skip it
continue;
}
if (shouldIncludeFile(resolved, root)) {
files.add(resolved);
}
}
}
return Array.from(files).sort();
}
function shouldIncludeFile(filePath: string, root: string): boolean {
const relative = path.relative(root, filePath).replace(/\\/g, '/');
if (relative.endsWith('.d.ts') || relative.endsWith('.d.tsx')) {
return false;
}
if (/\.test\.[tj]sx?$/.test(relative) || /\.spec\.[tj]sx?$/.test(relative)) {
return false;
}
if (/\.stories\.[tj]sx?$/.test(relative)) {
return false;
}
if (relative.includes('/__tests__/')) {
return false;
}
return true;
}
async function analyzeFile(
filePath: string,
root: string,
): Promise<FileExportSummary> {
const content = await fs.readFile(filePath, 'utf8');
const scriptKind = filePath.endsWith('.tsx')
? ts.ScriptKind.TSX
: ts.ScriptKind.TS;
const sourceFile = ts.createSourceFile(
filePath,
content,
ts.ScriptTarget.Latest,
true,
scriptKind,
);
const entries = new Map<string, ExportEntry>();
const exportSpecifiers: Array<{ localName: string; exportedAs: string }> = [];
const exportDefaultStatements: ts.ExportAssignment[] = [];
const exportedDeclarations: Array<{
statement: ts.Statement;
hasDefault: boolean;
}> = [];
// First pass: Collect all declarations with their documentation status
for (const statement of sourceFile.statements) {
if (ts.isExportDeclaration(statement)) {
// Collect export specifiers for second pass
if (
!statement.moduleSpecifier &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause)
) {
for (const element of statement.exportClause.elements) {
const localName = element.propertyName
? element.propertyName.text
: element.name.text;
const exportedAs = element.name.text;
exportSpecifiers.push({ localName, exportedAs });
}
}
continue;
}
if (ts.isExportAssignment(statement)) {
if (!statement.isExportEquals) {
exportDefaultStatements.push(statement);
}
continue;
}
if (ts.isVariableStatement(statement)) {
const exported = hasExportModifier(statement.modifiers);
const statementDoc = hasDocComment(statement, sourceFile);
for (const declaration of statement.declarationList.declarations) {
// Extract all binding identifiers (handles destructuring)
const identifiers = getBindingIdentifiers(declaration);
if (identifiers.length === 0) {
continue;
}
const declarationDoc = hasDocComment(declaration, sourceFile);
for (const name of identifiers) {
const entry = ensureEntry(entries, name);
entry.kinds.add('variable');
entry.documented ||= statementDoc || declarationDoc;
}
if (exported) {
exportedDeclarations.push({ statement, hasDefault: false });
}
}
continue;
}
if (
ts.isFunctionDeclaration(statement) ||
ts.isClassDeclaration(statement) ||
ts.isInterfaceDeclaration(statement) ||
ts.isTypeAliasDeclaration(statement) ||
ts.isEnumDeclaration(statement) ||
ts.isModuleDeclaration(statement)
) {
const name = getDeclarationName(statement, sourceFile);
const hasExport = hasExportModifier(statement.modifiers);
const hasDefault = hasDefaultModifier(statement.modifiers);
// For anonymous default exports (e.g., export default function() {}),
// use "default" as the name so they're tracked in coverage
const effectiveName = !name && hasExport && hasDefault ? 'default' : name;
if (!effectiveName) {
continue;
}
const entry = ensureEntry(entries, effectiveName);
entry.kinds.add(getKindLabel(statement));
entry.documented ||= hasDocComment(statement, sourceFile);
if (hasExport) {
exportedDeclarations.push({ statement, hasDefault });
}
continue;
}
}
// Second pass: Process all exports now that all declarations are collected
// Process inline exported declarations
for (const { statement, hasDefault } of exportedDeclarations) {
if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
// Extract all binding identifiers (handles destructuring)
const identifiers = getBindingIdentifiers(declaration);
for (const name of identifiers) {
const entry = entries.get(name);
if (entry) {
entry.exported = true;
entry.exportedAs.add(name);
}
}
}
} else {
const name = getDeclarationName(statement, sourceFile);
// For anonymous default exports, use "default" as the name
const effectiveName = !name && hasDefault ? 'default' : name;
if (!effectiveName) {
continue;
}
const entry = entries.get(effectiveName);
if (entry) {
entry.exported = true;
// For inline default exports (export default function foo), add "default" not the name
const exportName = hasDefault ? 'default' : effectiveName;
entry.exportedAs.add(exportName);
}
}
}
// Process export specifiers (export { Foo, Bar })
for (const specifier of exportSpecifiers) {
const entry = entries.get(specifier.localName);
if (entry) {
entry.exported = true;
entry.exportedAs.add(specifier.exportedAs);
}
}
// Process export default statements (export default Foo)
for (const statement of exportDefaultStatements) {
const entry = ensureEntry(entries, 'default');
entry.exported = true;
entry.kinds.add('default');
entry.exportedAs.add('default');
// Check if the export statement itself is documented
entry.documented ||= hasDocComment(statement, sourceFile);
// If exporting an identifier (export default Foo), inherit documentation from the referenced declaration
if (ts.isIdentifier(statement.expression)) {
const referencedName = statement.expression.text;
const referencedEntry = entries.get(referencedName);
if (referencedEntry?.documented) {
entry.documented = true;
}
}
}
const relativePath = path.relative(root, filePath).replace(/\\/g, '/');
const exportedEntries = Array.from(entries.values()).filter(
entry => entry.exported,
);
const documentedEntries = exportedEntries.filter(entry => entry.documented);
const missing = exportedEntries
.filter(entry => !entry.documented)
.map(entry => formatMissingName(entry));
return {
filePath,
relativePath,
totalExports: exportedEntries.length,
documentedExports: documentedEntries.length,
coverage:
exportedEntries.length === 0
? 1
: documentedEntries.length / exportedEntries.length,
missing,
};
}
function ensureEntry(map: Map<string, ExportEntry>, key: string): ExportEntry {
const existing = map.get(key);
if (existing) {
return existing;
}
const entry: ExportEntry = {
localName: key,
kinds: new Set<string>(),
exportedAs: new Set<string>(),
documented: false,
exported: false,
};
map.set(key, entry);
return entry;
}
function hasExportModifier(
modifiers: ts.NodeArray<ts.Modifier> | undefined,
): boolean {
return Boolean(
modifiers?.some(
modifier =>
modifier.kind === ts.SyntaxKind.ExportKeyword ||
modifier.kind === ts.SyntaxKind.DefaultKeyword,
),
);
}
function hasDefaultModifier(
modifiers: ts.NodeArray<ts.Modifier> | undefined,
): boolean {
return Boolean(
modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword),
);
}
function getDeclarationName(
node: ts.Node,
sourceFile: ts.SourceFile,
): string | undefined {
if ('name' in node && node.name) {
const nameNode = (node as ts.Node & { name?: ts.Node }).name as
| ts.Node
| undefined;
if (!nameNode) {
return undefined;
}
if (
ts.isIdentifier(nameNode) ||
ts.isStringLiteralLike(nameNode) ||
ts.isNumericLiteral(nameNode)
) {
return nameNode.text;
}
return nameNode.getText(sourceFile).trim();
}
if (ts.isModuleDeclaration(node)) {
return node.name.text;
}
if (ts.isExportAssignment(node)) {
return 'default';
}
return undefined;
}
/**
* Extract all binding identifiers from a declaration.
* Handles destructuring patterns like { a, b } and [x, y].
*/
function getBindingIdentifiers(declaration: ts.VariableDeclaration): string[] {
const identifiers: string[] = [];
function collectIdentifiers(name: ts.BindingName): void {
if (ts.isIdentifier(name)) {
identifiers.push(name.text);
} else if (ts.isObjectBindingPattern(name)) {
for (const element of name.elements) {
collectIdentifiers(element.name);
}
} else if (ts.isArrayBindingPattern(name)) {
for (const element of name.elements) {
if (ts.isBindingElement(element)) {
collectIdentifiers(element.name);
}
}
}
}
collectIdentifiers(declaration.name);
return identifiers;
}
function getKindLabel(node: ts.Node): string {
if (ts.isFunctionDeclaration(node)) {
return 'function';
}
if (ts.isClassDeclaration(node)) {
return 'class';
}
if (ts.isInterfaceDeclaration(node)) {
return 'interface';
}
if (ts.isTypeAliasDeclaration(node)) {
return 'type';
}
if (ts.isEnumDeclaration(node)) {
return 'enum';
}
if (ts.isModuleDeclaration(node)) {
return 'namespace';
}
return 'declaration';
}
function hasDocComment(node: ts.Node, sourceFile: ts.SourceFile): boolean {
const jsDocNodes = (node as ts.Node & { jsDoc?: readonly ts.JSDoc[] }).jsDoc;
if (jsDocNodes && jsDocNodes.length > 0) {
return true;
}
const jsDocRanges = ts.getJSDocCommentRanges(node, sourceFile.text);
if (jsDocRanges && jsDocRanges.length > 0) {
return true;
}
const leadingRanges = ts.getLeadingCommentRanges(
sourceFile.text,
node.getFullStart(),
);
if (leadingRanges) {
return leadingRanges.some(range =>
sourceFile.text.slice(range.pos, range.end).startsWith('/**'),
);
}
return false;
}
function formatPercent(value: number): string {
return `${(value * 100).toFixed(2)}%`;
}
function printTable(summaries: FileExportSummary[], label?: string): void {
const title = label ? `Docstring coverage (${label})` : 'Docstring coverage';
console.log(title);
console.log('='.repeat(title.length));
const headers = ['File', 'Exports', 'With Docs', 'Coverage', 'Missing'];
const rows = summaries.map(summary => [
summary.relativePath,
summary.totalExports.toString(),
summary.documentedExports.toString(),
formatPercent(summary.coverage),
summary.missing.join(', '),
]);
const widths = headers.map((header, columnIndex) => {
const columnValues = rows.map(row => row[columnIndex]);
const maxContentLength = columnValues.reduce(
(max, value) => Math.max(max, value.length),
header.length,
);
const maxWidth =
columnIndex === 0
? Math.min(70, Math.max(20, maxContentLength))
: maxContentLength;
return maxWidth;
});
const formatRow = (values: string[]): string =>
values
.map((value, index) => {
const width = widths[index];
const trimmed =
index === 0 && value.length > width
? `${value.slice(value.length - width + 1)}`
: value;
return trimmed.padEnd(width, ' ');
})
.join(' ');
console.log(formatRow(headers));
console.log(
formatRow(
widths.map(width => '-'.repeat(Math.max(3, Math.min(width, 80)))),
),
);
rows.forEach(row => console.log(formatRow(row)));
}
function printSummary(
total: number,
documented: number,
coverage: number,
): void {
console.log();
if (total === 0) {
console.log('Overall coverage: 100.00% (0/0 exported declarations)');
return;
}
console.log(
`Overall coverage: ${formatPercent(coverage)} (${documented}/${total} exported declarations documented)`,
);
}
function printUndocumentedHighlights(summaries: FileExportSummary[]): void {
const missingEntries: Array<{ file: string; names: string[] }> = [];
for (const summary of summaries) {
if (summary.missing.length > 0) {
missingEntries.push({
file: summary.relativePath,
names: summary.missing,
});
}
}
if (missingEntries.length === 0) {
console.log('All exported declarations include TSDoc comments.');
return;
}
console.log();
console.log('Undocumented exports:');
for (const entry of missingEntries) {
console.log(` ${entry.file}`);
for (const name of entry.names) {
console.log(` - ${name}`);
}
}
}
function formatMissingName(entry: ExportEntry): string {
const exportedNames = Array.from(entry.exportedAs);
if (exportedNames.length === 0) {
return entry.localName;
}
const aliasList = exportedNames.filter(
name => name !== entry.localName && name !== 'default',
);
if (exportedNames.includes('default')) {
if (aliasList.length > 0) {
return `default (local: ${entry.localName}, aliases: ${aliasList.join(', ')})`;
}
if (entry.localName !== 'default') {
return `default (local: ${entry.localName})`;
}
return 'default export';
}
if (aliasList.length > 0) {
return `${aliasList.join(', ')} (local: ${entry.localName})`;
}
return entry.localName;
}
async function writeJsonReport(
targetPath: string,
report: JsonReport,
): Promise<void> {
const resolvedPath = path.resolve(process.cwd(), targetPath);
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
await fs.writeFile(
resolvedPath,
`${JSON.stringify(report, null, 2)}\n`,
'utf8',
);
console.log(
`\nSaved coverage snapshot to ${path.relative(process.cwd(), resolvedPath)}`,
);
}
void main();

3518
yarn.lock

File diff suppressed because it is too large Load Diff