Analyzers (WUH###)¶
Unity Helpers ships a Roslyn analyzer that reports footguns in code that already compiles and, for the most part, already works. It runs on your code as well as the package's, because the shapes it finds are not specific to either.
Open Tools > Wallstop Studios > Unity Helpers > Analyzer Policies, or select Configure Analyzer Severities in Project Settings > Wallstop Studios > Unity Helpers. Each diagnostic has a severity selector: Default, Off, Info, Warning, Error, or Hidden. Default keeps the analyzer's shipped behavior, including the six opt-in policies. Enable All and Disable All still apply Warning or Off across the catalog.
The compact UI Toolkit list supports searching by diagnostic ID, title, or description. Hover or focus a policy to see its explanation and syntax-highlighted examples of reported and preferred code. Click the policy or press Enter to pin the example card, use Copy fix for the preferred snippet, and press Escape to dismiss it. Pinned examples stay visible while you search, change severities, or use the toolbar; select another policy to replace the pinned example. Unpin lets hovering or focusing another rule change the example again.
The window refreshes Assets/Default.ruleset while open and when it gains focus. A severity edit re-reads the file before updating only the selected rule, preserving other diagnostics, unknown rules, includes, and unrelated analyzer groups. Malformed XML and duplicate overrides for the selected diagnostic are refused without changing the file. These project file writes are outside Unity Undo; use version control to revert them. Avoid simultaneous writes from other tools.
Editor scripts can call AnalyzerPolicyAPI.TrySetEnabled, or AnalyzerPolicyAPI.TrySetSeverity with a diagnostic ID and ruleset action (Default, None, Info, Warning, Error, or Hidden). Both support an explicit .ruleset asset path for assembly-specific rulesets managed by Unity; the window edits the project-wide default ruleset. Writes import the asset immediately.
Ruleset XML must have no namespace. Older policy files with an MSBuild namespace can stop script compilation. The window reports these files as needing repair; choose Enable All or Disable All to repair the namespace while keeping other rules and includes. API writes also repair the namespace. Reading or refreshing a file does not change it.
For package development, npm run verify:shipped-analyzers compares all three shipped compiler-input DLLs with fresh Release builds. npm run verify:shipped-analyzers:fix stages each replacement in a hidden directory before publishing it. A failed copy or replacement leaves that DLL and its importer metadata intact and returns failure. Earlier successful replacements in the same run remain published; retry the command after resolving the failure. Temporary directories are excluded from Unity imports.
| Id | Reports |
|---|---|
WUH001 | A lookup factory passed as a method group |
WUH002 | A nested collection Unity does not serialize |
WUH003 | ?. / ?[] / ?? / ??= / is null on a UnityEngine.Object |
WUH004 | An NUnit null assertion that passes over a destroyed object |
WUH005 | UnityEngine.Random, which no test can replay in isolation |
WUH006 | A discarded EffectHandle |
WUH007 | A discarded coroutine handle |
WUH008 | A TryXxx out value read without testing the call |
WUH009 | A teardown's base call that is not last |
WUH010 | A dictionary indexer read (off by default) |
WUH011 | A comparer mode changed after collection construction |
WUH012 | A serialized row dereferenced without a null test |
WUH013 | A counting loop that could be a foreach (off by default) |
WUH014 | A disposable struct whose Dispose assigns |
WUH015 | A Unity callback with an invalid signature |
WUH016 | A Unity callback hides an ancestor callback |
WUH017 | A GetComponent compared against null |
WUH018 | String equality without an explicit policy (off by default) |
WUH019 | A counting loop repeatedly reading a stable size (off by default) |
WUH020 | A throwing stack or queue read with an available Try method |
WUH021 | Repeated size observations (off by default) |
WUH022 | A disposable value type converted to a reference type |
WUH023 | A public struct without typed equality (off by default) |
WUH024 | Enum operations with typed helpers (off by default) |
WUH025 | A relational field with an unsupported type |
WUH026 | Bitwise operators on boolean operands |
These are a different family from the WPROTO### serialization diagnostics, and they follow a different policy on purpose:
WPROTO### | WUH### | |
|---|---|---|
| Reports | A serialization contract that cannot be honoured | An allocation or footgun in correct code |
| Severity | Error: the alternative is an exception from a shipped player | Warning by default; project overrides supported |
| Can fail your build | Yes, and it should | Only when explicitly promoted to Error |
| Default | On | On, except WUH010, WUH013, WUH018, WUH019, WUH021, WUH023, and WUH024 |
A WUH### diagnostic defaults to a warning. Taking a package upgrade does not promote it to a build error; an explicit Error override does. If your project treats warnings as errors, see Turning one off. WUH010, WUH013, WUH018, WUH019, WUH021, WUH023, and WUH024 go further and are off until you ask for them, because their shapes are correct code far more often than they are defects.
The compact policy list offers a severity selector beside each diagnostic:
| Previous policy list | Individual severity controls |
|---|---|
![]() | ![]() |
The example card shows the diagnostic and its preferred code together:

WUH001: a lookup factory passed as a method group¶
C# does not cache a method-group conversion until C# 11, and Unity pins C# 9 on every version this package supports. So a method group written at a call site builds a new delegate on every call, including the lookups that hit, which is the case the lookup exists to make cheap.
Measured on Unity 6000.4.6f1 over 400,000 warm-cache hits, against a control that moved 30.6 MB:
| factory shape | bytes/call |
|---|---|
| method group | 106.3 |
| lambda capturing a method parameter | 115.8 |
static lambda plus a state-taking overload | 0.0 |
cached static readonly Func<...> field | 0.1 |
Where it looks¶
ConcurrentDictionary<K, V>.GetOrAddand.AddOrUpdateConditionalWeakTable<K, V>.GetValue- Every delegate-taking member of this package's own
DictionaryExtensions:GetOrAdd,GetOrElse,AddOrUpdate,TryAdd,Merge,DifferenceandReverse, which extendIDictionaryandIReadOnlyDictionary, so a plainDictionary<K, V>is covered through them even though the BCL gives it no factory-taking member of its own.
That second bullet is matched by parameter type, not by method name. A name list was tried first and was the wrong shape: it named three members and missed TryAdd, whose creator runs only when the key is absent (exactly the defect), along with three more that take an optional Func creator. Matching the delegate parameter means the next factory-taking extension is covered the day it is written.
GetOrElse never adds anything, but it takes the same Func<V> and rebuilds it on every call that finds the key. Same defect.
What it deliberately does not report¶
- A
staticlambda, or a delegate held in a field, a local, or a parameter. Those are built once. - Any method group on a compiler at C# 11 or newer, which caches the conversion. The analyzer checks the compilation's language version and stays silent above C# 10.
- A method named
GetOrAddon a type that is not in the list above. Your own cache type is yours.
WUH002: a nested collection Unity does not serialize¶
Unity's serializer flattens a List<T> or a T[] into a repeated field, and it will not do that twice. A field that resolves onto a collection of collections is dropped in full, with no error and no warning: the asset records the outer structure and none of the inner values, and the Inspector goes on accepting edits that vanish on the next reload.
SerializableList<T> ships for exactly this. It is a [Serializable] class wrapping one List<T>, which is the layer of indirection Unity needs.
Why the declaration does not look nested¶
SerializableDictionary<string, List<Foo>> names one collection. The second appears only when its backing TValueCache[] is substituted, two base classes further up. So the analyzer does not match the declaration's syntax: it asks the symbol what Unity will actually serialize, walking the serialized instance fields of the field's type and of theirs. That covers every adapter this package ships, any it adds later, and a wrapper of your own, with no list to keep in sync.
Where it looks¶
Any field Unity will serialize:
- one carrying
[SerializeField], wherever it appears, or - a public instance field on a type deriving from
UnityEngine.Object, or - a public instance field of a
[Serializable]type the walk reached from one of those. A DTO written the ordinary way ([Serializable], public fields, no[SerializeField]anywhere) is exactly what a dictionary value usually is, and Unity serializes its public fields.
What it deliberately does not report¶
- A public field on a plain class that has no
[SerializeField], where nothing has established that Unity serializes the containing type. It may never reach Unity's serializer at all, and an ordinary algorithm'sList<List<int>>is not a serialization bug. - Anything marked
[NonSerialized],static, orconst. - A collection of
UnityEngine.Objectreferences. Those are serialized as references to a separate asset, so the nesting never happens. - A multi-dimensional array. Unity serializes
int[,]at no nesting at all, so reporting it here would name the wrong cause.
WUH003: null-propagation on a UnityEngine.Object¶
UnityEngine.Object overloads == so that a destroyed object compares equal to null. The C# null-conditional and null-coalescing operators do not use that overload -- they test CLR null. So on a destroyed object obj?.Foo() runs the member access and obj ?? fallback hands back the destroyed object, both silently, and both at exactly the moment the guard was written for.
Six spellings are reported: ?., the null-conditional index ?[], ??, ??=, is null and is not null. The message quotes back whichever one you wrote, so ?[] is never reported as ?..
A pattern is the same mistake in different syntax -- row is null is a CLR null test, so a destroyed object reads as alive -- and it is easy to reach for precisely because it looks like a null check rather than an operator.
A type pattern is deliberately not reported. row is Sprite is not a null test at all: a destroyed object keeps its managed wrapper and still matches, so there is no null test to correct. That is the same reason WUH012 refuses to accept one as a guard.
| C# | |
|---|---|
The signal is the receiver's type, not the operator. Vector2? p; p?.x is correct and common -- a nullable value type is what ?. is for -- so a regex over ?. reports mostly false positives. This asks the semantic model whether the operand is assignable to UnityEngine.Object, including through a generic constraint.
Objects.NotNull and Objects.Null are the package's own tests and go through the overload.
What it deliberately does not report¶
- A nullable value type, a
string, or any type not assignable toUnityEngine.Object. - An unconstrained generic
T, which may not be a Unity object at all. - A receiver whose static type is not a Unity object even if it holds one -- the rule is the static type, because that is what decides which
==the compiler emits.
WUH004: a null assertion that passes over a destroyed object¶
This is the same overload, failing the other way: the assertion passes over an object that is gone. NUnit.Framework.Assert.IsNotNull(destroyed) is green about a thing that no longer exists, and NUnit.Framework.Assert.IsNull(destroyed) fails about one that does not exist either.
| C# | |
|---|---|
Reported for IsNotNull, IsNull, NotNull, Null, and AreEqual / AreNotEqual against a null literal on either side. Assert.That(x, Is.Null) is a constraint expression and is not reported.
Where it looks¶
NUnit.Framework and nothing else. Within that namespace the match is on any type whose name ends in Assert, not on Assert alone, so CollectionAssert and StringAssert are covered by the same rule and so is a same-named type NUnit adds later.
Why Unity's own Assert is excluded¶
UnityEngine.Assertions.Assert is already destroyed-aware, so reporting it would be a false positive on correct code. Measured in a Unity 6000.4.6f1 editor against a destroyed GameObject, with Assert.raiseExceptions = true and an IsNotNull((string)null) control that did fail:
| call on a destroyed object | result |
|---|---|
UnityEngine.Assertions.Assert.IsNull(destroyed) | passes |
UnityEngine.Assertions.Assert.IsNotNull(destroyed) | fails |
Both are the destroyed-aware answers, and both are the opposite of what the same calls answer for a live object. Its IsNull<T> / IsNotNull<T> forward to a UnityEngine.Object overload that compares through the == operator, where NUnit.Framework.Assert.IsNull(object) has no such overload and genuinely tests CLR null. This is recorded because it reads as an omission: do not add the UnityEngine.Assertions namespace back.
WUH005: UnityEngine.Random¶
UnityEngine.Random is one process-global generator that every caller shares. Its state is not out of reach -- InitState and state are exactly the two members that set and read it -- but they move every other caller along with them, so no test can replay one system's draws in isolation. A spawn table, a scatter or a procedural layout built on it produces a bug report that says "sometimes the fruit lands inside the wall" and no way to reproduce it.
| C# | |
|---|---|
The package ships ~20 generators behind IRandom, all seedable and serializable, plus PRNG.Instance. Taking an IRandom field is what lets a test seed it. System.Random is a different mistake with a different fix and is deliberately out of scope.
Swapping afterwards changes every call site at once, which is why the rule is cheapest to adopt at zero uses. Port a range with care: UnityEngine.Random.Range(x, x) returns x, while IRandom.NextFloat(min, max) throws when max is not greater than min. For a spread that may legitimately be zero, draw with NextFloatInRange.
Where it looks¶
Any member of UnityEngine.Random -- method, property or field -- and its nested State type, wherever that type is named. Resolution is through the semantic model rather than textual, because both dodges are free: using R = UnityEngine.Random; leaves no Random. token to grep for, and using static UnityEngine.Random; leaves no qualifier at all.
UnityEngine.Random.State reports under this same id rather than one of its own, and its half of the message is a different one: a type annotation draws nothing, so PRNG.Instance is no fix for it. What that snapshot ties you to is the engine's single generator, which is the only thing it can ever be resumed into. Hold a RandomState instead -- every IRandom hands one out through InternalState, the generators take one back through a constructor, and it serializes.
A second id was built for it and backed out on purpose. Splitting the declaration off would have walked it out from under every #pragma warning disable WUH005 a consumer had already written around a deliberate engine save and restore -- a package upgrade re-raising a warning they had answered, which is exactly what the WUH### contract forbids. Do not propose the split again.
What it deliberately does not report¶
- Code inside
UnityEngine.Randomitself, and inside this package's ownUnityRandomadapter, whose entire job is to call it. The exemption is scoped to that one type, so the twenty seedable generators beside it are still covered. System.Random, which is a different mistake with a different fix.
WUH006: a discarded EffectHandle¶
ApplyEffect hands back the EffectHandle that removes the effect. Two members return one: EffectHandler.ApplyEffect(AttributeEffect), and the AttributeUtilities.ApplyEffect(this Object, AttributeEffect) extension that finds or adds the handler for you. Both answer EffectHandle?. Where the effect is ModifierDurationType.Infinite -- the default a designer lands on, because a duration is a number somebody has to choose -- nothing else expires it, and the object carrying the effect routinely outlives whatever applied it: a summoner, a trigger volume or a cutscene director applies a hold to the player and then goes away.
| C# | |
|---|---|
Duration is authored data the compiler cannot see, so the rule is deliberately not gated on it.
Where it looks¶
A discarded call named ApplyEffect whose return type is EffectHandle or EffectHandle?. The name gate is there because the return type alone is too wide: Attribute.Add, Attribute.Subtract and EnsureHandle hand back an EffectHandle with no undo obligation attached.
TagHandler declares no ApplyEffect at all. Its ForceApplyEffect(AttributeEffect) returns void, so it never reaches this rule -- that is the member to call for an instant effect nothing will ever need to take off.
A handle-less wrapper of your own needs a suppression¶
ApplyEffectsNoAlloc is a differently named member on a different type, not an overload of ApplyEffect -- and it is not exempt. Its two handle-less overloads (ApplyEffectsNoAlloc(this Object, List<AttributeEffect>) and the IEnumerable<AttributeEffect> one) each loop over effectHandler.ApplyEffect(...) and drop the handle, so both carry an explicit #pragma warning disable WUH006 around the loop. Any wrapper you write in the same shape will need the same suppression, with a reason.
Prefer the sibling overload where the handles matter: ApplyEffectsNoAlloc(this Object, List<AttributeEffect>, List<EffectHandle>) fills a buffer you own, and ApplyEffects returns the list.
WUH007: a discarded coroutine handle¶
StartCoroutine returns the only thing that can stop the work, and the only thing that can answer "is this already running". Drop it and StopAllCoroutines is the sole remaining lever -- which also stops the coroutine doing the stopping.
The rule matches on the return type, not on a method name. Measured in the tree this came from, the package's own periodic-job and delay helpers each outnumbered raw StartCoroutine, so a name-only rule saw 9 of 44 call sites. Matching UnityEngine.Coroutine covers MonoBehaviour.StartCoroutine, this package's StartFunctionAsCoroutine, ExecuteFunctionAfterDelay, ExecuteFunctionNextFrame and ExecuteFunctionAfterFrame, and any starter of your own, with no list to keep in sync.
Where one owner starts many, a List<Coroutine> the owner clears where its state ends is the answer, not a bigger guard. A site that must outlive its starter should say so with a suppression carrying a reason.
Reassigning a field over a live handle -- the shape that produces "it got faster every time I re-triggered it" -- is a dataflow question and is not reported.
WUH008: a TryXxx out value read without testing the call¶
| C# | |
|---|---|
The compiler already forces the callee to write every out before it returns, so the slot is never unwritten -- it is unspecified. The BCL happens to write default on failure. Nothing obliges anyone else's TryXxx to, and this package ships plenty of them, so the same shape over its own API reads whatever the callee left in the slot. The failure is quiet in the worst way: a default struct or a 0 count is a plausible value, so the symptom is wrong behaviour rather than a crash.
It is the mirror of the rule the package holds about writing an out: assign it immediately before each return, never once at the top, because an up-front assignment disables the compiler's definite-assignment check. Reading the out after false throws that away from the caller's side.
What it deliberately does not report¶
out _. A discard has nothing to read.- A discarded call whose
outis never read afterwards --_ = set.TryAdd(x, out Thing unused);is a legitimate "add if absent". if (TryX(out v)) { ... },if (!TryX(out v)) { return; },while (TryX(out v)), or any other shape where theboolreached a condition, a local, a field, an argument or a return. That is the overwhelming majority and reporting it would make the rule unusable.- A read that precedes the call in source order but reaches it on a later loop iteration. Pairing is positional within one operation block rather than through a control-flow graph, which is sound for the shape the rule is about -- call, then read -- and refuses to guess about the rest.
- An
outbound to another object's field (TryFill(out other.Field)). Only a local, a parameter, astaticfield or one reached throughthisnames a single storage slot; the field symbol is shared by every instance, so trackinga.Fieldandb.Fieldunder it would pair a binding on one object with a read of another.
WUH009: a teardown's base call that is not last¶
| C# | |
|---|---|
There is a real asymmetry here, and it is why "always call base first" is wrong advice:
- Setup chains base-first. The base has to have registered before the body uses what it registered.
- Teardown chains base-last. The body has to finish using it before the base takes it away.
Reported in an override of OnDestroy, OnDisable, OnApplicationQuit or Dispose() when the base call is a top-level statement with executed statements after it. Local function declarations, empty statements and a bare return; do not count, because none of them runs anything the base call could have been moved ahead of; a base call nested inside an if or a try is left alone rather than guessed at. There is deliberately no allow-list for a body that "only logs afterwards": moving one line is cheaper than a suppression, and an exception list reads as permission.
The message quotes the base call exactly as it was written, arguments included, rather than reconstructing a parameterless one.
Dispose counts at the parameterless arity only. Dispose(bool disposing) is the BCL's own disposal protocol -- Stream, HttpContent, DbConnection -- where chaining base.Dispose(disposing) first is the documented convention rather than a mistake.
No mirror rule for setup exists, here or anywhere else in the package. Ordering Awake and OnEnable base-first is still right; nothing checks it for you.
WUH010: a dictionary indexer read (opt-in)¶
This is the one member of the family that is off by default. Reading a key you know is present is correct and ubiquitous, so an on-by-default rule here would bury the other eleven on your first build. Turn it on when you want the discipline:
| XML | |
|---|---|
A key indexer has nothing to return for a key that is absent. Dictionary<K, V> throws KeyNotFoundException; TryGetValue reports and hands you the value in the same lookup.
| C# | |
|---|---|
if (map.ContainsKey(k)) { Use(map[k]); } is still reported, deliberately. Proving the guard covers the read needs dataflow, and the shape it would exempt is a double lookup that TryGetValue collapses into one — both point the same way.
match.Groups["name"], which is worse than throwing¶
GroupCollection's string indexer does not throw. It returns a non-participating Group whose Value is "". So a group name the pattern does not declare — a typo, or a rename of the named group in the regex above — reads as an ordinary miss, forever, with nothing red. TryGetValue, or testing Group.Success, makes it visible.
GroupCollection implements IReadOnlyDictionary<string, Group> only from .NET Core 3.0, and not on the netstandard2.1 profile Unity compiles against — so the interface test alone would report this in a modern unit test and nowhere a consumer builds. The analyzer matches the type by name as well, which is why it fires where it matters.
What it deliberately does not report¶
- A write:
map[key] = valueis add-or-update and has no betterTryform. An interface-typed write and an object-initializer["a"] = 1are the same. - A compound read-modify-write is reported (
map[key] += 1,map[key]++,map[key] ??= v): the read half throws exactly as a bare read does. The counting idiom becomesmap[key] = map.TryGetValue(key, out int count) ? count + 1 : 1;. - An indexer on a
List<T>, an array, astring, aSpan<T>, or aSortedList'sValues. match.Groups[0], which is anintindex and always present.
The package holds its own production code to it¶
Generator~/CheckProjects.ruleset enables WUH010 for all five local check projects, where warnings are errors: TypeCheck (Runtime/), EditorCheck (Editor/), TestCheck (Tests/Runtime/), EditorTestCheck (Tests/Editor/) and IntegrationCheck (Runtime/Integrations/).
The test trees were exempt until #653: 286 of the 346 sites the rule first reported were in Tests/, dominated by fixtures whose subject is the indexer, and rewriting Assert.AreEqual(1, map["a"]) in a dictionary's own test deletes what it tests. Ten such fixtures now carry a file-level #pragma warning disable WUH010 naming the subject; the rest read through Tests/Core/DictionaryAssertions.ValueFor, which fails naming the key.
Runtime/Integrations/ is the fifth and newest, added with the project that compiles it (#687) -- until then no check project compiled that tree at all, so no WUH### rule had ever run there. It contributes zero WUH010 sites.
WUH011: changing a serialized string comparer after use¶
SerializedStringComparer is mutable so Unity can serialize and author its compareMode. A collection retains the comparer instance it receives. Changing the mode after construction changes where future lookups search without moving keys already stored under the old hash, so a present key can become unreachable.
Freeze the comparison rule when the collection takes ownership, or finish configuring it first:
| C# | |
|---|---|
The rule follows a local, parameter, or directly referenced field through straight-line code in one lexical block. It recognizes Dictionary, HashSet, and ConcurrentDictionary, resets when the variable is rebound, and stays quiet before collection construction or after Freeze(). Keeping the use and write in one block is deliberately conservative: it avoids claiming that mutually exclusive branches both ran. Aliases, custom collections, and ownership passed between methods are outside its local flow scope.
WUH012: a serialized row dereferenced without a test¶
A serialized List<T> or T[] of references is untrusted data. Delete or rename the asset a row names -- or take a component off a prefab, which is a far more ordinary edit -- and Unity leaves the row behind, empty. No editing session has to have happened for "the list is authored correctly" to stop being true.
Guarding the list is not guarding the rows: if (_postProcessors != null) still throws on the first empty row. Test the row, or drop the null rows once and walk them forever after:
Compaction counts as the guard on purpose. Where a list is walked more than once, testing each row in each walk is the worse of the two repairs, and a rule that makes the code worse in order to be satisfied is a rule people route around. A null test anywhere in the walk's body clears the rule too: once the author has treated the row as an input, where the test belongs is theirs. if (!row) counts, because UnityEngine.Object's implicit bool conversion is the same native aliveness check == performs.
The rule asks the symbol whether the element type derives from UnityEngine.Object, so an alias, a constrained type parameter, or a base class of your own reaches it. It looks only at foreach over a field the Unity serializer accepts: a [NonSerialized] field, a private field with no [SerializeField], and a local copy are all outside it.
WUH013: a counting loop that could be a foreach¶
foreach over an array or a List<T> allocates nothing: the array form compiles to an indexed loop, and List<T> returns a struct enumerator the JIT keeps on the stack. So a counting loop over either, whose body only ever uses the index to reach into that same sequence, says less than foreach does for no benefit. Other concrete collections also qualify when the compiler binds a public GetEnumerator() returning a value type, and its element type matches the indexer. The compiler checks MoveNext and Current, including inherited and hidden members; a method called GetEnumerator alone is insufficient. Integer-keyed dictionaries do not qualify because their indexer returns values while enumeration returns key/value pairs. This rule proves the enumeration shape avoids boxing; it does not measure allocations inside a custom collection's enumerator methods.
| C# | |
|---|---|
The counting loop is the right shape more often than not, which is why this is off by default. It is not reported when:
- the sequence is an interface --
IReadOnlyList<T>andIList<T>hand backIEnumerator<T>, which boxes, so the counting loop is the allocation-free one; - the body uses the index for anything else -- reporting it, offsetting it, indexing a second collection with it;
- the walk is not the ordinary forward one: a non-zero start, a stride other than one, or backwards;
- the body writes to the sequence, replaces it, or directly mutates the iterated list, including in-place compaction through a different index;
- the body calls an unknown instance method on a custom collection, or exposes the sequence through a local, tuple, array, assignment, or argument that may permit mutation through an alias.
A bound cached in a single local declaration immediately before the loop also qualifies, such as int count = rows.Count;. The rule keeps indexed traversal when the body changes that local, when other statements intervene, or when the cached expression covers only part of the sequence.
The discriminator is the sequence's type, which is why this cannot be a source linter: the loop over List<T> and the identical loop over IReadOnlyList<T> are the same tokens and opposite answers.
Turn it on with <Rule Id="WUH013" Action="Warning" /> in Assets/Default.ruleset. It remains off by default for consumers. The package enables it in all five local check projects after #671. The closing inventory contained 306 sites: 187 in Editor/, 95 in Tests/, and 24 in Runtime/ (22 in integrations). A persistent audit found 12 additional sites in Editor files excluded from normal host compilation for old Unity reference gaps, with three more pool loops in SINGLE_THREADED, bringing the complete inventory to 321. The audit checks all ten excluded production files and requires a reporting control beside an unresolved API in the same compilation. Its separate negative control proves those sources can fail the gate. The custom-enumerator and cached-bound extension also covers NativeArray color scans, TypeCache discovery, and pooled component collection. These traversals keep their element order; no player throughput improvement is claimed. Ordinary reads now use foreach; mutation-sensitive loops keep indexed traversal. If a callback can mutate the sequence indirectly, keep the indexed loop with a narrowly scoped suppression that explains the callback path. Negative controls require WUH013 to fail every check tree.
WUH014: a disposable struct's Dispose that assigns¶
readonly on a disposable struct settles one half of copy safety and not the other. It stops a struct tracking "have I been disposed?" in one of its own fields, where the flag is per copy and a copy handed to a method cannot see that the original finished. It says nothing about a scope that captures a global and restores it from its own field: every copy agrees about what to put back and none of them about whether it already has, so a second Dispose re-imposes a value the world has moved past — which reads as "something else changed it back".
RestorableGlobal<T> ships for exactly this, at any nesting depth and in any disposal order.
Where the line is drawn¶
The signal is an assignment inside the parameterless Dispose of a struct that implements IDisposable, and what decides it is the assignment's target:
| Target | Reported | Why |
|---|---|---|
| One of the struct's own instance fields | Yes | Every copy carries its own, so every copy re-runs it. |
A static field or property, anywhere | Yes | It outlives every copy, and each copy re-imposes it. |
A local declared inside Dispose | No | Nothing outside the call can see it. |
| A member of an object the struct references | No | That object is shared by every copy — where it belongs. |
| An array or indexer element | No | Same: the array is the shared thing. |
That fourth row is the important one. A struct whose Dispose mutates something it holds a reference to is the correct shape, and it is how every disposable this package ships works — SemaphoreLease, PooledResource<T>, IndentLevelScope. Giving a claim back is a call to whoever issued it, not an assignment.
What it deliberately does not report¶
- A
class. One instance, one_disposedflag, and the flag works. Dispose(bool disposing), which is the BCL's own protocol and a class's shape.- An assignment inside a lambda or a local function declared in
Dispose, whose body runs somewhere else. - A write through a
reflocal that aliases a field. Tracking that is a dataflow question, and a rule that guesses is a rule people route around. - A scope that restores through a captured delegate rather than an assignment. The mechanical signal cannot see it; the fix is the same one.
It pairs with "a disposable struct is readonly" rather than replacing it — the project this was measured on had the readonly half passing on all three offenders. On by default: measured across this package's Runtime/, Editor/ and Tests/ at five findings in three types, which is far from the "the rule is right and the shape is everywhere" bar that puts WUH010 and WUH013 behind an opt-in (#627).
WUH015: an invalid Unity lifecycle signature¶
Recognized Unity callback families include lifecycle, physics, rendering, animation, audio, input, application, transform, legacy network, and editor-window messages. Ancestry and parameter types are semantic: aliases, partial classes, generic bases, and separately compiled ancestors work without source-text guesses. Each owning Unity type has its own callback set.
| C# | |
|---|---|
Accepted coroutine forms and optional collision arguments are preserved. A method's static/generic shape, ref return, or wrong argument type is reported. Overrides and explicit interface implementations remain governed by their declared contracts. Uncertain coroutine forms, including IEnumerator returns on 2D collision and trigger messages, remain unreported to avoid unsupported warnings based on missing documentation. This is conservative diagnostic coverage, not exhaustive engine-contract validation or proof that a Unity version or configuration dispatches a callback.
WUH016: a hidden inherited Unity callback¶
A derived callback with the same parameter signature as an ancestor callback is reported even when the base callback is private or comes from another assembly. Use a virtual base callback and an override with an explicit base call when both implementations must run. An actual override is accepted; intentional hiding can be suppressed.
The Unity Method Analyzer window now presents these compiler diagnostics alongside the compiler's ordinary inheritance diagnostics. Its regex source scanner is retired. Both rules are warnings, enabled by default and suppressible through standard compiler controls or the package's existing test-only SuppressAnalyzerAttribute.
WUH017: a GetComponent compared against null¶
A same-object GetComponent<T>() compared against null -- GetComponent<T>() != null, GetComponent(typeof(T)) == null, is null, is not null, in either operand position -- asks only whether one is present, and that is TryGetComponent's question. Unity documents that GetComponent allocates in the Editor when the component is absent and TryGetComponent does not: a cost no player profiler run will ever show you, paid on every editor frame that takes the absent branch. The comparison discards the component it found, so nothing is lost by switching.
The rule matches the callee symbol, not the name. Three different methods are spelled GetComponent, and only the ones with a Try counterpart that means the same thing are reported: the generic form and the System.Type form declared on UnityEngine.Component and UnityEngine.GameObject. GetComponentInChildren/GetComponentInParent are not reported -- there is no TryGetComponentInChildren, and the package's HasComponent forwards to TryGetComponent, which searches one object -- and neither is GetComponent(string) nor the package's own Helpers.GetComponent<T>, whose null comparison tests the target (it returns default for a target that is neither a GameObject nor a Component) rather than asking an existence question. GetComponentInChildren<T>() != null stays as written; the fix this rule names must exist where the diagnostic fires.
WUH018: implicit string equality (opt-in)¶
The string == and != operators use ordinal, case-sensitive comparison. That behavior is often correct, but the source does not show whether the choice is deliberate. Enable WUH018 when a project requires every non-null string comparison to state its policy:
| XML | |
|---|---|
| C# | |
|---|---|
The rule reports the binary operator when both operands resolve to System.String. It does not report null checks, pattern checks, string.Equals, object.ReferenceEquals, non-string operands, or user-defined equality operators. It is off by default because operator equality is valid and common; the diagnostic is an explicit-policy audit, not a claim that ordinal comparison is wrong.
The package enables this rule, together with WUH010 and WUH013, in every package-owned .NET project. Both analyzer implementations and their tooling self-host the shipped analyzer binaries; the five Unity source gates cover Runtime, Editor, integrations, runtime tests, and editor tests. All owned code therefore enforces these shared policies while the consumer defaults remain unchanged.
WUH019: repeated stable loop bound (opt-in)¶
Cache a stable Length, LongLength, Count, childCount, arraySize, or array dimension (GetLength / GetLongLength with a constant dimension) in a named local before an indexed loop. This avoids repeated getters and makes a snapshot explicit. Keep foreach when an allocation-free enumeration fits; WUH013 still applies independently.
| XML | |
|---|---|
| C# | |
|---|---|
The semantic check covers arrays, strings, spans, memory, concrete standard-library collection getters, Unity Transform.childCount, and SerializedProperty.arraySize. It checks for conditions, including arithmetic bounds. A borrowed interface Count getter can itself execute user code, so it is not diagnosed; cache such a source only after proving its concrete ownership. It leaves virtual, interface, and unknown getters and receiver expressions alone. It does not advise hoisting a conditionally evaluated bound ahead of a null guard, a receiver replacement, direct collection mutation, an alias or unknown-code escape, callbacks over mutable collections, aliases declared before traversal, concurrent collections, or traversal that yields or awaits while a caller can mutate the collection. Mutable collection loops that invoke other code are conservative: standard collection calls can invoke user equality code, and separate parameters can alias the same collection at runtime. The analyzer does not prove a call graph pure. Callback-sensitive loops accept only known primitive operation shapes; unknown constructions, implicit disposal or enumeration, dynamic access, custom event accessors, interpolation, deconstruction, and user operators are left alone. Unknown interface and callback-capable dictionary indexers also retain live bounds. Concrete List<T> indexers have no callback dispatch. Field receivers used around unknown calls are conservative because those calls can replace fields. Array element writes are safe: they do not change the length. Known array dimensions are stable; a changing dimension argument is not a cacheable bound.
Live bounds remain deliberate in pool purges, callback-driven collection walks, serialized-array rows removed while traversal continues, iterators, and activation traversals whose Unity callbacks can change the hierarchy. Do not cache these sizes without changing the traversal contract. A suppression should name the changing state when a callback or alias is outside the analyzer's view.
The policy is off by default. Production TypeCheck, EditorCheck, and IntegrationCheck enable it through Generator~/ProductionCheckProjects.ruleset, which imports the shared policies. Test and tooling projects keep the shared ruleset. WUH019 promotion is limited to those production projects: explicitly listing a diagnostic in WarningsAsErrors enables it even when a shared ruleset says None. The excluded-production-source audit runs WUH013, WUH019, and WUH021 and requires a reporting control for each beside an unresolved API in the same compilation. Known host reference gaps cannot silently bypass these collection-size rules in the nine audited production sources.
WUH020: hard stack and queue reads¶
Use Stack<T>.TryPop, Queue<T>.TryDequeue, and their TryPeek methods when the target framework exposes them. These methods make the empty case explicit without throwing. Shared Stack<T> and Queue<T> still require synchronization between threads.
| C# | |
|---|---|
The analyzer resolves the invoked method to the generic .NET stack or queue types. A custom Pop, Peek, or Dequeue method, even on a class named Stack, stays outside the rule. A method hidden by a derived custom type also stays outside it; an inherited .NET method remains covered, including through a constrained generic type parameter. An alternative must be accessible, return bool, and accept exactly one out parameter matching the hard method's return type. Older target frameworks without these APIs are exempt. Non-generic collections without Try methods are exempt as well. Concurrent stacks and queues already expose Try methods for these operations, which produce no diagnostic.
A separate Count guard does not exempt a hard read: the rule requires the available Try API even when a caller knows the collection is populated. A guard can also become stale if another caller changes the collection. The diagnostic is on by default, suppressible, and a warning. There is no automatic fix because choosing what happens when the collection is empty requires caller policy. Checking the boolean result also avoids WUH008.
WUH021: repeated collection Count (opt-in)¶
This policy covers repeated size observations: collection counts, known lengths, and LINQ counts.
Read Count once when a guard and later work require one snapshot:
| C# | |
|---|---|
The rule recognizes these semantic contracts when the target framework exposes them:
| Size member | Known types |
|---|---|
Count property | .NET ICollection<T>, IReadOnlyCollection<T>, and non-generic ICollection implementations, including List<T> and ArraySegment<T> |
Length and LongLength | Arrays, including multidimensional arrays and receivers typed as System.Array |
Length | string, Span<T>, ReadOnlySpan<T>, Memory<T>, ReadOnlyMemory<T>, StringBuilder, ImmutableArray<T>, and ReadOnlySequence<T> |
Count() and LongCount() | Non-predicate System.Linq.Enumerable overloads on one source |
Collection contracts include inherited interfaces and constrained generic receivers. Standard List<T> exposes Count, rather than Length. Custom members with these names outside the known contracts are exempt. Missing optional framework types require no new reference.
| C# | |
|---|---|
Both extension-call and static Enumerable.Count(items) forms identify the same constructed method and source. Count and LongCount are distinct contracts: their result widths and overflow behavior differ. A property Count and Enumerable.Count are also kept separate. Predicate counts, Queryable, parallel LINQ, custom Count methods, different generic element contracts, and factory/property receivers whose identity is unknown are exempt. The rule does not track aliases. Enumeration can invoke callbacks, so a count call discards observations of other receivers; a repeated count of the same source is still reported when one snapshot is intended. It follows the same local, parameter, or field receiver along uninterrupted evaluation paths, including guards, conditional expressions, and call arguments. Reference casts retain receiver identity when the invoked member remains the same. Interface dispatch on an unsealed receiver can select a different implementation, so it does not merge a class Count with an interface Count. A sealed receiver can establish that mapping. Distinct explicit Count implementations remain separate observations.
Calls, mutations, unknown getters, user operators, and constructors interrupt observations. Unfamiliar operation shapes are opaque boundaries, including implicit coalesce conversions and record clones. Static field access can run a type initializer and also interrupts earlier reads. Constructors run before their initializers; nested functions start with independent state. Loop and exception boundaries do not carry earlier observations into later paths. This conservative analysis does not prove size observations pure or thread-safe. Interface getters and enumeration can execute custom code; enumeration can be expensive, stateful, or one-shot. Concurrent observations can differ even without a visible mutation. A snapshot does not synchronize a collection. Suppress intentional repeated observations or repeated enumerations when one snapshot would change the intended behavior. There is no automatic code fix.
This suppressible warning is off by default for consumers. Enable it with <Rule Id="WUH021" Action="Warning" /> in a project ruleset. Production TypeCheck, EditorCheck, and IntegrationCheck enable and promote WUH021 alongside WUH019 through Generator~/ProductionCheckProjects.ruleset; tests and tooling retain the shared opt-in policy. Compiler controls require both production diagnostics and their absence in shared projects. The existing excluded-source audit also enforces WUH021 in its nine production subjects and requires a separate reporting control alongside known unresolved host APIs.
Turning one off¶
Suppress a single call site whose lookup is genuinely cold:
| C# | |
|---|---|
Or turn the rule off for the whole project in Assets/Default.ruleset:
An IDE or standalone .NET build can set dotnet_diagnostic.WUH001.severity = none in .editorconfig instead.
Related¶
- Serialization diagnostics: the
WPROTO###family - Reflection performance: where these caches are used most
WUH022: boxing a disposable value type¶
A conversion from a disposable struct to IDisposable, object, ValueType, or another implemented interface boxes a copy. Nullable disposable values box when present; a constrained generic conversion can box its value-type instantiations; reference-type instantiations and empty nullable inputs do not allocate a box. The warning reports the compiler's boxing conversion, including arguments, returns, casts, and collection elements.
| C# | |
|---|---|
A generic helper constrained to IDisposable can call Dispose without converting its parameter to an interface variable. Concrete copies, direct using, class-to-interface conversions, unboxing, and structs that do not implement IDisposable remain unreported. Intentional interface boundaries can use a narrow suppression; for example, a nongeneric IEnumerator.Current must return object. Explicit IEnumerable.GetEnumerator and IDictionary.GetEnumerator returns are excluded because the interface protocol requires boxing a value enumerator. Ordinary public methods returning an enumerator interface remain reportable. Generated code follows the analyzer family's exclusion policy.
This policy is a warning about allocation, not a proof of exclusive ownership. The package retains its runtime copy-safe disposal guarantees when the analyzer is disabled or suppressed. C# defines value copies on assignment and boxing in its struct specification, and analyzers can be suppressed by consumers.
Why disposal bookkeeping remains at runtime¶
The #924 investigation found that prohibiting source copies alone cannot preserve the current disposal contract. A single uncopied local can call Dispose twice, or call it manually before the compiler-generated disposal at the end of using. Already compiled callers do not run a new analyzer. Reflection, separately compiled methods, generated code, closures, generic wrappers, and aliases also prevent a local source warning from proving that every possible owner has surrendered its right to dispose. ref struct limits escaping and boxing, but remains a value type that can be copied; its compiler restrictions do not provide move-only ownership.
The host compiler fixture DisposalOwnershipCompilerTests compiles the actual shipped SemaphoreLease and DisposalLease sources at C# 9, with and without SINGLE_THREADED. Assignment, by-value calls, closure capture, static-field retention, repeated Dispose, and manual disposal inside using compile without either disposal analyzer reporting a warning. A separate positive control confirms interface boxing produces WUH022, then compiles the same conversion with a local warning suppression and receives no analyzer diagnostic. These tests pin the boundary between advisory source checks and runtime ownership; they do not measure Unity player timing or allocation.
A C# 9 readonly ref struct wrapper also compiles local assignment, by-value calls, repeated Dispose, and manual disposal inside using in both threading modes. The wrapper contains the actual shipped semaphore lease; it restricts heap escape but still copies ownership. These eight compiler controls show why a stack-only public lease cannot replace the runtime generation check. Changing the public lease itself to a ref struct would also break its existing asynchronous and interface-based APIs.
The runtime sweep identified DisposableScope, DisposableScope<TPrevious>, SemaphoreLease, PooledResource<T>, and PooledArray<T> using shared (slot, generation) disposal leases. RestorableGlobal<T> and editor global scopes use owner-issued borrow identifiers, which also protect nested and out-of-order restoration. PartitionPooled deliberately keeps copies of the consumer's lease so enumeration cleanup returns outstanding partitions without returning a live renter's reused buffer. These are existing runtime behaviors that a blanket copy ban would forbid.
Removing a generation check from a shared disposal handle would require a separate ownership contract enforced across every caller, including repeated disposal and all escapes. A suppressible allocation analyzer cannot supply that contract. WUH022 instead removes an observable, avoidable allocation at the source boundary while retaining stale-generation rejection, exactly one concurrent disposal winner, warmed acquisition behavior, and safe copies. This investigation reports compiler conversion evidence and ownership behavior, without claiming measured player allocation or timing improvements.
A private operation can instead keep the entire lifetime inside its own body without issuing a handle. The four numeric WallMath.Mean list paths use internal SystemArrayPool<T> private-owner rent/return helpers, backed by the same ArrayPool<T>.Shared, and return in finally. Only the initialized logical prefix is read; accumulation order, validation, source contents, and numeric clearing behavior stay unchanged. Tests that observe slot generations verify that these paths do not acquire or claim disposal leases. This boundary does not rely on an analyzer and does not weaken public lease guarantees. It demonstrates removal of lease work, not a measured timing improvement; it does not extend to buffers that escape or whose disposal is shared with consumers.
The same internal helpers centralize existing private cache and serializer array rentals without changing their lifetimes, clearing flags, or failure behavior. Serializer owner leases still use their existing runtime checks; storing an array in its private owner does not issue another lease.
See using pooled resources safely for lease lifetimes, safe returned results and the choice between an array lease and a synchronous borrowed buffer.
WUH023: public struct equality (opt-in)¶
Enable this warning to require each publicly accessible ordinary struct to implement System.IEquatable<T> for its own type. Generic structs use their complete constructed self type. An unrelated interface with the same name or IEquatable<OtherType> does not satisfy the rule. Partial declarations produce one warning. Internal and private types and generated code are excluded. Unity's supported C# version cannot use a ref struct as this interface's type argument, so ref structs are excluded.
| XML | |
|---|---|
Use Tools > Wallstop Studios > Unity Helpers > Analyzer Policies to configure the warning, or add this rule to your analyzer ruleset. The package enables it in its production compile gates.
Package value records, configurations, surrogates, scopes, and enumerators provide typed equality and matching object equality and hashes. Existing custom contracts keep their equality rules. New implementations compare stored fields with their default typed comparers: strings use ordinal value equality, floating point values use Equals (including NaN and signed zero), and arrays and ordinary collections retain reference equality. Scope equality compares immutable lease identity; comparison and hashing do not claim a lease, dispose a resource, or inspect mutable disposal state. Enumerator equality compares stored state and does not advance either enumerator.
Typed equality avoids boxing the containing struct at generic equality call sites. A stored type whose own default comparer uses object equality can still box internally. This warning checks the interface contract; it does not prove allocation counts, equality laws, hash quality, deep collection equality, or whether a mutable value is safe to change after use as a dictionary key. Keep hash codes within the current process and avoid mutating dictionary keys.
WUH024: typed enum helpers (opt-in)¶
Enable this rule when you want suggestions for enum checks and repeated enum names:
| XML | |
|---|---|
Import WallstopStudios.UnityHelpers.Core.Extension to use the helpers:
| C# | |
|---|---|
The rule recognizes Enum.HasFlag only when both arguments have the same concrete enum type. It also recognizes all-bits comparisons such as (permissions & Permissions.ReadWrite) == Permissions.ReadWrite, including zero masks. Single-bit checks such as (permissions & Permissions.Read) != 0 are also covered, including signed high bits. It leaves multi-bit any-match tests, variable-mask comparisons, mixed enum types, boxed Enum values, constrained generic calls and expression trees unchanged.
Parameterless enum ToString() can use ToCachedName() for repeated names. Formatted calls such as ToString("D") retain their formatting. Name caching retains entries for previously unseen values; keep ordinary ToString() when values are numerous and rarely repeated. The rule does not promise a speedup on every runtime and provides no automatic rewrite. Suppress intentional calls with the normal analyzer policy controls.
WUH025: unsupported relational field¶
ParentComponent, SiblingComponent, and ChildComponent fields must use a component, interface, component base type, or a vector array, List<T>, or HashSet<T> of those values. The compiler reports multidimensional and nested collections and unrelated scalar or element types, including private fields and attributes on auto-property backing fields.
Choose Error for WUH025 in Analyzer Policies to stop compilation, or add this rule inside the Unity Helpers analyzer group in Assets/Default.ruleset:
| XML | |
|---|---|
The default is a suppressible warning. Package-owned compiler checks promote it to an error. Generic fields whose concrete element type is not yet known still require runtime validation. All interface contracts remain supported, including interfaces that also implement enumeration; a component can implement these interfaces. The analyzer does not infer collection storage from an interface name. Runtime guards remain necessary for older compiled assemblies and reflection.
WUH026: boolean bitwise operators¶
Use && and || to combine boolean values. WUH026 reports &, |, ^, &=, |=, and ^= when both operands resolve to bool or bool?, including members declared in another file. Integer masks, enum flags, and user-defined operators on other types are excluded. The warning is on by default and can be suppressed like the other WUH rules.
Keep required calls in their original order. For example, this must visit every atlas even after one changes:
When a call can also change the accumulator, save the old accumulator before calling it. A direct changed = changed || GenerateAtlas(atlas) would skip the call once changed becomes true. Compound assignments to properties or indexers also require preserving receiver evaluation, getter order, and the original value.
For non-nullable booleans, use left != right for exclusive-or, or use an explicit conditional toggle when accumulating a change. Nullable booleans need separate null handling: false & null is false, true | null is true, and null ^ false is null. left != right does not preserve nullable exclusive-or semantics. There is no automatic fix because the analyzer cannot choose which evaluations and null states the application needs.

