From c9aa6ae20ffaf202f1394258cc0639aa7b2ad860 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Thu, 20 Aug 2026 16:05:26 +0200 Subject: [PATCH] Give a library whose Swift name collides its npm scope back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dependency's Swift name is derived from its npm package name with the scope dropped, which makes two collisions unavoidable: `@powersync/react-native` derives `ReactNative`, one of React Native's own names, and `@a/foo` and `@b/foo` both derive `Foo`. Either was emitted into the package graph as-is, and SwiftPM then failed deep inside dependency resolution with a duplicate-name error that named neither the library nor the react-native.config.js that caused it. The scope that was dropped is the fix. A scoped dep whose derived name is reserved gets the TitleCased scope prepended (`PowersyncReactNative`), and deps that resolved to the same name get it prepended too (`AFoo`, `BFoo`), so the library author has nothing to do. Every disambiguation logs one line naming the package, the name it would have taken and the name it got. Nothing can regress on this — both collisions fail SwiftPM resolution today, so no working library carries such a name and no consumer imports headers under it. The two cases need different shapes. A reserved name is decidable per dep, so it resolves in `resolveSwiftName`. A collision with another dep is not visible from there, so it is a pass over the resolved set: group by name, and prepend the scope to every scoped member of a group larger than one. Every member moves rather than one arbitrary winner staying put, since there is no non-arbitrary winner. Two members never move: a name the author set with `spm.name` (their choice wins, and the others move around it) and an unscoped one (no scope to borrow). The pass runs once, and then the whole set is validated — this is the part that has to be right. A borrowed scope can land on a name another dep already holds (`@a/foo` → `AFoo`, next to a package `a-foo`) or on a reserved one, and two libraries silently sharing a name is worse than the error this replaces. So both existing checks now run over the final set, and anything a scope could not resolve still fails with the message it did before: two unscoped deps deriving the same name, an explicit `spm.name` that is reserved, a group whose only scoped member's new name is taken. Retrying instead of failing would trade a diagnosable error for a name nobody can predict. Reserving the names React Native puts in a manifest is what makes the first case diagnosable at all. Matching is case-insensitive throughout: a name that differs from another only in case is not distinct enough for the build to keep the two apart. The reserved check runs before the dep-vs-dep one, so the more specific diagnosis wins, and it runs for a library that ships an autolinking plugin too: `spm scaffold` knows nothing about plugins, so an exemption there would leave the two commands disagreeing about the same library. `SpmNameCollisionError` distinguishes a misconfiguration from a resolution failure, so `scaffoldAll` — which degrades to the direct deps when a transitive dep can't be found — still surfaces it instead of scaffolding manifests SPM will reject. Its remote package config moves out of the same try for the same reason. Every Swift name reaching a manifest now comes from that one resolved set: the autolinker's two `toSwiftName` fallbacks would have re-derived the pre-disambiguation name and emitted a reference nothing matches, so they are replaced by a required lookup that fails loudly. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/expand-spm-dependencies-test.js | 487 +++++++++++++++++- .../generate-spm-autolinking-test.js | 245 ++++++++- .../__tests__/scaffold-package-swift-test.js | 125 +++++ .../scripts/spm/__tests__/spm-utils-test.js | 37 +- .../scripts/spm/expand-spm-dependencies.js | 246 ++++++++- .../scripts/spm/generate-spm-autolinking.js | 50 +- .../scripts/spm/scaffold-package-swift.js | 14 +- .../react-native/scripts/spm/spm-utils.js | 23 +- 8 files changed, 1155 insertions(+), 72 deletions(-) diff --git a/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js index 49e91fdd6404..f018a8a8b517 100644 --- a/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js +++ b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js @@ -34,16 +34,25 @@ */ const { + SpmNameCollisionError, expandSpmDependencies, + isValidSwiftName, resolveSwiftName, } = require('../expand-spm-dependencies'); -const {toSwiftName} = require('../spm-utils'); +const { + REACT_HEADERS_TARGET_DIR, + RESERVED_SWIFT_NAMES, + toSwiftName, +} = require('../spm-utils'); function makeReadConfig(configs /*: {[string]: ?Object} */) { return (root /*: string */) => Object.prototype.hasOwnProperty.call(configs, root) ? configs[root] : null; } +// The reserved map its caller builds; these cases only exercise `spm.name`. +const NONE = new Map(); + function makeResolveDep(resolutions /*: {[string]: ?string} */) { return (name /*: string */) => Object.prototype.hasOwnProperty.call(resolutions, name) @@ -326,6 +335,22 @@ describe('expandSpmDependencies', () => { ).toThrow(/ReactNativeWorklets/); }); + it('throws SpmNameCollisionError on a dep-vs-dep collision too', () => { + const direct = [ + {name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}, + {name: 'other-package', root: '/o', platforms: {ios: {}}}, + ]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/w': {}, + '/o': {spm: {name: 'ReactNativeWorklets'}}, + }), + resolveDep: makeResolveDep({}), + }), + ).toThrow(SpmNameCollisionError); + }); + it('throws on a CASE-INSENSITIVE swiftName collision (worklets vs Worklets)', () => { // Distinct as exact strings, but collide as directories on the default // case-insensitive macOS filesystem. @@ -365,29 +390,459 @@ describe('expandSpmDependencies', () => { }); it('rejects spm.name with disallowed characters (spaces, slashes, dots)', () => { - expect(() => resolveSwiftName('a', {spm: {name: 'foo bar'}})).toThrow( - /invalid 'spm.name'/, + const resolve = name => () => resolveSwiftName('a', {spm: {name}}, NONE); + expect(resolve('foo bar')).toThrow(/invalid 'spm.name'/); + expect(resolve('foo/bar')).toThrow(/invalid 'spm.name'/); + expect(resolve('foo.bar')).toThrow(/invalid 'spm.name'/); + }); + + it('accepts lowercase-with-hyphen and CamelCase spm.name values', () => { + const resolve = name => resolveSwiftName('a', {spm: {name}}, NONE); + expect(resolve('reanimated')).toBe('reanimated'); + expect(resolve('hermes-engine')).toBe('hermes-engine'); + expect(resolve('RNWorklets')).toBe('RNWorklets'); + expect(resolve('react_native_foo')).toBe('react_native_foo'); + }); +}); + +// --------------------------------------------------------------------------- +// Scope disambiguation: a derived name that lands on one React Native reserves. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (scope disambiguation)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + it('prepends the scope when the derived name is reserved', () => { + const [dep] = expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {}}, ); - expect(() => resolveSwiftName('a', {spm: {name: 'foo/bar'}})).toThrow( - /invalid 'spm.name'/, + expect(dep.swiftName).toBe('PowersyncReactNative'); + }); + + it('logs one line naming the package, the reserved name and the name it got', () => { + const log = jest.fn(); + expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {}}, + {log}, ); - expect(() => resolveSwiftName('a', {spm: {name: 'foo.bar'}})).toThrow( - /invalid 'spm.name'/, + expect(log).toHaveBeenCalledTimes(1); + const [line] = log.mock.calls[0]; + expect(line).toContain('@powersync/react-native'); + expect(line).toContain("'ReactNative'"); + expect(line).toContain("'PowersyncReactNative'"); + }); + + it('says nothing when no disambiguation happens', () => { + const log = jest.fn(); + const [dep] = expand( + [{name: '@powersync/common', root: '/c', platforms: {ios: {}}}], + {'/c': {}}, + {log}, ); + expect(dep.swiftName).toBe('Common'); + expect(log).not.toHaveBeenCalled(); }); - it('accepts lowercase-with-hyphen and CamelCase spm.name values', () => { - expect(resolveSwiftName('a', {spm: {name: 'reanimated'}})).toBe( - 'reanimated', + it('title-cases a hyphenated scope', () => { + const [dep] = expand( + [{name: '@my-org/react-native', root: '/o', platforms: {ios: {}}}], + {'/o': {}}, + ); + expect(dep.swiftName).toBe('MyOrgReactNative'); + }); + + it('disambiguates a name that matches a reserved one only in case', () => { + // toSwiftName('@scope/reactcodegen') === 'Reactcodegen' — distinct from + // 'ReactCodegen' as a string, the same directory on a case-insensitive + // filesystem. + const [dep] = expand( + [{name: '@scope/reactcodegen', root: '/s', platforms: {ios: {}}}], + {'/s': {}}, + ); + expect(dep.swiftName).toBe('ScopeReactcodegen'); + }); + + it('disambiguates a transitive dep too', () => { + const result = expandSpmDependencies( + [{name: 'top', root: '/top', platforms: {ios: {}}}], + { + readConfig: makeReadConfig({ + '/top': {spm: {dependencies: ['@scope/react-native']}}, + '/s': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'@scope/react-native': '/s'}), + }, + ); + expect(result.map(d => d.swiftName)).toEqual(['Top', 'ScopeReactNative']); + }); + + it('disambiguates against a caller-supplied reserved name (remote identity)', () => { + const [dep] = expand( + [{name: '@acme/my-fork', root: '/f', platforms: {ios: {}}}], + {'/f': {}}, + {extraReservedNames: ['MyFork']}, ); - expect(resolveSwiftName('a', {spm: {name: 'hermes-engine'}})).toBe( - 'hermes-engine', + expect(dep.swiftName).toBe('AcmeMyFork'); + }); + + it("leaves an explicit 'spm.name' alone on a package that would have collided", () => { + const log = jest.fn(); + const [dep] = expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {spm: {name: 'PowerSync'}}}, + {log}, ); - expect(resolveSwiftName('a', {spm: {name: 'RNWorklets'}})).toBe( - 'RNWorklets', + expect(dep.swiftName).toBe('PowerSync'); + expect(log).not.toHaveBeenCalled(); + }); + + it('throws when the disambiguated name is reserved as well', () => { + const run = () => + expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {}}, + {extraReservedNames: ['PowersyncReactNative']}, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/React Native reserves/); + }); + + it('gives two scoped packages that would take the same reserved name distinct names', () => { + const result = expand( + [ + {name: '@a/react-native', root: '/a', platforms: {ios: {}}}, + {name: '@b/react-native', root: '/b', platforms: {ios: {}}}, + ], + {'/a': {}, '/b': {}}, ); - expect(resolveSwiftName('a', {spm: {name: 'react_native_foo'}})).toBe( - 'react_native_foo', + expect(result.map(d => d.swiftName)).toEqual([ + 'AReactNative', + 'BReactNative', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Scope disambiguation across deps: two libraries deriving one name. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (scope disambiguation across deps)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + const scoped = (name, root) => ({name, root, platforms: {ios: {}}}); + + it('pulls two scoped deps apart with their scopes', () => { + const result = expand([scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], { + '/a': {}, + '/b': {}, + }); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo']); + }); + + it('logs one line per rewritten dep, naming the shared name and the new one', () => { + const log = jest.fn(); + expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], + { + '/a': {}, + '/b': {}, + }, + {log}, + ); + expect(log).toHaveBeenCalledTimes(2); + const lines = log.mock.calls.map(([line]) => line); + expect(lines[0]).toContain('@a/foo'); + expect(lines[0]).toContain("'Foo'"); + expect(lines[0]).toContain("'AFoo'"); + expect(lines[1]).toContain('@b/foo'); + expect(lines[1]).toContain("'BFoo'"); + }); + + it('leaves an unscoped member alone — it has no scope to borrow', () => { + const result = expand([scoped('@a/foo', '/a'), scoped('foo', '/f')], { + '/a': {}, + '/f': {}, + }); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'Foo']); + }); + + it("leaves a member's explicit 'spm.name' alone and moves the others around it", () => { + const log = jest.fn(); + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], + {'/a': {spm: {name: 'Foo'}}, '/b': {}}, + {log}, + ); + expect(result.map(d => d.swiftName)).toEqual(['Foo', 'BFoo']); + expect(log).toHaveBeenCalledTimes(1); + expect(log.mock.calls[0][0]).toContain('@b/foo'); + }); + + it('groups case-insensitively, so a lowercase override still moves the others', () => { + const result = expand([scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], { + '/a': {spm: {name: 'foo'}}, + '/b': {}, + }); + expect(result.map(d => d.swiftName)).toEqual(['foo', 'BFoo']); + }); + + it('rewrites every scoped member of a three-way collision', () => { + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b'), scoped('@c/foo', '/c')], + {'/a': {}, '/b': {}, '/c': {}}, ); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo', 'CFoo']); }); + + it('rewrites the scoped members of a three-way collision and keeps the unscoped one', () => { + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b'), scoped('foo', '/f')], + {'/a': {}, '/b': {}, '/f': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo', 'Foo']); + }); + + it('throws when a borrowed scope lands on a third package instead of producing two of the same name', () => { + // 'a-foo' already derives 'AFoo', the name '@a/foo' borrows. + const run = () => + expand( + [ + scoped('@a/foo', '/a'), + scoped('@b/foo', '/b'), + scoped('a-foo', '/af'), + ], + {'/a': {}, '/b': {}, '/af': {}}, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/both resolve to 'AFoo'/); + }); + + it('throws when a borrowed scope lands on a name React Native reserves', () => { + // Both derive 'Native'; the borrow takes '@react/native' to 'ReactNative'. + const run = () => + expand([scoped('@react/native', '/r'), scoped('@other/native', '/o')], { + '/r': {}, + '/o': {}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/React Native reserves/); + }); + + it('still throws for two unscoped deps deriving the same name', () => { + const run = () => + expand( + [scoped('react-native-foo', '/a'), scoped('react_native_foo', '/b')], + {'/a': {}, '/b': {}}, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'react-native-foo' \('ReactNativeFoo'\) and 'react_native_foo' \('ReactNativeFoo'\) both resolve to 'ReactNativeFoo'\./, + ); + expect(run).toThrow(/Set a distinct 'spm\.name'/); + }); + + it('changes nothing, and says nothing, for a set with no collisions', () => { + const log = jest.fn(); + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/bar', '/b'), scoped('baz', '/c')], + {'/a': {}, '/b': {}, '/c': {}}, + {log}, + ); + expect(result.map(d => d.swiftName)).toEqual(['Foo', 'Bar', 'Baz']); + expect(log).not.toHaveBeenCalled(); + }); + + it('borrows a second time when an already-borrowed name collides, and the incumbent keeps its name', () => { + // Both land on 'AReactNative': one by borrowing, one by derivation. + const result = expand( + [scoped('@a/react-native', '/a'), scoped('a-react-native', '/b')], + {'/a': {}, '/b': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual([ + 'AAReactNative', + 'AReactNative', + ]); + }); + + it('disambiguates a transitive dep against a direct one', () => { + const result = expandSpmDependencies([scoped('@a/foo', '/a')], { + readConfig: makeReadConfig({ + '/a': {spm: {dependencies: ['@b/foo']}}, + '/b': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'@b/foo': '/b'}), + }); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo']); + }); +}); + +// --------------------------------------------------------------------------- +// Reserved React Native names — the backstop for what a scope cannot resolve. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (reserved React Native names)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + it('throws when an unscoped dep auto-derives a reserved product name', () => { + const run = () => + expand([{name: 'react-headers', root: '/rh', platforms: {ios: {}}}], { + '/rh': {}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'react-headers' resolves to 'ReactHeaders', which React Native reserves/, + ); + expect(run).toThrow( + /Set a different 'spm\.name' in react-headers's react-native\.config\.js\./, + ); + }); + + it('throws when an explicit spm.name override lands on a reserved name', () => { + expect(() => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: 'ReactAppHeaders'}}, + }), + ).toThrow( + /'some-lib' resolves to 'ReactAppHeaders', which React Native reserves/, + ); + }); + + it('throws when a transitive dep lands on a reserved name', () => { + expect(() => + expandSpmDependencies( + [{name: 'top', root: '/top', platforms: {ios: {}}}], + { + readConfig: makeReadConfig({ + '/top': {spm: {dependencies: ['react-native-headers']}}, + '/rnh': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'react-native-headers': '/rnh'}), + }, + ), + ).toThrow( + /'react-native-headers' resolves to 'ReactNativeHeaders', which React Native reserves/, + ); + }); + + it('reserves the caller-supplied extraReservedNames (remote package identity)', () => { + const direct = [{name: 'my-fork', root: '/f', platforms: {ios: {}}}]; + expect(() => + expand(direct, {'/f': {}}, {extraReservedNames: ['MyFork']}), + ).toThrow(/'my-fork' resolves to 'MyFork', which React Native reserves/); + }); + + it('accepts that same name when no extraReservedNames are supplied', () => { + const [dep] = expand( + [{name: 'my-fork', root: '/f', platforms: {ios: {}}}], + { + '/f': {}, + }, + ); + expect(dep.swiftName).toBe('MyFork'); + }); + + it('leaves a non-colliding dep untouched', () => { + const [dep] = expand( + [{name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}], + {'/w': {spm: {name: 'worklets'}}}, + {extraReservedNames: ['SomeRemoteIdentity']}, + ); + expect(dep.swiftName).toBe('worklets'); + }); + + it('reports the reserved-name diagnosis in preference to the dep-vs-dep one', () => { + // Both unscoped deps derive 'ReactNative', so neither can borrow a scope. + expect(() => + expand( + [ + {name: 'react-native', root: '/a', platforms: {ios: {}}}, + {name: 'react_native', root: '/b', platforms: {ios: {}}}, + ], + {'/a': {}, '/b': {}}, + ), + ).toThrow(/React Native reserves/); + }); + + it('rejects every name in RESERVED_SWIFT_NAMES', () => { + expect(RESERVED_SWIFT_NAMES.length).toBeGreaterThan(0); + for (const reserved of RESERVED_SWIFT_NAMES) { + expect(() => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: reserved}}, + }), + ).toThrow(/React Native reserves/); + } + }); + + it('rejects the autolinking aggregator package name', () => { + expect(() => + expand([{name: 'autolinked', root: '/a', platforms: {ios: {}}}], { + '/a': {}, + }), + ).toThrow( + /'autolinked' resolves to 'Autolinked', which React Native reserves/, + ); + }); + + it('accepts the React headers TARGET dir name — it is not a package or product, so nothing collides', () => { + const [dep] = expand( + [{name: 'some-lib', root: '/s', platforms: {ios: {}}}], + { + '/s': {spm: {name: REACT_HEADERS_TARGET_DIR}}, + }, + ); + expect(dep.swiftName).toBe(REACT_HEADERS_TARGET_DIR); + }); + + it('reports a case-only match against a reserved name, naming both spellings', () => { + const run = () => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: 'reactnative'}}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'some-lib' resolves to 'reactnative', which differs from React Native's reserved 'ReactNative' only in case/, + ); + expect(run).toThrow(/spm\.name/); + }); +}); + +// --------------------------------------------------------------------------- +// isValidSwiftName — the charset rule `spm.name` enforces. +// --------------------------------------------------------------------------- + +describe('isValidSwiftName', () => { + it.each(['worklets', 'ReactNativeFoo', 'hermes-engine', 'react_native_foo'])( + 'accepts %j', + name => { + expect(isValidSwiftName(name)).toBe(true); + }, + ); + + it.each(['', 'foo bar', 'foo/bar', 'foo.bar', '9lives', 42, null])( + 'rejects %j', + name => { + expect(isValidSwiftName(name)).toBe(false); + }, + ); }); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index 39939562e151..ea32e11dbc06 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -10,9 +10,11 @@ 'use strict'; +const {SpmNameCollisionError} = require('../expand-spm-dependencies'); const { AUTOGEN_MARKER, MissingManifestError, + autolinkingDepToSpmTarget, collectSpmSources, expandSpmSourceGlobs, findSelfManagedPackageDir, @@ -1082,7 +1084,7 @@ describe('main() — autolinking plugin host exemption', () => { // Builds a minimal app fixture whose ONLY autolinked iOS dep is `expo`, which // ships NO Package.swift. When `withPlugin` is set, expo declares an // autolinking plugin in its own react-native.config.js (transitive opt-in). - function buildFixture({withPlugin}) { + function buildFixture({withPlugin, depName = 'expo'}) { const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-plugin-host-')); created.push(appRoot); // rnRoot only needs to exist (main() existence-checks it, then passes it @@ -1095,7 +1097,7 @@ describe('main() — autolinking plugin host exemption', () => { JSON.stringify({name: 'app'}), ); // The plugin-host dep: native sources present, but NO Package.swift. - const expoDir = path.join(appRoot, 'node_modules', 'expo'); + const expoDir = path.join(appRoot, 'node_modules', ...depName.split('/')); fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); fs.writeFileSync( path.join(expoDir, 'ios', 'Expo.mm'), @@ -1121,7 +1123,9 @@ describe('main() — autolinking plugin host exemption', () => { fs.writeFileSync( path.join(autolinkDir, 'autolinking.json'), JSON.stringify({ - dependencies: {expo: {root: expoDir, platforms: {ios: {}}}}, + dependencies: { + [depName]: {root: expoDir, platforms: {ios: {}}}, + }, }), ); return {appRoot, rnRoot}; @@ -1154,6 +1158,25 @@ describe('main() — autolinking plugin host exemption', () => { main(['--app-root', appRoot, '--react-native-root', rnRoot]), ).toThrow(MissingManifestError); }); + + // `spm scaffold` has no plugin knowledge, so exempting the autolinker alone + // left the two commands disagreeing about the same dep. + it.each([[true], [false]])( + 'rejects a dep deriving a reserved name whether or not it ships a plugin (withPlugin=%s)', + withPlugin => { + // 'react-headers' derives the reserved 'ReactHeaders', with no scope. + const {appRoot, rnRoot} = buildFixture({ + withPlugin, + depName: 'react-headers', + }); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(SpmNameCollisionError); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(/'react-headers'.*React Native reserves/s); + }, + ); }); // --------------------------------------------------------------------------- @@ -1532,3 +1555,219 @@ describe('main() — .spm-sync-watch-paths emission', () => { expect([...lines].sort()).toEqual(lines); }); }); + +// --------------------------------------------------------------------------- +// main() — scope disambiguation: the borrowed name reaching a real manifest. +// --------------------------------------------------------------------------- + +describe('main() — scope disambiguation', () => { + let created = []; + let spies = []; + let logSpy; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + spies.push(logSpy); + for (const m of ['warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + // Each dep ships a Package.swift, so it reaches the aggregator as self-managed. + function buildFixture(...depNames) { + const appRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scope-disambig-')), + ); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + const dependencies = {}; + for (const depName of depNames) { + const depDir = path.join(appRoot, 'node_modules', ...depName.split('/')); + fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true}); + fs.writeFileSync( + path.join(depDir, 'Package.swift'), + '// swift-tools-version:6.0\n// hand-authored\n', + ); + fs.writeFileSync(path.join(depDir, 'ios', 'Lib.h'), '// header\n'); + fs.writeFileSync(path.join(depDir, 'ios', 'Lib.mm'), '// src\n'); + dependencies[depName] = {root: depDir, platforms: {ios: {}}}; + } + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + return {appRoot, rnRoot}; + } + + it('emits the disambiguated name as the package ref, the product ref and the header slice', () => { + const {appRoot, rnRoot} = buildFixture('@powersync/react-native'); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const outDir = path.join(appRoot, 'build/generated/autolinking'); + const pkg = fs.readFileSync(path.join(outDir, 'Package.swift'), 'utf8'); + expect(pkg).toContain( + '.package(name: "PowersyncReactNative", path: "libs/PowersyncReactNative")', + ); + expect(pkg).toContain( + '.product(name: "PowersyncReactNative", package: "PowersyncReactNative")', + ); + // Nothing is referenced under the name the derivation would have taken. + expect(pkg).not.toContain('"ReactNative", path: "libs/'); + expect(pkg).not.toContain('package: "ReactNative"'); + + // So `#import ` resolves for consumers. + expect( + fs.existsSync( + path.join(outDir, 'headers/PowersyncReactNative/ios/Lib.h'), + ), + ).toBe(true); + expect(fs.existsSync(path.join(outDir, 'libs/PowersyncReactNative'))).toBe( + true, + ); + expect(fs.existsSync(path.join(outDir, 'headers/ReactNative'))).toBe(false); + }); + + it('tells the developer which name it took and why', () => { + const {appRoot, rnRoot} = buildFixture('@powersync/react-native'); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const line = logSpy.mock.calls + .map(call => call.join(' ')) + .find(l => l.includes('PowersyncReactNative')); + expect(line).toBeDefined(); + expect(line).toContain('@powersync/react-native'); + expect(line).toContain("'ReactNative'"); + }); + + it('still rejects an unscoped dep deriving a reserved name — it has no scope to borrow', () => { + const {appRoot, rnRoot} = buildFixture('react-headers'); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(SpmNameCollisionError); + }); + + it('emits both names of a dep-vs-dep collision as package refs, product refs and header slices', () => { + const {appRoot, rnRoot} = buildFixture('@a/foo', '@b/foo'); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const outDir = path.join(appRoot, 'build/generated/autolinking'); + const pkg = fs.readFileSync(path.join(outDir, 'Package.swift'), 'utf8'); + for (const name of ['AFoo', 'BFoo']) { + expect(pkg).toContain(`.package(name: "${name}", path: "libs/${name}")`); + expect(pkg).toContain(`.product(name: "${name}", package: "${name}")`); + expect( + fs.existsSync(path.join(outDir, `headers/${name}/ios/Lib.h`)), + ).toBe(true); + } + expect(pkg).not.toContain('"Foo", path: "libs/'); + expect(pkg).not.toContain('package: "Foo"'); + expect(fs.existsSync(path.join(outDir, 'headers/Foo'))).toBe(false); + }); + + it('still rejects a collision the scopes cannot resolve', () => { + // 'a-foo' already derives 'AFoo', the name '@a/foo' borrows. + const {appRoot, rnRoot} = buildFixture('@a/foo', '@b/foo', 'a-foo'); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(SpmNameCollisionError); + }); +}); + +// --------------------------------------------------------------------------- +// autolinkingDepToSpmTarget — resolved names only, never re-derived ones. +// --------------------------------------------------------------------------- + +describe('autolinkingDepToSpmTarget', () => { + const dep = (extra = {}) => ({ + name: '@powersync/react-native', + root: '/dep', + platforms: {ios: {sourceDir: '/dep/ios'}}, + ...extra, + }); + + it('carries a resolved sibling name into the emitted sibling refs', () => { + const target = autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map([['@powersync/react-native', 'PowersyncReactNative']]), + ); + const manifest = generateSynthPackageSwift({ + swiftName: target.name, + spmDependencies: (target.spmTargetDependencies ?? []).map(swiftName => ({ + swiftName, + })), + hasReactDep: false, + targetPath: '.', + }); + expect(manifest).toContain( + '.package(name: "PowersyncReactNative", path: "../PowersyncReactNative")', + ); + expect(manifest).toContain( + '.product(name: "PowersyncReactNative", package: "PowersyncReactNative")', + ); + expect(manifest).not.toContain('ReactNative", package: "ReactNative"'); + }); + + it('fails loudly instead of re-deriving a dep with no resolved name', () => { + expect(() => + autolinkingDepToSpmTarget( + '@powersync/react-native', + dep(), + '/out', + new Map(), + ), + ).toThrow(/expandSpmDependencies/); + }); + + it('fails loudly instead of re-deriving an unmapped spm.dependency', () => { + expect(() => + autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map(), + ), + ).toThrow(/@powersync\/react-native/); + expect(() => + autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map(), + ), + ).toThrow(/expandSpmDependencies/); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js index e0e72b3c4b3a..88c40e68ddc9 100644 --- a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js +++ b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js @@ -18,6 +18,7 @@ const { scaffoldPackageSwiftForDep, translatePodspecToSpmTarget, } = require('../scaffold-package-swift'); +const {RemoteVersionError} = require('../spm-utils'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -1017,6 +1018,130 @@ describe('scaffoldAll', () => { 'skipped-no-podspec', ); }); + + function writeAutolinkingJson(dependencies) { + const autolinkingDir = path.join(appRoot, 'build/generated/autolinking'); + fs.mkdirSync(autolinkingDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkingDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + } + + it('propagates a Swift name collision instead of scaffolding anyway, plugin or not', () => { + // 'react-headers' derives the reserved 'ReactHeaders', with no scope to + // borrow. Degrading to the direct deps would scaffold manifests SPM rejects + // later, and a plugin buys no exemption — `spm scaffold` has no plugin code. + const depRoot = path.join(appRoot, 'node_modules', 'react-headers'); + fs.mkdirSync(depRoot, {recursive: true}); + fs.writeFileSync( + path.join(depRoot, 'react-native.config.js'), + "module.exports = {spm: {autolinkingPlugin: './spm-plugin.js'}};\n", + ); + writeAutolinkingJson({ + 'react-headers': {root: depRoot, platforms: {ios: {}}}, + }); + expect(() => + scaffoldAll({appRoot, projectRoot: appRoot, reactNativeRoot: appRoot}), + ).toThrow(/React Native reserves/); + }); + + it('still falls back to the direct deps when a transitive dep cannot be resolved', () => { + const depRoot = path.join(appRoot, 'node_modules', 'react-native-a'); + fs.mkdirSync(depRoot, {recursive: true}); + fs.writeFileSync( + path.join(depRoot, 'react-native.config.js'), + "module.exports = {spm: {dependencies: ['ghost-dep-that-is-not-installed']}};\n", + ); + writeAutolinkingJson({ + 'react-native-a': {root: depRoot, platforms: {ios: {}}}, + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results.map(r => r.depName)).toEqual(['react-native-a']); + expect(logSpy.mock.calls.map(call => call.join(' ')).join('\n')).toMatch( + /Transitive spm\.dependencies expansion failed/, + ); + } finally { + logSpy.mockRestore(); + } + }); + + it('emits the remote package reference for every dep it scaffolds', () => { + const depRoot = path.join(appRoot, 'node_modules', 'react-native-foo'); + fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.mm'), '// native\n'); + fs.writeFileSync( + path.join(depRoot, 'react-native-foo.podspec'), + 'Pod::Spec.new do |s|\n' + + ' s.name = "react-native-foo"\n' + + ' s.version = "1.0"\n' + + ' s.source_files = "ios/**/*.{h,m,mm}"\n' + + ' s.dependency "React-Core"\n' + + 'end\n', + ); + writeAutolinkingJson({ + 'react-native-foo': {root: depRoot, platforms: {ios: {}}}, + }); + const prevUrl = process.env.RN_SPM_REMOTE_URL; + const prevVersion = process.env.RN_SPM_REMOTE_VERSION; + process.env.RN_SPM_REMOTE_URL = 'https://example.com/rn.git'; + process.env.RN_SPM_REMOTE_VERSION = '9.9.9'; + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results.map(r => r.status)).toEqual(['written']); + const manifest = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + expect(manifest).toContain( + '.package(url: "https://example.com/rn.git", exact: "9.9.9")', + ); + expect(manifest).not.toContain('.package(name: "ReactNative"'); + } finally { + logSpy.mockRestore(); + if (prevUrl == null) delete process.env.RN_SPM_REMOTE_URL; + else process.env.RN_SPM_REMOTE_URL = prevUrl; + if (prevVersion == null) delete process.env.RN_SPM_REMOTE_VERSION; + else process.env.RN_SPM_REMOTE_VERSION = prevVersion; + } + }); + + it('propagates a RemoteVersionError from the remote package config', () => { + writeAutolinkingJson({ + 'react-native-a': {root: '/no/such/a', platforms: {ios: {}}}, + }); + const prevUrl = process.env.RN_SPM_REMOTE_URL; + const prevVersion = process.env.RN_SPM_REMOTE_VERSION; + process.env.RN_SPM_REMOTE_URL = 'https://example.com/react-native-spm.git'; + delete process.env.RN_SPM_REMOTE_VERSION; + try { + // No react-native under the temp appRoot, so no version resolves — the + // author must see that, not have it degraded into "expansion failed". + expect(() => + scaffoldAll({appRoot, projectRoot: appRoot, reactNativeRoot: appRoot}), + ).toThrow(RemoteVersionError); + } finally { + if (prevUrl == null) { + delete process.env.RN_SPM_REMOTE_URL; + } else { + process.env.RN_SPM_REMOTE_URL = prevUrl; + } + if (prevVersion != null) { + process.env.RN_SPM_REMOTE_VERSION = prevVersion; + } + } + }); }); // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js index f50bf4c2f9dd..dc3d7a8e071b 100644 --- a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js +++ b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js @@ -22,6 +22,7 @@ const { REACT_NATIVE_UMBRELLA_PRODUCT, REACT_NATIVE_XCFRAMEWORK_PRODUCTS, RemoteVersionError, + RESERVED_SWIFT_NAMES, buildPerAppHeaderTree, defaultCacheDir, displayPath, @@ -57,11 +58,10 @@ describe('toSwiftName', () => { }); // --------------------------------------------------------------------------- -// Name constants — the single list every generated manifest derives its -// package and product names from +// Reserved Swift names — the one list the manifests and the guard both use // --------------------------------------------------------------------------- -describe('name constants', () => { +describe('reserved Swift names', () => { it('names the React Native package and the per-app codegen package', () => { expect(REACT_NATIVE_PACKAGE_NAME).toBe('ReactNative'); expect(REACT_CODEGEN_PACKAGE_NAME).toBe('React-GeneratedCode'); @@ -97,11 +97,42 @@ describe('name constants', () => { expect(REACT_HEADERS_TARGET_DIR).toBe('ReactHeadersTarget'); }); + // The one test that pins the literal strings: every other check compares + // constants to constants, so this is what would catch a rename. + it('reserves exactly the names React Native puts in a manifest', () => { + expect([...RESERVED_SWIFT_NAMES].sort()).toEqual([ + 'Autolinked', + 'React-GeneratedCode', + 'ReactAppDependencyProvider', + 'ReactAppHeaders', + 'ReactCodegen', + 'ReactHeaders', + 'ReactNative', + 'ReactNativeDependenciesHeaders', + 'ReactNativeHeaders', + ]); + }); + + it('holds names that real generated manifests actually use', () => { + const { + generateXCFrameworksPackageSwift, + } = require('../generate-spm-package'); + const manifest = generateXCFrameworksPackageSwift(); + for (const name of [REACT_NATIVE_PACKAGE_NAME, ...REACT_NATIVE_PRODUCTS]) { + expect(manifest).toContain(`"${name}"`); + } + }); + + it('does NOT reserve the headers target dir — target names only have to be unique within their own package', () => { + expect(RESERVED_SWIFT_NAMES).not.toContain(REACT_HEADERS_TARGET_DIR); + }); + it('freezes the lists so no caller can mutate the shared source of truth', () => { for (const list of [ REACT_NATIVE_PRODUCTS, REACT_CODEGEN_PRODUCTS, REACT_CODEGEN_APP_PRODUCTS, + RESERVED_SWIFT_NAMES, ]) { expect(Array.isArray(list)).toBe(true); expect(Object.isFrozen(list)).toBe(true); diff --git a/packages/react-native/scripts/spm/expand-spm-dependencies.js b/packages/react-native/scripts/spm/expand-spm-dependencies.js index 4b529c08afa4..dead1f9bb11b 100644 --- a/packages/react-native/scripts/spm/expand-spm-dependencies.js +++ b/packages/react-native/scripts/spm/expand-spm-dependencies.js @@ -10,7 +10,7 @@ 'use strict'; -const {toSwiftName} = require('./spm-utils'); +const {RESERVED_SWIFT_NAMES, toSwiftName} = require('./spm-utils'); const fs = require('node:fs'); const path = require('node:path'); @@ -32,7 +32,7 @@ const path = require('node:path'); * list with autolinking-shaped entries so the downstream pipeline can convert * each to an SPM target without further branching. * - * I/O is injected (readConfig, resolveDep) so the logic stays pure and + * I/O is injected (readConfig, resolveDep, log) so the logic stays pure and * testable. */ @@ -44,52 +44,231 @@ import type {AutolinkedDep} from './spm-types'; type RnConfig = {...}; type ReadConfig = (root: string) => ?RnConfig; type ResolveDep = (name: string, fromRoot: string) => ?string; +type Log = (message: string) => void; +// Keyed by lower case, valued with the canonical spelling: two names differing +// only in case are not distinct enough for the build to keep the two apart. +type ReservedNames = ReadonlyMap; type Options = { readConfig: ReadConfig, resolveDep: ResolveDep, + // Names to reserve alongside RESERVED_SWIFT_NAMES, supplied by the caller + // (remote mode relabels the RN package) since this module reads no config. + extraReservedNames?: ?ReadonlyArray, + log?: ?Log, }; */ -// Validates and returns the Swift target name for a dep. Falls back to -// toSwiftName(npmName) when no override is set. The override is the dep's -// `react-native.config.js` `spm.name`, intended for libraries whose import -// prefix differs from the auto-derived name (e.g. `react-native-worklets` -// publishes headers under `` via the podspec `s.header_dir`, -// so the SPM target name should be `worklets`, not `ReactNativeWorklets`). +/** + * A misconfiguration rather than a resolution failure: scaffoldAll degrades past + * a transitive dep it cannot find, but must still surface this. + */ +class SpmNameCollisionError extends Error { + constructor(message /*: string */) { + super(message); + this.name = 'SpmNameCollisionError'; + } +} + +// The charset `spm.name` must satisfy — permissive on purpose, since it has to +// admit header-dir style (lowercase with hyphens) as well as Swift identifiers. +function isValidSwiftName(name /*: unknown */) /*: boolean */ { + return typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name); +} + +function reservedSwiftNames( + extraReservedNames /*: ?ReadonlyArray */, +) /*: ReservedNames */ { + return new Map( + [...RESERVED_SWIFT_NAMES, ...(extraReservedNames ?? [])].map(name => [ + name.toLowerCase(), + name, + ]), + ); +} + +// The scope-borrowed form of a name: `@powersync/react-native`'s `ReactNative` +// becomes `PowersyncReactNative`. +function scopeBorrowedName( + npmName /*: string */, + swiftName /*: string */, +) /*: ?string */ { + const scope = /^@([^/]+)\//.exec(npmName)?.[1]; + return scope == null ? null : `${toSwiftName(scope)}${swiftName}`; +} + +// The Swift target name for one dep, judged in isolation. `spm.name` is for +// libraries whose import prefix differs from the derived name: +// `react-native-worklets` ships headers as `` (podspec +// `s.header_dir`), so its target is `worklets`, not `ReactNativeWorklets`. A +// derived name that lands on a reserved one borrows the npm scope instead. function resolveSwiftName( npmName /*: string */, config /*: ?RnConfig */, + reserved /*: ReservedNames */, + log /*:: ?: ?Log */, ) /*: string */ { // $FlowFixMe[prop-missing] config has dynamic shape const override = config?.spm?.name; - if (override == null) { - return toSwiftName(npmName); + if (override != null) { + if (typeof override !== 'string' || override.length === 0) { + throw new Error( + `react-native autolinking: '${npmName}' has an invalid 'spm.name' override: expected a non-empty string, got ${JSON.stringify(override)}.`, + ); + } + if (!isValidSwiftName(override)) { + throw new Error( + `react-native autolinking: '${npmName}' has an invalid 'spm.name' override '${override}': must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, + ); + } + return override; + } + + const derived = toSwiftName(npmName); + if (!reserved.has(derived.toLowerCase())) { + return derived; + } + const disambiguated = scopeBorrowedName(npmName, derived); + if (disambiguated == null || reserved.has(disambiguated.toLowerCase())) { + return derived; + } + log?.( + `'${npmName}' would take React Native's reserved name '${derived}', so its npm scope is prepended: '${disambiguated}'. ` + + `Set 'spm.name' in ${npmName}'s react-native.config.js to choose the name yourself.`, + ); + return disambiguated; +} + +function assertNameNotReserved( + swiftName /*: string */, + reserved /*: ReservedNames */, + labels /*: {label: string, remedy: string} */, +) /*: void */ { + const reservedName = reserved.get(swiftName.toLowerCase()); + if (reservedName == null) { + return; + } + // Vaguer about the case clash than the dep-vs-dep message on purpose: this + // set spans package identities and product names, which collide differently. + throw new SpmNameCollisionError( + `react-native autolinking: SPM Swift name collision: ${labels.label} resolves to '${swiftName}', ` + + (reservedName === swiftName + ? `which React Native reserves for its own SPM package and products.` + : `which differs from React Native's reserved '${reservedName}' only in case — not distinct enough for the build to keep the two apart.`) + + ` ${labels.remedy}`, + ); +} + +/** + * Throws when `swiftName` is one React Native's own manifests use. `remedy` is + * the fix: a library sets `spm.name`, an app renames its `spm.modules` entry. + */ +function assertSwiftNameNotReserved( + swiftName /*: string */, + options /*: { + label: string, + remedy: string, + extraReservedNames?: ?ReadonlyArray, + } */, +) /*: void */ { + const {label, remedy, extraReservedNames} = options; + assertNameNotReserved(swiftName, reservedSwiftNames(extraReservedNames), { + label, + remedy, + }); +} + +// Reserved-name backstop over the resolved set. Unconditional: a plugin-shipping +// library is checked like any other, so `spm scaffold` — which knows nothing +// about plugins — cannot disagree with the autolinker about the same dep. +function assertNoReservedSwiftNames( + deps /*: ReadonlyArray */, + reserved /*: ReservedNames */, +) /*: void */ { + for (const dep of deps) { + const swiftName = dep.swiftName; + if (swiftName == null) { + continue; + } + assertNameNotReserved(swiftName, reserved, { + label: `'${dep.name}'`, + remedy: `Set a different 'spm.name' in ${dep.name}'s react-native.config.js.`, + }); } - if (typeof override !== 'string' || override.length === 0) { - throw new Error( - `react-native autolinking: '${npmName}' has an invalid 'spm.name' override: expected a non-empty string, got ${JSON.stringify(override)}.`, - ); +} + +// Pulls apart deps that resolved to the same name by borrowing their npm scopes. +// Every scoped member of a colliding group moves: there is no non-arbitrary +// winner to keep. Exactly one pass — retrying would trade a diagnosable error +// for a name nobody can predict. +function disambiguateSharedSwiftNames( + deps /*: ReadonlyArray */, + autoNamed /*: ReadonlySet */, + log /*: ?Log */, +) /*: void */ { + const groups /*: Map> */ = + new Map(); + for (const dep of deps) { + const swiftName = dep.swiftName; + if (swiftName == null) { + continue; + } + const key = swiftName.toLowerCase(); + const group = groups.get(key); + if (group == null) { + groups.set(key, [{dep, swiftName}]); + } else { + group.push({dep, swiftName}); + } } - // Accept Swift-identifier style (TitleCase / snake_case) and header-dir - // style (lowercase, optional hyphens). Reject whitespace, slashes, and - // other characters that would break SPM target / module identifiers. - if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(override)) { - throw new Error( - `react-native autolinking: '${npmName}' has an invalid 'spm.name' override '${override}': must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, - ); + + for (const group of groups.values()) { + if (group.length < 2) { + continue; + } + for (const {dep, swiftName} of group) { + // A name we derived can borrow a second time (`AAReactNative`); the + // member whose name we did not derive is the incumbent and keeps it. + if (!autoNamed.has(dep.name)) { + continue; + } + const borrowed = scopeBorrowedName(dep.name, swiftName); + if (borrowed == null) { + continue; + } + const others = group + .filter(other => other.dep !== dep) + .map(other => `'${other.dep.name}'`) + .join(', '); + log?.( + `'${dep.name}' would share the name '${swiftName}' with ${others}, so its npm scope is prepended: '${borrowed}'. ` + + `Set 'spm.name' in ${dep.name}'s react-native.config.js to choose the name yourself.`, + ); + dep.swiftName = borrowed; + } } - return override; } function expandSpmDependencies( directDeps /*: Array */, options /*: Options */, ) /*: Array */ { - const {readConfig, resolveDep} = options; + const {readConfig, resolveDep, extraReservedNames, log} = options; + const reserved = reservedSwiftNames(extraReservedNames); const byName /*: Map */ = new Map(); for (const dep of directDeps) { byName.set(dep.name, {...dep, spmDependencies: []}); } + const autoNamed /*: Set */ = new Set(); + const resolveName = ( + npmName /*: string */, + config /*: ?RnConfig */, + ) /*: string */ => { + // $FlowFixMe[prop-missing] config has dynamic shape + if (config?.spm?.name == null) { + autoNamed.add(npmName); + } + return resolveSwiftName(npmName, config, reserved, log); + }; const queue /*: Array */ = directDeps.map(d => d.name); while (queue.length > 0) { @@ -105,7 +284,7 @@ function expandSpmDependencies( // Resolve swiftName lazily from the same config read we already need for // spm.dependencies — saves a duplicate readConfig call per direct dep. if (current.swiftName == null) { - current.swiftName = resolveSwiftName(currentName, config); + current.swiftName = resolveName(currentName, config); } // $FlowFixMe[prop-missing] config has dynamic shape const transitives /*: Array */ = config?.spm?.dependencies ?? []; @@ -134,7 +313,7 @@ function expandSpmDependencies( name: transitiveName, root: transitiveRoot, platforms: {ios: iosPlatform}, - swiftName: resolveSwiftName(transitiveName, transitiveConfig), + swiftName: resolveName(transitiveName, transitiveConfig), spmDependencies: [], }); queue.push(transitiveName); @@ -144,6 +323,14 @@ function expandSpmDependencies( current.spmDependencies = currentSpmDeps; } + const allDeps /*: Array */ = Array.from(byName.values()); + + disambiguateSharedSwiftNames(allDeps, autoNamed, log); + + // Both checks below validate the FINAL set, after that pass: a borrowed scope + // can land on a reserved name, or on one another dep already holds. + assertNoReservedSwiftNames(allDeps, reserved); + // Collision check: two deps mapping to the same Swift name (whether via // override or auto-derivation) would clobber each other in the synth // package layout and the centralized headers tree. Surface it now with a @@ -154,7 +341,7 @@ function expandSpmDependencies( // passes but the two still collide as directories on the default // case-insensitive macOS filesystem (synth package layout + headers tree). const seen /*: Map */ = new Map(); - for (const dep of byName.values()) { + for (const dep of allDeps) { const swiftName = dep.swiftName; if (swiftName == null) { continue; @@ -163,7 +350,7 @@ function expandSpmDependencies( const existing = seen.get(key); if (existing != null) { const same = existing.swiftName === swiftName; - throw new Error( + throw new SpmNameCollisionError( `react-native autolinking: SPM Swift name collision: '${existing.name}' ('${existing.swiftName}') and '${dep.name}' ('${swiftName}') ` + (same ? `both resolve to '${swiftName}'.` @@ -174,7 +361,7 @@ function expandSpmDependencies( seen.set(key, {name: dep.name, swiftName}); } - return Array.from(byName.values()); + return allDeps; } // --------------------------------------------------------------------------- @@ -209,7 +396,10 @@ function defaultResolveDep( } module.exports = { + SpmNameCollisionError, + assertSwiftNameNotReserved, expandSpmDependencies, + isValidSwiftName, resolveSwiftName, defaultReadConfig, defaultResolveDep, diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index cf2932731a48..3cdee1d2645d 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -74,7 +74,6 @@ const { findProjectRoot, makeLogger, remotePackageConfig, - toSwiftName, } = require('./spm-utils'); const fs = require('node:fs'); const path = require('node:path'); @@ -97,6 +96,11 @@ 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 */ { + return remoteCfg != null ? [remoteCfg.identity] : undefined; +} function reactNativePackageDecl(localDecl /*: string */) /*: string */ { return remoteCfg != null ? `.package(url: "${remoteCfg.url}", exact: "${remoteCfg.version}")` @@ -737,9 +741,9 @@ function expandSpmSourceGlobs( * 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, possibly overridden via the dep's - * `spm.name` config). Optional for backwards compatibility with callers that - * don't have the map; falls back to `toSwiftName(name)` per entry. + * name (populated by expandSpmDependencies, honoring the dep's `spm.name` + * config and scope disambiguation). Every name this function emits comes from + * there — see requireSwiftName. */ /** * Read the dep's podspec (if any) and extract its declared @@ -796,11 +800,27 @@ function extractPodspecHeaderSearchPaths( 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 */, + swiftNameByNpm /*: Map */, ) /*: SpmTarget | null */ { const iosPlatform = dep.platforms.ios; const sourceDir = iosPlatform.sourceDir ?? dep.root; @@ -813,10 +833,7 @@ function autolinkingDepToSpmTarget( // same convention the spmModule branch in main() follows. const relSourcePath = path.relative(outputDir, sourceDir); - // Prefer the resolved Swift name (which honors `spm.name` overrides set in - // the dep's react-native.config.js). Fall back to toSwiftName(depName) when - // the caller didn't run expandSpmDependencies. - const targetName = dep.swiftName ?? toSwiftName(depName); + const targetName = requireSwiftName(depName, dep.swiftName); // No exclude inference — main()'s emission loop emits `sources:` (an // explicit allowlist). User-supplied excludes still work. @@ -826,13 +843,11 @@ function autolinkingDepToSpmTarget( 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. Each - // transitive npm name's Swift name comes from the map (honoring overrides); - // toSwiftName fallback handles entries the map doesn't know about. + // synth's .product(...) deps list reaches the consuming target. const spmDeps /*: Array */ = dep.spmDependencies ?? []; const spmTargetDependencies = spmDeps.length > 0 - ? spmDeps.map(n => swiftNameByNpm?.get(n) ?? toSwiftName(n)) + ? spmDeps.map(n => requireSwiftName(n, swiftNameByNpm.get(n))) : undefined; const headerSearchPaths = extractPodspecHeaderSearchPaths(sourceDir); @@ -1260,11 +1275,13 @@ function main(argv /*:: ?: Array */) /*: void */ { const allDeps = expandSpmDependencies(directDeps, { readConfig: defaultReadConfig, resolveDep: defaultResolveDep, + extraReservedNames: reservedNamesForRun(), + log, }); - // Map every autolinked npm name to its resolved Swift name (post-override) - // so transitive references inside autolinkingDepToSpmTarget find the right - // target identifier — not just the auto-derived toSwiftName. + // Map every autolinked npm name to its resolved Swift name so transitive + // references inside autolinkingDepToSpmTarget find the right target + // identifier. const swiftNameByNpm /*: Map */ = new Map(); for (const dep of allDeps) { if (dep.swiftName != null) { @@ -1890,6 +1907,7 @@ if (require.main === module) { module.exports = { main, + autolinkingDepToSpmTarget, generateAutolinkedPackageSwift, generateSynthPackageSwift, reactDescriptor, diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js index ce3103ebedda..09c740bd54ab 100644 --- a/packages/react-native/scripts/spm/scaffold-package-swift.js +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -36,6 +36,7 @@ import type { */ const { + SpmNameCollisionError, defaultReadConfig, defaultResolveDep, expandSpmDependencies, @@ -792,6 +793,7 @@ type ScaffoldContext = { // 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, + remote: ?{url: string, version: string, identity: string}, }; */ @@ -1004,7 +1006,7 @@ function scaffoldPackageSwiftForDep( .join('/'); const content = emitScaffoldedPackageSwift(spec, { cacheSlotLabel: ctx.cacheSlotLabel, - remote: remotePackageConfig(ctx.appRoot), + remote: ctx.remote, codegenPackageDir: relFromManifest('build', 'generated', 'ios'), localXcfwPackageDir: relFromManifest('build', 'xcframeworks'), }); @@ -1131,13 +1133,22 @@ function scaffoldAll( directDeps.push({name, root, platforms: {ios: iosPlatform}}); } + // Outside the try: a malformed remote config (RemoteVersionError) is a + // misconfiguration to surface, not an expansion failure to degrade past. + const remote = remotePackageConfig(appRoot); + let allDeps /*: Array */ = []; try { allDeps = expandSpmDependencies(directDeps, { readConfig: defaultReadConfig, resolveDep: defaultResolveDep, + extraReservedNames: remote != null ? [remote.identity] : undefined, + log, }); } catch (e) { + if (e instanceof SpmNameCollisionError) { + throw e; + } // A transitive-resolution failure shouldn't abort the whole scaffold pass; // fall back to the direct deps so at least those get manifests. log(`Transitive spm.dependencies expansion failed: ${e.message}`); @@ -1180,6 +1191,7 @@ function scaffoldAll( dryRun: opts.dryRun === true, cacheSlotLabel: opts.cacheSlotLabel ?? null, podToNpm, + remote, }; const skipSet /*: Set */ = new Set(opts.skipDeps ?? []); diff --git a/packages/react-native/scripts/spm/spm-utils.js b/packages/react-native/scripts/spm/spm-utils.js index c974b12b01f6..b916e3dfe921 100644 --- a/packages/react-native/scripts/spm/spm-utils.js +++ b/packages/react-native/scripts/spm/spm-utils.js @@ -14,12 +14,13 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -// The package and product names React Native's own generated manifests use, -// in one place so the emitters in this directory cannot drift apart from each -// other. +// The package and product names React Native's own generated manifests use. A +// dependency whose Swift name lands on one of them surfaces as a duplicate-name +// error from inside SPM's resolution, far from the react-native.config.js that +// caused it, so the autolinker rejects the collision up front instead. // -// Adding a React Native SPM product also touches, depending on what the -// product is: +// These cover the emitters in this directory and the collision guard. Adding a +// React Native SPM product also touches, depending on what the product is: // - scripts/codegen/templates/Package.swift.spm-template — if the per-app // codegen package must depend on it (a static Swift file, patched by // string replacement, not generated from these constants) @@ -62,6 +63,17 @@ const REACT_CODEGEN_APP_PRODUCTS /*: ReadonlyArray */ = Object.freeze([ 'ReactAppDependencyProvider', ]); const REACT_HEADERS_TARGET_DIR /*: string */ = 'ReactHeadersTarget'; +// Target names only have to be unique within their own package, so +// REACT_HEADERS_TARGET_DIR is deliberately absent: a dependency named after it +// collides with nothing. +const RESERVED_SWIFT_NAMES /*: ReadonlyArray */ = Object.freeze([ + REACT_NATIVE_PACKAGE_NAME, + REACT_CODEGEN_PACKAGE_NAME, + AUTOLINKED_PACKAGE_NAME, + ...REACT_NATIVE_PRODUCTS, + ...REACT_CODEGEN_PRODUCTS, + ...REACT_CODEGEN_APP_PRODUCTS, +]); /** * Creates a logger trio {log, warn, die} that prefixes messages with [name]. @@ -725,6 +737,7 @@ module.exports = { REACT_CODEGEN_PRODUCTS, REACT_CODEGEN_APP_PRODUCTS, REACT_HEADERS_TARGET_DIR, + RESERVED_SWIFT_NAMES, makeLogger, displayPath, sharedCacheDir,