UNPKG

react-native

Version:

A framework for building native apps using React

2,043 lines • 83.9 kB
/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow strict-local
 * @format
 */

'use strict';

/*:: import type {SwiftpmConfig} from './swiftpm-config';
import type {
  AggregatorInput,
  AutolinkedDep,
  AutolinkingArgs,
  DiscoveredPlugin,
  NpmDepRef,
  PluginFlavoredFramework,
  PluginPackageDep,
  PluginProductDep,
  PluginScriptPhase,
  ReactDescriptor,
  RawAutolinkingJson,
  SpmModuleConfig,
  SpmTarget,
  SynthPackageSpec,
  TargetEntry,
} from './spm-types'; */

/**
 * generate-spm-autolinking.js – Generates autolinked/Package.swift, the SPM
 * equivalent of CocoaPods' `use_native_modules!`.
 *
 * Usage:
 *   node generate-spm-autolinking.js [options]
 *
 * Options:
 *   --app-root <path>            Path to the app directory (default: cwd)
 *   --react-native-root <path>   Path to react-native package root
 *   --autolinking-json <path>    Path to autolinking.json (default: build/generated/autolinking/autolinking.json)
 *   --output <path>              Output dir (default: autolinked/)
 *
 * Reads:
 *   - build/generated/autolinking/autolinking.json (produced by react-native codegen)
 *   - react-native.config.js (for spm.modules extra modules, optional)
 *
 * Generates:
 *   - autolinked/Package.swift
 *
 * V1 behavior:
 *   - Processes npm-package native modules with platforms.ios != null from autolinking.json
 *   - Also processes any `spm.modules` entries from react-native.config.js for local modules
 *   - Targets resolve React headers via SPM product dependencies (no flags)
 *
 * V2 behavior (future):
 *   - npm packages with their own Package.swift use .package(url: ...) instead of inline targets
 */

const {discoverPlugins, invokePlugins} = require('./autolinking-plugins');
const {
  SpmNameCollisionError,
  assertSwiftNameNotReserved,
  defaultReadConfig,
  defaultReadPodspec,
  defaultResolveDep,
  expandSpmDependencies,
} = require('./expand-spm-dependencies');
const {
  MIN_IOS_VERSION_SUPPORTED,
  sanitizeIosDeploymentTarget,
} = require('./ios-deployment-target');
const {findPodspecs, readPodspecCached} = require('./read-podspec');
const {
  AUTOLINKED_PACKAGE_NAME,
  REACT_CODEGEN_PACKAGE_NAME,
  REACT_CODEGEN_PRODUCTS,
  REACT_NATIVE_PACKAGE_NAME,
  REACT_NATIVE_PRODUCTS,
  RemoteVersionError,
  displayPath,
  findProjectRoot,
  isValidSwiftName,
  makeLogger,
  remotePackageConfig,
  swiftNameKey,
} = require('./spm-utils');
const {readSwiftpmConfig, stringList} = require('./swiftpm-config');
const fs = require('node:fs');
const path = require('node:path');
const yargs = require('yargs');

const {log, warn} = makeLogger('generate-spm-autolinking');

// Targets compiling against React get all headers via SPM product
// dependencies — no search-path flags: the React module from the invariant
// ReactHeaders Clang target, every other RN namespace from ReactNativeHeaders
// binaryTarget (pure-RN), the third-party deps namespaces (folly/glog/
// boost/...) from the ReactNativeDependenciesHeaders sidecar, and the app's
// generated headers from the ReactAppHeaders target in the codegen package.
//
// Remote mode (remotePackageConfig): the ReactNative-family products come
// from the single remote package identity instead of the local path-based
// package, so app + every library unify on one SPM-resolved version.
let remoteCfg /*: ?{url: string, version: string, identity: string} */ = null;

function reactNativePackageLabel() /*: string */ {
  return remoteCfg != null ? remoteCfg.identity : REACT_NATIVE_PACKAGE_NAME;
}
// In remote mode the RN package is labelled with the remote identity, so that
// name is reserved for this run too.
function reservedNamesForRun() /*: ?Array<string> */ {
  return remoteCfg != null ? [remoteCfg.identity] : undefined;
}
function reactNativePackageDecl(localDecl /*: string */) /*: string */ {
  return remoteCfg != null
    ? `.package(url: "${remoteCfg.url}", exact: "${remoteCfg.version}")`
    : localDecl;
}
// The React product set — single source of truth shared by reactProductDeps()
// (the emitted string) and the plugin ReactDescriptor. The invariant ReactNative
// products come from the RN package (identity varies local vs remote);
// ReactAppHeaders is in the separate, per-app React-GeneratedCode package
// (easy to miss when hand-rolling — hence exposing it to plugins).
function reactProducts() /*: Array<{name: string, package: string}> */ {
  const rn = reactNativePackageLabel();
  return [
    ...REACT_NATIVE_PRODUCTS.map(name => ({name, package: rn})),
    ...REACT_CODEGEN_PRODUCTS.map(name => ({
      name,
      package: REACT_CODEGEN_PACKAGE_NAME,
    })),
  ];
}
function reactProductDeps() /*: string */ {
  return reactProducts()
    .map(p => `.product(name: "${p.name}", package: "${p.package}")`)
    .join(', ');
}

// Structured React descriptor handed to autolinking plugins: how to depend on
// React through one source of truth, so a plugin's own Package.swift doesn't
// re-derive RN's package path / identity / product names.
//
// `packageRef` is local or remote (distinguished by which keys are present):
//   - local:  {name, path (ABSOLUTE), relPath (relative to outputDir)}
//   - remote: {name, url, version}
// The canonical `path` is ABSOLUTE because a plugin may write its manifests in
// arbitrary subdirs of outputDir, for which an outputDir-relative path would be
// wrong; the generated manifests are gitignored + regenerated every sync, so an
// absolute path carries no portability cost. `relPath` is a convenience for the
// common case. `products` derives from the SAME reactProducts() RN wires into
// its own autolinked targets (parity: a plugin's target gets exactly RN's React
// surface), filtered to only products whose package is resolvable this run —
// the invariant is that any listed product can be depended on without guarding.
// Returns null when there is no resolvable React dependency.
function reactDescriptor(
  absXcframeworks /*: ?string */,
  xcframeworksRelPath /*: ?string */,
  codegenPackageExists /*: boolean */,
) /*: ?ReactDescriptor */ {
  let packageRef;
  if (remoteCfg != null) {
    packageRef = {
      name: remoteCfg.identity,
      url: remoteCfg.url,
      version: remoteCfg.version,
    };
  } else if (absXcframeworks != null) {
    packageRef = {
      name: REACT_NATIVE_PACKAGE_NAME,
      path: toPosix(absXcframeworks),
      relPath:
        xcframeworksRelPath != null ? toPosix(xcframeworksRelPath) : undefined,
    };
  } else {
    return null;
  }
  const products = reactProducts().filter(
    p => p.package !== REACT_CODEGEN_PACKAGE_NAME || codegenPackageExists,
  );
  return {packageRef, products};
}

// Normalize a (possibly Windows) path to posix separators for embedding in
// a Package.swift `.package(path:)` literal — SPM expects forward slashes.
function toPosix(p /*: string */) /*: string */ {
  return p.split(path.sep).join('/');
}

function parseArgs(argv /*: Array<string> */) /*: AutolinkingArgs */ {
  const parsed = yargs(argv)
    .option('app-root', {
      type: 'string',
      default: process.cwd(),
      describe: 'Path to the app directory',
    })
    .option('react-native-root', {
      type: 'string',
      describe: 'Path to react-native package root',
    })
    .option('autolinking-json', {
      type: 'string',
      describe:
        'Path to autolinking.json (default: build/generated/autolinking/autolinking.json)',
    })
    .option('output', {
      type: 'string',
      describe: 'Output dir (default: autolinked/)',
    })
    .option('xcframeworks-path', {
      type: 'string',
      describe:
        'Path to the xcframeworks sub-package (absolute or relative to appRoot)',
    })
    .option('ios-deployment-target', {
      type: 'string',
      describe: `Platform floor of the generated manifests (default: ${MIN_IOS_VERSION_SUPPORTED})`,
    })
    .usage(
      'Usage: $0 [options]\n\nGenerates autolinked/Package.swift for SPM autolinking.',
    )
    .help()
    .parseSync();

  return {
    appRoot: parsed['app-root'],
    reactNativeRoot: parsed['react-native-root'] ?? null,
    autolinkingJson: parsed['autolinking-json'] ?? null,
    output: parsed.output ?? null,
    xcframeworksPath: parsed['xcframeworks-path'] ?? null,
    iosDeploymentTarget: sanitizeIosDeploymentTarget(
      parsed['ios-deployment-target'],
    ),
  };
}

/**
 * Reads autolinking.json and returns dependencies with iOS platform support.
 */
function readAutolinkingJson(
  filePath /*: string */,
) /*: RawAutolinkingJson | null */ {
  if (!fs.existsSync(filePath)) {
    return null;
  }
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}

/**
 * One of the app's settings, and the root that declared it. Looked up field by
 * field so a project-root `swiftpmConfig` cannot hide a field the Xcode dir
 * still declares: the directory holding the app's package.json wins (where
 * codegen reads `codegenConfig` from), and `appRoot` — the Xcode project dir,
 * the settings' old home — fills the gaps.
 */
function appConfigField(
  appRoot /*: string */,
  field /*: 'modules' | 'denyPlugins' */,
) /*: ?{root: string, value: unknown} */ {
  const projectRoot = findProjectRoot(appRoot);
  const roots = projectRoot === appRoot ? [appRoot] : [projectRoot, appRoot];
  for (const root of roots) {
    const value = readSwiftpmConfig(root, defaultReadConfig(root))?.[field];
    if (value === undefined) {
      continue;
    }
    if (root !== projectRoot) {
      warn(
        `Read '${field}' from ${displayPath(root)}. Move it to 'swiftpmConfig' in ${displayPath(path.join(projectRoot, 'package.json'))} — an app's SwiftPM settings belong with its package.json.`,
      );
    }
    return {root, value};
  }
  return null;
}

/**
 * The app's own native modules — the ones autolinking.json cannot discover —
 * with the root that declared them, since each `path` is relative to the file
 * it is written in:
 *
 *   // <project root>/package.json
 *   "swiftpmConfig": {
 *     "modules": [
 *       {
 *         "name": "MyNativeModule",
 *         "path": "ios/MyNativeModule",
 *         "exclude": ["*.js", "*.podspec"]
 *       }
 *     ]
 *   }
 */
function readSpmModulesFromConfig(
  appRoot /*: string */,
) /*: {modules: Array<SpmModuleConfig>, root: string} */ {
  const declared = appConfigField(appRoot, 'modules');
  if (declared == null || !Array.isArray(declared.value)) {
    return {modules: [], root: appRoot};
  }
  // Entries stay unvalidated here: assertSpmModuleName checks each name, and
  // the emission loop reads the rest defensively.
  // $FlowFixMe[incompatible-type] user-authored entries
  const modules /*: ReadonlyArray<SpmModuleConfig> */ = declared.value;
  return {modules: [...modules], root: declared.root};
}

function moduleClashDiagnosis(
  moduleName /*: string */,
  clash /*: string */,
) /*: string */ {
  if (clash === moduleName) {
    return `is already the name of another autolinked target.`;
  }
  if (clash.toLowerCase() === moduleName.toLowerCase()) {
    return `differs from the existing target '${clash}' only in case, which collides on case-insensitive filesystems.`;
  }
  return `compiles as the same module as the existing target '${clash}'.`;
}

/**
 * Validates one app-local `spm.modules` name against the same rules a library's
 * `spm.name` gets: a usable Swift identifier, not a name React Native reserves,
 * and not one already taken by another module or an autolinked dep.
 * `taken` is keyed by swiftNameKey, valued with the name as written.
 */
function assertSpmModuleName(
  name /*: unknown */,
  taken /*: Map<string, string> */,
) /*: void */ {
  const remedy =
    "Rename it in this app's package.json 'swiftpmConfig.modules'.";
  if (typeof name !== 'string' || !isValidSwiftName(name)) {
    throw new Error(
      `react-native autolinking: invalid 'swiftpmConfig.modules' name ${JSON.stringify(name) ?? 'undefined'}: must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`,
    );
  }
  const moduleName = name;
  assertSwiftNameNotReserved(moduleName, {
    label: `the 'swiftpmConfig.modules' entry '${moduleName}'`,
    remedy,
    extraReservedNames: reservedNamesForRun(),
  });
  const clash = taken.get(swiftNameKey(moduleName));
  if (clash != null) {
    throw new SpmNameCollisionError(
      `react-native autolinking: SPM Swift name collision: the 'swiftpmConfig.modules' entry '${moduleName}' ` +
        moduleClashDiagnosis(moduleName, clash) +
        ` ${remedy}`,
    );
  }
}

/**
 * The app's `denyPlugins` — npm names of autolinking plugins to skip. The
 * escape hatch for transitive plugin discovery (an app opts a framework's
 * plugin OUT); no allowlist is required.
 */
function readDenyPluginsFromConfig(appRoot /*: string */) /*: Array<string> */ {
  return stringList(appConfigField(appRoot, 'denyPlugins')?.value);
}

/**
 * Returns "." if the source directory has .h/.hpp files directly at its root
 * AND no subdirectories exist at that root (adjacent subdirectories would cause
 * Clang to reject the umbrella header).
 * Returns null otherwise.
 */
function inferPublicHeadersPath(sourcePath /*: string */) /*: string | null */ {
  if (!fs.existsSync(sourcePath)) return null;
  const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean}> */ =
    // $FlowFixMe[incompatible-type] Dirent typing
    fs.readdirSync(sourcePath, {withFileTypes: true});
  const hasHeaders = entries.some(
    e =>
      (e.isFile() || e.isSymbolicLink()) &&
      (e.name.endsWith('.h') || e.name.endsWith('.hpp')),
  );
  const hasSubdirs = entries.some(e => e.isDirectory());
  // Only use "." if headers at root AND no adjacent subdirectories.
  // If both headers and subdirectories exist, Clang rejects the module map
  // (umbrella header + adjacent directories = error).
  return hasHeaders && !hasSubdirs ? '.' : null;
}

/*::
type ExtensionFilter = ReadonlySet<string>;
*/

const HEADER_EXTENSIONS /*: ExtensionFilter */ = new Set(['.h', '.hpp']);
const IMPL_EXTENSIONS /*: ExtensionFilter */ = new Set([
  '.m',
  '.mm',
  '.c',
  '.cpp',
  '.swift',
]);
const ALL_SOURCE_EXTENSIONS /*: ExtensionFilter */ = new Set([
  ...HEADER_EXTENSIONS,
  ...IMPL_EXTENSIONS,
]);

// Directory names whose contents should never be included in an SPM target —
// test fixtures, Android sources, vendored modules. Shared between the source
// walker and the header linker so they agree on what to skip.
const SKIP_DIRS_DEFAULT /*: ReadonlySet<string> */ = new Set([
  'android',
  'tests',
  '__tests__',
  '__mocks__',
  'test',
  'jest',
  'node_modules',
]);

// Name of the dir-symlink inside each wrapper that points at the dep's real
// source dir. With target.path = "." (the wrapper), SPM resolves source paths
// like "<WRAPPER_ROOT_NAME>/Foo.mm" by following this link.
const WRAPPER_ROOT_NAME = 'root';

// The config-gated C++ defines the prebuilt React.framework is built with (see
// packages/react-native/Package.swift). Autolinked Fabric C++ must compile with
// the same NDEBUG state, else DebugStringConvertible's vtable / ShadowNode
// layout diverges from a Release React.framework and the link fails.
const REACT_CXX_CONFIG_DEFINES = [
  '.define("DEBUG", .when(configuration: .debug))',
  '.define("NDEBUG", .when(configuration: .release))',
];

// Marker the autolinker stamps onto every synth Package.swift it writes.
// Files lacking this marker are treated as user-managed (self-managed) and
// are referenced directly rather than wrapped — see findSelfManagedPackageDir.
const AUTOGEN_MARKER =
  '// AUTO-GENERATED by scripts/generate-spm-autolinking.js';

/**
 * A dep is "self-managed" when it ships a hand-written Package.swift
 * (i.e. one that lacks our AUTOGEN_MARKER). The autolinker skips wrapping
 * it and references the manifest's directory directly — useful for
 * libraries that want to ship a real SPM manifest and have full control
 * over their target settings.
 *
 * Two layouts are recognized:
 *   1. <dep>/Package.swift            — manifest at the npm-package root
 *   2. <dep>/ios/Package.swift        — manifest co-located with ObjC
 *                                       sources, keeping the npm-package
 *                                       root free of SPM artifacts
 *                                       (.build/, .swiftpm/, Package.resolved)
 *
 * Returns the directory that contains the hand-authored manifest, or null
 * when no candidate exists. That directory is what the aggregator hands to
 * SPM as `.package(path:)` — for layout 2 that means `<dep>/ios`.
 */
function findSelfManagedPackageDir(absSource /*: string */) /*: ?string */ {
  for (const sub of ['', 'ios']) {
    const dir = sub === '' ? absSource : path.join(absSource, sub);
    try {
      const content = fs.readFileSync(path.join(dir, 'Package.swift'), 'utf8');
      if (!content.includes(AUTOGEN_MARKER)) {
        return dir;
      }
    } catch {
      // candidate does not exist; try the next one
    }
  }
  return null;
}

/**
 * Does this dep ship a CocoaPods podspec? (Checked at the dep root and under
 * ios/.) A missing manifest is auto-scaffoldable only when a podspec exists —
 * the scaffolder translates the podspec into a Package.swift.
 */
function hasPodspec(absSource /*: string */) /*: boolean */ {
  return [absSource, path.join(absSource, 'ios')].some(
    dir => findPodspecs(dir).length > 0,
  );
}

/**
 * True when a dep has BOTH Swift and C-family (.m/.mm/.c/.cpp) sources. SPM
 * cannot compile mixed-language sources in a single target, and RN libs that
 * mix them are typically bidirectionally coupled (ObjC↔Swift) — which can't be
 * split into two targets either (it would be a circular dependency). So such a
 * dep is unsupportable by the scaffolder; we surface a clear, distinct error
 * instead of emitting a manifest that fails with a cryptic SPM resolve error.
 * Heuristic filesystem scan (bounded depth; skips examples/tests/build noise).
 */
function hasMixedLanguageSources(absSource /*: string */) /*: boolean */ {
  const SKIP /*: Set<string> */ = new Set([
    'node_modules',
    'Pods',
    'build',
    '.git',
    '__tests__',
    'example',
    'Example',
    'examples',
  ]);
  let hasSwift = false;
  let hasClang = false;
  const walk = (dir /*: string */, depth /*: number */) => {
    if (depth > 6 || (hasSwift && hasClang)) return;
    let entries /*: Array<{name: string, isDirectory(): boolean}> */ = [];
    try {
      // $FlowFixMe[incompatible-type] Dirent typing
      entries = fs.readdirSync(dir, {withFileTypes: true});
    } catch {
      return;
    }
    for (const e of entries) {
      // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in stubs
      const name /*: string */ = e.name;
      if (e.isDirectory()) {
        if (!name.startsWith('.') && !SKIP.has(name)) {
          walk(path.join(dir, name), depth + 1);
        }
      } else if (/\.swift$/i.test(name)) {
        hasSwift = true;
      } else if (/\.(mm?|c|cc|cpp|cxx)$/i.test(name)) {
        hasClang = true;
      }
      if (hasSwift && hasClang) return;
    }
  };
  walk(absSource, 0);
  return hasSwift && hasClang;
}

/**
 * Error thrown when one or more autolinked community npm deps have no Swift
 * Package Manager manifest (neither a shipped Package.swift nor a scaffolded
 * one). The autolinker no longer silently synthesizes a manifest for these —
 * that hid the gap and duplicated the scaffolder. Carries the dep list so the
 * CLI can surface a precise, actionable message and set a distinct exit code
 * (the Xcode build phase keys off it to fail the build).
 */
class MissingManifestError extends Error {
  /*:: missingManifests: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}>; */
  constructor(
    deps /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */,
  ) {
    super(
      `${deps.length} autolinked native module(s) have no Package.swift. ` +
        'Run `npx react-native spm scaffold` to generate them.',
    );
    this.name = 'MissingManifestError';
    this.missingManifests = deps;
  }
}

/**
 * Prints one `error:`-prefixed line per missing-manifest dep so Xcode surfaces
 * each as a build error (Xcode parses lines beginning with `error: `), then
 * returns the MissingManifestError to throw. Kept together so the message and
 * the thrown error never drift.
 */
function reportMissingManifests(
  deps /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */,
) /*: MissingManifestError */ {
  for (const d of deps) {
    if (d.mixed === true) {
      console.error(
        `error: "${d.npmName}" has mixed Swift + Objective-C/C++ sources, which Swift Package Manager cannot compile in a single target (and its Swift↔ObjC interop typically can't be split into two targets without a circular dependency).\n` +
          `  • Opt it out of SPM autolinking in your app's react-native.config.js:\n` +
          `      module.exports = { dependencies: { '${d.npmName}': { platforms: { ios: null } } } };\n` +
          `  • Or consume ${d.npmName} as a prebuilt binary (xcframework) instead.`,
      );
      continue;
    }
    if (d.hasPodspec) {
      console.error(
        `error: Package.swift is missing for library "${d.npmName}" — it ships no Swift Package Manager support.\n` +
          `  1. Run \`npx react-native spm scaffold\` to generate a Package.swift for ${d.npmName}.\n` +
          `  2. Persist it with a patch: \`npx patch-package ${d.npmName}\`, and commit the patch (node_modules is not committed).\n` +
          `  3. Ask ${d.npmName}'s maintainer to ship a Package.swift upstream (or contribute one).\n` +
          '  4. Without a committed patch, this same error returns whenever node_modules is reset (fresh install / CI).',
      );
    } else {
      console.error(
        `error: Package.swift is missing for library "${d.npmName}", and it ships no podspec so it cannot be scaffolded automatically.\n` +
          `  • It needs Swift Package Manager support added manually — ask ${d.npmName}'s maintainer to ship a Package.swift upstream (or contribute one).`,
      );
    }
  }
  return new MissingManifestError(deps);
}

/**
 * Mirrors every header file under `srcDir` as a relative symlink at the same
 * relative location under `destDir`. Used for the centralized cross-package
 * headers tree at `<outputDir>/headers/<SwiftName>/` so consumers can resolve
 * `#import <SwiftName/Header.h>` via a single `-I <outputDir>/headers` flag.
 *
 * Idempotent: existing symlinks pointing at the right target are left alone;
 * stale entries are pruned. Header symlinks here are inert to Xcode (it
 * doesn't navigate them as editable source — they're compiler-only).
 */
function linkHeaderTree(
  srcDir /*: string */,
  destDir /*: string */,
  skipDirNames /*: Set<string> */ = new Set(),
) /*: void */ {
  if (!srcDir || !path.isAbsolute(srcDir)) {
    throw new Error(
      `linkHeaderTree: srcDir must be a non-empty absolute path, got: "${srcDir}"`,
    );
  }
  if (!destDir || !path.isAbsolute(destDir)) {
    throw new Error(
      `linkHeaderTree: destDir must be a non-empty absolute path, got: "${destDir}"`,
    );
  }
  if (!fs.existsSync(srcDir)) {
    return;
  }

  /*:: type HeaderEntry = {relSrc: string, absSrc: string}; */
  const headers /*: Array<HeaderEntry> */ = [];
  function collect(dir /*: string */, relBase /*: string */) /*: void */ {
    const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean}> */ =
      // $FlowFixMe[incompatible-type] Dirent typing
      fs.readdirSync(dir, {withFileTypes: true});
    for (const entry of entries) {
      const {name} = entry;
      if (entry.isDirectory()) {
        if (SKIP_DIRS_DEFAULT.has(name) || skipDirNames.has(name)) continue;
        collect(path.join(dir, name), path.join(relBase, name));
      } else if (entry.isFile() && HEADER_EXTENSIONS.has(path.extname(name))) {
        headers.push({
          relSrc: path.join(relBase, name),
          absSrc: path.join(dir, name),
        });
      }
    }
  }
  collect(srcDir, '');

  if (headers.length === 0) {
    try {
      if (fs.lstatSync(destDir).isDirectory()) {
        fs.rmSync(destDir, {recursive: true, force: true});
      }
    } catch {
      // destDir does not exist – fine
    }
    return;
  }

  fs.mkdirSync(destDir, {recursive: true});

  const expected /*: Set<string> */ = new Set();
  for (const {relSrc, absSrc} of headers) {
    const linkPath = path.join(destDir, relSrc);
    expected.add(relSrc);
    fs.mkdirSync(path.dirname(linkPath), {recursive: true});
    const desiredTarget = path.relative(path.dirname(linkPath), absSrc);
    try {
      const existing = fs.lstatSync(linkPath);
      if (
        existing.isSymbolicLink() &&
        fs.readlinkSync(linkPath) === desiredTarget
      ) {
        continue;
      }
      fs.unlinkSync(linkPath);
    } catch {
      // nothing to remove
    }
    fs.symlinkSync(desiredTarget, linkPath);
  }

  // Prune stale entries: walk destDir and delete anything not in `expected`.
  function pruneWalk(dir /*: string */, relBase /*: string */) /*: void */ {
    if (!fs.existsSync(dir)) return;
    const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean}> */ =
      // $FlowFixMe[incompatible-type] Dirent typing
      fs.readdirSync(dir, {withFileTypes: true});
    for (const entry of entries) {
      const rel = path.join(relBase, entry.name);
      const abs = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        pruneWalk(abs, rel);
        if (fs.readdirSync(abs).length === 0) {
          fs.rmdirSync(abs);
        }
      } else {
        if (!expected.has(rel)) {
          fs.unlinkSync(abs);
        }
      }
    }
  }
  pruneWalk(destDir, '');
}

/**
 * Searches sourcePath for a PrivacyInfo.xcprivacy file (at root or one level deep).
 * Returns the relative path from sourcePath if found, null otherwise.
 */
function findPrivacyManifest(sourcePath /*: string */) /*: string | null */ {
  if (!fs.existsSync(sourcePath)) return null;
  // Check root level
  if (fs.existsSync(path.join(sourcePath, 'PrivacyInfo.xcprivacy'))) {
    return 'PrivacyInfo.xcprivacy';
  }
  // Check one level deep (e.g. ios/PrivacyInfo.xcprivacy)
  const entries /*: Array<{name: string, isDirectory(): boolean}> */ =
    // $FlowFixMe[incompatible-type] Dirent typing
    fs.readdirSync(sourcePath, {withFileTypes: true});
  for (const entry of entries) {
    if (entry.isDirectory()) {
      const nested = path.join(sourcePath, entry.name, 'PrivacyInfo.xcprivacy');
      if (fs.existsSync(nested)) {
        return path.join(entry.name, 'PrivacyInfo.xcprivacy');
      }
    }
  }
  return null;
}

/**
 * Recursively yields forward-slash paths (relative to sourcePath) for every
 * regular file under sourcePath, skipping directories whose name is in
 * SKIP_DIRS_DEFAULT. Used as the building block for both the auto-discovery
 * (collectSpmSources) and explicit-glob (expandSpmSourceGlobs) paths so they
 * agree on what's a candidate before extension/glob filtering applies.
 */
function walkSourceFiles(sourcePath /*: string */) /*: Array<string> */ {
  const out /*: Array<string> */ = [];
  if (!fs.existsSync(sourcePath)) {
    return out;
  }
  function walk(dir /*: string */, rel /*: string */) /*: void */ {
    const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean}> */ =
      // $FlowFixMe[incompatible-type] Dirent typing
      fs.readdirSync(dir, {withFileTypes: true});
    for (const entry of entries) {
      const {name} = entry;
      const childRel = rel === '' ? name : `${rel}/${name}`;
      if (entry.isDirectory()) {
        if (SKIP_DIRS_DEFAULT.has(name)) continue;
        walk(path.join(dir, name), childRel);
      } else if (entry.isFile() || entry.isSymbolicLink()) {
        out.push(childRel);
      }
    }
  }
  walk(sourcePath, '');
  return out;
}

/**
 * Idempotent symlink: ensure `linkPath` is a symlink to `target`. If it
 * already is, leave it untouched (preserves inode). If it points elsewhere
 * or is a real file/directory, replace it. Returns true when the symlink
 * was created or replaced, false when it was already correct.
 */
function ensureSymlink(
  linkPath /*: string */,
  target /*: string */,
) /*: boolean */ {
  try {
    const stat = fs.lstatSync(linkPath);
    if (stat.isSymbolicLink() && fs.readlinkSync(linkPath) === target) {
      return false;
    }
    if (stat.isSymbolicLink() || !stat.isDirectory()) {
      fs.unlinkSync(linkPath);
    } else {
      fs.rmSync(linkPath, {recursive: true, force: true});
    }
  } catch {
    // linkPath does not exist – fine
  }
  fs.symlinkSync(target, linkPath);
  return true;
}

// Default sources allowlist when no explicit glob is provided — analog of
// CocoaPods' `s.source_files` auto-discovery.
function collectSpmSources(sourcePath /*: string */) /*: Array<string> */ {
  return walkSourceFiles(sourcePath)
    .filter(p => ALL_SOURCE_EXTENSIONS.has(path.extname(p)))
    .sort();
}

// Filters walkSourceFiles output through CocoaPods-style globs via micromatch.
// Skip-dir filtering applies before matching, so `**/*.{h,mm}` never returns
// paths under `tests/`, `android/`, etc. — even if the pattern would match.
function expandSpmSourceGlobs(
  sourcePath /*: string */,
  patterns /*: Array<string> */,
) /*: Array<string> */ {
  if (patterns.length === 0) {
    return [];
  }
  // $FlowFixMe[untyped-import] micromatch ships no types
  const micromatch = require('micromatch');
  return micromatch(walkSourceFiles(sourcePath), patterns).sort();
}

/**
 * Converts an autolinking.json dependency to an SPM target spec.
 * Returns null if the dependency doesn't have iOS support.
 *
 * `swiftNameByNpm` maps each autolinked dep's npm name to its resolved Swift
 * name (populated by expandSpmDependencies from the dep's podspec and
 * `spm.name`). Every name this function emits comes from there — see
 * requireSwiftName.
 */
/**
 * Read the dep's podspec (if any) and extract its declared
 * `pod_target_xcconfig` HEADER_SEARCH_PATHS, substituted relative to the dep
 * source dir. Returns paths suitable for an SPM `.headerSearchPath()`
 * directive whose target.path is the dep root (entries are NOT yet prefixed
 * with the synth wrapper's `root/` — the emission site adds that prefix
 * because the wrapper's target.path is `.`, not the source dir).
 *
 * Without these, path-style angle includes like
 * `<react/renderer/components/safeareacontext/X.h>` (used by
 * react-native-safe-area-context, reanimated, screens, etc.) fail to resolve
 * because the headers live under the dep's `common/cpp/` rather than under
 * the framework-imported xcframework headers.
 */
function extractPodspecHeaderSearchPaths(
  sourceDir /*: string */,
) /*: Array<string> */ {
  const podspecPath /*: ?string */ = findPodspecs(sourceDir)[0];
  if (podspecPath == null) return [];

  let model;
  try {
    model = readPodspecCached(podspecPath);
  } catch {
    return [];
  }

  const out /*: Array<string> */ = [];
  for (const raw of model.headerSearchPaths) {
    const substituted = raw
      .replace(/\$\(PODS_TARGET_SRCROOT\)/g, '.')
      .replace(/\$\{PODS_TARGET_SRCROOT\}/g, '.');
    // Drop entries still containing unresolved Xcode tokens — emitting them
    // verbatim would surface as clang "no such file" failures.
    if (/\$[({]/.test(substituted)) continue;
    const cleaned = substituted.replace(/^\.\//, '').replace(/^\//, '');
    if (cleaned.length > 0 && !out.includes(cleaned)) {
      out.push(cleaned);
    }
  }
  return out;
}

/**
 * The Swift name expandSpmDependencies resolved for `npmName`, or a hard error:
 * re-deriving one here would emit a reference nothing in the graph matches.
 */
function requireSwiftName(
  npmName /*: string */,
  resolved /*: ?string */,
) /*: string */ {
  if (resolved == null) {
    throw new Error(
      `react-native autolinking: no resolved Swift name for '${npmName}'. expandSpmDependencies must resolve every autolinked dep's name before SPM targets are generated.`,
    );
  }
  return resolved;
}

function autolinkingDepToSpmTarget(
  depName /*: string */,
  dep /*: AutolinkedDep */,
  outputDir /*: string */,
  swiftNameByNpm /*: Map<string, string> */,
) /*: SpmTarget | null */ {
  const iosPlatform = dep.platforms.ios;
  const sourceDir = iosPlatform.sourceDir ?? dep.root;
  if (sourceDir == null) {
    return null;
  }

  // target.path is stored relative to the autolinker's outputDir so main()'s
  // `path.resolve(outputDir, target.path)` recovers the absolute source dir —
  // same convention the spmModule branch in main() follows.
  const relSourcePath = path.relative(outputDir, sourceDir);

  const targetName = requireSwiftName(depName, dep.swiftName);

  // No exclude inference — main()'s emission loop emits `sources:` (an
  // explicit allowlist). User-supplied excludes still work.

  // Detect PrivacyInfo.xcprivacy
  const privacyManifest = findPrivacyManifest(sourceDir);
  const resources = privacyManifest != null ? [privacyManifest] : undefined;

  // Map declared spm.dependencies (npm names) to Swift target names so the
  // synth's .product(...) deps list reaches the consuming target.
  const spmDeps /*: Array<string> */ = dep.spmDependencies ?? [];
  const spmTargetDependencies =
    spmDeps.length > 0
      ? spmDeps.map(n => requireSwiftName(n, swiftNameByNpm.get(n)))
      : undefined;

  const headerSearchPaths = extractPodspecHeaderSearchPaths(sourceDir);

  return {
    name: targetName,
    path: relSourcePath,
    exclude: [],
    publicHeadersPath: inferPublicHeadersPath(sourceDir),
    resources,
    spmTargetDependencies,
    headerSearchPaths:
      headerSearchPaths.length > 0 ? headerSearchPaths : undefined,
  };
}

/**
 * Generates the full autolinked/Package.swift content.
 *
 * xcframeworksRelPath – path to the xcframeworks sub-package relative to the
 *   autolinked/ directory (e.g. "../build/xcframeworks"). When non-null a
 *   React dependency is declared. Headers need no search paths — React/react
 *   come from the ReactHeaders target, every other RN namespace from
 *   ReactNativeHeaders, folly/glog/boost from ReactNativeDependenciesHeaders,
 *   and the app's generated headers from the ReactAppHeaders product — so
 *   <React/...>, <ReactCommon/...>, <react/renderer/...>, folly/glog/boost,
 *   and <ReactCodegen/...> all resolve.
 */
/**
 * Top-level autolinked/Package.swift — a thin aggregator that references each
 * autolinked dep as its own sub-package (under packages/<SwiftName>) and
 * re-exports them through a single AutolinkedAggregate target. Per-dep
 * settings (header paths, cFlags, link order) live in each synth sub-package;
 * see generateSynthPackageSwift below.
 *
 * input: { deps: Array<{swiftName: string}> }
 */
function generateAutolinkedPackageSwift(
  input /*: AggregatorInput */,
) /*: string */ {
  const npmDeps /*: ReadonlyArray<NpmDepRef> */ = input.npmDeps ?? [];
  const inlineTargets /*: ReadonlyArray<SpmTarget> */ =
    input.inlineTargets ?? [];
  const hasReactDep /*: boolean */ = input.hasReactDep !== false;
  // Relative path from autolinked/ to build/xcframeworks/, e.g. "../build/xcframeworks".
  const xcframeworksRelPath /*: ?string */ = input.xcframeworksRelPath;
  // Autolinking-plugin contributions (Expo & other frameworks).
  const pluginPackageDeps /*: ReadonlyArray<PluginPackageDep> */ =
    input.pluginPackageDeps ?? [];
  const pluginProductDeps /*: ReadonlyArray<PluginProductDep> */ =
    input.pluginProductDeps ?? [];
  const iosDeploymentTarget /*: string */ =
    input.iosDeploymentTarget ?? MIN_IOS_VERSION_SUPPORTED;

  // Package-level dependencies: one .package(path:) per autolinked dep,
  // plus ReactNative if any inline target needs to import React headers.
  const packageDeps /*: Array<string> */ = npmDeps.map(d => {
    const pkgPath = d.packagePath ?? `packages/${d.swiftName}`;
    return `.package(name: "${d.swiftName}", path: "${pkgPath}")`;
  });
  // Framework plugin packages: local (path) or remote (url + exact version).
  for (const p of pluginPackageDeps) {
    packageDeps.push(
      p.path != null
        ? `.package(name: "${p.name}", path: "${p.path}")`
        : `.package(url: "${p.url ?? ''}", exact: "${p.version ?? ''}")`,
    );
  }
  if (
    inlineTargets.length > 0 &&
    hasReactDep &&
    typeof xcframeworksRelPath === 'string'
  ) {
    packageDeps.push(
      reactNativePackageDecl(
        `.package(name: "${REACT_NATIVE_PACKAGE_NAME}", path: "${xcframeworksRelPath}")`,
      ),
    );
    // Per-app generated headers come from the ReactAppHeaders product in
    // the codegen package (sibling of the autolinking dir).
    packageDeps.push(
      `.package(name: "${REACT_CODEGEN_PACKAGE_NAME}", path: "../ios")`,
    );
  }

  // AutolinkedAggregate's target dependencies: .product(...) for npm sub-package
  // products and .target(...) for inline spmModule targets in the same package.
  const aggregateDeps /*: Array<string> */ = [
    ...npmDeps.map(
      d => `.product(name: "${d.swiftName}", package: "${d.swiftName}")`,
    ),
    ...inlineTargets.map(t => `.target(name: "${t.name}")`),
    ...pluginProductDeps.map(
      p => `.product(name: "${p.name}", package: "${p.package}")`,
    ),
  ];

  const inlineDecls = inlineTargets.map(t => {
    const excludeLine =
      t.exclude && t.exclude.length > 0
        ? `\n            exclude: [${t.exclude.map(e => `"${e}"`).join(', ')}],`
        : '';
    const publicHeadersLine =
      t.publicHeadersPath != null
        ? `\n            publicHeadersPath: "${t.publicHeadersPath}",`
        : '';
    const resourcesLine =
      t.resources && t.resources.length > 0
        ? `\n            resources: [${t.resources.map(r => `.copy("${r}")`).join(', ')}],`
        : '';
    return `        .target(
            name: "${t.name}",
            dependencies: [${reactProductDeps()}],
            path: "${t.path}",${excludeLine}${publicHeadersLine}${resourcesLine}
            cxxSettings: [${REACT_CXX_CONFIG_DEFINES.join(', ')}],
            linkerSettings: [.linkedFramework("UIKit"), .linkedFramework("Foundation"), .linkedFramework("CoreGraphics")]
        )`;
  });

  const packageDepsBlock =
    packageDeps.length > 0
      ? `    dependencies: [\n        ${packageDeps.join(',\n        ')},\n    ],\n`
      : '';
  const aggregateDepsLine =
    aggregateDeps.length > 0
      ? `\n            dependencies: [${aggregateDeps.join(', ')}],`
      : '';

  const inlineDeclsBlock =
    inlineDecls.length > 0 ? `,\n${inlineDecls.join(',\n')}` : '';

  // Eval-time missing-manifest guard. SwiftPM resolves the package graph BEFORE
  // the Xcode "Sync SPM Autolinking" build phase runs, so a community library
  // whose Package.swift is absent at resolution time (e.g. a scaffolded manifest
  // wiped by a node_modules reset without a committed patch) fails resolution
  // with an opaque "package manifest cannot be accessed" error — and the
  // actionable sync-phase message never prints. Manifest evaluation may READ the
  // filesystem (only writes are sandboxed), so the aggregator checks each
  // referenced library here and explains the cause + fix at resolution time.
  const guardEntries = npmDeps.map(d => {
    const pkgPath = d.packagePath ?? `packages/${d.swiftName}`;
    return `    (path: "${pkgPath}", npm: "${d.npmName ?? d.swiftName}")`;
  });
  const guardBlock =
    guardEntries.length > 0
      ? `// Eval-time guard: surface a wiped/absent library Package.swift here (at
// resolution) instead of the opaque SwiftPM "manifest cannot be accessed".
let __rnAutolinkedLibs: [(path: String, npm: String)] = [
${guardEntries.join(',\n')},
]
do {
    let __here = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
    let __missing = __rnAutolinkedLibs.filter {
        !FileManager.default.fileExists(
            atPath: __here.appendingPathComponent($0.path)
                .appendingPathComponent("Package.swift").path)
    }
    if !__missing.isEmpty {
        var __msg = ""
        for lib in __missing {
            __msg += "error: Package.swift is missing for library \\"\\(lib.npm)\\" — its Swift Package Manager manifest is not present (a scaffolded manifest wiped by a node_modules reset without a committed patch, or the library ships none).\\n"
            __msg += "  1. Run \`npx react-native spm scaffold\` to (re)generate it.\\n"
            __msg += "  2. Persist it with \`npx patch-package \\(lib.npm)\` and commit the patch (node_modules is not committed).\\n"
            __msg += "  3. Ask \\(lib.npm)'s maintainer to ship a Package.swift upstream.\\n"
            __msg += "  4. Without a committed patch, this error returns on every fresh install / CI.\\n"
        }
        FileHandle.standardError.write(Data(__msg.utf8))
        fatalError("Missing Package.swift for: \\(__missing.map { $0.npm }.joined(separator: ", ")). See the message above.")
    }
}

`
      : '';

  return `// swift-tools-version: 6.0
// AUTO-GENERATED by scripts/generate-spm-autolinking.js – do not edit manually.
// Top-level Autolinked package. Every autolinked dep (npm or spmModule) is
// referenced as .package(path: <dep-source-dir>) — each has its own synth
// Package.swift written in-place. AutolinkedAggregate depends on every dep's
// product so the app build pulls them all in.

import PackageDescription
import Foundation

${guardBlock}let package = Package(
    name: "${AUTOLINKED_PACKAGE_NAME}",
    platforms: [.iOS("${iosDeploymentTarget}")],
    products: [
        .library(name: "${AUTOLINKED_PACKAGE_NAME}", targets: ["AutolinkedAggregate"]),
    ],
${packageDepsBlock}    targets: [
        .target(
            name: "AutolinkedAggregate",${aggregateDepsLine}
            path: "AutolinkedAggregate"
        )${inlineDeclsBlock}
    ],
    cxxLanguageStandard: .cxx20
)
`;
}

/**
 * Per-dep synthesized Package.swift, written at
 * <outputDir>/packages/<SwiftName>/Package.swift. `targetPath` points at a
 * `root` directory symlink to the real source dir, so source files stay real
 * (Xcode atomic-save works).
 *
 * The React + codegen package references are plain relative paths supplied by
 * the caller (`reactNativePackagePath` / `codegenPackagePath`), computed from
 * the synth's fixed location under the autolinking dir — the manifest holds no
 * runtime discovery and no absolute paths. Headers are served by the
 * ReactHeaders/ReactNativeHeaders/ReactNativeDependenciesHeaders targets and
 * the ReactAppHeaders product, so no
 * search-path flags are needed. Siblings use their absolute synth path from
 * `siblingSynthAbsolutePaths` (production) or a `siblingPackageBaseRelative`
 * fallback (tests).
 */
function generateSynthPackageSwift(spec /*: SynthPackageSpec */) /*: string */ {
  const swiftName /*: string */ = spec.swiftName;
  const exclude /*: Array<string> */ = spec.exclude ?? [];
  const sources /*: ?Array<string> */ = spec.sources;
  const publicHeadersPath /*: ?string */ = spec.publicHeadersPath ?? null;
  // Per-dep header search paths from the podspec's
  // pod_target_xcconfig HEADER_SEARCH_PATHS — already prefixed by the caller
  // with the synth wrapper's `root/` so they resolve relative to target.path.
  // Emitted as `.headerSearchPath()` directives, which SPM accepts on
  // cSettings / cxxSettings without needing absolute paths.
  const headerSearchPaths /*: Array<string> */ = spec.headerSearchPaths ?? [];
  const spmDependencies /*: Array<{swiftName: string}> */ =
    spec.spmDependencies ?? [];
  const hasReactDep /*: boolean */ = spec.hasReactDep !== false;
  const resources /*: ?Array<string> */ = spec.resources;
  const isDynamic /*: boolean */ = spec.isDynamic !== false;
  const targetPath /*: string */ = spec.targetPath ?? `Sources/${swiftName}`;
  const siblingSynthAbsolutePaths /*: {[string]: string} */ =
    spec.siblingSynthAbsolutePaths ?? {};
  const iosDeploymentTarget /*: string */ =
    spec.iosDeploymentTarget ?? MIN_IOS_VERSION_SUPPORTED;

  // Package dependencies — ReactNative + each spm sibling synth package.
  // The React + codegen package paths are plain relative strings computed by
  // the caller at generation time (the synth always lives at a fixed depth
  // under the autolinking dir, and is regenerated on every `react-native
  // spm` run), so the manifest holds no runtime discovery. Siblings use their
  // absolute synth path when the caller provides one (production); else a
  // relative fallback.
  const packageDeps /*: Array<string> */ = [];
  if (hasReactDep) {
    const reactNativePackagePath /*: string */ =
      spec.reactNativePackagePath ?? '../../../../xcframeworks';
    const codegenPackagePath /*: string */ =
      spec.codegenPackagePath ?? '../../../ios';
    packageDeps.push(
      reactNativePackageDecl(
        `.package(name: "${REACT_NATIVE_PACKAGE_NAME}", path: "${reactNativePackagePath}")`,
      ),
    );
    // Per-app generated headers come from the ReactAppHeaders product in
    // the codegen package.
    packageDeps.push(
      `.package(name: "${REACT_CODEGEN_PACKAGE_NAME}", path: "${codegenPackagePath}")`,
    );
  }
  for (const dep of spmDependencies) {
    const absPath = siblingSynthAbsolutePaths[dep.swiftName];
    if (absPath != null) {
      packageDeps.push(
        `.package(name: "${dep.swiftName}", path: "${absPath}")`,
      );
    } else {
      const siblingRel /*: string */ = spec.siblingPackageBaseRelative ?? '..';
      packageDeps.push(
        `.package(name: "${dep.swiftName}", path: "${siblingRel}/${dep.swiftName}")`,
      );
    }
  }

  // Target dependencies — products from each declared package dep.
  const targetDeps /*: Array<string> */ = [];
  if (hasReactDep) {
    targetDeps.push(reactProductDeps());
  }
  for (const dep of spmDependencies) {
    targetDeps.push(
      `.product(name: "${dep.swiftName}", package: "${dep.swiftName}")`,
    );
  }

  const excludeLine =
    exclude.length > 0
      ? `\n            exclude: [${exclude.map(e => `"${e}"`).join(', ')}],`
      : '';
  // sources: explicit allowlist. One file per line because lists can run to
  // dozens of entries and an unbroken array becomes unreadable in diffs.
  const sourcesLine =
    sources != null && sources.length > 0
      ? `\n            sources: [\n${sources.map(s => `                "${s}",`).join('\n')}\n            ],`
      : '';
  const publicHeadersLine =
    publicHeadersPath != null
      ? `\n            publicHeadersPath: "${publicHeadersPath}",`
      : '';
  const resourcesLine =
    resources != null && resources.length > 0
      ? `\n            resources: [${resources.map(r => `.copy("${r}")`).join(', ')}],`
      : '';

  const packageDepsBlock =
    packageDeps.length > 0
      ? `    dependencies: [\n        ${packageDeps.join(',\n        ')},\n    ],\n`
      : '';
  // `.headerSearchPath(...)` entries from the podspec — first-class
  // directives keep SPM's diagnostics meaningful (clang reports the
  // dep-relative path on miss). React headers need no paths at all.
  const headerSearchPathList = headerSearchPaths.map(
    p => `.headerSearchPath("${p}")`,
  );
  const cSettingsLine =
    headerSearchPathList.length > 0
      ? `\n            cSettings: [${headerSearchPathList.join(', ')}],`
      : '';
  // ALWAYS emit the config-gated C++ defines (even with no header paths): a
  // Fabric C++ target must compile with the same NDEBUG state as the prebuilt
  // React.framework, else DebugStringConvertible's vtable / ShadowNode layout
  // diverges and a Release build fails to link. Mirrors Package.swift.
  const cxxEntries = [...REACT_CXX_CONFIG_DEFINES, ...headerSearchPathList];
  const cxxSettingsLine = `\n            cxxSettings: [${cxxEntries.join(', ')}],`;

  return `// swift-tools-version: 6.0
// AUTO-GENERATED by scripts/generate-spm-autolinking.js – do not edit manually.
// Synth Package.swift for autolinked dep "${swiftName}".

import PackageDescription

let package = Package(
    name: "${swiftName}",
    platforms: [.iOS("${iosDeploymentTarget}")],
    products: [
        .library(name: "${swiftName}"${isDynamic ? ', type: .dynamic' : ''}, targets: ["${swiftName}"]),
    ],
${packageDepsBlock}    targets: [
        .target(
            name: "${swiftName}",
            dependencies: [${targetDeps.join(', ')}],
            path: "${targetPath}",${excludeLine}${sourcesLine}${publicHeadersLine}${resourcesLine}${cSettingsLine}${cxxSettingsLine}
            linkerSettings: [.linkedFramework("UIKit"), .linkedFramework("Foundation"), .linkedFramework("CoreGraphics")]
        ),
    ],
    cxxLanguageStandard: .cxx20
)
`;
}

function main(argv /*:: ?: Array<string> */) /*: void */ {
  const args = parseArgs(argv ?? process.argv.slice(2));
  // Resolve to absolute so path.join() produces absolute paths everywhere —
  // entryAbsDirs, the headers farm, etc. all assume an absolute appRoot.
  const appRoot = path.resolve(args.appRoot);
  remoteCfg = remotePackageConfig(appRoot);
  if (remoteCfg != null) {
    log(`Remote ReactNative package: ${remoteCfg.url} @ ${remoteCfg.version}`);
  }

  let rnRoot = args.reactNativeRoot;
  if (rnRoot == null) {
    rnRoot = path.join(appRoot, 'node_modules', 'react-native');
    if (!fs.existsSync(rnRoot)) {
      // Monorepo: try walking up
      let dir = appRoot;
      for (let i = 0; i < 5; i++) {
        const parent = path.dirname(dir);
        if (parent === dir) break;
        dir = parent;
        const c = path.join(dir, 'node_modules', 'react-native');
        if (fs.existsSync(c)) {
          rnRoot = c;
          break;
        }
      }
    }
    if (rnRoot == null || !fs.existsSync(rnRoot)) {
      console.error(
        '[generate-spm-autolinking] Could not find react-native. Pass --react-native-root.',
      );
      process.exitCode = 1;
      return;
    }
  }

  const autolinkingJsonPath =
    args.autolinkingJson ??
    path.join(appRoot, 'build', 'generated', 'autolinking', 'autolinking.json');

  // Output lands under <appRoot>/build/generated/autolinking/ — co-located
  // with autolinking.json (written by generate-spm-autolinking-config.js) and
  // alongside the iOS-conventional build/ tree (Pods/, build/xcframeworks/…).
  const outputDir =
    args.output != null
      ? path.resolve(appRoot, args.output)
      : path.join(appRoot, 'build', 'generated', 'autolinking');

  // Collect all targets along with their routing metadata.
  const entries /*: Array<TargetEntry> */ = [];
  // Autolinking plugins discovered from deps' react-native.config.js
  // (populated during the dep walk below; invoked before the aggregator write).
  let discoveredPlugins /*: Array<DiscoveredPlugin> */ = [];

  // 1. From autolinking.json (npm packages with iOS native modules), expanded
  //    with transitive deps declared via `spm.dependencies` in each package's
  //    react-native.config.js (analog of podspec `s.dependency`).
  const autolinkingData = readAutolinkingJson(autolinkingJsonPath);
  const depsMap = autolinkingData?.dependencies;
  if (depsMap != null) {
    // Narrow each on-disk AutolinkingDepJson into the validated AutolinkedDep
    // shape expected by expandSpmDependencies and autolinkingDepToSpmTarget.
    const directDeps /*: Array<AutolinkedDep> */ = [];
    for (const name of Object.keys(depsMap)) {
      const dep = depsMap[name];
      if (dep == null) continue;
      const iosPlatform = dep.platforms?.ios;
      const root = dep.root;
      if (iosPlatform == null || root == null) continue;
      directDeps.push({
        name,
        root,
        platforms: {ios: iosPlatform},
      });
    }
    const allDeps = expandSpmDependencies(directDeps, {
      readConfig: defaultReadConfig,
      resolveDep: defaultResolveDep,
      readPodspec: defaultReadPodspec,
      extraReservedNames: reservedNamesForRun(),
    });

    // Map every autolinked npm name to its resolved Swift name so transitive
    // references inside autolinkingDepToSpmTarget find the right target
    // identifier.
    const swiftNameByNpm /*: Map<string, string> */ = new Map();
    for (const dep of allDeps) {
      if (dep.swiftName != null) {
        swiftNameByNpm.set(dep.name, dep.swiftName);
      }
    }

    // Discover framework autolinking plugins (Expo & others) from the same
    // dep set — invoked before the aggregator is written (below).
    discoveredPlugins = discoverPlugins(
      allDeps,
      defaultReadConfig,
      readDenyPluginsFromConfig(appRoot),
    );
    for (const p of discoveredPlugins) {
      log(`Found SPM autolinking plugin: ${p.depName}`);
    }

    // A dep that declares an autolinking plugin OWNS its native contribution:
    // the plugin (invoked below) returns its package/product/generated-source
    // deps. RN must not also try to source-build that package through the
    // community-lib path — it typically has no Package.swift and may be mixed
    // Swift/ObjC (e.g. expo → ExpoModulesCore), which would throw before the
    // plugin ever runs. Mirrors CocoaPods: `use_expo_modules!` owns Expo's
    // pods; podspec autolinking doesn't also build Expo as a community lib.
    // Skipping here also keeps the dep out of `entries` → out of the
    // missing-manifest scan and the aggregator's package refs.
    const pluginHostDeps /*: Set<string> */ = new Set(
      discoveredPlugins.map(p => p.depName),
    );

    // Skipped means no sibling package is created for the host either, so a
    // dep declaring it in `spm.dependencies` gets a package reference to a
    // path this run never writes — SPM then reports only the missing path.
    // Only manifests React Native emits can carry that reference: a dep
    // shipping its own Package.swift declares its package references itself,
    // and the classification loop below would treat it as self-managed.
    const pluginHostDependents /*: Map<string, Array<string>> */ = new Map();
    for (const dep of allDeps) {
      const declaredHosts = (dep.spmDependencies ?? []).filter(name =>
        pluginHostDeps.has(name),
      );
      if (declaredHosts.length === 0) {
        continue;
      }
      const sourceDir = dep.platforms.ios.sourceDir ?? dep.root;
      if (sourceDir == null || findSelfManagedPackageDir(sourceDir) != null) {
        continue;
      }
      for (const host of declaredHosts) {
        const dependents = pluginHostDependents.get(host) ?? [];
        dependents.push(dep.name);
        pluginHostDependents.set(host, dependents);
      }
    }

    for (const dep of allDeps) {
      if (pluginHostDeps.has(dep.name)) {
        const dependents = pluginHostDependents.get(dep.name);
        if (dependents != null) {
          throw new Error(
            `react-native autolinking: '${dep.name}' ships an SPM autolinking plugin, which owns its native contribution — so React Native does not build it as a sibling target for anything to depend on. It is declared as a SwiftPM dependency by ${dependents.map(name => `'${name}'`).join(', ')}. Remove it there; nothing is lost. Its plugin links its products into the app and resolves its own ecosystem's dependencies, so a library that builds against it does not declare it here.`,
          );
        }
        log(
          `Skipping ${dep.name} target generation — provided by its SPM autolinking plugin`,
        );
        continue;
      }
      const target = autolinkingDepToSpmTarget(
        dep.name,
        dep,
        outputDir,
        swiftNameByNpm,
      );
      if (target != null) {
        entries.push({
          target,
          origin: 'npm',
          npmName: dep.name,
          root: dep.root,
        });
        log(`Found npm native module: ${target.name} → ${target.path}`);
      }
    }
  } else {
    log(
      `No autolinking.json found at ${path.relative(appRoot, autolinkingJsonPath)} or no dependencies. Using only built-in modules.`,
    );
  }

  // 2. From react-native.config.js spm.modules (user-defined extra modules).
  // If the module declares `sources: [glob, ...]` (CocoaPods-style), expand
  // the globs now relative to its dir and attach the file list to the target
  // so the emission loop below renders `sources: [...]` literally.
  const {modules: configModules, root: configModulesRoot} =
    readSpmModulesFromConfig(appRoot);
  // Module names land in the manifest exactly as written, so they get the same
  // checks a dep's Swift name gets. Seeded with the dep target names already
  // emitted so a module can't shadow an autolinked library either.
  const takenSwiftNames /*: Map<string, string> */ = new Map(
    entries.map(entry => [swiftNameKey(entry.target.name), entry.target.name]),
  );
  for (const mod of configModules) {
    assertSpmModuleName(mod.name, takenSwiftNames);
    takenSwiftNames.set(swiftNameKey(mod.name), mod.name);
    // Relative to the package.json (or config file) that declared it, so a
    // path reads correctly from where it is written.
    const absPath = path.resolve(configModulesRoot, mod.path);
    const relPath = path.relative(outputDir, absPath);
    const userSources =
      Array.isArray(mod.sources) && mod.sources.length > 0
        ? expandSpmSourceGlobs(absPath, mod.sources)
        : null;
    entries.push({
      target: {
        name: mod.name,
        path: relPath,
        exclude: mod.exclude ?? [],
        // The synth wrapper owns the module's public interface: it declares
        // publicHeadersPath: "include", a symlink to the module's header tree.
        publicHeadersPath: null,
        sources: userSources,
      },
      origin: 'spmModule',
    });
    log(`Config module: ${mod.name} → ${relPath}`);
  }

  // Resolve xcframeworks package path relative to outputDir (autolinked/).
  // When provided this causes each target to declare a React dependency so
  // Xcode adds the xcframework's header search paths (needed for <React/...>).
  // Always set xcframeworksRelPath to the default even if the directory doesn't
  // exist yet — on first run, step 2 (autolinking) runs before step 4
  // (xcframework symlinks), but the generated Swift code resolves paths at
  // Xcode build time, not generation time.
  let xcframeworksRelPath /*: string | null */ = null;
  const absXcframeworks /*: string */ =
    args.xcframeworksPath != null
      ? path.resolve(appRoot, args.xcframeworksPath)
      : path.join(appRoot, 'build', 'xcframeworks');
  xcframeworksRelPath = path.relative(outputDir, absXcframeworks);

  if (xcframeworksRelPath != null) {
    log(
      `React xcframeworks → ${xcframeworksRelPath} (relative to autolinked/)`,
    );
  }

  // Whether autolinked targets declare a React dependency at all. Headers are
  // served by the ReactHeaders/ReactNativeHeaders/ReactNativeDependenciesHeaders
  // binaryTargets and the ReactAppHeaders product — no `-I` flags anywhere.
  const hasReactDep = xcframeworksRelPath != null;

  // Each entry gets a wrapper dir at <outputDir>/packages/<SwiftName>/ that
  // contains the synth Package.swift and a `root` directory symlink pointing
  // at the dep's real source dir. SPM derives package identity from the path
  // basename, so the wrapper's unique name (SwiftName) sidesteps the basename
  // collision that in-place at the source dir would have. Files inside the
  // source dir stay real, so Xcode's atomic-save works through the dir
  // symlink (intermediate path components — even symlinks — resolve cleanly;
  // the issue was only file-symlinks as the final path component).
  const entryAbsDirs /*: Map<string, string> */ = new Map();
  for (const entry of entries) {
    entryAbsDirs.set(
      entry.target.name,
      path.resolve(outputDir, entry.target.path),
    );
  }

  const packagesDir = path.join(outputDir, 'packages');
  const headersDir = path.join(outputDir, 'headers');
  // libs/<SwiftName>/ symlinks for self-managed deps. The symlink basename
  // is the Swift module name (guaranteed unique per dep), so SPM's
  // path-basename-based package identity never collides — even when two
  // libs ship their own Package.swift inside `ios/` (a common convention).
  // Populated below as self-managed deps are visited, then pruned. Entries
  // that do not change keep their inode: Xcode holds each one as a loaded
  // package root, and recreating one it already resolved fails the build with
  // "Missing package product".
  const libsDir = path.join(outputDir, 'libs');
  const wantedLibAliases /*: Set<string> */ = new Set();
  fs.mkdirSync(packagesDir, {recursive: true});
  fs.mkdirSync(headersDir, {recursive: true});
  fs.mkdirSync(libsDir, {recursive: true});

  const wrapperDirs /*: Map<string, string> */ = new Map();
  const selfManagedDirs /*: Map<string, string> */ = new Map();
  const aggregatorPackageDeps /*: Array<NpmDepRef> */ = [];
  // Community npm deps that autolink but ship/scaffold no Package.swift. We no
  // longer silently synthesize one for them (that duplicated the scaffolder and
  // hid the gap from the developer and the library author) — collect them and
  // fail with an actionable message after the classification pass. spmModules
  // (app-local, explicitly declared in react-native.config.js) keep their synth
  // wrappers: an app-local dir has no npm identity, so there is no package for
  // the aggregator to reference until one is written for it.
  const missingManifests /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */ =
    [];

  for (const entry of entries) {
    const {target} = entry;
    const absSource /*: string */ = entryAbsDirs.get(target.name) ?? '';
    if (!fs.existsSync(absSource)) {
      log(`Skipping ${target.name}: source dir missing (${absSource})`);
      continue;
    }
    const selfManagedDir = findSelfManagedPackageDir(absSource);
    if (selfManagedDir != null) {
      // Record the manifest's actual directory — for the nested layout this
      // is <dep>/ios, not <dep>. SPM resolves `.package(path:)` against that
      // directory expecting Package.swift to live alongside.
      selfManagedDirs.set(target.name, selfManagedDir);
      // If a wrapper exists from a prior synth-mode run (i.e. the dep WAS
      // autolinker-wrapped, then later transitioned to self-managed via
      // `spm scaffold` or shipping its own Package.swift), remove the
      // wrapper now. Without this, the pruning loop below preserves it
      // (because the dep is "active" via selfManagedDirs) and Xcode's
      // SwiftPM cache picks up the stale wrapper Package.swift instead of
      // the self-managed one.
      const staleWrapper = path.join(packagesDir, target.name);
      if (fs.existsSync(staleWrapper)) {
        fs.rmSync(staleWrapper, {recursive: true, force: true});
        log(`Removed stale wrapper: packages/${target.name}/`);
      }
      log(
        `Self-managed: ${target.name} → ${path.relative(appRoot, selfManagedDir)} (using its own Package.swift)`,
      );
      continue;
    }
    if (entry.origin === 'npm') {
      // No shipped or scaffolded manifest — this is the gap we now surface.
      // A mixed-language dep is reported distinctly (it can't be scaffolded at
      // all, so "run spm scaffold" would be misleading).
      missingManifests.push({
        name: target.name,
        npmName: entry.npmName ?? target.name,
        hasPodspec: hasPodspec(absSource),
        mixed: hasMixedLanguageSources(absSource),
      });
      // Drop any stale wrapper from a previous synth-mode run so SPM doesn't
      // resolve against it.
      const staleWrapper = path.join(packagesDir, target.name);
      if (fs.existsSync(staleWrapper)) {
        fs.rmSync(staleWrapper, {recursive: true, force: true});
      }
      continue;
    }
    // spmModule: the synth wrapper is the mechanism, not a fallback — an
    // app-local dir has no npm identity, so the wrapper is the only package the
    // aggregator can reference. No podspec is read on this route by design:
    // app-local native code isn't required to carry one. (A hand-written
    // Package.swift still wins — the self-managed check above claims it first.)
    // But a mixed-language module can't be wrapped either — SPM can't compile
    // Swift + C-family sources in one target, and a synth wrapper would fail
    // with a cryptic SPM resolve error. Surface the same friendly diagnostic the
    // community-dep path uses instead of letting SPM emit the cryptic one.
    if (hasMixedLanguageSources(absSource)) {
      throw new Error(
        `react-native autolinking: the spm.module "${target.name}" mixes Swift ` +
          `and C-family (.m/.mm/.c/.cpp) sources, which SwiftPM cannot compile ` +
          `in a single target. Split it into separate single-language modules, ` +
          `or ship a hand-written Package.swift with multiple targets.`,
      );
    }
    const wrapperDir = path.join(packagesDir, target.name);
    wrapperDirs.set(target.name, wrapperDir);
    fs.mkdirSync(wrapperDir, {recursive: true});
    ensureSymlink(path.join(wrapperDir, WRAPPER_ROOT_NAME), absSource);
  }

  // Fail before writing any wrappers/aggregator: a missing community-lib
  // manifest is a hard error the developer must resolve by scaffolding (or the
  // library shipping its own). reportMissingManifests prints one `error:` line
  // per dep so Xcode renders them as build errors.
  if (missingManifests.length > 0) {
    throw reportMissingManifests(missingManifests);
  }

  // Sibling refs: each synth Package.swift declares its sibling deps via the
  // dep's actual package root — wrapper dir for autolinker-managed deps,
  // source dir for self-managed ones. SPM identity stays unique either way
  // (wrapper basename = SwiftName; self-managed manifests declare the same
  // package name).
  const siblingPackagePaths /*: {[string]: string} */ = {};
  for (const [name, wrapper] of wrapperDirs.entries()) {
    siblingPackagePaths[name] = wrapper;
  }
  for (const [name, sourceDir] of selfManagedDirs.entries()) {
    siblingPackagePaths[name] = sourceDir;
  }

  for (const entry of entries) {
    const {target} = entry;
    const absSource /*: string */ = entryAbsDirs.get(target.name) ?? '';

    // Self-managed deps: skip the synth step entirely. The dep's own
    // Package.swift handles its targets, headers, and React framework
    // wiring. We just register it with the aggregator so the app pulls it
    // in alongside autolinker-managed deps. The central headers/<SwiftName>/
    // tree still gets populated so consumers (host app + sibling synths
    // that hit -I autolinking/headers) can resolve `<SwiftName/Header.h>`
    // by file path — synth packages use `-fno-implicit-module-maps`, so
    // we can't rely on SPM's auto-generated module map alone.
    if (selfManagedDirs.has(target.name)) {
      // Centralized headers tree walks the WHOLE dep root, not just the
      // manifest's directory — headers may live anywhere (e.g. common/cpp/
      // outside of ios/), and cross-package consumers should still resolve
      // them via the centralized -I path.
      linkHeaderTree(absSource, path.join(headersDir, target.name));
      // Route the manifest reference through a uniquely-named symlink at
      // libs/<SwiftName>/ so SPM derives the package identity from the
      // alias basename. Two libs that both ship Package.swift inside their
      // own `ios/` subdir would otherwise collide with identity "ios".
      const realPackageDir = selfManagedDirs.get(target.name) ?? absSource;
      const aliasPath = path.join(libsDir, target.name);
      ensureSymlink(aliasPath, realPackageDir);
      wantedLibAliases.add(target.name);
      aggregatorPackageDeps.push({
        swiftName: target.name,
        packagePath: `libs/${target.name}`,
        npmName: entry.npmName ?? target.name,
      });
      continue;
    }

    const wrapperDir = wrapperDirs.get(target.name);
    if (wrapperDir == null) continue;
    const skipDirNames = new Set(
      (target.exclude || [])
        .filter(e => e.endsWith('/'))
        .map(e => e.slice(0, -1)),
    );

    const siblingSynthAbsolutePaths /*: {[string]: string} */ = {};
    for (const sibling of target.spmTargetDependencies ?? []) {
      const sibPath = siblingPackagePaths[sibling];
      if (sibPath != null) {
        siblingSynthAbsolutePaths[sibling] = sibPath;
      }
    }

    // target.path = "." (the wrapper dir) so SPM sees an empty `include/`
    // sibling of `root/` for its required `publicHeadersPath`. Without that,
    // SPM defaults publicHeadersPath to "include" and errors out when no
    // such dir exists inside the dep's source tree. Sources come from
    // `root/<...>` via the dir symlink — paths from auto-discovery or
    // user globs are relative to the dep's source dir, so we prefix with
    // `root/` to keep them inside target.path.
    const withRoot = (p /*: string */) => `${WRAPPER_ROOT_NAME}/${p}`;
    const prefixedExclude /*: Array<string> */ = (target.exclude ?? []).map(
      withRoot,
    );
    const prefixedResources /*: ?Array<string> */ =
      target.resources != null ? target.resources.map(withRoot) : undefined;

    // sources: explicit allowlist. Pre-resolved on the target (spmModule
    // glob expansion) or auto-collected here. We always emit `sources:` so
    // SPM never falls back to scanning the source dir verbatim (which would
    // pick up tests/, *.js, *.podspec, etc.).
    const rawSources /*: Array<string> */ =
      target.sources != null && target.sources.length > 0
        ? target.sources
        : collectSpmSources(absSource);
    const prefixedSources /*: ?Array<string> */ =
      rawSources.length > 0 ? rawSources.map(withRoot) : null;

    // Podspec HEADER_SEARCH_PATHS were captured relative to the dep's source
    // dir. The wrapper exposes the source dir under `root/` (target.path is
    // `.`, the wrapper dir), so each entry must be prefixed with `root/` so
    // clang sees the real subtree.
    const prefixedHeaderSearchPaths /*: ?Array<string> */ =
      target.headerSearchPaths != null && target.headerSearchPaths.length > 0
        ? target.headerSearchPaths.map(withRoot)
        : null;

    const synthContent = generateSynthPackageSwift({
      swiftName: target.name,
      iosDeploymentTarget: args.iosDeploymentTarget,
      exclude: prefixedExclude,
      sources: prefixedSources,
      // Stub include/ subdir lives in the wrapper dir; satisfies SPM's
      // publicHeadersPath requirement without exposing anything. Cross-pkg
      // angle includes resolve through the merged header tree (the autolinking
      // header farm at <outputDir>/headers is folded into it).
      publicHeadersPath: 'include',
      resources: prefixedResources,
      headerSearchPaths: prefixedHeaderSearchPaths,
      spmDependencies: (target.spmTargetDependencies ?? []).map(swiftName => ({
        swiftName,
      })),
      hasReactDep,
      // Relative paths from the synth dir (<outputDir>/packages/<Name>) to the
      // app's React xcframeworks + codegen packages. Computed here because the
      // synth's depth is fixed and it is regenerated every run — no runtime
      // discovery needed in the manifest.
      reactNativePackagePath: toPosix(
        path.relative(wrapperDir, absXcframeworks),
      ),
      codegenPackagePath: toPosix(
        path.relative(
          wrapperDir,
          path.join(appRoot, 'build', 'generated', 'ios'),
        ),
      ),
      isDynamic: false,
      targetPath: '.',
      siblingSynthAbsolutePaths,
    });

    fs.writeFileSync(
      path.join(wrapperDir, 'Package.swift'),
      synthContent,
      'utf8',
    );
    // Centralized headers tree at <outputDir>/headers/<SwiftName>/<relpath>.h.
    // Used two ways:
    //   * SPM-internal: cFlags add `-I <outputDir>/headers`, so cross-package
    //     angle includes like <SwiftName/Header.h> resolve.
    //   * Host app + sibling consumers: each wrapper's `include/` is a dir
    //     symlink to its slice of this tree, so `#import <RelPath/Header.h>`
    //     (e.g. <ReactCommon/RCTSampleTurboModule.h>) resolves through SPM's
    //     publicHeadersPath propagation (-I .../packages/<SwiftName>/include).
    const pkgHeadersDir = path.join(headersDir, target.name);
    linkHeaderTree(absSource, pkgHeadersDir, skipDirNames);

    const includePath = path.join(wrapperDir, 'include');
    if (fs.existsSync(pkgHeadersDir)) {
      ensureSymlink(includePath, pkgHeadersDir);
    } else {
      // Header-less package (rare): keep an empty dir so SPM's
      // publicHeadersPath: "include" requirement is still satisfied.
      fs.mkdirSync(includePath, {recursive: true});
    }

    log(
      `Synth: packages/${target.name}/ → ${path.relative(appRoot, absSource)}`,
    );

    aggregatorPackageDeps.push({
      swiftName: target.name,
      packagePath: `packages/${target.name}`,
    });
  }

  // Prune stale wrappers, header dirs and lib aliases for entries no longer
  // autolinked. Preserve both wrapper-managed and self-managed names; only
  // entries that are no longer autolinked at all get removed. Note:
  // `packages/` only has wrapper-managed names (self-managed deps live in
  // their own source dirs), but `headers/` has both since we populate the
  // central tree for everyone. `libs/` keeps only the aliases written above,
  // so a dep that stopped being self-managed loses its alias too.
  const activeNames /*: Set<string> */ = new Set([
    ...wrapperDirs.keys(),
    ...selfManagedDirs.keys(),
  ]);
  const pruneTargets /*: Array<[string, Set<string>]> */ = [
    ['packages', activeNames],
    ['headers', activeNames],
    ['libs', wantedLibAliases],
  ];
  for (const [subdir, keptNames] of pruneTargets) {
    const dir = path.join(outputDir, subdir);
    try {
      const existing /*: Array<{name: string, isSymbolicLink(): boolean, isDirectory(): boolean}> */ =
        // $FlowFixMe[incompatible-type] Dirent typing
        fs.readdirSync(dir, {withFileTypes: true});
      for (const entry of existing) {
        if (keptNames.has(entry.name)) continue;
        const stale = path.join(dir, entry.name);
        if (entry.isSymbolicLink() || !entry.isDirectory()) {
          fs.unlinkSync(stale);
        } else {
          fs.rmSync(stale, {recursive: true, force: true});
        }
        log(`Removed stale ${subdir}/${entry.name}`);
      }
    } catch {
      // dir doesn't exist – fine
    }
  }

  // Invoke discovered framework plugins now that the RN dep graph + autolinking
  // data are final. Their package/product contributions merge into the
  // aggregator below; this runs in add/update AND every build-time sync (both
  // call main), so a plugin's contribution survives every regeneration.
  let pluginPackageDeps /*: Array<PluginPackageDep> */ = [];
  let pluginProductDeps /*: Array<PluginProductDep> */ = [];
  let pluginGeneratedSources /*: Array<{path: string}> */ = [];
  let pluginFlavoredFrameworks /*: Array<PluginFlavoredFramework> */ = [];
  let pluginWatchPaths /*: Array<string> */ = [];
  let pluginScriptPhases /*: Array<PluginScriptPhase> */ = [];
  if (discoveredPlugins.length > 0) {
    // React-GeneratedCode is the per-app codegen package (referenced as
    // `../ios` from outputDir). It may be absent (no codegen this run), so the
    // descriptor only lists its products when it actually resolves.
    const codegenPackageExists = fs.existsSync(
      path.join(outputDir, '..', 'ios', 'Package.swift'),
    );
    const result = invokePlugins(
      discoveredPlugins,
      {
        appRoot,
        projectRoot: findProjectRoot(appRoot),
        reactNativeRoot: rnRoot,
        autolinking: autolinkingData ?? {},
        outputDir,
        react: reactDescriptor(
          absXcframeworks,
          xcframeworksRelPath,
          codegenPackageExists,
        ),
      },
      {warn},
    );
    pluginPackageDeps = result.packageDependencies;
    pluginProductDeps = result.productDependencies;
    pluginGeneratedSources = result.generatedSources;
    pluginFlavoredFrameworks = result.flavoredFrameworks;
    pluginWatchPaths = result.watchPaths;
    pluginScriptPhases = result.scriptPhases;
    log(
      `SPM plugins contributed ${pluginPackageDeps.length} package(s), ` +
        `${pluginProductDeps.length} product(s), ` +
        `${pluginGeneratedSources.length} generated source(s), ` +
        `${pluginFlavoredFrameworks.length} flavored framework(s), ` +
        `${pluginScriptPhases.length} script phase(s)`,
    );
  }

  // Plugin sidecars. All are ALWAYS written — even `[]` — so removing a
  // plugin (or dropping its declaration) clears stale entries. Machine-local
  // absolute paths; gitignored + regenerated every sync.
  fs.mkdirSync(outputDir, {recursive: true});
  // Generated-source registration (e.g. Expo's ExpoModulesProvider.swift),
  // consumed by the injector at add/update time — the provider ordering
  // contract is still Preview and co-designed with the first consumer.
  fs.writeFileSync(
    path.join(outputDir, '.spm-plugin-generated-sources.json'),
    JSON.stringify(pluginGeneratedSources, null, 2) + '\n',
    'utf8',
  );
  // Flavored-framework sidecar consumed by `spm add` / `spm update` when it
  // regenerates the app's linker settings and sole embed phase.
  fs.writeFileSync(
    path.join(outputDir, '.spm-plugin-flavored-frameworks.json'),
    JSON.stringify(pluginFlavoredFrameworks, null, 2) + '\n',
    'utf8',
  );
  fs.writeFileSync(
    path.join(outputDir, '.spm-plugin-script-phases.json'),
    JSON.stringify(pluginScriptPhases, null, 2) + '\n',
    'utf8',
  );

  // Top-level aggregator: references every entry as .package(path:) and
  // depends on each via .product(...). No more inline targets — every
  // autolinked dep is a real SPM package in its own source dir.
  const aggregatorContent = generateAutolinkedPackageSwift({
    npmDeps: aggregatorPackageDeps,
    iosDeploymentTarget: args.iosDeploymentTarget,
    hasReactDep,
    xcframeworksRelPath,
    pluginPackageDeps,
    pluginProductDeps,
  });
  fs.mkdirSync(outputDir, {recursive: true});
  const outputPath = path.join(outputDir, 'Package.swift');
  fs.writeFileSync(outputPath, aggregatorContent, 'utf8');
  log(`Generated: ${path.relative(appRoot, outputPath)}`);

  // .spm-sync-watch-paths: absolute paths (dirs OR files) the Xcode auto-sync
  // build phase watches for staleness. Three kinds of input, mixed freely:
  //   1. Each module's source dir (entryAbsDirs) — a dir; adding/removing a
  //      child bumps its mtime so `find -newer` trips and the `sources:`
  //      allowlist regenerates.
  //   2. Each npm dep's checked-in root `Package.swift` (a file) and its
  //      `.react-native/` metadata dir. Editing a manifest does NOT bump the
  //      source dir's mtime, so without these a manifest edit stays silently
  //      stale until an unrelated install triggers a sync. The dep ROOT is
  //      threaded through the entry (entry.root, from the autolinking model),
  //      not derived by walking up from the (possibly nested) source dir.
  //   3. Plugin-contributed paths (e.g. Expo's own Package.swift / per-module
  //      manifests) — already validated absolute in invokePlugins.
  // The phase distinguishes dir vs file with `-d`/`-f` at build time (no
  // markers). The existsSync filter here is safe: these paths come from a
  // successful sync so they exist now; a path that later VANISHES is caught at
  // phase time against this file and forces a re-sync.
  const watchCandidates /*: Array<string> */ = [...entryAbsDirs.values()];
  for (const entry of entries) {
    const root = entry.root;
    if (entry.origin !== 'npm' || root == null) {
      continue;
    }
    watchCandidates.push(path.join(root, 'Package.swift'));
    watchCandidates.push(path.join(root, '.react-native'));
  }
  watchCandidates.push(...pluginWatchPaths);
  const watchPaths = Array.from(new Set(watchCandidates))
    .filter(p => p.length > 0 && fs.existsSync(p))
    .sort();
  fs.writeFileSync(
    path.join(outputDir, '.spm-sync-watch-paths'),
    watchPaths.join('\n') + (watchPaths.length > 0 ? '\n' : ''),
    'utf8',
  );

  // AutolinkedAggregate is glue; needs at least one source file (Swift, so we
  // sidestep the Obj-C public-headers-dir requirement).
  const aggregateDir = path.join(outputDir, 'AutolinkedAggregate');
  fs.mkdirSync(aggregateDir, {recursive: true});
  const stubPath = path.join(aggregateDir, 'AutolinkedAggregate.swift');
  if (!fs.existsSync(stubPath)) {
    fs.writeFileSync(
      stubPath,
      '// Placeholder. Real native modules live in transitively-referenced sub-packages.\n',
      'utf8',
    );
  }
  const legacyStub = path.join(aggregateDir, 'AutolinkedAggregate.m');
  if (fs.existsSync(legacyStub)) {
    fs.unlinkSync(legacyStub);
  }

  // One-time migration cleanup: remove the legacy <appRoot>/autolinked/ tree
  // and any stale in-source `Package.swift` / `include/<Name>/` from the
  // prior in-place layout (those files lived in user source dirs and have
  // been replaced by the wrapper layout under outputDir).
  const legacyAutolinkedDir = path.join(appRoot, 'autolinked');
  if (
    fs.existsSync(legacyAutolinkedDir) &&
    path.resolve(legacyAutolinkedDir) !== path.resolve(outputDir)
  ) {
    fs.rmSync(legacyAutolinkedDir, {recursive: true, force: true});
    log(`Removed legacy autolinked/ tree`);
  }
  for (const absSource of entryAbsDirs.values()) {
    const legacyPkg = path.join(absSource, 'Package.swift');
    let removedLegacyPkg = false;
    try {
      const content = fs.readFileSync(legacyPkg, 'utf8');
      if (content.includes(AUTOGEN_MARKER)) {
        fs.unlinkSync(legacyPkg);
        removedLegacyPkg = true;
        log(
          `Removed legacy in-place synth: ${path.relative(appRoot, legacyPkg)}`,
        );
      }
    } catch {
      // not present – fine
    }
    // Only treat include/ as a legacy leftover when the in-place synth
    // manifest was just removed alongside it: a SCAFFOLDED dep legitimately
    // owns include/<SwiftName>/ (the namespaced shim headers behind its
    // publicHeadersPath: "include") — nuking it unconditionally would break
    // every dependent's `#import <SwiftName/Header.h>` on the next sync.
    if (removedLegacyPkg) {
      const legacyInclude = path.join(absSource, 'include');
      try {
        if (fs.lstatSync(legacyInclude).isDirectory()) {
          fs.rmSync(legacyInclude, {recursive: true, force: true});
          log(
            `Removed legacy in-place include/: ${path.relative(appRoot, legacyInclude)}`,
          );
        }
      } catch {
        // not present – fine
      }
    }
  }
}

if (require.main === module) {
  try {
    main();
  } catch (e) {
    if (e instanceof RemoteVersionError) {
      log(e.message);
      process.exitCode = 2;
    } else {
      throw e;
    }
  }
}

module.exports = {
  main,
  autolinkingDepToSpmTarget,
  generateAutolinkedPackageSwift,
  generateSynthPackageSwift,
  reactDescriptor,
  linkHeaderTree,
  collectSpmSources,
  expandSpmSourceGlobs,
  findSelfManagedPackageDir,
  hasPodspec,
  hasMixedLanguageSources,
  readDenyPluginsFromConfig,
  MissingManifestError,
  reportMissingManifests,
  AUTOGEN_MARKER,
};