Static ECS Analyzer
May 27, 2026 · View on GitHub
Static ECS Analyzer — Roslyn diagnostics & code-fixes for FFS.StaticEcs
Roslyn analyzer + code-fix suite that catches common misuses of the StaticEcs framework at compile time.
The package is fully self-contained — FFS.StaticEcs itself does not bundle these analyzers; you add this package explicitly.
Installation
NuGet
dotnet add package FFS.StaticEcs.Analyzers
Unity (UPM)
Via git URL in Unity PackageManager:
https://github.com/Felid-Force-Studios/StaticEcs-Analyzer.git
Or adding to the manifest Packages/manifest.json:
"com.felid-force-studios.static-ecs-analyzer": "https://github.com/Felid-Force-Studios/StaticEcs-Analyzer.git"
The analyzer auto-disables itself when the compilation does not reference FFS.StaticEcs, so it is safe to keep in any solution.
Diagnostic categories:
FFS.StaticEcs.Correctness— code that compiles but is semantically wrong (silent copies of ref-returns, use-after-free of entities, contradictory query filters, …).FFS.StaticEcs.Performance— patterns that allocate or block runtime optimizations (closure-capturing lambdas inQuery.For).FFS.StaticEcs.Usage— style/clarity suggestions (more direct API available).
Rule index
| ID | Category | Severity | Title | CodeFix |
|---|---|---|---|---|
| FFSECS0010 | Correctness | Error | Ref-returning member result must be bound by 'ref' | yes |
| FFSECS0011 | Correctness | Info | Read<T>() result is bound to a copy | yes |
| FFSECS0012 | Correctness | Info | Ref-local backed by StaticEcs storage passed by value (atomically-valued types skipped) | yes |
| FFSECS0013 | Correctness | Info | Writable ref obtained from a ref-returning member is used only for reading; suggest the read-only sibling | yes |
| FFSECS0020 | Correctness | Error | StaticEcs marker interface must be implemented by a struct | yes |
| FFSECS0021 | Correctness | Error | IMultiComponent must be implemented by a struct | yes |
| FFSECS0022 | Correctness | Warning | Non-unmanaged IMultiComponent must override Write/Read | — |
| FFSECS0030 | Correctness | Info | Query.For lambda parameter declared ref but never mutated | yes |
| FFSECS0031 | Performance | Error | Lambda in Query.For captures outer state | — |
| FFSECS0032 | Usage | Info | IsMatch<TFilter>() can be replaced with a direct Entity method | yes |
| FFSECS0033 | Usage | Info | foreach over Entities() convertible to Query.For(...) | yes |
| FFSECS0040 | Correctness | Error | ref/in reference to a component used after invalidation | — |
| FFSECS0041 | Correctness | Error | Entity used after invalidation | — |
| FFSECS0042 | Correctness | Warning | Ref/Mut/Read<T> called without a visible presence guarantee | yes |
| FFSECS0050 | Correctness | Error | Redundant component in query filter | — |
| FFSECS0051 | Correctness | Error | Contradictory All + None in query filter | — |
Rules
FFSECS0010
Category: Correctness · Severity: Error · CodeFix: yes
Entity.Ref/Mut/Add, Components<T>.Ref/Mut/Add, Resource<T>.Value, NamedResource<T>.Value, Multi<T>.First/Last/[i], MultiComponentsIterator<T>.Current all return by reference. Binding the result to a non-ref local silently copies the component — any mutation goes to the copy, not the storage. Detected in variable declarations, value-arguments, simple assignments and non-ref return statements.
Reference-typed payloads (e.g. Resource<MyClass>.Value) are suppressed: copying a reference is cheap and idiomatic. The same applies when the outer value of the chain is atomically copyable — a primitive, enum, IntPtr/UIntPtr, or reference type — e.g. entity.Ref<C>().PrimitiveField. A copy of such a value is losslessly equivalent to a ref-binding, so the diagnostic is suppressed.
Pattern (will fire)
var pos = entity.Ref<Position>(); // FFSECS0010 — silent copy
Consume(entity.Ref<Position>()); // FFSECS0010 — copied at call boundary
return entity.Ref<Position>(); // FFSECS0010 — copied at return
Fix (no diagnostic)
ref var pos = ref entity.Ref<Position>(); // ok — ref-bound
entity.Ref<Position>().Value = 5; // ok — direct write through ref
Consume(ref entity.Ref<Position>()); // ok — passed by ref
Explicit opt-in to a copy: *RO siblings
When you genuinely want a snapshot (a copy) from Resource<T> / NamedResource<T> / Multi<T> / MultiComponentsIterator<T>, use the dedicated *RO members instead of binding the mutable ref-return to a plain local. These return ref readonly T and are intentionally not in the analyzer's allow-list — the RO suffix communicates the intent in the source.
| Mutable (flagged) | Read-only sibling |
|---|---|
Resource<T>.Value | Resource<T>.ValueRO |
NamedResource<T>.Value | NamedResource<T>.ValueRO |
Multi<T>.First() | Multi<T>.GetFirst() |
Multi<T>.Last() | Multi<T>.GetLast() |
Multi<T>[idx] | Multi<T>.Get(idx) |
MultiComponentsIterator<T>.Current | MultiComponentsIterator<T>.CurrentRO |
var snapshot = timer.ValueRO; // ok — explicit RO opt-in, no diagnostic
ref readonly var refSnap = ref multi.GetFirst();
For Entity and Components<T> the snapshot path is Read<T>() / Read(Entity) — paired with FFSECS0011 (Info hint).
The codefix offers a one-click «Switch to '*RO' (intentional copy)» action.
For Entity.Ref<T> / Components<T>.Ref/Mut on a var-declaration the codefix also offers «Switch to Read<T>()». The exact set of variants depends on the payload size:
- T ≤ 8 bytes (per the FFSECS0011 size-based suppression): two actions are offered — first
var x = entity.Read<T>();(plain copy snapshot, recommended), thenref readonly var x = ref entity.Read<T>();(bind-by-ref readonly, in case the user wants the binding explicitly). - T > 8 bytes or unknown: only the bind-by-
ref readonlyvariant is offered — a copy of a larger struct would be a pessimization.
FFSECS0011
Category: Correctness · Severity: Info · CodeFix: yes
Entity.Read<T>() and Components<T>.Read(Entity) return ref readonly T. Binding to a non-ref-readonly local copies the value — undesirable for large components. Surfaced as an IDE hint; silence per-project with dotnet_diagnostic.FFSECS0011.severity = none.
Like FFSECS0010, the rule is suppressed when the outer value of the chain is atomically copyable — primitive, enum, IntPtr/UIntPtr, or reference type. For example entity.Read<C>().PrimitiveField does not fire: the call already produces a value that survives the copy losslessly.
The rule is also suppressed when the Read<T> payload is a value type with a conservatively-estimated size of ≤ 8 bytes — e.g. struct Id { uint Value; }, readonly struct WorldEntityMask { uint Mask; }, struct Pair { uint A; uint B; }. A ≤ 8-byte copy fits in a single register on x64/ARM64 ABIs, so binding to ref readonly var brings no measurable win and readonly-ness of the struct doesn't matter. Estimation is conservative: open generics, explicit struct layout, pointer/function-pointer/fixed-buffer fields → no suppression.
Pattern (will fire)
var snapshot = entity.Read<Position>(); // FFSECS0011 — copied
Fix (no diagnostic)
ref readonly var snap = ref entity.Read<Position>();
Consume(in entity.Read<Position>()); // ok — passed by 'in'
FFSECS0012
Category: Correctness · Severity: Info · CodeFix: yes
A ref / ref readonly local bound to a StaticEcs storage source was passed by value. This copies the component at the call boundary — the callee mutates the copy. The hint is heuristic: the analyzer can't tell an accidental loss of ref semantics from an intentional pass-the-current-value, so it surfaces as Info; to silence globally use dotnet_diagnostic.FFSECS0012.severity = none in .editorconfig.
Atomically-valued types are excluded automatically — they have no internal state that could be lost via a copy: CLR primitives (bool/int/float/...), enums, and reference types (the local holds a pointer; copying the pointer still hits the same heap object).
ref readonly locals are also untracked: the user already opted into a readonly snapshot — there's no writable ref semantics to lose. This covers e.g. extension-method invocations on ref readonly locals (entityType.Name() inside string interpolation, etc.) which Roslyn lowers to a value-typed this argument.
Pattern (will fire)
ref var hp = ref entity.Ref<Health>(); // Health — multi-field struct
Consume(hp); // FFSECS0012 — copied at the call
Fix
Consume(ref hp); // ok
Consume(in hp); // ok — 'in' accepts a ref local
ref var id = ref entity.Add<PlayerId>().Value; // .Value — ushort, atomic
SetBehaviour(id); // ok — primitive isn't tracked
ref var st = ref entity.Ref<C>().Status; // Status — enum
M(st); // ok — enum isn't tracked
FFSECS0013
Category: Correctness · Severity: Info · CodeFix: yes
A writable ref obtained from a StaticEcs ref-returning member is used only for reading. Two shapes are detected:
- Ref-local binding —
ref var x = ref entity.Ref<T>()(or.Mut<T>()/ a Multi/Resource/Iterator ref-member) whose body never writes throughx, never passes it byref/out, never takes a writable ref-alias from it, and never invokes a non-readonly instance method on it. - Inline read —
entity.Ref<T>().Field/world.Resource<T>().Value.X/multi.First().Field/multi[i].Yetc., where the result flows into a read-only consumer (var initializer, field/property access,in/by-value argument, return from a non-ref method).
In both cases the writable ref is unused, and Mut<T> additionally marks the component as changed for nothing. The hint suggests the matching read-only sibling. Severity Info: silence via dotnet_diagnostic.FFSECS0013.severity = none in .editorconfig.
Read-only siblings:
| Writable member | Read-only sibling |
|---|---|
Entity.Ref<T>() / Entity.Mut<T>() | Entity.Read<T>() |
Components<T>.Ref(Entity) / Mut(Entity) | Components<T>.Read(Entity) |
Resource<T>.Value, NamedResource<T>.Value | ValueRO |
Multi<T>.First() | GetFirst() |
Multi<T>.Last() | GetLast() |
Multi<T>[int] | Multi<T>.Get(int) |
MultiComponentsIterator<T>.Current | CurrentRO |
Entity.Add<T>() / Components<T>.Add(...) have no read-only sibling and never trigger the rule.
"Mutation" is defined conservatively: direct writes, compound assignments, ++/--, pass-by-ref/out, taking a writable ref alias (ref var alias = ref local.Field), and instance-method calls on a non-readonly struct all count. By-value reads, in-passing, and ref readonly alias creation are not mutations.
Pattern (will fire)
// Ref-local binding form.
ref var d = ref entity.Ref<Attack>(); // FFSECS0013 — only reads below
Console.WriteLine(d.Delay);
Process(d.AttackerId); // by-value pass — not a mutation
// Inline form — outer is a reference type.
var transform = entity.Ref<GameObjectRef>().Val.transform; // FFSECS0013 → Read<GameObjectRef>()
// Inline form — outer is a primitive.
var x = entity.Ref<Position>().X; // FFSECS0013
// Inline form — chain through a non-ref property.
var y = entity.Ref<Big>().SomeProperty.Field; // FFSECS0013
// Multi / Resource inline forms.
var first = multi.First().Field; // FFSECS0013 → GetFirst()
var cfgN = world.Resource<Cfg>().Value.SomeInt; // FFSECS0013 → ValueRO
Fix (no diagnostic)
// Ref-local: bind by 'ref readonly' (default).
ref readonly var d = ref entity.Read<Attack>();
// For payload ≤ 8 bytes — also offered as a copy snapshot.
var d = entity.Read<Attack>();
// Inline.
var transform = entity.Read<GameObjectRef>().Val.transform;
var first = multi.GetFirst().Field;
var cfgN = world.Resource<Cfg>().ValueRO.SomeInt;
Will NOT fire
// Direct write through the ref-local.
ref var d = ref entity.Ref<Attack>();
d.Delay = 0;
// Direct write through inline ref.
entity.Ref<Position>().X = 5;
// Writable ref-alias on a field.
ref var t = ref entity.Ref<Transform>();
ref var x = ref t.Position.X;
x = 1f;
// Non-readonly method call on a non-readonly struct — conservatively counted as mutation.
ref var s = ref entity.Ref<StateMachine>();
s.Advance();
entity.Ref<StateMachine>().Advance(); // also silent — inline form, same rule
// Ref/out argument.
Method(ref entity.Ref<Position>().X);
// Add has no read-only sibling and is never flagged.
entity.Add<Tag>().Value = 1;
FFSECS0020
Category: Correctness · Severity: Error · CodeFix: yes
A class implementing any StaticEcs marker interface (IComponent, ITag, IEvent, ILinkType, ILinksType, IEntityType, IWorldType) breaks generic dispatch — every public API in StaticEcs has a where T : struct constraint, and reflection-based RegisterAll would skip class types.
Pattern (will fire)
public class Health : IComponent { public int Value; } // FFSECS0020
Fix
public struct Health : IComponent { public int Value; } // ok
FFSECS0021
Category: Correctness · Severity: Error · CodeFix: yes
Same as FFSECS0020 but specifically for IMultiComponent.
FFSECS0022
Category: Correctness · Severity: Warning · CodeFix: —
A struct implementing IMultiComponent that is not unmanaged (contains managed fields like string, arrays, delegates, …) must override both Write(ref BinaryPackWriter) and Read(ref BinaryPackReader). The interface's default implementations are no-ops — without overrides, snapshots silently produce empty data for the managed payload.
Pattern (will fire)
public struct Inventory : IMultiComponent { public string Owner; } // FFSECS0022 — no overrides
Fix
public struct Inventory : IMultiComponent {
public string Owner;
public void Write(ref BinaryPackWriter w) { w.WriteString(Owner); }
public void Read(ref BinaryPackReader r) { Owner = r.ReadString(); }
}
Unmanaged structs (int/float/Nullable<int> etc.) are bulk-copied by the storage and don't need overrides.
FFSECS0030
Category: Correctness · Severity: Info · CodeFix: yes
A ref T parameter of a Query.For lambda that is never written marks the component as changed at runtime whenever change-tracking is enabled — even though the body only reads it. Use the in T overload to signal read-only intent and skip the change mark.
Pattern (will fire)
W.Query().For((ref Health h) => { Console.WriteLine(h.Value); }); // FFSECS0030
Fix
W.Query().For((in Health h) => { Console.WriteLine(h.Value); });
FFSECS0031
Category: Performance · Severity: Error · CodeFix: —
A lambda passed to Query.For (or any fluent builder's .For(...)) that captures outer state (this, a method-local variable, an instance field/property/method) allocates a closure every time the query runs. Use one of the alternatives:
staticlambda + theuserDataoverload (For<TData>(userData, static (ref TData d, …) => …)).- A
structimplementingW.IQuery.Write<…>/W.IQuery.Read<…>. foreach (var entity in W.Query<…>().Entities())+ref varlocals.
Pattern (will fire)
var multiplier = 2;
W.Query().For((ref Health h) => { h.Value *= multiplier; }); // FFSECS0031
Fix
var multiplier = 2;
W.Query().For(multiplier, static (ref int m, ref Health h) => { h.Value *= m; });
Method-group references to non-static instance methods are also flagged (they capture this).
FFSECS0032
Category: Usage · Severity: Info · CodeFix: yes
Entity.IsMatch<TFilter>() works for any IQueryFilter, but for simple shapes (All<…>, Any<…>, None<…>, their *WithDisabled/*OnlyDisabled siblings, EntityIs<…>, EntityIsAny<…>, EntityIsNot<…>) Entity has a shorter, intent-revealing direct method:
| Filter | Equivalent |
|---|---|
All<T..> (arity 1-3) | HasEnabled<T..>() |
AllWithDisabled<T..> | Has<T..>() |
AllOnlyDisabled<T..> | HasDisabled<T..>() |
Any<T..> (arity 2-3) | HasEnabledAny<T..>() |
AnyWithDisabled<T..> | HasAny<T..>() |
AnyOnlyDisabled<T..> | HasDisabledAny<T..>() |
None<T..> | !HasEnabled<…> / !HasEnabledAny<…> |
NoneWithDisabled<T..> | !Has<…> / !HasAny<…> |
EntityIs<T> | Is<T>() |
EntityIsAny<T..> | IsAny<T..>() |
EntityIsNot<T..> | IsNot<T..>() |
Pattern (will fire)
if (entity.IsMatch<All<Health, Mana>>()) { … } // FFSECS0032
if (entity.IsMatch<None<Stunned>>()) { … } // FFSECS0032
Fix
if (entity.HasEnabled<Health, Mana>()) { … }
if (!entity.HasEnabled<Stunned>()) { … }
Constraint check: HasEnabled/HasEnabledAny/HasDisabled/HasDisabledAny all require T : struct, IComponent, IDisableable. For All<…>, Any<…>, None<…> (which accept IComponentOrTag — tags allowed) the diagnostic only fires when every type argument is both IComponent and IDisableable; otherwise the naive replacement would not compile and is silently skipped. *OnlyDisabled filters already carry the same constraints, so the check is automatic for them.
Composite filters (And<…>, Or<…>, Nothing) and arity > 3 are not suggested — IsMatch is the only practical entry point there.
FFSECS0033
Category: Usage · Severity: Info · CodeFix: yes
The pattern foreach (var entity in W.Query<…>().Entities()) { ref var x = ref entity.Ref<T>(); … } has a more compact, intent-revealing form via W.Query<…>().For((ref T x, …) => { … }) — the components touched through entity.Ref<T>()/Mut<T>()/Read<T>() move into the lambda parameter list and are simultaneously removed from any All<…> they were listed in, since For adds them back to the filter implicitly.
How the codefix rewrites the body:
- Components reached via
entity.Ref<T>()/Mut<T>()for aTlisted in someAll<…>becomeref Tlambda parameters;entity.Read<T>()becomesin T. The matchingref var X = ref entity.Ref<T>();declarations are deleted. - Each absorbed
Tis removed from itsAll<…>. EmptyAll<…>nodes collapse out of any enclosingAnd<…>; ifAnd<…>ends up with a single argument it is unwrapped; the top-level filter disappears entirely when nothing remains. - All other filters (
None<…>,Any<…>,EntityIs<…>, …) andAll<…>components that the body does not touch are preserved verbatim. - If the body uses
entityfor anything other than absorbing components fromAll<…>(e.g.entity.Has<Tag>(),entity.Destroy(), orentity.Ref<U>()whereUis not inAll<…>), the lambda gets anEntity entityparameter and those calls stay in place. - If the body references one outer local/parameter, the codefix uses the
For<TData>(ref data, static (ref TData data, …) => …)overload and the lambda becomesstatic— no closure allocation.
Skipped (the diagnostic does NOT fire) when any of the following hold:
- The body contains
break,continue,return,yield,goto,throw,await, a nested anonymous function, or a nested local function — these cannot be preserved one-to-one inside a lambda body. - The body captures
this, an instance field, or two or more distinct outer locals/parameters — the rewrite would need a multi-field UserData struct, which the codefix does not synthesize. - The total absorbed component count exceeds 6 —
Foroverloads only go up toT0..T5. - No
entity.Ref/Mut/Read<T>()call inside the body touches aTlisted inAll<…>— there is nothing to absorb and the rewrite would be pure churn. - The filter shape uses constructs the codefix cannot safely modify (e.g.
Or<…>at the top level) — onlyAll<…>/And<…>/None<…>/Any<…>compositions are supported in V1.
Pattern (will fire)
foreach (var entity in W.Query<All<NeedsData>>().Entities()) {
ref var needs = ref entity.Ref<NeedsData>();
needs.Hunger++;
needs.Thirst++;
needs.Tired++;
}
Fix
W.Query().For((ref NeedsData needs) => {
needs.Hunger++;
needs.Thirst++;
needs.Tired++;
});
FFSECS0040
Category: Correctness · Severity: Error · CodeFix: —
ref/in references to a component become stale after the underlying entity is invalidated. Three patterns are tracked:
- Lambda in
WorldQuery.For— the references are the lambda'sref/incomponent parameters. structimplementingIQuery.*— the references are theref/inparameters of theInvokemethod.ref-locals fromentity.Ref/Mut/Read/Add(...).
Invalidators: Destroy, MoveTo, Unload (full kill), Delete<T> (only references to a component of type T).
Pattern (will fire)
W.Query().For((W.Entity e, ref Health hp) => {
e.Destroy();
hp.Value = 0; // FFSECS0040 — hp points into freed storage
});
Fix
W.Query().For((W.Entity e, ref Health hp) => {
var snap = hp.Value; // copy first
e.Destroy();
Use(snap); // ok
});
FFSECS0041
Category: Correctness · Severity: Error · CodeFix: —
Counterpart to FFSECS0040 but tracks the entity variable itself, not the ref/in references to its components. After Destroy/MoveTo/Unload on a local or parameter, any further operation on that variable (Has, Add, IsActual, …) is flagged. The only allowed operations on the tainted variable are:
- Direct reassignment (
entity = W.NewEntity<…>();). - Out-parameter rebind (
Method(out entity);orMethod(out var entity)inside a loop).
Pattern (will fire)
var e = W.NewEntity<Default>();
e.Destroy();
_ = e.Has<Health>(); // FFSECS0041
Fix
var e = W.NewEntity<Default>();
e.Destroy();
e = W.NewEntity<Default>(); // reassignment kills the taint
_ = e.Has<Health>(); // ok
The merge across conditional branches is conservative — if any predecessor path leaves the variable invalidated, the merge point is tainted.
FFSECS0042
Category: Correctness · Severity: Warning · CodeFix: yes
Entity.Ref<T>(), Entity.Mut<T>(), Entity.Read<T>() require T to be present on the entity — otherwise debug builds assert and release builds return data of an unrelated component. The analyzer runs a forward dataflow over the method/lambda CFG and emits a warning at every call site where the receiver entity is not statically proven to carry T on every incoming path.
A guarantee for T on entity is established by:
- The true branch of a previous
entity.Has<T...>(),HasEnabled<T...>(),HasDisabled<T...>()(any arity — each generic argument is added). - The true branch of a previous
entity.IsMatch<F>()whereFreduces (through nestedAnd<…>) toAll<T>/AllOnlyDisabled<T>/AllWithDisabled<T>.None/Any/EntityIs*filters add nothing. - The body of a
Query<TFilter>().For(...)lambda — every component listed inTFilter'sAll*is guaranteed for the lambda'sEntityparameter, plus everyref T/in Tcomponent parameter declared in the lambda signature. - The body of an
IQuery<...>.Invokemethod — everyref T/in Tcomponent parameter is guaranteed for theEntityparameter.TFilteris not visible at this layer, so only signature-derived components count. - A previous
entity.Add<T>(...),Set<T>(...),Ref<T>(),Mut<T>(),Read<T>()on the same local/parameter, without an intervening invalidator.
Invalidators clear guarantees:
entity.Delete<T>()removes onlyT.entity.Destroy()/MoveTo(…)/Unload(…)remove every guarantee on that entity.- Re-assigning the entity variable (
entity = …;) or passing it as aref/outargument clears every guarantee on the local/parameter.
The analyser only tracks entities that resolve to a single ILocalSymbol or IParameterSymbol. Chained, property/field, or default(Entity) receivers cannot have a Has guard attach to them, and are reported unconditionally.
Pattern (will fire)
ref var pos = ref entity.Ref<Position>(); // FFSECS0042 — no proof Position is present
W.Query<None<Stunned>>().For((W.Entity e) => { e.Ref<Position>(); }); // FFSECS0042 — filter does not contain All<Position>
if (entity.Has<Velocity>()) { entity.Ref<Position>(); } // FFSECS0042 — guard is for Velocity, not Position
entity.Delete<Position>();
ref var lost = ref entity.Ref<Position>(); // FFSECS0042 — guarantee was cleared by Delete<Position>
Fix
if (entity.Has<Position>()) {
ref var pos = ref entity.Ref<Position>(); // ok — true-branch guarantees Position
}
if (!entity.Has<Position>()) return;
ref var pos2 = ref entity.Ref<Position>(); // ok — guarded by early-return
entity.Add<Position>();
ref var pos3 = ref entity.Ref<Position>(); // ok — Add establishes the guarantee
W.Query<All<Position, Velocity>>()
.For((W.Entity e, ref Position p) => { ref var velocity = ref e.Ref<Velocity>(); }); // ok — both via All<…>
Per-call escape hatch: postfix !
ref var pos = ref entity.Ref<Position>()!; // ok — `!` suppresses FFSECS0042 here
entity.Mut<Position>()!.X = 5; // ok — works for Mut/Read too
The postfix null-forgiving operator (!) after a Ref/Mut/Read invocation silences the diagnostic for that single call. C# preserves the value/ref category through !, so the suppressed call still returns by reference and can be bound with ref var. The ! token is accepted regardless of project-level nullable settings. After the suppressed call the dataflow records the guarantee for T, so subsequent uses on the same entity for the same component don't need the marker repeated. Suppression on the receiver (entity!.Ref<T>()) is not recognised — the marker must annotate the specific component access. The bundled CodeFix offers a one-click "Suppress FFSECS0042 with '!' after the call".
Limitations
- Boolean guards through intermediate locals (
var ok = entity.Has<Position>(); if (ok) …) are not propagated. - Components reached via
Components<T>.Ref/Mut/Read(entity)overloads are not the rule's check points in V1; theentity.X<T>()instance form is. - Cross-method analysis is out of scope: a helper
void Use(W.Entity e) => e.Ref<T>();will warn unless guarded insideUse.
FFSECS0050
Category: Correctness · Severity: Error · CodeFix: —
A component is referenced more than once inside the same query — either as a duplicate inside same-kind filters (All+All, None+None, Any+Any, including their *WithDisabled/*OnlyDisabled variants), or as an overlap between the filter chain and a lambda ref/in parameter, or with an IQuery struct's component generic.
Pattern (will fire)
foreach (var _ in W.Query<All<Health>, All<Health>>().Entities()) { } // FFSECS0050
W.Query<All<Health>>().For((W.Entity e, in Health hp) => { }); // FFSECS0050 — filter ↔ lambda
W.Query<All<Health>>().Write<Health>().For<MyWriteFn>(); // FFSECS0050 — filter ↔ IQuery generic
foreach (var _ in W.Query<All<Health>, AllOnlyDisabled<Health>>().Entities()) { } // FFSECS0050 — base + disabled variant
FFSECS0051
Category: Correctness · Severity: Error · CodeFix: —
The query has the same component in both an All<…> and a None<…> — the resulting set is always empty. The implicit All contribution of lambda parameters and IQuery struct generics also counts.
Pattern (will fire)
foreach (var _ in W.Query<All<Health>, None<Health>>().Entities()) { } // FFSECS0051
W.Query<None<Health>>().For((W.Entity e, in Health hp) => { }); // FFSECS0051 — lambda implies All
Suppressing diagnostics
Per-line / per-block:
#pragma warning disable FFSECS0011
var snap = entity.Read<Health>();
#pragma warning restore FFSECS0011
Per-project (.editorconfig):
[*.cs]
dotnet_diagnostic.FFSECS0011.severity = none
Per-build (csproj):
<NoWarn>FFSECS0011</NoWarn>
Source code
All analyzers live under StaticEcs/Analyzers~/Src/Analyzers/*.cs; code fixes under StaticEcs/Analyzers~/CodeFixes/. Rule IDs are centralised in StaticEcs/Analyzers~/Shared/FFSECSIds.cs.