deeplink-parity 0.7.0 → 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 CHANGED
@@ -146,6 +146,7 @@ npx deeplink-parity baseline [path...] # freeze current findings; fail o
146
146
  --json Machine-readable output on stdout
147
147
  --output <file> Also write the JSON result to a file
148
148
  --format github GitHub Actions annotations (auto-detected on Actions)
149
+ --fail-on <level> error (default) · warn to also gate on warnings · never
149
150
  --yes init only: accept the top suggestion without prompting
150
151
  -v, --version Print the version
151
152
  -h, --help Show this message
@@ -190,7 +191,7 @@ Findings appear as annotations on the run, and counts are available to later ste
190
191
  | `well-known` | — | Read from disk instead of the network |
191
192
  | `config` | auto | Route config file (see [Route parity](#route-parity)) |
192
193
  | `baseline` | auto | Baseline file (see [Start with existing findings](#start-with-existing-findings)) |
193
- | `fail-on` | `error` | `never` to report without failing the step |
194
+ | `fail-on` | `error` | `warn` to also gate on warnings (route gaps), `never` to report without failing |
194
195
  | `version` | pinned | npm version to run |
195
196
 
196
197
  Outputs: `errors`, `warnings`, `notices`, `domains`, `report` (path to the JSON result).
@@ -219,7 +220,9 @@ npx deeplink-parity baseline ./app-ios ./app-android
219
220
 
220
221
  That writes `deeplink-parity-baseline.json` — commit it. From then on every check still
221
222
  **reports** the known findings, marked `(baselined)`, but only a finding that is not in
222
- the file fails the run. The file stores a one-line summary per finding, so a reviewer
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
223
226
  can read what is being tolerated. When a baselined problem gets fixed, the check says
224
227
  so; re-run `baseline` to tighten the file.
225
228
 
package/dist/cli.js CHANGED
@@ -51,6 +51,7 @@ function parseArgs(argv) {
51
51
  let output;
52
52
  let config;
53
53
  let baseline;
54
+ let failOn = 'error';
54
55
  // Actions sets GITHUB_ACTIONS=true; annotate by default there
55
56
  let format = process.env.GITHUB_ACTIONS === 'true' ? 'github' : 'console';
56
57
  for (let i = 0; i < args.length; i++) {
@@ -77,9 +78,27 @@ function parseArgs(argv) {
77
78
  config = args[++i];
78
79
  else if (arg === '--baseline')
79
80
  baseline = args[++i];
80
- else if (!arg.startsWith('-'))
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
81
96
  positional.push(arg);
82
97
  }
98
+ if (format !== 'console' && format !== 'github') {
99
+ console.error(`error: --format must be "console" or "github", got "${format}"`);
100
+ process.exit(2);
101
+ }
83
102
  const command = positional[0] === 'init' || positional[0] === 'baseline' ? positional[0] : 'check';
84
103
  const roots = (command === 'check' ? positional : positional.slice(1)).map((r) => resolve(r));
85
104
  return {
@@ -96,6 +115,7 @@ function parseArgs(argv) {
96
115
  config,
97
116
  baseline,
98
117
  format,
118
+ failOn,
99
119
  };
100
120
  }
101
121
  /**
@@ -173,7 +193,14 @@ async function main() {
173
193
  });
174
194
  }
175
195
  }
176
- const nothingFound = result.iosApps.length === 0 && result.androidApps.length === 0 && result.findings.length === 0;
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;
177
204
  if (nothingFound) {
178
205
  console.error(`No app configuration declaring deep links was found in ${opts.roots.join(', ')}`);
179
206
  console.error('Expected a .entitlements file with applinks:, or an AndroidManifest.xml with intent-filters.');
@@ -214,9 +241,12 @@ async function main() {
214
241
  : undefined,
215
242
  summary: {
216
243
  domains: result.domains.length,
217
- error: result.findings.filter((f) => f.severity === 'error').length,
218
- warn: result.findings.filter((f) => f.severity === 'warn').length,
219
- info: result.findings.filter((f) => f.severity === 'info').length,
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,
220
250
  },
221
251
  findings: result.findings,
222
252
  };
@@ -242,7 +272,7 @@ async function main() {
242
272
  printGithubAnnotations(result.findings);
243
273
  printReport(result.findings, result.domains, { version: pkg.version, routesLine });
244
274
  }
245
- process.exit(exitCodeFor(result.findings));
275
+ process.exit(exitCodeFor(result.findings, opts.failOn));
246
276
  }
247
277
  main().catch((err) => {
248
278
  console.error(err instanceof Error ? err.message : err);
@@ -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\/(androidTest|test)\//.test(p));
58
+ (p) => !/src[\/\\](androidTest|test)[\/\\]/.test(p));
59
59
  if (manifests.length === 0)
60
60
  return [];
61
61
  const index = emptyIndex();
@@ -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
  }
@@ -81,8 +81,12 @@ export function localSource(dir) {
81
81
  body: await readFile(path, 'utf8'),
82
82
  };
83
83
  }
84
- catch {
85
- return { url: path, ok: false, status: 404, redirected: false };
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 {
@@ -23,26 +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
- for (const f of findings.filter((x) => x.severity === severity)) {
28
- const head = paint(LABEL[severity], COLOR[severity]);
29
- // Not every finding is tied to a domain — fall back to the rule id so the line is never blank
30
- const subject = f.domain ? paint(f.domain, BOLD) : paint(f.rule, DIM);
31
- const known = f.baselined ? ` ${paint('(baselined)', DIM)}` : '';
32
- console.log(`${head} ${subject}${known}`);
33
- console.log(` ${f.message}`);
34
- if (f.detail)
35
- console.log(` ${paint(f.detail, DIM)}`);
36
- if (f.source)
37
- console.log(` ${paint(f.source, DIM)}`);
38
- console.log();
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
+ }
39
51
  }
40
52
  }
41
53
  const counts = ORDER.map((s) => `${findings.filter((f) => f.severity === s).length} ${s}`);
42
54
  console.log(counts.join(', '));
43
55
  console.log();
44
56
  }
45
- /** A baselined error is a known problem being tracked — it must not fail the run. */
46
- export function exitCodeFor(findings) {
47
- return findings.some((f) => f.severity === 'error' && !f.baselined) ? 1 : 0;
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;
48
67
  }
@@ -16,8 +16,17 @@ 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);
21
30
  // a known finding stays visible but must not paint the run red
22
31
  const level = f.baselined ? 'notice' : LEVEL[f.severity];
23
32
  const props = [`title=${escapeProperty(`deeplink-parity ${f.rule}`)}`];
@@ -32,4 +41,7 @@ export function printGithubAnnotations(findings) {
32
41
  .join(' — ');
33
42
  console.log(`::${level} ${props.join(',')}::${escapeData(body)}`);
34
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`)}`);
46
+ }
35
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
- const name = /name\s*=\s*["']([^"']+)["']/.exec(args)?.[1];
34
- const value = /value\s*=\s*([\s\S]+?)\s*$/.exec(args)?.[1];
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]);
@@ -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
- 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
- }
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
- for (const app of iosApps) {
40
- // the same domain can be declared by several targets; fetch it once
41
- const fresh = app.domains.filter((d) => !ios.domains.has(d));
42
- fresh.forEach((d) => ios.domains.add(d));
43
- const wildcards = fresh.filter(isWildcardDomain);
44
- wildcards.forEach((d) => findings.push(wildcardFinding(d, app.entitlementsPath)));
45
- const pending = fresh.filter((d) => !isWildcardDomain(d));
46
- const results = await mapLimit(pending, FETCH_CONCURRENCY, (d) => source.aasa(d));
47
- pending.forEach((domain, i) => {
48
- const { findings: domainFindings, aasa } = checkIosDomain(app, domain, results[i]);
49
- findings.push(...domainFindings);
50
- if (aasa)
51
- ios.paths.set(domain, aasaPaths(aasa));
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
- for (const app of androidApps) {
55
- findings.push(...checkAndroidUnresolved(app));
56
- const fresh = app.hosts.filter((h) => !android.domains.has(h.host));
57
- fresh.forEach((h) => {
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
- if (h.paths.length > 0)
60
- android.paths.set(h.host, h.paths);
61
- });
62
- fresh
63
- .filter((h) => isWildcardDomain(h.host))
64
- .forEach((h) => findings.push(wildcardFinding(h.host, app.manifestPath)));
65
- const pending = fresh.filter((h) => !isWildcardDomain(h.host));
66
- // an unverified host is never checked by the system, so there is nothing to fetch
67
- const results = await mapLimit(pending, FETCH_CONCURRENCY, (h) => h.autoVerify ? source.assetlinks(h.host) : Promise.resolve(null));
68
- pending.forEach((entry, i) => {
69
- const res = results[i] ?? { url: '', ok: false, redirected: false };
70
- findings.push(...checkAndroidHost(app, entry, res, { sha256 }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deeplink-parity",
3
- "version": "0.7.0",
3
+ "version": "0.8.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": {