Skip to content

Commit dfdb704

Browse files
chrfalchclaude
andcommitted
Hold an app's spm.modules names to the same rules as a library's
`spm.modules` names go into the generated manifest exactly as written, with no validation at all: a name React Native reserves produced the same opaque SwiftPM duplicate-name failure a library's name did, a name that is not a Swift identifier produced a manifest SwiftPM refuses to parse, and two modules — or a module and an autolinked library — could quietly claim the same target name. Run the reserved-name and charset checks the library surface already has, and check each name against the targets already emitted, so the app author is told which entry to rename in their own react-native.config.js. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3f937fb commit dfdb704

3 files changed

Lines changed: 189 additions & 7 deletions

File tree

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

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,6 +1235,137 @@ describe('main() — autolinking plugin host exemption', () => {
12351235
);
12361236
});
12371237

1238+
// ---------------------------------------------------------------------------
1239+
// main() — spm.modules name validation
1240+
//
1241+
// App-local module names land in the manifest exactly as written, so they need
1242+
// the checks an autolinked dep's Swift name gets: a valid identifier, not one
1243+
// of React Native's reserved names, and unique across modules and deps.
1244+
// ---------------------------------------------------------------------------
1245+
1246+
describe('main() — spm.modules names', () => {
1247+
let created = [];
1248+
let spies = [];
1249+
1250+
beforeEach(() => {
1251+
for (const m of ['log', 'warn', 'error']) {
1252+
spies.push(jest.spyOn(console, m).mockImplementation(() => {}));
1253+
}
1254+
});
1255+
1256+
afterEach(() => {
1257+
for (const s of spies) s.mockRestore();
1258+
spies = [];
1259+
for (const d of created) fs.rmSync(d, {recursive: true, force: true});
1260+
created = [];
1261+
});
1262+
1263+
// App fixture whose react-native.config.js declares `spm.modules`, plus an
1264+
// optional autolinked dep (for the module-vs-dep collision case).
1265+
function buildApp({modules, dep}) {
1266+
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-modules-'));
1267+
created.push(appRoot);
1268+
const rnRoot = path.join(appRoot, 'rn');
1269+
fs.mkdirSync(rnRoot, {recursive: true});
1270+
fs.writeFileSync(
1271+
path.join(appRoot, 'package.json'),
1272+
JSON.stringify({name: 'app'}),
1273+
);
1274+
for (const mod of modules) {
1275+
const modDir = path.join(appRoot, mod.path);
1276+
fs.mkdirSync(modDir, {recursive: true});
1277+
fs.writeFileSync(path.join(modDir, 'Module.mm'), '// native source\n');
1278+
}
1279+
fs.writeFileSync(
1280+
path.join(appRoot, 'react-native.config.js'),
1281+
`module.exports = ${JSON.stringify({spm: {modules}})};\n`,
1282+
);
1283+
const dependencies = {};
1284+
if (dep != null) {
1285+
const depDir = path.join(appRoot, 'node_modules', dep.name);
1286+
fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true});
1287+
fs.writeFileSync(
1288+
path.join(depDir, 'ios', 'Dep.mm'),
1289+
'// native source\n',
1290+
);
1291+
fs.writeFileSync(
1292+
path.join(depDir, 'Package.swift'),
1293+
'// swift-tools-version: 6.0\n',
1294+
);
1295+
dependencies[dep.name] = {root: depDir, platforms: {ios: {}}};
1296+
}
1297+
const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking');
1298+
fs.mkdirSync(autolinkDir, {recursive: true});
1299+
fs.writeFileSync(
1300+
path.join(autolinkDir, 'autolinking.json'),
1301+
JSON.stringify({dependencies}),
1302+
);
1303+
return {appRoot, rnRoot};
1304+
}
1305+
1306+
const run = ({appRoot, rnRoot}) =>
1307+
main(['--app-root', appRoot, '--react-native-root', rnRoot]);
1308+
1309+
it('accepts a normal module name', () => {
1310+
const app = buildApp({
1311+
modules: [{name: 'MyNativeModule', path: 'ios/MyNativeModule'}],
1312+
});
1313+
expect(() => run(app)).not.toThrow();
1314+
});
1315+
1316+
it('rejects a module named after a reserved React Native name', () => {
1317+
const app = buildApp({
1318+
modules: [{name: 'ReactNative', path: 'ios/MyNativeModule'}],
1319+
});
1320+
expect(() => run(app)).toThrow(SpmNameCollisionError);
1321+
expect(() => run(app)).toThrow(
1322+
/the 'spm.modules' entry 'ReactNative' resolves to 'ReactNative', which React Native reserves/,
1323+
);
1324+
expect(() => run(app)).toThrow(/'spm\.modules'\.$/);
1325+
});
1326+
1327+
it('rejects a reserved product name in any casing', () => {
1328+
const app = buildApp({
1329+
modules: [{name: 'reactheaders', path: 'ios/MyNativeModule'}],
1330+
});
1331+
expect(() => run(app)).toThrow(SpmNameCollisionError);
1332+
expect(() => run(app)).toThrow(
1333+
/the 'spm\.modules' entry 'reactheaders' resolves to 'reactheaders', which differs from React Native's reserved 'ReactHeaders' only in case/,
1334+
);
1335+
});
1336+
1337+
it('rejects a module name that is not a valid Swift identifier', () => {
1338+
const app = buildApp({
1339+
modules: [{name: 'My Module', path: 'ios/MyNativeModule'}],
1340+
});
1341+
expect(() => run(app)).toThrow(/invalid 'spm.modules' name "My Module"/);
1342+
});
1343+
1344+
it('rejects two modules resolving to the same name', () => {
1345+
const app = buildApp({
1346+
modules: [
1347+
{name: 'Shared', path: 'ios/one'},
1348+
{name: 'shared', path: 'ios/two'},
1349+
],
1350+
});
1351+
expect(() => run(app)).toThrow(SpmNameCollisionError);
1352+
expect(() => run(app)).toThrow(
1353+
/the 'spm.modules' entry 'shared' differs from the existing target 'Shared' only in case/,
1354+
);
1355+
});
1356+
1357+
it('rejects a module colliding with an autolinked dep', () => {
1358+
const app = buildApp({
1359+
modules: [{name: 'ReactNativeFoo', path: 'ios/MyNativeModule'}],
1360+
dep: {name: 'react-native-foo'},
1361+
});
1362+
expect(() => run(app)).toThrow(SpmNameCollisionError);
1363+
expect(() => run(app)).toThrow(
1364+
/the 'spm.modules' entry 'ReactNativeFoo' is already the name of another autolinked target/,
1365+
);
1366+
});
1367+
});
1368+
12381369
// ---------------------------------------------------------------------------
12391370
// main() — plugin flavoredFrameworks sidecar
12401371
//

packages/react-native/scripts/spm/expand-spm-dependencies.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ class SpmNameCollisionError extends Error {
7373

7474
// The charset `spm.name` must satisfy — permissive on purpose, since it has to
7575
// admit header-dir style (lowercase with hyphens) as well as Swift identifiers.
76+
// Shared with the app's own `spm.modules` names.
7677
function isValidSwiftName(name /*: unknown */) /*: boolean */ {
7778
return typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name);
7879
}

packages/react-native/scripts/spm/generate-spm-autolinking.js

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,12 @@
5959

6060
const {discoverPlugins, invokePlugins} = require('./autolinking-plugins');
6161
const {
62+
SpmNameCollisionError,
63+
assertSwiftNameNotReserved,
6264
defaultReadConfig,
6365
defaultResolveDep,
6466
expandSpmDependencies,
67+
isValidSwiftName,
6568
} = require('./expand-spm-dependencies');
6669
const {readPodspec} = require('./read-podspec');
6770
const {
@@ -264,6 +267,41 @@ function readSpmModulesFromConfig(
264267
}
265268
}
266269

270+
/**
271+
* Validates one app-local `spm.modules` name against the same rules a library's
272+
* `spm.name` gets: a usable Swift identifier, not a name React Native reserves,
273+
* and not one already taken by another module or an autolinked dep.
274+
* `taken` maps lower-cased name → the name as written.
275+
*/
276+
function assertSpmModuleName(
277+
name /*: unknown */,
278+
taken /*: Map<string, string> */,
279+
) /*: void */ {
280+
const remedy =
281+
"Rename it in this app's react-native.config.js 'spm.modules'.";
282+
if (typeof name !== 'string' || !isValidSwiftName(name)) {
283+
throw new Error(
284+
`react-native autolinking: invalid 'spm.modules' name ${JSON.stringify(name) ?? 'undefined'}: must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`,
285+
);
286+
}
287+
const moduleName = name;
288+
assertSwiftNameNotReserved(moduleName, {
289+
label: `the 'spm.modules' entry '${moduleName}'`,
290+
remedy,
291+
extraReservedNames: reservedNamesForRun(),
292+
});
293+
const clash = taken.get(moduleName.toLowerCase());
294+
if (clash != null) {
295+
throw new SpmNameCollisionError(
296+
`react-native autolinking: SPM Swift name collision: the 'spm.modules' entry '${moduleName}' ` +
297+
(clash === moduleName
298+
? `is already the name of another autolinked target.`
299+
: `differs from the existing target '${clash}' only in case, which collides on case-insensitive filesystems.`) +
300+
` ${remedy}`,
301+
);
302+
}
303+
}
304+
267305
/**
268306
* Reads the app's `spm.denyPlugins` — npm names of autolinking plugins to
269307
* skip. The escape hatch for the transitive plugin discovery (an app opts a
@@ -1377,7 +1415,15 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
13771415
// the globs now relative to its dir and attach the file list to the target
13781416
// so the emission loop below renders `sources: [...]` literally.
13791417
const configModules = readSpmModulesFromConfig(appRoot);
1418+
// Module names land in the manifest exactly as written, so they get the same
1419+
// checks a dep's Swift name gets. Seeded with the dep target names already
1420+
// emitted so a module can't shadow an autolinked library either.
1421+
const takenSwiftNames /*: Map<string, string> */ = new Map(
1422+
entries.map(entry => [entry.target.name.toLowerCase(), entry.target.name]),
1423+
);
13801424
for (const mod of configModules) {
1425+
assertSpmModuleName(mod.name, takenSwiftNames);
1426+
takenSwiftNames.set(mod.name.toLowerCase(), mod.name);
13811427
const absPath = path.resolve(appRoot, mod.path);
13821428
const relPath = path.relative(outputDir, absPath);
13831429
const userSources =
@@ -1460,8 +1506,9 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
14601506
// longer silently synthesize one for them (that duplicated the scaffolder and
14611507
// hid the gap from the developer and the library author) — collect them and
14621508
// fail with an actionable message after the classification pass. spmModules
1463-
// (app-local, podspec-less, explicitly declared in react-native.config.js)
1464-
// keep their synth wrappers: there is nothing to scaffold for them.
1509+
// (app-local, explicitly declared in react-native.config.js) keep their synth
1510+
// wrappers: an app-local dir has no npm identity, so there is no package for
1511+
// the aggregator to reference until one is written for it.
14651512
const missingManifests /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */ =
14661513
[];
14671514

@@ -1513,11 +1560,14 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
15131560
}
15141561
continue;
15151562
}
1516-
// spmModule: synth wrapper is the legitimate mechanism (no podspec exists
1517-
// to scaffold from, and the app developer declared it explicitly). But a
1518-
// mixed-language module can't be wrapped either — SPM can't compile Swift +
1519-
// C-family sources in one target, and a synth wrapper would fail with a
1520-
// cryptic SPM resolve error. Surface the same friendly diagnostic the
1563+
// spmModule: the synth wrapper is the mechanism, not a fallback — an
1564+
// app-local dir has no npm identity, so the wrapper is the only package the
1565+
// aggregator can reference. No podspec is read on this route by design:
1566+
// app-local native code isn't required to carry one. (A hand-written
1567+
// Package.swift still wins — the self-managed check above claims it first.)
1568+
// But a mixed-language module can't be wrapped either — SPM can't compile
1569+
// Swift + C-family sources in one target, and a synth wrapper would fail
1570+
// with a cryptic SPM resolve error. Surface the same friendly diagnostic the
15211571
// community-dep path uses instead of letting SPM emit the cryptic one.
15221572
if (hasMixedLanguageSources(absSource)) {
15231573
throw new Error(

0 commit comments

Comments
 (0)