UNPKG

react-native

Version:

A framework for building native apps using React

1,415 lines • 56.5 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 {
  AutolinkedDep,
  AutolinkingIosPlatform,
  PodspecModel,
  ScaffoldResult,
  SpmScaffoldSpec,
} from './spm-types';
*/

/**
 * scaffold-package-swift.js — generates a `Package.swift` into
 * `node_modules/<dep>/` for community RN libraries that don't ship SPM
 * support. The autolinker's existing `isSelfManagedPackage` check
 * (generate-spm-autolinking.js, AUTOGEN_MARKER) treats these scaffolded
 * files as self-managed and references them directly from the aggregator —
 * no autolinker changes required.
 *
 * Three layers:
 *   - translatePodspecToSpmTarget(model, dep): pure translation
 *   - emitScaffoldedPackageSwift(spec, ctx): pure Swift emission
 *   - scaffoldPackageSwiftForDep(dep, ctx): I/O + skip-rule + write
 *   - scaffoldAll(opts): orchestrator over autolinking.json
 */

const {
  SpmNameCollisionError,
  defaultReadConfig,
  defaultReadPodspec,
  defaultResolveDep,
  expandSpmDependencies,
  resolveSwiftName,
} = require('./expand-spm-dependencies');
const {expandSpmSourceGlobs} = require('./generate-spm-autolinking');
const {MIN_IOS_VERSION_SUPPORTED} = require('./ios-deployment-target');
const {findPodspecs, readPodspecCached} = require('./read-podspec');
const {
  REACT_CODEGEN_PACKAGE_NAME,
  REACT_CODEGEN_PRODUCTS,
  REACT_NATIVE_PACKAGE_NAME,
  REACT_NATIVE_PRODUCTS,
  SCAFFOLDER_MARKER,
  makeLogger,
  remotePackageConfig,
} = require('./spm-utils');
const {readSwiftpmConfig, writeSwiftpmName} = require('./swiftpm-config');
const fs = require('node:fs');
const path = require('node:path');

const {log, warn} = makeLogger('scaffold-package-swift');

// SCAFFOLDER_MARKER lives in spm-utils.js (shared, no import cycle). It must NOT
// contain the autolinker's AUTOGEN_MARKER ('// AUTO-GENERATED by
// scripts/generate-spm-autolinking.js') — that marker is what the autolinker
// uses to tell self-managed deps from its own synth output. Our marker is
// recognized by the scaffolder itself (skip-rule), but invisible to the
// autolinker (treated as self-managed).

// Bump when the emitter's output format changes in a way that requires
// re-scaffolding existing files (rather than just being a content drift the
// cache-slot label already handles). v2: swift-tools-version moved to
// line 1 — pre-v2 scaffolds emit it on a later line, which recent Xcode
// rejects with "Swift tools version 3.1.0 ... no longer supported". v4: the
// single `let rnHeaders = appRoot + "/.../ReactHeadersAll"` split into the two
// `rnCoreHeaders` / `appHeaders` lets (merged tree replaced by two trees), so
// pre-v4 scaffolds reference a tree that is no longer materialized. v5:
// header resolution moved to product dependencies (ReactNativeHeaders +
// ReactAppHeaders binary/headers targets) — the rnCoreHeaders/appHeaders
// trees no longer exist, so pre-v5 scaffolds carry dead lets and would break
// if anything still referenced them. v7: the runtime appRoot walker and the
// siblingPath helper are gone — package paths are plain relative strings
// computed at scaffold time (the walker anchored on
// build/xcframeworks/Package.swift, which remote mode no longer writes).
//
// Skip-rule contract: when an existing file's version is < this constant,
// the scaffolder regenerates regardless of --force (the bump implies the
// existing file is broken under current tooling). A file with the marker
// but no version line is treated as v1.
// v8: relative app paths (codegen / xcframeworks) are now computed from the
// autolinker's libs/<SwiftName> symlink location instead of the real dep.root,
// fixing a doubled-path resolution failure on fresh SwiftPM resolves.
// v9: header search paths derived from each subspec's header_mappings_dir
// (dirname) so namespaced includes (`<reanimated/...>`) resolve. v10:
// publicHeadersPath derived from the header_mappings_dir namespace root so a
// package exposes `<namespace/...>` to dependents; pod-style sibling deps
// (reanimated's `s.dependency "RNWorklets"`) wired to their npm package. v11:
// sibling .package path uses the libs/<SwiftName> symlink name, not the npm
// name (fixes "package ... doesn't exist" on resolve). v12: preprocessor
// defines from pod_target_xcconfig emitted as `.define(...)`. v13: ObjC(++)
// targets get an ambient-import prefix header (Foundation/UIKit) `-include`d,
// replacing CocoaPods' generated prefix.pch. v18: React-core deps also get
// the ReactNativeDependenciesHeaders product (ReactNativeHeaders went
// pure-RN; folly/glog/boost/... now come from the deps sidecar);
// publicHeadersPath falls back to "." for root-level-source podspecs
// (`s.source_files = "*.{h,m,mm}"`) instead of SPM's nonexistent `include/`.
// v19: scaffolded C++ targets carry DEBUG/NDEBUG config defines so their Fabric
// ABI matches the prebuilt React.framework (Release strips DebugStringConvertible
// under NDEBUG). Bumped so existing scaffolds regenerate with the defines.
// v20: the platform floor is the app's iOS deployment target in string form
// (the `.v15` enum cannot express a dependency minimum like 16.4).
const SCAFFOLDER_VERSION = 20;
const SCAFFOLDER_VERSION_LINE_RE = /^\/\/ AUTO-SCAFFOLDED-VERSION: (\d+)$/m;

const AUTOGEN_MARKER =
  '// AUTO-GENERATED by scripts/generate-spm-autolinking.js';

// CocoaPods auto-generates a `<Target>-prefix.pch` that imports Foundation +
// UIKit into every ObjC translation unit, so pod sources can use NSThread /
// dispatch / UIKit symbols without an explicit import. SPM has no prefix-header
// mechanism, so we emit this file at the dep root and `-include` it on the
// target (cSettings + cxxSettings) to reproduce that ambient import. The
// `__OBJC__` guard makes it inert for plain C/C++ sources, and UIKit is
// `__has_include`-guarded for platforms that lack it.
// The config-gated C++ defines the prebuilt React.framework is built with (see
// packages/react-native/Package.swift). Fabric C++ in autolinked deps must
// compile with the SAME NDEBUG state or its DebugStringConvertible / ShadowNode
// ABI diverges from the Release React.framework and the link fails.
const REACT_CXX_CONFIG_DEFINES = [
  '.define("DEBUG", .when(configuration: .debug))',
  '.define("NDEBUG", .when(configuration: .release))',
];

const SCAFFOLD_PREFIX_HEADER = 'react-native-spm-prefix.h';
const SCAFFOLD_PREFIX_HEADER_CONTENTS = `// AUTO-SCAFFOLDED by react-native spm scaffold — mirrors CocoaPods' default
// prefix header so ObjC sources that rely on an implicit Foundation/UIKit
// import compile under SPM (which has no prefix-header mechanism). Safe to
// delete + regenerate via \`npx react-native spm scaffold\`.
#ifdef __OBJC__
#import <Foundation/Foundation.h>
#if __has_include(<UIKit/UIKit.h>)
#import <UIKit/UIKit.h>
#endif
#endif
`;

// Names of deps the scaffolder always refuses to touch — `react-native`
// itself is handled by the xcframework subpackage, never as an autolinked
// target.
const NEVER_SCAFFOLD /*: ReadonlySet<string> */ = new Set(['react-native']);

// React-core / React-Fabric / etc. dependency names collapse to a single
// invariant React header-product set. The app target links the prebuilt
// runtime frameworks outside SwiftPM. This
// list matches what `install_modules_dependencies(s)` materializes plus
// common podspec hand-rolled additions.
const REACT_CORE_DEP_PREFIXES = [
  'React-',
  'React_',
  'ReactCommon',
  'RCT-Folly',
  'RCT',
  'glog',
  'boost',
  'fmt',
  'DoubleConversion',
  'Yoga',
  'hermes-engine',
];

function isReactCoreDep(name /*: string */) /*: boolean */ {
  return REACT_CORE_DEP_PREFIXES.some(p => name.startsWith(p));
}

// True when the dep ships a `codegenConfig` in package.json — RN's standard
// marker that the library participates in the New Architecture / codegen. Such
// a lib's Fabric sources include the app-generated component headers
// (`<react/renderer/components/<name>/ShadowNodes.h>` etc.), which are vended by
// the per-app React-GeneratedCode package — so it implicitly depends on React
// core even when its podspec only wires that via `install_modules_dependencies`
// (which we strip). Safe/quiet: a missing or unparseable package.json → false.
function depHasCodegenConfig(depRoot /*: string */) /*: boolean */ {
  try {
    const pkg = JSON.parse(
      fs.readFileSync(path.join(depRoot, 'package.json'), 'utf8'),
    );
    return pkg != null && pkg.codegenConfig != null;
  } catch {
    return false;
  }
}

// Every subdirectory (relative to depRoot) under depRoot/base, recursively —
// used to expand a CocoaPods `path/**` recursive header search path into the
// concrete dirs SPM needs (SPM has no recursive search-path syntax). Skips
// VCS / build / dependency noise.
function collectSubdirs(
  depRoot /*: string */,
  base /*: string */,
) /*: Array<string> */ {
  const SKIP /*: Set<string> */ = new Set([
    'node_modules',
    'Pods',
    'build',
    '.git',
  ]);
  const out /*: Array<string> */ = [];
  const walk = (absDir /*: string */, relDir /*: string */) => {
    let entries /*: Array<{name: string, isDirectory(): boolean}> */ = [];
    try {
      // $FlowFixMe[incompatible-type] Dirent typing
      entries = fs.readdirSync(absDir, {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() || name.startsWith('.') || SKIP.has(name)) continue;
      const rel = relDir === '.' ? name : `${relDir}/${name}`;
      out.push(rel);
      walk(path.join(absDir, name), rel);
    }
  };
  walk(path.join(depRoot, base), base === '.' ? '.' : base);
  return out;
}

// ---------------------------------------------------------------------------
// Translation
// ---------------------------------------------------------------------------

/**
 * Pure: turns a flattened PodspecModel into an SpmScaffoldSpec the emitter
 * can consume. Substitutes Xcode build-setting tokens (`$(PODS_TARGET_SRCROOT)`)
 * against the dep root, buckets dependencies, drops unknown tokens with a
 * warning.
 */
function translatePodspecToSpmTarget(
  model /*: PodspecModel */,
  dep /*: AutolinkedDep */,
  // Maps a podspec name (e.g. "RNWorklets") to the npm package name of an
  // autolinked sibling (e.g. "react-native-worklets"). Lets us wire a
  // `s.dependency "RNWorklets"` — a pod-style name the `react-native-*`
  // heuristic can't recognize — to the right sibling package. Empty by default.
  podToNpm /*: Map<string, string> */ = new Map(),
  // npm name → resolved Swift name for every autolinked dep, so a sibling
  // reference honors that sibling's `spm.name` instead of re-deriving it.
  swiftNameByNpm /*: Map<string, string> */ = new Map(),
) /*: SpmScaffoldSpec */ {
  const warnings = [...model.warnings];

  // Whatever the autolinker resolved (see resolveSwiftName). It registers the
  // dep under that name in its aggregator and in any sibling spm.dependencies
  // refs, so the scaffolded product/library name must match it exactly —
  // otherwise SPM fails to resolve `.product(name: "X", package: "X")`.
  const swiftName = dep.swiftName;
  if (swiftName == null) {
    throw new Error(
      `react-native spm scaffold: no resolved Swift name for '${dep.name}'. expandSpmDependencies must resolve every dep's name before a manifest is written.`,
    );
  }

  // Header search paths — substitute Xcode build-setting tokens against the
  // dep root. Anything we can't substitute is dropped + warned (avoids
  // emitting `$(SOMETHING)` literally into the Swift file).
  const headerSearchPaths /*: Array<string> */ = [];
  const addSearchPath = (p /*: string */) => {
    if (p.length > 0 && !headerSearchPaths.includes(p)) {
      headerSearchPaths.push(p);
    }
  };
  for (const raw of model.headerSearchPaths) {
    let substituted = raw
      .replace(/\$\(PODS_TARGET_SRCROOT\)/g, '.')
      .replace(/\$\{PODS_TARGET_SRCROOT\}/g, '.');
    if (/\$[({]/.test(substituted)) {
      warnings.push(
        `Dropped HEADER_SEARCH_PATHS entry "${raw}" — contains unresolved Xcode token. ` +
          `Add it manually if needed.`,
      );
      continue;
    }
    // CocoaPods `path/**` (or `/*`) = recursive search. SPM has no recursive
    // search-path syntax, so add the base dir + every subdirectory under it.
    const recursive = /\/\*\*?$/.test(substituted);
    substituted = substituted
      .replace(/\/\*\*?$/, '') // strip the glob marker
      .replace(/\/{2,}/g, '/'); // collapse `cpp//` → `cpp/`
    // Strip leading "./" (emitter prefixes with the target path) + trailing "/".
    const base = substituted
      .replace(/^\.\//, '')
      .replace(/^\//, '')
      .replace(/\/$/, '');
    addSearchPath(base === '' ? '.' : base);
    if (recursive) {
      for (const sub of collectSubdirs(dep.root, base === '' ? '.' : base)) {
        addSearchPath(sub);
      }
    }
  }

  // header_mappings_dir → search path. CocoaPods exposes a subspec's headers
  // under `<basename(mappings_dir)>/...` by copying them into Pods/Headers
  // preserving structure relative to the mappings dir. SPM has no such copy
  // step, so namespaced includes like `<reanimated/apple/sensor/X.h>` (header
  // physically at `apple/reanimated/apple/sensor/X.h`, mappings dir
  // `apple/reanimated`) only resolve if the mappings dir's PARENT (`apple`) is
  // on the search path. Add dirname() of every subspec's mappings dir.
  for (const mappingsDir of model.headerMappingsDirs) {
    const parent = path.posix.dirname(mappingsDir.replace(/^\.\//, ''));
    // dirname of a single-segment dir is "." (root) — already implicitly
    // searched; skip it and anything that doesn't exist on disk.
    if (
      parent.length > 0 &&
      parent !== '.' &&
      !headerSearchPaths.includes(parent) &&
      fs.existsSync(path.join(dep.root, parent))
    ) {
      headerSearchPaths.push(parent);
    }
  }

  // Bucket dependencies. React-Core / React-jsi / RCT-Folly / glog etc. ALL
  // collapse to the invariant React header products
  // reference because they're bundled in the prebuilt React.xcframework.
  // Sibling RN libs (autolinked or self-managed deps) flow through the
  // existing spm-deps mechanism. Unknown names are dropped with a warning.
  let coreReactNative = false;
  const siblingNames /*: Array<string> */ = [];
  const selfPodspecName = model.name;
  for (const depName of model.dependencies) {
    // Cross-subspec refs like "react-native-safe-area-context/common" are a
    // CocoaPods construct for one subspec depending on another from the
    // SAME podspec. After flattenSubspecs has merged everything into one
    // target the subspec ref is meaningless — drop silently.
    if (depName.includes('/') && depName.startsWith(`${selfPodspecName}/`)) {
      continue;
    }
    const podSibling = podToNpm.get(depName.split('/')[0]);
    if (isReactCoreDep(depName)) {
      coreReactNative = true;
    } else if (depName.startsWith('react-native-')) {
      // Generic cross-subspec ref guard for any "package/subspec" form.
      const baseName = depName.split('/')[0];
      if (!siblingNames.includes(baseName)) {
        siblingNames.push(baseName);
      }
    } else if (podSibling != null && podSibling !== dep.name) {
      // A pod-style dependency name (e.g. reanimated's `s.dependency
      // "RNWorklets"`) that resolves to an autolinked sibling's npm package
      // (react-native-worklets). Wire it as a sibling .package/.product so
      // the dep's cross-package includes (`<worklets/...>`) resolve.
      if (!siblingNames.includes(podSibling)) {
        siblingNames.push(podSibling);
      }
    } else {
      // Could be a non-RN dep ("MMKV", "AFNetworking"). The scaffolder
      // doesn't know how to wire those — surface a warning so user can
      // edit the generated Package.swift.
      warnings.push(
        `Unknown dependency "${depName}" — not wired into the scaffolded Package.swift. Edit manually if needed.`,
      );
    }
  }

  // New-Architecture libraries declare their React-core dependency via the
  // `install_modules_dependencies(s)` podspec helper (which auto-adds
  // React-Core / React-RCTFabric / React-Codegen), NOT an explicit
  // `s.dependency "React-Core"`. We strip that helper when evaluating the
  // podspec, so the React-core dependency never surfaces in model.dependencies
  // and `coreReactNative` would stay false. The authoritative, RN-standard
  // marker for "this lib participates in the New Architecture / codegen" is a
  // `codegenConfig` block in package.json — when present, the app's codegen has
  // generated `react/renderer/components/<name>/{ShadowNodes,Props,…}.h` that
  // the lib's Fabric sources include via angle brackets. Those generated
  // headers live in the per-app React-GeneratedCode package, so the lib must
  // depend on it (and, transitively, on React core). Treat codegenConfig as
  // an implicit React-core dependency.
  // `codegenConfig` only marks Fabric/New-Arch libraries. A plain ObjC module
  // (e.g. rn-tester's TestLibrary*) that wires React core solely through
  // `install_modules_dependencies(s)` has no codegenConfig — but the helper's
  // presence is itself an authoritative "depends on React core" signal, so
  // treat it the same way.
  if (
    !coreReactNative &&
    (depHasCodegenConfig(dep.root) ||
      model.usesInstallModulesDependencies === true)
  ) {
    coreReactNative = true;
  }

  // SPM's `sources:` field does NOT accept CocoaPods-style globs — it wants
  // a list of explicit file paths (relative to the target's `path:`).
  // Expand the podspec globs against the actual filesystem so the emitted
  // Package.swift parses cleanly.
  let expandedSources /*: Array<string> */ = [];
  try {
    expandedSources = expandSpmSourceGlobs(dep.root, model.sourceFiles);
  } catch (e) {
    warnings.push(
      `Source glob expansion failed (${e.message}); emitting raw globs — SPM will likely reject them.`,
    );
    expandedSources = model.sourceFiles;
  }
  // Strip files matching exclude_files (podspec's negation list).
  if (model.excludeFiles.length > 0) {
    try {
      // $FlowFixMe[untyped-import] micromatch has no types
      const micromatch = require('micromatch');
      const isExcluded = micromatch.matcher(model.excludeFiles);
      expandedSources = expandedSources.filter(f => !isExcluded(f));
    } catch (e) {
      warnings.push(
        `exclude_files filtering failed (${e.message}); keeping all sources.`,
      );
    }
  }
  // Never self-ingest scaffold artifacts: a re-scaffold runs with the
  // previous run's output on disk, and root-level podspec globs sweep it
  // into sources — `*.{h,...,swift}` matches Package.swift itself, whose
  // `.swift` extension then trips the mixed-language gate into DELETING the
  // manifest it just wrote (alternating scaffold/self-destruct runs); the
  // prefix header and the include/ shim mirror would similarly feed back.
  expandedSources = expandedSources.filter(
    f =>
      f !== SCAFFOLD_PREFIX_HEADER &&
      f !== 'Package.swift' &&
      !f.startsWith('include/'),
  );

  // Header-map emulation: CocoaPods builds a header map (USE_HEADERMAP=YES by
  // default) so a source can `#import "Foo.h"` by bare name regardless of which
  // subdirectory Foo.h lives in. SPM has no header map, so add every directory
  // that contains a header to the search path. Covers libs (e.g. svg) that
  // spread flat-named headers across many subdirs.
  const headerFiles = expandedSources.filter(f => /\.(h|hh|hpp)$/i.test(f));
  try {
    for (const f of expandSpmSourceGlobs(dep.root, model.publicHeaderFiles)) {
      if (/\.(h|hh|hpp)$/i.test(f)) headerFiles.push(f);
    }
  } catch {
    // public_header_files globbing is best-effort — source_files usually
    // already covers the headers.
  }
  for (const f of headerFiles) {
    const d = path.posix.dirname(f);
    addSearchPath(d === '' ? '.' : d);
  }

  // ObjC(++) sources may rely on CocoaPods' implicit prefix-header import of
  // Foundation/UIKit. When present, emit + `-include` a prefix header (below).
  // The prefix is `-include`d by bare name, so the dep root (".") must be on
  // the header search path for clang to find it.
  const needsObjCPrefix = expandedSources.some(f => /\.mm?$/.test(f));
  if (needsObjCPrefix && !headerSearchPaths.includes('.')) {
    headerSearchPaths.push('.');
  }

  // SPM REQUIRES publicHeadersPath to be a real directory inside the target
  // when the target compiles C-family sources (default is "include" which
  // typically doesn't exist in a podspec-shaped dep). Pick the first
  // existing prefix dir from public_header_files / source_files. The
  // chosen dir doesn't need to be the include surface — path-style
  // includes (`<react/renderer/.../X.h>`) resolve via headerSearchPaths
  // instead. publicHeadersPath just has to point at a real dir containing
  // some .h files so SPM accepts the target definition.
  let publicHeadersPath /*: ?string */ = null;
  // Prefer the namespace root (parent of a header_mappings_dir). SPM propagates
  // a target's publicHeadersPath to DEPENDENT packages as a search path, so
  // setting it to e.g. `Common/cpp` (parent of `Common/cpp/worklets`) is what
  // lets a sibling package resolve `<worklets/...>`. Prefer a cross-platform
  // (Common) dir — that holds the C++ API siblings consume — over a
  // platform-specific (apple/) one.
  const mappingsParents = model.headerMappingsDirs
    .map(d => path.posix.dirname(d.replace(/^\.\//, '')))
    .filter(
      p => p.length > 0 && p !== '.' && fs.existsSync(path.join(dep.root, p)),
    );
  if (mappingsParents.length > 0) {
    publicHeadersPath =
      mappingsParents.find(p => /(?:^|\/)common(?:\/|$)/i.test(p)) ??
      mappingsParents[0];
  }
  if (publicHeadersPath == null) {
    for (const glob of [...model.publicHeaderFiles, ...model.sourceFiles]) {
      const prefix = glob.split('/')[0];
      if (
        prefix.length > 0 &&
        !prefix.includes('*') &&
        fs.existsSync(path.join(dep.root, prefix))
      ) {
        publicHeadersPath = prefix;
        break;
      }
    }
  }
  let namespacedShimHeaders /*: Array<string> */ = [];
  if (publicHeadersPath == null) {
    // Root-level podspecs (`s.source_files = "*.{h,m,mm}"`) keep their
    // headers in the package root. CocoaPods exposes them to dependents as
    // `#import <PodName/Header.h>` via header maps; SPM has no equivalent, so
    // the scaffold writer mirrors them into a generated include/<SwiftName>/
    // shim dir and publicHeadersPath points there (also fixing SPM's
    // rejection over the nonexistent default `include/`).
    const rootHeaders = expandedSources.filter(
      f => f.endsWith('.h') && !f.includes('/'),
    );
    if (rootHeaders.length > 0) {
      namespacedShimHeaders = rootHeaders;
      publicHeadersPath = 'include';
    }
  }
  if (publicHeadersPath == null) {
    warnings.push(
      `Could not infer publicHeadersPath from source globs. SPM may reject the target — add a publicHeadersPath manually to the scaffolded Package.swift.`,
    );
  }

  const siblingSwiftNames /*: {[npmName: string]: string} */ = {};
  for (const npmName of siblingNames) {
    const resolved = swiftNameByNpm.get(npmName);
    if (resolved != null) {
      siblingSwiftNames[npmName] = resolved;
    }
  }

  return {
    swiftName,
    sources: expandedSources,
    headerSearchPaths,
    preprocessorDefines: model.preprocessorDefines,
    needsObjCPrefix,
    coreReactNative,
    siblingNames,
    siblingSwiftNames,
    extraFrameworks: model.frameworks,
    weakFrameworks: model.weakFrameworks,
    compilerFlags: model.compilerFlags,
    publicHeadersPath,
    namespacedShimHeaders,
    resources: model.resources,
    warnings,
  };
}

// ---------------------------------------------------------------------------
// Swift emission
// ---------------------------------------------------------------------------

/*::
type EmitContext = {
  // Identifier embedded as a comment so SPM's manifest hash bumps when the
  // active xcframework slot changes (otherwise cached evaluations would
  // keep pointing at the prior slot's headers).
  cacheSlotLabel: ?string,
  // Remote SPM package mode (url/version/identity) — see remotePackageConfig.
  remote?: ?{url: string, version: string, identity: string},
  // Relative path (posix, from the dep's package dir) to the app's codegen
  // package (<appRoot>/build/generated/ios). Computed at scaffold time —
  // safe because the file is re-scaffolded per app/cache slot, and any
  // node_modules relayout implies a reinstall that drops the file anyway.
  codegenPackageDir?: ?string,
  // Relative path to the app's local xcframeworks package
  // (<appRoot>/build/xcframeworks). Only referenced when remote == null.
  localXcfwPackageDir?: ?string,
  iosDeploymentTarget?: ?string,
};
*/

/**
 * Renders the SpmScaffoldSpec to a complete Package.swift string suitable
 * for writing into `node_modules/<dep>/`. Fully declarative: all package
 * references are plain relative paths computed at scaffold time (no runtime
 * discovery — the scaffolder knows both the dep dir and the app root, and
 * re-scaffolds whenever either could have moved).
 */
function emitScaffoldedPackageSwift(
  spec /*: SpmScaffoldSpec */,
  ctx /*:: ?: EmitContext */ = {cacheSlotLabel: null, remote: null},
) /*: string */ {
  const slotComment =
    ctx.cacheSlotLabel != null ? `\n// Cache slot: ${ctx.cacheSlotLabel}` : '';
  const iosDeploymentTarget /*: string */ =
    ctx.iosDeploymentTarget ?? MIN_IOS_VERSION_SUPPORTED;

  // React headers need NO search paths — they come from the React /
  // ReactNativeHeaders binaryTargets and the ReactAppHeaders product (see
  // the dependencies block below). Per-dep HEADER_SEARCH_PATHS from the
  // podspec are emitted as `.headerSearchPath("<rel>")` so they resolve
  // relative to `path:` ("." below). Custom podspec compiler flags keep
  // using unsafeFlags (rare; surfaced verbatim).
  const customFlagsCxx = spec.compilerFlags.map(f => `"${f}"`);

  const headerSearchPathDirectives = spec.headerSearchPaths
    .map(p => `.headerSearchPath("${p}")`)
    .join(', ');

  // Preprocessor defines → `.define("NAME", to: "VALUE", .when(...))`. The
  // value is escaped as a Swift string literal because it may itself contain
  // quotes (e.g. a string-literal macro `-DFOO="[A:false]"`).
  const swiftStr = (s /*: string */) =>
    `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
  const defineDirectives = (spec.preprocessorDefines ?? [])
    .map(d => {
      const toPart = d.value != null ? `, to: ${swiftStr(d.value)}` : '';
      const condPart =
        d.config === 'debug'
          ? ', .when(configuration: .debug)'
          : d.config === 'release'
            ? ', .when(configuration: .release)'
            : '';
      return `.define(${swiftStr(d.name)}${toPart}${condPart})`;
    })
    .join(', ');

  const settingsEntries = (extra /*: Array<string> */) => {
    const parts = [
      defineDirectives,
      headerSearchPathDirectives,
      ...extra,
    ].filter(e => e.length > 0);
    return `[${parts.join(', ')}]`;
  };
  // Force-include the ambient-import prefix header on ObjC(++) sources (the
  // `__OBJC__` guard makes it a no-op for plain C/C++). `-include` resolves the
  // bare name via the dep-root header search path (".", ensured in translate).
  const prefixFlag = spec.needsObjCPrefix
    ? `.unsafeFlags(["-include", "${SCAFFOLD_PREFIX_HEADER}"])`
    : '';
  const cSettings = settingsEntries([prefixFlag].filter(e => e.length > 0));
  const cxxSettings = settingsEntries(
    [
      prefixFlag,
      ...(customFlagsCxx.length > 0
        ? [`.unsafeFlags([${customFlagsCxx.join(', ')}])`]
        : []),
      // Match the prebuilt React.framework's config-gated C++ ABI. NDEBUG in
      // Release strips DebugStringConvertible's vtable (and shifts the
      // ShadowNode layout that inherits it), so a Fabric C++ target compiled
      // WITHOUT NDEBUG fails to link against a Release React.framework. Mirrors
      // packages/react-native/Package.swift's own C++ targets.
      ...REACT_CXX_CONFIG_DEFINES,
    ].filter(e => e.length > 0),
  );

  // Linker frameworks: defaults + podspec-declared extras + weak frameworks.
  // Dedup on render so the user doesn't see duplicate UIKit lines.
  const defaults = ['UIKit', 'Foundation', 'CoreGraphics'];
  const linkedFrameworks = Array.from(
    new Set([...defaults, ...spec.extraFrameworks]),
  );
  const linkerEntries = [
    ...linkedFrameworks.map(f => `.linkedFramework("${f}")`),
    ...spec.weakFrameworks.map(
      f => `.linkedFramework("${f}", .when(platforms: [.iOS]))`,
    ),
  ];

  // Dependencies block. Always declares ReactNative if any React-core dep
  // was in the podspec; sibling RN deps come from autolinking's existing
  // .package(path: ...) graph, under the names the autolinker resolved.
  const packageDeps /*: Array<string> */ = [];
  const targetDeps /*: Array<string> */ = [];
  if (spec.coreReactNative) {
    // Remote mode: React Native comes from the remote package identity (no
    // app-layout knowledge). The per-app codegen package is generated INTO
    // the app by definition, so it stays a path reference — relative,
    // computed at scaffold time.
    const remote = ctx.remote;
    const rnLabel =
      remote != null ? remote.identity : REACT_NATIVE_PACKAGE_NAME;
    const codegenDir = ctx.codegenPackageDir;
    if (codegenDir == null) {
      throw new Error(
        'emitScaffoldedPackageSwift: codegenPackageDir is required when the dep depends on React core.',
      );
    }
    if (remote != null) {
      packageDeps.push(
        `.package(url: "${remote.url}", exact: "${remote.version}")`,
      );
    } else {
      const xcfwDir = ctx.localXcfwPackageDir;
      if (xcfwDir == null) {
        throw new Error(
          'emitScaffoldedPackageSwift: localXcfwPackageDir is required when no remote package is configured.',
        );
      }
      packageDeps.push(
        `.package(name: "${REACT_NATIVE_PACKAGE_NAME}", path: "${xcfwDir}")`,
      );
    }
    packageDeps.push(
      `.package(name: "${REACT_CODEGEN_PACKAGE_NAME}", path: "${codegenDir}")`,
    );
    for (const product of REACT_NATIVE_PRODUCTS) {
      targetDeps.push(`.product(name: "${product}", package: "${rnLabel}")`);
    }
    for (const product of REACT_CODEGEN_PRODUCTS) {
      targetDeps.push(
        `.product(name: "${product}", package: "${REACT_CODEGEN_PACKAGE_NAME}")`,
      );
    }
  }
  for (const siblingName of spec.siblingNames) {
    const swiftSibling = spec.siblingSwiftNames?.[siblingName];
    if (swiftSibling == null) {
      throw new Error(
        `react-native spm scaffold: no resolved Swift name for the sibling '${siblingName}'. expandSpmDependencies must resolve every dep's name before a manifest is written.`,
      );
    }
    // The autolinker references each self-managed (scaffolded) dep through a
    // `libs/<SwiftName>` symlink, and SPM resolves a manifest's relative
    // package paths against that symlink location — so a sibling lives at
    // `../<SwiftName>` (NOT `../<npm-name>`, which would be `libs/<npm-name>`
    // and not exist).
    packageDeps.push(
      `.package(name: "${swiftSibling}", path: "../${swiftSibling}")`,
    );
    targetDeps.push(
      `.product(name: "${swiftSibling}", package: "${swiftSibling}")`,
    );
  }

  // sources: emit only when the podspec declared them. If empty (regex-fallback
  // podspec), no `sources:`/`exclude:` is emitted and SPM auto-scans the target
  // dir — which for `path: "."` is the entire dep root. That is a known
  // limitation of the regex-fallback path (the pod-ipc parse yields explicit
  // sources); a future improvement could emit an `exclude:` list for the dep's
  // example/test/build dirs.
  const sourcesLine =
    spec.sources.length > 0
      ? `\n            sources: [\n${spec.sources.map(s => `                "${s}",`).join('\n')}\n            ],`
      : '';

  const publicHeadersLine =
    spec.publicHeadersPath != null
      ? `\n            publicHeadersPath: "${spec.publicHeadersPath}",`
      : '';

  const resourcesLine =
    spec.resources.length > 0
      ? `\n            resources: [${spec.resources.map(r => `.copy("${r}")`).join(', ')}],`
      : '';

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

  const targetDepsLine =
    targetDeps.length > 0
      ? `\n            dependencies: [${targetDeps.join(', ')}],`
      : '';

  return `// swift-tools-version: 6.0
${SCAFFOLDER_MARKER}
// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}${slotComment}
// Edit the contents below if needed and re-run \`npx patch-package <dep-name>\`
// to persist across \`npm install\`. To regenerate from the podspec, remove
// this file (or just this marker) and re-run \`npx react-native spm scaffold\`.
//
// Package references are plain relative paths, computed when this file was
// scaffolded. They stay correct because the file is re-scaffolded per app
// and cache slot, and any node_modules relayout reinstalls this package
// (dropping the file) anyway.

import PackageDescription

let package = Package(
    name: "${spec.swiftName}",
    platforms: [.iOS("${iosDeploymentTarget}")],
    products: [
        .library(name: "${spec.swiftName}", targets: ["${spec.swiftName}"]),
    ],
${packageDepsBlock}    targets: [
        .target(
            name: "${spec.swiftName}",${targetDepsLine}
            path: ".",${sourcesLine}${publicHeadersLine}${resourcesLine}
            cSettings: ${cSettings},
            cxxSettings: ${cxxSettings},
            linkerSettings: [${linkerEntries.join(', ')}]
        ),
    ],
    cxxLanguageStandard: .cxx20
)
`;
}

const PODSPEC_KEY_ORIGINS = {
  header_dir: "its podspec's 'header_dir'",
  module_name: "its podspec's 'module_name'",
  name: 'its pod name',
};

// Null when the caller resolved the name without recording where it came from,
// since the alternative is inventing an origin.
function swiftNameOrigin(dep /*: AutolinkedDep */) /*: ?string */ {
  switch (dep.swiftNameSource) {
    case 'config':
      return "its declared 'swiftpmConfig.name'";
    case 'podspec':
      return PODSPEC_KEY_ORIGINS[dep.swiftNamePodspecKey ?? 'name'];
    case 'npm':
      return 'its npm package name';
    default:
      return null;
  }
}

/**
 * Says which SwiftPM name the library got and where that name came from. The
 * name is the prefix every `#import <Name/Header.h>` against the library has to
 * spell, and a podspec-derived one is written into the library's package.json,
 * so it is not a decision to make silently.
 */
function reportSwiftName(
  dep /*: AutolinkedDep */,
  swiftName /*: string */,
  model /*: PodspecModel */,
) /*: void */ {
  const origin = swiftNameOrigin(dep);
  if (origin == null) {
    return;
  }
  log(`${dep.name}: SwiftPM name '${swiftName}' (from ${origin}).`);
  // Two declared namespaces, one name: CocoaPods compiles the module as
  // `module_name` while consumers import through `header_dir`, so a library
  // that declares both loses one of them here.
  const {headerDir, moduleName} = model;
  if (
    dep.swiftNamePodspecKey === 'header_dir' &&
    headerDir != null &&
    moduleName != null &&
    headerDir !== moduleName
  ) {
    warn(
      `${dep.name}'s podspec declares both a 'header_dir' ('${headerDir}') and a 'module_name' ('${moduleName}'): ` +
        `'${swiftName}' is the SwiftPM name, and '${moduleName}' is not used. ` +
        `Set 'swiftpmConfig.name' in its package.json to override that choice.`,
    );
  }
}

// ---------------------------------------------------------------------------
// Per-dep orchestrator
// ---------------------------------------------------------------------------

/*::
type ScaffoldContext = {
  appRoot: string,
  projectRoot: string,
  reactNativeRoot: string,
  // Forces overwrite of files carrying the scaffolder marker. Files
  // WITHOUT the marker (user-edited, upstream-shipped) are never touched.
  force: boolean,
  // When true, no file is written — caller gets the would-be content and
  // can preview.
  dryRun: boolean,
  // Slot label (e.g. "0.87.0-nightly-20260513-6e262624f/debug") embedded as
  // a comment so SPM's manifest hash bumps on slot changes.
  cacheSlotLabel: ?string,
  // podspec-name → npm-name index over all autolinked deps, so pod-style
  // `s.dependency` names (e.g. "RNWorklets") wire to the right sibling.
  podToNpm?: Map<string, string>,
  // npm-name → resolved Swift name over all autolinked deps, so sibling
  // references honor each sibling's declared name.
  swiftNameByNpm?: Map<string, string>,
  remote: ?{url: string, version: string, identity: string},
  iosDeploymentTarget?: ?string,
};
*/

/**
 * Decides whether to scaffold one dep, runs the translation + emission,
 * writes the file (unless dryRun). Pure path/skip logic — all I/O via fs.
 */
function scaffoldPackageSwiftForDep(
  dep /*: AutolinkedDep */,
  ctx /*: ScaffoldContext */,
) /*: ScaffoldResult */ {
  const depName = dep.name;

  if (NEVER_SCAFFOLD.has(depName)) {
    return {
      depName,
      status: 'skipped-is-react-native',
      reason: 'react-native itself is handled by the xcframework package.',
    };
  }

  // ios platform missing → no native code to wrap.
  if (dep.platforms.ios == null) {
    return {
      depName,
      status: 'skipped-no-ios',
      reason: 'autolinking.json has no ios platform entry.',
    };
  }

  // Dep can opt out in its own package.json.
  if (
    readSwiftpmConfig(dep.root, defaultReadConfig(dep.root))?.scaffold === false
  ) {
    return {
      depName,
      status: 'skipped-opt-out',
      reason: "the library sets 'swiftpmConfig.scaffold' to false.",
    };
  }

  const pkgSwiftPath = path.join(dep.root, 'Package.swift');

  // A self-managed Package.swift may live either at the dep root (legacy
  // convention) or inside ios/ (preferred for community RN libs that want
  // their npm-package root free of SPM artifacts). If a nested manifest
  // exists and is user-authored, skip — writing a stray root manifest would
  // cause the autolinker to prefer the wrong file.
  const nestedPkgSwiftPath = path.join(dep.root, 'ios', 'Package.swift');
  if (!fs.existsSync(pkgSwiftPath) && fs.existsSync(nestedPkgSwiftPath)) {
    const nested = fs.readFileSync(nestedPkgSwiftPath, 'utf8');
    if (
      !nested.includes(AUTOGEN_MARKER) &&
      !nested.includes(SCAFFOLDER_MARKER)
    ) {
      return {
        depName,
        status: 'skipped-self-managed',
        reason:
          'Existing ios/Package.swift was not produced by this scaffolder. ' +
          'Leaving it alone.',
      };
    }
  }

  // Skip rules around existing files.
  if (fs.existsSync(pkgSwiftPath)) {
    const existing = fs.readFileSync(pkgSwiftPath, 'utf8');
    if (existing.includes(AUTOGEN_MARKER)) {
      return {
        depName,
        status: 'skipped-autogen',
        reason:
          'Existing Package.swift carries the autolinker AUTOGEN marker. ' +
          'spm update will regenerate the synth wrapper.',
      };
    }
    if (existing.includes(SCAFFOLDER_MARKER)) {
      // Version bump → unconditional regen. A bumped SCAFFOLDER_VERSION
      // implies the emitter's output format changed in a way that requires
      // regenerating existing files; --force is not needed here.
      const vMatch = existing.match(SCAFFOLDER_VERSION_LINE_RE);
      const existingVersion = vMatch != null ? parseInt(vMatch[1], 10) : 1;
      const versionStale = existingVersion < SCAFFOLDER_VERSION;
      if (!ctx.force && !versionStale) {
        // Slot label change is the other auto-regen trigger — bumps SPM's
        // manifest hash so the cache-slot path gets re-resolved.
        const m = existing.match(/^\/\/ Cache slot: (.+)$/m);
        const existingSlot = m != null ? m[1] : null;
        if (ctx.cacheSlotLabel == null || existingSlot === ctx.cacheSlotLabel) {
          return {
            depName,
            status: 'skipped-scaffolder-marker',
            reason:
              'Already scaffolded for this cache slot. Pass --force to regenerate.',
          };
        }
      }
      if (versionStale) {
        log(
          `Re-scaffolding ${depName} (format v${existingVersion} → v${SCAFFOLDER_VERSION})`,
        );
      }
      // Fall through: regenerate (force, slot changed, or version bumped).
    } else {
      // No marker at all → user-managed or upstream-shipped. Never touch.
      return {
        depName,
        status: 'skipped-self-managed',
        reason:
          'Existing Package.swift was not produced by this scaffolder. ' +
          'Leaving it alone.',
      };
    }
  }

  // autolinking.json may have provided podspecPath; otherwise search dep.root.
  // $FlowFixMe[prop-missing] dynamic shape from autolinking.json
  const podspecPath /*: ?string */ =
    dep.platforms.ios.podspecPath ?? findPodspecs(dep.root)[0];
  if (podspecPath == null || !fs.existsSync(podspecPath)) {
    return {
      depName,
      status: 'skipped-no-podspec',
      reason: 'No .podspec found in dep root.',
    };
  }

  let model;
  try {
    model = readPodspecCached(podspecPath);
  } catch (e) {
    return {
      depName,
      status: 'error',
      reason: `Podspec read failed: ${e.message}`,
    };
  }

  const spec = translatePodspecToSpmTarget(
    model,
    dep,
    ctx.podToNpm ?? new Map(),
    ctx.swiftNameByNpm ?? new Map(),
  );

  // Mixed-language fail-closed: SPM can't compile Swift + C-family in one
  // target. Don't emit a manifest that would fail with a cryptic "mixed
  // language source files" resolve error — skip with a clear reason (and remove
  // any stale scaffolded manifest so the autolinker reports it distinctly and
  // points the user at opting it out / a binary distribution).
  const hasSwift = spec.sources.some(f => /\.swift$/i.test(f));
  const hasClang = spec.sources.some(f => /\.(mm?|c|cc|cpp|cxx)$/i.test(f));
  if (hasSwift && hasClang) {
    if (!ctx.dryRun && fs.existsSync(pkgSwiftPath)) {
      const existing = fs.readFileSync(pkgSwiftPath, 'utf8');
      if (existing.includes(SCAFFOLDER_MARKER)) {
        fs.rmSync(pkgSwiftPath, {force: true});
      }
    }
    return {
      depName,
      status: 'skipped-mixed-language',
      reason:
        'has mixed Swift + Objective-C/C++ sources, which SPM cannot compile ' +
        'in one target (and the bidirectional ObjC↔Swift interop typical of ' +
        'such libs cannot be split without a circular dependency). Opt it out ' +
        'via react-native.config.js (platforms.ios = null) or use a prebuilt ' +
        'xcframework.',
    };
  }

  // Relative paths into the app, embedded in the scaffolded Package.swift.
  //
  // The manifest is written to <dep.root>/Package.swift, but the autolinker
  // references the dep through a symlink at
  // <appRoot>/build/generated/autolinking/libs/<SwiftName>. On a fresh resolve
  // SwiftPM interprets a manifest's relative `.package(path:)` entries against
  // that SYMLINK location (it does not canonicalize the symlink first), so the
  // paths must be relative to the symlink dir — NOT the real dep.root.
  // Computing from dep.root produced a doubled path
  // (…/autolinking/ios/build/generated/ios) → opaque "package manifest cannot
  // be accessed" resolution failure. posix separators, as SPM expects.
  const symlinkDir = path.join(
    ctx.appRoot,
    'build',
    'generated',
    'autolinking',
    'libs',
    spec.swiftName,
  );
  const relFromManifest = (...segments /*: Array<string> */) =>
    path
      .relative(symlinkDir, path.join(ctx.appRoot, ...segments))
      .split(path.sep)
      .join('/');
  const content = emitScaffoldedPackageSwift(spec, {
    cacheSlotLabel: ctx.cacheSlotLabel,
    remote: ctx.remote,
    iosDeploymentTarget: ctx.iosDeploymentTarget,
    codegenPackageDir: relFromManifest('build', 'generated', 'ios'),
    localXcfwPackageDir: relFromManifest('build', 'xcframeworks'),
  });

  // Distinguish "first-time scaffold" (no file at all) from "regenerate"
  // (file with the scaffolder marker, e.g. slot changed). Used by the
  // CLI orchestrator to decide whether to prompt for confirmation:
  // first-time scaffolds touch a node_modules dir the user may not expect,
  // regens are silent because we're maintaining a file we already own.
  const previouslyExisted = fs.existsSync(pkgSwiftPath);

  if (!ctx.dryRun) {
    fs.writeFileSync(pkgSwiftPath, content, 'utf8');
    // Emit the ambient-import prefix header next to the manifest when the
    // target has ObjC(++) sources (the manifest `-include`s it by bare name).
    if (spec.needsObjCPrefix) {
      fs.writeFileSync(
        path.join(dep.root, SCAFFOLD_PREFIX_HEADER),
        SCAFFOLD_PREFIX_HEADER_CONTENTS,
        'utf8',
      );
    }
    // Mirror root-level headers into the generated include/<SwiftName>/ shim
    // dir (publicHeadersPath) so dependents can use the CocoaPods header-map
    // spelling `#import <SwiftName/Header.h>`.
    if (spec.namespacedShimHeaders.length > 0) {
      const shimDir = path.join(dep.root, 'include', spec.swiftName);
      fs.mkdirSync(shimDir, {recursive: true});
      for (const header of spec.namespacedShimHeaders) {
        fs.writeFileSync(
          path.join(shimDir, header),
          `// AUTO-SCAFFOLDED by react-native spm scaffold — the namespaced\n` +
            `// spelling (<${spec.swiftName}/${header}>) of the package-root header,\n` +
            `// matching how CocoaPods header maps expose it.\n` +
            `#import "../../${header}"\n`,
          'utf8',
        );
      }
    }
  }

  reportSwiftName(dep, spec.swiftName, model);

  // Migration: record a name we derived from the podspec, so the library can
  // drop the podspec. A declared name is already where it belongs, and a name
  // guessed from the npm name would freeze a guess into someone's config.
  const swiftpmName =
    dep.swiftNameSource === 'podspec'
      ? writeSwiftpmName(dep.root, spec.swiftName, {dryRun: ctx.dryRun})
      : undefined;

  return {
    depName,
    status: 'written',
    packageSwiftPath: pkgSwiftPath,
    warnings: spec.warnings,
    previouslyExisted,
    swiftpmName,
  };
}

/**
 * The dependency set the autolinker considers: the iOS entries of
 * autolinking.json plus their transitive SwiftPM dependencies. Null when the
 * file declares none; throws when it cannot be read or parsed. `onSkipped`
 * reports the entries dropped for having no iOS platform.
 */
function collectAutolinkedDeps(
  opts /*: {
    autolinkingJsonPath: string,
    remote: ?{url: string, version: string, identity: string},
    onSkipped?: (name: string) => void,
  } */,
) /*: ?Array<AutolinkedDep> */ {
  const {autolinkingJsonPath, remote, onSkipped} = opts;
  /*:: type AutolinkingJson = {dependencies?: ?{[string]: {root?: string, platforms?: {ios?: ?{...}, ...}, ...}}, ...}; */
  // $FlowFixMe[incompatible-type] JSON.parse returns any
  const data /*: AutolinkingJson */ = JSON.parse(
    fs.readFileSync(autolinkingJsonPath, 'utf8'),
  );
  const deps = data.dependencies;
  if (deps == null) {
    return null;
  }

  const directDeps /*: Array<AutolinkedDep> */ = [];
  for (const name of Object.keys(deps)) {
    const raw = deps[name];
    if (raw == null) continue;
    const root = raw.root;
    const ios = raw.platforms?.ios;
    if (typeof root !== 'string' || ios == null) {
      onSkipped?.(name);
      continue;
    }
    // $FlowFixMe[incompatible-type] `ios` shape is runtime-validated above
    const iosPlatform /*: AutolinkingIosPlatform */ = ios;
    directDeps.push({name, root, platforms: {ios: iosPlatform}});
  }

  try {
    return expandSpmDependencies(directDeps, {
      readConfig: defaultReadConfig,
      resolveDep: defaultResolveDep,
      readPodspec: defaultReadPodspec,
      extraReservedNames: remote != null ? [remote.identity] : undefined,
    });
  } catch (e) {
    if (e instanceof SpmNameCollisionError) {
      throw e;
    }
    // A transitive-resolution failure shouldn't abort the whole pass; fall back
    // to the direct deps so at least those are covered. They are named the way
    // the autolinker names them — a manifest written under any other name
    // outlives this error on disk and then fails to resolve.
    log(`Transitive dependency expansion failed: ${e.message}`);
    return directDeps.map(dep => {
      const resolved = resolveSwiftName(
        dep.name,
        readSwiftpmConfig(dep.root, defaultReadConfig(dep.root)),
        defaultReadPodspec(dep.root, dep.platforms.ios.podspecPath),
      );
      return {
        ...dep,
        swiftName: resolved.name,
        swiftNameSource: resolved.source,
        swiftNamePodspecKey: resolved.podspecKey,
      };
    });
  }
}

// The `.iOS(...)` element of the emitted platforms array — the `.v15` enum form
// pre-v20 scaffolds carry included, and without the array's closing bracket so
// a user-extended array (`[.iOS(…), .macOS(…)]`) is still matched.
const PLATFORM_FLOOR_RE = /platforms: \[\.iOS\((\.v\d+|"[^"]*")\)/;

/**
 * Bring the platform floor of already-scaffolded manifests up to the app's
 * deployment target — `spm add`/`update` must not leave a dep pinned below the
 * app (SwiftPM would refuse to link it), and re-scaffolding is the user's call.
 * Only the platform line of a file carrying our own marker is rewritten; no
 * file is created. `from` is the previous token, verbatim (`15.1` or `.v15`).
 */
function refreshScaffoldedPlatformFloors(
  opts /*: {appRoot: string, autolinkingJsonPath?: string, iosDeploymentTarget: string} */,
) /*: Array<{depName: string, path: string, from: string, to: string}> */ {
  const {appRoot, iosDeploymentTarget} = opts;
  const autolinkingJsonPath =
    opts.autolinkingJsonPath ??
    path.join(appRoot, 'build', 'generated', 'autolinking', 'autolinking.json');
  const refreshed = [];

  // Best-effort: an unreadable autolinking.json, a name collision or a
  // malformed remote config is the autolinker's error to report a moment
  // later, not this pass's.
  let deps /*: ?Array<AutolinkedDep> */ = null;
  try {
    deps = collectAutolinkedDeps({
      autolinkingJsonPath,
      remote: remotePackageConfig(appRoot),
    });
  } catch {
    return refreshed;
  }
  if (deps == null) {
    return refreshed;
  }

  for (const {name: depName, root} of deps) {
    const manifestPath = path.join(root, 'Package.swift');
    let content;
    try {
      content = fs.readFileSync(manifestPath, 'utf8');
    } catch {
      continue;
    }
    if (
      !content.includes(SCAFFOLDER_MARKER) ||
      content.includes(AUTOGEN_MARKER)
    ) {
      continue;
    }
    const match = content.match(PLATFORM_FLOOR_RE);
    if (match == null) {
      continue;
    }
    const from = match[1].replace(/"/g, '');
    if (from === iosDeploymentTarget) {
      continue;
    }
    fs.writeFileSync(
      manifestPath,
      content.replace(match[0], `platforms: [.iOS("${iosDeploymentTarget}")`),
      'utf8',
    );
    refreshed.push({
      depName,
      path: manifestPath,
      from,
      to: iosDeploymentTarget,
    });
  }
  return refreshed;
}

// ---------------------------------------------------------------------------
// Multi-dep orchestrator
// ---------------------------------------------------------------------------

/*::
type ScaffoldAllOptions = {
  appRoot: string,
  projectRoot: string,
  reactNativeRoot: string,
  force?: boolean,
  dryRun?: boolean,
  cacheSlotLabel?: ?string,
  autolinkingJsonPath?: string,
  iosDeploymentTarget?: ?string,
  // npm dep names to skip entirely — used when the user declined the
  // confirmation prompt for first-time scaffolds. Skipped deps still
  // appear in the returned results array with status='skipped-opt-out'.
  skipDeps?: ReadonlyArray<string>,
};
*/

/**
 * Walks autolinking.json, scaffolds each iOS-native dep, returns one
 * ScaffoldResult per dep. Caller (setup-apple-spm.js) prints a structured
 * summary.
 */
function scaffoldAll(
  opts /*: ScaffoldAllOptions */,
) /*: Array<ScaffoldResult> */ {
  const {appRoot, projectRoot, reactNativeRoot} = opts;
  const autolinkingJsonPath =
    opts.autolinkingJsonPath ??
    path.join(appRoot, 'build', 'generated', 'autolinking', 'autolinking.json');

  if (!fs.existsSync(autolinkingJsonPath)) {
    log(
      `autolinking.json not found at ${path.relative(appRoot, autolinkingJsonPath)}; nothing to scaffold.`,
    );
    return [];
  }

  // The scaffolder covers EXACTLY the set the autolinker considers, transitive
  // `spm.dependencies` included. Without them, a transitive native dep that
  // ships no Package.swift would be flagged by the autolinker but never
  // scaffolded here — leaving `react-native spm scaffold` unable to clear the
  // autolinker's missing-manifest error.
  const results /*: Array<ScaffoldResult> */ = [];
  // Outside collectAutolinkedDeps' own try: a malformed remote config
  // (RemoteVersionError) is a misconfiguration to surface, not an expansion
  // failure to degrade past.
  const remote = remotePackageConfig(appRoot);
  const allDeps = collectAutolinkedDeps({
    autolinkingJsonPath,
    remote,
    onSkipped: name => {
      results.push({
        depName: name,
        status: 'skipped-no-ios',
        reason: 'no iOS platform in autolinking.json',
      });
    },
  });
  if (allDeps == null) {
    return results;
  }

  // Index every autolinked dep's podspec name → its npm name, so a dep that
  // depends on a sibling by its pod name (reanimated's `s.dependency
  // "RNWorklets"`) can be wired to the sibling's package (react-native-worklets).
  // The podspec file basename is the pod name for RN-ecosystem libs (and is
  // cheap — no `pod ipc` pre-pass).
  const podToNpm /*: Map<string, string> */ = new Map();
  for (const dep of allDeps) {
    const podspecPath = dep.platforms?.ios?.podspecPath;
    if (typeof podspecPath === 'string' && podspecPath.length > 0) {
      podToNpm.set(path.basename(podspecPath, '.podspec'), dep.name);
    } else {
      // Transitive entries synthesized by expandSpmDependencies (declared via
      // `spm.dependencies` in react-native.config.js) carry no podspecPath —
      // discover the podspec at the dep root so podspec-name sibling deps
      // (e.g. `s.dependency "TestLibraryCommon"`) still wire to their npm
      // package.
      for (const found of findPodspecs(dep.root)) {
        podToNpm.set(path.basename(found, '.podspec'), dep.name);
      }
    }
  }

  const swiftNameByNpm /*: Map<string, string> */ = new Map();
  for (const dep of allDeps) {
    if (dep.swiftName != null) {
      swiftNameByNpm.set(dep.name, dep.swiftName);
    }
  }

  const ctx /*: ScaffoldContext */ = {
    appRoot,
    projectRoot,
    reactNativeRoot,
    force: opts.force === true,
    dryRun: opts.dryRun === true,
    cacheSlotLabel: opts.cacheSlotLabel ?? null,
    iosDeploymentTarget: opts.iosDeploymentTarget ?? null,
    podToNpm,
    swiftNameByNpm,
    remote,
  };
  const skipSet /*: Set<string> */ = new Set(opts.skipDeps ?? []);

  for (const dep of allDeps) {
    if (skipSet.has(dep.name)) {
      results.push({
        depName: dep.name,
        status: 'skipped-opt-out',
        reason: 'User declined scaffolding for this dep.',
      });
      continue;
    }
    try {
      results.push(scaffoldPackageSwiftForDep(dep, ctx));
    } catch (e) {
      results.push({depName: dep.name, status: 'error', reason: e.message});
    }
  }
  return results;
}

module.exports = {
  refreshScaffoldedPlatformFloors,
  scaffoldAll,
  scaffoldPackageSwiftForDep,
  translatePodspecToSpmTarget,
  emitScaffoldedPackageSwift,
  SCAFFOLDER_MARKER,
  SCAFFOLDER_VERSION,
};