parityguard 0.1.0-alpha.0 → 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/README.md +28 -7
- package/data/capabilities.json +17 -2
- package/data/notifications.json +6 -0
- package/data/platform-support.json +13 -0
- package/dist/cli.js +9 -0
- package/dist/data.d.ts +2 -0
- package/dist/data.js +8 -0
- package/dist/engine.js +10 -3
- package/dist/project.js +10 -3
- package/dist/rules/helpers.d.ts +6 -0
- package/dist/rules/helpers.js +7 -0
- package/dist/rules/index.js +8 -1
- package/dist/rules/permissions.js +79 -26
- package/dist/rules/pg001-unmatched-os-branch.js +23 -5
- package/dist/rules/pg002-incomplete-platform-select.js +6 -1
- 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 +2 -0
- package/package.json +15 -5
package/README.md
CHANGED
|
@@ -47,13 +47,21 @@ parityguard explain PG002 # explain one rule
|
|
|
47
47
|
|
|
48
48
|
| ID | What it catches | Default |
|
|
49
49
|
|---|---|---|
|
|
50
|
-
| PG001 | `if (Platform.OS === 'ios')` with no else and no early return. 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 | error |
|
|
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
52
|
| PG003 | `X.ios.tsx` and `X.android.tsx` that export different names | error |
|
|
53
|
-
| PG006 | Privacy-sensitive library used
|
|
54
|
-
| PG007 |
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
57
65
|
Config plugins (Expo) are matched by name only, so a plugin is assumed to declare what it should.
|
|
58
66
|
|
|
59
67
|
Run `parityguard explain <ID>` for details.
|
|
@@ -99,7 +107,7 @@ Copy `examples/parityguard-workflow.yml` into your app repo as `.github/workflow
|
|
|
99
107
|
|
|
100
108
|
Action inputs: `path`, `strict`, `version`, `sarif-file`, `upload-sarif`, `fail-on-error`.
|
|
101
109
|
|
|
102
|
-
Before using it: publish the package to npm (the Action runs it through `npx`), replace `
|
|
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.
|
|
103
111
|
|
|
104
112
|
## Known limitations
|
|
105
113
|
|
|
@@ -107,6 +115,8 @@ Before using it: publish the package to npm (the Action runs it through `npx`),
|
|
|
107
115
|
- Apps nested inside a larger repo are not auto-discovered yet. Point the CLI at the app folder.
|
|
108
116
|
- Only `Platform.OS` comparisons against `'ios'` and `'android'` are analyzed. `web` is ignored.
|
|
109
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+.
|
|
110
120
|
- Not yet validated on a large corpus, so the false-positive rate is unknown.
|
|
111
121
|
|
|
112
122
|
## Measuring false positives (corpus)
|
|
@@ -139,6 +149,16 @@ scripts/ corpus tooling
|
|
|
139
149
|
3. Add a violation to `fixtures/bad-app`, keep `fixtures/good-app` clean, and update `test/engine.test.ts`.
|
|
140
150
|
4. Prefer fewer, high-confidence rules. Medium-confidence rules stay hidden unless `--strict`.
|
|
141
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
|
+
|
|
142
162
|
## Troubleshooting
|
|
143
163
|
|
|
144
164
|
- `tsc` or `vitest` not found: run `npm install` first.
|
|
@@ -148,3 +168,4 @@ scripts/ corpus tooling
|
|
|
148
168
|
## License
|
|
149
169
|
|
|
150
170
|
MIT. Add a `LICENSE` file before publishing.
|
|
171
|
+
x
|
package/data/capabilities.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"dataVersion": "2026.11.
|
|
2
|
+
"dataVersion": "2026.11.1",
|
|
3
3
|
"capabilities": [
|
|
4
4
|
{ "id": "camera", "imports": ["expo-camera", "react-native-vision-camera", "react-native-camera"],
|
|
5
5
|
"iosKeys": ["NSCameraUsageDescription"], "androidPermissions": ["android.permission.CAMERA"],
|
|
@@ -17,6 +17,21 @@
|
|
|
17
17
|
{ "id": "photos", "imports": ["expo-image-picker", "expo-media-library", "@react-native-camera-roll/camera-roll"],
|
|
18
18
|
"iosKeys": ["NSPhotoLibraryUsageDescription"],
|
|
19
19
|
"androidPermissions": ["android.permission.READ_MEDIA_IMAGES", "android.permission.READ_EXTERNAL_STORAGE"],
|
|
20
|
-
"expoPlugins": ["expo-image-picker", "expo-media-library"] }
|
|
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"] }
|
|
21
36
|
]
|
|
22
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.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
2
4
|
import { parseArgs } from "node:util";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
import { loadConfig } from "./config.js";
|
|
@@ -12,6 +14,7 @@ Usage:
|
|
|
12
14
|
parityguard check [path] --update-baseline
|
|
13
15
|
parityguard rules
|
|
14
16
|
parityguard explain <RULE_ID>
|
|
17
|
+
parityguard --version
|
|
15
18
|
|
|
16
19
|
Exit codes: 0 clean (or warnings only), 1 errors found, 2 usage/internal error.`;
|
|
17
20
|
async function main() {
|
|
@@ -23,9 +26,15 @@ async function main() {
|
|
|
23
26
|
"update-baseline": { type: "boolean", default: false },
|
|
24
27
|
"no-baseline": { type: "boolean", default: false },
|
|
25
28
|
config: { type: "string" },
|
|
29
|
+
version: { type: "boolean", short: "v", default: false },
|
|
26
30
|
help: { type: "boolean", short: "h", default: false },
|
|
27
31
|
},
|
|
28
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
|
+
}
|
|
29
38
|
const [cmd, arg] = positionals;
|
|
30
39
|
if (values.help || !cmd) {
|
|
31
40
|
console.log(HELP);
|
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.js
CHANGED
|
@@ -26,15 +26,22 @@ export function analyze(root, opts = {}) {
|
|
|
26
26
|
if (override === "off")
|
|
27
27
|
continue;
|
|
28
28
|
const rule = override ? { ...base, severity: override } : base;
|
|
29
|
+
const seen = new Map();
|
|
29
30
|
const report = (r) => {
|
|
30
|
-
const
|
|
31
|
+
const source = fs.readFileSync(r.file, "utf8");
|
|
32
|
+
const text = source.split(/\r?\n/)[r.line - 1]?.trim() ?? "";
|
|
31
33
|
const rel = path.relative(root, r.file).split(path.sep).join("/");
|
|
32
34
|
const confidence = r.confidence ?? rule.confidence;
|
|
33
|
-
|
|
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)) {
|
|
34
42
|
suppressed++;
|
|
35
43
|
return;
|
|
36
44
|
}
|
|
37
|
-
const fingerprint = crypto.createHash("sha1").update(`${rule.id}|${rel}|${r.anchor ?? text}`).digest("hex").slice(0, 16);
|
|
38
45
|
if (opts.baseline?.has(fingerprint)) {
|
|
39
46
|
baselined++;
|
|
40
47
|
return;
|
package/dist/project.js
CHANGED
|
@@ -23,7 +23,7 @@ function readJson(p) {
|
|
|
23
23
|
export function loadNativeConfig(root) {
|
|
24
24
|
const n = {
|
|
25
25
|
isExpo: false, dynamicConfig: false, hasIosConfig: false, hasAndroidConfig: false,
|
|
26
|
-
iosKeys: new Set(), androidPermissions: new Set(), expoPlugins: new Set(),
|
|
26
|
+
iosKeys: new Set(), iosEmpty: new Set(), androidPermissions: new Set(), expoPlugins: new Set(),
|
|
27
27
|
};
|
|
28
28
|
// Expo static config
|
|
29
29
|
const appJson = readJson(path.join(root, "app.json"));
|
|
@@ -32,7 +32,11 @@ export function loadNativeConfig(root) {
|
|
|
32
32
|
n.isExpo = true;
|
|
33
33
|
n.hasIosConfig = true;
|
|
34
34
|
n.hasAndroidConfig = true;
|
|
35
|
-
Object.
|
|
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
|
+
}
|
|
36
40
|
for (const p of expo.android?.permissions ?? []) {
|
|
37
41
|
n.androidPermissions.add(p.includes(".") ? p : `android.permission.${p}`);
|
|
38
42
|
}
|
|
@@ -50,8 +54,11 @@ export function loadNativeConfig(root) {
|
|
|
50
54
|
const plist = path.join(iosDir, d.name, "Info.plist");
|
|
51
55
|
if (d.isDirectory() && fs.existsSync(plist)) {
|
|
52
56
|
n.hasIosConfig = true;
|
|
53
|
-
|
|
57
|
+
const xml = fs.readFileSync(plist, "utf8");
|
|
58
|
+
for (const m of xml.matchAll(/<key>([^<]+)<\/key>/g))
|
|
54
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]);
|
|
55
62
|
}
|
|
56
63
|
}
|
|
57
64
|
}
|
package/dist/rules/helpers.d.ts
CHANGED
|
@@ -11,3 +11,9 @@ export declare const pos: (n: Node) => {
|
|
|
11
11
|
line: number;
|
|
12
12
|
column: number;
|
|
13
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;
|
package/dist/rules/helpers.js
CHANGED
|
@@ -26,3 +26,10 @@ export const pos = (n) => {
|
|
|
26
26
|
const { line, column } = sf.getLineAndColumnAtPos(n.getStart());
|
|
27
27
|
return { file: sf.getFilePath(), line, column };
|
|
28
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
|
+
}
|
package/dist/rules/index.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { pg001 } from "./pg001-unmatched-os-branch.js";
|
|
2
2
|
import { pg002 } from "./pg002-incomplete-platform-select.js";
|
|
3
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";
|
|
4
7
|
import { makePermissionRule } from "./permissions.js";
|
|
5
|
-
export const rules = [
|
|
8
|
+
export const rules = [
|
|
9
|
+
pg001, pg002, pg003,
|
|
10
|
+
makePermissionRule("ios"), makePermissionRule("android"),
|
|
11
|
+
pg008, pg009, pg010,
|
|
12
|
+
];
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export const capabilityData = JSON.parse(fs.readFileSync(path.resolve(here, "../../data/capabilities.json"), "utf8"));
|
|
6
|
-
function usages(ctx) {
|
|
1
|
+
import { SyntaxKind } from "ts-morph";
|
|
2
|
+
import { loadData } from "../data.js";
|
|
3
|
+
export const capabilityData = loadData("capabilities.json");
|
|
4
|
+
function libraryUsages(ctx) {
|
|
7
5
|
const found = new Map();
|
|
8
6
|
for (const sf of ctx.sourceFiles) {
|
|
9
7
|
for (const imp of sf.getImportDeclarations()) {
|
|
@@ -18,38 +16,93 @@ function usages(ctx) {
|
|
|
18
16
|
}
|
|
19
17
|
return [...found.values()];
|
|
20
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
|
+
}
|
|
21
33
|
export function makePermissionRule(platform) {
|
|
22
34
|
const ios = platform === "ios";
|
|
23
35
|
return {
|
|
24
36
|
id: ios ? "PG006" : "PG007",
|
|
25
|
-
title: ios ? "Missing iOS usage description" : "Missing Android permission",
|
|
37
|
+
title: ios ? "Missing or empty iOS usage description" : "Missing Android permission",
|
|
26
38
|
severity: "error",
|
|
27
39
|
confidence: "high",
|
|
28
40
|
explain: ios
|
|
29
|
-
? "Using a privacy-sensitive API without
|
|
30
|
-
|
|
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.",
|
|
31
46
|
check(ctx, report) {
|
|
32
47
|
const n = ctx.native;
|
|
33
48
|
if (ios ? !n.hasIosConfig : !n.hasAndroidConfig)
|
|
34
49
|
return; // nothing to compare against
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
: u.cap.androidPermissions.some((p) => n.androidPermissions.has(p));
|
|
50
|
+
const confidence = n.dynamicConfig ? "low" : "high";
|
|
51
|
+
const coveredAndroid = new Set();
|
|
52
|
+
for (const u of libraryUsages(ctx)) {
|
|
39
53
|
const viaPlugin = u.cap.expoPlugins.some((p) => n.expoPlugins.has(p));
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
}
|
|
53
106
|
}
|
|
54
107
|
},
|
|
55
108
|
};
|
|
@@ -1,23 +1,41 @@
|
|
|
1
1
|
import { Node, SyntaxKind } from "ts-morph";
|
|
2
|
-
import { asPlatformCheck, endsWithExit, pos } from "./helpers.js";
|
|
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
|
+
}
|
|
3
16
|
export const pg001 = {
|
|
4
17
|
id: "PG001",
|
|
5
18
|
title: "Platform.OS branch has no counterpart",
|
|
6
19
|
severity: "warn",
|
|
7
20
|
confidence: "high",
|
|
8
21
|
explain: "An `if (Platform.OS === 'ios')` with no else (and no early return) means the other platform silently skips that logic. " +
|
|
9
|
-
"`Platform.OS === 'x' && <JSX/>` is reported at medium confidence because it is often intentional (shown only with --strict)."
|
|
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.",
|
|
10
24
|
check(ctx, report) {
|
|
11
25
|
for (const sf of ctx.sourceFiles) {
|
|
12
26
|
for (const ifs of sf.getDescendantsOfKind(SyntaxKind.IfStatement)) {
|
|
13
27
|
const chk = asPlatformCheck(ifs.getExpression());
|
|
14
28
|
if (!chk || ifs.getElseStatement() || endsWithExit(ifs.getThenStatement()))
|
|
15
29
|
continue;
|
|
16
|
-
|
|
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}'`;
|
|
17
35
|
report({
|
|
18
36
|
...pos(ifs),
|
|
19
|
-
message:
|
|
20
|
-
why: `On ${
|
|
37
|
+
message: `\`${cond}\` branch runs on ${branch} only; nothing handles ${other}.`,
|
|
38
|
+
why: `On ${other}, this logic is skipped entirely with no fallback.`,
|
|
21
39
|
fix: `Add an else branch, or add \`// parity-ignore PG001: <reason>\` if the difference is intentional.`,
|
|
22
40
|
});
|
|
23
41
|
}
|
|
@@ -5,7 +5,8 @@ export const pg002 = {
|
|
|
5
5
|
title: "Platform.select is missing a platform",
|
|
6
6
|
severity: "error",
|
|
7
7
|
confidence: "high",
|
|
8
|
-
explain: "`Platform.select({ ios: … })` returns `undefined` on Android unless `android`, `native`, or `default` is provided."
|
|
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).",
|
|
9
10
|
check(ctx, report) {
|
|
10
11
|
for (const sf of ctx.sourceFiles) {
|
|
11
12
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
@@ -22,8 +23,12 @@ export const pg002 = {
|
|
|
22
23
|
const missing = ["ios", "android"].filter((k) => !keys.has(k));
|
|
23
24
|
if (missing.length === 0)
|
|
24
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()));
|
|
25
29
|
report({
|
|
26
30
|
...pos(call),
|
|
31
|
+
confidence: styleLike ? "medium" : undefined,
|
|
27
32
|
message: `Platform.select is missing: ${missing.join(", ")}.`,
|
|
28
33
|
why: `On ${missing.join(" and ")} this expression evaluates to \`undefined\` at runtime.`,
|
|
29
34
|
fix: "Add the missing key, or add a `default` value.",
|
|
@@ -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
CHANGED
|
@@ -31,6 +31,8 @@ export interface NativeConfig {
|
|
|
31
31
|
hasIosConfig: boolean;
|
|
32
32
|
hasAndroidConfig: boolean;
|
|
33
33
|
iosKeys: Set<string>;
|
|
34
|
+
/** iOS usage keys that are declared but have an empty value. */
|
|
35
|
+
iosEmpty: Set<string>;
|
|
34
36
|
androidPermissions: Set<string>;
|
|
35
37
|
expoPlugins: Set<string>;
|
|
36
38
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parityguard",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
4
|
"description": "Catch iOS/Android drift in React Native TypeScript projects before it reaches a device.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,15 @@
|
|
|
19
19
|
"build": "tsc -p tsconfig.json",
|
|
20
20
|
"test": "vitest run",
|
|
21
21
|
"check:fixtures": "node dist/cli.js check fixtures/bad-app",
|
|
22
|
-
"prepublishOnly": "npm run build && npm test"
|
|
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"
|
|
23
31
|
},
|
|
24
32
|
"dependencies": {
|
|
25
33
|
"ts-morph": "^28.0.0"
|
|
@@ -31,8 +39,7 @@
|
|
|
31
39
|
},
|
|
32
40
|
"publishConfig": {
|
|
33
41
|
"access": "public",
|
|
34
|
-
"registry": "https://registry.npmjs.org/"
|
|
35
|
-
"tag": "alpha"
|
|
42
|
+
"registry": "https://registry.npmjs.org/"
|
|
36
43
|
},
|
|
37
44
|
"keywords": [
|
|
38
45
|
"react-native",
|
|
@@ -42,5 +49,8 @@
|
|
|
42
49
|
"cross-platform",
|
|
43
50
|
"typescript",
|
|
44
51
|
"expo"
|
|
45
|
-
]
|
|
52
|
+
],
|
|
53
|
+
"overrides": {
|
|
54
|
+
"minimatch": "^11.0.7"
|
|
55
|
+
}
|
|
46
56
|
}
|