deeplink-parity 0.6.5 → 0.8.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 +28 -3
- package/dist/baseline.js +69 -0
- package/dist/cli.js +75 -10
- package/dist/discover/android.js +1 -1
- package/dist/discover/ios.js +12 -3
- package/dist/fetch/wellKnown.js +6 -2
- package/dist/report/console.js +34 -13
- package/dist/report/github.js +18 -2
- package/dist/resolve/androidResources.js +17 -3
- package/dist/routes/check.js +3 -13
- package/dist/run.js +69 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,8 +59,8 @@ npx deeplink-parity ./my-app-ios ./my-app-android
|
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
Findings show up as annotations on the run. Add `sha256` to include the Android signing
|
|
62
|
-
key in the check
|
|
63
|
-
|
|
62
|
+
key in the check. Already have findings? Freeze them once with `npx deeplink-parity
|
|
63
|
+
baseline` and commit the file — the check then fails only on new ones.
|
|
64
64
|
|
|
65
65
|
Domains are checked with zero setup. To also compare the **screens behind the links**,
|
|
66
66
|
run `npx deeplink-parity init` once — see [Route parity](#route-parity).
|
|
@@ -74,6 +74,7 @@ run `npx deeplink-parity init` once — see [Route parity](#route-parity).
|
|
|
74
74
|
- [Usage](#usage)
|
|
75
75
|
- [GitHub Action](#github-action)
|
|
76
76
|
- [In CI](#in-ci)
|
|
77
|
+
- [Start with existing findings](#start-with-existing-findings)
|
|
77
78
|
- [Run it on a schedule](#run-it-on-a-schedule)
|
|
78
79
|
- [Validate before deploying](#validate-before-deploying)
|
|
79
80
|
- [Route parity](#route-parity)
|
|
@@ -135,14 +136,17 @@ No configuration file. Domains are discovered from your app, then the matching w
|
|
|
135
136
|
```bash
|
|
136
137
|
npx deeplink-parity [path...] [options] # check (default)
|
|
137
138
|
npx deeplink-parity init [path...] # interactive setup for route comparison
|
|
139
|
+
npx deeplink-parity baseline [path...] # freeze current findings; fail on new ones only
|
|
138
140
|
|
|
139
141
|
--sha256 <fingerprint> Android signing fingerprint to look for in assetlinks.json
|
|
140
142
|
--well-known <dir> Read well-known files from <dir>/<domain>/ instead of the network
|
|
141
143
|
--config <file> Route config (default: deeplink-parity.yml in cwd or a root)
|
|
144
|
+
--baseline <file> Baseline file (default: deeplink-parity-baseline.json in cwd or a root)
|
|
142
145
|
--print-routes Print the extracted route tables before the report
|
|
143
146
|
--json Machine-readable output on stdout
|
|
144
147
|
--output <file> Also write the JSON result to a file
|
|
145
148
|
--format github GitHub Actions annotations (auto-detected on Actions)
|
|
149
|
+
--fail-on <level> error (default) · warn to also gate on warnings · never
|
|
146
150
|
--yes init only: accept the top suggestion without prompting
|
|
147
151
|
-v, --version Print the version
|
|
148
152
|
-h, --help Show this message
|
|
@@ -186,7 +190,8 @@ Findings appear as annotations on the run, and counts are available to later ste
|
|
|
186
190
|
| `sha256` | — | Android signing fingerprint |
|
|
187
191
|
| `well-known` | — | Read from disk instead of the network |
|
|
188
192
|
| `config` | auto | Route config file (see [Route parity](#route-parity)) |
|
|
189
|
-
| `
|
|
193
|
+
| `baseline` | auto | Baseline file (see [Start with existing findings](#start-with-existing-findings)) |
|
|
194
|
+
| `fail-on` | `error` | `warn` to also gate on warnings (route gaps), `never` to report without failing |
|
|
190
195
|
| `version` | pinned | npm version to run |
|
|
191
196
|
|
|
192
197
|
Outputs: `errors`, `warnings`, `notices`, `domains`, `report` (path to the JSON result).
|
|
@@ -204,6 +209,26 @@ writes nothing but JSON to stdout; progress notes go to stderr. On GitHub Action
|
|
|
204
209
|
findings are also emitted as annotations, which appear on the run summary and against the
|
|
205
210
|
file when one is involved — set `--format github` to force it elsewhere.
|
|
206
211
|
|
|
212
|
+
### Start with existing findings
|
|
213
|
+
|
|
214
|
+
A tool that fails on day one gets turned off on day two. If the first run reports
|
|
215
|
+
problems you cannot fix immediately, freeze them:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
npx deeplink-parity baseline ./app-ios ./app-android
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
That writes `deeplink-parity-baseline.json` — commit it. From then on every check still
|
|
222
|
+
**reports** the known findings, marked `(baselined)`, but only a finding that is not in
|
|
223
|
+
the file can fail the run. By default only errors fail; pass `--fail-on warn` to make
|
|
224
|
+
warnings (route gaps, platform domain gaps) gate too — that is what makes freezing
|
|
225
|
+
warnings in a baseline meaningful. The file stores a one-line summary per finding, so a reviewer
|
|
226
|
+
can read what is being tolerated. When a baselined problem gets fixed, the check says
|
|
227
|
+
so; re-run `baseline` to tighten the file.
|
|
228
|
+
|
|
229
|
+
A known problem keeps its identity when the failure changes shape — a domain that
|
|
230
|
+
answered 404 yesterday and times out today is still one known problem, not a new alert.
|
|
231
|
+
|
|
207
232
|
### Run it on a schedule
|
|
208
233
|
|
|
209
234
|
Your deep-link configuration changes a few times a year. The things that break it do not live in your repo at all:
|
package/dist/baseline.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
export const BASELINE_NAME = 'deeplink-parity-baseline.json';
|
|
4
|
+
/**
|
|
5
|
+
* Identity of a finding across runs. Domain problems key on rule + domain so a failure
|
|
6
|
+
* that changes shape (404 today, timeout tomorrow) stays one known problem; findings
|
|
7
|
+
* without a domain (route gaps) carry their identity in the message.
|
|
8
|
+
*/
|
|
9
|
+
export function baselineKey(f) {
|
|
10
|
+
return `${f.rule}|${f.domain ?? f.message}`;
|
|
11
|
+
}
|
|
12
|
+
/** Same lookup order as the route config: an explicit path wins, then cwd, then roots. */
|
|
13
|
+
export async function findBaseline(explicit, roots) {
|
|
14
|
+
if (explicit)
|
|
15
|
+
return resolve(explicit);
|
|
16
|
+
for (const dir of [process.cwd(), ...roots]) {
|
|
17
|
+
const candidate = join(dir, BASELINE_NAME);
|
|
18
|
+
try {
|
|
19
|
+
await access(candidate);
|
|
20
|
+
return candidate;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// keep looking
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
export async function loadBaseline(path) {
|
|
29
|
+
const raw = JSON.parse(await readFile(path, 'utf8'));
|
|
30
|
+
if (!Array.isArray(raw?.entries)) {
|
|
31
|
+
throw new Error(`${path} is not a baseline file — expected an "entries" array`);
|
|
32
|
+
}
|
|
33
|
+
return new Set(raw.entries.map((e) => e.key));
|
|
34
|
+
}
|
|
35
|
+
/** Only findings that can fail a run belong in a baseline; info never fails. */
|
|
36
|
+
export async function writeBaseline(path, findings) {
|
|
37
|
+
const entries = new Map();
|
|
38
|
+
for (const f of findings) {
|
|
39
|
+
if (f.severity === 'info')
|
|
40
|
+
continue;
|
|
41
|
+
const key = baselineKey(f);
|
|
42
|
+
if (!entries.has(key)) {
|
|
43
|
+
entries.set(key, { key, summary: [f.domain, f.message].filter(Boolean).join(' — ') });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const sorted = [...entries.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
47
|
+
await writeFile(path, `${JSON.stringify({ entries: sorted }, null, 2)}\n`);
|
|
48
|
+
return sorted.length;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Marks known findings in place and reports which baseline entries are now stale.
|
|
52
|
+
* Exit-code policy lives in exitCodeFor: a baselined error no longer fails the run.
|
|
53
|
+
*/
|
|
54
|
+
export function applyBaseline(findings, baseline) {
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
for (const f of findings) {
|
|
57
|
+
if (f.severity === 'info')
|
|
58
|
+
continue;
|
|
59
|
+
const key = baselineKey(f);
|
|
60
|
+
if (baseline.has(key)) {
|
|
61
|
+
f.baselined = true;
|
|
62
|
+
seen.add(key);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
matched: seen.size,
|
|
67
|
+
resolved: [...baseline].filter((k) => !seen.has(k)).sort(),
|
|
68
|
+
};
|
|
69
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
|
+
import { applyBaseline, BASELINE_NAME, findBaseline, loadBaseline, writeBaseline } from './baseline.js';
|
|
4
5
|
import { localSource, networkSource } from './fetch/wellKnown.js';
|
|
5
6
|
import { exitCodeFor, printReport } from './report/console.js';
|
|
6
7
|
import { printGithubAnnotations } from './report/github.js';
|
|
@@ -13,6 +14,7 @@ matches what is actually hosted, across iOS and Android.
|
|
|
13
14
|
Usage
|
|
14
15
|
deeplink-parity [path...] [options] check (default)
|
|
15
16
|
deeplink-parity init [path...] interactive setup for route comparison
|
|
17
|
+
deeplink-parity baseline [path...] freeze current findings; checks then fail on new ones only
|
|
16
18
|
|
|
17
19
|
Pass one path per checkout. iOS and Android usually live in separate
|
|
18
20
|
repositories, and comparing them is the point:
|
|
@@ -23,6 +25,7 @@ Options
|
|
|
23
25
|
--sha256 <fingerprint> Android signing fingerprint to look for in assetlinks.json
|
|
24
26
|
--well-known <dir> Read well-known files from <dir>/<domain>/ instead of the network
|
|
25
27
|
--config <file> Route config (default: deeplink-parity.yml in cwd or a root)
|
|
28
|
+
--baseline <file> Baseline file (default: deeplink-parity-baseline.json in cwd or a root)
|
|
26
29
|
--print-routes Print the extracted route tables before the report
|
|
27
30
|
--json Machine-readable output on stdout
|
|
28
31
|
--output <file> Also write the JSON result to a file
|
|
@@ -47,6 +50,8 @@ function parseArgs(argv) {
|
|
|
47
50
|
let wellKnown;
|
|
48
51
|
let output;
|
|
49
52
|
let config;
|
|
53
|
+
let baseline;
|
|
54
|
+
let failOn = 'error';
|
|
50
55
|
// Actions sets GITHUB_ACTIONS=true; annotate by default there
|
|
51
56
|
let format = process.env.GITHUB_ACTIONS === 'true' ? 'github' : 'console';
|
|
52
57
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -71,11 +76,31 @@ function parseArgs(argv) {
|
|
|
71
76
|
output = args[++i];
|
|
72
77
|
else if (arg === '--config')
|
|
73
78
|
config = args[++i];
|
|
74
|
-
else if (
|
|
79
|
+
else if (arg === '--baseline')
|
|
80
|
+
baseline = args[++i];
|
|
81
|
+
else if (arg === '--fail-on') {
|
|
82
|
+
const v = args[++i];
|
|
83
|
+
if (v !== 'error' && v !== 'warn' && v !== 'never') {
|
|
84
|
+
console.error(`error: --fail-on must be "error", "warn" or "never", got "${v}"`);
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
failOn = v;
|
|
88
|
+
}
|
|
89
|
+
else if (arg.startsWith('-')) {
|
|
90
|
+
// a mistyped flag must not become a scan root or silently drop an option —
|
|
91
|
+
// --well-knwon once sent a "no egress" CI run out to the real network
|
|
92
|
+
console.error(`error: unknown flag "${arg}" — see --help`);
|
|
93
|
+
process.exit(2);
|
|
94
|
+
}
|
|
95
|
+
else
|
|
75
96
|
positional.push(arg);
|
|
76
97
|
}
|
|
77
|
-
|
|
78
|
-
|
|
98
|
+
if (format !== 'console' && format !== 'github') {
|
|
99
|
+
console.error(`error: --format must be "console" or "github", got "${format}"`);
|
|
100
|
+
process.exit(2);
|
|
101
|
+
}
|
|
102
|
+
const command = positional[0] === 'init' || positional[0] === 'baseline' ? positional[0] : 'check';
|
|
103
|
+
const roots = (command === 'check' ? positional : positional.slice(1)).map((r) => resolve(r));
|
|
79
104
|
return {
|
|
80
105
|
command,
|
|
81
106
|
roots: roots.length ? roots : [resolve('.')],
|
|
@@ -88,7 +113,9 @@ function parseArgs(argv) {
|
|
|
88
113
|
wellKnown,
|
|
89
114
|
output,
|
|
90
115
|
config,
|
|
116
|
+
baseline,
|
|
91
117
|
format,
|
|
118
|
+
failOn,
|
|
92
119
|
};
|
|
93
120
|
}
|
|
94
121
|
/**
|
|
@@ -130,6 +157,9 @@ async function main() {
|
|
|
130
157
|
}
|
|
131
158
|
const configPath = await findConfig(opts.config, opts.roots);
|
|
132
159
|
const routes = configPath ? await loadRoutesConfig(configPath) : undefined;
|
|
160
|
+
// the baseline subcommand regenerates the file, so it must not also consume one
|
|
161
|
+
const baselinePath = opts.command === 'baseline' ? undefined : await findBaseline(opts.baseline, opts.roots);
|
|
162
|
+
const baseline = baselinePath ? await loadBaseline(baselinePath) : undefined;
|
|
133
163
|
const source = opts.wellKnown ? localSource(resolve(opts.wellKnown)) : networkSource();
|
|
134
164
|
const result = await run({
|
|
135
165
|
roots: opts.roots,
|
|
@@ -143,7 +173,34 @@ async function main() {
|
|
|
143
173
|
}
|
|
144
174
|
},
|
|
145
175
|
});
|
|
146
|
-
|
|
176
|
+
if (opts.command === 'baseline') {
|
|
177
|
+
const target = opts.baseline ? resolve(opts.baseline) : resolve(BASELINE_NAME);
|
|
178
|
+
const count = await writeBaseline(target, result.findings);
|
|
179
|
+
console.log(`Froze ${count} finding(s) into ${target}`);
|
|
180
|
+
console.log('Commit it. From now on the check fails only on findings that are not in this file.');
|
|
181
|
+
console.log('Re-run `deeplink-parity baseline` after fixing something, to tighten it.');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const applied = baseline ? applyBaseline(result.findings, baseline) : undefined;
|
|
185
|
+
if (applied) {
|
|
186
|
+
for (const key of applied.resolved) {
|
|
187
|
+
result.findings.push({
|
|
188
|
+
severity: 'info',
|
|
189
|
+
rule: 'baseline-resolved',
|
|
190
|
+
message: `A baselined finding no longer occurs: ${key}`,
|
|
191
|
+
detail: 'Re-run `deeplink-parity baseline` to tighten the baseline.',
|
|
192
|
+
source: baselinePath,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// a run that successfully extracted route tables is a legitimate run — before this
|
|
197
|
+
// guard, a routes-only run failed exactly when every route matched and passed when
|
|
198
|
+
// there were gaps, inverting success and failure
|
|
199
|
+
const routesExtracted = Boolean(result.routes?.ios ?? result.routes?.android);
|
|
200
|
+
const nothingFound = result.iosApps.length === 0 &&
|
|
201
|
+
result.androidApps.length === 0 &&
|
|
202
|
+
result.findings.length === 0 &&
|
|
203
|
+
!routesExtracted;
|
|
147
204
|
if (nothingFound) {
|
|
148
205
|
console.error(`No app configuration declaring deep links was found in ${opts.roots.join(', ')}`);
|
|
149
206
|
console.error('Expected a .entitlements file with applinks:, or an AndroidManifest.xml with intent-filters.');
|
|
@@ -162,6 +219,7 @@ async function main() {
|
|
|
162
219
|
const payload = {
|
|
163
220
|
version: pkg.version,
|
|
164
221
|
routeConfig: configPath,
|
|
222
|
+
baseline: baselinePath ? { path: baselinePath, matched: applied?.matched ?? 0 } : undefined,
|
|
165
223
|
ios: result.iosApps.map((a) => ({
|
|
166
224
|
entitlements: a.entitlementsPath,
|
|
167
225
|
teamId: a.teamId,
|
|
@@ -183,9 +241,12 @@ async function main() {
|
|
|
183
241
|
: undefined,
|
|
184
242
|
summary: {
|
|
185
243
|
domains: result.domains.length,
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
244
|
+
// counts exclude baselined findings, matching the exit-code semantics — a
|
|
245
|
+
// consumer branching on `error` must agree with whether the run failed
|
|
246
|
+
error: result.findings.filter((f) => f.severity === 'error' && !f.baselined).length,
|
|
247
|
+
warn: result.findings.filter((f) => f.severity === 'warn' && !f.baselined).length,
|
|
248
|
+
info: result.findings.filter((f) => f.severity === 'info' && !f.baselined).length,
|
|
249
|
+
baselined: result.findings.filter((f) => f.baselined).length,
|
|
189
250
|
},
|
|
190
251
|
findings: result.findings,
|
|
191
252
|
};
|
|
@@ -193,12 +254,16 @@ async function main() {
|
|
|
193
254
|
// machine-readable output in the same run.
|
|
194
255
|
if (opts.output)
|
|
195
256
|
await writeFile(opts.output, `${JSON.stringify(payload, null, 2)}\n`);
|
|
196
|
-
const
|
|
257
|
+
const baselineLine = baselinePath
|
|
258
|
+
? ` · baseline: ${applied?.matched ?? 0} known finding(s), failing on new only`
|
|
259
|
+
: '';
|
|
260
|
+
const routesLine = (configPath
|
|
197
261
|
? `routes: ${configPath}` +
|
|
198
262
|
(result.routes
|
|
199
263
|
? ` (iOS ${result.routes.ios?.paths.length ?? 0} · Android ${result.routes.android?.paths.length ?? 0})`
|
|
200
264
|
: '')
|
|
201
|
-
: 'routes: no config found — run "deeplink-parity init" to compare route tables'
|
|
265
|
+
: 'routes: no config found — run "deeplink-parity init" to compare route tables') +
|
|
266
|
+
baselineLine;
|
|
202
267
|
if (opts.json) {
|
|
203
268
|
console.log(JSON.stringify(payload, null, 2));
|
|
204
269
|
}
|
|
@@ -207,7 +272,7 @@ async function main() {
|
|
|
207
272
|
printGithubAnnotations(result.findings);
|
|
208
273
|
printReport(result.findings, result.domains, { version: pkg.version, routesLine });
|
|
209
274
|
}
|
|
210
|
-
process.exit(exitCodeFor(result.findings));
|
|
275
|
+
process.exit(exitCodeFor(result.findings, opts.failOn));
|
|
211
276
|
}
|
|
212
277
|
main().catch((err) => {
|
|
213
278
|
console.error(err instanceof Error ? err.message : err);
|
package/dist/discover/android.js
CHANGED
|
@@ -55,7 +55,7 @@ function applicationIds(gradleSources) {
|
|
|
55
55
|
export async function discoverAndroid(root) {
|
|
56
56
|
const manifests = (await walk(root, (n) => n === 'AndroidManifest.xml')).filter(
|
|
57
57
|
// androidTest/debug manifests rarely declare shipping deep links
|
|
58
|
-
(p) => !/src
|
|
58
|
+
(p) => !/src[\/\\](androidTest|test)[\/\\]/.test(p));
|
|
59
59
|
if (manifests.length === 0)
|
|
60
60
|
return [];
|
|
61
61
|
const index = emptyIndex();
|
package/dist/discover/ios.js
CHANGED
|
@@ -69,9 +69,10 @@ function matchSigning(path, signing) {
|
|
|
69
69
|
return best;
|
|
70
70
|
}
|
|
71
71
|
export async function discoverIos(root) {
|
|
72
|
+
const findings = [];
|
|
72
73
|
const entitlementFiles = await walk(root, (n) => n.endsWith('.entitlements'));
|
|
73
74
|
if (entitlementFiles.length === 0)
|
|
74
|
-
return [];
|
|
75
|
+
return { apps: [], findings };
|
|
75
76
|
const pbxprojPaths = await walk(root, (n) => n === 'project.pbxproj');
|
|
76
77
|
const signing = new Map();
|
|
77
78
|
for (const path of pbxprojPaths) {
|
|
@@ -86,7 +87,15 @@ export async function discoverIos(root) {
|
|
|
86
87
|
try {
|
|
87
88
|
parsed = plist.parse(await readFile(path, 'utf8'));
|
|
88
89
|
}
|
|
89
|
-
catch {
|
|
90
|
+
catch (err) {
|
|
91
|
+
// a file that fails to parse would read as "no declaration" — silent narrowing
|
|
92
|
+
findings.push({
|
|
93
|
+
severity: 'warn',
|
|
94
|
+
rule: 'entitlements-unreadable',
|
|
95
|
+
message: `Could not parse ${path} — ${err instanceof Error ? err.message : String(err)}`,
|
|
96
|
+
detail: 'If this file declares applinks:, its domains were NOT checked this run.',
|
|
97
|
+
source: path,
|
|
98
|
+
});
|
|
90
99
|
continue;
|
|
91
100
|
}
|
|
92
101
|
const dict = parsed;
|
|
@@ -104,5 +113,5 @@ export async function discoverIos(root) {
|
|
|
104
113
|
teamId: target.teamId,
|
|
105
114
|
});
|
|
106
115
|
}
|
|
107
|
-
return apps;
|
|
116
|
+
return { apps, findings };
|
|
108
117
|
}
|
package/dist/fetch/wellKnown.js
CHANGED
|
@@ -81,8 +81,12 @@ export function localSource(dir) {
|
|
|
81
81
|
body: await readFile(path, 'utf8'),
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
|
-
catch {
|
|
85
|
-
|
|
84
|
+
catch (err) {
|
|
85
|
+
// only a missing file is a 404 — a permission error reported as 404 misleads
|
|
86
|
+
if (err.code === 'ENOENT') {
|
|
87
|
+
return { url: path, ok: false, status: 404, redirected: false };
|
|
88
|
+
}
|
|
89
|
+
return { url: path, ok: false, redirected: false, error: err instanceof Error ? err.message : String(err) };
|
|
86
90
|
}
|
|
87
91
|
};
|
|
88
92
|
return {
|
package/dist/report/console.js
CHANGED
|
@@ -23,24 +23,45 @@ export function printReport(findings, checkedDomains, meta) {
|
|
|
23
23
|
console.log();
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
|
+
const SHOW_PER_RULE = 20;
|
|
26
27
|
for (const severity of ORDER) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
const ofSeverity = findings.filter((x) => x.severity === severity);
|
|
29
|
+
const byRule = new Map();
|
|
30
|
+
for (const f of ofSeverity) {
|
|
31
|
+
byRule.set(f.rule, [...(byRule.get(f.rule) ?? []), f]);
|
|
32
|
+
}
|
|
33
|
+
for (const [rule, group] of byRule) {
|
|
34
|
+
for (const f of group.slice(0, SHOW_PER_RULE)) {
|
|
35
|
+
const head = paint(LABEL[severity], COLOR[severity]);
|
|
36
|
+
// Not every finding is tied to a domain — fall back to the rule id so the line is never blank
|
|
37
|
+
const subject = f.domain ? paint(f.domain, BOLD) : paint(f.rule, DIM);
|
|
38
|
+
const known = f.baselined ? ` ${paint('(baselined)', DIM)}` : '';
|
|
39
|
+
console.log(`${head} ${subject}${known}`);
|
|
40
|
+
console.log(` ${f.message}`);
|
|
41
|
+
if (f.detail)
|
|
42
|
+
console.log(` ${paint(f.detail, DIM)}`);
|
|
43
|
+
if (f.source)
|
|
44
|
+
console.log(` ${paint(f.source, DIM)}`);
|
|
45
|
+
console.log();
|
|
46
|
+
}
|
|
47
|
+
if (group.length > SHOW_PER_RULE) {
|
|
48
|
+
console.log(paint(` … ${group.length - SHOW_PER_RULE} more ${rule} finding(s) — see --json or --output for all`, DIM));
|
|
49
|
+
console.log();
|
|
50
|
+
}
|
|
38
51
|
}
|
|
39
52
|
}
|
|
40
53
|
const counts = ORDER.map((s) => `${findings.filter((f) => f.severity === s).length} ${s}`);
|
|
41
54
|
console.log(counts.join(', '));
|
|
42
55
|
console.log();
|
|
43
56
|
}
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
/**
|
|
58
|
+
* A baselined finding is a known problem being tracked — it must not fail the run.
|
|
59
|
+
* --fail-on warn makes route parity (all warn) an actual CI gate; without it, freezing
|
|
60
|
+
* warns in a baseline had no effect beyond a label.
|
|
61
|
+
*/
|
|
62
|
+
export function exitCodeFor(findings, failOn = 'error') {
|
|
63
|
+
if (failOn === 'never')
|
|
64
|
+
return 0;
|
|
65
|
+
const failing = (f) => !f.baselined && (f.severity === 'error' || (failOn === 'warn' && f.severity === 'warn'));
|
|
66
|
+
return findings.some(failing) ? 1 : 0;
|
|
46
67
|
}
|
package/dist/report/github.js
CHANGED
|
@@ -16,8 +16,19 @@ function escapeProperty(value) {
|
|
|
16
16
|
* changed lines when a finding points at a file in the repository.
|
|
17
17
|
* https://docs.github.com/actions/reference/workflow-commands-for-github-actions
|
|
18
18
|
*/
|
|
19
|
+
const ANNOTATIONS_PER_RULE = 10;
|
|
19
20
|
export function printGithubAnnotations(findings) {
|
|
21
|
+
const emitted = new Map();
|
|
22
|
+
const truncated = new Map();
|
|
20
23
|
for (const f of findings) {
|
|
24
|
+
const count = emitted.get(f.rule) ?? 0;
|
|
25
|
+
if (count >= ANNOTATIONS_PER_RULE) {
|
|
26
|
+
truncated.set(f.rule, (truncated.get(f.rule) ?? 0) + 1);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
emitted.set(f.rule, count + 1);
|
|
30
|
+
// a known finding stays visible but must not paint the run red
|
|
31
|
+
const level = f.baselined ? 'notice' : LEVEL[f.severity];
|
|
21
32
|
const props = [`title=${escapeProperty(`deeplink-parity ${f.rule}`)}`];
|
|
22
33
|
// Annotations anchor to a path inside the checkout. A source can also be a URL or an
|
|
23
34
|
// absolute path outside it — neither anchors to anything, so the annotation stays
|
|
@@ -25,7 +36,12 @@ export function printGithubAnnotations(findings) {
|
|
|
25
36
|
if (f.source && !/^https?:\/\//.test(f.source) && !isAbsolute(f.source)) {
|
|
26
37
|
props.push(`file=${escapeProperty(f.source)}`);
|
|
27
38
|
}
|
|
28
|
-
const body = [f.domain, f.message, f.detail]
|
|
29
|
-
|
|
39
|
+
const body = [f.baselined ? '(baselined)' : '', f.domain, f.message, f.detail]
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.join(' — ');
|
|
42
|
+
console.log(`::${level} ${props.join(',')}::${escapeData(body)}`);
|
|
43
|
+
}
|
|
44
|
+
for (const [rule, count] of truncated) {
|
|
45
|
+
console.log(`::notice title=${escapeProperty(`deeplink-parity ${rule}`)}::${escapeData(`${count} more ${rule} finding(s) not annotated — see the JSON report for all`)}`);
|
|
30
46
|
}
|
|
31
47
|
}
|
|
@@ -30,8 +30,16 @@ export async function indexResValues(paths, index) {
|
|
|
30
30
|
const args = m[1];
|
|
31
31
|
if (!/["']string["']/.test(args))
|
|
32
32
|
continue;
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
let name = /name\s*=\s*["']([^"']+)["']/.exec(args)?.[1];
|
|
34
|
+
let value = name ? /value\s*=\s*([\s\S]+?)\s*$/.exec(args)?.[1] : undefined;
|
|
35
|
+
if (!name) {
|
|
36
|
+
// parenthesised positional form — resValue("string", "host", "example.com")
|
|
37
|
+
const positional = /^\s*["']string["']\s*,\s*["']([^"']+)["']\s*,\s*([\s\S]+?)\s*$/.exec(args);
|
|
38
|
+
if (positional) {
|
|
39
|
+
name = positional[1];
|
|
40
|
+
value = positional[2];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
35
43
|
if (!name || !value)
|
|
36
44
|
continue;
|
|
37
45
|
const list = index.resValues.get(name) ?? [];
|
|
@@ -69,8 +77,14 @@ export async function indexProperties(paths, index) {
|
|
|
69
77
|
*/
|
|
70
78
|
function resolveGradleExpression(expr, index) {
|
|
71
79
|
const literal = /^(["'])([^"']*)\1(?:\s+as\s+\w+)?$/.exec(expr.trim());
|
|
72
|
-
if (literal)
|
|
80
|
+
if (literal) {
|
|
81
|
+
// a double-quoted string containing $ is interpolated (Groovy GString, Kotlin
|
|
82
|
+
// template) — treating "${envHost}.example.com" as a literal host once sent a
|
|
83
|
+
// fetch to a domain that does not exist and misreported it as unreachable
|
|
84
|
+
if (literal[1] === '"' && literal[2].includes('$'))
|
|
85
|
+
return undefined;
|
|
73
86
|
return literal[2];
|
|
87
|
+
}
|
|
74
88
|
const lookup = /\[\s*["']([^"']+)["']\s*\]/.exec(expr) ?? /getProperty\(\s*["']([^"']+)["']\s*\)/.exec(expr);
|
|
75
89
|
if (lookup)
|
|
76
90
|
return index.properties.get(lookup[1]);
|
package/dist/routes/check.js
CHANGED
|
@@ -1,20 +1,10 @@
|
|
|
1
|
-
/** Above this many gaps per direction, one grouped finding replaces per-path noise. */
|
|
2
|
-
const GROUP_THRESHOLD = 20;
|
|
3
1
|
const DYNAMIC_NOTE = 'Routes handled dynamically (prefix or path-component matching) never appear in a route table — confirm before acting.';
|
|
4
2
|
function gapFindings(present, absent, gaps) {
|
|
5
3
|
const presentName = present.platform === 'ios' ? 'iOS' : 'Android';
|
|
6
4
|
const absentName = absent.platform === 'ios' ? 'iOS' : 'Android';
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
}
|
|
5
|
+
// Always one finding per path: a grouped finding keyed its baseline identity on a
|
|
6
|
+
// message containing the gap COUNT, so 21→22 gaps refired everything. The reporters
|
|
7
|
+
// collapse long runs visually instead.
|
|
18
8
|
return gaps.map((path) => ({
|
|
19
9
|
severity: 'warn',
|
|
20
10
|
rule: 'route-gap',
|
package/dist/run.js
CHANGED
|
@@ -22,9 +22,9 @@ async function runRouteChecks(routes, roots, findings) {
|
|
|
22
22
|
}
|
|
23
23
|
export async function run({ roots, source, sha256, routes, onDiscovered, }) {
|
|
24
24
|
const discovered = await Promise.all(roots.map(async (root) => Promise.all([discoverIos(root), discoverAndroid(root)])));
|
|
25
|
-
const iosApps = discovered.flatMap(([ios]) => ios);
|
|
25
|
+
const iosApps = discovered.flatMap(([ios]) => ios.apps);
|
|
26
26
|
const androidApps = discovered.flatMap(([, android]) => android);
|
|
27
|
-
const findings = [];
|
|
27
|
+
const findings = discovered.flatMap(([ios]) => ios.findings);
|
|
28
28
|
// an Expo checkout has no native project committed; say so rather than report nothing
|
|
29
29
|
if (iosApps.length === 0 && androidApps.length === 0) {
|
|
30
30
|
for (const root of roots)
|
|
@@ -36,39 +36,76 @@ export async function run({ roots, source, sha256, routes, onDiscovered, }) {
|
|
|
36
36
|
...iosApps.flatMap((a) => a.domains),
|
|
37
37
|
...androidApps.flatMap((a) => a.hosts.map((h) => h.host)),
|
|
38
38
|
]).size);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
39
|
+
// The same domain is often declared by several targets (app + widget, prod + dev
|
|
40
|
+
// flavours). Fetch each domain once, but evaluate the rules for every (app, domain)
|
|
41
|
+
// pair — a second target missing from the AASA is exactly the false clean this tool
|
|
42
|
+
// exists to prevent. Domain-level findings that come out identical are deduped below.
|
|
43
|
+
{
|
|
44
|
+
const allDomains = [...new Set(iosApps.flatMap((a) => a.domains))];
|
|
45
|
+
for (const domain of allDomains.filter(isWildcardDomain)) {
|
|
46
|
+
const declaringApp = iosApps.find((a) => a.domains.includes(domain));
|
|
47
|
+
if (declaringApp)
|
|
48
|
+
findings.push(wildcardFinding(domain, declaringApp.entitlementsPath));
|
|
49
|
+
ios.domains.add(domain);
|
|
50
|
+
}
|
|
51
|
+
const fetchable = allDomains.filter((d) => !isWildcardDomain(d));
|
|
52
|
+
const results = await mapLimit(fetchable, FETCH_CONCURRENCY, (d) => source.aasa(d));
|
|
53
|
+
const fetched = new Map(fetchable.map((d, i) => [d, results[i]]));
|
|
54
|
+
for (const app of iosApps) {
|
|
55
|
+
for (const domain of app.domains) {
|
|
56
|
+
const res = fetched.get(domain);
|
|
57
|
+
if (!res)
|
|
58
|
+
continue;
|
|
59
|
+
ios.domains.add(domain);
|
|
60
|
+
const { findings: domainFindings, aasa } = checkIosDomain(app, domain, res);
|
|
61
|
+
findings.push(...domainFindings);
|
|
62
|
+
if (aasa && !ios.paths.has(domain))
|
|
63
|
+
ios.paths.set(domain, aasaPaths(aasa));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
53
66
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
67
|
+
{
|
|
68
|
+
for (const app of androidApps)
|
|
69
|
+
findings.push(...checkAndroidUnresolved(app));
|
|
70
|
+
const entries = androidApps.flatMap((app) => app.hosts.map((h) => ({ app, h })));
|
|
71
|
+
const seenWildcards = new Set();
|
|
72
|
+
for (const { app, h } of entries) {
|
|
73
|
+
if (!isWildcardDomain(h.host) || seenWildcards.has(h.host))
|
|
74
|
+
continue;
|
|
75
|
+
seenWildcards.add(h.host);
|
|
58
76
|
android.domains.add(h.host);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const results = await mapLimit(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
findings.push(wildcardFinding(h.host, app.manifestPath));
|
|
78
|
+
}
|
|
79
|
+
// an unverified host is never checked by the system, so there is nothing to fetch —
|
|
80
|
+
// but fetch once per host if ANY declaring app verifies it, whichever app came first
|
|
81
|
+
const checkable = entries.filter(({ h }) => !isWildcardDomain(h.host));
|
|
82
|
+
const hostsToFetch = [
|
|
83
|
+
...new Set(checkable.filter(({ h }) => h.autoVerify).map(({ h }) => h.host)),
|
|
84
|
+
];
|
|
85
|
+
const results = await mapLimit(hostsToFetch, FETCH_CONCURRENCY, (host) => source.assetlinks(host));
|
|
86
|
+
const fetched = new Map(hostsToFetch.map((host, i) => [host, results[i]]));
|
|
87
|
+
for (const { app, h } of checkable) {
|
|
88
|
+
android.domains.add(h.host);
|
|
89
|
+
if (h.paths.length > 0) {
|
|
90
|
+
// every declaring app's paths count toward the cross-platform comparison
|
|
91
|
+
android.paths.set(h.host, [...new Set([...(android.paths.get(h.host) ?? []), ...h.paths])]);
|
|
92
|
+
}
|
|
93
|
+
const res = (h.autoVerify ? fetched.get(h.host) : null) ?? { url: '', ok: false, redirected: false };
|
|
94
|
+
findings.push(...checkAndroidHost(app, h, res, { sha256 }));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// per-(app, domain) evaluation makes domain-level findings repeat verbatim — keep one
|
|
98
|
+
{
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
const deduped = findings.filter((f) => {
|
|
101
|
+
const key = [f.severity, f.rule, f.domain ?? '', f.message, f.source ?? ''].join('\u0000');
|
|
102
|
+
if (seen.has(key))
|
|
103
|
+
return false;
|
|
104
|
+
seen.add(key);
|
|
105
|
+
return true;
|
|
71
106
|
});
|
|
107
|
+
findings.length = 0;
|
|
108
|
+
findings.push(...deduped);
|
|
72
109
|
}
|
|
73
110
|
findings.push(...checkCrossPlatform(ios, android));
|
|
74
111
|
const routeTables = routes ? await runRouteChecks(routes, roots, findings) : undefined;
|
package/package.json
CHANGED