@mf-toolkit/shared-inspector

v0.5.4

Published

Stop debugging Module Federation in production. Audit shared config at build time.

Readme

@mf-toolkit/shared-inspector

npm version license node

shared-inspector banner

Validate Module Federation shared config at build time.

shared config errors are silent — mismatched versions, broken singleton negotiation, duplicate instances in the bundle. shared-inspector surfaces these issues before they reach production. Every finding includes a risk score and a ready-to-paste fix.

The problem

Module Federation teams manually manage shared config and make three kinds of mistakes:

  • Over-sharing — packages listed in shared that the microfrontend never imports. Creates artificial version coupling between independent teams.
  • Under-sharing — packages used by both host and remote but missing from shared. Each microfrontend may bundle its own copy (e.g. multiple React instances, each ~130 KB).
  • Version mismatchrequiredVersion doesn't match the installed version. Module Federation silently falls back to a local bundle. For packages with global state (React, styled-components) this causes "Invalid hook call" in production.

Existing tools (webpack-bundle-analyzer, source-map-explorer) show what ended up in the bundle, not why shared config is suboptimal. Different questions.

Why not bundle analyzer?

Bundle analyzers (webpack-bundle-analyzer, source-map-explorer, stats.json inspection) answer a different question: what is in the output? They are useful for auditing final bundle size, but they don't model Module Federation's shared dependency negotiation.

| Question | Bundle analyzer | shared-inspector | |----------|----------------|-----------------| | Which packages are large? | ✅ | — | | Is React duplicated across MFs? | Visible after the fact | ✅ Detected before build ships | | Is requiredVersion out of sync with the installed version? | ✗ | ✅ | | Is a package marked singleton in one MF but not another? | ✗ | ✅ | | Which packages are declared shared but never imported? | ✗ | ✅ | | Which used packages are missing from shared entirely? | ✗ | ✅ | | Cross-MF version conflicts across teams? | ✗ | ✅ via federation manifests |

In short: bundle analyzers are useful for post-build inspection. shared-inspector is focused on the shared config itself — catching misconfiguration at build time and explaining what the runtime consequences would be.

Example

A shell host app (React 18) and a checkout remote have been developed by separate teams. Their shared configs have drifted:

// shell — webpack.config.js
shared: {
  react:     { singleton: true, requiredVersion: '^18.2.0' },
  'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
  zustand:   { singleton: true },
}

// checkout — webpack.config.js
shared: {
  react:     { singleton: true, requiredVersion: '^17.0.2' }, // ← stale version
  'react-dom': { singleton: true, requiredVersion: '^17.0.2' },
  lodash:    {},                                               // ← never imported
  // zustand: missing — checkout imports it, but it's not in shared
}

Running npx @mf-toolkit/shared-inspector in the checkout project:

[MfSharedInspector] checkout (depth: local-graph, 34 files scanned)
────────────────────────────────────────────────────────────

⚠  Version Mismatch — react
   configured: ^17.0.2 | installed: 17.0.2
   → Risk: Invalid hook call, broken context across MFs
   💡 Fix:
   shared: {
     react: { singleton: true, requiredVersion: "^18.2.0" }
   }

→  Not Shared — zustand (8 imports in 5 files)
   → Risk: Each MF may get its own store instance — state changes may not propagate across MFs
   💡 Fix:
   shared: {
     zustand: { singleton: true }
   }

✗  Unused Shared — lodash
   0 imports, shared without singleton
   → Wastes bundle negotiation overhead with no benefit
   💡 Fix: Remove "lodash" from shared config

────────────────────────────────────────────────────────────
Score: 69/100  🟠 RISKY

After manually updating the config based on the suggestions abovereact version aligned, zustand added to shared, lodash removed:

Score: 100/100  ✅ HEALTHY

The cross-team federation report also clears: shell and checkout now negotiate a single React instance and a single Zustand store at runtime.

Installation

npm install --save-dev @mf-toolkit/shared-inspector

Quick start

Run against any MF project — no config file needed:

npx @mf-toolkit/shared-inspector

The tool scans ./src, reads installed versions from package.json, and prints a diagnostic report. Each finding includes what's wrong, what breaks at runtime, and a ready-to-paste fix:

✓  Scanned 47 files

[MfSharedInspector] checkout (depth: local-graph, 47 files scanned)
────────────────────────────────────────────────────────────

⚠  Version Mismatch — react
   configured: ^18.0.0 | installed: 17.0.2
   → Risk: Invalid hook call, broken context across MFs
   💡 Fix:
   shared: {
     react: { singleton: true, requiredVersion: "^18.0.0" }
   }

✗  Unused Shared — lodash
   0 imports, shared without singleton
   → Wastes bundle negotiation overhead with no benefit
   💡 Fix: Remove "lodash" from shared config

→  Not Shared — mobx (12 imports in 8 files via re-export in src/shared/index.ts)
   → Risk: Each MF may get its own MobX instance — observables and reactions can fail to sync between MFs
   💡 Fix:
   shared: {
     mobx: { singleton: true }
   }

────────────────────────────────────────────────────────────
Score: 62/100  🟠 RISKY

Issues:
  🔴  1 high    — version mismatch
  🟠  1 medium  — singleton gaps, duplicate libs
  🟡  1 low     — over-sharing

Total: 12 shared, 10 used, 1 unused, 1 candidates, 1 mismatch, 0 eager risks

Colors are auto-applied in TTY terminals and disabled in CI / piped output (NO_COLOR / TERM=dumb respected).

Risk scoring

Every report ends with a score out of 100:

| Severity | Penalty | Covers | |----------|---------|--------| | 🔴 HIGH | −20 each | Version mismatches, cross-MF version conflicts | | 🟠 MEDIUM | −8 each | Singleton risks, eager risks, duplicate libs, host gaps | | 🟡 LOW | −3 each | Unused shared packages, ghost shares |

| Score | Label | |-------|-------| | 90–100 | ✅ HEALTHY | | 70–89 | 🟡 GOOD | | 40–69 | 🟠 RISKY | | 0–39 | 🔴 CRITICAL |

Demo

See the tool in action across four real-world scenarios — healthy baseline, config drift, hidden federation conflicts, and critical misconfiguration — in a fully runnable e-commerce microfrontend:

zvitaly7/mf-storefront-demo — three independent React 18 apps (shell, catalog, checkout) with Module Federation, demonstrating how shared-inspector catches issues that static review and bundle analyzers miss.

Advanced: CI integration

Integrate into build pipelines to fail on findings, gate on score, or emit manifests for later federation analysis.

Failing the build

Use --fail-on to exit with code 1 when specific findings are detected:

npx @mf-toolkit/shared-inspector --source ./src --shared react,react-dom --fail-on mismatch

With the webpack plugin:

import { MfSharedInspectorPlugin } from '@mf-toolkit/shared-inspector/webpack';

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'checkout',
      shared: { react: { singleton: true }, mobx: { singleton: true } },
    }),

    // sharedConfig not needed — auto-extracted from ModuleFederationPlugin above
    new MfSharedInspectorPlugin({
      sourceDirs: ['./src'],
      failOn: 'mismatch', // 'mismatch' | 'unused' | 'any'
      warn: true,
    }),
  ],
};

Gating on score

Use scoreProjectReport programmatically to set custom thresholds:

import { analyzeProject, scoreProjectReport } from '@mf-toolkit/shared-inspector';

const report = analyzeProject(manifest);
const { score, label, high, medium, low } = scoreProjectReport(report);

if (score < 70) {
  console.error(`Shared config score: ${score}/100 (${label})`);
  process.exit(1);
}

Writing manifests for federation analysis

Each MF can emit a project-manifest.json as a build artifact to enable cross-team analysis in a later step:

npx @mf-toolkit/shared-inspector --shared ./shared-config.json --write-manifest

With the webpack plugin:

new MfSharedInspectorPlugin({
  sourceDirs: ['./src'],
  writeManifest: true, // writes project-manifest.json for CI aggregation
  warn: true,
})

Upload the manifest as a CI artifact:

# .github/workflows/build.yml
jobs:
  build-checkout:
    steps:
      - run: npm run build   # MfSharedInspectorPlugin writes project-manifest.json
      - uses: actions/upload-artifact@v4
        with: { name: manifest-checkout, path: project-manifest.json }

Advanced: Federation mode

Federation mode: detects version conflicts across microfrontends before they reach runtime

Once each MF has emitted its manifest, aggregate them to detect cross-team conflicts: version mismatches, singleton inconsistencies, and shared-config gaps across host and remotes.

Manifests can be local files or HTTP/HTTPS URLs — making federation analysis work across separate repositories without manual file passing.

CLI

# Local files (monorepo or pre-downloaded)
npx @mf-toolkit/shared-inspector federation checkout.json catalog.json cart.json

# URLs — fetch manifests directly from remote storage
npx @mf-toolkit/shared-inspector federation \
  https://storage.example.com/manifests/checkout.json \
  https://storage.example.com/manifests/cart.json \
  https://storage.example.com/manifests/catalog.json

# Mix of local files and URLs
npx @mf-toolkit/shared-inspector federation checkout.json https://storage.example.com/cart.json

Polyrepo setup

In a polyrepo, each team owns a separate repository. The recommended workflow:

Step 1 — each MF repo publishes its manifest on every build:

# .github/workflows/build.yml (in each MF repo)
jobs:
  build:
    steps:
      - run: npm run build        # MfSharedInspectorPlugin writes project-manifest.json
      - uses: actions/upload-artifact@v4
        with: { name: manifest-${{ github.event.repository.name }}, path: project-manifest.json }

Step 2 — a dedicated federation-check job downloads all manifests and runs analysis:

# .github/workflows/federation-check.yml (in a shared/platform repo)
jobs:
  federation-check:
    steps:
      - uses: actions/download-artifact@v4
        with: { name: manifest-checkout, github-token: ${{ secrets.GITHUB_TOKEN }}, repository: org/checkout, run-id: ... }
      - uses: actions/download-artifact@v4
        with: { name: manifest-cart, github-token: ${{ secrets.GITHUB_TOKEN }}, repository: org/cart, run-id: ... }
      - run: |
          npx @mf-toolkit/shared-inspector federation \
            manifest-checkout/project-manifest.json \
            manifest-cart/project-manifest.json

Alternatively, upload manifests to a shared HTTP storage (S3, CDN, object store) and use URL inputs directly — no artifact coordination required:

      - run: |
          npx @mf-toolkit/shared-inspector federation \
            https://manifests.internal/checkout/latest.json \
            https://manifests.internal/cart/latest.json

Programmatic

import { analyzeFederation, formatFederationReport, scoreFederationReport } from '@mf-toolkit/shared-inspector';

const report = analyzeFederation([checkoutManifest, catalogManifest, cartManifest]);
const { score, label } = scoreFederationReport(report);

console.log(formatFederationReport(report));
// ⚠  Version Conflict — react
//    checkout: ^17.0.0
//    catalog: ^18.0.0
//    → Risk: MF singleton negotiation may silently load the wrong version → Invalid hook call
//    💡 Fix: shared: { react: { singleton: true, requiredVersion: "^18.0.0" } }
//
// Score: 60/100  🟠 RISKY

Programmatic API

Two-phase API

import { buildProjectManifest, analyzeProject, formatReport } from '@mf-toolkit/shared-inspector';

// Phase 1: collect facts
const manifest = await buildProjectManifest({
  name: 'checkout',
  sourceDirs: ['./src'],
  sharedConfig: {
    react: { singleton: true, requiredVersion: '^18.0.0' },
    'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
    lodash: {},
  },
  // depth: 'local-graph'            ← default, follows barrel re-exports
  // tsconfigPath: './tsconfig.json' ← optional, resolves @alias/* imports
  // workspacePackages: ['@my-org/*'] ← optional, excludes local monorepo packages
});

// Phase 2: analyse facts
const report = analyzeProject(manifest);

console.log(report.mismatched);
// [{ package: 'react', configured: '^18.0.0', installed: '17.0.2' }]

console.log(report.candidates);
// [{ package: 'mobx', importCount: 12, via: 'reexport', files: ['src/shared/index.ts'] }]

console.log(report.unused);
// [{ package: 'lodash', singleton: false }]

// Human-readable output with risk descriptions and fix snippets
console.log(formatReport(report, { name: manifest.project.name }));

Shortcut API

import { inspect } from '@mf-toolkit/shared-inspector';

const report = await inspect({
  name: 'checkout',
  sourceDirs: ['./src'],
  sharedConfig: { /* ... */ },
});

Analysis depth

| Depth | What it finds | Speed | |-------|--------------|-------| | 'direct' | Explicit import / require statements | Fast (ms) | | 'local-graph' (default) | + packages reachable via barrel re-exports and local wrappers | Slower (seconds) |

The difference matters when your project uses barrel files:

// src/shared/index.ts
export { observer } from 'mobx-react';    // re-export
export { makeAutoObservable } from 'mobx'; // re-export

// src/app.tsx
import { observer } from './shared';  // relative import — direct mode stops here
  • depth: 'direct' sees ./shared (relative) → skips. mobx not found.
  • depth: 'local-graph' follows ./shared → finds mobx and mobx-react via re-export.

TypeScript path aliases

// tsconfig.json
{ "compilerOptions": { "paths": { "@components/*": ["src/components/*"] } } }

await buildProjectManifest({
  sourceDirs: ['./src'],
  tsconfigPath: './tsconfig.json', // enables alias resolution
});

Without tsconfigPath, @components/Button is treated as an external package and packages imported inside it are invisible in local-graph mode.

Interactive wizard

Not sure which flags to pass? Run the step-by-step wizard:

$ npx @mf-toolkit/shared-inspector --interactive

[MfSharedInspector] Interactive setup

Source directories to scan (default: ./src):
Scan depth — direct or local-graph (default: local-graph):
Shared packages — comma-separated names or path to .json (empty to skip): react,react-dom,mobx
Path to tsconfig.json for alias resolution (empty to skip):
Workspace packages to exclude, comma-separated (empty to skip):
Fail build on findings — mismatch / unused / any / none (default: none): mismatch
Write project-manifest.json? (y/N): n

CLI reference

| Flag | Default | Description | |------|---------|-------------| | --source, -s <dirs> | ./src | Source dirs to scan, comma-separated | | --depth <depth> | local-graph | Scan depth: direct | local-graph | | --shared <packages\|file> | — | Comma-separated package names or path to .json config | | --tsconfig <path> | — | tsconfig.json for path alias resolution | | --workspace-packages <pkgs> | — | Comma-separated workspace packages to exclude | | --name <name> | auto from package.json | Project name | | --kind <kind> | unknown | Project role: host | remote | unknown | | --fail-on <rule> | — | Exit 1 when findings match: mismatch | unused | any | | --min-score <n> | — | Exit 1 when score is below n (0–100) | | --json | false | Output findings as JSON (suppresses spinner and banner) | | --write-manifest | false | Write project-manifest.json to output dir | | --output-dir <dir> | . | Output directory for manifest | | --interactive, -i | — | Launch step-by-step wizard | | --version, -v | — | Print version and exit | | --help, -h | — | Show help |

Federation subcommand:

mf-inspector federation <manifest1> [manifest2...] [--fail-on <rule>] [--min-score <n>] [--json]

Each manifest can be a local file path or an http(s):// URL. Local paths are resolved relative to the current working directory.

| Flag | Description | |------|-------------| | --fail-on <rule> | Exit 1 when findings match: mismatch (version conflicts) | unused (ghost shares) | any | | --min-score <n> | Exit 1 when federation score is below n | | --json | Output findings as JSON |

API reference

buildProjectManifest(options)

| Option | Type | Default | Description | |--------|------|---------|-------------| | name | string | — | Project name | | sourceDirs | string[] | — | Directories to scan | | depth | 'direct' \| 'local-graph' | 'local-graph' | Scan depth | | sharedConfig | Record<string, SharedDepConfig> | {} | MF shared config | | packageJsonPath | string | './package.json' | Path to package.json | | extensions | string[] | ['.ts','.tsx','.js','.jsx'] | File extensions | | ignore | string[] | [] | Packages to exclude (supports @scope/*) | | tsconfigPath | string | undefined | tsconfig.json for path alias resolution | | workspacePackages | string[] | [] | Local monorepo packages to exclude | | kind | 'host' \| 'remote' \| 'unknown' | 'unknown' | Project role |

analyzeProject(manifest, options?)

| Option | Type | Default | Description | |--------|------|---------|-------------| | alwaysShared | string[] | ['react','react-dom'] | Never flag as unused | | additionalCandidates | string[] | [] | Extend built-in candidates list | | additionalSingletonRisks | string[] | [] | Extend built-in singleton-risk list |

scoreProjectReport(report) / scoreFederationReport(report)

Returns a RiskScore:

interface RiskScore {
  score: number;                           // 0–100, higher is better
  label: 'HEALTHY' | 'GOOD' | 'RISKY' | 'CRITICAL';
  high: number;                            // count of high-severity findings
  medium: number;                          // count of medium-severity findings
  low: number;                             // count of low-severity findings
}

analyzeFederation(manifests, options?)

Accepts N ProjectManifest objects (one per microfrontend) and returns a FederationReport.

| Option | Type | Default | Description | |--------|------|---------|-------------| | alwaysShared | string[] | ['react','react-dom'] | Exclude from ghost/gap detection |

writeReport(report, outputPath) / writeManifest(manifest, outputPath)

Write a ProjectReport or ProjectManifest to a JSON file. Parent directories are created automatically.

import { writeReport, writeManifest } from '@mf-toolkit/shared-inspector';

await writeReport(report, './dist/shared-report.json');
await writeManifest(manifest, './dist/project-manifest.json');

MfSharedInspectorPlugin options

Extends all buildProjectManifest options (except name, auto-resolved from compiler) plus:

| Option | Type | Default | Description | |--------|------|---------|-------------| | warn | boolean | true | Print findings to console | | failOn | 'mismatch' \| 'unused' \| 'any' | undefined | Fail the build when findings match | | writeManifest | boolean | false | Write project-manifest.json to outputDir | | outputDir | string | '.' | Directory for manifest output | | analysis | AnalysisOptions | {} | Options forwarded to analyzeProject |

Detection categories

Per-project (analyzeProject)

| Category | Severity | Description | |----------|----------|-------------| | mismatched | 🔴 HIGH | requiredVersion doesn't satisfy installed version | | singletonRisks | 🟠 MEDIUM | Global-state packages shared without singleton: true | | eagerRisks | 🟠 MEDIUM | eager: true without singleton: true | | candidates | 🟠 MEDIUM | Used packages missing from shared (each MF bundles own copy) | | unused | 🟡 LOW | In shared config but not observed in scanned sources |

Cross-MF (analyzeFederation)

| Category | Severity | Description | |----------|----------|-------------| | versionConflicts | 🔴 HIGH | requiredVersion ranges across MFs have no overlap | | singletonMismatches | 🟠 MEDIUM | singleton: true in some MFs, absent in others | | hostGaps | 🟠 MEDIUM | Package used by 2+ MFs but not declared in shared by anyone | | ghostShares | 🟡 LOW | Package in shared of one MF, unused/unshared by all others |

How it works

Four steps, no magic:

  1. Scan — statically extracts import/require statements from source files
  2. Normalize — reads your declared shared config (explicit or auto-extracted from ModuleFederationPlugin)
  3. Resolve — reads installed versions from node_modules to detect requiredVersion drift
  4. Cross-reference — produces findings, a risk score, and optionally a project-manifest.json for federation analysis

No webpack build required. Runs in seconds on the source tree directly.

When not to use this tool

  • You are not using Module Federation (webpack or rspack)
  • Your shared config is intentionally empty or minimal by design
  • You only need bundle size analysis — use webpack-bundle-analyzer instead
  • Your MF setup uses dynamic runtime sharing with non-standard orchestration that doesn't rely on the shared config

Known limitations

The tool works well for the vast majority of typical Module Federation setups. The limitations below only apply to edge cases or highly dynamic configurations:

  • TypeScript path aliases without tsconfigPath: aliased imports are treated as external package names.
  • Dynamic imports with variables (import(moduleName)): not analysed — requires runtime information.
  • Exact tsconfig alias patterns (non-wildcard): only "@alias/*" wildcard form is supported.
  • Subclassed ModuleFederationPlugin: auto-extraction matches by constructor name — pass sharedConfig explicitly for custom subclasses.

License

MIT