deeplink-parity 0.5.0 → 0.6.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.
- package/README.md +121 -7
- package/dist/cli.js +68 -18
- package/dist/discover/walk.js +1 -0
- package/dist/routes/candidates.js +68 -0
- package/dist/routes/check.js +51 -0
- package/dist/routes/config.js +56 -0
- package/dist/routes/extract.js +76 -0
- package/dist/routes/init.js +118 -0
- package/dist/run.js +14 -1
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
Checks that what your app **declares** about deep links matches what is actually **hosted** — on both iOS and Android, from one command.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
|
|
13
|
-
npx deeplink-parity ./my-app-ios ./my-app-android
|
|
12
|
+
npx deeplink-parity .
|
|
14
13
|
```
|
|
15
14
|
|
|
16
15
|
```
|
|
@@ -30,6 +29,54 @@ WARN promo.example.com
|
|
|
30
29
|
|
|
31
30
|
<br>
|
|
32
31
|
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
Nothing to configure. Domains are read from your app and the matching files are fetched
|
|
35
|
+
from them.
|
|
36
|
+
|
|
37
|
+
**One repository** — a monorepo, Flutter, or React Native:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx deeplink-parity .
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Two repositories** — native iOS and Android, which is the case worth checking:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx deeplink-parity ./my-app-ios ./my-app-android
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**On GitHub Actions**, one line:
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
- uses: actions/checkout@v4
|
|
53
|
+
- uses: camosss/deeplink-parity@v1
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Findings show up as annotations on the run. Add `sha256` to include the Android signing
|
|
57
|
+
key in the check, and `fail-on: never` to report without failing while an existing backlog
|
|
58
|
+
is cleared.
|
|
59
|
+
|
|
60
|
+
<br>
|
|
61
|
+
|
|
62
|
+
## Contents
|
|
63
|
+
|
|
64
|
+
- [Why](#why)
|
|
65
|
+
- [What it checks](#what-it-checks)
|
|
66
|
+
- [Usage](#usage)
|
|
67
|
+
- [GitHub Action](#github-action)
|
|
68
|
+
- [In CI](#in-ci)
|
|
69
|
+
- [Run it on a schedule](#run-it-on-a-schedule)
|
|
70
|
+
- [Validate before deploying](#validate-before-deploying)
|
|
71
|
+
- [Route parity](#route-parity)
|
|
72
|
+
- [Native, Flutter and React Native](#native-flutter-and-react-native)
|
|
73
|
+
- [Hardened against real projects](#hardened-against-real-projects)
|
|
74
|
+
- [What it does not do](#what-it-does-not-do)
|
|
75
|
+
- [Development](#development)
|
|
76
|
+
- [License](#license)
|
|
77
|
+
|
|
78
|
+
<br>
|
|
79
|
+
|
|
33
80
|
## Why
|
|
34
81
|
|
|
35
82
|
Deep links fail **silently**. There is no crash, no error log, no red test. A user taps a link, the browser opens instead of your app, and they shrug and move on. Nobody finds out for months.
|
|
@@ -77,11 +124,18 @@ No configuration file. Domains are discovered from your app, then the matching w
|
|
|
77
124
|
## Usage
|
|
78
125
|
|
|
79
126
|
```bash
|
|
80
|
-
npx deeplink-parity [path...] [options]
|
|
127
|
+
npx deeplink-parity [path...] [options] # check (default)
|
|
128
|
+
npx deeplink-parity init [path...] # interactive setup for route comparison
|
|
81
129
|
|
|
82
130
|
--sha256 <fingerprint> Android signing fingerprint to look for in assetlinks.json
|
|
83
131
|
--well-known <dir> Read well-known files from <dir>/<domain>/ instead of the network
|
|
84
|
-
--
|
|
132
|
+
--config <file> Route config (default: deeplink-parity.yml in cwd or a root)
|
|
133
|
+
--print-routes Print the extracted route tables before the report
|
|
134
|
+
--json Machine-readable output on stdout
|
|
135
|
+
--output <file> Also write the JSON result to a file
|
|
136
|
+
--format github GitHub Actions annotations (auto-detected on Actions)
|
|
137
|
+
--yes init only: accept the top suggestion without prompting
|
|
138
|
+
-h, --help Show this message
|
|
85
139
|
```
|
|
86
140
|
|
|
87
141
|
Pass one path per checkout. A monorepo holding both platforms works as a single path,
|
|
@@ -121,6 +175,7 @@ Findings appear as annotations on the run, and counts are available to later ste
|
|
|
121
175
|
| `paths` | `.` | Checkout paths, whitespace separated |
|
|
122
176
|
| `sha256` | — | Android signing fingerprint |
|
|
123
177
|
| `well-known` | — | Read from disk instead of the network |
|
|
178
|
+
| `config` | auto | Route config file (see [Route parity](#route-parity)) |
|
|
124
179
|
| `fail-on` | `error` | `never` to report without failing the step |
|
|
125
180
|
| `version` | pinned | npm version to run |
|
|
126
181
|
|
|
@@ -139,7 +194,7 @@ writes nothing but JSON to stdout; progress notes go to stderr. On GitHub Action
|
|
|
139
194
|
findings are also emitted as annotations, which appear on the run summary and against the
|
|
140
195
|
file when one is involved — set `--format github` to force it elsewhere.
|
|
141
196
|
|
|
142
|
-
### Run it on a schedule
|
|
197
|
+
### Run it on a schedule
|
|
143
198
|
|
|
144
199
|
Your deep-link configuration changes a few times a year. The things that break it do not live in your repo at all:
|
|
145
200
|
|
|
@@ -152,14 +207,73 @@ A pull-request check never sees any of this. **A daily scheduled run does.** See
|
|
|
152
207
|
|
|
153
208
|
### Validate before deploying
|
|
154
209
|
|
|
155
|
-
`--well-known <dir>` reads the files from disk instead of the network, so the web team can
|
|
210
|
+
`--well-known <dir>` reads the files from disk instead of the network, so the web team can
|
|
211
|
+
check staged files before they ship — and CI can run with no egress at all.
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
well-known/
|
|
215
|
+
└── links.example.com/
|
|
216
|
+
├── apple-app-site-association
|
|
217
|
+
└── assetlinks.json
|
|
218
|
+
```
|
|
156
219
|
|
|
157
220
|
```bash
|
|
158
|
-
npx deeplink-parity . --well-known ./
|
|
221
|
+
npx deeplink-parity . --well-known ./well-known
|
|
159
222
|
```
|
|
160
223
|
|
|
224
|
+
This answers a different question, though: whether the app matches the files you intend to
|
|
225
|
+
publish, not whether it matches what is live right now. The failures this tool exists to
|
|
226
|
+
catch — a deploy putting a redirect in front of `/.well-known/`, an expired domain, a
|
|
227
|
+
rotated key — only show up against the real host. Files hosted by an attribution vendor
|
|
228
|
+
cannot be checked offline at all.
|
|
229
|
+
|
|
161
230
|
<br>
|
|
162
231
|
|
|
232
|
+
## Route parity
|
|
233
|
+
|
|
234
|
+
Domains are only half the story. The other half is the screen behind the link: a route
|
|
235
|
+
registered on Android but never on iOS means `myapp://link/events` navigates on one
|
|
236
|
+
platform and goes nowhere on the other — and the person sending that link has no way
|
|
237
|
+
to know.
|
|
238
|
+
|
|
239
|
+
Route tables live in app code with no standard location, so this check is **opt-in via
|
|
240
|
+
one config file**. Run the interactive setup once:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
npx deeplink-parity init ./my-app-ios ./my-app-android
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
It scans for likely route-table files, lets you pick one per platform (or type a path
|
|
247
|
+
yourself), recognises the table's shape, previews what it extracts, and writes
|
|
248
|
+
`deeplink-parity.yml`:
|
|
249
|
+
|
|
250
|
+
```yaml
|
|
251
|
+
routes:
|
|
252
|
+
ios:
|
|
253
|
+
file: App/Routing/DeepLinkRoutes.swift # relative to whichever root it lives under
|
|
254
|
+
match: 'case \w+ = "(/[a-z0-9/_-]+)"' # capture group 1 = the path
|
|
255
|
+
android:
|
|
256
|
+
file: app/src/main/java/…/DeepLinks.kt
|
|
257
|
+
match: '[A-Z_]+\("(/[a-z0-9/_-]+)"\)'
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Commit it. From then on every check — CLI and Action alike — also diffs the two route
|
|
261
|
+
tables and reports the paths one platform has and the other does not. Verify the
|
|
262
|
+
extraction any time with `--print-routes`.
|
|
263
|
+
|
|
264
|
+
Accuracy guards, in keeping with the rest of the tool:
|
|
265
|
+
|
|
266
|
+
- A regex that matches nothing is an **error**, never a clean pass
|
|
267
|
+
- If only one platform extracts, no gaps are reported — a broken table is not a diff
|
|
268
|
+
- Routes handled dynamically (prefix or component matching) never appear in a table;
|
|
269
|
+
gap findings carry that caveat
|
|
270
|
+
|
|
271
|
+
### What the tool touches
|
|
272
|
+
|
|
273
|
+
The **check never writes to the repositories it scans** — it reads files and GETs two
|
|
274
|
+
public well-known files per domain, nothing else. `init` writes exactly one file,
|
|
275
|
+
`deeplink-parity.yml`, in your working directory, after printing its content and asking.
|
|
276
|
+
|
|
163
277
|
## Native, Flutter and React Native
|
|
164
278
|
|
|
165
279
|
Whatever the app is written in, deep links are declared in the same two native files and
|
package/dist/cli.js
CHANGED
|
@@ -4,12 +4,15 @@ import { resolve } from 'node:path';
|
|
|
4
4
|
import { localSource, networkSource } from './fetch/wellKnown.js';
|
|
5
5
|
import { exitCodeFor, printReport } from './report/console.js';
|
|
6
6
|
import { printGithubAnnotations } from './report/github.js';
|
|
7
|
+
import { findConfig, loadRoutesConfig } from './routes/config.js';
|
|
8
|
+
import { runInit } from './routes/init.js';
|
|
7
9
|
import { run } from './run.js';
|
|
8
10
|
const USAGE = `deeplink-parity — check that what your app declares about deep links
|
|
9
11
|
matches what is actually hosted, across iOS and Android.
|
|
10
12
|
|
|
11
13
|
Usage
|
|
12
|
-
deeplink-parity [path...] [options]
|
|
14
|
+
deeplink-parity [path...] [options] check (default)
|
|
15
|
+
deeplink-parity init [path...] interactive setup for route comparison
|
|
13
16
|
|
|
14
17
|
Pass one path per checkout. iOS and Android usually live in separate
|
|
15
18
|
repositories, and comparing them is the point:
|
|
@@ -19,19 +22,29 @@ Usage
|
|
|
19
22
|
Options
|
|
20
23
|
--sha256 <fingerprint> Android signing fingerprint to look for in assetlinks.json
|
|
21
24
|
--well-known <dir> Read well-known files from <dir>/<domain>/ instead of the network
|
|
25
|
+
--config <file> Route config (default: deeplink-parity.yml in cwd or a root)
|
|
26
|
+
--print-routes Print the extracted route tables before the report
|
|
22
27
|
--json Machine-readable output on stdout
|
|
23
28
|
--output <file> Also write the JSON result to a file
|
|
24
29
|
--format github GitHub Actions annotations (auto-detected on Actions)
|
|
30
|
+
--yes init only: accept the top suggestion without prompting
|
|
25
31
|
-h, --help Show this message
|
|
32
|
+
|
|
33
|
+
The check reads the repositories and GETs two public well-known files per domain —
|
|
34
|
+
it never writes to the repositories it scans. init writes exactly one file
|
|
35
|
+
(deeplink-parity.yml), shows the content first, and asks.
|
|
26
36
|
`;
|
|
27
37
|
function parseArgs(argv) {
|
|
28
38
|
const args = argv.slice(2);
|
|
29
|
-
const
|
|
39
|
+
const positional = [];
|
|
30
40
|
let json = false;
|
|
31
41
|
let help = false;
|
|
42
|
+
let printRoutes = false;
|
|
43
|
+
let yes = false;
|
|
32
44
|
let sha256;
|
|
33
45
|
let wellKnown;
|
|
34
46
|
let output;
|
|
47
|
+
let config;
|
|
35
48
|
// Actions sets GITHUB_ACTIONS=true; annotate by default there
|
|
36
49
|
let format = process.env.GITHUB_ACTIONS === 'true' ? 'github' : 'console';
|
|
37
50
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -40,6 +53,10 @@ function parseArgs(argv) {
|
|
|
40
53
|
json = true;
|
|
41
54
|
else if (arg === '-h' || arg === '--help')
|
|
42
55
|
help = true;
|
|
56
|
+
else if (arg === '--print-routes')
|
|
57
|
+
printRoutes = true;
|
|
58
|
+
else if (arg === '--yes')
|
|
59
|
+
yes = true;
|
|
43
60
|
else if (arg === '--sha256')
|
|
44
61
|
sha256 = args[++i];
|
|
45
62
|
else if (arg === '--well-known')
|
|
@@ -48,42 +65,67 @@ function parseArgs(argv) {
|
|
|
48
65
|
format = args[++i];
|
|
49
66
|
else if (arg === '--output')
|
|
50
67
|
output = args[++i];
|
|
68
|
+
else if (arg === '--config')
|
|
69
|
+
config = args[++i];
|
|
51
70
|
else if (!arg.startsWith('-'))
|
|
52
|
-
|
|
71
|
+
positional.push(arg);
|
|
53
72
|
}
|
|
73
|
+
const command = positional[0] === 'init' ? 'init' : 'check';
|
|
74
|
+
const roots = (command === 'init' ? positional.slice(1) : positional).map((r) => resolve(r));
|
|
54
75
|
return {
|
|
55
|
-
|
|
76
|
+
command,
|
|
77
|
+
roots: roots.length ? roots : [resolve('.')],
|
|
56
78
|
json,
|
|
57
79
|
help,
|
|
80
|
+
printRoutes,
|
|
81
|
+
yes,
|
|
58
82
|
sha256,
|
|
59
83
|
wellKnown,
|
|
60
|
-
format,
|
|
61
84
|
output,
|
|
85
|
+
config,
|
|
86
|
+
format,
|
|
62
87
|
};
|
|
63
88
|
}
|
|
64
89
|
async function main() {
|
|
65
|
-
const
|
|
66
|
-
if (help) {
|
|
90
|
+
const opts = parseArgs(process.argv);
|
|
91
|
+
if (opts.help) {
|
|
67
92
|
console.log(USAGE);
|
|
68
93
|
return;
|
|
69
94
|
}
|
|
70
|
-
|
|
95
|
+
if (opts.command === 'init') {
|
|
96
|
+
process.exit(await runInit(opts.roots, opts.yes));
|
|
97
|
+
}
|
|
98
|
+
const configPath = await findConfig(opts.config, opts.roots);
|
|
99
|
+
const routes = configPath ? await loadRoutesConfig(configPath) : undefined;
|
|
100
|
+
const source = opts.wellKnown ? localSource(resolve(opts.wellKnown)) : networkSource();
|
|
71
101
|
const result = await run({
|
|
72
|
-
roots,
|
|
102
|
+
roots: opts.roots,
|
|
73
103
|
source,
|
|
74
|
-
sha256,
|
|
104
|
+
sha256: opts.sha256,
|
|
105
|
+
routes,
|
|
75
106
|
onDiscovered: (count) => {
|
|
76
107
|
// some apps declare a domain per country; say so before spending minutes on it
|
|
77
|
-
if (!wellKnown && count > 50) {
|
|
108
|
+
if (!opts.wellKnown && count > 50) {
|
|
78
109
|
console.error(`Checking ${count} domains — requests are pooled, so this will take a while.`);
|
|
79
110
|
}
|
|
80
111
|
},
|
|
81
112
|
});
|
|
82
|
-
|
|
83
|
-
|
|
113
|
+
const nothingFound = result.iosApps.length === 0 && result.androidApps.length === 0 && result.findings.length === 0;
|
|
114
|
+
if (nothingFound) {
|
|
115
|
+
console.error(`No app configuration declaring deep links was found in ${opts.roots.join(', ')}`);
|
|
84
116
|
console.error('Expected a .entitlements file with applinks:, or an AndroidManifest.xml with intent-filters.');
|
|
85
117
|
process.exit(2);
|
|
86
118
|
}
|
|
119
|
+
if (opts.printRoutes && result.routes) {
|
|
120
|
+
for (const table of [result.routes.ios, result.routes.android]) {
|
|
121
|
+
if (!table)
|
|
122
|
+
continue;
|
|
123
|
+
console.log(`\n${table.platform === 'ios' ? 'iOS' : 'Android'} routes (${table.paths.length}) — ${table.files.join(', ')}`);
|
|
124
|
+
for (const path of table.paths)
|
|
125
|
+
console.log(` ${path}`);
|
|
126
|
+
}
|
|
127
|
+
console.log();
|
|
128
|
+
}
|
|
87
129
|
const payload = {
|
|
88
130
|
ios: result.iosApps.map((a) => ({
|
|
89
131
|
entitlements: a.entitlementsPath,
|
|
@@ -96,6 +138,14 @@ async function main() {
|
|
|
96
138
|
packageIds: a.packageIds,
|
|
97
139
|
hosts: a.hosts.map((h) => h.host),
|
|
98
140
|
})),
|
|
141
|
+
routes: result.routes
|
|
142
|
+
? {
|
|
143
|
+
ios: result.routes.ios ? { files: result.routes.ios.files, paths: result.routes.ios.paths } : undefined,
|
|
144
|
+
android: result.routes.android
|
|
145
|
+
? { files: result.routes.android.files, paths: result.routes.android.paths }
|
|
146
|
+
: undefined,
|
|
147
|
+
}
|
|
148
|
+
: undefined,
|
|
99
149
|
summary: {
|
|
100
150
|
domains: result.domains.length,
|
|
101
151
|
error: result.findings.filter((f) => f.severity === 'error').length,
|
|
@@ -106,19 +156,19 @@ async function main() {
|
|
|
106
156
|
};
|
|
107
157
|
// A file keeps stdout free, so annotations and the readable report can coexist with
|
|
108
158
|
// machine-readable output in the same run.
|
|
109
|
-
if (output)
|
|
110
|
-
await writeFile(output, `${JSON.stringify(payload, null, 2)}\n`);
|
|
111
|
-
if (json) {
|
|
159
|
+
if (opts.output)
|
|
160
|
+
await writeFile(opts.output, `${JSON.stringify(payload, null, 2)}\n`);
|
|
161
|
+
if (opts.json) {
|
|
112
162
|
console.log(JSON.stringify(payload, null, 2));
|
|
113
163
|
}
|
|
114
164
|
else {
|
|
115
|
-
if (format === 'github')
|
|
165
|
+
if (opts.format === 'github')
|
|
116
166
|
printGithubAnnotations(result.findings);
|
|
117
167
|
printReport(result.findings, result.domains);
|
|
118
168
|
}
|
|
119
169
|
process.exit(exitCodeFor(result.findings));
|
|
120
170
|
}
|
|
121
171
|
main().catch((err) => {
|
|
122
|
-
console.error(err);
|
|
172
|
+
console.error(err instanceof Error ? err.message : err);
|
|
123
173
|
process.exit(2);
|
|
124
174
|
});
|
package/dist/discover/walk.js
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { walk } from '../discover/walk.js';
|
|
3
|
+
const PATH_STRING = /"\/[a-z0-9][a-z0-9/_-]*"/g;
|
|
4
|
+
const NAME_HINT = /deeplink|deep-link|route|link|configure|navigat/i;
|
|
5
|
+
// Retrofit / Moya files are full of path strings that are API endpoints, not routes
|
|
6
|
+
const API_MARKER = /@GET|@POST|@PUT|@DELETE|@PATCH\(|TargetType|baseURL|HttpUrl/;
|
|
7
|
+
const SKIP_PATH = /\/(test|tests|androidTest|mock|mocks|__tests__|Pods|\.claude)\//i;
|
|
8
|
+
const MAX_FILE_SIZE = 1_000_000;
|
|
9
|
+
/**
|
|
10
|
+
* Rank likely route-table files: many path-like strings, a routing hint in the name,
|
|
11
|
+
* and not an API client. Scoring is deterministic — the user confirms the pick, so
|
|
12
|
+
* precision comes from them, not from the heuristic.
|
|
13
|
+
*/
|
|
14
|
+
export async function findCandidates(roots) {
|
|
15
|
+
const sources = [];
|
|
16
|
+
for (const root of roots) {
|
|
17
|
+
sources.push(...(await walk(root, (n) => n.endsWith('.swift') || n.endsWith('.kt') || n.endsWith('.java'))));
|
|
18
|
+
}
|
|
19
|
+
const candidates = [];
|
|
20
|
+
for (const file of sources) {
|
|
21
|
+
if (SKIP_PATH.test(file))
|
|
22
|
+
continue;
|
|
23
|
+
try {
|
|
24
|
+
if ((await stat(file)).size > MAX_FILE_SIZE)
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const content = await readFile(file, 'utf8');
|
|
31
|
+
const pathStrings = (content.match(PATH_STRING) ?? []).length;
|
|
32
|
+
if (pathStrings < 3)
|
|
33
|
+
continue;
|
|
34
|
+
let score = pathStrings;
|
|
35
|
+
if (NAME_HINT.test(file))
|
|
36
|
+
score += 30;
|
|
37
|
+
if (API_MARKER.test(content))
|
|
38
|
+
score -= 40;
|
|
39
|
+
candidates.push({
|
|
40
|
+
file,
|
|
41
|
+
platform: file.endsWith('.swift') ? 'ios' : 'android',
|
|
42
|
+
pathStrings,
|
|
43
|
+
score,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return candidates.sort((a, b) => b.score - a.score);
|
|
47
|
+
}
|
|
48
|
+
/** Known table shapes, most specific first. Tried against the chosen file. */
|
|
49
|
+
const SHAPES = [
|
|
50
|
+
{ name: 'Swift enum rawValue', pattern: String.raw `case \w+\s*=\s*"(\/[^"\s]+)"` },
|
|
51
|
+
{ name: 'Swift path property', pattern: String.raw `let path\s*=\s*"(\/[^"\s]+)"` },
|
|
52
|
+
{ name: 'Kotlin enum argument', pattern: String.raw `[A-Z][A-Z_0-9]*\(\s*"(\/[^"\s]+)"` },
|
|
53
|
+
{ name: 'when/switch branch', pattern: String.raw `"(\/[^"\s]+)"\s*->` },
|
|
54
|
+
{ name: 'any path string', pattern: String.raw `"(\/[a-z0-9][a-z0-9\/_-]*)"` },
|
|
55
|
+
];
|
|
56
|
+
export async function suggestRegex(file) {
|
|
57
|
+
const content = await readFile(file, 'utf8');
|
|
58
|
+
const suggestions = [];
|
|
59
|
+
for (const shape of SHAPES) {
|
|
60
|
+
const paths = [
|
|
61
|
+
...new Set([...content.matchAll(new RegExp(shape.pattern, 'g'))].map((m) => m[1])),
|
|
62
|
+
].sort();
|
|
63
|
+
if (paths.length > 0)
|
|
64
|
+
suggestions.push({ name: shape.name, pattern: shape.pattern, paths });
|
|
65
|
+
}
|
|
66
|
+
// best extraction first; ties keep the more specific shape ahead
|
|
67
|
+
return suggestions.sort((a, b) => b.paths.length - a.paths.length);
|
|
68
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Above this many gaps per direction, one grouped finding replaces per-path noise. */
|
|
2
|
+
const GROUP_THRESHOLD = 20;
|
|
3
|
+
const DYNAMIC_NOTE = 'Routes handled dynamically (prefix or path-component matching) never appear in a route table — confirm before acting.';
|
|
4
|
+
function gapFindings(present, absent, gaps) {
|
|
5
|
+
const presentName = present.platform === 'ios' ? 'iOS' : 'Android';
|
|
6
|
+
const absentName = absent.platform === 'ios' ? 'iOS' : 'Android';
|
|
7
|
+
if (gaps.length > GROUP_THRESHOLD) {
|
|
8
|
+
return [
|
|
9
|
+
{
|
|
10
|
+
severity: 'warn',
|
|
11
|
+
rule: 'route-gap',
|
|
12
|
+
message: `${gaps.length} routes are in the ${presentName} route table but not ${absentName}'s`,
|
|
13
|
+
detail: `${gaps.join(', ')} · ${DYNAMIC_NOTE}`,
|
|
14
|
+
source: present.files[0],
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
return gaps.map((path) => ({
|
|
19
|
+
severity: 'warn',
|
|
20
|
+
rule: 'route-gap',
|
|
21
|
+
message: `${path} is in the ${presentName} route table but not ${absentName}'s`,
|
|
22
|
+
detail: `A ${path} link navigates on ${presentName} and goes nowhere on ${absentName}. ${DYNAMIC_NOTE}`,
|
|
23
|
+
source: present.files[0],
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The comparison only runs when both tables extracted successfully — diffing a full
|
|
28
|
+
* table against a failed or missing one would manufacture gaps that do not exist.
|
|
29
|
+
*/
|
|
30
|
+
export function compareRoutes(ios, android) {
|
|
31
|
+
if (!ios && !android)
|
|
32
|
+
return [];
|
|
33
|
+
if (!ios || !android) {
|
|
34
|
+
const present = (ios ?? android);
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
severity: 'info',
|
|
38
|
+
rule: 'route-single-platform',
|
|
39
|
+
message: `Routes are configured for ${present.platform === 'ios' ? 'iOS' : 'Android'} only`,
|
|
40
|
+
detail: `${present.paths.length} route(s) extracted. Configure both platforms to compare them.`,
|
|
41
|
+
source: present.files[0],
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
const iosSet = new Set(ios.paths);
|
|
46
|
+
const androidSet = new Set(android.paths);
|
|
47
|
+
return [
|
|
48
|
+
...gapFindings(ios, android, ios.paths.filter((p) => !androidSet.has(p))),
|
|
49
|
+
...gapFindings(android, ios, android.paths.filter((p) => !iosSet.has(p))),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { load } from 'js-yaml';
|
|
4
|
+
const CONFIG_NAMES = ['deeplink-parity.yml', 'deeplink-parity.yaml'];
|
|
5
|
+
/**
|
|
6
|
+
* An explicit --config always wins. Otherwise the file is looked up in the working
|
|
7
|
+
* directory first, then in each scanned root, so a monorepo can keep it at its root
|
|
8
|
+
* and a two-repo setup can keep it in either checkout.
|
|
9
|
+
*/
|
|
10
|
+
export async function findConfig(explicit, roots) {
|
|
11
|
+
if (explicit)
|
|
12
|
+
return resolve(explicit);
|
|
13
|
+
for (const dir of [process.cwd(), ...roots]) {
|
|
14
|
+
for (const name of CONFIG_NAMES) {
|
|
15
|
+
const candidate = join(dir, name);
|
|
16
|
+
try {
|
|
17
|
+
await access(candidate);
|
|
18
|
+
return candidate;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// keep looking
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Throws with a plain message on a malformed config — that is a setup error, not a finding. */
|
|
28
|
+
export async function loadRoutesConfig(path) {
|
|
29
|
+
const raw = load(await readFile(path, 'utf8'));
|
|
30
|
+
const routes = raw?.['routes'];
|
|
31
|
+
if (!routes || typeof routes !== 'object')
|
|
32
|
+
return {};
|
|
33
|
+
const normalize = (key) => {
|
|
34
|
+
const entry = routes[key];
|
|
35
|
+
if (!entry)
|
|
36
|
+
return undefined;
|
|
37
|
+
const files = Array.isArray(entry['files'])
|
|
38
|
+
? entry['files'].filter((f) => typeof f === 'string')
|
|
39
|
+
: typeof entry['file'] === 'string'
|
|
40
|
+
? [entry['file']]
|
|
41
|
+
: [];
|
|
42
|
+
const match = entry['match'];
|
|
43
|
+
if (files.length === 0 || typeof match !== 'string') {
|
|
44
|
+
throw new Error(`routes.${key} needs "file" (or "files") and "match" in ${path}`);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
// validate early so a bad pattern fails the run, not silently matches nothing
|
|
48
|
+
new RegExp(match, 'g');
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
throw new Error(`routes.${key}.match is not a valid regex: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
return { files, match };
|
|
54
|
+
};
|
|
55
|
+
return { ios: normalize('ios'), android: normalize('android') };
|
|
56
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
import { displayPath } from '../discover/walk.js';
|
|
4
|
+
/**
|
|
5
|
+
* A config path is relative to whichever scanned root it exists under, so the same
|
|
6
|
+
* file works locally (`~/ios ~/android`) and in CI (`ios/ android/`). Existing in
|
|
7
|
+
* more than one root is ambiguous and reported rather than guessed at.
|
|
8
|
+
*/
|
|
9
|
+
async function resolveInRoots(file, roots) {
|
|
10
|
+
if (isAbsolute(file)) {
|
|
11
|
+
try {
|
|
12
|
+
await access(file);
|
|
13
|
+
return [file];
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const hits = [];
|
|
20
|
+
for (const root of roots) {
|
|
21
|
+
const candidate = join(root, file);
|
|
22
|
+
try {
|
|
23
|
+
await access(candidate);
|
|
24
|
+
hits.push(candidate);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// not under this root
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return hits;
|
|
31
|
+
}
|
|
32
|
+
export async function extractRoutes(platform, config, roots) {
|
|
33
|
+
const findings = [];
|
|
34
|
+
const files = [];
|
|
35
|
+
const paths = new Set();
|
|
36
|
+
for (const file of config.files) {
|
|
37
|
+
const hits = await resolveInRoots(file, roots);
|
|
38
|
+
if (hits.length === 0) {
|
|
39
|
+
findings.push({
|
|
40
|
+
severity: 'error',
|
|
41
|
+
rule: 'routes-file-missing',
|
|
42
|
+
message: `Route file not found under any scanned root: ${file}`,
|
|
43
|
+
detail: 'Fix the path in deeplink-parity.yml, or re-run `deeplink-parity init`',
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (hits.length > 1) {
|
|
48
|
+
findings.push({
|
|
49
|
+
severity: 'error',
|
|
50
|
+
rule: 'routes-file-ambiguous',
|
|
51
|
+
message: `Route file exists under more than one root: ${file}`,
|
|
52
|
+
detail: hits.map((h) => displayPath(h)).join(' · '),
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const content = await readFile(hits[0], 'utf8');
|
|
57
|
+
files.push(displayPath(hits[0]));
|
|
58
|
+
for (const m of content.matchAll(new RegExp(config.match, 'g'))) {
|
|
59
|
+
paths.add(m[1] ?? m[0]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (files.length > 0 && paths.size === 0) {
|
|
63
|
+
// matching nothing is a broken config, never a clean pass
|
|
64
|
+
findings.push({
|
|
65
|
+
severity: 'error',
|
|
66
|
+
rule: 'routes-extraction-empty',
|
|
67
|
+
message: `The ${platform} route regex matched nothing`,
|
|
68
|
+
detail: `Pattern: ${config.match} · Files: ${files.join(', ')}. Verify with --print-routes.`,
|
|
69
|
+
source: files[0],
|
|
70
|
+
});
|
|
71
|
+
return { findings };
|
|
72
|
+
}
|
|
73
|
+
if (files.length === 0)
|
|
74
|
+
return { findings };
|
|
75
|
+
return { routes: { platform, files, paths: [...paths].sort() }, findings };
|
|
76
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { access, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { dump } from 'js-yaml';
|
|
5
|
+
import { findCandidates, suggestRegex } from './candidates.js';
|
|
6
|
+
const PREVIEW = 8;
|
|
7
|
+
/** The config records paths relative to the root a file lives under, never absolute. */
|
|
8
|
+
function rootRelative(file, roots) {
|
|
9
|
+
for (const root of roots) {
|
|
10
|
+
const rel = relative(root, file);
|
|
11
|
+
if (!rel.startsWith('..'))
|
|
12
|
+
return rel;
|
|
13
|
+
}
|
|
14
|
+
return file;
|
|
15
|
+
}
|
|
16
|
+
async function pickFile(rl, platform, candidates, yes) {
|
|
17
|
+
const top = candidates.filter((c) => c.platform === platform).slice(0, 5);
|
|
18
|
+
if (top.length === 0) {
|
|
19
|
+
if (yes)
|
|
20
|
+
return undefined;
|
|
21
|
+
const manual = (await rl.question(`No ${platform} route-file candidates found. Enter a path, or leave blank to skip: `)).trim();
|
|
22
|
+
return manual || undefined;
|
|
23
|
+
}
|
|
24
|
+
if (yes)
|
|
25
|
+
return top[0].file;
|
|
26
|
+
console.log(`\n${platform === 'ios' ? 'iOS' : 'Android'} route file candidates:`);
|
|
27
|
+
top.forEach((c, i) => console.log(` ${i + 1}. ${c.file} (${c.pathStrings} path strings)`));
|
|
28
|
+
console.log(` m. enter a path manually`);
|
|
29
|
+
console.log(` s. skip ${platform}`);
|
|
30
|
+
const answer = (await rl.question('> ')).trim().toLowerCase();
|
|
31
|
+
if (answer === 's' || answer === '')
|
|
32
|
+
return undefined;
|
|
33
|
+
if (answer === 'm') {
|
|
34
|
+
const manual = (await rl.question('path: ')).trim();
|
|
35
|
+
return manual || undefined;
|
|
36
|
+
}
|
|
37
|
+
const index = Number.parseInt(answer, 10);
|
|
38
|
+
return Number.isInteger(index) && index >= 1 && index <= top.length ? top[index - 1].file : undefined;
|
|
39
|
+
}
|
|
40
|
+
async function pickRegex(rl, file, yes) {
|
|
41
|
+
const suggestions = await suggestRegex(file);
|
|
42
|
+
if (suggestions.length > 0) {
|
|
43
|
+
const best = suggestions[0];
|
|
44
|
+
const preview = best.paths.slice(0, PREVIEW).join(', ');
|
|
45
|
+
const more = best.paths.length > PREVIEW ? ` … +${best.paths.length - PREVIEW} more` : '';
|
|
46
|
+
console.log(`\n Recognised shape: ${best.name}`);
|
|
47
|
+
console.log(` Extracts ${best.paths.length} path(s): ${preview}${more}`);
|
|
48
|
+
if (yes)
|
|
49
|
+
return best.pattern;
|
|
50
|
+
const answer = (await rl.question(' Use this? (Y = yes / r = enter my own regex): ')).trim().toLowerCase();
|
|
51
|
+
if (answer === '' || answer === 'y')
|
|
52
|
+
return best.pattern;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
console.log('\n No known shape matched this file.');
|
|
56
|
+
if (yes)
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const custom = (await rl.question(' Regex (capture group 1 = path), blank to skip: ')).trim();
|
|
60
|
+
return custom || undefined;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The only command that writes anything, and it writes exactly one file — the config —
|
|
64
|
+
* after printing its full content and asking. The scanned repositories are never touched.
|
|
65
|
+
*/
|
|
66
|
+
export async function runInit(roots, yes) {
|
|
67
|
+
console.log(`Scanning ${roots.length} root(s) for route tables…`);
|
|
68
|
+
const candidates = await findCandidates(roots);
|
|
69
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
70
|
+
try {
|
|
71
|
+
const picks = {};
|
|
72
|
+
for (const platform of ['ios', 'android']) {
|
|
73
|
+
const file = await pickFile(rl, platform, candidates, yes);
|
|
74
|
+
if (!file)
|
|
75
|
+
continue;
|
|
76
|
+
const match = await pickRegex(rl, file, yes);
|
|
77
|
+
if (!match)
|
|
78
|
+
continue;
|
|
79
|
+
picks[platform] = { file: rootRelative(file, roots), match };
|
|
80
|
+
}
|
|
81
|
+
if (!picks.ios && !picks.android) {
|
|
82
|
+
console.log('\nNothing selected — no config written.');
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
const yaml = dump({
|
|
86
|
+
routes: Object.fromEntries(Object.entries(picks).map(([platform, pick]) => [platform, pick])),
|
|
87
|
+
});
|
|
88
|
+
const target = join(process.cwd(), 'deeplink-parity.yml');
|
|
89
|
+
console.log(`\nThis will be written to ${target}:\n`);
|
|
90
|
+
console.log(yaml.replace(/^/gm, ' '));
|
|
91
|
+
try {
|
|
92
|
+
await access(target);
|
|
93
|
+
const answer = yes
|
|
94
|
+
? 'y'
|
|
95
|
+
: (await rl.question('deeplink-parity.yml already exists. Overwrite? (y/N): ')).trim().toLowerCase();
|
|
96
|
+
if (answer !== 'y') {
|
|
97
|
+
console.log('Left the existing file alone.');
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
if (!yes) {
|
|
103
|
+
const answer = (await rl.question('Write it? (Y/n): ')).trim().toLowerCase();
|
|
104
|
+
if (answer === 'n') {
|
|
105
|
+
console.log('Nothing written.');
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
await writeFile(target, yaml);
|
|
111
|
+
console.log(`Wrote ${target} — commit it, then run the check as usual.`);
|
|
112
|
+
console.log('The check itself never writes to the repositories it scans.');
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
rl.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
package/dist/run.js
CHANGED
|
@@ -6,10 +6,21 @@ import { checkAndroidHost, checkAndroidUnresolved } from './rules/android.js';
|
|
|
6
6
|
import { checkCrossPlatform } from './rules/cross.js';
|
|
7
7
|
import { aasaPaths, checkIosDomain } from './rules/ios.js';
|
|
8
8
|
import { isWildcardDomain, wildcardFinding } from './rules/wildcard.js';
|
|
9
|
+
import { compareRoutes } from './routes/check.js';
|
|
10
|
+
import { extractRoutes } from './routes/extract.js';
|
|
9
11
|
function emptyView() {
|
|
10
12
|
return { domains: new Set(), paths: new Map() };
|
|
11
13
|
}
|
|
12
|
-
|
|
14
|
+
async function runRouteChecks(routes, roots, findings) {
|
|
15
|
+
const [ios, android] = await Promise.all([
|
|
16
|
+
routes.ios ? extractRoutes('ios', routes.ios, roots) : undefined,
|
|
17
|
+
routes.android ? extractRoutes('android', routes.android, roots) : undefined,
|
|
18
|
+
]);
|
|
19
|
+
findings.push(...(ios?.findings ?? []), ...(android?.findings ?? []));
|
|
20
|
+
findings.push(...compareRoutes(ios?.routes, android?.routes));
|
|
21
|
+
return { ios: ios?.routes, android: android?.routes };
|
|
22
|
+
}
|
|
23
|
+
export async function run({ roots, source, sha256, routes, onDiscovered, }) {
|
|
13
24
|
const discovered = await Promise.all(roots.map(async (root) => Promise.all([discoverIos(root), discoverAndroid(root)])));
|
|
14
25
|
const iosApps = discovered.flatMap(([ios]) => ios);
|
|
15
26
|
const androidApps = discovered.flatMap(([, android]) => android);
|
|
@@ -60,10 +71,12 @@ export async function run({ roots, source, sha256, onDiscovered }) {
|
|
|
60
71
|
});
|
|
61
72
|
}
|
|
62
73
|
findings.push(...checkCrossPlatform(ios, android));
|
|
74
|
+
const routeTables = routes ? await runRouteChecks(routes, roots, findings) : undefined;
|
|
63
75
|
return {
|
|
64
76
|
iosApps,
|
|
65
77
|
androidApps,
|
|
66
78
|
domains: [...new Set([...ios.domains, ...android.domains])],
|
|
79
|
+
routes: routeTables,
|
|
67
80
|
findings,
|
|
68
81
|
};
|
|
69
82
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deeplink-parity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Checks that what your mobile app declares about deep links matches what is actually hosted — across iOS and Android.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,9 +37,11 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"fast-xml-parser": "^5.10.1",
|
|
40
|
+
"js-yaml": "^5.2.3",
|
|
40
41
|
"plist": "^3.1.0"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
44
|
+
"@types/js-yaml": "^4.0.9",
|
|
43
45
|
"@types/node": "^22.10.0",
|
|
44
46
|
"@types/plist": "^3.0.5",
|
|
45
47
|
"tsx": "^4.19.0",
|