Agent Skillsvchelaru/Gum › refactoring-direction

refactoring-direction

GitHub

定义 Gum 代码重构的方向性规则,强制使用实例、接口和单一职责类,禁止静态化或降级接口,指导提取新类及依赖注入策略。

.claude/skills/refactoring-direction/SKILL.md vchelaru/Gum

Trigger Scenarios

提议执行改变代码结构的重构 执行提取辅助方法、选择静态/实例或决定逻辑归属的重构

Install

npx skills add vchelaru/Gum --skill refactoring-direction -g -y
More Options

Non-standard path

npx skills add https://github.com/vchelaru/Gum/tree/main/.claude/skills/refactoring-direction -g -y

Use without installing

npx skills use vchelaru/Gum@refactoring-direction

指定 Agent (Claude Code)

npx skills add vchelaru/Gum --skill refactoring-direction -a claude-code -g -y

安装 repo 全部 skill

npx skills add vchelaru/Gum --all -g -y

预览 repo 内 skill

npx skills add vchelaru/Gum --list

SKILL.md

Frontmatter
{
    "name": "refactoring-direction",
    "description": "Refactoring direction rules for Gum. Trigger when proposing or performing refactors that change how code is shaped — extracting helpers, choosing between static and instance, deciding where new logic should live. Applies to all Gum source projects."
}

Refactoring direction

When refactoring Gum code, always move toward instances, interfaces, and dedicated single-responsibility classes. Never move the other way.

Specific rules

  1. Never promote an instance method, field, or class to static. Even if the method "has no instance state right now," keep it as an instance member. Static methods are sticky — they grow callers everywhere, can't be substituted in tests, and can't be evolved without source-breaking every caller. Instance methods preserve future optionality.

  2. Never demote an interface to a concrete type. If a parameter or field is currently typed as an interface (ISelectedState, IDialogService, IUndoManager, etc.), keep it that way. Adding a new dependency? Inject it as an interface, not a concrete class.

  3. Prefer extracting a new single-responsibility class over adding to a "god class." GraphicalUiElement, CodeGenerator, and similar large classes should not grow. New behavior goes in a new controller/service class that the existing class composes with, even if the new class starts with one method.

    Worked example — composition when there's no shared base class available. CircleRuntime and RectangleRuntime (MonoGameGum/GueDeriving/) each spend their one inheritance slot on a platform base (GraphicalUiElement/SkiaShapeRuntime), so when their XNALIKE-only Gradient/Dropshadow logic turned out to be near-identical, composition was the only option (PR #3427). The extraction used structs, not classes: Clone() relies on MemberwiseClone to copy backing fields by value for free, and a class field would instead let the clone and the original share one mutable instance. The structs also don't cache the renderable slot references they push into — callers pass the current slots on every call — so nothing can go stale when Clone() rebuilds fresh slots. Reach for this shape — composed struct, no cached mutable refs — whenever two sibling classes duplicate logic but can't share a base class and the state is safe to copy by value. This is distinct from gum-cross-platform-unification, which shares one runtime's code across platforms, not between two different runtime classes on the same platform.

  4. Prefer constructor injection over *.Self singletons. Don't reverse the tool's migration off static singletons.

    • ObjectFinder.Self is the sanctioned exception — fine in new or refactored code; it won't be removed. It is intentionally still a static singleton across the entire codebase (tool, runtimes, tests); an IObjectFinder interface exists and is DI-registered (Builder.cs binds it to the ObjectFinder.Self instance), but the ~476 .Self call sites are intentionally left as-is — replacing them wholesale is a project-wide refactor, not a drive-by cleanup. Calls like ObjectFinder.Self.GetBaseElements(...) / ObjectFinder.Self.GetDefaultChildName(...) are acceptable in any context. No tool singletons should be reintroduced.
    • RenderingLibrary.* / InputLibrary.* runtime singletons are also sanctioned exceptions — out of scope in any phase. Renderer.Self, SpriteManager.Self, ShapeManager.Self, LoaderManager.Self, Cursor.Self, Keyboard.Self, etc. are shared across all runtimes (not tool-only), so draining them is a runtime-wide refactor outside the tool's decoupling goal — leave them as statics, like ObjectFinder.Self. The drain line is ownership: tool-owned singletons (SelectedState, PluginManager, …) are in scope; runtime-owned ones are not.
    • Ownership isn't decided by which folder a class's .cs file sits in — check whether Gum.csproj excludes it (<Compile Remove>) in favor of a link into GumCommon.csproj, the same treatment as ObjectFinder.cs; that makes it a shared, headless (net8.0, no WPF) singleton already reachable from every runtime backend and Gum.Presentation, an ObjectFinder-class exception rather than a drain target. StandardElementsManager (Gum/Managers/) is exactly this case.
    • Plugins are MEF parts and drain the same as core classes — in scope for Phase 2. Inject either via an [ImportingConstructor] param or a public settable [Import] property (satisfied by an [Export]; live example: MainPropertiesWindowPlugin's [Import("LocalizationService")]) — both are real DI. The axis that matters is service-location (Locator.GetRequiredService<T>()) vs injection, not ctor-vs-property. MEF can only inject types bridged into its container in PluginManager.LoadPlugins via batch.AddExportedValue<T>. Draining a plugin relocates the Locator call to that composition root — it does not delete it: bridge T once in LoadPlugins (AddExportedValue<T>(Locator.GetRequiredService<T>())), then the plugin injects T. Use explicit typed per-service bridges, not a generic ExportProvider (over-engineering at this scale). A plugin taking PluginManager/IPluginManager itself as a ctor param is not a construction cycle, despite looking like the plugin host injecting itself into its own plugin: PluginManager is DI-constructed with an empty ctor (Builder.cs) and is fully built before LoadPlugins ever composes a plugin, so batch.AddExportedValue<PluginManager>(instance) in LoadPlugins bridges the same already-built singleton a Locator call would have fetched — it drains like any other service. The one constraint: the consuming ctor must not call a method on the injected PluginManager synchronously during construction — MEF populates PluginManager's own loaded-plugin list via ComposeParts(instance) after all plugins (including the consumer) are constructed, so a same-ctor call touching that list would see it incomplete. Storing the reference for later use (StartUp, event handlers) is always safe.
    • Replacing a Locator.GetRequiredService<T>() call with an injected T (one already exported to MEF) is good drive-by cleanup.
    • Drain a blocking singleton on the spot — don't ask about timing. There is a dedicated later phase for draining .Self singletons wholesale, but when a singleton is in the way of the current task — most often because the class can't be unit-tested until it takes its dependencies via the constructor — convert it to constructor injection as part of the current PR. Do not stop to ask whether this conflicts with the dedicated drain phase; it doesn't, and that question is noise. Make the judgment call yourself; only pause to ask if the drain is genuinely dangerous (a construction cycle you can't break cleanly) or its call-site blast radius is large enough to deserve its own PR. Expanding an interface (adding a method) or switching a concrete dependency to its interface in service of the drain is fine.
    • Breaking the DI cycle the Self+Initialize+Locator pattern was hiding. A class kept as a Self singleton that resolves its own dependencies inside an Initialize() via Locator is often doing that to dodge a construction cycle (a dependency's constructor needs the class back). When you move it to real constructor injection, that cycle resurfaces as a DI exception at startup. Break it by injecting the back-edge dependency as Lazy<T> — the tool's DI already registers Lazy<> (see Builder.cs), so Lazy<IFoo> resolves and you access .Value at call time, past graph construction. Inject only the cycling dependencies lazily; keep the acyclic ones direct. When you can choose which edge to lazy, prefer lazying the consumer's edge to the drained class (Lazy<IDrained> in the higher-level class) rather than the drained class's back-edge: that defers the entire drained subtree off the consumer's construction path, which stays correct even when the cycle travels through a third hop (A → B → drained → A) — whereas lazying only the direct back-edge does not break such multi-hop cycles. Example: CommandLineManager (drained, #3277) ↔ ProjectManager; the Lazy<ICommandLineManager> went into ProjectManager (the consumer), keeping CommandLineManager's own deps direct.

When testability is the driver

If the reason you're tempted to make something static is "so a test can call it without constructing the owner," the right answer is the opposite: keep it instance, and either (a) construct the owner in the test with stub dependencies, or (b) extract the logic into a new small class that's cheap to construct. The test's friction is a signal that the owner has too many responsibilities, not that the method should be static.

An extraction-for-testability is not done until the extracted unit has a test. When you pull logic into a new class/service/ViewModel/utility specifically to make it testable, add the test before considering the refactor complete — test-first if the move also changes behavior, or a characterization (pinning) test capturing current behavior if the move preserves it. Do not let the tdd skill's refactor/rename exemption talk you out of it: extracting a new unit is not a pure rename, and an extracted-but-untested seam wastes the entire point of the extraction.

Drains are usually behavior-preserving — calibrate the test to that. Most singleton/Locator drains change only how a dependency is obtained (static Locator → injected field), not what the code does, so test-first TDD has nothing to specify — the fitting tool is a characterization (pinning) test. But the compiler and DI container already verify most of a drain: injection wiring is type-checked, construction cycles throw at startup, and a static→instance conversion makes the compiler flag every stale call site. So a pinning test's marginal value is modest — add one where the injected seam is clean and the assertion is meaningful, and skip it where the harness would have to contort (e.g. hand-rolling a delegate to mock an out parameter) in favor of the compiler plus a manual check. The drain's real payoff is enabling later behavior tests, not the test written during the drain itself. This does not loosen the extraction rule above: pulling out a new unit still requires its own test — a static→instance conversion is not an extraction.

Before refactoring across runtimes

If a refactor touches shared runtime/rendering code (GumCommon, RenderingLibrary, anything under Runtimes/, or MonoGameGum), read gum-runtime-topology first. The same source is compiled into many assemblies and into FlatRedBall via shared projects, so "builds clean in AllLibraries.sln" does not mean "didn't break a consumer" (the WPF runtime and FRB are not in that solution).

Converging per-platform duplicate files

A recurring Gum refactor is driving two (or more) per-platform copies of the same file toward byte-for-byte identical content, so they can eventually collapse to one #if-gated linked source. This is done incrementally — block by block, mirroring #if RAYLIB / #if !RAYLIB in both copies wherever a difference is genuinely platform-specific, so the cross-file diff shrinks toward empty. If you're touching code that exists as duplicated per-platform copies (e.g. CustomSetPropertyOnRenderable.cs, GueDeriving/*Runtime.cs), read gum-cross-platform-unification for the full technique before editing.

Why this matters in Gum

Gum's tool code is mid-migration from static singletons (PluginManager.Self, *Manager.Self, etc.) to constructor-injected services. Every new static is a step backward and undoes that migration's payoff. The runtime libraries are similar: GraphicalUiElement is already bloated, and the project memory explicitly calls out "avoid adding new properties/methods directly to this class — prefer separate controller/manager classes." The direction in this skill is the same direction the rest of the codebase is moving.

Version History

  • c93866f Current 2026-08-20 09:20

Same Skill Collection

.claude/skills/gum-cross-platform-unification/SKILL.md
.claude/skills/gum-issue-creation/SKILL.md
.claude/skills/gum-localization/SKILL.md
.claude/skills/gum-monthly-release/SKILL.md
.claude/skills/gum-runtime-binding/SKILL.md
.claude/skills/gum-runtime-syntax-version/SKILL.md
.claude/skills/gum-tool-selection/SKILL.md
.claude/skills/tdd/SKILL.md

Metadata

Files
0
Version
c93866f
Hash
3672b514
Indexed
2026-08-20 09:20

- 위키
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-25 00:26
浙ICP备14020137号-1 $방문자$