Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/Build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,7 @@ jobs:
run: pnpm -r --filter='!native-widgets' --filter=$(if [ "${{ steps.files.outputs.global_files }}" = "" ] && ${{ github.event_name == 'pull_request' }}; then echo '...[origin/mx/11.12.x]'; else echo '**'; fi) run build
env:
NODE_OPTIONS: --max_old_space_size=8192
- name: "Validating manifest format"
run: pnpm run validate-manifest-format
- name: "Validating native dependencies"
run: pnpm run validate-native-dependencies
1 change: 1 addition & 0 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
. "$(dirname "$0")/_/husky.sh"

npx --no -- commitlint --edit "$1"
pnpm run validate-native-dependencies "$1"
11 changes: 9 additions & 2 deletions configs/jsactions/rollup-plugin-collect-dependencies.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,14 @@ async function resolvePackage(target, sourceDir, optional = false) {
}

async function hasNativeCode(dir) {
return (await fg(["**/{android,ios}/*", "**/*.podspec"], { cwd: dir })).length > 0;
return (await fg(
["**/{android,ios}/*", "**/*.podspec"],
{
cwd: dir,
ignore: ["**/example*/**", "**/__tests__/**", "**/docs/**", "**/.github/**"],
caseSensitiveMatch: false
}
)).length > 0;
}

async function getTransitiveDependencies(packagePath, isExternal) {
Expand Down Expand Up @@ -187,7 +194,7 @@ export async function copyJsModule(moduleSourcePath, to) {

// Skip certain directories
if (
relativePath.match(/[\\/](android|ios|windows|mac|jest|github|gradle|__.*__|docs|example.*)[\\/]/)
relativePath.match(/(^|[\\/])(android|ios|windows|mac|jest|\.github|gradle|__.*__|docs|example.*)([\\/]|$)/i)
) {
return false;
}
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
"build-mpk": "ts-node --project ./scripts/tsconfig.json ./scripts/release/build-mpk.ts",
"version": "ts-node --project ./scripts/tsconfig.json ./scripts/release/BumpVersion.ts",
"validate-staged-widget-versions": "node scripts/validation/validate-versions-staged-files.js",
"validate-manifest-format": "node --experimental-strip-types scripts/validation/validate-manifest-format.ts",
"validate-native-dependencies": "node --experimental-strip-types scripts/validation/validate-native-dependencies.ts",
"setup-mobile": "pnpm setup-android && pnpm setup-ios",
"build:widgets": "node ./scripts/widget/buildWidgets.js",
"test_widgets:maestro:ios": "bash maestro/run_maestro_widget_tests.sh ios",
Expand Down
21 changes: 18 additions & 3 deletions patches/@mendix__pluggable-widgets-tools@11.12.0.patch
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
diff --git a/configs/rollup-plugin-collect-dependencies.mjs b/configs/rollup-plugin-collect-dependencies.mjs
index d45c737bf32cb1029249fdc069b3df3f832d1ab4..9246919bd939c622447ba0227c5b695892f162a8 100644
index d45c737bf32cb1029249fdc069b3df3f832d1ab4..9f5ae6f6adf3e41220d897ba1aa074c7da458314 100644
--- a/configs/rollup-plugin-collect-dependencies.mjs
+++ b/configs/rollup-plugin-collect-dependencies.mjs
@@ -1,8 +1,7 @@
Expand All @@ -21,7 +21,22 @@ index d45c737bf32cb1029249fdc069b3df3f832d1ab4..9246919bd939c622447ba0227c5b6958
);
}
}
@@ -169,14 +168,28 @@ async function copyJsModule(moduleSourcePath, to) {
@@ -125,7 +124,13 @@ async function resolvePackage(target, sourceDir, optional = false) {
}

async function hasNativeCode(dir) {
- return (await fg(["**/{android,ios}/*", "**/*.podspec"], { cwd: dir })).length > 0;
+ return (await fg(
+ ["**/{android,ios}/*", "**/*.podspec"],
+ {
+ cwd: dir,
+ ignore: ["**/example*/**", "**/__tests__/**", "**/docs/**"]
+ }
+ )).length > 0;
}

async function getTransitiveDependencies(packagePath, isExternal) {
@@ -169,14 +174,28 @@ async function copyJsModule(moduleSourcePath, to) {
if (existsSync(to)) {
return;
}
Expand All @@ -45,7 +60,7 @@ index d45c737bf32cb1029249fdc069b3df3f832d1ab4..9246919bd939c622447ba0227c5b6958
+ filter: src => {
+ const relativePath = src.replace(actualSourcePath, "").replace(/^[\\/]/, "");
+
+ if (relativePath.match(/[\\/](android|ios|windows|mac|jest|github|gradle|__.*__|docs|example.*)[\\/]/)) {
+ if (relativePath.match(/(^|[\\/])(android|ios|windows|mac|jest|github|gradle|__.*__|docs|example.*)([\\/]|$)/)) {
+ return false;
+ }
+
Expand Down
92 changes: 46 additions & 46 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

118 changes: 118 additions & 0 deletions scripts/validation/validate-manifest-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { readFileSync, existsSync, readdirSync } from "fs";
import { join } from "path";

/**
* CI Check: Prevent Empty Manifest Objects
*
* Purpose: Ensure JS-only widgets don't emit empty {} manifests.
* Studio Pro's JSON parser uses MissingMemberHandling.Error with [JsonRequired],
* so an empty object would fail the entire manifest file parse.
*
* Expected behavior:
* - Widgets WITH native dependencies: emit {"nativeDependencies": {...}}
* - Widgets WITHOUT native dependencies: emit NO FILE at all
*/

interface Manifest {
nativeDependencies?: Record<string, string>;
[key: string]: unknown;
}

interface Violation {
widget: string;
file: string;
reason: string;
}

try {
validateManifestFormat();
} catch (error) {
console.error(error);
process.exit(1);
}

function validateManifestFormat(): void {
const violations: Violation[] = [];
const widgetsDir = join(process.cwd(), "packages/pluggableWidgets");

if (!existsSync(widgetsDir)) {
console.log("✅ No widgets directory found");
return;
}

const widgets = readdirSync(widgetsDir);
let checkedCount = 0;

for (const widget of widgets) {
const manifestDir = join(widgetsDir, widget, "dist/tmp/widgets");

if (!existsSync(manifestDir)) continue;

const files = readdirSync(manifestDir).filter(f => f.endsWith(".json") && f !== "package.json");

for (const file of files) {
checkedCount++;
const fullPath = join(manifestDir, file);

try {
const content = readFileSync(fullPath, "utf-8");

// Check if file is empty (not even valid JSON)
if (!content.trim()) {
violations.push({
widget,
file,
reason: "File is completely empty (not even valid JSON)"
});
continue;
}

const manifest: Manifest = JSON.parse(content);

// Check for empty manifest or empty nativeDependencies object
const isEmpty =
Object.keys(manifest).length === 0 ||
(manifest.nativeDependencies && Object.keys(manifest.nativeDependencies).length === 0);

if (isEmpty) {
violations.push({
widget,
file,
reason:
Object.keys(manifest).length === 0
? "Manifest is completely empty: {}"
: "nativeDependencies object is empty: {}"
});
}
} catch (error) {
// Invalid JSON is also a violation
violations.push({
widget,
file,
reason: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`
});
}
}
}

if (violations.length > 0) {
console.error("\n❌ EMPTY MANIFEST VIOLATION DETECTED\n");
console.error("The following widgets have empty manifest files:\n");

for (const violation of violations) {
console.error(` Widget: ${violation.widget}`);
console.error(` File: ${violation.file}`);
console.error(` Issue: ${violation.reason}\n`);
}

console.error("⚠️ Empty manifests break Studio Pro's JSON parser!");
console.error(" Expected behavior:");
console.error(" - Widgets WITH native deps: emit {nativeDependencies: {...}}");
console.error(" - Widgets WITHOUT native deps: emit NO FILE at all\n");
console.error("This is a bug in the build tooling. Check writeNativeDependenciesJson().\n");

throw new Error("Manifest format validation failed");
}

console.log(`✅ All manifests valid (checked ${checkedCount} manifest files)`);
}
184 changes: 184 additions & 0 deletions scripts/validation/validate-native-dependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { existsSync, readdirSync, readFileSync } from "fs";
import { join } from "path";
import { execSync } from "child_process";
import * as fg from "fast-glob";

/**
* CI Check: Detect New Native Dependencies
*
* Purpose: Warn when a widget adds a new dependency that contains native code.
* This helps catch when developers accidentally add native dependencies,
* which forces customers to rebuild their mobile apps.
*
* How it works:
* - Compares package.json dependencies against parent commit (HEAD^)
* - For any NEW dependencies, checks if they contain native code
* - Warns if the new dependency has ios/android folders
*
* Bypass: Include "NATIVE_DEPENDENCY_APPROVED" in the commit message.
*/

interface PackageJson {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
}

interface Violation {
widget: string;
dependency: string;
path: string;
}

try {
await validateNativeDependencies();
} catch (error) {
console.error(error);
process.exit(1);
}

async function validateNativeDependencies(): Promise<void> {
// Check for bypass approval
let isApproved = false;

// In commit-msg hook: read from file path passed as argument
const commitMsgFile = process.argv[2];
if (commitMsgFile && existsSync(commitMsgFile)) {
const commitMessage = readFileSync(commitMsgFile, "utf-8");
isApproved = commitMessage.includes("NATIVE_DEPENDENCY_APPROVED");
} else {
// In CI: check all commits between HEAD^ and HEAD
try {
const commitMessages = execSync("git log HEAD^..HEAD --format=%B", {
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"]
});
isApproved = commitMessages.includes("NATIVE_DEPENDENCY_APPROVED");
} catch (error) {
// If git log fails (e.g., no parent commit), fall back to checking HEAD only
try {
const commitMessage = execSync("git log -1 --format=%B", {
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"]
});
isApproved = commitMessage.includes("NATIVE_DEPENDENCY_APPROVED");
} catch (fallbackError) {
// If even that fails, continue with validation
}
}
}

if (isApproved) {
console.log("✅ Native dependency changes approved via commit message");
return;
}

const violations: Violation[] = [];
const widgetsDir = join(process.cwd(), "packages/pluggableWidgets");

if (!existsSync(widgetsDir)) {
console.log("✅ No widgets directory found");
return;
}

const widgets = readdirSync(widgetsDir);
let checkedCount = 0;

for (const widget of widgets) {
const widgetPath = join(widgetsDir, widget);
const packageJsonPath = join(widgetPath, "package.json");

if (!existsSync(packageJsonPath)) continue;

checkedCount++;

try {
// Get current package.json
const currentPackageJson: PackageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
const currentDeps: Record<string, string> = {
...currentPackageJson.dependencies,
...currentPackageJson.peerDependencies
};

// Get previous package.json from git (HEAD^)
let previousDeps: Record<string, string> = {};
try {
// Git always uses forward slashes, even on Windows
const relativePath = join("packages/pluggableWidgets", widget, "package.json").replace(/\\/g, "/");
const previousContent = execSync(`git show HEAD^:${relativePath}`, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"]
});
const previousPackageJson: PackageJson = JSON.parse(previousContent);
previousDeps = {
...previousPackageJson.dependencies,
...previousPackageJson.peerDependencies
};
} catch (error) {
// package.json doesn't exist in previous commit (new widget)
// We'll check all dependencies
}

// Find NEW dependencies (in current but not in previous)
const newDeps = Object.keys(currentDeps).filter(dep => !previousDeps[dep]);

if (newDeps.length === 0) continue;

// Check each new dependency for native code
for (const depName of newDeps) {
const depPath = join(widgetPath, "node_modules", depName);

if (!existsSync(depPath)) continue;

const hasNative = await hasNativeCode(depPath);

if (hasNative) {
violations.push({
widget,
dependency: `${depName}@${currentDeps[depName]}`,
path: depPath
});
}
}
} catch (error) {
console.warn(`⚠️ Could not check ${widget}: ${error instanceof Error ? error.message : String(error)}`);
}
}

if (violations.length > 0) {
console.error("\n❌ NEW NATIVE DEPENDENCY DETECTED\n");
console.error("The following widgets added dependencies with native code:\n");

for (const violation of violations) {
console.error(` Widget: ${violation.widget}`);
console.error(` New Dependency: ${violation.dependency}`);
console.error(` (Contains ios/android folders)\n`);
}

console.error("⚠️ Adding native dependencies is a BREAKING CHANGE:");
console.error(" - Forces customers to rebuild their mobile apps");
console.error(" - Requires resubmission to App Store / Play Store");
console.error(" - Can take days/weeks for customer deployment\n");
console.error("To approve this change, include 'NATIVE_DEPENDENCY_APPROVED' in your commit message.\n");

throw new Error("Native dependency validation failed");
}

console.log(`✅ No new native dependencies detected (checked ${checkedCount} widgets)`);
}

/**
* Check if a dependency contains native code
* Same logic as the patched hasNativeCode function
*/
async function hasNativeCode(dir: string): Promise<boolean> {
try {
const files = await fg.default(["**/{android,ios}/*", "**/*.podspec"], {
cwd: dir,
ignore: ["**/example*/**", "**/__tests__/**", "**/docs/**", "**/.github/**"],
caseSensitiveMatch: false
});
return files.length > 0;
} catch (error) {
return false;
}
}
Loading