Skip to content

Commit b8cc821

Browse files
chrfalchclaude
andcommitted
Give a library whose Swift name collides its npm scope back
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) <noreply@anthropic.com>
1 parent 0c7f63a commit b8cc821

8 files changed

Lines changed: 1155 additions & 72 deletions

‎packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js‎

Lines changed: 471 additions & 16 deletions
Large diffs are not rendered by default.

‎packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js‎

Lines changed: 242 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010

1111
'use strict';
1212

13+
const {SpmNameCollisionError} = require('../expand-spm-dependencies');
1314
const {
1415
AUTOGEN_MARKER,
1516
MissingManifestError,
17+
autolinkingDepToSpmTarget,
1618
collectSpmSources,
1719
expandSpmSourceGlobs,
1820
findSelfManagedPackageDir,
@@ -1082,7 +1084,7 @@ describe('main() — autolinking plugin host exemption', () => {
10821084
// Builds a minimal app fixture whose ONLY autolinked iOS dep is `expo`, which
10831085
// ships NO Package.swift. When `withPlugin` is set, expo declares an
10841086
// autolinking plugin in its own react-native.config.js (transitive opt-in).
1085-
function buildFixture({withPlugin}) {
1087+
function buildFixture({withPlugin, depName = 'expo'}) {
10861088
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-plugin-host-'));
10871089
created.push(appRoot);
10881090
// rnRoot only needs to exist (main() existence-checks it, then passes it
@@ -1095,7 +1097,7 @@ describe('main() — autolinking plugin host exemption', () => {
10951097
JSON.stringify({name: 'app'}),
10961098
);
10971099
// The plugin-host dep: native sources present, but NO Package.swift.
1098-
const expoDir = path.join(appRoot, 'node_modules', 'expo');
1100+
const expoDir = path.join(appRoot, 'node_modules', ...depName.split('/'));
10991101
fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true});
11001102
fs.writeFileSync(
11011103
path.join(expoDir, 'ios', 'Expo.mm'),
@@ -1121,7 +1123,9 @@ describe('main() — autolinking plugin host exemption', () => {
11211123
fs.writeFileSync(
11221124
path.join(autolinkDir, 'autolinking.json'),
11231125
JSON.stringify({
1124-
dependencies: {expo: {root: expoDir, platforms: {ios: {}}}},
1126+
dependencies: {
1127+
[depName]: {root: expoDir, platforms: {ios: {}}},
1128+
},
11251129
}),
11261130
);
11271131
return {appRoot, rnRoot};
@@ -1154,6 +1158,25 @@ describe('main() — autolinking plugin host exemption', () => {
11541158
main(['--app-root', appRoot, '--react-native-root', rnRoot]),
11551159
).toThrow(MissingManifestError);
11561160
});
1161+
1162+
// `spm scaffold` has no plugin knowledge, so exempting the autolinker alone
1163+
// left the two commands disagreeing about the same dep.
1164+
it.each([[true], [false]])(
1165+
'rejects a dep deriving a reserved name whether or not it ships a plugin (withPlugin=%s)',
1166+
withPlugin => {
1167+
// 'react-headers' derives the reserved 'ReactHeaders', with no scope.
1168+
const {appRoot, rnRoot} = buildFixture({
1169+
withPlugin,
1170+
depName: 'react-headers',
1171+
});
1172+
expect(() =>
1173+
main(['--app-root', appRoot, '--react-native-root', rnRoot]),
1174+
).toThrow(SpmNameCollisionError);
1175+
expect(() =>
1176+
main(['--app-root', appRoot, '--react-native-root', rnRoot]),
1177+
).toThrow(/'react-headers'.*React Native reserves/s);
1178+
},
1179+
);
11571180
});
11581181

11591182
// ---------------------------------------------------------------------------
@@ -1532,3 +1555,219 @@ describe('main() — .spm-sync-watch-paths emission', () => {
15321555
expect([...lines].sort()).toEqual(lines);
15331556
});
15341557
});
1558+
1559+
// ---------------------------------------------------------------------------
1560+
// main() — scope disambiguation: the borrowed name reaching a real manifest.
1561+
// ---------------------------------------------------------------------------
1562+
1563+
describe('main() — scope disambiguation', () => {
1564+
let created = [];
1565+
let spies = [];
1566+
let logSpy;
1567+
1568+
beforeEach(() => {
1569+
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
1570+
spies.push(logSpy);
1571+
for (const m of ['warn', 'error']) {
1572+
spies.push(jest.spyOn(console, m).mockImplementation(() => {}));
1573+
}
1574+
});
1575+
1576+
afterEach(() => {
1577+
for (const s of spies) s.mockRestore();
1578+
spies = [];
1579+
for (const d of created) fs.rmSync(d, {recursive: true, force: true});
1580+
created = [];
1581+
});
1582+
1583+
// Each dep ships a Package.swift, so it reaches the aggregator as self-managed.
1584+
function buildFixture(...depNames) {
1585+
const appRoot = fs.realpathSync(
1586+
fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scope-disambig-')),
1587+
);
1588+
created.push(appRoot);
1589+
const rnRoot = path.join(appRoot, 'rn');
1590+
fs.mkdirSync(rnRoot, {recursive: true});
1591+
fs.writeFileSync(
1592+
path.join(appRoot, 'package.json'),
1593+
JSON.stringify({name: 'app'}),
1594+
);
1595+
const dependencies = {};
1596+
for (const depName of depNames) {
1597+
const depDir = path.join(appRoot, 'node_modules', ...depName.split('/'));
1598+
fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true});
1599+
fs.writeFileSync(
1600+
path.join(depDir, 'Package.swift'),
1601+
'// swift-tools-version:6.0\n// hand-authored\n',
1602+
);
1603+
fs.writeFileSync(path.join(depDir, 'ios', 'Lib.h'), '// header\n');
1604+
fs.writeFileSync(path.join(depDir, 'ios', 'Lib.mm'), '// src\n');
1605+
dependencies[depName] = {root: depDir, platforms: {ios: {}}};
1606+
}
1607+
const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking');
1608+
fs.mkdirSync(autolinkDir, {recursive: true});
1609+
fs.writeFileSync(
1610+
path.join(autolinkDir, 'autolinking.json'),
1611+
JSON.stringify({dependencies}),
1612+
);
1613+
return {appRoot, rnRoot};
1614+
}
1615+
1616+
it('emits the disambiguated name as the package ref, the product ref and the header slice', () => {
1617+
const {appRoot, rnRoot} = buildFixture('@powersync/react-native');
1618+
main(['--app-root', appRoot, '--react-native-root', rnRoot]);
1619+
1620+
const outDir = path.join(appRoot, 'build/generated/autolinking');
1621+
const pkg = fs.readFileSync(path.join(outDir, 'Package.swift'), 'utf8');
1622+
expect(pkg).toContain(
1623+
'.package(name: "PowersyncReactNative", path: "libs/PowersyncReactNative")',
1624+
);
1625+
expect(pkg).toContain(
1626+
'.product(name: "PowersyncReactNative", package: "PowersyncReactNative")',
1627+
);
1628+
// Nothing is referenced under the name the derivation would have taken.
1629+
expect(pkg).not.toContain('"ReactNative", path: "libs/');
1630+
expect(pkg).not.toContain('package: "ReactNative"');
1631+
1632+
// So `#import <PowersyncReactNative/Lib.h>` resolves for consumers.
1633+
expect(
1634+
fs.existsSync(
1635+
path.join(outDir, 'headers/PowersyncReactNative/ios/Lib.h'),
1636+
),
1637+
).toBe(true);
1638+
expect(fs.existsSync(path.join(outDir, 'libs/PowersyncReactNative'))).toBe(
1639+
true,
1640+
);
1641+
expect(fs.existsSync(path.join(outDir, 'headers/ReactNative'))).toBe(false);
1642+
});
1643+
1644+
it('tells the developer which name it took and why', () => {
1645+
const {appRoot, rnRoot} = buildFixture('@powersync/react-native');
1646+
main(['--app-root', appRoot, '--react-native-root', rnRoot]);
1647+
1648+
const line = logSpy.mock.calls
1649+
.map(call => call.join(' '))
1650+
.find(l => l.includes('PowersyncReactNative'));
1651+
expect(line).toBeDefined();
1652+
expect(line).toContain('@powersync/react-native');
1653+
expect(line).toContain("'ReactNative'");
1654+
});
1655+
1656+
it('still rejects an unscoped dep deriving a reserved name — it has no scope to borrow', () => {
1657+
const {appRoot, rnRoot} = buildFixture('react-headers');
1658+
expect(() =>
1659+
main(['--app-root', appRoot, '--react-native-root', rnRoot]),
1660+
).toThrow(SpmNameCollisionError);
1661+
});
1662+
1663+
it('emits both names of a dep-vs-dep collision as package refs, product refs and header slices', () => {
1664+
const {appRoot, rnRoot} = buildFixture('@a/foo', '@b/foo');
1665+
main(['--app-root', appRoot, '--react-native-root', rnRoot]);
1666+
1667+
const outDir = path.join(appRoot, 'build/generated/autolinking');
1668+
const pkg = fs.readFileSync(path.join(outDir, 'Package.swift'), 'utf8');
1669+
for (const name of ['AFoo', 'BFoo']) {
1670+
expect(pkg).toContain(`.package(name: "${name}", path: "libs/${name}")`);
1671+
expect(pkg).toContain(`.product(name: "${name}", package: "${name}")`);
1672+
expect(
1673+
fs.existsSync(path.join(outDir, `headers/${name}/ios/Lib.h`)),
1674+
).toBe(true);
1675+
}
1676+
expect(pkg).not.toContain('"Foo", path: "libs/');
1677+
expect(pkg).not.toContain('package: "Foo"');
1678+
expect(fs.existsSync(path.join(outDir, 'headers/Foo'))).toBe(false);
1679+
});
1680+
1681+
it('still rejects a collision the scopes cannot resolve', () => {
1682+
// 'a-foo' already derives 'AFoo', the name '@a/foo' borrows.
1683+
const {appRoot, rnRoot} = buildFixture('@a/foo', '@b/foo', 'a-foo');
1684+
expect(() =>
1685+
main(['--app-root', appRoot, '--react-native-root', rnRoot]),
1686+
).toThrow(SpmNameCollisionError);
1687+
});
1688+
});
1689+
1690+
// ---------------------------------------------------------------------------
1691+
// autolinkingDepToSpmTarget — resolved names only, never re-derived ones.
1692+
// ---------------------------------------------------------------------------
1693+
1694+
describe('autolinkingDepToSpmTarget', () => {
1695+
const dep = (extra = {}) => ({
1696+
name: '@powersync/react-native',
1697+
root: '/dep',
1698+
platforms: {ios: {sourceDir: '/dep/ios'}},
1699+
...extra,
1700+
});
1701+
1702+
it('carries a resolved sibling name into the emitted sibling refs', () => {
1703+
const target = autolinkingDepToSpmTarget(
1704+
'react-native-consumer',
1705+
{
1706+
name: 'react-native-consumer',
1707+
root: '/consumer',
1708+
platforms: {ios: {sourceDir: '/consumer/ios'}},
1709+
swiftName: 'ReactNativeConsumer',
1710+
spmDependencies: ['@powersync/react-native'],
1711+
},
1712+
'/out',
1713+
new Map([['@powersync/react-native', 'PowersyncReactNative']]),
1714+
);
1715+
const manifest = generateSynthPackageSwift({
1716+
swiftName: target.name,
1717+
spmDependencies: (target.spmTargetDependencies ?? []).map(swiftName => ({
1718+
swiftName,
1719+
})),
1720+
hasReactDep: false,
1721+
targetPath: '.',
1722+
});
1723+
expect(manifest).toContain(
1724+
'.package(name: "PowersyncReactNative", path: "../PowersyncReactNative")',
1725+
);
1726+
expect(manifest).toContain(
1727+
'.product(name: "PowersyncReactNative", package: "PowersyncReactNative")',
1728+
);
1729+
expect(manifest).not.toContain('ReactNative", package: "ReactNative"');
1730+
});
1731+
1732+
it('fails loudly instead of re-deriving a dep with no resolved name', () => {
1733+
expect(() =>
1734+
autolinkingDepToSpmTarget(
1735+
'@powersync/react-native',
1736+
dep(),
1737+
'/out',
1738+
new Map(),
1739+
),
1740+
).toThrow(/expandSpmDependencies/);
1741+
});
1742+
1743+
it('fails loudly instead of re-deriving an unmapped spm.dependency', () => {
1744+
expect(() =>
1745+
autolinkingDepToSpmTarget(
1746+
'react-native-consumer',
1747+
{
1748+
name: 'react-native-consumer',
1749+
root: '/consumer',
1750+
platforms: {ios: {sourceDir: '/consumer/ios'}},
1751+
swiftName: 'ReactNativeConsumer',
1752+
spmDependencies: ['@powersync/react-native'],
1753+
},
1754+
'/out',
1755+
new Map(),
1756+
),
1757+
).toThrow(/@powersync\/react-native/);
1758+
expect(() =>
1759+
autolinkingDepToSpmTarget(
1760+
'react-native-consumer',
1761+
{
1762+
name: 'react-native-consumer',
1763+
root: '/consumer',
1764+
platforms: {ios: {sourceDir: '/consumer/ios'}},
1765+
swiftName: 'ReactNativeConsumer',
1766+
spmDependencies: ['@powersync/react-native'],
1767+
},
1768+
'/out',
1769+
new Map(),
1770+
),
1771+
).toThrow(/expandSpmDependencies/);
1772+
});
1773+
});

0 commit comments

Comments
 (0)