parityguard 0.0.0-stage → 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +170 -2
- package/data/capabilities.json +37 -0
- package/data/notifications.json +6 -0
- package/data/platform-support.json +13 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +73 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +23 -0
- package/dist/data.d.ts +2 -0
- package/dist/data.js +8 -0
- package/dist/engine.d.ts +17 -0
- package/dist/engine.js +73 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/project.d.ts +3 -0
- package/dist/project.js +74 -0
- package/dist/reporters.d.ts +4 -0
- package/dist/reporters.js +35 -0
- package/dist/rules/helpers.d.ts +19 -0
- package/dist/rules/helpers.js +35 -0
- package/dist/rules/index.d.ts +2 -0
- package/dist/rules/index.js +12 -0
- package/dist/rules/permissions.d.ts +14 -0
- package/dist/rules/permissions.js +109 -0
- package/dist/rules/pg001-unmatched-os-branch.d.ts +2 -0
- package/dist/rules/pg001-unmatched-os-branch.js +58 -0
- package/dist/rules/pg002-incomplete-platform-select.d.ts +2 -0
- package/dist/rules/pg002-incomplete-platform-select.js +39 -0
- package/dist/rules/pg003-divergent-platform-files.d.ts +2 -0
- package/dist/rules/pg003-divergent-platform-files.js +36 -0
- package/dist/rules/pg008-android-notification-permission.d.ts +2 -0
- package/dist/rules/pg008-android-notification-permission.js +51 -0
- package/dist/rules/pg009-android-back-button.d.ts +2 -0
- package/dist/rules/pg009-android-back-button.js +50 -0
- package/dist/rules/pg010-unsupported-platform-dependency.d.ts +2 -0
- package/dist/rules/pg010-unsupported-platform-dependency.js +45 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.js +1 -0
- package/package.json +54 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ParityGuard contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,171 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ParityGuard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Catch iOS/Android drift in React Native + TypeScript projects before it reaches a device.
|
|
4
|
+
|
|
5
|
+
> **Status: v0.1 alpha.** Five rules, tested on small fixtures and two clean real-world repos. Expect rough edges, and please report false positives.
|
|
6
|
+
|
|
7
|
+
ESLint tells you your code is wrong. ParityGuard tells you your code is right on one platform and wrong on the other.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
| Use | Node |
|
|
12
|
+
|---|---|
|
|
13
|
+
| Developing ParityGuard (build + tests) | **22.12+** (Vitest 5 requirement) |
|
|
14
|
+
| Running the built CLI | 20.19+ (only tested on Node 22) |
|
|
15
|
+
|
|
16
|
+
Works on macOS, Linux and Windows. No network access is needed during a run.
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
git clone <your-repo-url> parityguard && cd parityguard
|
|
22
|
+
npm install
|
|
23
|
+
npm run build
|
|
24
|
+
npm test
|
|
25
|
+
|
|
26
|
+
node dist/cli.js check <path-to-your-rn-app>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Once published to npm this becomes `npx parityguard check`.
|
|
30
|
+
|
|
31
|
+
## Commands
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
parityguard check [path] # analyze a project (default: current dir)
|
|
35
|
+
parityguard check [path] --strict # include medium/low-confidence findings
|
|
36
|
+
parityguard check [path] --format sarif # terminal (default) | json | sarif
|
|
37
|
+
parityguard check [path] --update-baseline # record existing findings; only NEW drift fails later
|
|
38
|
+
parityguard check [path] --no-baseline # ignore the baseline file
|
|
39
|
+
parityguard check [path] --config <file> # explicit config file
|
|
40
|
+
parityguard rules # list rules
|
|
41
|
+
parityguard explain PG002 # explain one rule
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Exit codes:** `0` clean or warnings only, `1` errors found, `2` usage/internal error.
|
|
45
|
+
|
|
46
|
+
## Rules
|
|
47
|
+
|
|
48
|
+
| ID | What it catches | Default |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| PG001 | `if (Platform.OS === 'ios')` with no else and no early return, **unless** the same function also handles the other platform (another `Platform.OS` check or `Platform.select`). The `&&` form (`Platform.OS === 'ios' && …`) is medium confidence and needs `--strict`. | warn |
|
|
51
|
+
| PG002 | `Platform.select` missing `ios`/`android` with no `default` or `native` key. Style-like usage (spread, or all values are objects) is medium confidence, `--strict` only. | error |
|
|
52
|
+
| PG003 | `X.ios.tsx` and `X.android.tsx` that export different names | error |
|
|
53
|
+
| PG006 | Privacy-sensitive library used with no iOS usage string, **or** a usage string that is empty | error |
|
|
54
|
+
| PG007 | Library used with no Android permission, **or** `PermissionsAndroid.PERMISSIONS.X` used in code but not declared | error |
|
|
55
|
+
| PG008 | Notification library used without `POST_NOTIFICATIONS` declared (Android 13+). A missing runtime request is medium confidence (`--strict`) | warn |
|
|
56
|
+
| PG009 | `<Modal>` without `onRequestClose`, or a `BackHandler.addEventListener` subscription that is discarded and never removed | warn |
|
|
57
|
+
| PG010 | Single-platform package (e.g. `expo-apple-authentication`) imported with no platform guard, or in the wrong `.ios`/`.android` file | warn |
|
|
58
|
+
|
|
59
|
+
PG004 and PG005 (iOS-only props, shadow without elevation) are reserved for a later release.
|
|
60
|
+
|
|
61
|
+
Rules are data-driven where possible, so adding coverage needs no code change:
|
|
62
|
+
- `data/capabilities.json`: libraries and the iOS keys / Android permissions they need (PG006, PG007)
|
|
63
|
+
- `data/notifications.json`: notification libraries and request calls (PG008)
|
|
64
|
+
- `data/platform-support.json`: single-platform packages (PG010). Seeded from package docs, so verify entries against current docs and send corrections.
|
|
65
|
+
Config plugins (Expo) are matched by name only, so a plugin is assumed to declare what it should.
|
|
66
|
+
|
|
67
|
+
Run `parityguard explain <ID>` for details.
|
|
68
|
+
|
|
69
|
+
## Handling intentional differences
|
|
70
|
+
|
|
71
|
+
Suppress a finding with a comment on the same line or the line above. Always give a reason:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// parity-ignore PG001: Android uses the system picker
|
|
75
|
+
if (Platform.OS === 'ios') { showNativePicker(); }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Omit the rule ID to suppress all rules on that line.
|
|
79
|
+
|
|
80
|
+
## Adopting on an existing app (baseline)
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
parityguard check . --update-baseline # writes parityguard.baseline.json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Commit the baseline file. From then on only findings that are not in it are reported. Entries are matched by rule, file and source text, so they survive unrelated line shifts.
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
Create `parityguard.config.json` (or `.mjs` / `.js`) in the project root:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"rules": { "PG001": "warn", "PG003": "off" },
|
|
95
|
+
"exclude": ["src/legacy/**"]
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- `rules`: set a rule to `error`, `warn`, `info` or `off`.
|
|
100
|
+
- `exclude`: globs relative to the project root.
|
|
101
|
+
|
|
102
|
+
See `examples/parityguard.config.json`.
|
|
103
|
+
|
|
104
|
+
## GitHub Action
|
|
105
|
+
|
|
106
|
+
Copy `examples/parityguard-workflow.yml` into your app repo as `.github/workflows/parityguard.yml`. It runs the check, uploads SARIF to GitHub code scanning, and fails the job on errors.
|
|
107
|
+
|
|
108
|
+
Action inputs: `path`, `strict`, `version`, `sarif-file`, `upload-sarif`, `fail-on-error`.
|
|
109
|
+
|
|
110
|
+
Before using it: publish the package to npm (the Action runs it through `npx`), replace `YOUR_ORG` in the workflow, and confirm the pinned versions of `actions/setup-node` and `github/codeql-action` are current. The Action has not yet been run on GitHub.
|
|
111
|
+
|
|
112
|
+
## Known limitations
|
|
113
|
+
|
|
114
|
+
- Dynamic `app.config.ts/js` cannot be evaluated, so permission findings there are low confidence (hidden without `--strict`).
|
|
115
|
+
- Apps nested inside a larger repo are not auto-discovered yet. Point the CLI at the app folder.
|
|
116
|
+
- Only `Platform.OS` comparisons against `'ios'` and `'android'` are analyzed. `web` is ignored.
|
|
117
|
+
- `ios/` and `android/` source folders are not scanned for JS/TS.
|
|
118
|
+
- PG010 treats a file as guarded if it mentions `Platform`, `isIOS` or `isAndroid` anywhere; it cannot follow guards in other files.
|
|
119
|
+
- PG008 does not read `targetSdkVersion`; it assumes you target Android 13+.
|
|
120
|
+
- Not yet validated on a large corpus, so the false-positive rate is unknown.
|
|
121
|
+
|
|
122
|
+
## Measuring false positives (corpus)
|
|
123
|
+
|
|
124
|
+
1. List repos in `corpus/repos.txt` (`owner/name` or `owner/name:subdir`).
|
|
125
|
+
2. `./scripts/corpus.sh` clones them and writes `corpus/results.csv` (needs bash and git; on Windows use WSL or Git Bash).
|
|
126
|
+
3. Label each row `TP` or `FP` in the last column.
|
|
127
|
+
4. `node scripts/fp-rate.mjs` prints the false-positive rate per rule. Target: under 5% for default-on rules.
|
|
128
|
+
|
|
129
|
+
## Project layout
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
src/
|
|
133
|
+
cli.ts command-line entry
|
|
134
|
+
engine.ts runs rules, applies ignores, baseline, confidence
|
|
135
|
+
project.ts loads source files and Expo/bare native config
|
|
136
|
+
config.ts config file loading and validation
|
|
137
|
+
reporters.ts terminal, JSON, SARIF output
|
|
138
|
+
rules/ one file per rule, plus shared helpers
|
|
139
|
+
data/ versioned JSON data (capabilities)
|
|
140
|
+
fixtures/ bad-app and good-app used by tests
|
|
141
|
+
test/ Vitest tests
|
|
142
|
+
scripts/ corpus tooling
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Adding a rule
|
|
146
|
+
|
|
147
|
+
1. Create `src/rules/pgXXX-name.ts` exporting a `Rule` (`id`, `title`, `severity`, `confidence`, `explain`, `check`).
|
|
148
|
+
2. Register it in `src/rules/index.ts`.
|
|
149
|
+
3. Add a violation to `fixtures/bad-app`, keep `fixtures/good-app` clean, and update `test/engine.test.ts`.
|
|
150
|
+
4. Prefer fewer, high-confidence rules. Medium-confidence rules stay hidden unless `--strict`.
|
|
151
|
+
|
|
152
|
+
## Releasing
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npm run bump:alpha # also: bump:patch | bump:minor | bump:major (needs a clean git tree)
|
|
156
|
+
npm run publish:alpha # or publish:latest for stable
|
|
157
|
+
npm run release:alpha # bump + publish alpha + push commit and tag
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
See `docs/ADOPTION_AND_CONTRIBUTING.md` for the full release process.
|
|
161
|
+
|
|
162
|
+
## Troubleshooting
|
|
163
|
+
|
|
164
|
+
- `tsc` or `vitest` not found: run `npm install` first.
|
|
165
|
+
- Tests fail on startup: check `node -v` (need 22.12+ for development).
|
|
166
|
+
- Nothing found in an Expo project using `app.config.ts`: expected, see limitations.
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT. Add a `LICENSE` file before publishing.
|
|
171
|
+
x
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dataVersion": "2026.11.1",
|
|
3
|
+
"capabilities": [
|
|
4
|
+
{ "id": "camera", "imports": ["expo-camera", "react-native-vision-camera", "react-native-camera"],
|
|
5
|
+
"iosKeys": ["NSCameraUsageDescription"], "androidPermissions": ["android.permission.CAMERA"],
|
|
6
|
+
"expoPlugins": ["expo-camera", "react-native-vision-camera"] },
|
|
7
|
+
{ "id": "location", "imports": ["expo-location", "react-native-geolocation-service", "@react-native-community/geolocation"],
|
|
8
|
+
"iosKeys": ["NSLocationWhenInUseUsageDescription", "NSLocationAlwaysAndWhenInUseUsageDescription"],
|
|
9
|
+
"androidPermissions": ["android.permission.ACCESS_FINE_LOCATION", "android.permission.ACCESS_COARSE_LOCATION"],
|
|
10
|
+
"expoPlugins": ["expo-location"] },
|
|
11
|
+
{ "id": "microphone", "imports": ["expo-av", "react-native-audio-recorder-player"],
|
|
12
|
+
"iosKeys": ["NSMicrophoneUsageDescription"], "androidPermissions": ["android.permission.RECORD_AUDIO"],
|
|
13
|
+
"expoPlugins": ["expo-av", "expo-audio"] },
|
|
14
|
+
{ "id": "contacts", "imports": ["expo-contacts", "react-native-contacts"],
|
|
15
|
+
"iosKeys": ["NSContactsUsageDescription"], "androidPermissions": ["android.permission.READ_CONTACTS"],
|
|
16
|
+
"expoPlugins": ["expo-contacts"] },
|
|
17
|
+
{ "id": "photos", "imports": ["expo-image-picker", "expo-media-library", "@react-native-camera-roll/camera-roll"],
|
|
18
|
+
"iosKeys": ["NSPhotoLibraryUsageDescription"],
|
|
19
|
+
"androidPermissions": ["android.permission.READ_MEDIA_IMAGES", "android.permission.READ_EXTERNAL_STORAGE"],
|
|
20
|
+
"expoPlugins": ["expo-image-picker", "expo-media-library"] },
|
|
21
|
+
{ "id": "biometrics", "imports": ["expo-local-authentication"],
|
|
22
|
+
"iosKeys": ["NSFaceIDUsageDescription"],
|
|
23
|
+
"androidPermissions": ["android.permission.USE_BIOMETRIC", "android.permission.USE_FINGERPRINT"],
|
|
24
|
+
"expoPlugins": ["expo-local-authentication"] },
|
|
25
|
+
{ "id": "bluetooth", "imports": ["react-native-ble-plx", "react-native-ble-manager"],
|
|
26
|
+
"iosKeys": ["NSBluetoothAlwaysUsageDescription"],
|
|
27
|
+
"androidPermissions": ["android.permission.BLUETOOTH_SCAN", "android.permission.BLUETOOTH_CONNECT", "android.permission.BLUETOOTH"],
|
|
28
|
+
"expoPlugins": ["react-native-ble-plx"] },
|
|
29
|
+
{ "id": "calendar", "imports": ["expo-calendar"],
|
|
30
|
+
"iosKeys": ["NSCalendarsUsageDescription", "NSCalendarsFullAccessUsageDescription"],
|
|
31
|
+
"androidPermissions": ["android.permission.READ_CALENDAR", "android.permission.WRITE_CALENDAR"],
|
|
32
|
+
"expoPlugins": ["expo-calendar"] },
|
|
33
|
+
{ "id": "tracking", "imports": ["expo-tracking-transparency"],
|
|
34
|
+
"iosKeys": ["NSUserTrackingUsageDescription"], "androidPermissions": [],
|
|
35
|
+
"expoPlugins": ["expo-tracking-transparency"] }
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dataVersion": "2026.11.1",
|
|
3
|
+
"imports": ["expo-notifications", "@notifee/react-native", "react-native-push-notification", "@react-native-firebase/messaging"],
|
|
4
|
+
"expoPlugins": ["expo-notifications"],
|
|
5
|
+
"requestCalls": ["requestPermissionsAsync", "requestPermission", "requestPermissions"]
|
|
6
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dataVersion": "2026.11.1",
|
|
3
|
+
"_note": "Seeded from package documentation; verify each entry against the package's current docs before relying on it. PRs welcome.",
|
|
4
|
+
"packages": [
|
|
5
|
+
{ "package": "expo-apple-authentication", "platform": "ios" },
|
|
6
|
+
{ "package": "react-native-health", "platform": "ios" },
|
|
7
|
+
{ "package": "@react-native-community/push-notification-ios", "platform": "ios" },
|
|
8
|
+
{ "package": "expo-intent-launcher", "platform": "android" },
|
|
9
|
+
{ "package": "expo-navigation-bar", "platform": "android" },
|
|
10
|
+
{ "package": "react-native-health-connect", "platform": "android" },
|
|
11
|
+
{ "package": "react-native-send-intent", "platform": "android" }
|
|
12
|
+
]
|
|
13
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { analyze, loadBaseline, writeBaseline } from "./engine.js";
|
|
8
|
+
import { json, sarif, terminal } from "./reporters.js";
|
|
9
|
+
import { rules } from "./rules/index.js";
|
|
10
|
+
const HELP = `parityguard — catch iOS/Android drift before it reaches a device
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
parityguard check [path] [--format terminal|json|sarif] [--strict] [--no-baseline] [--config file]
|
|
14
|
+
parityguard check [path] --update-baseline
|
|
15
|
+
parityguard rules
|
|
16
|
+
parityguard explain <RULE_ID>
|
|
17
|
+
parityguard --version
|
|
18
|
+
|
|
19
|
+
Exit codes: 0 clean (or warnings only), 1 errors found, 2 usage/internal error.`;
|
|
20
|
+
async function main() {
|
|
21
|
+
const { values, positionals } = parseArgs({
|
|
22
|
+
allowPositionals: true,
|
|
23
|
+
options: {
|
|
24
|
+
format: { type: "string", default: "terminal" },
|
|
25
|
+
strict: { type: "boolean", default: false },
|
|
26
|
+
"update-baseline": { type: "boolean", default: false },
|
|
27
|
+
"no-baseline": { type: "boolean", default: false },
|
|
28
|
+
config: { type: "string" },
|
|
29
|
+
version: { type: "boolean", short: "v", default: false },
|
|
30
|
+
help: { type: "boolean", short: "h", default: false },
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
if (values.version) {
|
|
34
|
+
const pkg = JSON.parse(fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
35
|
+
console.log(pkg.version);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
const [cmd, arg] = positionals;
|
|
39
|
+
if (values.help || !cmd) {
|
|
40
|
+
console.log(HELP);
|
|
41
|
+
return cmd ? 0 : 2;
|
|
42
|
+
}
|
|
43
|
+
if (cmd === "rules") {
|
|
44
|
+
rules.forEach((r) => console.log(`${r.id} ${r.severity.padEnd(5)} ${r.title}`));
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
if (cmd === "explain") {
|
|
48
|
+
const r = rules.find((x) => x.id === arg?.toUpperCase());
|
|
49
|
+
if (!r) {
|
|
50
|
+
console.error(`Unknown rule: ${arg}`);
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
console.log(`${r.id} — ${r.title}\nDefault severity: ${r.severity}, confidence: ${r.confidence}\n\n${r.explain}`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
if (cmd !== "check") {
|
|
57
|
+
console.error(`Unknown command: ${cmd}\n\n${HELP}`);
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
const root = path.resolve(arg ?? ".");
|
|
61
|
+
const baseline = values["no-baseline"] || values["update-baseline"] ? undefined : loadBaseline(root);
|
|
62
|
+
const config = await loadConfig(root, values.config);
|
|
63
|
+
const result = analyze(root, { strict: values.strict, baseline, config });
|
|
64
|
+
if (values["update-baseline"]) {
|
|
65
|
+
writeBaseline(root, result.findings);
|
|
66
|
+
console.log(`Baseline written with ${result.findings.length} finding(s). Only new drift will fail from now on.`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
const out = values.format === "json" ? json(result) : values.format === "sarif" ? sarif(result) : terminal(result);
|
|
70
|
+
console.log(out);
|
|
71
|
+
return result.findings.some((f) => f.severity === "error") ? 1 : 0;
|
|
72
|
+
}
|
|
73
|
+
main().then((c) => { process.exitCode = c; }, (e) => { console.error(e instanceof Error ? e.message : e); process.exitCode = 2; });
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Severity } from "./types.js";
|
|
2
|
+
export interface Config {
|
|
3
|
+
/** Override severity per rule, or turn it off. */
|
|
4
|
+
rules?: Record<string, Severity | "off">;
|
|
5
|
+
/** Globs (relative to project root) to skip, e.g. "src/legacy/**". */
|
|
6
|
+
exclude?: string[];
|
|
7
|
+
}
|
|
8
|
+
export declare function loadConfig(root: string, explicit?: string): Promise<Config>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
const NAMES = ["parityguard.config.json", "parityguard.config.mjs", "parityguard.config.js"];
|
|
5
|
+
export async function loadConfig(root, explicit) {
|
|
6
|
+
const file = explicit ? path.resolve(explicit) : NAMES.map((n) => path.join(root, n)).find((p) => fs.existsSync(p));
|
|
7
|
+
if (!file)
|
|
8
|
+
return {};
|
|
9
|
+
const cfg = file.endsWith(".json")
|
|
10
|
+
? JSON.parse(fs.readFileSync(file, "utf8"))
|
|
11
|
+
: (await import(pathToFileURL(file).href)).default;
|
|
12
|
+
validate(cfg, file);
|
|
13
|
+
return cfg;
|
|
14
|
+
}
|
|
15
|
+
function validate(cfg, file) {
|
|
16
|
+
const ok = new Set(["error", "warn", "info", "off"]);
|
|
17
|
+
for (const [id, sev] of Object.entries(cfg.rules ?? {})) {
|
|
18
|
+
if (!ok.has(sev))
|
|
19
|
+
throw new Error(`${file}: rule ${id} has invalid severity "${sev}" (use error|warn|info|off)`);
|
|
20
|
+
}
|
|
21
|
+
if (cfg.exclude && !Array.isArray(cfg.exclude))
|
|
22
|
+
throw new Error(`${file}: "exclude" must be an array of globs`);
|
|
23
|
+
}
|
package/dist/data.d.ts
ADDED
package/dist/data.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
/** Loads a versioned JSON data file shipped in /data (works from src/ and dist/). */
|
|
6
|
+
export function loadData(name) {
|
|
7
|
+
return JSON.parse(fs.readFileSync(path.resolve(here, "../data", name), "utf8"));
|
|
8
|
+
}
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Config } from "./config.js";
|
|
2
|
+
import type { Finding } from "./types.js";
|
|
3
|
+
export interface AnalyzeOptions {
|
|
4
|
+
strict?: boolean;
|
|
5
|
+
baseline?: Set<string>;
|
|
6
|
+
config?: Config;
|
|
7
|
+
}
|
|
8
|
+
export interface AnalyzeResult {
|
|
9
|
+
findings: Finding[];
|
|
10
|
+
suppressed: number;
|
|
11
|
+
baselined: number;
|
|
12
|
+
hidden: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function analyze(root: string, opts?: AnalyzeOptions): AnalyzeResult;
|
|
15
|
+
export declare const BASELINE_FILE = "parityguard.baseline.json";
|
|
16
|
+
export declare function loadBaseline(root: string): Set<string> | undefined;
|
|
17
|
+
export declare function writeBaseline(root: string, findings: Finding[]): void;
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadProject } from "./project.js";
|
|
5
|
+
import { rules } from "./rules/index.js";
|
|
6
|
+
const IGNORE_RE = /parity-ignore(?:\s+([A-Z0-9, ]+?))?(?:\s*:\s*(.*))?\s*(?:\*\/)?\s*$/;
|
|
7
|
+
function isSuppressed(text, line, ruleId) {
|
|
8
|
+
const lines = text.split(/\r?\n/);
|
|
9
|
+
for (const idx of [line - 1, line - 2]) { // same line, or the line above
|
|
10
|
+
const m = lines[idx]?.match(IGNORE_RE);
|
|
11
|
+
if (!m || !/(\/\/|\/\*)\s*parity-ignore/.test(lines[idx]))
|
|
12
|
+
continue;
|
|
13
|
+
const ids = m[1]?.split(/[,\s]+/).filter(Boolean);
|
|
14
|
+
if (!ids || ids.includes(ruleId))
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
export function analyze(root, opts = {}) {
|
|
20
|
+
root = path.resolve(root);
|
|
21
|
+
const ctx = loadProject(root, opts.config?.exclude);
|
|
22
|
+
const findings = [];
|
|
23
|
+
let suppressed = 0, baselined = 0, hidden = 0;
|
|
24
|
+
for (const base of rules) {
|
|
25
|
+
const override = opts.config?.rules?.[base.id];
|
|
26
|
+
if (override === "off")
|
|
27
|
+
continue;
|
|
28
|
+
const rule = override ? { ...base, severity: override } : base;
|
|
29
|
+
const seen = new Map();
|
|
30
|
+
const report = (r) => {
|
|
31
|
+
const source = fs.readFileSync(r.file, "utf8");
|
|
32
|
+
const text = source.split(/\r?\n/)[r.line - 1]?.trim() ?? "";
|
|
33
|
+
const rel = path.relative(root, r.file).split(path.sep).join("/");
|
|
34
|
+
const confidence = r.confidence ?? rule.confidence;
|
|
35
|
+
// Identical source lines in one file get an occurrence index, so baselines can tell them apart.
|
|
36
|
+
// Counted before any filtering so the index doesn't depend on flags like --strict.
|
|
37
|
+
const key = `${rule.id}|${rel}|${r.anchor ?? text}`;
|
|
38
|
+
const nth = seen.get(key) ?? 0;
|
|
39
|
+
seen.set(key, nth + 1);
|
|
40
|
+
const fingerprint = crypto.createHash("sha1").update(nth === 0 ? key : `${key}#${nth}`).digest("hex").slice(0, 16);
|
|
41
|
+
if (isSuppressed(source, r.line, rule.id)) {
|
|
42
|
+
suppressed++;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (opts.baseline?.has(fingerprint)) {
|
|
46
|
+
baselined++;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (confidence !== "high" && !opts.strict) {
|
|
50
|
+
hidden++;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
findings.push({
|
|
54
|
+
ruleId: rule.id, severity: rule.severity, confidence, message: r.message, why: r.why, fix: r.fix,
|
|
55
|
+
file: rel, line: r.line, column: r.column, fingerprint,
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
rule.check(ctx, report);
|
|
59
|
+
}
|
|
60
|
+
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
61
|
+
return { findings, suppressed, baselined, hidden };
|
|
62
|
+
}
|
|
63
|
+
export const BASELINE_FILE = "parityguard.baseline.json";
|
|
64
|
+
export function loadBaseline(root) {
|
|
65
|
+
const p = path.join(root, BASELINE_FILE);
|
|
66
|
+
if (!fs.existsSync(p))
|
|
67
|
+
return undefined;
|
|
68
|
+
return new Set(JSON.parse(fs.readFileSync(p, "utf8")).fingerprints);
|
|
69
|
+
}
|
|
70
|
+
export function writeBaseline(root, findings) {
|
|
71
|
+
const fingerprints = [...new Set(findings.map((f) => f.fingerprint))].sort();
|
|
72
|
+
fs.writeFileSync(path.join(root, BASELINE_FILE), JSON.stringify({ version: 1, fingerprints }, null, 2) + "\n");
|
|
73
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/project.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Project } from "ts-morph";
|
|
4
|
+
const IGNORE = ["node_modules", "dist", "build", "Pods", ".expo", "coverage", "ios", "android"];
|
|
5
|
+
export function loadProject(root, exclude = []) {
|
|
6
|
+
const project = new Project({
|
|
7
|
+
skipAddingFilesFromTsConfig: true,
|
|
8
|
+
skipFileDependencyResolution: true,
|
|
9
|
+
compilerOptions: { allowJs: true, jsx: 4 },
|
|
10
|
+
});
|
|
11
|
+
const globs = [`${root}/**/*.{ts,tsx,js,jsx}`, `!${root}/**/*.d.ts`, ...IGNORE.map((d) => `!${root}/**/${d}/**`), ...exclude.map((g) => `!${root}/${g}`)];
|
|
12
|
+
project.addSourceFilesAtPaths(globs);
|
|
13
|
+
return { root, project, sourceFiles: project.getSourceFiles(), native: loadNativeConfig(root) };
|
|
14
|
+
}
|
|
15
|
+
function readJson(p) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function loadNativeConfig(root) {
|
|
24
|
+
const n = {
|
|
25
|
+
isExpo: false, dynamicConfig: false, hasIosConfig: false, hasAndroidConfig: false,
|
|
26
|
+
iosKeys: new Set(), iosEmpty: new Set(), androidPermissions: new Set(), expoPlugins: new Set(),
|
|
27
|
+
};
|
|
28
|
+
// Expo static config
|
|
29
|
+
const appJson = readJson(path.join(root, "app.json"));
|
|
30
|
+
const expo = appJson?.expo ?? appJson;
|
|
31
|
+
if (expo && typeof expo === "object" && (appJson?.expo || expo.name)) {
|
|
32
|
+
n.isExpo = true;
|
|
33
|
+
n.hasIosConfig = true;
|
|
34
|
+
n.hasAndroidConfig = true;
|
|
35
|
+
for (const [k, v] of Object.entries(expo.ios?.infoPlist ?? {})) {
|
|
36
|
+
n.iosKeys.add(k);
|
|
37
|
+
if (typeof v === "string" && v.trim() === "")
|
|
38
|
+
n.iosEmpty.add(k);
|
|
39
|
+
}
|
|
40
|
+
for (const p of expo.android?.permissions ?? []) {
|
|
41
|
+
n.androidPermissions.add(p.includes(".") ? p : `android.permission.${p}`);
|
|
42
|
+
}
|
|
43
|
+
for (const pl of expo.plugins ?? [])
|
|
44
|
+
n.expoPlugins.add(Array.isArray(pl) ? pl[0] : pl);
|
|
45
|
+
}
|
|
46
|
+
if (["app.config.ts", "app.config.js"].some((f) => fs.existsSync(path.join(root, f))) && !n.isExpo) {
|
|
47
|
+
n.isExpo = true;
|
|
48
|
+
n.dynamicConfig = true;
|
|
49
|
+
}
|
|
50
|
+
// Bare iOS: ios/<App>/Info.plist
|
|
51
|
+
const iosDir = path.join(root, "ios");
|
|
52
|
+
if (fs.existsSync(iosDir)) {
|
|
53
|
+
for (const d of fs.readdirSync(iosDir, { withFileTypes: true })) {
|
|
54
|
+
const plist = path.join(iosDir, d.name, "Info.plist");
|
|
55
|
+
if (d.isDirectory() && fs.existsSync(plist)) {
|
|
56
|
+
n.hasIosConfig = true;
|
|
57
|
+
const xml = fs.readFileSync(plist, "utf8");
|
|
58
|
+
for (const m of xml.matchAll(/<key>([^<]+)<\/key>/g))
|
|
59
|
+
n.iosKeys.add(m[1]);
|
|
60
|
+
for (const m of xml.matchAll(/<key>([^<]+)<\/key>\s*<string>\s*<\/string>/g))
|
|
61
|
+
n.iosEmpty.add(m[1]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Bare Android manifest
|
|
66
|
+
const manifest = path.join(root, "android/app/src/main/AndroidManifest.xml");
|
|
67
|
+
if (fs.existsSync(manifest)) {
|
|
68
|
+
n.hasAndroidConfig = true;
|
|
69
|
+
for (const m of fs.readFileSync(manifest, "utf8").matchAll(/<uses-permission[^>]*android:name="([^"]+)"/g)) {
|
|
70
|
+
n.androidPermissions.add(m[1]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return n;
|
|
74
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { rules } from "./rules/index.js";
|
|
2
|
+
const c = (code) => (s) => (process.stdout.isTTY ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
3
|
+
const red = c(31), yellow = c(33), cyan = c(36), dim = c(2), bold = c(1);
|
|
4
|
+
export function terminal(r) {
|
|
5
|
+
const out = [];
|
|
6
|
+
let file = "";
|
|
7
|
+
for (const f of r.findings) {
|
|
8
|
+
if (f.file !== file) {
|
|
9
|
+
file = f.file;
|
|
10
|
+
out.push("", bold(file));
|
|
11
|
+
}
|
|
12
|
+
const sev = f.severity === "error" ? red("error") : f.severity === "warn" ? yellow("warn ") : cyan("info ");
|
|
13
|
+
out.push(` ${f.line}:${f.column} ${sev} ${f.message} ${dim(f.ruleId)}`);
|
|
14
|
+
out.push(dim(` why: ${f.why}`), dim(` fix: ${f.fix}`));
|
|
15
|
+
}
|
|
16
|
+
const errors = r.findings.filter((f) => f.severity === "error").length;
|
|
17
|
+
out.push("", `${r.findings.length} finding(s), ${errors} error(s). ` +
|
|
18
|
+
dim(`(${r.suppressed} suppressed, ${r.baselined} baselined, ${r.hidden} low-confidence hidden — use --strict to show)`));
|
|
19
|
+
return out.join("\n");
|
|
20
|
+
}
|
|
21
|
+
export const json = (r) => JSON.stringify(r, null, 2);
|
|
22
|
+
export function sarif(r) {
|
|
23
|
+
return JSON.stringify({
|
|
24
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json", version: "2.1.0",
|
|
25
|
+
runs: [{
|
|
26
|
+
tool: { driver: { name: "ParityGuard", rules: rules.map((x) => ({ id: x.id, shortDescription: { text: x.title }, fullDescription: { text: x.explain } })) } },
|
|
27
|
+
results: r.findings.map((f) => ({
|
|
28
|
+
ruleId: f.ruleId, level: f.severity === "error" ? "error" : f.severity === "warn" ? "warning" : "note",
|
|
29
|
+
message: { text: `${f.message} ${f.why}` },
|
|
30
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: f.file }, region: { startLine: f.line, startColumn: f.column } } }],
|
|
31
|
+
partialFingerprints: { parityGuard: f.fingerprint },
|
|
32
|
+
})),
|
|
33
|
+
}],
|
|
34
|
+
}, null, 2);
|
|
35
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Node } from "ts-morph";
|
|
2
|
+
export type PlatformName = "ios" | "android";
|
|
3
|
+
/** Recognises `Platform.OS === 'ios'` and variants. */
|
|
4
|
+
export declare function asPlatformCheck(expr: Node): {
|
|
5
|
+
platform: PlatformName;
|
|
6
|
+
negated: boolean;
|
|
7
|
+
} | undefined;
|
|
8
|
+
export declare function endsWithExit(stmt: Node): boolean;
|
|
9
|
+
export declare const pos: (n: Node) => {
|
|
10
|
+
file: string;
|
|
11
|
+
line: number;
|
|
12
|
+
column: number;
|
|
13
|
+
};
|
|
14
|
+
export declare function effectivePlatform(c: {
|
|
15
|
+
platform: PlatformName;
|
|
16
|
+
negated: boolean;
|
|
17
|
+
}): PlatformName;
|
|
18
|
+
/** Nearest enclosing function-like node, or the source file. */
|
|
19
|
+
export declare function enclosingScope(n: Node): Node;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Node, SyntaxKind } from "ts-morph";
|
|
2
|
+
/** Recognises `Platform.OS === 'ios'` and variants. */
|
|
3
|
+
export function asPlatformCheck(expr) {
|
|
4
|
+
if (!Node.isBinaryExpression(expr))
|
|
5
|
+
return undefined;
|
|
6
|
+
const op = expr.getOperatorToken().getKind();
|
|
7
|
+
const eq = op === SyntaxKind.EqualsEqualsEqualsToken || op === SyntaxKind.EqualsEqualsToken;
|
|
8
|
+
const neq = op === SyntaxKind.ExclamationEqualsEqualsToken || op === SyntaxKind.ExclamationEqualsToken;
|
|
9
|
+
if (!eq && !neq)
|
|
10
|
+
return undefined;
|
|
11
|
+
const [l, r] = [expr.getLeft(), expr.getRight()];
|
|
12
|
+
const [osSide, litSide] = l.getText() === "Platform.OS" ? [l, r] : r.getText() === "Platform.OS" ? [r, l] : [undefined, undefined];
|
|
13
|
+
if (!osSide || !litSide || !Node.isStringLiteral(litSide))
|
|
14
|
+
return undefined;
|
|
15
|
+
const v = litSide.getLiteralValue();
|
|
16
|
+
if (v !== "ios" && v !== "android")
|
|
17
|
+
return undefined;
|
|
18
|
+
return { platform: v, negated: neq };
|
|
19
|
+
}
|
|
20
|
+
export function endsWithExit(stmt) {
|
|
21
|
+
const last = Node.isBlock(stmt) ? stmt.getStatements().at(-1) : stmt;
|
|
22
|
+
return !!last && (Node.isReturnStatement(last) || Node.isThrowStatement(last) || Node.isContinueStatement(last) || Node.isBreakStatement(last));
|
|
23
|
+
}
|
|
24
|
+
export const pos = (n) => {
|
|
25
|
+
const sf = n.getSourceFile();
|
|
26
|
+
const { line, column } = sf.getLineAndColumnAtPos(n.getStart());
|
|
27
|
+
return { file: sf.getFilePath(), line, column };
|
|
28
|
+
};
|
|
29
|
+
export function effectivePlatform(c) {
|
|
30
|
+
return c.negated ? (c.platform === "ios" ? "android" : "ios") : c.platform;
|
|
31
|
+
}
|
|
32
|
+
/** Nearest enclosing function-like node, or the source file. */
|
|
33
|
+
export function enclosingScope(n) {
|
|
34
|
+
return (n.getFirstAncestor((a) => Node.isFunctionDeclaration(a) || Node.isFunctionExpression(a) || Node.isArrowFunction(a) || Node.isMethodDeclaration(a)) ?? n.getSourceFile());
|
|
35
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { pg001 } from "./pg001-unmatched-os-branch.js";
|
|
2
|
+
import { pg002 } from "./pg002-incomplete-platform-select.js";
|
|
3
|
+
import { pg003 } from "./pg003-divergent-platform-files.js";
|
|
4
|
+
import { pg008 } from "./pg008-android-notification-permission.js";
|
|
5
|
+
import { pg009 } from "./pg009-android-back-button.js";
|
|
6
|
+
import { pg010 } from "./pg010-unsupported-platform-dependency.js";
|
|
7
|
+
import { makePermissionRule } from "./permissions.js";
|
|
8
|
+
export const rules = [
|
|
9
|
+
pg001, pg002, pg003,
|
|
10
|
+
makePermissionRule("ios"), makePermissionRule("android"),
|
|
11
|
+
pg008, pg009, pg010,
|
|
12
|
+
];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Rule } from "../types.js";
|
|
2
|
+
interface Capability {
|
|
3
|
+
id: string;
|
|
4
|
+
imports: string[];
|
|
5
|
+
iosKeys: string[];
|
|
6
|
+
androidPermissions: string[];
|
|
7
|
+
expoPlugins: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare const capabilityData: {
|
|
10
|
+
dataVersion: string;
|
|
11
|
+
capabilities: Capability[];
|
|
12
|
+
};
|
|
13
|
+
export declare function makePermissionRule(platform: "ios" | "android"): Rule;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { SyntaxKind } from "ts-morph";
|
|
2
|
+
import { loadData } from "../data.js";
|
|
3
|
+
export const capabilityData = loadData("capabilities.json");
|
|
4
|
+
function libraryUsages(ctx) {
|
|
5
|
+
const found = new Map();
|
|
6
|
+
for (const sf of ctx.sourceFiles) {
|
|
7
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
8
|
+
const mod = imp.getModuleSpecifierValue();
|
|
9
|
+
for (const cap of capabilityData.capabilities) {
|
|
10
|
+
if (found.has(cap.id) || !cap.imports.some((i) => mod === i || mod.startsWith(i + "/")))
|
|
11
|
+
continue;
|
|
12
|
+
const { line, column } = sf.getLineAndColumnAtPos(imp.getStart());
|
|
13
|
+
found.set(cap.id, { cap, file: sf.getFilePath(), line, column, mod });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return [...found.values()];
|
|
18
|
+
}
|
|
19
|
+
/** `PermissionsAndroid.PERMISSIONS.CAMERA` -> android.permission.CAMERA (first occurrence). */
|
|
20
|
+
function codePermissionUsages(ctx) {
|
|
21
|
+
const found = new Map();
|
|
22
|
+
for (const sf of ctx.sourceFiles) {
|
|
23
|
+
for (const pae of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
24
|
+
const m = pae.getText().match(/^PermissionsAndroid\.PERMISSIONS\.([A-Z0-9_]+)$/);
|
|
25
|
+
if (!m || found.has(m[1]))
|
|
26
|
+
continue;
|
|
27
|
+
const { line, column } = sf.getLineAndColumnAtPos(pae.getStart());
|
|
28
|
+
found.set(`android.permission.${m[1]}`, { file: sf.getFilePath(), line, column });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
export function makePermissionRule(platform) {
|
|
34
|
+
const ios = platform === "ios";
|
|
35
|
+
return {
|
|
36
|
+
id: ios ? "PG006" : "PG007",
|
|
37
|
+
title: ios ? "Missing or empty iOS usage description" : "Missing Android permission",
|
|
38
|
+
severity: "error",
|
|
39
|
+
confidence: "high",
|
|
40
|
+
explain: ios
|
|
41
|
+
? "Using a privacy-sensitive API without a matching, non-empty Info.plist usage string crashes the app on iOS and gets it rejected in review. " +
|
|
42
|
+
"Detected from imports of known libraries (data/capabilities.json). An Expo config plugin for the library counts as declared."
|
|
43
|
+
: "Using a protected API without declaring the permission means requests are silently denied on Android. " +
|
|
44
|
+
"Detected from imports of known libraries and from `PermissionsAndroid.PERMISSIONS.X` usage in code. " +
|
|
45
|
+
"POST_NOTIFICATIONS is covered by PG008.",
|
|
46
|
+
check(ctx, report) {
|
|
47
|
+
const n = ctx.native;
|
|
48
|
+
if (ios ? !n.hasIosConfig : !n.hasAndroidConfig)
|
|
49
|
+
return; // nothing to compare against
|
|
50
|
+
const confidence = n.dynamicConfig ? "low" : "high";
|
|
51
|
+
const coveredAndroid = new Set();
|
|
52
|
+
for (const u of libraryUsages(ctx)) {
|
|
53
|
+
const viaPlugin = u.cap.expoPlugins.some((p) => n.expoPlugins.has(p));
|
|
54
|
+
if (ios) {
|
|
55
|
+
const declared = u.cap.iosKeys.filter((k) => n.iosKeys.has(k));
|
|
56
|
+
if (viaPlugin)
|
|
57
|
+
continue;
|
|
58
|
+
if (declared.length && declared.every((k) => n.iosEmpty.has(k))) {
|
|
59
|
+
report({
|
|
60
|
+
file: u.file, line: u.line, column: u.column, anchor: `${u.cap.id}:ios:empty`, confidence,
|
|
61
|
+
message: `\`${u.mod}\` (${u.cap.id}) is used but ${declared.join(", ")} is empty.`,
|
|
62
|
+
why: "App Store review rejects empty usage descriptions, and users see a blank reason in the permission prompt.",
|
|
63
|
+
fix: "Write a short, specific explanation of why the app needs this access.",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
else if (!declared.length) {
|
|
67
|
+
report({
|
|
68
|
+
file: u.file, line: u.line, column: u.column, anchor: `${u.cap.id}:ios`, confidence,
|
|
69
|
+
message: `\`${u.mod}\` (${u.cap.id}) is used but no usage description is declared. Expected one of: ${u.cap.iosKeys.join(", ")}.`,
|
|
70
|
+
why: "iOS terminates the app when this API is used without a usage string; App Store review also rejects it.",
|
|
71
|
+
fix: n.isExpo
|
|
72
|
+
? `Add the \`${u.cap.expoPlugins[0] ?? u.cap.id}\` config plugin, or set expo.ios.infoPlist in app.json.`
|
|
73
|
+
: `Add ${u.cap.iosKeys[0]} to ios/<App>/Info.plist.`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
if (!u.cap.androidPermissions.length)
|
|
79
|
+
continue; // no Android equivalent
|
|
80
|
+
if (viaPlugin || u.cap.androidPermissions.some((p) => n.androidPermissions.has(p))) {
|
|
81
|
+
u.cap.androidPermissions.forEach((p) => coveredAndroid.add(p));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
u.cap.androidPermissions.forEach((p) => coveredAndroid.add(p));
|
|
85
|
+
report({
|
|
86
|
+
file: u.file, line: u.line, column: u.column, anchor: `${u.cap.id}:android`, confidence,
|
|
87
|
+
message: `\`${u.mod}\` (${u.cap.id}) is used but no permission is declared. Expected one of: ${u.cap.androidPermissions.join(", ")}.`,
|
|
88
|
+
why: "Android denies the permission request without a manifest declaration, so the feature fails only on Android.",
|
|
89
|
+
fix: n.isExpo
|
|
90
|
+
? `Add the \`${u.cap.expoPlugins[0] ?? u.cap.id}\` config plugin, or set expo.android.permissions in app.json.`
|
|
91
|
+
: `Add <uses-permission android:name="${u.cap.androidPermissions[0]}"/> to AndroidManifest.xml.`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!ios) {
|
|
96
|
+
for (const [perm, loc] of codePermissionUsages(ctx)) {
|
|
97
|
+
if (perm === "android.permission.POST_NOTIFICATIONS" || coveredAndroid.has(perm) || n.androidPermissions.has(perm))
|
|
98
|
+
continue;
|
|
99
|
+
report({
|
|
100
|
+
...loc, anchor: `perm:${perm}`, confidence,
|
|
101
|
+
message: `Code requests ${perm} but it is not declared.`,
|
|
102
|
+
why: "PermissionsAndroid.request resolves as denied when the permission is missing from the manifest.",
|
|
103
|
+
fix: n.isExpo ? `Add "${perm.split(".").pop()}" to expo.android.permissions.` : `Add <uses-permission android:name="${perm}"/> to AndroidManifest.xml.`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Node, SyntaxKind } from "ts-morph";
|
|
2
|
+
import { asPlatformCheck, effectivePlatform, endsWithExit, enclosingScope, pos } from "./helpers.js";
|
|
3
|
+
/** True when the enclosing function also contains a check/select for the opposite platform. */
|
|
4
|
+
function handlesOtherPlatform(self, chk) {
|
|
5
|
+
const scope = enclosingScope(self);
|
|
6
|
+
const mine = effectivePlatform(chk);
|
|
7
|
+
if (scope.getDescendantsOfKind(SyntaxKind.CallExpression).some((c) => c.getExpression().getText() === "Platform.select"))
|
|
8
|
+
return true;
|
|
9
|
+
return scope.getDescendantsOfKind(SyntaxKind.BinaryExpression).some((b) => {
|
|
10
|
+
if (b === self)
|
|
11
|
+
return false;
|
|
12
|
+
const other = asPlatformCheck(b);
|
|
13
|
+
return !!other && effectivePlatform(other) !== mine;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export const pg001 = {
|
|
17
|
+
id: "PG001",
|
|
18
|
+
title: "Platform.OS branch has no counterpart",
|
|
19
|
+
severity: "warn",
|
|
20
|
+
confidence: "high",
|
|
21
|
+
explain: "An `if (Platform.OS === 'ios')` with no else (and no early return) means the other platform silently skips that logic. " +
|
|
22
|
+
"`Platform.OS === 'x' && <JSX/>` is reported at medium confidence because it is often intentional (shown only with --strict). " +
|
|
23
|
+
"Not reported when the same function also handles the other platform (another Platform.OS check, or Platform.select), since the platforms are then handled side by side.",
|
|
24
|
+
check(ctx, report) {
|
|
25
|
+
for (const sf of ctx.sourceFiles) {
|
|
26
|
+
for (const ifs of sf.getDescendantsOfKind(SyntaxKind.IfStatement)) {
|
|
27
|
+
const chk = asPlatformCheck(ifs.getExpression());
|
|
28
|
+
if (!chk || ifs.getElseStatement() || endsWithExit(ifs.getThenStatement()))
|
|
29
|
+
continue;
|
|
30
|
+
if (handlesOtherPlatform(ifs.getExpression(), chk))
|
|
31
|
+
continue;
|
|
32
|
+
const branch = effectivePlatform(chk);
|
|
33
|
+
const other = branch === "ios" ? "android" : "ios";
|
|
34
|
+
const cond = `Platform.OS ${chk.negated ? "!==" : "==="} '${chk.platform}'`;
|
|
35
|
+
report({
|
|
36
|
+
...pos(ifs),
|
|
37
|
+
message: `\`${cond}\` branch runs on ${branch} only; nothing handles ${other}.`,
|
|
38
|
+
why: `On ${other}, this logic is skipped entirely with no fallback.`,
|
|
39
|
+
fix: `Add an else branch, or add \`// parity-ignore PG001: <reason>\` if the difference is intentional.`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
for (const bin of sf.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
|
|
43
|
+
if (bin.getOperatorToken().getKind() !== SyntaxKind.AmpersandAmpersandToken)
|
|
44
|
+
continue;
|
|
45
|
+
const chk = asPlatformCheck(bin.getLeft());
|
|
46
|
+
if (!chk || Node.isIfStatement(bin.getParent()))
|
|
47
|
+
continue;
|
|
48
|
+
report({
|
|
49
|
+
...pos(bin),
|
|
50
|
+
confidence: "medium",
|
|
51
|
+
message: `\`Platform.OS === '${chk.platform}' && …\` renders/runs on ${chk.platform} only.`,
|
|
52
|
+
why: "The other platform gets no equivalent. Fine for iOS-only chrome, risky for functional UI.",
|
|
53
|
+
fix: "Confirm the other platform doesn't need an equivalent, then add a parity-ignore comment with a reason.",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Node, SyntaxKind } from "ts-morph";
|
|
2
|
+
import { pos } from "./helpers.js";
|
|
3
|
+
export const pg002 = {
|
|
4
|
+
id: "PG002",
|
|
5
|
+
title: "Platform.select is missing a platform",
|
|
6
|
+
severity: "error",
|
|
7
|
+
confidence: "high",
|
|
8
|
+
explain: "`Platform.select({ ios: … })` returns `undefined` on Android unless `android`, `native`, or `default` is provided. " +
|
|
9
|
+
"When the result is spread into an object or every value is an object (typical style overrides), `undefined` is usually harmless, so those cases are medium confidence (shown with --strict).",
|
|
10
|
+
check(ctx, report) {
|
|
11
|
+
for (const sf of ctx.sourceFiles) {
|
|
12
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
13
|
+
if (call.getExpression().getText() !== "Platform.select")
|
|
14
|
+
continue;
|
|
15
|
+
const arg = call.getArguments()[0];
|
|
16
|
+
if (!arg || !Node.isObjectLiteralExpression(arg))
|
|
17
|
+
continue;
|
|
18
|
+
if (arg.getProperties().some((p) => Node.isSpreadAssignment(p)))
|
|
19
|
+
continue; // can't know
|
|
20
|
+
const keys = new Set(arg.getProperties().map((p) => (Node.isPropertyAssignment(p) || Node.isShorthandPropertyAssignment(p) ? p.getName().replace(/['"]/g, "") : "")));
|
|
21
|
+
if (keys.has("default") || keys.has("native"))
|
|
22
|
+
continue;
|
|
23
|
+
const missing = ["ios", "android"].filter((k) => !keys.has(k));
|
|
24
|
+
if (missing.length === 0)
|
|
25
|
+
continue;
|
|
26
|
+
const parent = call.getParent();
|
|
27
|
+
const styleLike = Node.isSpreadAssignment(parent) || Node.isSpreadElement(parent) ||
|
|
28
|
+
arg.getProperties().every((p) => Node.isPropertyAssignment(p) && Node.isObjectLiteralExpression(p.getInitializer()));
|
|
29
|
+
report({
|
|
30
|
+
...pos(call),
|
|
31
|
+
confidence: styleLike ? "medium" : undefined,
|
|
32
|
+
message: `Platform.select is missing: ${missing.join(", ")}.`,
|
|
33
|
+
why: `On ${missing.join(" and ")} this expression evaluates to \`undefined\` at runtime.`,
|
|
34
|
+
fix: "Add the missing key, or add a `default` value.",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const pg003 = {
|
|
2
|
+
id: "PG003",
|
|
3
|
+
title: "Platform-specific files export different names",
|
|
4
|
+
severity: "error",
|
|
5
|
+
confidence: "high",
|
|
6
|
+
explain: "`X.ios.tsx` and `X.android.tsx` are resolved by name from shared code, so their exports must match.",
|
|
7
|
+
check(ctx, report) {
|
|
8
|
+
const groups = new Map();
|
|
9
|
+
for (const sf of ctx.sourceFiles) {
|
|
10
|
+
const m = sf.getFilePath().match(/^(.*)\.(ios|android)\.(tsx?|jsx?)$/);
|
|
11
|
+
if (!m)
|
|
12
|
+
continue;
|
|
13
|
+
const key = `${m[1]}.${m[3]}`;
|
|
14
|
+
groups.set(key, { ...groups.get(key), [m[2]]: sf });
|
|
15
|
+
}
|
|
16
|
+
for (const { ios, android } of groups.values()) {
|
|
17
|
+
if (!ios || !android)
|
|
18
|
+
continue;
|
|
19
|
+
const a = new Set([...ios.getExportedDeclarations().keys()]);
|
|
20
|
+
const b = new Set([...android.getExportedDeclarations().keys()]);
|
|
21
|
+
const onlyIos = [...a].filter((x) => !b.has(x));
|
|
22
|
+
const onlyAndroid = [...b].filter((x) => !a.has(x));
|
|
23
|
+
if (!onlyIos.length && !onlyAndroid.length)
|
|
24
|
+
continue;
|
|
25
|
+
report({
|
|
26
|
+
file: ios.getFilePath(), line: 1, column: 1,
|
|
27
|
+
anchor: `${onlyIos.join(",")}|${onlyAndroid.join(",")}`,
|
|
28
|
+
message: `Export mismatch between .ios and .android files` +
|
|
29
|
+
(onlyIos.length ? ` — only iOS exports: ${onlyIos.join(", ")}` : "") +
|
|
30
|
+
(onlyAndroid.length ? ` — only Android exports: ${onlyAndroid.join(", ")}` : "") + ".",
|
|
31
|
+
why: "Shared code importing the missing name crashes or gets `undefined` on one platform.",
|
|
32
|
+
fix: "Export the same names from both files (or extract the shared surface into a common module).",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { SyntaxKind } from "ts-morph";
|
|
2
|
+
import { loadData } from "../data.js";
|
|
3
|
+
const data = loadData("notifications.json");
|
|
4
|
+
export const pg008 = {
|
|
5
|
+
id: "PG008",
|
|
6
|
+
title: "Android 13+ notification permission not handled",
|
|
7
|
+
severity: "warn",
|
|
8
|
+
confidence: "high",
|
|
9
|
+
explain: "On Android 13 (API 33)+, apps targeting API 33+ must declare POST_NOTIFICATIONS and request it at runtime; otherwise notifications are silently dropped. " +
|
|
10
|
+
"iOS always prompts, so this only breaks on Android. The runtime-request check is medium confidence (shown with --strict) because the request may live behind a wrapper. " +
|
|
11
|
+
"An Expo `expo-notifications` plugin is assumed to declare the permission.",
|
|
12
|
+
check(ctx, report) {
|
|
13
|
+
const n = ctx.native;
|
|
14
|
+
if (!n.hasAndroidConfig)
|
|
15
|
+
return;
|
|
16
|
+
let loc;
|
|
17
|
+
for (const sf of ctx.sourceFiles) {
|
|
18
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
19
|
+
const mod = imp.getModuleSpecifierValue();
|
|
20
|
+
if (loc || !data.imports.some((i) => mod === i || mod.startsWith(i + "/")))
|
|
21
|
+
continue;
|
|
22
|
+
const { line, column } = sf.getLineAndColumnAtPos(imp.getStart());
|
|
23
|
+
loc = { file: sf.getFilePath(), line, column, mod };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (!loc)
|
|
27
|
+
return;
|
|
28
|
+
const declared = n.androidPermissions.has("android.permission.POST_NOTIFICATIONS") || data.expoPlugins.some((p) => n.expoPlugins.has(p));
|
|
29
|
+
if (!declared) {
|
|
30
|
+
report({
|
|
31
|
+
...loc, anchor: "notif:declare", confidence: n.dynamicConfig ? "low" : "high",
|
|
32
|
+
message: `\`${loc.mod}\` is used but android.permission.POST_NOTIFICATIONS is not declared.`,
|
|
33
|
+
why: "On Android 13+ notifications are blocked until the permission is declared and granted. iOS prompts automatically, so this only fails on Android.",
|
|
34
|
+
fix: n.isExpo ? "Add the `expo-notifications` plugin or \"POST_NOTIFICATIONS\" to expo.android.permissions." : `Add <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> to AndroidManifest.xml.`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const hasRequest = ctx.sourceFiles.some((sf) => sf.getDescendantsOfKind(SyntaxKind.CallExpression).some((c) => {
|
|
38
|
+
const text = c.getExpression().getText();
|
|
39
|
+
return data.requestCalls.includes(text.split(".").pop() ?? "") ||
|
|
40
|
+
(text.endsWith("PermissionsAndroid.request") && c.getText().includes("POST_NOTIFICATIONS"));
|
|
41
|
+
}));
|
|
42
|
+
if (!hasRequest) {
|
|
43
|
+
report({
|
|
44
|
+
...loc, anchor: "notif:request", confidence: "medium",
|
|
45
|
+
message: "No runtime notification-permission request found anywhere in the project.",
|
|
46
|
+
why: "Declaring the permission is not enough on Android 13+; the user must be asked at runtime.",
|
|
47
|
+
fix: "Call your library's permission request (e.g. requestPermissionsAsync) or PermissionsAndroid.request with POST_NOTIFICATIONS before scheduling notifications.",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Node, SyntaxKind } from "ts-morph";
|
|
2
|
+
import { pos } from "./helpers.js";
|
|
3
|
+
export const pg009 = {
|
|
4
|
+
id: "PG009",
|
|
5
|
+
title: "Android hardware back button not handled",
|
|
6
|
+
severity: "warn",
|
|
7
|
+
confidence: "high",
|
|
8
|
+
explain: "Two Android-only traps: (1) react-native `Modal` without `onRequestClose` ignores the hardware back button and RN requires the prop on Android; " +
|
|
9
|
+
"(2) `BackHandler.addEventListener(...)` whose subscription is discarded can never be removed, so the handler leaks and keeps hijacking back presses. iOS has no back button, so neither shows up there.",
|
|
10
|
+
check(ctx, report) {
|
|
11
|
+
for (const sf of ctx.sourceFiles) {
|
|
12
|
+
const modalLocal = sf.getImportDeclarations()
|
|
13
|
+
.filter((i) => i.getModuleSpecifierValue() === "react-native")
|
|
14
|
+
.flatMap((i) => i.getNamedImports())
|
|
15
|
+
.find((s) => s.getName() === "Modal");
|
|
16
|
+
if (modalLocal) {
|
|
17
|
+
const local = modalLocal.getAliasNode()?.getText() ?? "Modal";
|
|
18
|
+
const els = [...sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement), ...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)];
|
|
19
|
+
for (const el of els) {
|
|
20
|
+
if (el.getTagNameNode().getText() !== local)
|
|
21
|
+
continue;
|
|
22
|
+
const attrs = el.getAttributes();
|
|
23
|
+
if (attrs.some((a) => Node.isJsxSpreadAttribute(a)))
|
|
24
|
+
continue; // cannot know
|
|
25
|
+
if (attrs.some((a) => Node.isJsxAttribute(a) && a.getNameNode().getText() === "onRequestClose"))
|
|
26
|
+
continue;
|
|
27
|
+
report({
|
|
28
|
+
...pos(el),
|
|
29
|
+
message: "`<Modal>` has no `onRequestClose`.",
|
|
30
|
+
why: "On Android the hardware back button cannot dismiss this modal, and React Native requires the prop on Android.",
|
|
31
|
+
fix: "Add `onRequestClose={() => setVisible(false)}` (or your close handler).",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (sf.getFullText().includes("BackHandler.removeEventListener"))
|
|
36
|
+
continue; // legacy cleanup pattern present
|
|
37
|
+
for (const st of sf.getDescendantsOfKind(SyntaxKind.ExpressionStatement)) {
|
|
38
|
+
const e = st.getExpression();
|
|
39
|
+
if (Node.isCallExpression(e) && e.getExpression().getText() === "BackHandler.addEventListener") {
|
|
40
|
+
report({
|
|
41
|
+
...pos(st),
|
|
42
|
+
message: "`BackHandler.addEventListener` subscription is discarded, so it is never removed.",
|
|
43
|
+
why: "The handler keeps intercepting the back button after the screen unmounts, trapping the user or skipping navigation.",
|
|
44
|
+
fix: "Store the result and call `.remove()` in the effect cleanup: `const sub = BackHandler.addEventListener(...); return () => sub.remove();`",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { loadData } from "../data.js";
|
|
2
|
+
const data = loadData("platform-support.json");
|
|
3
|
+
const GUARD = /\bPlatform\b|\bis(?:IOS|Ios|Android)\b|\bIS_(?:IOS|ANDROID)\b/;
|
|
4
|
+
export const pg010 = {
|
|
5
|
+
id: "PG010",
|
|
6
|
+
title: "Single-platform dependency used without a platform guard",
|
|
7
|
+
severity: "warn",
|
|
8
|
+
confidence: "high",
|
|
9
|
+
explain: "Some packages only ship a native module for one platform (see data/platform-support.json). Importing them from shared code with no guard " +
|
|
10
|
+
"means the other platform hits a missing native module at runtime. A file is considered guarded if it references `Platform`/`isIOS`/`isAndroid`, " +
|
|
11
|
+
"or is a matching `.ios`/`.android` file. Data is seeded from package docs and should be verified.",
|
|
12
|
+
check(ctx, report) {
|
|
13
|
+
for (const sf of ctx.sourceFiles) {
|
|
14
|
+
const fileSuffix = sf.getFilePath().match(/\.(ios|android)\.[jt]sx?$/)?.[1];
|
|
15
|
+
const guarded = GUARD.test(sf.getFullText());
|
|
16
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
17
|
+
const mod = imp.getModuleSpecifierValue();
|
|
18
|
+
const entry = data.packages.find((p) => mod === p.package || mod.startsWith(p.package + "/"));
|
|
19
|
+
if (!entry)
|
|
20
|
+
continue;
|
|
21
|
+
const other = entry.platform === "ios" ? "Android" : "iOS";
|
|
22
|
+
const { line, column } = sf.getLineAndColumnAtPos(imp.getStart());
|
|
23
|
+
const base = { file: sf.getFilePath(), line, column, anchor: `${mod}:${fileSuffix ?? "shared"}` };
|
|
24
|
+
if (fileSuffix) {
|
|
25
|
+
if (fileSuffix === entry.platform)
|
|
26
|
+
continue;
|
|
27
|
+
report({
|
|
28
|
+
...base,
|
|
29
|
+
message: `\`${mod}\` supports ${entry.platform === "ios" ? "iOS" : "Android"} only but is imported in a .${fileSuffix} file.`,
|
|
30
|
+
why: `The ${other} build has no native module for this package.`,
|
|
31
|
+
fix: `Move the import to the .${entry.platform} file and give ${other} its own implementation.`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
else if (!guarded) {
|
|
35
|
+
report({
|
|
36
|
+
...base,
|
|
37
|
+
message: `\`${mod}\` supports ${entry.platform === "ios" ? "iOS" : "Android"} only but is imported with no platform guard.`,
|
|
38
|
+
why: `On ${other} the native module does not exist, so calls throw or silently do nothing.`,
|
|
39
|
+
fix: `Guard usage with Platform.OS === '${entry.platform}', or split into .${entry.platform}/.${entry.platform === "ios" ? "android" : "ios"} files.`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Project, SourceFile } from "ts-morph";
|
|
2
|
+
export type Severity = "error" | "warn" | "info";
|
|
3
|
+
export type Confidence = "high" | "medium" | "low";
|
|
4
|
+
export interface Finding {
|
|
5
|
+
ruleId: string;
|
|
6
|
+
severity: Severity;
|
|
7
|
+
confidence: Confidence;
|
|
8
|
+
message: string;
|
|
9
|
+
why: string;
|
|
10
|
+
fix: string;
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
column: number;
|
|
14
|
+
fingerprint: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ReportInput {
|
|
17
|
+
file: string;
|
|
18
|
+
line: number;
|
|
19
|
+
column: number;
|
|
20
|
+
message: string;
|
|
21
|
+
why: string;
|
|
22
|
+
fix: string;
|
|
23
|
+
confidence?: Confidence;
|
|
24
|
+
/** Stable text used for the baseline fingerprint (defaults to the source line). */
|
|
25
|
+
anchor?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface NativeConfig {
|
|
28
|
+
isExpo: boolean;
|
|
29
|
+
/** app.config.ts/js exists and can't be statically evaluated. */
|
|
30
|
+
dynamicConfig: boolean;
|
|
31
|
+
hasIosConfig: boolean;
|
|
32
|
+
hasAndroidConfig: boolean;
|
|
33
|
+
iosKeys: Set<string>;
|
|
34
|
+
/** iOS usage keys that are declared but have an empty value. */
|
|
35
|
+
iosEmpty: Set<string>;
|
|
36
|
+
androidPermissions: Set<string>;
|
|
37
|
+
expoPlugins: Set<string>;
|
|
38
|
+
}
|
|
39
|
+
export interface ProjectContext {
|
|
40
|
+
root: string;
|
|
41
|
+
project: Project;
|
|
42
|
+
sourceFiles: SourceFile[];
|
|
43
|
+
native: NativeConfig;
|
|
44
|
+
}
|
|
45
|
+
export interface Rule {
|
|
46
|
+
id: string;
|
|
47
|
+
title: string;
|
|
48
|
+
severity: Severity;
|
|
49
|
+
confidence: Confidence;
|
|
50
|
+
explain: string;
|
|
51
|
+
check(ctx: ProjectContext, report: (r: ReportInput) => void): void;
|
|
52
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parityguard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Catch iOS/Android drift in React Native TypeScript projects before it reaches a device.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"parityguard": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"data"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20.19"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"check:fixtures": "node dist/cli.js check fixtures/bad-app",
|
|
22
|
+
"prepublishOnly": "npm run build && npm test",
|
|
23
|
+
"preversion": "npm test",
|
|
24
|
+
"bump:alpha": "npm version prerelease --preid=alpha",
|
|
25
|
+
"bump:patch": "npm version patch",
|
|
26
|
+
"bump:minor": "npm version minor",
|
|
27
|
+
"bump:major": "npm version major",
|
|
28
|
+
"publish:alpha": "npm publish --tag alpha",
|
|
29
|
+
"publish:latest": "npm publish --tag latest",
|
|
30
|
+
"release:alpha": "npm run bump:alpha && npm run publish:alpha && git push --follow-tags"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"ts-morph": "^28.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^26.6.6",
|
|
37
|
+
"typescript": "^7.0.2",
|
|
38
|
+
"vitest": "^5.1.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public",
|
|
42
|
+
"registry": "https://registry.npmjs.org/"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"react-native",
|
|
46
|
+
"ios",
|
|
47
|
+
"android",
|
|
48
|
+
"lint",
|
|
49
|
+
"cross-platform",
|
|
50
|
+
"typescript",
|
|
51
|
+
"expo"
|
|
52
|
+
],
|
|
53
|
+
"overrides": {
|
|
54
|
+
"minimatch": "^11.0.7"
|
|
55
|
+
}
|
|
56
|
+
}
|