@devmedic/plugin-sdk
v0.1.0
Published
Stable authoring API for rules, reporters, and fixers. Capability negotiation and plugin manifest contract.
Readme
@devmedic/plugin-sdk
The Plugin Framework: everything DevMedic needs to discover, load, validate,
register, and run plugins. No rules, no React Native/Node.js/Flutter
support live here — this is infrastructure only. The core has no idea what
a "React Native plugin" is; supportedFrameworks is an opaque string array
the framework never inspects.
Building a plugin? Start with the authoring guide — it covers the full API with examples.
What's here
| Concern | Module | |
| ---------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Plugin metadata + validation | manifest.ts | Zod schema for id, name, version, description, author, homepage, supportedFrameworks, minimumCoreVersion, maximumCoreVersion. |
| Version compatibility | version-compatibility.ts | Checks a plugin's declared core-version bounds against the running core. |
| Authoring API | lifecycle.ts | definePlugin, the six lifecycle hooks, the PluginModule contract. |
| Dependency Injection | container.ts | ServiceContainer + createToken — token-based, factory memoization, child containers. |
| Plugin sources | sources.ts | Classifies internal / local / npm; discoverInstalledPlugins for the devmedic-plugin-* naming convention. |
| Discovery engine | discovery-engine.ts | discoverPlugins — merges built-in, workspace/node_modules @devmedic/plugin-*, local (plugins/, .devmedic/plugins/), config, and devmedic-plugin-* npm sources into one normalized, deduplicated list. |
| Discovery classification | discover.ts | resolveDeclaredPlugins — classifies a (now auto-discovered) plugin list without importing anything. |
| Loading | load.ts | Dynamic import() + manifest validation + version check, with defensive CJS/ESM interop handling. |
| Registration | registry.ts | Storage/lookup, including lazy registration. |
| Sandboxing | sandbox.ts | withTimeout — races a hook call against a deadline. |
| Orchestration | runtime.ts | PluginRuntime — builds context, runs lifecycle hooks with error isolation, hot-reload groundwork. |
| Plugin Validator | validator.ts | validatePlugin — one non-throwing pass checking manifest, version, dependencies, hooks, rules (incl. cross-plugin duplicates), configuration, supported projects, duplicate plugin ids, and API compatibility. |
Minimal example
import { definePlugin } from '@devmedic/plugin-sdk';
export default definePlugin({
manifest: { id: 'devmedic-plugin-example', name: 'Example', version: '1.0.0' },
hooks: {
initialize(context) {
console.log(`${context.pluginId} ready`);
},
},
});See the authoring guide for the full lifecycle, DI, configuration, discovery, and error-isolation model.
Plugin Validator
validatePlugin checks everything about a plugin in one pass and reports
every problem it finds, not just the first — it never throws, so a caller
always gets a result back and decides what "reject" means for them:
import { validatePlugin } from '@devmedic/plugin-sdk';
const result = validatePlugin(candidate, {
coreVersion: CORE_VERSION,
source: specifier, // for message text only
rules: loadedRules, // this plugin's own contributed rules, if resolved
existingRuleIds, // every rule id already loaded by OTHER plugins this run
existingPluginIds, // every plugin id already loaded this run
knownProjectTypes: ['react-native', 'expo'], // optional — flags a typo'd entry as a warning
configSchema: myPluginConfigSchema, // optional — any { safeParse } value, e.g. a real ZodSchema
config: myPluginConfig,
});
result.valid; // false only if any diagnostic has severity: 'error' — warnings alone don't invalidate
result.diagnostics; // readonly PluginDiagnostic[] — { severity, category, message, path? }Ten category values, one per requirement: manifest, version,
dependencies, hooks, rules, configuration, supportedProjects,
duplicateRuleIds, duplicatePluginIds, apiCompatibility — a result is
directly auditable against that list. Manifest schema issues are
re-categorized by field: a bad version string is 'version', not a
generic 'manifest'; minimumCoreVersion/maximumCoreVersion problems
are 'apiCompatibility'; supportedProjectTypes problems are
'supportedProjects' — all derived from the same PluginManifestSchema
safeParse, not a second hand-written check.
Two real bugs this fixed, both in apps/cli/src/plugin-loader.ts
(see its own doc comment and tests):
- Two different specifiers resolving to manifests with the same
idused to throwDuplicatePluginErrorstraight out ofPluginRegistry.register, uncaught — crashing the whole CLI run. - Two plugins each contributing a rule with the same id used to throw
DuplicateRuleErrorinsideRuleEngine.registerRule, caught by an unconditionalcatch {}meant only for "this plugin has no rules" — silently discarding the real failure, indistinguishable from success.
Both are now ordinary failures entries with a diagnostics array
explaining exactly why.
Rules are checked structurally, not by importing @devmedic/rule-engine —
RuleLike is a small duck-typed interface (id, analyze, canFix,
fix, examples), since rule-engine itself depends on this package;
importing it back would cycle. The deeper, authoritative rule check
(full static-field schema, real method presence) still happens in
@devmedic/rule-engine#validateRule, called by
loadRuleModule/RuleEngine.loadRulesFrom when a rule is actually
registered — validatePlugin's rule checks are a fast, framework-agnostic
sanity pass one layer up, plus the cross-plugin duplicate-id detection
validateRule alone can't do (it only sees one rule module at a time).
Configuration validation has no generic contract — PluginContext.config
is opaque (Readonly<Record<string, unknown>>); a plugin's own config
schema is purely its own convention today (see
@devmedic/plugin-react-native's config.ts). configSchema/config
are supplied by whoever calls validatePlugin and already knows a
specific plugin's schema — there's no discovery mechanism for "here is my
config schema" a plugin module can declare generically yet.
A real bug this caught
The first draft resolved a plugin's default export directly from import()
without accounting for how Node's native ESM/CJS interop differs from a
CJS module with only named exports (or no default at all) — the exact class
of interop mismatch @devmedic/parser-typescript hit with @babel/traverse.
load.ts's resolvePluginModule falls back to the whole namespace when
.default doesn't look like a plugin, and load.test.ts has a fixture that
exercises exactly that path (a plugin module with no default export at all).
Also manually verified end-to-end against the built dist/ output under
plain node — dynamic loading, DI, all six lifecycle hooks, error isolation,
and version-mismatch rejection all confirmed working outside vitest's
transform.
Depends on
@devmedic/corezodsemver
