react-native-doctor-ci 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,1654 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli-main.ts
4
+ import { join as join4 } from "path";
5
+ import { parseArgs } from "util";
6
+
7
+ // src/changed-deps.ts
8
+ function diffDependencies(base, current) {
9
+ if (base === null) return current.map((entry) => entry.name);
10
+ const baseSpecs = new Map(base.map((entry) => [entry.name, entry.spec]));
11
+ return current.filter((entry) => baseSpecs.get(entry.name) !== entry.spec).map((entry) => entry.name);
12
+ }
13
+
14
+ // src/cache.ts
15
+ import * as fs from "fs";
16
+ import * as path from "path";
17
+ var CACHE_VERSION = 1;
18
+ var TTL_MS = 24 * 60 * 60 * 1e3;
19
+ function isDegraded(entry) {
20
+ const transientReasons = /* @__PURE__ */ new Set([
21
+ "github-rate-limited",
22
+ "github-error",
23
+ "npm-lookup-failed"
24
+ ]);
25
+ const dep = entry.enriched;
26
+ const signals = [
27
+ dep.npm.deprecated,
28
+ dep.npm.hasCodegenConfig,
29
+ dep.npm.hasReactNativePeerDep,
30
+ dep.npm.hasNativeDirsHint,
31
+ dep.github.archived,
32
+ dep.github.pushedAt,
33
+ dep.lastPublish
34
+ ];
35
+ return signals.some((s) => !s.known && transientReasons.has(s.reason));
36
+ }
37
+ async function readCacheFile(cacheDir) {
38
+ const cacheFile = path.join(cacheDir, ".rn-doctor-cache.json");
39
+ try {
40
+ const content = await fs.promises.readFile(cacheFile, "utf-8");
41
+ const parsed = JSON.parse(content);
42
+ if (parsed.version !== CACHE_VERSION) {
43
+ return { version: CACHE_VERSION, entries: {} };
44
+ }
45
+ return parsed;
46
+ } catch {
47
+ return { version: CACHE_VERSION, entries: {} };
48
+ }
49
+ }
50
+ async function writeCacheFile(cacheDir, cache) {
51
+ const cacheFile = path.join(cacheDir, ".rn-doctor-cache.json");
52
+ await fs.promises.writeFile(cacheFile, JSON.stringify(cache, null, 2), "utf-8");
53
+ }
54
+ function getFreshEntry(cache, packageName, now = /* @__PURE__ */ new Date()) {
55
+ const entry = cache.entries[packageName];
56
+ if (!entry) {
57
+ return void 0;
58
+ }
59
+ const age = now.getTime() - new Date(entry.fetchedAt).getTime();
60
+ if (age >= TTL_MS || isDegraded(entry)) {
61
+ return void 0;
62
+ }
63
+ return entry;
64
+ }
65
+ function putEntry(cache, entry) {
66
+ return {
67
+ version: CACHE_VERSION,
68
+ entries: {
69
+ ...cache.entries,
70
+ [entry.enriched.name]: entry
71
+ }
72
+ };
73
+ }
74
+ function isEntryDegraded(entry) {
75
+ return isDegraded(entry);
76
+ }
77
+
78
+ // src/concurrency.ts
79
+ async function mapWithConcurrency(items, fn, concurrency = 8) {
80
+ if (items.length === 0) {
81
+ return [];
82
+ }
83
+ const results = Array.from({ length: items.length });
84
+ let index = 0;
85
+ const worker = async () => {
86
+ let currentIndex = index++;
87
+ while (currentIndex < items.length) {
88
+ results[currentIndex] = await fn(items[currentIndex]);
89
+ currentIndex = index++;
90
+ }
91
+ };
92
+ const workers = Array(Math.min(concurrency, items.length)).fill(null).map(() => worker());
93
+ await Promise.all(workers);
94
+ return results;
95
+ }
96
+
97
+ // src/http.ts
98
+ async function fetchJson(url, options = {}) {
99
+ const controller = new AbortController();
100
+ const timeoutMs = options.timeout ?? 1e4;
101
+ const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
102
+ try {
103
+ const response = await fetch(url, {
104
+ headers: {
105
+ "user-agent": "rn-doctor/0.0.0",
106
+ ...options.headers
107
+ },
108
+ signal: controller.signal
109
+ });
110
+ clearTimeout(timeoutHandle);
111
+ if (response.status === 404) {
112
+ return { status: "not-found" };
113
+ }
114
+ if (response.status === 403 || response.status === 429) {
115
+ return { status: "rate-limited" };
116
+ }
117
+ if (!response.ok) {
118
+ return { status: "error", message: `HTTP ${response.status}` };
119
+ }
120
+ const data = await response.json();
121
+ return { status: "ok", data };
122
+ } catch (err) {
123
+ clearTimeout(timeoutHandle);
124
+ if (err instanceof Error && err.name === "AbortError") {
125
+ return { status: "error", message: "Request timeout" };
126
+ }
127
+ if (err instanceof SyntaxError) {
128
+ return { status: "error", message: "Invalid JSON response" };
129
+ }
130
+ return {
131
+ status: "error",
132
+ message: err instanceof Error ? err.message : "Unknown error"
133
+ };
134
+ }
135
+ }
136
+
137
+ // src/sources/directory.ts
138
+ async function checkLibraries(packageNames) {
139
+ if (packageNames.length === 0) {
140
+ return { status: "ok", data: {} };
141
+ }
142
+ const chunks = [];
143
+ for (let i = 0; i < packageNames.length; i += 200) {
144
+ chunks.push(Array.from(packageNames.slice(i, i + 200)));
145
+ }
146
+ const results = {};
147
+ for (const chunk of chunks) {
148
+ const url = new URL("https://reactnative.directory/api/libraries/check");
149
+ url.searchParams.set("packages", chunk.join(","));
150
+ const outcome = await fetchJson(url.toString());
151
+ if (outcome.status !== "ok") {
152
+ return outcome;
153
+ }
154
+ Object.assign(results, outcome.data);
155
+ }
156
+ return { status: "ok", data: results };
157
+ }
158
+ async function fetchLibraryDetail(packageName) {
159
+ const url = new URL("https://reactnative.directory/api/library");
160
+ url.searchParams.set("name", packageName);
161
+ const outcome = await fetchJson(url.toString());
162
+ if (outcome.status !== "ok") {
163
+ return outcome;
164
+ }
165
+ if (Object.keys(outcome.data).length === 0) {
166
+ return { status: "ok", data: {} };
167
+ }
168
+ return outcome;
169
+ }
170
+ async function fetchLibraryDetails(packageNames, concurrency = 8) {
171
+ const outcomes = await mapWithConcurrency(
172
+ packageNames,
173
+ (name) => fetchLibraryDetail(name),
174
+ concurrency
175
+ );
176
+ const results = {};
177
+ for (let i = 0; i < outcomes.length; i++) {
178
+ const outcome = outcomes[i];
179
+ if (outcome?.status === "ok") {
180
+ results[packageNames[i]] = outcome.data;
181
+ }
182
+ }
183
+ return results;
184
+ }
185
+
186
+ // src/sources/npm.ts
187
+ async function fetchNpmLatestManifest(packageName) {
188
+ const encoded = encodeURIComponent(packageName);
189
+ const url = `https://registry.npmjs.org/${encoded}/latest`;
190
+ return fetchJson(url);
191
+ }
192
+ async function searchNpmForPackage(packageName) {
193
+ const url = new URL("https://registry.npmjs.org/-/v1/search");
194
+ url.searchParams.set("text", packageName);
195
+ url.searchParams.set("size", "1");
196
+ const outcome = await fetchJson(url.toString());
197
+ if (outcome.status !== "ok") {
198
+ return outcome;
199
+ }
200
+ const topResult = outcome.data.objects?.[0];
201
+ if (!topResult) {
202
+ return { status: "error", message: "No search results" };
203
+ }
204
+ return {
205
+ status: "ok",
206
+ data: {
207
+ name: topResult.package.name,
208
+ date: topResult.package.date
209
+ }
210
+ };
211
+ }
212
+ function parseGithubUrl(repoUrl) {
213
+ if (!repoUrl) {
214
+ return void 0;
215
+ }
216
+ const match = repoUrl.match(/github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?$/i);
217
+ if (!match || !match[1] || !match[2]) {
218
+ return void 0;
219
+ }
220
+ return { owner: match[1], repo: match[2] };
221
+ }
222
+
223
+ // src/sources/github.ts
224
+ var GitHubCircuitBreaker = class {
225
+ tripped = false;
226
+ /**
227
+ * Check if the circuit breaker is currently tripped.
228
+ */
229
+ isTripped() {
230
+ return this.tripped;
231
+ }
232
+ /**
233
+ * Trip the circuit breaker (call this on a 403/429 response).
234
+ */
235
+ trip() {
236
+ this.tripped = true;
237
+ }
238
+ };
239
+ async function fetchGithubRepo(owner, repo, token) {
240
+ const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
241
+ const headers = {};
242
+ if (token) {
243
+ headers["authorization"] = `Bearer ${token}`;
244
+ }
245
+ return fetchJson(url, { headers });
246
+ }
247
+ function parseGithubUrl2(url) {
248
+ if (!url) {
249
+ return void 0;
250
+ }
251
+ const match = url.match(
252
+ /(?:https:\/\/github\.com|git@github\.com:)\/?([^/]+)\/([^/.]+)(?:\.git|\/|$)/i
253
+ );
254
+ if (!match || !match[1] || !match[2]) {
255
+ return void 0;
256
+ }
257
+ return { owner: match[1], repo: match[2] };
258
+ }
259
+
260
+ // src/enrich.ts
261
+ function computeNewArchTier(dep) {
262
+ const { directoryVerdict, hasCodegenConfig } = dep;
263
+ if (directoryVerdict === "supported" || directoryVerdict === "new-arch-only") {
264
+ return "supported";
265
+ }
266
+ if (directoryVerdict === "unsupported") {
267
+ return "unsupported";
268
+ }
269
+ if (hasCodegenConfig.known && hasCodegenConfig.value) {
270
+ return "passWithNote";
271
+ }
272
+ return "unknown";
273
+ }
274
+ async function enrichDependencies(names, options = {}) {
275
+ const cacheDir = options.cacheDir || process.cwd();
276
+ const concurrency = options.concurrency ?? 8;
277
+ const githubToken = options.githubToken || process.env.GITHUB_TOKEN;
278
+ const warnings = [];
279
+ const enriched = [];
280
+ let cache = { version: 1, entries: {} };
281
+ if (!options.noCache) {
282
+ try {
283
+ cache = await readCacheFile(cacheDir);
284
+ } catch {
285
+ warnings.push({
286
+ source: "cache",
287
+ message: "Failed to read cache file; proceeding without cache"
288
+ });
289
+ }
290
+ }
291
+ const cached = [];
292
+ const toFetch = [];
293
+ for (const name of names) {
294
+ const entry = options.noCache ? void 0 : getFreshEntry(cache, name);
295
+ if (entry) {
296
+ cached.push(entry.enriched);
297
+ } else {
298
+ toFetch.push(name);
299
+ }
300
+ }
301
+ enriched.push(...cached);
302
+ if (toFetch.length === 0) {
303
+ return { dependencies: enriched, warnings };
304
+ }
305
+ const directoryCheckOutcome = await checkLibraries(toFetch);
306
+ const directoryCheckData = directoryCheckOutcome.status === "ok" ? directoryCheckOutcome.data : {};
307
+ if (directoryCheckOutcome.status === "error") {
308
+ warnings.push({
309
+ source: "directory",
310
+ message: `Failed to check RN Directory: ${directoryCheckOutcome.message}`
311
+ });
312
+ }
313
+ const npmManifests = await mapWithConcurrency(
314
+ toFetch,
315
+ async (name) => {
316
+ const outcome = await fetchNpmLatestManifest(name);
317
+ return { name, outcome };
318
+ },
319
+ concurrency
320
+ );
321
+ const npmData = {};
322
+ for (const { name, outcome } of npmManifests) {
323
+ if (outcome.status === "ok") {
324
+ npmData[name] = outcome.data;
325
+ } else if (outcome.status === "not-found") {
326
+ npmData[name] = { found: false };
327
+ } else if (outcome.status === "error") {
328
+ npmData[name] = { error: outcome.message };
329
+ } else if (outcome.status === "rate-limited") {
330
+ npmData[name] = { rateLimited: true };
331
+ }
332
+ }
333
+ const dirListed = toFetch.filter((name) => directoryCheckData[name]);
334
+ const directoryDetails = await (dirListed.length > 0 ? fetchLibraryDetails(dirListed, concurrency) : Promise.resolve({}));
335
+ const dirNotListed = toFetch.filter((name) => !directoryCheckData[name]);
336
+ const npmSearchResults = {};
337
+ for (const name of dirNotListed) {
338
+ const outcome = await searchNpmForPackage(name);
339
+ if (outcome.status === "ok" && outcome.data.name === name) {
340
+ npmSearchResults[name] = outcome.data;
341
+ }
342
+ }
343
+ const githubBreaker = new GitHubCircuitBreaker();
344
+ const githubCalls = [];
345
+ const packagesWithRepoUrl = /* @__PURE__ */ new Set();
346
+ for (const name of toFetch) {
347
+ let repoUrl;
348
+ const dirDetail = directoryDetails[name];
349
+ if (dirDetail && typeof dirDetail === "object" && "githubUrl" in dirDetail) {
350
+ repoUrl = dirDetail.githubUrl;
351
+ }
352
+ if (!repoUrl && npmData[name]?.repository?.url) {
353
+ repoUrl = npmData[name].repository.url;
354
+ }
355
+ if (!repoUrl) {
356
+ continue;
357
+ }
358
+ packagesWithRepoUrl.add(name);
359
+ let parsed = parseGithubUrl2(repoUrl);
360
+ if (!parsed) {
361
+ parsed = parseGithubUrl(repoUrl);
362
+ }
363
+ if (parsed) {
364
+ githubCalls.push({ name, owner: parsed.owner, repo: parsed.repo });
365
+ }
366
+ }
367
+ const githubData = {};
368
+ for (const { name, owner, repo } of githubCalls) {
369
+ if (githubBreaker.isTripped()) {
370
+ break;
371
+ }
372
+ const outcome = await fetchGithubRepo(owner, repo, githubToken);
373
+ if (outcome.status === "ok") {
374
+ githubData[name] = outcome.data;
375
+ } else if (outcome.status === "rate-limited") {
376
+ githubBreaker.trip();
377
+ warnings.push({
378
+ source: "github",
379
+ message: "GitHub API rate-limited after checking dependencies; remaining packages fall back to cached GitHub data or unknown"
380
+ });
381
+ break;
382
+ } else if (outcome.status === "error") {
383
+ githubData[name] = { error: outcome.message };
384
+ }
385
+ }
386
+ for (const name of toFetch) {
387
+ const npm = npmData[name] || {};
388
+ const dirCheck = directoryCheckData[name] || {};
389
+ const listedInDirectory = Object.prototype.hasOwnProperty.call(directoryCheckData, name);
390
+ const dirDetail = directoryDetails[name] || {};
391
+ const github = githubData[name];
392
+ const npmFound = npm.found !== false && !npm.rateLimited && !npm.error;
393
+ const npmNotFound = npm.found === false;
394
+ const deprecated = npmFound ? {
395
+ known: true,
396
+ value: {
397
+ deprecated: Boolean(npm.deprecated),
398
+ message: npm.deprecated ? String(npm.deprecated) : null
399
+ },
400
+ source: "npm"
401
+ } : { known: false, reason: npmNotFound ? "npm-not-found" : "npm-lookup-failed" };
402
+ const hasCodegenConfig = npmFound ? { known: true, value: Boolean(npm.codegenConfig), source: "npm" } : { known: false, reason: npmNotFound ? "npm-not-found" : "npm-lookup-failed" };
403
+ const hasReactNativePeerDep = npmFound ? {
404
+ known: true,
405
+ value: Boolean(npm.peerDependencies?.["react-native"]),
406
+ source: "npm"
407
+ } : { known: false, reason: npmNotFound ? "npm-not-found" : "npm-lookup-failed" };
408
+ const hasNativeDirsHint = npmFound ? {
409
+ known: true,
410
+ value: Boolean(
411
+ npm.files && Array.isArray(npm.files) && npm.files.some((f) => /^(android|ios)(\/.*)?$/.test(f))
412
+ ),
413
+ source: "npm"
414
+ } : { known: false, reason: npmNotFound ? "npm-not-found" : "npm-lookup-failed" };
415
+ let archivedSignal;
416
+ let pushedAtSignal;
417
+ let githubSource = null;
418
+ if (github && !github.error) {
419
+ archivedSignal = { known: true, value: github.archived === true, source: "github-api" };
420
+ if (github.pushed_at) {
421
+ pushedAtSignal = { known: true, value: github.pushed_at, source: "github-api" };
422
+ } else {
423
+ pushedAtSignal = { known: false, reason: "github-error" };
424
+ }
425
+ githubSource = "github-api";
426
+ } else if (dirDetail && typeof dirDetail === "object" && "github" in dirDetail) {
427
+ const dir = dirDetail;
428
+ if (dir.github?.isArchived !== void 0) {
429
+ archivedSignal = { known: true, value: dir.github.isArchived, source: "directory-fallback" };
430
+ if (dir.github.stats?.pushedAt) {
431
+ pushedAtSignal = { known: true, value: dir.github.stats.pushedAt, source: "directory-fallback" };
432
+ } else {
433
+ pushedAtSignal = { known: false, reason: "github-error" };
434
+ }
435
+ githubSource = "directory-fallback";
436
+ } else {
437
+ archivedSignal = { known: false, reason: githubBreaker.isTripped() ? "github-rate-limited" : "no-github-token" };
438
+ pushedAtSignal = { known: false, reason: githubBreaker.isTripped() ? "github-rate-limited" : "no-github-token" };
439
+ }
440
+ } else {
441
+ const noGithubReason = githubBreaker.isTripped() ? "github-rate-limited" : packagesWithRepoUrl.has(name) ? "no-github-token" : "no-repo-url";
442
+ archivedSignal = { known: false, reason: noGithubReason };
443
+ pushedAtSignal = { known: false, reason: noGithubReason };
444
+ }
445
+ let githubUrl = null;
446
+ if (dirDetail && typeof dirDetail === "object" && "githubUrl" in dirDetail) {
447
+ githubUrl = dirDetail.githubUrl || null;
448
+ } else if (npm.repository?.url) {
449
+ const parsed = parseGithubUrl(npm.repository.url) || parseGithubUrl2(npm.repository.url);
450
+ if (parsed) {
451
+ githubUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
452
+ }
453
+ }
454
+ let lastPublish;
455
+ if (dirDetail && typeof dirDetail === "object" && "npm" in dirDetail) {
456
+ const dir = dirDetail;
457
+ if (dir.npm?.latestReleaseDate) {
458
+ lastPublish = { known: true, value: { date: dir.npm.latestReleaseDate }, source: "directory" };
459
+ } else if (npmSearchResults[name]?.date) {
460
+ lastPublish = { known: true, value: { date: npmSearchResults[name].date }, source: "npm-search" };
461
+ } else {
462
+ lastPublish = { known: false, reason: "not-in-directory" };
463
+ }
464
+ } else if (npmSearchResults[name]?.date) {
465
+ lastPublish = { known: true, value: { date: npmSearchResults[name].date }, source: "npm-search" };
466
+ } else {
467
+ lastPublish = { known: false, reason: "not-in-directory" };
468
+ }
469
+ const rnNativeReasons = [];
470
+ if (listedInDirectory) {
471
+ rnNativeReasons.push("directory-listed");
472
+ }
473
+ if (hasReactNativePeerDep.known && hasReactNativePeerDep.value) {
474
+ rnNativeReasons.push("peer-dependency");
475
+ }
476
+ if (hasNativeDirsHint.known && hasNativeDirsHint.value) {
477
+ rnNativeReasons.push("native-files-hint");
478
+ }
479
+ const isRnNative = listedInDirectory || hasReactNativePeerDep.known && hasReactNativePeerDep.value || hasNativeDirsHint.known && hasNativeDirsHint.value;
480
+ const newArchTier = computeNewArchTier({
481
+ directoryVerdict: dirCheck.newArchitecture || null,
482
+ hasCodegenConfig
483
+ });
484
+ const depWarnings = [];
485
+ if (npmNotFound) {
486
+ depWarnings.push({
487
+ source: "npm",
488
+ dependency: name,
489
+ message: `Package not found on npm registry \u2014 verify the package name is correct`
490
+ });
491
+ }
492
+ if (npm.error) {
493
+ depWarnings.push({
494
+ source: "npm",
495
+ dependency: name,
496
+ message: `Failed to fetch npm data: ${npm.error}`
497
+ });
498
+ }
499
+ if (github?.error) {
500
+ depWarnings.push({
501
+ source: "github",
502
+ dependency: name,
503
+ message: `Failed to fetch GitHub data: ${github.error}`
504
+ });
505
+ }
506
+ const enrichedDep = {
507
+ name,
508
+ warnings: depWarnings,
509
+ npm: {
510
+ found: npmFound && !npmNotFound,
511
+ latestVersion: npmFound ? npm.version : null,
512
+ deprecated,
513
+ hasCodegenConfig,
514
+ hasReactNativePeerDep,
515
+ hasNativeDirsHint,
516
+ repositoryUrl: npm.repository?.url ? String(npm.repository.url).replace(/^git\+/, "") : null
517
+ },
518
+ directory: {
519
+ listed: listedInDirectory,
520
+ unmaintained: Boolean(dirCheck.unmaintained),
521
+ newArchitectureRaw: dirCheck.newArchitecture || null,
522
+ // Directory-sourced URL only; the npm-derived fallback belongs to `github.repoUrl`.
523
+ githubUrl: dirDetail && typeof dirDetail === "object" ? dirDetail.githubUrl || null : null,
524
+ lastPublishedAt: dirDetail && typeof dirDetail === "object" && dirDetail.npm?.latestReleaseDate || null,
525
+ // `?? null` (not `|| null`) so a genuine `false` (repo not archived) survives.
526
+ githubArchived: dirDetail && typeof dirDetail === "object" && typeof dirDetail.github?.isArchived === "boolean" ? dirDetail.github.isArchived : null,
527
+ githubPushedAt: dirDetail && typeof dirDetail === "object" && dirDetail.github?.stats?.pushedAt || null,
528
+ matchingScoreModifiers: dirDetail && typeof dirDetail === "object" && dirDetail.matchingScoreModifiers || []
529
+ },
530
+ github: {
531
+ archived: archivedSignal,
532
+ pushedAt: pushedAtSignal,
533
+ repoUrl: githubUrl,
534
+ source: githubSource
535
+ },
536
+ isRnNative,
537
+ rnNativeReasons,
538
+ newArch: {
539
+ tier: newArchTier,
540
+ evidence: {
541
+ directoryVerdict: dirCheck.newArchitecture || null,
542
+ hasCodegenConfig: hasCodegenConfig.known ? hasCodegenConfig.value : null
543
+ }
544
+ },
545
+ lastPublish
546
+ };
547
+ enriched.push(enrichedDep);
548
+ if (!options.noCache) {
549
+ const candidate = { fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), enriched: enrichedDep };
550
+ if (!isEntryDegraded(candidate)) {
551
+ cache = putEntry(cache, candidate);
552
+ }
553
+ }
554
+ }
555
+ if (!options.noCache && toFetch.length > 0) {
556
+ try {
557
+ await writeCacheFile(cacheDir, cache);
558
+ } catch {
559
+ warnings.push({
560
+ source: "cache",
561
+ message: "Failed to write cache file"
562
+ });
563
+ }
564
+ }
565
+ return { dependencies: enriched, warnings };
566
+ }
567
+
568
+ // src/git.ts
569
+ import { execFile } from "child_process";
570
+ var GitError = class extends Error {
571
+ constructor(message) {
572
+ super(message);
573
+ this.name = "GitError";
574
+ }
575
+ };
576
+ function createGitRunner() {
577
+ return (args, cwd) => new Promise((resolve2, reject) => {
578
+ execFile(
579
+ "git",
580
+ [...args],
581
+ { cwd, encoding: "utf8", windowsHide: true, maxBuffer: 16 * 1024 * 1024 },
582
+ (err, stdout, stderr) => {
583
+ if (err && err.code === "ENOENT") {
584
+ reject(
585
+ new GitError("git executable not found on PATH \u2014 --changed-only requires git.")
586
+ );
587
+ return;
588
+ }
589
+ const code = err ? err.code ?? 1 : 0;
590
+ resolve2({ code: typeof code === "number" ? code : 1, stdout, stderr });
591
+ }
592
+ );
593
+ });
594
+ }
595
+ async function resolveBaseCommit(git, cwd, baseRef) {
596
+ const result = await git(["merge-base", "HEAD", baseRef], cwd);
597
+ if (result.code === 0) {
598
+ const sha = result.stdout.trim();
599
+ if (sha !== "") return sha;
600
+ }
601
+ const stderr = result.stderr;
602
+ if (/not a git repository/i.test(stderr)) {
603
+ throw new GitError(
604
+ "--changed-only requires running inside a git repository (or drop the flag to check all dependencies)."
605
+ );
606
+ }
607
+ if (/not a valid object name|unknown revision|bad revision|needed a single revision/i.test(stderr)) {
608
+ throw new GitError(
609
+ `base ref "${baseRef}" was not found. Fetch it first (git fetch origin) or pass --base <ref>. In GitHub Actions, check out with fetch-depth: 0.`
610
+ );
611
+ }
612
+ if (stderr.trim() === "") {
613
+ throw new GitError(
614
+ `no merge base between HEAD and "${baseRef}" \u2014 histories are unrelated or the clone is too shallow. In GitHub Actions, check out with fetch-depth: 0.`
615
+ );
616
+ }
617
+ throw new GitError(`git merge-base HEAD ${baseRef} failed \u2014 ${stderr.trim()}`);
618
+ }
619
+ async function readFileAtCommit(git, cwd, commit, relPath) {
620
+ const posixPath = relPath.replaceAll("\\", "/");
621
+ const result = await git(["show", `${commit}:./${posixPath}`], cwd);
622
+ if (result.code === 0) {
623
+ let text = result.stdout;
624
+ if (text.charCodeAt(0) === 65279) text = text.slice(1);
625
+ return text;
626
+ }
627
+ if (/does not exist in|exists on disk, but not in|but not in the working tree|path .* does not exist/i.test(result.stderr)) {
628
+ return null;
629
+ }
630
+ throw new GitError(`git show ${commit}:./${posixPath} failed \u2014 ${result.stderr.trim()}`);
631
+ }
632
+
633
+ // src/package-json.ts
634
+ import { readFile } from "fs/promises";
635
+ import { join as join2 } from "path";
636
+ var ManifestError = class extends Error {
637
+ constructor(message) {
638
+ super(message);
639
+ this.name = "ManifestError";
640
+ }
641
+ };
642
+ function listDependencyEntries(parsed, where = "package.json") {
643
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
644
+ throw new ManifestError(`${where} is not a JSON object.`);
645
+ }
646
+ const deps = parsed["dependencies"];
647
+ if (deps === void 0) return [];
648
+ if (typeof deps !== "object" || deps === null || Array.isArray(deps)) {
649
+ throw new ManifestError(`${where} has a "dependencies" field that is not an object.`);
650
+ }
651
+ return Object.entries(deps).map(([name, spec]) => {
652
+ if (typeof spec !== "string") {
653
+ throw new ManifestError(`${where} has a non-string version for dependency "${name}".`);
654
+ }
655
+ return { name, spec };
656
+ });
657
+ }
658
+ function entriesFromManifestText(text, where) {
659
+ const stripped = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
660
+ let parsed;
661
+ try {
662
+ parsed = JSON.parse(stripped);
663
+ } catch (err) {
664
+ throw new ManifestError(
665
+ `${where} is not valid JSON \u2014 ${err instanceof Error ? err.message : String(err)}`
666
+ );
667
+ }
668
+ return listDependencyEntries(parsed, where);
669
+ }
670
+ async function readManifestAt(path2, missingMessage) {
671
+ let text;
672
+ try {
673
+ text = await readFile(path2, "utf8");
674
+ if (text.charCodeAt(0) === 65279) text = text.slice(1);
675
+ } catch (err) {
676
+ const code = err.code;
677
+ if (code === "ENOENT") {
678
+ throw new ManifestError(missingMessage ?? `No package.json found at ${path2}.`);
679
+ }
680
+ throw new ManifestError(
681
+ `Could not read ${path2} \u2014 ${err instanceof Error ? err.message : String(err)}`
682
+ );
683
+ }
684
+ let parsed;
685
+ try {
686
+ parsed = JSON.parse(text);
687
+ } catch (err) {
688
+ throw new ManifestError(
689
+ `${path2} is not valid JSON \u2014 ${err instanceof Error ? err.message : String(err)}`
690
+ );
691
+ }
692
+ const entries = listDependencyEntries(parsed, path2);
693
+ return { path: path2, text, dependencies: entries.map((entry) => entry.name), entries };
694
+ }
695
+ async function readPackageJson(cwd) {
696
+ return readManifestAt(
697
+ join2(cwd, "package.json"),
698
+ `No package.json found in ${cwd}. Run rn-doctor from the project root (the directory containing package.json).`
699
+ );
700
+ }
701
+ function findDependencyLine(text, name) {
702
+ let line = 1;
703
+ let depth = 0;
704
+ let inDependencies = false;
705
+ let i = 0;
706
+ while (i < text.length) {
707
+ const ch = text[i];
708
+ if (ch === "\n") {
709
+ line++;
710
+ i++;
711
+ continue;
712
+ }
713
+ if (ch === '"') {
714
+ const stringLine = line;
715
+ let value = "";
716
+ i++;
717
+ while (i < text.length) {
718
+ const c = text[i];
719
+ if (c === "\\") {
720
+ value += text.slice(i, i + 2);
721
+ i += 2;
722
+ continue;
723
+ }
724
+ if (c === "\n") line++;
725
+ if (c === '"') {
726
+ i++;
727
+ break;
728
+ }
729
+ value += c;
730
+ i++;
731
+ }
732
+ let j = i;
733
+ while (j < text.length && (text[j] === " " || text[j] === " " || text[j] === "\r" || text[j] === "\n")) j++;
734
+ const isKey = text[j] === ":";
735
+ if (isKey) {
736
+ if (depth === 1 && value === "dependencies") {
737
+ let k = j + 1;
738
+ while (k < text.length && (text[k] === " " || text[k] === " " || text[k] === "\r" || text[k] === "\n")) k++;
739
+ if (text[k] === "{") inDependencies = true;
740
+ } else if (inDependencies && depth === 2 && value === name) {
741
+ return stringLine;
742
+ }
743
+ }
744
+ continue;
745
+ }
746
+ if (ch === "{" || ch === "[") {
747
+ depth++;
748
+ } else if (ch === "}" || ch === "]") {
749
+ depth--;
750
+ if (inDependencies && depth <= 1) inDependencies = false;
751
+ }
752
+ i++;
753
+ }
754
+ return null;
755
+ }
756
+
757
+ // src/policy.ts
758
+ var DEFAULT_POLICY = {
759
+ rules: {
760
+ newArchitecture: "error",
761
+ newArchUnknown: "warn",
762
+ lastPublish: { warnMonths: 12, errorMonths: 24 },
763
+ githubArchived: "error",
764
+ npmDeprecated: "error",
765
+ directoryUnmaintained: "warn"
766
+ },
767
+ scope: "rn-native-only",
768
+ allow: []
769
+ };
770
+ var MEAN_MONTH_MS = 365.2425 / 12 * 24 * 60 * 60 * 1e3;
771
+ function allowStateFor(name, allow, now) {
772
+ const entry = allow.find((a) => a.package === name);
773
+ if (!entry) return { kind: "none" };
774
+ if (entry.expires === null) return { kind: "active", entry };
775
+ const expiryEnd = Date.parse(`${entry.expires}T23:59:59.999Z`);
776
+ return now.getTime() <= expiryEnd ? { kind: "active", entry } : { kind: "expired", entry };
777
+ }
778
+ function allowHint(name) {
779
+ return `If this is intentional, allowlist it in .rn-doctor.yml: { package: ${name}, reason: "<why>", expires: YYYY-MM-DD }.`;
780
+ }
781
+ function directoryUrl(name) {
782
+ return `https://reactnative.directory/?search=${encodeURIComponent(name)}`;
783
+ }
784
+ function npmUrl(name) {
785
+ return `https://www.npmjs.com/package/${name}`;
786
+ }
787
+ function evaluateRules(dep, rules, now) {
788
+ const hits = [];
789
+ const name = dep.name;
790
+ if (rules.newArchitecture !== "off") {
791
+ if (dep.newArch.tier === "unsupported") {
792
+ hits.push({
793
+ rule: "newArchitecture",
794
+ severity: rules.newArchitecture,
795
+ message: `${name} does not support the React Native New Architecture (RN Directory verdict: unsupported). Look for a maintained alternative or check the repo for New Architecture plans. ${allowHint(name)}`,
796
+ evidenceUrl: directoryUrl(name)
797
+ });
798
+ } else if (dep.newArch.tier === "passWithNote") {
799
+ hits.push({
800
+ rule: "newArchitecture",
801
+ severity: "note",
802
+ message: `${name} is not listed in the RN Directory, but its npm package ships a codegenConfig, which indicates New Architecture support. Treating as a pass; verify manually if this dependency is critical.`,
803
+ evidenceUrl: npmUrl(name)
804
+ });
805
+ }
806
+ }
807
+ if (rules.newArchUnknown !== "off" && dep.newArch.tier === "unknown") {
808
+ hits.push({
809
+ rule: "newArchUnknown",
810
+ severity: rules.newArchUnknown,
811
+ message: `New Architecture support for ${name} is unknown: it is not listed in the RN Directory and its npm package has no codegen hints. Verify support manually (check the repo README/releases). ${allowHint(name)}`,
812
+ evidenceUrl: directoryUrl(name)
813
+ });
814
+ }
815
+ if (rules.lastPublish !== "off" && dep.lastPublish.known) {
816
+ const publishedAt = Date.parse(dep.lastPublish.value.date);
817
+ if (!Number.isNaN(publishedAt)) {
818
+ const ageMonths = (now.getTime() - publishedAt) / MEAN_MONTH_MS;
819
+ const { warnMonths, errorMonths } = rules.lastPublish;
820
+ const band = ageMonths >= errorMonths ? "error" : ageMonths >= warnMonths ? "warn" : null;
821
+ if (band !== null) {
822
+ const threshold = band === "error" ? errorMonths : warnMonths;
823
+ hits.push({
824
+ rule: "lastPublish",
825
+ severity: band,
826
+ message: `${name} was last published ${String(Math.floor(ageMonths))} months ago (${dep.lastPublish.value.date.slice(0, 10)}), exceeding the ${String(threshold)}-month threshold. Check whether it is abandoned; consider a maintained fork, or let renovate surface a replacement. ` + allowHint(name),
827
+ evidenceUrl: npmUrl(name)
828
+ });
829
+ }
830
+ }
831
+ }
832
+ if (rules.githubArchived !== "off" && dep.github.archived.known && dep.github.archived.value) {
833
+ hits.push({
834
+ rule: "githubArchived",
835
+ severity: rules.githubArchived,
836
+ message: `The GitHub repository for ${name} is archived (read-only): no fixes or releases will land. Replace it or pin a fork. ${allowHint(name)}`,
837
+ evidenceUrl: dep.github.repoUrl ?? npmUrl(name)
838
+ });
839
+ }
840
+ if (rules.npmDeprecated !== "off" && dep.npm.deprecated.known && dep.npm.deprecated.value.deprecated) {
841
+ const upstream = dep.npm.deprecated.value.message;
842
+ hits.push({
843
+ rule: "npmDeprecated",
844
+ severity: rules.npmDeprecated,
845
+ message: `${name} is deprecated on npm` + (upstream ? `: "${upstream}"` : "") + `. Follow the deprecation notice to migrate. ${allowHint(name)}`,
846
+ evidenceUrl: npmUrl(name)
847
+ });
848
+ }
849
+ if (rules.directoryUnmaintained !== "off" && dep.directory.listed && dep.directory.unmaintained) {
850
+ hits.push({
851
+ rule: "directoryUnmaintained",
852
+ severity: rules.directoryUnmaintained,
853
+ message: `${name} is flagged as unmaintained in the RN Directory. Plan a migration to a maintained alternative. ${allowHint(name)}`,
854
+ evidenceUrl: directoryUrl(name)
855
+ });
856
+ }
857
+ return hits;
858
+ }
859
+ function evaluatePolicy(dependencies, policy, options = {}) {
860
+ const now = options.now ?? /* @__PURE__ */ new Date();
861
+ const findings = [];
862
+ for (const dep of dependencies) {
863
+ if (policy.scope === "rn-native-only" && !dep.isRnNative) continue;
864
+ const allowState = allowStateFor(dep.name, policy.allow, now);
865
+ for (const hit of evaluateRules(dep, policy.rules, now)) {
866
+ if (hit.severity === "note" || allowState.kind === "none") {
867
+ findings.push({ package: dep.name, ...hit, suppressedBy: null });
868
+ } else if (allowState.kind === "active") {
869
+ findings.push({
870
+ package: dep.name,
871
+ ...hit,
872
+ suppressedBy: {
873
+ reason: allowState.entry.reason,
874
+ expires: allowState.entry.expires
875
+ }
876
+ });
877
+ } else {
878
+ const { entry } = allowState;
879
+ findings.push({
880
+ package: dep.name,
881
+ rule: hit.rule,
882
+ severity: "error",
883
+ message: `${hit.message} NOTE: the allowlist entry for ${dep.name}` + (entry.reason ? ` ("${entry.reason}")` : "") + ` expired on ${entry.expires ?? "?"} \u2014 escalated to error. Renew the entry with a new expiry, or remove the dependency.`,
884
+ evidenceUrl: hit.evidenceUrl,
885
+ suppressedBy: null
886
+ });
887
+ }
888
+ }
889
+ }
890
+ return findings;
891
+ }
892
+
893
+ // src/policy-file.ts
894
+ import { readFile as readFile2 } from "fs/promises";
895
+ import { resolve } from "path";
896
+ import { parse } from "yaml";
897
+ var DEFAULT_POLICY_FILENAME = ".rn-doctor.yml";
898
+ var PolicyError = class extends Error {
899
+ constructor(message) {
900
+ super(message);
901
+ this.name = "PolicyError";
902
+ }
903
+ };
904
+ var RULE_SEVERITIES = ["error", "warn", "off"];
905
+ var SCOPES = ["rn-native-only", "all-deps"];
906
+ var RULE_KEYS = [
907
+ "newArchitecture",
908
+ "newArchUnknown",
909
+ "lastPublish",
910
+ "githubArchived",
911
+ "npmDeprecated",
912
+ "directoryUnmaintained"
913
+ ];
914
+ var TOP_LEVEL_KEYS = ["rules", "scope", "allow"];
915
+ function isRecord(value) {
916
+ return typeof value === "object" && value !== null && !Array.isArray(value);
917
+ }
918
+ function isList(value) {
919
+ return Array.isArray(value);
920
+ }
921
+ function fail(where, problem) {
922
+ throw new PolicyError(`Invalid policy${where}: ${problem}`);
923
+ }
924
+ function parseSeverity(value, key, where) {
925
+ if (typeof value === "string" && RULE_SEVERITIES.includes(value)) {
926
+ return value;
927
+ }
928
+ return fail(where, `rules.${key} must be one of ${RULE_SEVERITIES.join(" | ")}, got ${JSON.stringify(value)}`);
929
+ }
930
+ function parseLastPublish(value, where) {
931
+ if (value === "off") return "off";
932
+ if (isRecord(value)) {
933
+ const keys = Object.keys(value);
934
+ const unknown = keys.filter((k) => k !== "warnMonths" && k !== "errorMonths");
935
+ if (unknown.length > 0) {
936
+ fail(where, `rules.lastPublish has unknown key(s): ${unknown.join(", ")} (expected warnMonths, errorMonths)`);
937
+ }
938
+ const { warnMonths, errorMonths } = value;
939
+ if (typeof warnMonths !== "number" || typeof errorMonths !== "number") {
940
+ fail(where, `rules.lastPublish.warnMonths and .errorMonths must both be numbers`);
941
+ }
942
+ if (warnMonths < 0 || errorMonths < 0 || errorMonths < warnMonths) {
943
+ fail(where, `rules.lastPublish thresholds must be >= 0 with errorMonths >= warnMonths`);
944
+ }
945
+ return { warnMonths, errorMonths };
946
+ }
947
+ return fail(where, `rules.lastPublish must be "off" or { warnMonths, errorMonths }, got ${JSON.stringify(value)}`);
948
+ }
949
+ function parseRules(value, where) {
950
+ if (!isRecord(value)) {
951
+ return fail(where, `"rules" must be a mapping, got ${JSON.stringify(value)}`);
952
+ }
953
+ const unknown = Object.keys(value).filter((k) => !RULE_KEYS.includes(k));
954
+ if (unknown.length > 0) {
955
+ fail(where, `unknown rule(s): ${unknown.join(", ")} (known rules: ${RULE_KEYS.join(", ")})`);
956
+ }
957
+ const defaults = DEFAULT_POLICY.rules;
958
+ const severity = (key, fallback) => key in value ? parseSeverity(value[key], key, where) : fallback;
959
+ return {
960
+ newArchitecture: severity("newArchitecture", defaults.newArchitecture),
961
+ newArchUnknown: severity("newArchUnknown", defaults.newArchUnknown),
962
+ lastPublish: "lastPublish" in value ? parseLastPublish(value.lastPublish, where) : defaults.lastPublish,
963
+ githubArchived: severity("githubArchived", defaults.githubArchived),
964
+ npmDeprecated: severity("npmDeprecated", defaults.npmDeprecated),
965
+ directoryUnmaintained: severity("directoryUnmaintained", defaults.directoryUnmaintained)
966
+ };
967
+ }
968
+ var EXPIRES_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
969
+ function parseAllowEntry(value, index, where) {
970
+ const at = `allow[${String(index)}]`;
971
+ if (!isRecord(value)) {
972
+ return fail(where, `${at} must be a mapping with a "package" key, got ${JSON.stringify(value)}`);
973
+ }
974
+ const unknown = Object.keys(value).filter((k) => k !== "package" && k !== "reason" && k !== "expires");
975
+ if (unknown.length > 0) {
976
+ fail(where, `${at} has unknown key(s): ${unknown.join(", ")} (expected package, reason, expires)`);
977
+ }
978
+ const pkg = value.package;
979
+ if (typeof pkg !== "string" || pkg.length === 0) {
980
+ fail(where, `${at}.package must be a non-empty string`);
981
+ }
982
+ const reason = value.reason;
983
+ if (reason !== void 0 && typeof reason !== "string") {
984
+ fail(where, `${at}.reason must be a string when present`);
985
+ }
986
+ let expires = value.expires;
987
+ if (expires instanceof Date && !Number.isNaN(expires.getTime())) {
988
+ expires = expires.toISOString().slice(0, 10);
989
+ }
990
+ if (expires !== void 0 && (typeof expires !== "string" || !EXPIRES_PATTERN.test(expires))) {
991
+ fail(where, `${at}.expires must be a YYYY-MM-DD date when present, got ${JSON.stringify(value.expires)}`);
992
+ }
993
+ return {
994
+ package: pkg,
995
+ reason: typeof reason === "string" ? reason : null,
996
+ expires: typeof expires === "string" ? expires : null
997
+ };
998
+ }
999
+ function parsePolicy(yamlText, filePath) {
1000
+ const where = filePath ? ` (${filePath})` : "";
1001
+ let raw;
1002
+ try {
1003
+ raw = parse(yamlText);
1004
+ } catch (err) {
1005
+ return fail(where, `YAML syntax error \u2014 ${err instanceof Error ? err.message : String(err)}`);
1006
+ }
1007
+ if (raw === null || raw === void 0) return DEFAULT_POLICY;
1008
+ if (!isRecord(raw)) {
1009
+ return fail(where, `top level must be a mapping, got ${JSON.stringify(raw)}`);
1010
+ }
1011
+ const unknown = Object.keys(raw).filter((k) => !TOP_LEVEL_KEYS.includes(k));
1012
+ if (unknown.length > 0) {
1013
+ fail(where, `unknown top-level key(s): ${unknown.join(", ")} (expected ${TOP_LEVEL_KEYS.join(", ")})`);
1014
+ }
1015
+ const rules = "rules" in raw ? parseRules(raw.rules, where) : DEFAULT_POLICY.rules;
1016
+ let scope = DEFAULT_POLICY.scope;
1017
+ if ("scope" in raw) {
1018
+ const value = raw.scope;
1019
+ if (typeof value !== "string" || !SCOPES.includes(value)) {
1020
+ fail(where, `"scope" must be one of ${SCOPES.join(" | ")}, got ${JSON.stringify(value)}`);
1021
+ }
1022
+ scope = value;
1023
+ }
1024
+ let allow = DEFAULT_POLICY.allow;
1025
+ if ("allow" in raw) {
1026
+ const value = raw.allow;
1027
+ if (!isList(value)) {
1028
+ return fail(where, `"allow" must be a list of entries, got ${JSON.stringify(value)}`);
1029
+ }
1030
+ allow = value.map((entry, i) => parseAllowEntry(entry, i, where));
1031
+ }
1032
+ return { rules, scope, allow };
1033
+ }
1034
+ async function loadPolicy(path2, cwd = process.cwd()) {
1035
+ const explicit = path2 !== void 0;
1036
+ const filePath = explicit ? resolve(cwd, path2) : resolve(cwd, DEFAULT_POLICY_FILENAME);
1037
+ let text;
1038
+ try {
1039
+ text = await readFile2(filePath, "utf8");
1040
+ } catch (err) {
1041
+ const code = err.code;
1042
+ if (code === "ENOENT" && !explicit) return DEFAULT_POLICY;
1043
+ throw new PolicyError(
1044
+ explicit && code === "ENOENT" ? `Policy file not found: ${filePath}. Check the --policy path.` : `Could not read policy file ${filePath}: ${err instanceof Error ? err.message : String(err)}`
1045
+ );
1046
+ }
1047
+ return parsePolicy(text, filePath);
1048
+ }
1049
+
1050
+ // src/report-annotations.ts
1051
+ function escapeData(value) {
1052
+ return value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
1053
+ }
1054
+ function escapeProperty(value) {
1055
+ return escapeData(value).replaceAll(":", "%3A").replaceAll(",", "%2C");
1056
+ }
1057
+ function commandFor(f) {
1058
+ if (f.suppressedBy !== null || f.severity === "note") return "notice";
1059
+ return f.severity === "error" ? "error" : "warning";
1060
+ }
1061
+ function renderAnnotations(report, lineOf) {
1062
+ const lines = [];
1063
+ for (const f of report.findings) {
1064
+ const command = commandFor(f);
1065
+ const line = lineOf(f.file, f.package);
1066
+ const properties = [
1067
+ `file=${escapeProperty(f.file)}`,
1068
+ ...line !== null ? [`line=${String(line)}`] : [],
1069
+ `title=${escapeProperty(`rn-doctor: ${f.rule} (${f.package})`)}`
1070
+ ].join(",");
1071
+ const suffix = f.suppressedBy !== null ? ` [allowed${f.suppressedBy.reason ? `: ${f.suppressedBy.reason}` : ""}${f.suppressedBy.expires ? `, expires ${f.suppressedBy.expires}` : ""}]` : "";
1072
+ lines.push(`::${command} ${properties}::${escapeData(f.message + suffix)}`);
1073
+ }
1074
+ return lines.length > 0 ? `${lines.join("\n")}
1075
+ ` : "";
1076
+ }
1077
+
1078
+ // src/report.ts
1079
+ function summarize(findings) {
1080
+ let errors = 0;
1081
+ let warnings = 0;
1082
+ let notes = 0;
1083
+ let suppressed = 0;
1084
+ for (const f of findings) {
1085
+ if (f.suppressedBy !== null) {
1086
+ suppressed++;
1087
+ } else if (f.severity === "error") {
1088
+ errors++;
1089
+ } else if (f.severity === "warn") {
1090
+ warnings++;
1091
+ } else {
1092
+ notes++;
1093
+ }
1094
+ }
1095
+ return { errors, warnings, notes, suppressed };
1096
+ }
1097
+ function computeExitCode(findings) {
1098
+ return findings.some((f) => f.severity === "error" && f.suppressedBy === null) ? 1 : 0;
1099
+ }
1100
+
1101
+ // src/report-json.ts
1102
+ function renderJson(report) {
1103
+ const summary = summarize(report.findings);
1104
+ const doc = {
1105
+ version: 1,
1106
+ summary: {
1107
+ checked: report.checkedCount,
1108
+ errors: summary.errors,
1109
+ warnings: summary.warnings,
1110
+ notes: summary.notes,
1111
+ suppressed: summary.suppressed
1112
+ },
1113
+ findings: report.findings.map((f) => ({
1114
+ file: f.file,
1115
+ package: f.package,
1116
+ rule: f.rule,
1117
+ severity: f.severity,
1118
+ message: f.message,
1119
+ evidenceUrl: f.evidenceUrl,
1120
+ suppressedBy: f.suppressedBy === null ? null : { reason: f.suppressedBy.reason, expires: f.suppressedBy.expires }
1121
+ })),
1122
+ warnings: report.warnings.map((w) => ({
1123
+ ...w.dependency !== void 0 ? { dependency: w.dependency } : {},
1124
+ source: w.source,
1125
+ message: w.message
1126
+ }))
1127
+ };
1128
+ return `${JSON.stringify(doc, null, 2)}
1129
+ `;
1130
+ }
1131
+
1132
+ // src/report-pretty.ts
1133
+ var ESC = String.fromCharCode(27);
1134
+ var ANSI = {
1135
+ red: "31",
1136
+ yellow: "33",
1137
+ cyan: "36",
1138
+ green: "32",
1139
+ dim: "2",
1140
+ bold: "1"
1141
+ };
1142
+ function paint(code, text, on) {
1143
+ return on ? `${ESC}[${code}m${text}${ESC}[0m` : text;
1144
+ }
1145
+ function badge(f, color) {
1146
+ if (f.suppressedBy !== null) return paint(ANSI.dim, `allowed(${f.severity})`, color);
1147
+ if (f.severity === "error") return paint(ANSI.red, "error", color);
1148
+ if (f.severity === "warn") return paint(ANSI.yellow, "warn", color);
1149
+ return paint(ANSI.cyan, "note", color);
1150
+ }
1151
+ function plural(n, word) {
1152
+ return `${String(n)} ${word}${n === 1 ? "" : "s"}`;
1153
+ }
1154
+ function renderPretty(report, options) {
1155
+ const { color } = options;
1156
+ const lines = [];
1157
+ const multiManifest = (report.manifestCount ?? 1) > 1;
1158
+ let currentFile = null;
1159
+ for (const f of report.findings) {
1160
+ if (multiManifest && f.file !== currentFile) {
1161
+ currentFile = f.file;
1162
+ lines.push(paint(ANSI.dim, `${f.file}:`, color));
1163
+ lines.push("");
1164
+ }
1165
+ lines.push(`${badge(f, color)} ${paint(ANSI.bold, f.package, color)} [${f.rule}]`);
1166
+ lines.push(` ${f.message}`);
1167
+ if (f.suppressedBy !== null) {
1168
+ const reason = f.suppressedBy.reason ?? "no reason given";
1169
+ const expires = f.suppressedBy.expires ? `, expires ${f.suppressedBy.expires}` : "";
1170
+ lines.push(paint(ANSI.dim, ` allowed by .rn-doctor.yml: ${reason}${expires}`, color));
1171
+ }
1172
+ if (f.evidenceUrl !== null) {
1173
+ lines.push(paint(ANSI.dim, ` evidence: ${f.evidenceUrl}`, color));
1174
+ }
1175
+ lines.push("");
1176
+ }
1177
+ if (report.warnings.length > 0) {
1178
+ lines.push("Data warnings (missing or degraded lookups - findings may be incomplete):");
1179
+ for (const w of report.warnings) {
1180
+ const scope = w.dependency !== void 0 ? `${w.dependency}: ` : "";
1181
+ lines.push(paint(ANSI.dim, ` - [${w.source}] ${scope}${w.message}`, color));
1182
+ }
1183
+ lines.push("");
1184
+ }
1185
+ const s = summarize(report.findings);
1186
+ const n = report.checkedCount;
1187
+ const checked = `${String(n)} ${n === 1 ? "dependency" : "dependencies"}` + (multiManifest ? ` in ${String(report.manifestCount)} manifests` : "");
1188
+ if (s.errors === 0 && s.warnings === 0 && s.notes === 0 && s.suppressed === 0) {
1189
+ lines.push(paint(ANSI.green, `No findings across ${checked}.`, color));
1190
+ } else {
1191
+ const parts = [
1192
+ paint(ANSI.red, plural(s.errors, "error"), color),
1193
+ paint(ANSI.yellow, plural(s.warnings, "warning"), color),
1194
+ plural(s.notes, "note"),
1195
+ `${String(s.suppressed)} allowed`
1196
+ ];
1197
+ lines.push(`${parts.join(", ")} across ${checked}.`);
1198
+ }
1199
+ return `${lines.join("\n")}
1200
+ `;
1201
+ }
1202
+
1203
+ // src/version.ts
1204
+ var VERSION = "0.0.0";
1205
+
1206
+ // src/report-sarif.ts
1207
+ var INFORMATION_URI = "https://www.npmjs.com/package/react-native-doctor-ci";
1208
+ var RULE_IDS = [
1209
+ "newArchitecture",
1210
+ "newArchUnknown",
1211
+ "lastPublish",
1212
+ "githubArchived",
1213
+ "npmDeprecated",
1214
+ "directoryUnmaintained"
1215
+ ];
1216
+ var RULE_DESCRIPTIONS = {
1217
+ newArchitecture: "The package does not support the React Native New Architecture, per the React Native Directory.",
1218
+ newArchUnknown: "New Architecture support for the package could not be determined from the React Native Directory or npm codegen hints.",
1219
+ lastPublish: "The package's latest npm publish is older than the configured staleness threshold.",
1220
+ githubArchived: "The package's GitHub repository is archived (read-only).",
1221
+ npmDeprecated: "The package is marked deprecated on the npm registry.",
1222
+ directoryUnmaintained: "The React Native Directory flags the package as unmaintained."
1223
+ };
1224
+ function sarifLevel(severity) {
1225
+ return severity === "warn" ? "warning" : severity;
1226
+ }
1227
+ function renderSarif(report, options = {}) {
1228
+ const lineOf = options.lineOf ?? (() => null);
1229
+ const summary = summarize(report.findings);
1230
+ const results = report.findings.map((f) => {
1231
+ const line = lineOf(f.file, f.package);
1232
+ return {
1233
+ ruleId: f.rule,
1234
+ ruleIndex: RULE_IDS.indexOf(f.rule),
1235
+ level: sarifLevel(f.severity),
1236
+ message: { text: f.message },
1237
+ locations: [
1238
+ {
1239
+ physicalLocation: {
1240
+ artifactLocation: { uri: f.file },
1241
+ ...line !== null ? { region: { startLine: line } } : {}
1242
+ }
1243
+ }
1244
+ ],
1245
+ ...f.suppressedBy !== null ? {
1246
+ suppressions: [
1247
+ {
1248
+ kind: "external",
1249
+ status: "accepted",
1250
+ justification: `Allowlisted in .rn-doctor.yml` + (f.suppressedBy.reason ? `: ${f.suppressedBy.reason}` : "") + (f.suppressedBy.expires ? ` (expires ${f.suppressedBy.expires})` : "")
1251
+ }
1252
+ ]
1253
+ } : {},
1254
+ ...f.evidenceUrl !== null ? { hostedViewerUri: f.evidenceUrl } : {}
1255
+ };
1256
+ });
1257
+ const doc = {
1258
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
1259
+ version: "2.1.0",
1260
+ runs: [
1261
+ {
1262
+ tool: {
1263
+ driver: {
1264
+ name: "rn-doctor",
1265
+ informationUri: INFORMATION_URI,
1266
+ version: VERSION,
1267
+ rules: RULE_IDS.map((id) => ({
1268
+ id,
1269
+ shortDescription: { text: RULE_DESCRIPTIONS[id] },
1270
+ helpUri: INFORMATION_URI
1271
+ }))
1272
+ }
1273
+ },
1274
+ invocations: [
1275
+ {
1276
+ executionSuccessful: true,
1277
+ toolExecutionNotifications: report.warnings.map((w) => ({
1278
+ level: "warning",
1279
+ message: {
1280
+ text: (w.dependency !== void 0 ? `${w.dependency}: ` : "") + w.message
1281
+ }
1282
+ }))
1283
+ }
1284
+ ],
1285
+ results,
1286
+ properties: {
1287
+ checked: report.checkedCount,
1288
+ errors: summary.errors,
1289
+ warnings: summary.warnings,
1290
+ notes: summary.notes,
1291
+ suppressed: summary.suppressed
1292
+ }
1293
+ }
1294
+ ]
1295
+ };
1296
+ return `${JSON.stringify(doc, null, 2)}
1297
+ `;
1298
+ }
1299
+
1300
+ // src/workspaces.ts
1301
+ import { readdir, readFile as readFile3, stat } from "fs/promises";
1302
+ import { join as join3, relative, sep } from "path";
1303
+ import { parse as parse2 } from "yaml";
1304
+ var WorkspaceError = class extends Error {
1305
+ constructor(message) {
1306
+ super(message);
1307
+ this.name = "WorkspaceError";
1308
+ }
1309
+ };
1310
+ function segmentToRegExp(segment) {
1311
+ const escaped = segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/\\\\]*");
1312
+ return new RegExp(`^${escaped}$`);
1313
+ }
1314
+ async function isDirectory(path2) {
1315
+ try {
1316
+ return (await stat(path2)).isDirectory();
1317
+ } catch {
1318
+ return false;
1319
+ }
1320
+ }
1321
+ async function listSubdirs(dir) {
1322
+ let entries;
1323
+ try {
1324
+ entries = await readdir(dir, { withFileTypes: true });
1325
+ } catch {
1326
+ return [];
1327
+ }
1328
+ return entries.filter((e) => e.isDirectory() && e.name !== "node_modules" && !e.name.startsWith(".")).map((e) => e.name);
1329
+ }
1330
+ async function walkDirs(dir) {
1331
+ const out = [dir];
1332
+ for (const name of await listSubdirs(dir)) {
1333
+ out.push(...await walkDirs(join3(dir, name)));
1334
+ }
1335
+ return out;
1336
+ }
1337
+ async function expandPattern(rootDir, pattern) {
1338
+ const segments = pattern.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter((s) => s !== "" && s !== ".");
1339
+ let dirs = [rootDir];
1340
+ for (const [index, segment] of segments.entries()) {
1341
+ if (segment === "**") {
1342
+ const rest = segments.slice(index + 1).join("/");
1343
+ const under = (await Promise.all(dirs.map((d) => walkDirs(d)))).flat();
1344
+ if (rest === "") return under;
1345
+ return (await Promise.all(under.map((d) => expandPattern(d, rest)))).flat();
1346
+ }
1347
+ if (segment.includes("*")) {
1348
+ const re = segmentToRegExp(segment);
1349
+ const next = [];
1350
+ for (const dir of dirs) {
1351
+ for (const name of await listSubdirs(dir)) {
1352
+ if (re.test(name)) next.push(join3(dir, name));
1353
+ }
1354
+ }
1355
+ dirs = next;
1356
+ } else {
1357
+ const next = [];
1358
+ for (const dir of dirs) {
1359
+ const candidate = join3(dir, segment);
1360
+ if (await isDirectory(candidate)) next.push(candidate);
1361
+ }
1362
+ dirs = next;
1363
+ }
1364
+ if (dirs.length === 0) return [];
1365
+ }
1366
+ return dirs;
1367
+ }
1368
+ function relPosix(rootDir, dir) {
1369
+ return relative(rootDir, dir).split(sep).join("/");
1370
+ }
1371
+ async function expandWorkspacePatterns(rootDir, patterns) {
1372
+ const included = /* @__PURE__ */ new Map();
1373
+ const exclusions = [];
1374
+ for (const pattern of patterns) {
1375
+ if (pattern.startsWith("!")) {
1376
+ exclusions.push(pattern.slice(1));
1377
+ continue;
1378
+ }
1379
+ for (const dir of await expandPattern(rootDir, pattern)) {
1380
+ const rel = relPosix(rootDir, dir);
1381
+ if (rel !== "") included.set(rel, dir);
1382
+ }
1383
+ }
1384
+ for (const exclusion of exclusions) {
1385
+ const excluded = new Set(
1386
+ (await expandPattern(rootDir, exclusion)).map((dir) => relPosix(rootDir, dir))
1387
+ );
1388
+ for (const rel of [...included.keys()]) {
1389
+ if (excluded.has(rel)) included.delete(rel);
1390
+ }
1391
+ }
1392
+ return [...included.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, dir]) => dir);
1393
+ }
1394
+ async function readPnpmWorkspacePatterns(rootDir) {
1395
+ let text;
1396
+ try {
1397
+ text = await readFile3(join3(rootDir, "pnpm-workspace.yaml"), "utf8");
1398
+ } catch {
1399
+ return null;
1400
+ }
1401
+ let parsed;
1402
+ try {
1403
+ parsed = parse2(text);
1404
+ } catch (err) {
1405
+ throw new WorkspaceError(
1406
+ `pnpm-workspace.yaml is not valid YAML \u2014 ${err instanceof Error ? err.message : String(err)}`
1407
+ );
1408
+ }
1409
+ if (typeof parsed !== "object" || parsed === null) return null;
1410
+ const packages = parsed["packages"];
1411
+ if (packages === void 0) return null;
1412
+ if (!Array.isArray(packages) || !packages.every((p) => typeof p === "string")) {
1413
+ throw new WorkspaceError('pnpm-workspace.yaml has a "packages" field that is not a list of strings.');
1414
+ }
1415
+ return packages;
1416
+ }
1417
+ function readManifestWorkspacePatterns(parsed) {
1418
+ if (typeof parsed !== "object" || parsed === null) return null;
1419
+ let field = parsed["workspaces"];
1420
+ if (field === void 0) return null;
1421
+ if (typeof field === "object" && field !== null && !Array.isArray(field)) {
1422
+ field = field["packages"];
1423
+ if (field === void 0) return null;
1424
+ }
1425
+ if (!Array.isArray(field) || !field.every((p) => typeof p === "string")) {
1426
+ throw new WorkspaceError('package.json has a "workspaces" field that is not a list of strings.');
1427
+ }
1428
+ return field;
1429
+ }
1430
+ async function discoverWorkspaces(rootDir, rootManifestParsed) {
1431
+ const patterns = await readPnpmWorkspacePatterns(rootDir) ?? readManifestWorkspacePatterns(rootManifestParsed);
1432
+ if (patterns === null) {
1433
+ throw new WorkspaceError(
1434
+ '--workspaces requires a "workspaces" field in package.json or a packages list in pnpm-workspace.yaml.'
1435
+ );
1436
+ }
1437
+ const result = [{ dir: rootDir, manifestRelPath: "package.json" }];
1438
+ for (const dir of await expandWorkspacePatterns(rootDir, patterns)) {
1439
+ try {
1440
+ await stat(join3(dir, "package.json"));
1441
+ } catch {
1442
+ continue;
1443
+ }
1444
+ result.push({ dir, manifestRelPath: `${relPosix(rootDir, dir)}/package.json` });
1445
+ }
1446
+ return result;
1447
+ }
1448
+
1449
+ // src/cli-main.ts
1450
+ var USAGE = `rn-doctor - React Native dependency health gate for CI
1451
+
1452
+ Usage:
1453
+ rn-doctor [options]
1454
+
1455
+ Checks the "dependencies" of the package.json in the current directory
1456
+ against the policy in .rn-doctor.yml (or the built-in default policy).
1457
+
1458
+ Options:
1459
+ --json Output a machine-readable JSON report (stable-ordered)
1460
+ --sarif Output a SARIF 2.1.0 report (for code-scanning upload)
1461
+ --policy <path> Path to the policy file (default: .rn-doctor.yml if present)
1462
+ --changed-only Check only dependencies added or changed vs the base ref
1463
+ (compares against the merge-base of HEAD and the base)
1464
+ --base <ref> Base ref for --changed-only (default: origin/main)
1465
+ --workspaces Also check every workspace package.json (npm/yarn
1466
+ "workspaces" or pnpm-workspace.yaml), grouped by manifest
1467
+ --no-cache Bypass the enrichment cache (read and write)
1468
+ --annotations Force GitHub annotations on (default: auto in GitHub Actions)
1469
+ --no-annotations Force GitHub annotations off
1470
+ -v, --version Print the version and exit
1471
+ -h, --help Show this help and exit
1472
+
1473
+ Exit codes:
1474
+ 0 clean (no policy errors; warnings, notes and allowlisted findings are ok)
1475
+ 1 policy errors found
1476
+ 2 tool failure (bad flags, unreadable package.json, invalid policy file,
1477
+ git failure under --changed-only, missing workspace configuration)
1478
+ `;
1479
+ function parseCliArgs(argv) {
1480
+ const { values } = parseArgs({
1481
+ args: [...argv],
1482
+ strict: true,
1483
+ allowPositionals: false,
1484
+ options: {
1485
+ json: { type: "boolean", default: false },
1486
+ sarif: { type: "boolean", default: false },
1487
+ policy: { type: "string" },
1488
+ "changed-only": { type: "boolean", default: false },
1489
+ // No parseArgs default: --base without --changed-only must be detectable.
1490
+ base: { type: "string" },
1491
+ workspaces: { type: "boolean", default: false },
1492
+ // Node 20 parseArgs has no allowNegative; model --no-* as literal flags.
1493
+ "no-cache": { type: "boolean", default: false },
1494
+ annotations: { type: "boolean", default: false },
1495
+ "no-annotations": { type: "boolean", default: false },
1496
+ version: { type: "boolean", short: "v", default: false },
1497
+ help: { type: "boolean", short: "h", default: false }
1498
+ }
1499
+ });
1500
+ return {
1501
+ json: values.json,
1502
+ sarif: values.sarif,
1503
+ policy: values.policy,
1504
+ changedOnly: values["changed-only"],
1505
+ base: values.base,
1506
+ workspaces: values.workspaces,
1507
+ noCache: values["no-cache"],
1508
+ annotations: values.annotations,
1509
+ noAnnotations: values["no-annotations"],
1510
+ version: values.version,
1511
+ help: values.help
1512
+ };
1513
+ }
1514
+ async function runCli(argv, io) {
1515
+ let flags;
1516
+ try {
1517
+ flags = parseCliArgs(argv);
1518
+ } catch (err) {
1519
+ io.stderr.write(`rn-doctor: ${err instanceof Error ? err.message : String(err)}
1520
+
1521
+ ${USAGE}`);
1522
+ return 2;
1523
+ }
1524
+ if (flags.help) {
1525
+ io.stdout.write(USAGE);
1526
+ return 0;
1527
+ }
1528
+ if (flags.version) {
1529
+ io.stdout.write(`${VERSION}
1530
+ `);
1531
+ return 0;
1532
+ }
1533
+ if (flags.json && flags.sarif) {
1534
+ io.stderr.write("rn-doctor: --json and --sarif are mutually exclusive.\n");
1535
+ return 2;
1536
+ }
1537
+ if (flags.annotations && flags.noAnnotations) {
1538
+ io.stderr.write("rn-doctor: --annotations and --no-annotations are mutually exclusive.\n");
1539
+ return 2;
1540
+ }
1541
+ if (flags.base !== void 0 && !flags.changedOnly) {
1542
+ io.stderr.write("rn-doctor: --base requires --changed-only.\n");
1543
+ return 2;
1544
+ }
1545
+ try {
1546
+ const rootManifest = await readPackageJson(io.cwd);
1547
+ const policy = await loadPolicy(flags.policy, io.cwd);
1548
+ const runWarnings = [];
1549
+ let scans;
1550
+ if (flags.workspaces) {
1551
+ const dirs = await discoverWorkspaces(io.cwd, JSON.parse(rootManifest.text));
1552
+ if (dirs.length === 1) {
1553
+ runWarnings.push({
1554
+ source: "workspaces",
1555
+ message: "The workspace configuration matched no directories; checking the root manifest only."
1556
+ });
1557
+ }
1558
+ scans = await Promise.all(
1559
+ dirs.map(async (w) => ({
1560
+ relPath: w.manifestRelPath,
1561
+ manifest: w.manifestRelPath === "package.json" ? rootManifest : await readManifestAt(join4(w.dir, "package.json"))
1562
+ }))
1563
+ );
1564
+ } else {
1565
+ scans = [{ relPath: "package.json", manifest: rootManifest }];
1566
+ }
1567
+ let checks;
1568
+ if (flags.changedOnly) {
1569
+ const git = io.git ?? createGitRunner();
1570
+ const baseRef = flags.base ?? "origin/main";
1571
+ const baseCommit = await resolveBaseCommit(git, io.cwd, baseRef);
1572
+ checks = await Promise.all(
1573
+ scans.map(async ({ relPath, manifest }) => {
1574
+ const baseText = await readFileAtCommit(git, io.cwd, baseCommit, relPath);
1575
+ let baseEntries = null;
1576
+ if (baseText !== null) {
1577
+ try {
1578
+ baseEntries = entriesFromManifestText(baseText, `${relPath} at ${baseRef}`);
1579
+ } catch (err) {
1580
+ if (!(err instanceof ManifestError)) throw err;
1581
+ runWarnings.push({
1582
+ source: "git",
1583
+ message: `${relPath} at ${baseRef} could not be parsed (${err.message}); checking all of its dependencies.`
1584
+ });
1585
+ }
1586
+ }
1587
+ return { relPath, names: diffDependencies(baseEntries, manifest.entries) };
1588
+ })
1589
+ );
1590
+ } else {
1591
+ checks = scans.map(({ relPath, manifest }) => ({ relPath, names: manifest.dependencies }));
1592
+ }
1593
+ const union = [...new Set(checks.flatMap((c) => c.names))];
1594
+ const enriched = union.length > 0 ? await enrichDependencies(union, {
1595
+ noCache: flags.noCache,
1596
+ cacheDir: io.cwd,
1597
+ githubToken: io.env.GITHUB_TOKEN
1598
+ }) : { dependencies: [], warnings: [] };
1599
+ const findings = evaluatePolicy(enriched.dependencies, policy, { now: io.now });
1600
+ const located = [];
1601
+ for (const { relPath, names } of checks) {
1602
+ const declared = new Set(names);
1603
+ for (const f of findings) {
1604
+ if (declared.has(f.package)) located.push({ ...f, file: relPath });
1605
+ }
1606
+ }
1607
+ const report = {
1608
+ findings: located,
1609
+ warnings: [...runWarnings, ...enriched.warnings],
1610
+ checkedCount: checks.reduce((sum, c) => sum + c.names.length, 0),
1611
+ ...flags.workspaces ? { manifestCount: scans.length } : {}
1612
+ };
1613
+ const texts = new Map(scans.map(({ relPath, manifest }) => [relPath, manifest.text]));
1614
+ const lineOf = (file, name) => {
1615
+ const text = texts.get(file);
1616
+ return text === void 0 ? null : findDependencyLine(text, name);
1617
+ };
1618
+ if (flags.json) {
1619
+ io.stdout.write(renderJson(report));
1620
+ } else if (flags.sarif) {
1621
+ io.stdout.write(renderSarif(report, { lineOf }));
1622
+ } else {
1623
+ const color = Boolean(io.stdout.isTTY) && !io.env.NO_COLOR;
1624
+ io.stdout.write(renderPretty(report, { color }));
1625
+ const emitAnnotations = flags.annotations || io.env.GITHUB_ACTIONS === "true" && !flags.noAnnotations;
1626
+ if (emitAnnotations) {
1627
+ io.stdout.write(renderAnnotations(report, lineOf));
1628
+ }
1629
+ }
1630
+ return computeExitCode(report.findings);
1631
+ } catch (err) {
1632
+ if (err instanceof ManifestError || err instanceof PolicyError || err instanceof GitError || err instanceof WorkspaceError) {
1633
+ io.stderr.write(`rn-doctor: ${err.message}
1634
+ `);
1635
+ return 2;
1636
+ }
1637
+ io.stderr.write(
1638
+ `rn-doctor: unexpected error - ${err instanceof Error ? err.stack ?? err.message : String(err)}
1639
+ `
1640
+ );
1641
+ return 2;
1642
+ }
1643
+ }
1644
+
1645
+ // src/cli.ts
1646
+ void runCli(process.argv.slice(2), {
1647
+ stdout: process.stdout,
1648
+ stderr: process.stderr,
1649
+ cwd: process.cwd(),
1650
+ env: process.env
1651
+ }).then((code) => {
1652
+ process.exitCode = code;
1653
+ });
1654
+ //# sourceMappingURL=cli.js.map