Skip to content

Commit cdaf50b

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 b8cc821 commit cdaf50b

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
@@ -1179,6 +1179,137 @@ describe('main() — autolinking plugin host exemption', () => {
11791179
);
11801180
});
11811181

1182+
// ---------------------------------------------------------------------------
1183+
// main() — spm.modules name validation
1184+
//
1185+
// App-local module names land in the manifest exactly as written, so they need
1186+
// the checks an autolinked dep's Swift name gets: a valid identifier, not one
1187+
// of React Native's reserved names, and unique across modules and deps.
1188+
// ---------------------------------------------------------------------------
1189+
1190+
describe('main() — spm.modules names', () => {
1191+
let created = [];
1192+
let spies = [];
1193+
1194+
beforeEach(() => {
1195+
for (const m of ['log', 'warn', 'error']) {
1196+
spies.push(jest.spyOn(console, m).mockImplementation(() => {}));
1197+
}
1198+
});
1199+
1200+
afterEach(() => {
1201+
for (const s of spies) s.mockRestore();
1202+
spies = [];
1203+
for (const d of created) fs.rmSync(d, {recursive: true, force: true});
1204+
created = [];
1205+
});
1206+
1207+
// App fixture whose react-native.config.js declares `spm.modules`, plus an
1208+
// optional autolinked dep (for the module-vs-dep collision case).
1209+
function buildApp({modules, dep}) {
1210+
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-modules-'));
1211+
created.push(appRoot);
1212+
const rnRoot = path.join(appRoot, 'rn');
1213+
fs.mkdirSync(rnRoot, {recursive: true});
1214+
fs.writeFileSync(
1215+
path.join(appRoot, 'package.json'),
1216+
JSON.stringify({name: 'app'}),
1217+
);
1218+
for (const mod of modules) {
1219+
const modDir = path.join(appRoot, mod.path);
1220+
fs.mkdirSync(modDir, {recursive: true});
1221+
fs.writeFileSync(path.join(modDir, 'Module.mm'), '// native source\n');
1222+
}
1223+
fs.writeFileSync(
1224+
path.join(appRoot, 'react-native.config.js'),
1225+
`module.exports = ${JSON.stringify({spm: {modules}})};\n`,
1226+
);
1227+
const dependencies = {};
1228+
if (dep != null) {
1229+
const depDir = path.join(appRoot, 'node_modules', dep.name);
1230+
fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true});
1231+
fs.writeFileSync(
1232+
path.join(depDir, 'ios', 'Dep.mm'),
1233+
'// native source\n',
1234+
);
1235+
fs.writeFileSync(
1236+
path.join(depDir, 'Package.swift'),
1237+
'// swift-tools-version: 6.0\n',
1238+
);
1239+
dependencies[dep.name] = {root: depDir, platforms: {ios: {}}};
1240+
}
1241+
const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking');
1242+
fs.mkdirSync(autolinkDir, {recursive: true});
1243+
fs.writeFileSync(
1244+
path.join(autolinkDir, 'autolinking.json'),
1245+
JSON.stringify({dependencies}),
1246+
);
1247+
return {appRoot, rnRoot};
1248+
}
1249+
1250+
const run = ({appRoot, rnRoot}) =>
1251+
main(['--app-root', appRoot, '--react-native-root', rnRoot]);
1252+
1253+
it('accepts a normal module name', () => {
1254+
const app = buildApp({
1255+
modules: [{name: 'MyNativeModule', path: 'ios/MyNativeModule'}],
1256+
});
1257+
expect(() => run(app)).not.toThrow();
1258+
});
1259+
1260+
it('rejects a module named after a reserved React Native name', () => {
1261+
const app = buildApp({
1262+
modules: [{name: 'ReactNative', path: 'ios/MyNativeModule'}],
1263+
});
1264+
expect(() => run(app)).toThrow(SpmNameCollisionError);
1265+
expect(() => run(app)).toThrow(
1266+
/the 'spm.modules' entry 'ReactNative' resolves to 'ReactNative', which React Native reserves/,
1267+
);
1268+
expect(() => run(app)).toThrow(/'spm\.modules'\.$/);
1269+
});
1270+
1271+
it('rejects a reserved product name in any casing', () => {
1272+
const app = buildApp({
1273+
modules: [{name: 'reactheaders', path: 'ios/MyNativeModule'}],
1274+
});
1275+
expect(() => run(app)).toThrow(SpmNameCollisionError);
1276+
expect(() => run(app)).toThrow(
1277+
/the 'spm\.modules' entry 'reactheaders' resolves to 'reactheaders', which differs from React Native's reserved 'ReactHeaders' only in case/,
1278+
);
1279+
});
1280+
1281+
it('rejects a module name that is not a valid Swift identifier', () => {
1282+
const app = buildApp({
1283+
modules: [{name: 'My Module', path: 'ios/MyNativeModule'}],
1284+
});
1285+
expect(() => run(app)).toThrow(/invalid 'spm.modules' name "My Module"/);
1286+
});
1287+
1288+
it('rejects two modules resolving to the same name', () => {
1289+
const app = buildApp({
1290+
modules: [
1291+
{name: 'Shared', path: 'ios/one'},
1292+
{name: 'shared', path: 'ios/two'},
1293+
],
1294+
});
1295+
expect(() => run(app)).toThrow(SpmNameCollisionError);
1296+
expect(() => run(app)).toThrow(
1297+
/the 'spm.modules' entry 'shared' differs from the existing target 'Shared' only in case/,
1298+
);
1299+
});
1300+
1301+
it('rejects a module colliding with an autolinked dep', () => {
1302+
const app = buildApp({
1303+
modules: [{name: 'ReactNativeFoo', path: 'ios/MyNativeModule'}],
1304+
dep: {name: 'react-native-foo'},
1305+
});
1306+
expect(() => run(app)).toThrow(SpmNameCollisionError);
1307+
expect(() => run(app)).toThrow(
1308+
/the 'spm.modules' entry 'ReactNativeFoo' is already the name of another autolinked target/,
1309+
);
1310+
});
1311+
});
1312+
11821313
// ---------------------------------------------------------------------------
11831314
// main() — plugin flavoredFrameworks sidecar
11841315
//

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

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

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

‎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 {
@@ -265,6 +268,41 @@ function readSpmModulesFromConfig(
265268
}
266269
}
267270

271+
/**
272+
* Validates one app-local `spm.modules` name against the same rules a library's
273+
* `spm.name` gets: a usable Swift identifier, not a name React Native reserves,
274+
* and not one already taken by another module or an autolinked dep.
275+
* `taken` maps lower-cased name → the name as written.
276+
*/
277+
function assertSpmModuleName(
278+
name /*: unknown */,
279+
taken /*: Map<string, string> */,
280+
) /*: void */ {
281+
const remedy =
282+
"Rename it in this app's react-native.config.js 'spm.modules'.";
283+
if (typeof name !== 'string' || !isValidSwiftName(name)) {
284+
throw new Error(
285+
`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.`,
286+
);
287+
}
288+
const moduleName = name;
289+
assertSwiftNameNotReserved(moduleName, {
290+
label: `the 'spm.modules' entry '${moduleName}'`,
291+
remedy,
292+
extraReservedNames: reservedNamesForRun(),
293+
});
294+
const clash = taken.get(moduleName.toLowerCase());
295+
if (clash != null) {
296+
throw new SpmNameCollisionError(
297+
`react-native autolinking: SPM Swift name collision: the 'spm.modules' entry '${moduleName}' ` +
298+
(clash === moduleName
299+
? `is already the name of another autolinked target.`
300+
: `differs from the existing target '${clash}' only in case, which collides on case-insensitive filesystems.`) +
301+
` ${remedy}`,
302+
);
303+
}
304+
}
305+
268306
/**
269307
* Reads the app's `spm.denyPlugins` — npm names of autolinking plugins to
270308
* skip. The escape hatch for the transitive plugin discovery (an app opts a
@@ -1347,7 +1385,15 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
13471385
// the globs now relative to its dir and attach the file list to the target
13481386
// so the emission loop below renders `sources: [...]` literally.
13491387
const configModules = readSpmModulesFromConfig(appRoot);
1388+
// Module names land in the manifest exactly as written, so they get the same
1389+
// checks a dep's Swift name gets. Seeded with the dep target names already
1390+
// emitted so a module can't shadow an autolinked library either.
1391+
const takenSwiftNames /*: Map<string, string> */ = new Map(
1392+
entries.map(entry => [entry.target.name.toLowerCase(), entry.target.name]),
1393+
);
13501394
for (const mod of configModules) {
1395+
assertSpmModuleName(mod.name, takenSwiftNames);
1396+
takenSwiftNames.set(mod.name.toLowerCase(), mod.name);
13511397
const absPath = path.resolve(appRoot, mod.path);
13521398
const relPath = path.relative(outputDir, absPath);
13531399
const userSources =
@@ -1428,8 +1474,9 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
14281474
// longer silently synthesize one for them (that duplicated the scaffolder and
14291475
// hid the gap from the developer and the library author) — collect them and
14301476
// fail with an actionable message after the classification pass. spmModules
1431-
// (app-local, podspec-less, explicitly declared in react-native.config.js)
1432-
// keep their synth wrappers: there is nothing to scaffold for them.
1477+
// (app-local, explicitly declared in react-native.config.js) keep their synth
1478+
// wrappers: an app-local dir has no npm identity, so there is no package for
1479+
// the aggregator to reference until one is written for it.
14331480
const missingManifests /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */ =
14341481
[];
14351482

@@ -1481,11 +1528,14 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
14811528
}
14821529
continue;
14831530
}
1484-
// spmModule: synth wrapper is the legitimate mechanism (no podspec exists
1485-
// to scaffold from, and the app developer declared it explicitly). But a
1486-
// mixed-language module can't be wrapped either — SPM can't compile Swift +
1487-
// C-family sources in one target, and a synth wrapper would fail with a
1488-
// cryptic SPM resolve error. Surface the same friendly diagnostic the
1531+
// spmModule: the synth wrapper is the mechanism, not a fallback — an
1532+
// app-local dir has no npm identity, so the wrapper is the only package the
1533+
// aggregator can reference. No podspec is read on this route by design:
1534+
// app-local native code isn't required to carry one. (A hand-written
1535+
// Package.swift still wins — the self-managed check above claims it first.)
1536+
// But a mixed-language module can't be wrapped either — SPM can't compile
1537+
// Swift + C-family sources in one target, and a synth wrapper would fail
1538+
// with a cryptic SPM resolve error. Surface the same friendly diagnostic the
14891539
// community-dep path uses instead of letting SPM emit the cryptic one.
14901540
if (hasMixedLanguageSources(absSource)) {
14911541
throw new Error(

0 commit comments

Comments
 (0)