skills/code-plugin-architecture/SKILL.md
Use when the user's pain is "adding/removing one more X means editing N files" and X is a recurring variant kind: popup, banner, modal, ad slot, payment method, AI model/tool, form field type, connector, sub-site, command, menu item, agent, extension point, or data source. Use when they want to design, refactor, review, name, or explain a pluggable mechanism using registry, interface/trait contract, runtime core, and convention folders; mention pluginize, pluggable, plugin architecture, extension point, registry pattern, or extensibility. Use when explaining the first-principles rationale, DDD/SOLID/OCP mapping, or industry analogies behind that structure. Use for cross-stack mapping to VSCode contributes, Webpack/Vite plugins, Rust/Tauri connectors, Python entry_points, or cargo features. Skip one variant's internals/styles/hooks/copy/bugs, and skip register/registry meaning DI container, user signup, or package registry.
npx skillsauth add adonis0123/adonis-skills code-plugin-architectureInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
把"会持续新增的一类东西"改造成插件化结构的方法论 skill。
不是脚手架,不绑定具体框架/语言。React / Vue / Rust / Python / CLI / VSCode extension / Webpack plugin / 后台菜单都适用——所有这些本质上都是这套模式的实例。
工程里反复出现的同一类痛点:
| 症状 | 真正问题 | |---|---| | "每次加一个新弹窗都要改 5 个文件" | 缺中央注册表 + 占位符引导 | | "加新工具有半天要踩坑找改哪儿" | 没有约定式目录 | | "几个相似模块各自 if-else 判断 type" | 缺核心 factory,business 写在调用方 | | "首屏变慢,因为所有模态框都被打包进来" | 缺延迟加载层(仅 frontend / runtime loading 场景) | | "新人改 A 弹窗结果 B 弹窗坏了" | 单插件没有自治目录 | | "命名一会儿 camelCase 一会儿 snake_case,dataName 还不一样" | 没有从 key 推导其他命名 | | "下线一个旧支付方式要改 8 处" | 注册表与实现耦合 |
如果用户描述命中其中任何一条,这就是要这个 skill 介入的时刻。
任何插件化系统的最小骨架是这五层。只要少一层,扩展性就会在 3 个月内塌掉。
注:第一版方法论曾把它叫"四件套"(把 Contract 隐藏在 Registry 类型里)。实践证明把 Contract 显式拎出来更稳——绝大多数事故都来自 Contract 不清晰。
每个插件必须有一个唯一、稳定、来自最权威层的 key。
站点: hostKey: 'site-a.example.com' ← 来自 SiteKey 枚举(顶层)
工具: appKey: 'Motion' ← 来自后端 Labels schema(最权威)
弹窗: configKey: 'npsSurveyModal' ← camelCase 自定义
模型: modelId: 'gpt-4o' ← 来自供应商 ID
原则:
错误示范:弹窗的 configKey = "promoModal",组件名 <PromoModalV2>,埋点名 promo_pop,文件夹 PromoPopup/——四个名字互相猜不出,半年后没人敢删。
明确每个插件必须提供什么、可以提供什么的契约 — 通常是一个 interface / trait / TypedDict / Protocol。
// 必填字段(结构同质)
interface PluginContract {
key: PluginKey
component: ComponentRef
// ...
// 可选行为钩子
shouldShow?: (ctx: Ctx) => boolean
onMount?: (ctx: Ctx) => Cleanup
}
原则:
一处显式的"我这里有哪些插件"清单。
// ✅ 注册表只引用,不实现
import { DynamicNpsSurveyModal } from './popups/dynamic'
export const popupsConfig = {
npsSurveyModal: initialPopupConfig({
priority: 504,
component: DynamicNpsSurveyModal,
cache: { type: 'interval', timeout: 1, upperLimit: 2 },
dataWidgetName: 'nps_survey_popup',
}),
// [Popup-Config]:(add) ← 占位符
}
原则:
pluginConfig({...}) 工厂统一兜底默认值)。通用、无业务的内核,对具体插件保持完全无知。Runtime Core 负责"机制",不负责"内容"。
export function createPluginCoreFactory<
T extends PluginRegistry,
S extends Record<string, any>,
>(initialState, options) {
return (...a) => {
// 通用状态机
return {
init: (options) => { /* cache decision */ },
closeAndTerminate: () => { /* ... */ },
openForce: () => { /* ... */ },
// ...
}
}
}
原则:
<T extends Registry> 接收注册表形状,自己不 import 任何具体插件。init.ts 的事。关于 Provider Orchestration:在 React/Vue/前端场景里,"何时选择 openKey"、"渲染哪个插件"这种编排决策通常落在 Provider/Orchestrator 层(订阅 Core store,按 Contract 字段做决策),不在 Core 里。Core 是状态机和切面 SDK,Orchestrator 是策略实现。两者都属于"业务零知识",但职责不同。把它当 Core 的子层即可,不必单列。
单个插件自治在一个目录内,所有相关物聚拢。
popups/[Name]/
├── index.tsx ← UI(默认 export 一个组件)
├── init.ts ← 何时打开(条件 / AB / 登录态)+ 该插件特有的副作用
└── constants.ts ← 这个插件专属的常量、文案
原则:
rm -rf 这个目录 + 注册表删一行。没有跨目录的反向引用。开篇那张痛点表,业界有个正式名字叫 Shotgun Surgery(霰弹式修改)——"一个逻辑改动要散着改很多处",根因是"同一个职责(管理这一类东西)被切散到了多个文件"。插件化要解决的就是它。
从第一性原理看,目标只有一句话:把"加一个新变种"的成本从 O(N)(改 N 处)降到 O(1)(新建一个自治单元 + 注册表加一行)。一旦把目标定死成 O(1),五件套就不是谁拍脑袋的设计,而是逻辑上绕不开的必要条件——少任何一件,成本就回不到 O(1):
| 要达成 | 否则会怎样 | 推出的件 | 业界可类比 | |---|---|---|---| | 所有变种共享稳定"形状" | 核心要为每种特判 | Contract | 可充当 Core↔Plugin 边界的 Published Language(DDD) | | 有唯一权威"身份" | 命名漂移、无法机器推导 | Identity | 插件的 name/id | | 有且仅有一处"名单" | "有哪些"这一知识被切散 | Registry | Fowler Plugin:"在配置期而非编译期把实现接上";Microkernel 的中心清单 | | 核心对具体变种零知识 | 核心随变种增长而改 | Runtime Core | OCP:对扩展开放、对修改封闭(用抽象/多态替代 switch) | | 单变种自治、无外部反向引用 | 删一个要满地找 | Convention Folder | 高内聚 + 单一变更点 |
这就是"高内聚低耦合"在这里的精确落点:高内聚 = Convention Folder(一个变种的东西聚一处)+ Registry(一类东西的名单聚一处);低耦合 = Contract 是唯一通道,依赖方向单向
Plugin → Contract ← Core(谁都不依赖对方实现,即 DIP)。注意类比是"可类比",不是等号——例如 Microkernel 的 registry 含动态发现/协议协商/版本治理,本方法论的 Registry 通常只是"中心元数据清单"那一层。完整推导、DDD 全映射与出处见 references/first-principles.md。
五件套不是只能用一次。当单个 plugin 内部也长出"会持续新增的变种"时,就在它内部再套一层五件套——一级注册表挂这个 plugin,它内部有自己的二级 Identity / Contract / Registry。这能避免把 N 个内部变种全炸成一级 key(否则一级编排的优先级/调度会爆炸)。
关键洞察:Contract 不规定"插件必须是一个组件"。同一个 Contract 下,变种可以是不同"种类"的东西——有的渲染 UI,有的只执行一个副作用。用一个可辨识联合表达就够了:
type VariantStrategy =
| { mode: 'view'; render: ComponentRef } // 渲染型:交给渲染层
| { mode: 'effect'; perform: (deps) => void } // 副作用型:直接执行一个动作
编排层按 mode 分流,渲染层只认 view、副作用层只认 effect,核心依旧零业务。这其实是 Strategy 模式与插件化的结合,也天然对应 DDD 的"领域 / 应用 / UI"分层(详见 references/first-principles.md)。
五件套之外,还有八个让系统真的"用起来顺"的要点。前七条是"结构舒适度",第八条是"运行时正确性"。
config.ts ← 静态(priority、cache 策略、埋点名、默认 disabled)
init.ts ← 动态(订阅 store、判断登录态、调用 AB 实验、调 init({open: true/false}))
同一属性只在一处写。比如 cache 策略一律放 config.ts,init.ts 不重复传——否则双写就是双倍 bug。
⚠️ 历史债的迁移建议:真实项目里这条很容易腐烂——比如某个 banner 的 init.ts 同时在 config.ts 和 init.ts 写 cache: { type: 'count', count: 2 },原因往往是早期没有 config 层、后来加上时没回收旧 init 里的 cache。这类双写应该作为技术债清理,而不是当作"规范"复制粘贴。
每个插件实现都用 dynamic() / lazy() / 框架的延迟加载机制包一层。
// dynamics.ts — 集中维护所有动态 import
export const DynamicNpsSurveyModal = dynamic(
() => import('./NpsSurveyModal'),
{ ssr: false },
)
注册表只引用 Dynamic* 名字。0 个用户用的插件不该进 bundle。
适用范围限定:这条对前端 / 用户面应用(首屏 bundle 重要)成立;后端内部插件、CLI 工具、内部 admin 工具可以省。Rust 编译型项目用 cargo features 是等价手段。
在中央注册表里留注释占位符,引导下一个加插件的人在哪一行操作:
const config = {
[SiteKey.SiteA]: siteAConfig,
[SiteKey.SiteB]: siteBConfig,
// [SiteKey-Site]:(config) ← 占位符
}
让"加新插件"成为机械动作而非脑力税。也是后续做 codegen / 模板插入的锚点。
每个插件主键能机械推导出所有其他命名。列成表写在 README 里:
| 占位符 | 规则 | 例子 |
|---|---|---|
| __KEY__ | camelCase | npsSurveyModal |
| __COMPONENT__ | PascalCase | NpsSurveyModal |
| __DIR__ | = __COMPONENT__ | NpsSurveyModal |
| __HOOK__ | use__COMPONENT__Init | useNpsSurveyModalInit |
| __DATA_NAME__ | snake_case | nps_survey_modal |
| __DYNAMIC__ | Dynamic__COMPONENT__ | DynamicNpsSurveyModal |
无歧义、可机器化。这一张表是 skill 落地阶段最值钱的产出。
| 副作用类型 | 归属 |
|---|---|
| 横切同质副作用(每个插件都做同样的事) | Runtime Core 一处 subscribe |
| 插件特有副作用(只这一个插件需要) | 该插件的 init.ts 或 render adapter |
| 跨插件编排副作用("A 打开后强制开 B") | Provider/Orchestrator 层 |
// ✅ 横切:所有 plugin 都要的曝光埋点 → core
store.subscribe(
(state) => state.openKey,
(openKey) => openKey && tracker?.trackEvent({...}),
)
// ✅ 特有:NPS 弹窗专属的"提交后 closeAndTerminate" → init.ts
// ✅ 编排:用户登录后强制打开 onboardingExperiment → Provider
错误:把所有插件可能的副作用都塞进 core,导致 core 出现 if (key === 'X') 分支。
实际工程里"插件"往往不只一种(弹窗、横幅、抽屉、Banner、Toast……)。把它们建在同一个 core factory 上,每种变种用自己的 store + Provider 注入:
PluginProvider/
├── _factory/core.ts ← 共享内核(缓存策略、生命周期、横切埋点)
├── popups/ ← 变种 A:弹窗
│ ├── store.ts ← createPopupsStore (cachePrefix='popups')
│ └── Provider/ ← 弹窗 Orchestrator(按 priority 选 openKey)
└── banners/ ← 变种 B:横幅
├── store.ts ← createBannersStore (cachePrefix='banners')
└── Provider/ ← 横幅 Orchestrator(按 priority 选 openKey + renderHeight)
复用所有共性,分隔状态隔离。变种之间的差异(横幅有 renderHeight、弹窗没有)通过 slice 扩展实现。
开发环境把 store 挂到 window(或等价的运行时全局):
if (!IS_PROD) {
(window as any).__POPUPS_STORE__ = store
(window as any).clearPluginCache_popups = () => clearCacheWithPrefix('popups')
}
加新插件后第一次没显示——直接打开 DevTools 看 store 状态、清缓存就能定位。省下"为啥不显示"的 30 分钟来回猜。
这条是"声明式注册"在真实运行时会撞上的两个坑,框架无关。
① 重算逃生阀。注册通常做成幂等的(同一个 key 注册第二次就短路,避免重复初始化)。但当一个插件的"要不要显示 / 要不要启用"取决于会变化的运行时上下文(登录态、权限、AB 实验分组、远程开关),幂等就会把后续更新吃掉——上下文从"未登录"变成"已登录",插件状态却停在第一次注册的结果。
register({ key, enabled: deriveEnabled(ctx), force: true })
// ^ 绕过幂等短路,按新 ctx 重算
判断边界:注册入口要不要 force,取决于"注册意图是否会随上下文失效"。纯静态插件不需要;意图依赖外部可变状态的,必须留这个逃生阀,否则会出"切换账号后弹窗永不出现"这类静默 bug。
② 状态维度隔离。Core 持有的横切状态(缓存、关闭计数、已读标记、冷却时间)如果要按维度隔离(按用户 / 租户 / 环境),用"主键 + 维度后缀"组合出存储键,而不是为每个维度新造一个主键:
storageKey = `${prefix}_${pluginKey}_${dimensionSuffix}` // 如 ..._${userId}
否则 A 用户的"已关闭一次"会串到同设备的 B 用户身上。维度后缀让同一个插件主键在多用户/多环境下天然隔离,且不污染主键命名体系。
接到"把这块改成插件化"或"我要建一套 X 的插件机制"任务时,按这个顺序做。
回答三个问题:
写出每条插件必须有什么、可以有什么:
interface PluginContract {
// 必填(结构同质)
priority: number
component: React.ComponentType<any>
dataWidgetName: string
// 可选(变种特有)
cache?: PluginCache
disabled?: boolean
closeIconClassName?: string // 只横幅用
}
写一个 initialPluginConfig(data) { return { open: false, ...data } } 兜底默认值。
零业务、纯泛型。核心动作至少:
绝对不允许出现具体插件的 key 字符串。
如果需要"按 Contract 字段做策略决策"(例如 priority 排序选 openKey),把决策放到 Provider/Orchestrator 层,而不是 Core 状态机里——Orchestrator 也是业务零知识,但它消费 Core 暴露的 store。
一份注册表。注册表里只有引用、配置、占位符,没有 if-else。
照 Convention Folder 模板写一个最小可用插件。这是后面所有插件的"复制粘贴模板"。
在 README 或注释里:
按下面的"评审清单"对照过一遍。每一条都能回答"是"才算落地完成。挂上全局调试探针。
把已有的"散装 if-else"迁移到插件化结构,跟从零搭建是两件事。强行一刀切迁移 = 大批量回归。推荐绞杀者模式:
{type: 'default'} 占位、埋点名沿用旧值)。localStorage key / 埋点 name 要兼容一段时间——给 Core 加 legacyKey?: string 字段做映射,迁移期过后回收。反模式:一个大 PR 把所有旧弹窗一次性重写——审 review 几乎不可能、回归看不全、出 bug 时无法二分。
落地一个插件化结构 / 评审现有插件化结构时,逐条对照。每条都标了 Check method——优先用机器化方法验证,能 grep 就别人眼瞅。
| # | 评审项 | Check method |
|---|---|---|
| 1 | 加新插件 = 新建一个目录 + 注册表加一行 | 在 onboarding 文档里走一遍"加一个 X"的步骤,数改了几个文件 |
| 2 | 删插件 = rm -rf 目录 + 注册表删一行 | 选一个最简单的插件,真的删一次(branch 上),看构建 + typecheck 报错处 |
| 3 | 单插件目录内可以完整理解 | human review:随机抽一个插件目录,让没看过的人 10 分钟内说出它做啥 |
| 4 | Core factory 不知道任何具体插件 | grep -RE "['\"](pluginKeyA|pluginKeyB)['\"]" core/ 应为空(用真实插件 key 替换;分隔用裸 |,标准 ERE,不要转义) |
| 5 | 同一属性只在一处定义(cache 不要 config + init 双写) | grep "cache:" plugins/*/init.ts 应为空 |
| 6 | Bundle 真的按需加载 | DevTools Network panel / bundle analyzer 看每个插件是否独立 chunk(非 frontend 场景跳过) |
| 7 | 命名遵循推导表 | 写一个脚本:从 key 推导出所有命名,对照实际文件名/常量名/埋点名 |
| 8 | 占位符注释存在 | grep -RE "\[[A-Za-z]+-[A-Za-z]+\]:\(" registry.ts 应有命中(约定格式:// [Domain-Action]:(slot)) |
| 9 | 横切副作用在 Core subscribe 一处 | grep "subscribe\|emit\|trackEvent" plugins/ ≈ 0;core/ 集中 |
| 10 | 开发环境有调试探针 | DevTools console:__YOUR_REGISTRY_STORE__ 应可访问;清缓存命令应存在 |
| 11 | 没有 if (key === 'X') 这种特例 | grep -RE "key\s*===\s*['\"]" core/ orchestrator/ 应为空 |
关于占位符的格式约定:本 skill 全程统一用 // [Domain-Action]:(slot) 形式,例如:
// [Popup-Config]:(add) — 在 Popups 注册表里加新 popup config 的位置// [SiteKey-Site]:(config) — 在站群中央注册表里加新 hostKey config 的位置// [Plugin-Placeholder]:(register) — Rust 例子里 vec! 注册插件的位置grep 时用同一种 regex 即可命中所有。不要混用 [X-Placeholder] 和 [X]:(Y) 两种格式——选一种全项目沿用,否则 grep 总会漏。
Check method 的轻重:
grep 类规则可以放进 lint / CI(一旦回归立即拦住)。typecheck 类规则可以用 satisfies Record<Identity, Contract> 强约束。human review 类规则只能进 PR template。Network panel 类规则进 release 前 checklist。每条都通才算"真的插件化"。任意一条不通——先回答"为什么这条不适用于本场景",能解释清楚再放过。
按需读取:
当用户说"帮我把这块改成插件化"但没说细节,主动问:
不要在没回答这六个问题之前开始写 Core——很可能写出来用户用不上。
执行这个 skill 时,至少产出:
development
Use this skill when the user wants to set, write, or use a goal or /goal that makes a coding agent keep working until a verifiable done condition is met. This skill configures the autonomy and stopping contract for Codex, Claude Code, or portable agent prompts; it does not perform the underlying task. Trigger on requests like 'should I set a goal?', 'set up a durable goal', 'give me a /goal prompt', 'keep refactoring until tests pass', 'I am stepping away, have the agent finish this', or goal prompts for migrations, refactors, ports, spec implementations, eval loops, backlog cleanup, or multi-checkpoint work. Do not use for single quick edits, running tests once, OKR/scrum goal questions, recurring reminders, or token-budget settings.
testing
Create safe Git feature or hotfix branches with concise names. Use this whenever the user asks to create a branch, start work on a new feature or fix, wants a `feat/...` or `hotfix/...` branch name, asks for a short branch slug from a task description, or wants help before beginning local Git work. Default to recommending the branch name and command first, then create only after user confirmation. Do not push, commit, rebase, or create PRs.
development
Use BEFORE heavier workflow skills when route choice matters. Route creative work without a design doc/spec to Brainstorm; destructive or hard-to-reverse work to Discuss; unresolved decisions, Plan/Full fan-out, ship checks, unclear bugs, and fresh-eyes fix-then-re-review need this gate. Skip single-line read-only lookups, pure typo/formatting edits, trivial safe one-line fixes, and clearly safe named-skill requests. Outputs Route, Runtime skill, Fallback alias, and Execution path.
development
Cross-agent code review handoff and review-fix-re-review loop with persistent packet artifacts. Requires a git repo because packet addressing uses git rev-parse --show-toplevel. Use when the user asks for an independent, read-only second pair of eyes on a diff/branch/PR another agent or teammate implemented; asks to verify reviewer feedback before fixing; says a fix is done and wants scoped re-review; asks to continue the latest review packet; or asks for first-principles, DDD, high-cohesion/low-coupling review. Persists each loop under $repo_root/.review-handoff/active/ so agents can resume without copy-paste. Do NOT use for ordinary implementation, generic staged-change review, review-comment copy editing, non-git folders/zips/tarballs/temp dirs, or when the user names a different review skill.