mfe-guard 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aneeq Rehman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,134 @@
1
- # Temporary Holding Version
1
+ # mfe-guard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Catch Module Federation shared-dependency conflicts before they crash your micro-frontends in production.**
4
+
5
+ You deploy a new remote. Everything builds. Then an older remote dies in the browser with:
6
+
7
+ ```
8
+ Error: [ Federation Runtime ]: Version 19.2.3 from inventory of shared singleton
9
+ module @angular/material/autocomplete does not satisfy the requirement of orders which needs 19.2.1
10
+ ```
11
+
12
+ …or worse, a cryptic `ReferenceError: Cannot access '_t' before initialization`.
13
+
14
+ `mfe-guard` reads the `mf-manifest.json` of every shell and remote, works out which version of each shared singleton will **actually win at runtime**, and fails CI when any participant will not accept it, with the exact fix.
15
+
16
+ ```
17
+ ✖ error singleton-unsatisfied @angular/material (3 entry points)
18
+ @angular/material singleton resolves to 19.2.3, which does not satisfy orders
19
+ inventory provides 19.2.3 → wins as singleton (highest version)
20
+ orders requires "19.2.1" ✗
21
+ fix: in orders: change @angular/material "19.2.1" → "^19.2.1" (or align both on 19.2.3)
22
+ ```
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ npx mfe-guard check --help # no install needed
28
+ npm i -D mfe-guard # or as a dev dependency
29
+ ```
30
+
31
+ Requires Node 18.17+.
32
+
33
+ ## Usage
34
+
35
+ ### Manifest mode (recommended)
36
+
37
+ `@module-federation/enhanced` (and Nx, rspack, Modern.js on top of it) emits `mf-manifest.json` next to `remoteEntry.js`. Point mfe-guard at the deployed manifests of every participant. They can live in completely separate repos:
38
+
39
+ ```bash
40
+ npx mfe-guard check \
41
+ -m shell=https://app.test.example.com/mf-manifest.json \
42
+ -m orders=https://orders.test.example.com/mf-manifest.json \
43
+ -m inventory=https://inventory.test.example.com/mf-manifest.json
44
+ ```
45
+
46
+ Local paths work too (`-m orders=./dist/orders/mf-manifest.json`), and so do `mf-stats.json` files.
47
+
48
+ ### Project mode (no build needed)
49
+
50
+ Compare project folders directly. The required range comes from `package.json`, and the shipped version comes from `node_modules` when installed:
51
+
52
+ ```bash
53
+ npx mfe-guard check -p orders=../orders-app -p inventory=../inventory-app --singletons "*"
54
+ ```
55
+
56
+ By default only well-known singletons (`@angular/*`, `rxjs`, `zone.js`, `primeng`, `@primeng/*`, `@ngrx/*`, `@ngxs/*`, `react`, `react-dom`, `vue`) are compared. **Nx shares every dependency as a singleton**, so pass `--singletons "*"` there.
57
+
58
+ ### Config file
59
+
60
+ Drop a `mfe-guard.config.json` in the repo and just run `npx mfe-guard check`:
61
+
62
+ ```json
63
+ {
64
+ "participants": [
65
+ { "name": "shell", "manifest": "https://app.test.example.com/mf-manifest.json" },
66
+ { "name": "orders", "manifest": "https://orders.test.example.com/mf-manifest.json" },
67
+ { "name": "inventory", "project": "../inventory-app" }
68
+ ],
69
+ "ignore": ["version-drift", "exact-pin:@acme/ui-kit"],
70
+ "failOn": "error"
71
+ }
72
+ ```
73
+
74
+ CLI flags are merged on top of the config.
75
+
76
+ ## Rules
77
+
78
+ | Rule | Severity | What it catches |
79
+ |---|---|---|
80
+ | `singleton-unsatisfied` | error | The version that wins a singleton (the highest provided) does not satisfy another participant's `requiredVersion`. With `strictVersion` it throws at runtime; without it, you silently run on a version you never tested. |
81
+ | `major-mismatch` | error | Participants ship different major versions of the same shared package. |
82
+ | `duplicate-name` | error | Two participants share a federation `name` or global/chunk name, so they overwrite each other's modules. |
83
+ | `exact-pin` | warn | A singleton is required as an exact version (`"19.2.1"`), so any patch bump elsewhere breaks it. |
84
+ | `not-singleton` | warn | Framework packages (`@angular/core`, `rxjs`, `zone.js`, `react`, …) shared without `singleton: true`. |
85
+ | `version-drift` | info | Versions differ but are still compatible; shown so you know what will run. |
86
+
87
+ Secondary entry points (`@angular/material/autocomplete`, `@angular/common/http`, …) are collapsed into their root package, so you get one finding instead of forty.
88
+
89
+ Ignore by rule (`exact-pin`), by package (`leaflet`), or by both (`exact-pin:leaflet`).
90
+
91
+ ## CI
92
+
93
+ ```yaml
94
+ # .github/workflows/mfe-guard.yml
95
+ name: mfe-guard
96
+ on: [pull_request]
97
+ jobs:
98
+ federation:
99
+ runs-on: ubuntu-latest
100
+ steps:
101
+ - uses: actions/checkout@v4
102
+ - uses: actions/setup-node@v4
103
+ with: { node-version: 20 }
104
+ - run: npx mfe-guard check # reads mfe-guard.config.json
105
+ ```
106
+
107
+ Exit codes: `0` ok · `1` findings at or above `--fail-on` (default `error`) · `2` usage or load error. Use `--json` for machine-readable output.
108
+
109
+ ## Programmatic API
110
+
111
+ ```ts
112
+ import { check, loadManifest, formatText } from 'mfe-guard';
113
+
114
+ const result = check([
115
+ await loadManifest('https://orders.test.example.com/mf-manifest.json', 'orders'),
116
+ await loadManifest('./dist/inventory/mf-manifest.json', 'inventory'),
117
+ ]);
118
+ console.log(result.summary); // { error: 2, warn: 3, info: 1 }
119
+ ```
120
+
121
+ ## How resolution is modelled
122
+
123
+ For a singleton, the Module Federation runtime picks the **highest** version offered by any loaded participant (default `shareStrategy`). mfe-guard assumes every listed participant is loaded together, which is the worst case and therefore the one worth guarding against in CI.
124
+
125
+ ## Roadmap
126
+
127
+ - Lockfile parsing (pnpm / npm / yarn) for exact versions without `node_modules`
128
+ - Nx executor and a ready-made GitHub Action
129
+ - HTML report
130
+ - Classic webpack `remoteEntry.js` (no manifest) support
131
+
132
+ ## License
133
+
134
+ MIT © Aneeq Rehman
@@ -0,0 +1,4 @@
1
+ import type { CheckOptions, CheckResult, Participant } from './types.js';
2
+ export declare const DEFAULT_CRITICAL_SINGLETONS: string[];
3
+ /** Run every rule over the given participants. Pure function — no I/O. */
4
+ export declare function check(input: Participant[], opts?: CheckOptions): CheckResult;
package/dist/check.js ADDED
@@ -0,0 +1,166 @@
1
+ import semver from 'semver';
2
+ import { collapseEntryPoints, isExactPin, isRange, matchesPattern } from './util.js';
3
+ export const DEFAULT_CRITICAL_SINGLETONS = [
4
+ '@angular/core',
5
+ '@angular/common',
6
+ '@angular/router',
7
+ '@angular/platform-browser',
8
+ 'rxjs',
9
+ 'zone.js',
10
+ 'react',
11
+ 'react-dom',
12
+ ];
13
+ const SEVERITY_ORDER = { error: 0, warn: 1, info: 2 };
14
+ function versionLabel(u) {
15
+ return u.e.versionGuessed ? `${u.e.version} (guessed from "${u.e.requiredVersion}" — install deps for the exact version)` : `${u.e.version}`;
16
+ }
17
+ /** Run every rule over the given participants. Pure function — no I/O. */
18
+ export function check(input, opts = {}) {
19
+ const critical = opts.criticalSingletons ?? DEFAULT_CRITICAL_SINGLETONS;
20
+ const ignore = opts.ignore ?? [];
21
+ const isIgnored = (rule, pkg) => ignore.includes(rule) || ignore.includes(`${rule}:${pkg}`) || ignore.includes(pkg);
22
+ const participants = input.map((p) => ({ ...p, shared: collapseEntryPoints(p.shared) }));
23
+ const findings = [];
24
+ const add = (f) => {
25
+ if (!isIgnored(f.rule, f.package))
26
+ findings.push(f);
27
+ };
28
+ // ── duplicate-name ──────────────────────────────────────────────
29
+ for (const key of ['federationName', 'globalName']) {
30
+ const byName = new Map();
31
+ for (const p of participants) {
32
+ const n = p[key];
33
+ if (!n)
34
+ continue;
35
+ byName.set(n, [...(byName.get(n) ?? []), p]);
36
+ }
37
+ for (const [n, ps] of byName) {
38
+ if (ps.length < 2)
39
+ continue;
40
+ add({
41
+ rule: 'duplicate-name',
42
+ severity: 'error',
43
+ package: '',
44
+ message: `${ps.length} participants use the same ${key === 'globalName' ? 'global (chunk) name' : 'federation name'} "${n}"`,
45
+ details: ps.map((p) => `${p.name} ← ${p.source}`),
46
+ fix: 'give every remote a unique MF `name` (and `output.uniqueName`) — they will overwrite each other\'s modules at runtime',
47
+ participants: ps.map((p) => p.name),
48
+ });
49
+ }
50
+ }
51
+ // ── per-package rules ──────────────────────────────────────────
52
+ const byPkg = new Map();
53
+ for (const p of participants) {
54
+ for (const e of p.shared)
55
+ byPkg.set(e.name, [...(byPkg.get(e.name) ?? []), { p, e }]);
56
+ }
57
+ let packagesCompared = 0;
58
+ for (const [pkg, uses] of [...byPkg].sort(([a], [b]) => a.localeCompare(b))) {
59
+ if (uses.length < 2)
60
+ continue; // only one participant shares it — nothing to conflict with
61
+ packagesCompared++;
62
+ const entryPoints = Math.max(...uses.map((u) => u.e.entryPoints ?? 1));
63
+ const names = uses.map((u) => u.p.name);
64
+ const isSingleton = uses.some((u) => u.e.singleton);
65
+ // not-singleton
66
+ if (critical.some((c) => matchesPattern(pkg, c))) {
67
+ const loose = uses.filter((u) => !u.e.singleton);
68
+ if (loose.length) {
69
+ add({
70
+ rule: 'not-singleton',
71
+ severity: 'warn',
72
+ package: pkg,
73
+ entryPoints,
74
+ message: `${pkg} must be a singleton but is not in ${loose.map((u) => u.p.name).join(', ')}`,
75
+ details: uses.map((u) => `${u.p.name}: singleton=${u.e.singleton}`),
76
+ fix: `set { singleton: true } for ${pkg} in every participant — two copies break DI / zones / shared state`,
77
+ participants: loose.map((u) => u.p.name),
78
+ });
79
+ }
80
+ }
81
+ const providers = uses.filter((u) => u.e.version && semver.valid(u.e.version));
82
+ // major-mismatch
83
+ const majors = new Set(providers.map((u) => semver.major(u.e.version)));
84
+ if (majors.size > 1) {
85
+ add({
86
+ rule: 'major-mismatch',
87
+ severity: 'error',
88
+ package: pkg,
89
+ entryPoints,
90
+ message: `${pkg} is shipped in ${majors.size} different major versions`,
91
+ details: providers.map((u) => `${u.p.name} ships ${versionLabel(u)}`),
92
+ fix: `align every participant on the same major version of ${pkg}`,
93
+ participants: providers.map((u) => u.p.name),
94
+ });
95
+ continue;
96
+ }
97
+ if (!isSingleton || providers.length === 0)
98
+ continue;
99
+ // singleton-unsatisfied — MF picks the highest provided version for a singleton
100
+ const winner = providers.reduce((a, b) => (semver.gt(b.e.version, a.e.version) ? b : a));
101
+ const winVer = winner.e.version;
102
+ const unsatisfied = uses.filter((u) => u.e.version !== winVer &&
103
+ isRange(u.e.requiredVersion) &&
104
+ !semver.satisfies(winVer, u.e.requiredVersion, { includePrerelease: true }));
105
+ if (unsatisfied.length) {
106
+ const fixes = unsatisfied.map((u) => {
107
+ const range = u.e.requiredVersion;
108
+ const lo = semver.minVersion(range);
109
+ if (lo && semver.lt(winVer, lo.version)) {
110
+ return `upgrade ${pkg} in ${winner.p.name} to >= ${lo.version}`;
111
+ }
112
+ if (lo && semver.major(lo) === semver.major(winVer)) {
113
+ return `in ${u.p.name}: change ${pkg} "${range}" → "^${lo.version}" (or align both on ${winVer})`;
114
+ }
115
+ return `align ${pkg} versions between ${u.p.name} and ${winner.p.name}`;
116
+ });
117
+ add({
118
+ rule: 'singleton-unsatisfied',
119
+ severity: 'error',
120
+ package: pkg,
121
+ entryPoints,
122
+ message: `${pkg} singleton resolves to ${winVer}, which does not satisfy ${unsatisfied.map((u) => u.p.name).join(', ')}`,
123
+ details: [
124
+ `${winner.p.name} provides ${versionLabel(winner)} → wins as singleton (highest version)`,
125
+ ...unsatisfied.map((u) => `${u.p.name} requires "${u.e.requiredVersion}" ✗`),
126
+ ],
127
+ fix: fixes.join('; '),
128
+ participants: names,
129
+ });
130
+ }
131
+ else {
132
+ const distinct = [...new Set(providers.map((u) => u.e.version))];
133
+ if (distinct.length > 1) {
134
+ add({
135
+ rule: 'version-drift',
136
+ severity: 'info',
137
+ package: pkg,
138
+ entryPoints,
139
+ message: `${pkg} versions differ (${distinct.join(', ')}); singleton resolves to ${winVer}`,
140
+ details: providers.map((u) => `${u.p.name} ships ${versionLabel(u)}`),
141
+ participants: names,
142
+ });
143
+ }
144
+ }
145
+ // exact-pin — fragile even when it happens to work today
146
+ const unsatisfiedSet = new Set(unsatisfied.map((u) => u.p.name));
147
+ const pinned = uses.filter((u) => u.e.singleton && isRange(u.e.requiredVersion) && isExactPin(u.e.requiredVersion) && !unsatisfiedSet.has(u.p.name));
148
+ if (pinned.length) {
149
+ add({
150
+ rule: 'exact-pin',
151
+ severity: 'warn',
152
+ package: pkg,
153
+ entryPoints,
154
+ message: `${pkg} is pinned to an exact version in ${pinned.map((u) => u.p.name).join(', ')}`,
155
+ details: pinned.map((u) => `${u.p.name} requires exactly "${u.e.requiredVersion}"`),
156
+ fix: `use a caret range (e.g. "^${pinned[0].e.requiredVersion.replace(/^=/, '')}") so a patch bump in another remote does not break it`,
157
+ participants: pinned.map((u) => u.p.name),
158
+ });
159
+ }
160
+ }
161
+ findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.package.localeCompare(b.package));
162
+ const summary = { error: 0, warn: 0, info: 0 };
163
+ for (const f of findings)
164
+ summary[f.severity]++;
165
+ return { participants, packagesCompared, findings, summary };
166
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { parseArgs } from 'node:util';
4
+ import { check } from './check.js';
5
+ import { DEFAULT_CONFIG_FILE, loadConfig } from './config.js';
6
+ import { loadManifest } from './loaders/manifest.js';
7
+ import { loadProject } from './loaders/project.js';
8
+ import { formatJson, formatText } from './report.js';
9
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
10
+ const HELP = `mfe-guard v${VERSION} — catch Module Federation shared-dependency conflicts before production
11
+
12
+ Usage
13
+ mfe-guard check [options]
14
+
15
+ Inputs (repeatable, at least two participants)
16
+ -m, --manifest <name=url|path> mf-manifest.json (or mf-stats.json) of a shell/remote
17
+ -p, --project <name=dir> project folder with package.json (+ node_modules)
18
+ -c, --config <file> JSON config (default: ./${DEFAULT_CONFIG_FILE} if present)
19
+
20
+ Options
21
+ --singletons <a,b,...> project mode: packages treated as singletons ("*" = Nx default)
22
+ --ignore <rule|pkg|rule:pkg,...>
23
+ --fail-on <error|warn|never> exit 1 at this severity (default: error)
24
+ --json machine-readable output
25
+ -h, --help
26
+ -v, --version
27
+
28
+ Examples
29
+ mfe-guard check -m shell=https://app.test/mf-manifest.json \\
30
+ -m orders=https://orders.test/mf-manifest.json \\
31
+ -m inventory=https://inventory.test/mf-manifest.json
32
+
33
+ mfe-guard check -p orders=../orders-app -p inventory=../inventory-app --singletons "*"
34
+
35
+ Exit codes: 0 ok · 1 findings at/above --fail-on · 2 usage or load error`;
36
+ function splitPair(v, flag) {
37
+ const i = v.indexOf('=');
38
+ if (i <= 0 || i === v.length - 1)
39
+ throw new UsageError(`${flag} expects name=value, got "${v}"`);
40
+ return { name: v.slice(0, i), value: v.slice(i + 1) };
41
+ }
42
+ const list = (v) => (v ? v.split(',').map((s) => s.trim()).filter(Boolean) : undefined);
43
+ class UsageError extends Error {
44
+ }
45
+ async function main(argv) {
46
+ const { values, positionals } = parseArgs({
47
+ args: argv,
48
+ allowPositionals: true,
49
+ options: {
50
+ manifest: { type: 'string', short: 'm', multiple: true },
51
+ project: { type: 'string', short: 'p', multiple: true },
52
+ config: { type: 'string', short: 'c' },
53
+ singletons: { type: 'string' },
54
+ ignore: { type: 'string' },
55
+ 'fail-on': { type: 'string' },
56
+ json: { type: 'boolean' },
57
+ help: { type: 'boolean', short: 'h' },
58
+ version: { type: 'boolean', short: 'v' },
59
+ },
60
+ });
61
+ if (values.version) {
62
+ console.log(VERSION);
63
+ return 0;
64
+ }
65
+ if (values.help || positionals.length === 0) {
66
+ console.log(HELP);
67
+ return positionals.length === 0 && !values.help ? 2 : 0;
68
+ }
69
+ if (positionals[0] !== 'check')
70
+ throw new UsageError(`unknown command "${positionals[0]}" (try: mfe-guard check)`);
71
+ // config file (explicit or auto-detected) + CLI flags are merged
72
+ const configPath = values.config ?? (existsSync(DEFAULT_CONFIG_FILE) ? DEFAULT_CONFIG_FILE : undefined);
73
+ const config = configPath ? await loadConfig(configPath) : { participants: [] };
74
+ const participantsCfg = [
75
+ ...config.participants,
76
+ ...(values.manifest ?? []).map((v) => {
77
+ const { name, value } = splitPair(v, '--manifest');
78
+ return { name, manifest: value };
79
+ }),
80
+ ...(values.project ?? []).map((v) => {
81
+ const { name, value } = splitPair(v, '--project');
82
+ return { name, project: value };
83
+ }),
84
+ ];
85
+ if (participantsCfg.length < 2) {
86
+ throw new UsageError('need at least two participants (use --manifest / --project or a config file)');
87
+ }
88
+ const failOn = (values['fail-on'] ?? config.failOn ?? 'error');
89
+ if (!['error', 'warn', 'never'].includes(failOn))
90
+ throw new UsageError(`--fail-on must be error, warn or never`);
91
+ const singletons = list(values.singletons) ?? config.singletons;
92
+ const participants = await Promise.all(participantsCfg.map((p) => p.manifest ? loadManifest(p.manifest, p.name) : loadProject(p.project, p.name, { singletons })));
93
+ const result = check(participants, {
94
+ ignore: [...(config.ignore ?? []), ...(list(values.ignore) ?? [])],
95
+ criticalSingletons: config.criticalSingletons,
96
+ });
97
+ console.log(values.json ? formatJson(result, VERSION) : formatText(result, VERSION));
98
+ if (failOn === 'never')
99
+ return 0;
100
+ if (result.summary.error > 0)
101
+ return 1;
102
+ if (failOn === 'warn' && result.summary.warn > 0)
103
+ return 1;
104
+ return 0;
105
+ }
106
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
107
+ console.error(`mfe-guard: ${err instanceof Error ? err.message : String(err)}`);
108
+ if (err instanceof UsageError)
109
+ console.error('run "mfe-guard --help" for usage');
110
+ process.exit(2);
111
+ });
@@ -0,0 +1,19 @@
1
+ export type FailOn = 'error' | 'warn' | 'never';
2
+ export interface ParticipantConfig {
3
+ name: string;
4
+ /** URL or path to mf-manifest.json / mf-stats.json. */
5
+ manifest?: string;
6
+ /** Path to a project folder containing package.json (and ideally node_modules). */
7
+ project?: string;
8
+ }
9
+ export interface MfeGuardConfig {
10
+ participants: ParticipantConfig[];
11
+ /** Project mode: packages treated as shared singletons. ["*"] matches Nx defaults. */
12
+ singletons?: string[];
13
+ criticalSingletons?: string[];
14
+ ignore?: string[];
15
+ failOn?: FailOn;
16
+ }
17
+ export declare const DEFAULT_CONFIG_FILE = "mfe-guard.config.json";
18
+ /** Load a JSON config; relative project/manifest paths resolve against the config file. */
19
+ export declare function loadConfig(path: string): Promise<MfeGuardConfig>;
package/dist/config.js ADDED
@@ -0,0 +1,26 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, resolve } from 'node:path';
3
+ export const DEFAULT_CONFIG_FILE = 'mfe-guard.config.json';
4
+ /** Load a JSON config; relative project/manifest paths resolve against the config file. */
5
+ export async function loadConfig(path) {
6
+ const abs = resolve(path);
7
+ let raw;
8
+ try {
9
+ raw = JSON.parse(await readFile(abs, 'utf8'));
10
+ }
11
+ catch (e) {
12
+ throw new Error(`cannot read config ${path}: ${e.message}`);
13
+ }
14
+ if (!Array.isArray(raw.participants)) {
15
+ throw new Error(`${path}: "participants" must be an array`);
16
+ }
17
+ const base = dirname(abs);
18
+ const fix = (s) => (s && !/^https?:\/\//i.test(s) && !isAbsolute(s) ? resolve(base, s) : s);
19
+ raw.participants = raw.participants.map((p, i) => {
20
+ if (!p?.name || (!p.manifest && !p.project)) {
21
+ throw new Error(`${path}: participants[${i}] needs "name" and either "manifest" or "project"`);
22
+ }
23
+ return { ...p, manifest: fix(p.manifest), project: fix(p.project) };
24
+ });
25
+ return raw;
26
+ }
@@ -0,0 +1,7 @@
1
+ export { check, DEFAULT_CRITICAL_SINGLETONS } from './check.js';
2
+ export { parseManifest, loadManifest } from './loaders/manifest.js';
3
+ export { loadProject, DEFAULT_SINGLETONS } from './loaders/project.js';
4
+ export { loadConfig } from './config.js';
5
+ export { formatText, formatJson } from './report.js';
6
+ export type * from './types.js';
7
+ export type { MfeGuardConfig, ParticipantConfig, FailOn } from './config.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { check, DEFAULT_CRITICAL_SINGLETONS } from './check.js';
2
+ export { parseManifest, loadManifest } from './loaders/manifest.js';
3
+ export { loadProject, DEFAULT_SINGLETONS } from './loaders/project.js';
4
+ export { loadConfig } from './config.js';
5
+ export { formatText, formatJson } from './report.js';
@@ -0,0 +1,8 @@
1
+ import type { Participant } from '../types.js';
2
+ /**
3
+ * Parse an `mf-manifest.json` (or `mf-stats.json`) produced by
4
+ * @module-federation/enhanced, webpack or rspack.
5
+ */
6
+ export declare function parseManifest(json: unknown, source: string, label?: string): Participant;
7
+ /** Load a manifest from an http(s) URL or a local path. */
8
+ export declare function loadManifest(source: string, label?: string): Promise<Participant>;
@@ -0,0 +1,55 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ /**
3
+ * Parse an `mf-manifest.json` (or `mf-stats.json`) produced by
4
+ * @module-federation/enhanced, webpack or rspack.
5
+ */
6
+ export function parseManifest(json, source, label) {
7
+ if (!json || typeof json !== 'object') {
8
+ throw new Error(`${source}: not a JSON object`);
9
+ }
10
+ const m = json;
11
+ const meta = (m.metaData ?? {});
12
+ const federationName = m.name ?? meta.name ?? m.id;
13
+ if (!Array.isArray(m.shared)) {
14
+ throw new Error(`${source}: no "shared" array found. Point mfe-guard at mf-manifest.json (or mf-stats.json), not remoteEntry.js.`);
15
+ }
16
+ const shared = m.shared
17
+ .filter((s) => s && typeof s.name === 'string')
18
+ .map((s) => ({
19
+ name: s.name,
20
+ version: typeof s.version === 'string' ? s.version : undefined,
21
+ requiredVersion: s.requiredVersion === false ? false : typeof s.requiredVersion === 'string' ? s.requiredVersion : undefined,
22
+ singleton: s.singleton === true,
23
+ strictVersion: typeof s.strictVersion === 'boolean' ? s.strictVersion : undefined,
24
+ eager: typeof s.eager === 'boolean' ? s.eager : undefined,
25
+ }));
26
+ return {
27
+ name: label ?? federationName ?? source,
28
+ federationName,
29
+ globalName: typeof meta.globalName === 'string' ? meta.globalName : undefined,
30
+ source,
31
+ kind: 'manifest',
32
+ shared,
33
+ };
34
+ }
35
+ /** Load a manifest from an http(s) URL or a local path. */
36
+ export async function loadManifest(source, label) {
37
+ let text;
38
+ if (/^https?:\/\//i.test(source)) {
39
+ const res = await fetch(source, { headers: { accept: 'application/json' } });
40
+ if (!res.ok)
41
+ throw new Error(`${source}: HTTP ${res.status} ${res.statusText}`);
42
+ text = await res.text();
43
+ }
44
+ else {
45
+ text = await readFile(source, 'utf8');
46
+ }
47
+ let json;
48
+ try {
49
+ json = JSON.parse(text);
50
+ }
51
+ catch {
52
+ throw new Error(`${source}: response is not valid JSON`);
53
+ }
54
+ return parseManifest(json, source, label);
55
+ }
@@ -0,0 +1,12 @@
1
+ import type { Participant } from '../types.js';
2
+ /** Packages assumed to be shared singletons in project mode unless configured otherwise. */
3
+ export declare const DEFAULT_SINGLETONS: string[];
4
+ export interface ProjectOptions {
5
+ /** Patterns of packages treated as shared singletons. Use ["*"] for Nx (shares everything). */
6
+ singletons?: string[];
7
+ }
8
+ /**
9
+ * Build a participant from a project folder: package.json gives the required range,
10
+ * node_modules (when installed) gives the version it will actually ship.
11
+ */
12
+ export declare function loadProject(dir: string, label: string, opts?: ProjectOptions): Promise<Participant>;
@@ -0,0 +1,52 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import semver from 'semver';
3
+ import { join } from 'node:path';
4
+ import { cleanVersion, isRange, matchesPattern } from '../util.js';
5
+ /** Packages assumed to be shared singletons in project mode unless configured otherwise. */
6
+ export const DEFAULT_SINGLETONS = [
7
+ '@angular/*',
8
+ 'rxjs',
9
+ 'zone.js',
10
+ 'primeng',
11
+ '@primeng/*',
12
+ '@ngrx/*',
13
+ '@ngxs/*',
14
+ 'react',
15
+ 'react-dom',
16
+ 'vue',
17
+ ];
18
+ async function readJson(path) {
19
+ try {
20
+ return JSON.parse(await readFile(path, 'utf8'));
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ /**
27
+ * Build a participant from a project folder: package.json gives the required range,
28
+ * node_modules (when installed) gives the version it will actually ship.
29
+ */
30
+ export async function loadProject(dir, label, opts = {}) {
31
+ const pkg = await readJson(join(dir, 'package.json'));
32
+ if (!pkg)
33
+ throw new Error(`${dir}: package.json not found or unreadable`);
34
+ const singletons = opts.singletons ?? DEFAULT_SINGLETONS;
35
+ const deps = pkg.dependencies ?? {};
36
+ const shared = [];
37
+ for (const [name, range] of Object.entries(deps)) {
38
+ if (!singletons.some((p) => matchesPattern(name, p)))
39
+ continue;
40
+ if (!isRange(range))
41
+ continue; // workspace:, git urls, file: ...
42
+ const installed = cleanVersion((await readJson(join(dir, 'node_modules', name, 'package.json')))?.version);
43
+ shared.push({
44
+ name,
45
+ version: installed ?? semver.minVersion(range)?.version,
46
+ versionGuessed: !installed,
47
+ requiredVersion: range,
48
+ singleton: true,
49
+ });
50
+ }
51
+ return { name: label, source: dir, kind: 'project', shared };
52
+ }
@@ -0,0 +1,3 @@
1
+ import type { CheckResult } from './types.js';
2
+ export declare function formatText(result: CheckResult, version: string): string;
3
+ export declare function formatJson(result: CheckResult, version: string): string;
package/dist/report.js ADDED
@@ -0,0 +1,52 @@
1
+ const useColor = () => !process.env.NO_COLOR && (process.stdout.isTTY || !!process.env.FORCE_COLOR);
2
+ const paint = (code) => (s) => (useColor() ? `\x1b[${code}m${s}\x1b[0m` : s);
3
+ const red = paint(31);
4
+ const yellow = paint(33);
5
+ const cyan = paint(36);
6
+ const dim = paint(2);
7
+ const bold = paint(1);
8
+ const green = paint(32);
9
+ const ICON = { error: '✖', warn: '⚠', info: 'ℹ' };
10
+ const COLOR = { error: red, warn: yellow, info: cyan };
11
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
12
+ export function formatText(result, version) {
13
+ const out = [];
14
+ out.push(bold(`mfe-guard v${version}`) +
15
+ dim(` — ${plural(result.participants.length, 'participant')}, ${plural(result.packagesCompared, 'shared package')} compared`));
16
+ for (const p of result.participants) {
17
+ out.push(dim(` • ${p.name} (${p.kind}, ${p.shared.length} shared) ← ${p.source}`));
18
+ }
19
+ out.push('');
20
+ if (!result.findings.length) {
21
+ out.push(green('✔ No conflicts found.'));
22
+ return out.join('\n');
23
+ }
24
+ for (const f of result.findings) {
25
+ const c = COLOR[f.severity];
26
+ const pkg = f.package ? ` ${bold(f.package)}${f.entryPoints && f.entryPoints > 1 ? dim(` (${f.entryPoints} entry points)`) : ''}` : '';
27
+ out.push(`${c(`${ICON[f.severity]} ${f.severity.padEnd(5)}`)} ${dim(f.rule)}${pkg}`);
28
+ out.push(` ${f.message}`);
29
+ for (const d of f.details)
30
+ out.push(dim(` ${d}`));
31
+ if (f.fix)
32
+ out.push(` ${green('fix:')} ${f.fix}`);
33
+ out.push('');
34
+ }
35
+ const s = result.summary;
36
+ out.push([
37
+ s.error ? red(plural(s.error, 'error')) : plural(0, 'error'),
38
+ s.warn ? yellow(plural(s.warn, 'warning')) : plural(0, 'warning'),
39
+ plural(s.info, 'info'),
40
+ ].join(', '));
41
+ return out.join('\n');
42
+ }
43
+ export function formatJson(result, version) {
44
+ return JSON.stringify({
45
+ tool: 'mfe-guard',
46
+ version,
47
+ participants: result.participants.map((p) => ({ name: p.name, kind: p.kind, source: p.source, shared: p.shared.length })),
48
+ packagesCompared: result.packagesCompared,
49
+ summary: result.summary,
50
+ findings: result.findings,
51
+ }, null, 2);
52
+ }
@@ -0,0 +1,55 @@
1
+ /** One shared package as declared by a participant (shell or remote). */
2
+ export interface SharedEntry {
3
+ /** Package name as shared, e.g. "primeng" or "primeng/autocomplete". */
4
+ name: string;
5
+ /** Version this participant ships/provides, if known. */
6
+ version?: string;
7
+ /** True when `version` was guessed from the range because node_modules was not installed. */
8
+ versionGuessed?: boolean;
9
+ /** Range this participant requires at runtime. `false` = no requirement. */
10
+ requiredVersion?: string | false;
11
+ singleton: boolean;
12
+ strictVersion?: boolean;
13
+ eager?: boolean;
14
+ /** How many entry points were collapsed into this entry (primeng/*, @angular/common/http...). */
15
+ entryPoints?: number;
16
+ }
17
+ /** A shell or remote taking part in the federation. */
18
+ export interface Participant {
19
+ /** Label used in reports (the MF name, or the label given on the CLI). */
20
+ name: string;
21
+ /** Federation name from the manifest (used for duplicate-name detection). */
22
+ federationName?: string;
23
+ /** Global/chunk-loading name, if the manifest exposes it. */
24
+ globalName?: string;
25
+ /** Where it was loaded from (URL or path). */
26
+ source: string;
27
+ /** "manifest" = exact data from mf-manifest.json, "project" = heuristic from package.json. */
28
+ kind: 'manifest' | 'project';
29
+ shared: SharedEntry[];
30
+ }
31
+ export type Severity = 'error' | 'warn' | 'info';
32
+ export type RuleId = 'singleton-unsatisfied' | 'major-mismatch' | 'duplicate-name' | 'exact-pin' | 'not-singleton' | 'version-drift';
33
+ export interface Finding {
34
+ rule: RuleId;
35
+ severity: Severity;
36
+ /** Root package name, e.g. "primeng". Empty for participant-level rules. */
37
+ package: string;
38
+ entryPoints?: number;
39
+ message: string;
40
+ details: string[];
41
+ fix?: string;
42
+ participants: string[];
43
+ }
44
+ export interface CheckOptions {
45
+ /** Packages that must always be singletons (warned by `not-singleton`). */
46
+ criticalSingletons?: string[];
47
+ /** Rule ids or "rule:package" pairs to ignore. */
48
+ ignore?: string[];
49
+ }
50
+ export interface CheckResult {
51
+ participants: Participant[];
52
+ packagesCompared: number;
53
+ findings: Finding[];
54
+ summary: Record<Severity, number>;
55
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/util.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { SharedEntry } from './types.js';
2
+ /** "primeng/autocomplete" -> "primeng", "@angular/common/http" -> "@angular/common". */
3
+ export declare function rootPackage(name: string): string;
4
+ /** Clean a version string into valid semver, or undefined. */
5
+ export declare function cleanVersion(v: string | undefined): string | undefined;
6
+ /** True for a usable semver range (rejects workspace:, git urls, "*"-less garbage). */
7
+ export declare function isRange(r: unknown): r is string;
8
+ /** True when the range pins one exact version ("1.2.3", "=1.2.3"). */
9
+ export declare function isExactPin(r: string): boolean;
10
+ /** Glob-ish match: "@angular/*", "rxjs", "*". */
11
+ export declare function matchesPattern(name: string, pattern: string): boolean;
12
+ /**
13
+ * Collapse secondary entry points into one entry per root package.
14
+ * The root's own entry wins when present; otherwise the first entry point is used.
15
+ */
16
+ export declare function collapseEntryPoints(shared: SharedEntry[]): SharedEntry[];
package/dist/util.js ADDED
@@ -0,0 +1,55 @@
1
+ import semver from 'semver';
2
+ /** "primeng/autocomplete" -> "primeng", "@angular/common/http" -> "@angular/common". */
3
+ export function rootPackage(name) {
4
+ const parts = name.split('/');
5
+ return name.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
6
+ }
7
+ /** Clean a version string into valid semver, or undefined. */
8
+ export function cleanVersion(v) {
9
+ if (!v)
10
+ return undefined;
11
+ return semver.valid(v) ?? semver.coerce(v)?.version ?? undefined;
12
+ }
13
+ /** True for a usable semver range (rejects workspace:, git urls, "*"-less garbage). */
14
+ export function isRange(r) {
15
+ return typeof r === 'string' && r.length > 0 && semver.validRange(r) !== null;
16
+ }
17
+ /** True when the range pins one exact version ("1.2.3", "=1.2.3"). */
18
+ export function isExactPin(r) {
19
+ return semver.valid(r.replace(/^=/, '').trim()) !== null;
20
+ }
21
+ /** Glob-ish match: "@angular/*", "rxjs", "*". */
22
+ export function matchesPattern(name, pattern) {
23
+ if (pattern === '*')
24
+ return true;
25
+ if (pattern.endsWith('/*'))
26
+ return name.startsWith(pattern.slice(0, -1));
27
+ return name === pattern;
28
+ }
29
+ /**
30
+ * Collapse secondary entry points into one entry per root package.
31
+ * The root's own entry wins when present; otherwise the first entry point is used.
32
+ */
33
+ export function collapseEntryPoints(shared) {
34
+ const byRoot = new Map();
35
+ for (const s of shared) {
36
+ const root = rootPackage(s.name);
37
+ const existing = byRoot.get(root);
38
+ if (!existing) {
39
+ byRoot.set(root, { ...s, name: root, entryPoints: 1 });
40
+ continue;
41
+ }
42
+ existing.entryPoints = (existing.entryPoints ?? 1) + 1;
43
+ if (s.name === root) {
44
+ byRoot.set(root, { ...s, name: root, entryPoints: existing.entryPoints });
45
+ }
46
+ else {
47
+ // keep the strictest picture: singleton if any entry point is one
48
+ existing.singleton = existing.singleton || s.singleton;
49
+ existing.version ??= s.version;
50
+ if (existing.requiredVersion === undefined)
51
+ existing.requiredVersion = s.requiredVersion;
52
+ }
53
+ }
54
+ return [...byRoot.values()];
55
+ }
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "mfe-guard",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Catch Module Federation shared-dependency conflicts (singleton version mismatches, duplicate remote names) before they crash your micro-frontends in production.",
5
+ "type": "module",
6
+ "bin": {
7
+ "mfe-guard": "dist/cli.js"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build": "node scripts/clean.mjs && tsc -p tsconfig.json",
24
+ "test": "npm run build && node --test test/check.test.mjs test/cli.test.mjs",
25
+ "prepublishOnly": "npm test"
26
+ },
27
+ "keywords": [
28
+ "module-federation",
29
+ "micro-frontends",
30
+ "mfe",
31
+ "webpack",
32
+ "rspack",
33
+ "nx",
34
+ "angular",
35
+ "singleton",
36
+ "shared-dependencies",
37
+ "ci",
38
+ "lint"
39
+ ],
40
+ "author": "Aneeq Rehman",
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/maneeq/mfe-guard.git"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/maneeq/mfe-guard/issues"
48
+ },
49
+ "homepage": "https://github.com/maneeq/mfe-guard#readme",
50
+ "engines": {
51
+ "node": ">=18.17"
52
+ },
53
+ "dependencies": {
54
+ "semver": "^7.6.3"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^20.14.0",
58
+ "@types/semver": "^7.5.8",
59
+ "typescript": "~5.7.2"
60
+ }
61
+ }