Add docstring reporting script and workflows (#1333)

* Trim docstring coverage snapshots

* format all the tings

* update lock

* Update docstring coverage snapshots (#1521)

* docstring fixes

* address agent feedback

* update lock files

* address agent feedback

* lock react-native-svg version to prevent pipeline failures

* update docstring logic

* remove docstring coverage from ci

* remove old report, fix cursorignroe rule
This commit is contained in:
Justin Hernandez
2025-12-25 11:17:42 -08:00
committed by GitHub
parent 8c96f6fd5c
commit 7dbd46f245
17 changed files with 2076 additions and 765 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

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

@@ -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

@@ -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

@@ -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

@@ -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",

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

@@ -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');

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.20.3",
"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

@@ -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();

1501
yarn.lock

File diff suppressed because it is too large Load Diff