Skip to content

Commit 3355acc

Browse files
committed
ci: add wiring check
1 parent ebca4f8 commit 3355acc

File tree

4 files changed

+264
-1
lines changed

4 files changed

+264
-1
lines changed

.github/workflows/build.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ jobs:
2727
- name: Install dependencies
2828
id: install_code
2929
run: bun i
30+
- name: Check plugin wiring
31+
run: bun run check:wiring
3032
- name: Build
3133
id: build_code
3234
run: bun run build

.github/workflows/test.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ jobs:
3030
- name: Install dependencies
3131
id: install_code
3232
run: bun i
33+
- name: Check plugin wiring
34+
run: bun run check:wiring
3335
- name: Setup java
3436
uses: actions/setup-java@v5
3537
with:
@@ -47,6 +49,8 @@ jobs:
4749
- name: Install dependencies
4850
id: install_code
4951
run: bun i
52+
- name: Check plugin wiring
53+
run: bun run check:wiring
5054
- name: Build
5155
id: build_code
5256
run: bun run verify:ios
@@ -60,6 +64,8 @@ jobs:
6064
- name: Install dependencies
6165
id: install_code
6266
run: bun i
67+
- name: Check plugin wiring
68+
run: bun run check:wiring
6369
- name: Lint
6470
id: lint_code
6571
run: bun run lint

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
5050
"clean": "rimraf ./dist",
5151
"watch": "tsc --watch",
52-
"prepublishOnly": "npm run build"
52+
"prepublishOnly": "npm run build",
53+
"check:wiring": "node scripts/check-capacitor-plugin-wiring.mjs"
5354
},
5455
"devDependencies": {
5556
"@capacitor/android": "^8.0.0",
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Capacitor plugin wiring/name checker.
4+
*
5+
* Enforces that runtime plugin name matches across:
6+
* - JS: registerPlugin('Name')
7+
* - Android: @CapacitorPlugin(name = "Name")
8+
* - iOS: CAPBridgedPlugin jsName = "Name"
9+
*
10+
* And (when iOS is declared in package.json capacitor config):
11+
* - CocoaPods podspec s.name matches SwiftPM Package(name: ...) and .library(name: ...)
12+
*
13+
* Usage:
14+
* node tools/check-capacitor-plugin-wiring.mjs # checks current working dir
15+
* node tools/check-capacitor-plugin-wiring.mjs --dir path # checks given plugin dir
16+
*/
17+
18+
import fs from "node:fs";
19+
import path from "node:path";
20+
21+
const SKIP_DIRS = new Set([
22+
"node_modules",
23+
"dist",
24+
"build",
25+
".build",
26+
".gradle",
27+
"Pods",
28+
"DerivedData",
29+
".swiftpm",
30+
".git",
31+
]);
32+
33+
function readText(p) {
34+
try {
35+
return fs.readFileSync(p, "utf8");
36+
} catch {
37+
return "";
38+
}
39+
}
40+
41+
function exists(p) {
42+
try {
43+
fs.accessSync(p);
44+
return true;
45+
} catch {
46+
return false;
47+
}
48+
}
49+
50+
function walkFiles(rootDir, exts) {
51+
const out = [];
52+
const stack = [rootDir];
53+
while (stack.length) {
54+
const dir = stack.pop();
55+
let entries;
56+
try {
57+
entries = fs.readdirSync(dir, { withFileTypes: true });
58+
} catch {
59+
continue;
60+
}
61+
for (const e of entries) {
62+
if (e.isDirectory()) {
63+
if (SKIP_DIRS.has(e.name)) continue;
64+
stack.push(path.join(dir, e.name));
65+
continue;
66+
}
67+
if (!e.isFile()) continue;
68+
for (const ext of exts) {
69+
if (e.name.endsWith(ext)) {
70+
out.push(path.join(dir, e.name));
71+
break;
72+
}
73+
}
74+
}
75+
}
76+
out.sort();
77+
return out;
78+
}
79+
80+
function uniq(arr) {
81+
const out = [];
82+
for (const x of arr) {
83+
if (!x) continue;
84+
if (!out.includes(x)) out.push(x);
85+
}
86+
return out;
87+
}
88+
89+
function parseArgs(argv) {
90+
const out = { dir: process.cwd() };
91+
for (let i = 2; i < argv.length; i++) {
92+
const a = argv[i];
93+
if (a === "--dir" || a === "--pluginDir") {
94+
out.dir = path.resolve(argv[++i] || ".");
95+
continue;
96+
}
97+
}
98+
return out;
99+
}
100+
101+
const args = parseArgs(process.argv);
102+
const pluginDir = args.dir;
103+
const pkgPath = path.join(pluginDir, "package.json");
104+
105+
if (!exists(pkgPath)) {
106+
console.error(`[wiring] ERROR: missing package.json in ${pluginDir}`);
107+
process.exit(2);
108+
}
109+
110+
let pkg;
111+
try {
112+
pkg = JSON.parse(readText(pkgPath));
113+
} catch (e) {
114+
console.error(`[wiring] ERROR: invalid package.json (${pkgPath}): ${e?.message || e}`);
115+
process.exit(2);
116+
}
117+
118+
const cap = typeof pkg.capacitor === "object" && pkg.capacitor ? pkg.capacitor : {};
119+
const supportsAndroid = typeof cap.android === "object" && cap.android;
120+
const supportsIos = typeof cap.ios === "object" && cap.ios;
121+
122+
// Not a Capacitor plugin package (e.g. meta/workspace package).
123+
// We only enforce wiring rules for actual plugin packages declaring a `capacitor` config.
124+
if (!supportsAndroid && !supportsIos) {
125+
process.exit(0);
126+
}
127+
128+
// ---------------- JS (registerPlugin) ----------------
129+
const jsSrcDir = path.join(pluginDir, "src");
130+
let jsName = "";
131+
if (exists(jsSrcDir)) {
132+
const jsFiles = walkFiles(jsSrcDir, [".ts", ".js"]);
133+
const reRegister = /registerPlugin(?:<[^>]*>)?\(\s*['"]([^'"]+)['"]/;
134+
for (const f of jsFiles) {
135+
const m = reRegister.exec(readText(f));
136+
if (m) {
137+
jsName = m[1];
138+
break;
139+
}
140+
}
141+
}
142+
143+
// ---------------- Android (@CapacitorPlugin) ----------------
144+
let androidNames = [];
145+
if (supportsAndroid) {
146+
const androidMain = path.join(pluginDir, "android", "src", "main");
147+
const files = walkFiles(androidMain, [".java", ".kt"]);
148+
const foundAnnotations = [];
149+
for (const f of files) {
150+
const txt = readText(f);
151+
if (!txt.includes("@CapacitorPlugin")) continue;
152+
foundAnnotations.push(f);
153+
const m =
154+
/@CapacitorPlugin\(\s*name\s*=\s*"([^"]+)"/.exec(txt) ||
155+
/@CapacitorPlugin\(\s*name\s*=\s*([A-Za-z0-9_]+)\b/.exec(txt);
156+
if (m) androidNames.push(m[1]);
157+
}
158+
androidNames = uniq(androidNames);
159+
160+
// Enforce explicit name attribute to prevent silent class-name drift.
161+
if (foundAnnotations.length && !androidNames.length) {
162+
console.error(
163+
`[wiring] ERROR: Android has @CapacitorPlugin but none specify name = \"...\". Add an explicit name to the plugin class.`
164+
);
165+
process.exit(1);
166+
}
167+
}
168+
169+
// ---------------- iOS (jsName) ----------------
170+
let iosJsNames = [];
171+
if (supportsIos) {
172+
const iosDir = path.join(pluginDir, "ios");
173+
const scanRoot = exists(path.join(iosDir, "Sources")) ? path.join(iosDir, "Sources") : iosDir;
174+
const swiftFiles = walkFiles(scanRoot, [".swift"]);
175+
const reJsName = /\bjsName\s*=\s*"([^"]+)"/g;
176+
for (const f of swiftFiles) {
177+
const txt = readText(f);
178+
if (!txt.includes("jsName")) continue;
179+
let m;
180+
while ((m = reJsName.exec(txt))) iosJsNames.push(m[1]);
181+
}
182+
iosJsNames = uniq(iosJsNames);
183+
}
184+
185+
// ---------------- Podspec/SPM ----------------
186+
function parsePodspecName(podspecPath) {
187+
const txt = readText(podspecPath);
188+
const m = /\bs\.name\s*=\s*'([^']+)'/.exec(txt);
189+
return m ? m[1] : "";
190+
}
191+
192+
function parseSpmNames(packageSwiftPath) {
193+
const txt = readText(packageSwiftPath);
194+
const pkg = /Package\(\s*name\s*:\s*"([^"]+)"/.exec(txt)?.[1] || "";
195+
const libs = [];
196+
const reLib = /\.library\(\s*name\s*:\s*"([^"]+)"/g;
197+
let m;
198+
while ((m = reLib.exec(txt))) libs.push(m[1]);
199+
return { pkgName: pkg, libNames: uniq(libs) };
200+
}
201+
202+
// ---------------- Validate ----------------
203+
const errors = [];
204+
205+
if (!jsName) {
206+
errors.push("JS: no registerPlugin('...') found under src/");
207+
}
208+
209+
if (supportsAndroid) {
210+
if (!androidNames.length) errors.push("Android: missing @CapacitorPlugin(name = \"...\")");
211+
if (jsName && androidNames.length && androidNames.some((n) => n !== jsName)) {
212+
errors.push(`Android: @CapacitorPlugin(name)=${JSON.stringify(androidNames)} != JS registerPlugin=${jsName}`);
213+
}
214+
}
215+
216+
if (supportsIos) {
217+
if (!iosJsNames.length) errors.push('iOS: missing jsName = "..." in Swift sources');
218+
if (jsName && iosJsNames.length && iosJsNames.some((n) => n !== jsName)) {
219+
errors.push(`iOS: jsName=${JSON.stringify(iosJsNames)} != JS registerPlugin=${jsName}`);
220+
}
221+
222+
const podspecs = fs
223+
.readdirSync(pluginDir, { withFileTypes: true })
224+
.filter((e) => e.isFile() && e.name.endsWith(".podspec"))
225+
.map((e) => path.join(pluginDir, e.name))
226+
.sort();
227+
if (!podspecs.length) errors.push("iOS: missing *.podspec at plugin root");
228+
if (podspecs.length > 1) errors.push(`iOS: multiple podspecs at plugin root: ${podspecs.map((p) => path.basename(p))}`);
229+
230+
const pkgSwift = path.join(pluginDir, "Package.swift");
231+
if (!exists(pkgSwift)) {
232+
errors.push("iOS: missing Package.swift at plugin root");
233+
} else if (podspecs.length) {
234+
const podName = parsePodspecName(podspecs[0]);
235+
const { pkgName, libNames } = parseSpmNames(pkgSwift);
236+
if (!podName) errors.push("Podspec: missing s.name = '...'");
237+
if (!pkgName) errors.push('SPM: missing Package(name: "...")');
238+
if (podName && pkgName && podName !== pkgName) {
239+
errors.push(`Podspec: s.name=${podName} != Package(name)=${pkgName}`);
240+
}
241+
if (pkgName && libNames.length && !libNames.includes(pkgName)) {
242+
errors.push(`SPM: Package(name)=${pkgName} not present in .library(name) list ${JSON.stringify(libNames)}`);
243+
}
244+
}
245+
}
246+
247+
if (errors.length) {
248+
const relDir = path.relative(process.cwd(), pluginDir) || ".";
249+
console.error(`[wiring] FAIL in ${relDir}`);
250+
for (const e of errors) console.error(`- ${e}`);
251+
process.exit(1);
252+
}
253+
254+
process.exit(0);

0 commit comments

Comments
 (0)