deeplink-parity 0.5.1 → 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 +52 -1
- 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
|
@@ -68,6 +68,7 @@ is cleared.
|
|
|
68
68
|
- [In CI](#in-ci)
|
|
69
69
|
- [Run it on a schedule](#run-it-on-a-schedule)
|
|
70
70
|
- [Validate before deploying](#validate-before-deploying)
|
|
71
|
+
- [Route parity](#route-parity)
|
|
71
72
|
- [Native, Flutter and React Native](#native-flutter-and-react-native)
|
|
72
73
|
- [Hardened against real projects](#hardened-against-real-projects)
|
|
73
74
|
- [What it does not do](#what-it-does-not-do)
|
|
@@ -123,13 +124,17 @@ No configuration file. Domains are discovered from your app, then the matching w
|
|
|
123
124
|
## Usage
|
|
124
125
|
|
|
125
126
|
```bash
|
|
126
|
-
npx deeplink-parity [path...] [options]
|
|
127
|
+
npx deeplink-parity [path...] [options] # check (default)
|
|
128
|
+
npx deeplink-parity init [path...] # interactive setup for route comparison
|
|
127
129
|
|
|
128
130
|
--sha256 <fingerprint> Android signing fingerprint to look for in assetlinks.json
|
|
129
131
|
--well-known <dir> Read well-known files from <dir>/<domain>/ instead of the network
|
|
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
|
|
130
134
|
--json Machine-readable output on stdout
|
|
131
135
|
--output <file> Also write the JSON result to a file
|
|
132
136
|
--format github GitHub Actions annotations (auto-detected on Actions)
|
|
137
|
+
--yes init only: accept the top suggestion without prompting
|
|
133
138
|
-h, --help Show this message
|
|
134
139
|
```
|
|
135
140
|
|
|
@@ -170,6 +175,7 @@ Findings appear as annotations on the run, and counts are available to later ste
|
|
|
170
175
|
| `paths` | `.` | Checkout paths, whitespace separated |
|
|
171
176
|
| `sha256` | — | Android signing fingerprint |
|
|
172
177
|
| `well-known` | — | Read from disk instead of the network |
|
|
178
|
+
| `config` | auto | Route config file (see [Route parity](#route-parity)) |
|
|
173
179
|
| `fail-on` | `error` | `never` to report without failing the step |
|
|
174
180
|
| `version` | pinned | npm version to run |
|
|
175
181
|
|
|
@@ -223,6 +229,51 @@ cannot be checked offline at all.
|
|
|
223
229
|
|
|
224
230
|
<br>
|
|
225
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
|
+
|
|
226
277
|
## Native, Flutter and React Native
|
|
227
278
|
|
|
228
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",
|