parityguard 0.0.0-stage → 0.1.0-alpha.0
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.
Potentially problematic release.
This version of parityguard might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/data/capabilities.json +22 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +64 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +23 -0
- package/dist/engine.d.ts +17 -0
- package/dist/engine.js +66 -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 +67 -0
- package/dist/reporters.d.ts +4 -0
- package/dist/reporters.js +35 -0
- package/dist/rules/helpers.d.ts +13 -0
- package/dist/rules/helpers.js +28 -0
- package/dist/rules/index.d.ts +2 -0
- package/dist/rules/index.js +5 -0
- package/dist/rules/permissions.d.ts +14 -0
- package/dist/rules/permissions.js +56 -0
- package/dist/rules/pg001-unmatched-os-branch.d.ts +2 -0
- package/dist/rules/pg001-unmatched-os-branch.js +40 -0
- package/dist/rules/pg002-incomplete-platform-select.d.ts +2 -0
- package/dist/rules/pg002-incomplete-platform-select.js +34 -0
- package/dist/rules/pg003-divergent-platform-files.d.ts +2 -0
- package/dist/rules/pg003-divergent-platform-files.js +36 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.js +1 -0
- package/package.json +44 -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,150 @@
|
|
|
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. 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 |
|
|
52
|
+
| PG003 | `X.ios.tsx` and `X.android.tsx` that export different names | error |
|
|
53
|
+
| PG006 | Privacy-sensitive library used (camera, location, ...) with no iOS usage string | error |
|
|
54
|
+
| PG007 | Same library used with no Android permission | error |
|
|
55
|
+
|
|
56
|
+
PG006 and PG007 are data-driven: add libraries in `data/capabilities.json`, no code change needed.
|
|
57
|
+
Config plugins (Expo) are matched by name only, so a plugin is assumed to declare what it should.
|
|
58
|
+
|
|
59
|
+
Run `parityguard explain <ID>` for details.
|
|
60
|
+
|
|
61
|
+
## Handling intentional differences
|
|
62
|
+
|
|
63
|
+
Suppress a finding with a comment on the same line or the line above. Always give a reason:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// parity-ignore PG001: Android uses the system picker
|
|
67
|
+
if (Platform.OS === 'ios') { showNativePicker(); }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Omit the rule ID to suppress all rules on that line.
|
|
71
|
+
|
|
72
|
+
## Adopting on an existing app (baseline)
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
parityguard check . --update-baseline # writes parityguard.baseline.json
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
Create `parityguard.config.json` (or `.mjs` / `.js`) in the project root:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"rules": { "PG001": "warn", "PG003": "off" },
|
|
87
|
+
"exclude": ["src/legacy/**"]
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- `rules`: set a rule to `error`, `warn`, `info` or `off`.
|
|
92
|
+
- `exclude`: globs relative to the project root.
|
|
93
|
+
|
|
94
|
+
See `examples/parityguard.config.json`.
|
|
95
|
+
|
|
96
|
+
## GitHub Action
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
Action inputs: `path`, `strict`, `version`, `sarif-file`, `upload-sarif`, `fail-on-error`.
|
|
101
|
+
|
|
102
|
+
Before using it: publish the package to npm (the Action runs it through `npx`), replace `mechazod` 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
|
+
|
|
104
|
+
## Known limitations
|
|
105
|
+
|
|
106
|
+
- Dynamic `app.config.ts/js` cannot be evaluated, so permission findings there are low confidence (hidden without `--strict`).
|
|
107
|
+
- Apps nested inside a larger repo are not auto-discovered yet. Point the CLI at the app folder.
|
|
108
|
+
- Only `Platform.OS` comparisons against `'ios'` and `'android'` are analyzed. `web` is ignored.
|
|
109
|
+
- `ios/` and `android/` source folders are not scanned for JS/TS.
|
|
110
|
+
- Not yet validated on a large corpus, so the false-positive rate is unknown.
|
|
111
|
+
|
|
112
|
+
## Measuring false positives (corpus)
|
|
113
|
+
|
|
114
|
+
1. List repos in `corpus/repos.txt` (`owner/name` or `owner/name:subdir`).
|
|
115
|
+
2. `./scripts/corpus.sh` clones them and writes `corpus/results.csv` (needs bash and git; on Windows use WSL or Git Bash).
|
|
116
|
+
3. Label each row `TP` or `FP` in the last column.
|
|
117
|
+
4. `node scripts/fp-rate.mjs` prints the false-positive rate per rule. Target: under 5% for default-on rules.
|
|
118
|
+
|
|
119
|
+
## Project layout
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
src/
|
|
123
|
+
cli.ts command-line entry
|
|
124
|
+
engine.ts runs rules, applies ignores, baseline, confidence
|
|
125
|
+
project.ts loads source files and Expo/bare native config
|
|
126
|
+
config.ts config file loading and validation
|
|
127
|
+
reporters.ts terminal, JSON, SARIF output
|
|
128
|
+
rules/ one file per rule, plus shared helpers
|
|
129
|
+
data/ versioned JSON data (capabilities)
|
|
130
|
+
fixtures/ bad-app and good-app used by tests
|
|
131
|
+
test/ Vitest tests
|
|
132
|
+
scripts/ corpus tooling
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Adding a rule
|
|
136
|
+
|
|
137
|
+
1. Create `src/rules/pgXXX-name.ts` exporting a `Rule` (`id`, `title`, `severity`, `confidence`, `explain`, `check`).
|
|
138
|
+
2. Register it in `src/rules/index.ts`.
|
|
139
|
+
3. Add a violation to `fixtures/bad-app`, keep `fixtures/good-app` clean, and update `test/engine.test.ts`.
|
|
140
|
+
4. Prefer fewer, high-confidence rules. Medium-confidence rules stay hidden unless `--strict`.
|
|
141
|
+
|
|
142
|
+
## Troubleshooting
|
|
143
|
+
|
|
144
|
+
- `tsc` or `vitest` not found: run `npm install` first.
|
|
145
|
+
- Tests fail on startup: check `node -v` (need 22.12+ for development).
|
|
146
|
+
- Nothing found in an Expo project using `app.config.ts`: expected, see limitations.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT. Add a `LICENSE` file before publishing.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dataVersion": "2026.11.0",
|
|
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
|
+
]
|
|
22
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { analyze, loadBaseline, writeBaseline } from "./engine.js";
|
|
6
|
+
import { json, sarif, terminal } from "./reporters.js";
|
|
7
|
+
import { rules } from "./rules/index.js";
|
|
8
|
+
const HELP = `parityguard — catch iOS/Android drift before it reaches a device
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
parityguard check [path] [--format terminal|json|sarif] [--strict] [--no-baseline] [--config file]
|
|
12
|
+
parityguard check [path] --update-baseline
|
|
13
|
+
parityguard rules
|
|
14
|
+
parityguard explain <RULE_ID>
|
|
15
|
+
|
|
16
|
+
Exit codes: 0 clean (or warnings only), 1 errors found, 2 usage/internal error.`;
|
|
17
|
+
async function main() {
|
|
18
|
+
const { values, positionals } = parseArgs({
|
|
19
|
+
allowPositionals: true,
|
|
20
|
+
options: {
|
|
21
|
+
format: { type: "string", default: "terminal" },
|
|
22
|
+
strict: { type: "boolean", default: false },
|
|
23
|
+
"update-baseline": { type: "boolean", default: false },
|
|
24
|
+
"no-baseline": { type: "boolean", default: false },
|
|
25
|
+
config: { type: "string" },
|
|
26
|
+
help: { type: "boolean", short: "h", default: false },
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const [cmd, arg] = positionals;
|
|
30
|
+
if (values.help || !cmd) {
|
|
31
|
+
console.log(HELP);
|
|
32
|
+
return cmd ? 0 : 2;
|
|
33
|
+
}
|
|
34
|
+
if (cmd === "rules") {
|
|
35
|
+
rules.forEach((r) => console.log(`${r.id} ${r.severity.padEnd(5)} ${r.title}`));
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
if (cmd === "explain") {
|
|
39
|
+
const r = rules.find((x) => x.id === arg?.toUpperCase());
|
|
40
|
+
if (!r) {
|
|
41
|
+
console.error(`Unknown rule: ${arg}`);
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
console.log(`${r.id} — ${r.title}\nDefault severity: ${r.severity}, confidence: ${r.confidence}\n\n${r.explain}`);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
if (cmd !== "check") {
|
|
48
|
+
console.error(`Unknown command: ${cmd}\n\n${HELP}`);
|
|
49
|
+
return 2;
|
|
50
|
+
}
|
|
51
|
+
const root = path.resolve(arg ?? ".");
|
|
52
|
+
const baseline = values["no-baseline"] || values["update-baseline"] ? undefined : loadBaseline(root);
|
|
53
|
+
const config = await loadConfig(root, values.config);
|
|
54
|
+
const result = analyze(root, { strict: values.strict, baseline, config });
|
|
55
|
+
if (values["update-baseline"]) {
|
|
56
|
+
writeBaseline(root, result.findings);
|
|
57
|
+
console.log(`Baseline written with ${result.findings.length} finding(s). Only new drift will fail from now on.`);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
const out = values.format === "json" ? json(result) : values.format === "sarif" ? sarif(result) : terminal(result);
|
|
61
|
+
console.log(out);
|
|
62
|
+
return result.findings.some((f) => f.severity === "error") ? 1 : 0;
|
|
63
|
+
}
|
|
64
|
+
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/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,66 @@
|
|
|
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 report = (r) => {
|
|
30
|
+
const text = fs.readFileSync(r.file, "utf8").split(/\r?\n/)[r.line - 1]?.trim() ?? "";
|
|
31
|
+
const rel = path.relative(root, r.file).split(path.sep).join("/");
|
|
32
|
+
const confidence = r.confidence ?? rule.confidence;
|
|
33
|
+
if (isSuppressed(fs.readFileSync(r.file, "utf8"), r.line, rule.id)) {
|
|
34
|
+
suppressed++;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const fingerprint = crypto.createHash("sha1").update(`${rule.id}|${rel}|${r.anchor ?? text}`).digest("hex").slice(0, 16);
|
|
38
|
+
if (opts.baseline?.has(fingerprint)) {
|
|
39
|
+
baselined++;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (confidence !== "high" && !opts.strict) {
|
|
43
|
+
hidden++;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
findings.push({
|
|
47
|
+
ruleId: rule.id, severity: rule.severity, confidence, message: r.message, why: r.why, fix: r.fix,
|
|
48
|
+
file: rel, line: r.line, column: r.column, fingerprint,
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
rule.check(ctx, report);
|
|
52
|
+
}
|
|
53
|
+
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
54
|
+
return { findings, suppressed, baselined, hidden };
|
|
55
|
+
}
|
|
56
|
+
export const BASELINE_FILE = "parityguard.baseline.json";
|
|
57
|
+
export function loadBaseline(root) {
|
|
58
|
+
const p = path.join(root, BASELINE_FILE);
|
|
59
|
+
if (!fs.existsSync(p))
|
|
60
|
+
return undefined;
|
|
61
|
+
return new Set(JSON.parse(fs.readFileSync(p, "utf8")).fingerprints);
|
|
62
|
+
}
|
|
63
|
+
export function writeBaseline(root, findings) {
|
|
64
|
+
const fingerprints = [...new Set(findings.map((f) => f.fingerprint))].sort();
|
|
65
|
+
fs.writeFileSync(path.join(root, BASELINE_FILE), JSON.stringify({ version: 1, fingerprints }, null, 2) + "\n");
|
|
66
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/project.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
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(), 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
|
+
Object.keys(expo.ios?.infoPlist ?? {}).forEach((k) => n.iosKeys.add(k));
|
|
36
|
+
for (const p of expo.android?.permissions ?? []) {
|
|
37
|
+
n.androidPermissions.add(p.includes(".") ? p : `android.permission.${p}`);
|
|
38
|
+
}
|
|
39
|
+
for (const pl of expo.plugins ?? [])
|
|
40
|
+
n.expoPlugins.add(Array.isArray(pl) ? pl[0] : pl);
|
|
41
|
+
}
|
|
42
|
+
if (["app.config.ts", "app.config.js"].some((f) => fs.existsSync(path.join(root, f))) && !n.isExpo) {
|
|
43
|
+
n.isExpo = true;
|
|
44
|
+
n.dynamicConfig = true;
|
|
45
|
+
}
|
|
46
|
+
// Bare iOS: ios/<App>/Info.plist
|
|
47
|
+
const iosDir = path.join(root, "ios");
|
|
48
|
+
if (fs.existsSync(iosDir)) {
|
|
49
|
+
for (const d of fs.readdirSync(iosDir, { withFileTypes: true })) {
|
|
50
|
+
const plist = path.join(iosDir, d.name, "Info.plist");
|
|
51
|
+
if (d.isDirectory() && fs.existsSync(plist)) {
|
|
52
|
+
n.hasIosConfig = true;
|
|
53
|
+
for (const m of fs.readFileSync(plist, "utf8").matchAll(/<key>([^<]+)<\/key>/g))
|
|
54
|
+
n.iosKeys.add(m[1]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Bare Android manifest
|
|
59
|
+
const manifest = path.join(root, "android/app/src/main/AndroidManifest.xml");
|
|
60
|
+
if (fs.existsSync(manifest)) {
|
|
61
|
+
n.hasAndroidConfig = true;
|
|
62
|
+
for (const m of fs.readFileSync(manifest, "utf8").matchAll(/<uses-permission[^>]*android:name="([^"]+)"/g)) {
|
|
63
|
+
n.androidPermissions.add(m[1]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
@@ -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,13 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
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 { makePermissionRule } from "./permissions.js";
|
|
5
|
+
export const rules = [pg001, pg002, pg003, makePermissionRule("ios"), makePermissionRule("android")];
|
|
@@ -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,56 @@
|
|
|
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
|
+
export const capabilityData = JSON.parse(fs.readFileSync(path.resolve(here, "../../data/capabilities.json"), "utf8"));
|
|
6
|
+
function usages(ctx) {
|
|
7
|
+
const found = new Map();
|
|
8
|
+
for (const sf of ctx.sourceFiles) {
|
|
9
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
10
|
+
const mod = imp.getModuleSpecifierValue();
|
|
11
|
+
for (const cap of capabilityData.capabilities) {
|
|
12
|
+
if (found.has(cap.id) || !cap.imports.some((i) => mod === i || mod.startsWith(i + "/")))
|
|
13
|
+
continue;
|
|
14
|
+
const { line, column } = sf.getLineAndColumnAtPos(imp.getStart());
|
|
15
|
+
found.set(cap.id, { cap, file: sf.getFilePath(), line, column, mod });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return [...found.values()];
|
|
20
|
+
}
|
|
21
|
+
export function makePermissionRule(platform) {
|
|
22
|
+
const ios = platform === "ios";
|
|
23
|
+
return {
|
|
24
|
+
id: ios ? "PG006" : "PG007",
|
|
25
|
+
title: ios ? "Missing iOS usage description" : "Missing Android permission",
|
|
26
|
+
severity: "error",
|
|
27
|
+
confidence: "high",
|
|
28
|
+
explain: ios
|
|
29
|
+
? "Using a privacy-sensitive API without the matching Info.plist usage string crashes the app on iOS and gets it rejected in review."
|
|
30
|
+
: "Using a protected API without declaring the permission means the request is silently denied on Android.",
|
|
31
|
+
check(ctx, report) {
|
|
32
|
+
const n = ctx.native;
|
|
33
|
+
if (ios ? !n.hasIosConfig : !n.hasAndroidConfig)
|
|
34
|
+
return; // nothing to compare against
|
|
35
|
+
for (const u of usages(ctx)) {
|
|
36
|
+
const declared = ios
|
|
37
|
+
? u.cap.iosKeys.some((k) => n.iosKeys.has(k))
|
|
38
|
+
: u.cap.androidPermissions.some((p) => n.androidPermissions.has(p));
|
|
39
|
+
const viaPlugin = u.cap.expoPlugins.some((p) => n.expoPlugins.has(p));
|
|
40
|
+
if (declared || viaPlugin)
|
|
41
|
+
continue;
|
|
42
|
+
const need = ios ? u.cap.iosKeys : u.cap.androidPermissions;
|
|
43
|
+
report({
|
|
44
|
+
file: u.file, line: u.line, column: u.column, anchor: `${u.cap.id}:${platform}`,
|
|
45
|
+
confidence: n.dynamicConfig ? "low" : "high",
|
|
46
|
+
message: `\`${u.mod}\` (${u.cap.id}) is used but ${ios ? "no usage description" : "no permission"} is declared. Expected one of: ${need.join(", ")}.`,
|
|
47
|
+
why: ios ? "iOS terminates the app when this API is used without a usage string; App Store review also rejects it."
|
|
48
|
+
: "Android denies the permission request without a manifest declaration, so the feature fails only on Android.",
|
|
49
|
+
fix: n.isExpo
|
|
50
|
+
? `Add the \`${u.cap.expoPlugins[0] ?? u.cap.id}\` config plugin, or set ${ios ? "expo.ios.infoPlist" : "expo.android.permissions"} in app.json.`
|
|
51
|
+
: ios ? `Add ${need[0]} to ios/<App>/Info.plist.` : `Add <uses-permission android:name="${need[0]}"/> to AndroidManifest.xml.`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Node, SyntaxKind } from "ts-morph";
|
|
2
|
+
import { asPlatformCheck, endsWithExit, pos } from "./helpers.js";
|
|
3
|
+
export const pg001 = {
|
|
4
|
+
id: "PG001",
|
|
5
|
+
title: "Platform.OS branch has no counterpart",
|
|
6
|
+
severity: "warn",
|
|
7
|
+
confidence: "high",
|
|
8
|
+
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).",
|
|
10
|
+
check(ctx, report) {
|
|
11
|
+
for (const sf of ctx.sourceFiles) {
|
|
12
|
+
for (const ifs of sf.getDescendantsOfKind(SyntaxKind.IfStatement)) {
|
|
13
|
+
const chk = asPlatformCheck(ifs.getExpression());
|
|
14
|
+
if (!chk || ifs.getElseStatement() || endsWithExit(ifs.getThenStatement()))
|
|
15
|
+
continue;
|
|
16
|
+
const other = chk.platform === "ios" ? "android" : "ios";
|
|
17
|
+
report({
|
|
18
|
+
...pos(ifs),
|
|
19
|
+
message: `Branch for ${chk.negated ? "non-" : ""}${chk.platform} has no ${other} counterpart.`,
|
|
20
|
+
why: `On ${chk.negated ? chk.platform : other}, this logic is skipped entirely with no fallback.`,
|
|
21
|
+
fix: `Add an else branch, or add \`// parity-ignore PG001: <reason>\` if the difference is intentional.`,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
for (const bin of sf.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
|
|
25
|
+
if (bin.getOperatorToken().getKind() !== SyntaxKind.AmpersandAmpersandToken)
|
|
26
|
+
continue;
|
|
27
|
+
const chk = asPlatformCheck(bin.getLeft());
|
|
28
|
+
if (!chk || Node.isIfStatement(bin.getParent()))
|
|
29
|
+
continue;
|
|
30
|
+
report({
|
|
31
|
+
...pos(bin),
|
|
32
|
+
confidence: "medium",
|
|
33
|
+
message: `\`Platform.OS === '${chk.platform}' && …\` renders/runs on ${chk.platform} only.`,
|
|
34
|
+
why: "The other platform gets no equivalent. Fine for iOS-only chrome, risky for functional UI.",
|
|
35
|
+
fix: "Confirm the other platform doesn't need an equivalent, then add a parity-ignore comment with a reason.",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
check(ctx, report) {
|
|
10
|
+
for (const sf of ctx.sourceFiles) {
|
|
11
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
12
|
+
if (call.getExpression().getText() !== "Platform.select")
|
|
13
|
+
continue;
|
|
14
|
+
const arg = call.getArguments()[0];
|
|
15
|
+
if (!arg || !Node.isObjectLiteralExpression(arg))
|
|
16
|
+
continue;
|
|
17
|
+
if (arg.getProperties().some((p) => Node.isSpreadAssignment(p)))
|
|
18
|
+
continue; // can't know
|
|
19
|
+
const keys = new Set(arg.getProperties().map((p) => (Node.isPropertyAssignment(p) || Node.isShorthandPropertyAssignment(p) ? p.getName().replace(/['"]/g, "") : "")));
|
|
20
|
+
if (keys.has("default") || keys.has("native"))
|
|
21
|
+
continue;
|
|
22
|
+
const missing = ["ios", "android"].filter((k) => !keys.has(k));
|
|
23
|
+
if (missing.length === 0)
|
|
24
|
+
continue;
|
|
25
|
+
report({
|
|
26
|
+
...pos(call),
|
|
27
|
+
message: `Platform.select is missing: ${missing.join(", ")}.`,
|
|
28
|
+
why: `On ${missing.join(" and ")} this expression evaluates to \`undefined\` at runtime.`,
|
|
29
|
+
fix: "Add the missing key, or add a `default` value.",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -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
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
androidPermissions: Set<string>;
|
|
35
|
+
expoPlugins: Set<string>;
|
|
36
|
+
}
|
|
37
|
+
export interface ProjectContext {
|
|
38
|
+
root: string;
|
|
39
|
+
project: Project;
|
|
40
|
+
sourceFiles: SourceFile[];
|
|
41
|
+
native: NativeConfig;
|
|
42
|
+
}
|
|
43
|
+
export interface Rule {
|
|
44
|
+
id: string;
|
|
45
|
+
title: string;
|
|
46
|
+
severity: Severity;
|
|
47
|
+
confidence: Confidence;
|
|
48
|
+
explain: string;
|
|
49
|
+
check(ctx: ProjectContext, report: (r: ReportInput) => void): void;
|
|
50
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parityguard",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
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
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"ts-morph": "^28.0.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^26.6.6",
|
|
29
|
+
"typescript": "^7.0.2",
|
|
30
|
+
"vitest": "^5.1.0"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"registry": "https://registry.npmjs.org/",
|
|
35
|
+
"tag": "alpha"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"react-native",
|
|
39
|
+
"ios",
|
|
40
|
+
"android",
|
|
41
|
+
"lint",
|
|
42
|
+
"cross-platform",
|
|
43
|
+
"typescript",
|
|
44
|
+
"expo"
|
|
45
|
+
]
|
|
46
|
+
}
|