Unilyze Metric Definitions
Definitions, compliance specifications, and known differences for each metric computed by Unilyze.
Cognitive Complexity (CogCC)
Compliance specification: SonarSource Cognitive Complexity Whitepaper
Rules
| Category | Target | Increment |
|---|---|---|
| Structural | if, else if, else, switch, for, foreach, while, do, catch |
+1 + nesting |
| Fundamental | goto, direct recursion |
+1 |
| Logical operators | &&, ||, or, and |
+1 (only when the operator kind changes; consecutive same-kind operators stay at +1. or is same-kind as ||, and as &&) |
| Nesting increase | lambda, anonymous method | nesting +1 (no structural increment) |
| Shorthand | ??, ?. |
0 (no increment) |
Differences from SonarAnalyzer.CSharp (S3776)
Cross-validation results against SonarAnalyzer.CSharp 10.20.0 (70 methods from Unilyze's own source):
| Metric | Value |
|---|---|
| Spearman rank correlation | 1.000 |
| Exact match rate | 100.0% (70/70) |
| Within ±1 rate | 100.0% (70/70) |
| Syntax | SonarAnalyzer | Unilyze | Notes |
|---|---|---|---|
or pattern combinator |
+1 | +1 | Supported (treated same-kind as ||) |
and pattern combinator |
+1 | +1 | Supported (treated same-kind as &&) |
| Direct recursion | +1 | +1 | Supported (method-name-based detection) |
| static local function | Independent calculation | Included in method | Specification difference |
?? (null coalesce) |
0 | 0 | Match (fixed in v0.2.0) |
switch expression |
+1 + nesting | +1 + nesting | Match |
Cyclomatic Complexity (CycCC)
Compliance specification: McCabe, T.J. (1976) "A Complexity Measure"
Each predicate node (branch point) is counted as +1. Base paths are 1.
Count targets
| Node | Increment |
|---|---|
if |
+1 |
case label / case pattern |
+1 |
for, foreach (including deconstruction foreach), while, do |
+1 |
catch |
+1 |
? : (ternary operator) |
+1 |
?. (null conditional) |
+1 |
?? (null coalescing) |
+1 |
&&, || |
+1 each |
&, | on bool operands |
+1 each (semantic-model analysis levels only; not counted under SyntaxOnly where types cannot be resolved) |
goto |
+1 |
switch expression arm |
+1 |
??=, the switch expression itself, catch when filters, and and / or patterns are not counted.
Convention differences from the official Roslyn engine (CodeAnalysisMetricData / Metrics.exe / CA1502)
Cross-validation of all 339 methods in src/Unilyze against the official engine established the following convention-difference table (zero residual for 97/100 types; remaining 3 types decomposed to ±1) (reproduction steps: scripts/crossval; validation data in the Validation section):
| Syntax | Official engine | Unilyze |
|---|---|---|
if / ? : / loops / case label·pattern / ?. / ?? / && / \|\| |
+1 | +1 |
default label |
+1 | not counted |
catch |
not counted | +1 |
switch expression arm |
not counted | +1 |
goto |
not counted | +1 |
??= |
not counted | not counted |
bool & / \| |
+1 | +1 only with semantic model |
| Type aggregation | base 1 per member symbol (implicit ctor, accessor, operator included) | base 1 per declared method only |
Notes:
- This document previously stated "the official engine does not count
?.??"; that was incorrect (disproved by implementation cross-validation). Both engines count them - Do not apply CA1502's default threshold of 25 directly to unilyze CycCC. Switch expression arms and similar additions make unilyze values systematically higher; no approximate conversion formula is provided (re-analysis with the official engine is required for accurate comparison)
- Design decision: unilyze intentionally maintains the extended interpretation (counting catch / arm / goto as real branches).
catchand switch arms are faithful to McCabe's branch-point definition and preserve compatibility with existing baselines (refactor loop, trends, badges). Use CA1502 / Metrics.exe directly when official-compatible values are needed. For modern complexity gates, CogCC (100% aligned with SonarAnalyzer S3776) is recommended
LCOM-HS (Henderson-Sellers)
Compliance specification: Henderson-Sellers, B. (1996) "Object-Oriented Metrics: Measures of Complexity"
Formula
LCOM-HS = (avg(mA) - M) / (1 - M)
mA(f) = number of methods that access field f
avg(mA) = average of mA across all fields
M = number of instance methods (constructors included)
Interpretation
| Value | Meaning |
|---|---|
| 0.0 | Perfect cohesion (all methods access all fields) |
| 1.0 | Perfect separation (each method accesses only a distinct field) |
| null | Not computable (0 fields, or 0–1 methods) |
Differences from NDepend / CK
| Item | NDepend (latest) | CK | Unilyze |
|---|---|---|---|
| auto-property | Excluded from F | Included in F | Excluded from F (fixed in v0.2.0) |
| Constructor | Included in M | Included in M | Included in M (fixed in v0.2.0) |
| static members | Excluded | Excluded | Excluded |
WMC (Weighted Methods per Class)
Compliance specification: Chidamber, S.R. & Kemerer, C.F. (1994) "A Metrics Suite for Object Oriented Design"
Formula
WMC = Σ CycCC(method_i) for all methods in class
Sum of Cyclomatic Complexity for all methods in the class. Weighting uses CycCC.
Interpretation
| Value | Meaning |
|---|---|
| 0 | No methods (data class, enum, etc.) |
| 1-20 | Typical range |
| > 20 | Refactoring candidate |
NOC (Number of Children)
Compliance specification: Chidamber & Kemerer (1994)
Count of direct subclasses. Derived by reverse lookup from DependencyBuilder Inheritance dependencies.
Interpretation
| Value | Meaning |
|---|---|
| 0 | Not inherited |
| High | Reusable base class; large blast radius on change |
RFC (Response For a Class)
Formula
RFC = M + R
M = number of methods in the class (constructors included)
R = number of unique external methods invoked from within M
Semantic / Syntactic paths
| Path | Resolution method |
|---|---|
| Semantic | Resolve InvocationExpression symbols via SemanticModel. Accurate |
| Syntactic (fallback) | Approximate by InvocationExpression method name string. Cannot distinguish overloads |
Interpretation
| Value | Meaning |
|---|---|
| <= 50 | Typical range |
| > 50 | Tends to be difficult to test and understand |
CBO (Coupling Between Objects)
Compliance specification: Chidamber & Kemerer (1994)
Formula
CBO = number of unique external types coupled to type T
Count of types referenced from T's declaration, members, and method bodies, excluding self and excluded types.
Counting conventions
Implementation: CboCalculator.cs
| Path | Resolution method |
|---|---|
| Semantic | Walk descendants of the type declaration for TypeSyntax / ObjectCreationExpression / CastExpression, resolve ITypeSymbol via SemanticModel, add INamedTypeSymbol.OriginalDefinition to the set (generic type arguments and array element types collected recursively) |
| Syntactic (fallback) | Collect type name strings from base list, field/property types, method/constructor signatures and bodies (local variable declarations, new, cast, typeof) |
Common exclusions:
- Self type
- Semantic: built-in types where
SpecialTypeis notNone,System.ValueType/System.Enum/System.Delegate/System.MulticastDelegate/System.Attribute/System.Void - Syntactic: C# primitive names (
int,string,object, etc.)
CBO is computed directly from the type-declaration AST, independent of the TypeDependency graph. DI registration edges are not included in CBO.
Thresholds (code smell)
| Level | Condition |
|---|---|
Warning (HighCoupling) |
CBO >= 15 |
Critical (HighCoupling) |
CBO >= 25 |
Constants: SmellThresholds.HighCouplingCboWarning / HighCouplingCboCritical
Notes
- Under
SyntaxOnlyanalysis without SemanticModel, CBO is underestimated (coupling to external engine types is invisible) - Differs from the official Metrics engine ClassCoupling in counting scope and granularity (see Validation)
DIT (Depth of Inheritance)
Compliance specification: Chidamber & Kemerer (1994)
Formula
DIT = length of inheritance chain from type T up to (but not including) `System.Object`
interface / struct → 0. class / record counts direct and indirect base classes.
Counting conventions
Implementation: DitCalculator.cs
| Path | Convention |
|---|---|
| Semantic | interface → 0. struct → 0. Otherwise walk INamedTypeSymbol.BaseType until System.Object and count steps (System.Object itself is not counted) |
| Syntactic (fallback) | interface / struct / record struct → 0. No base list → 0. First base is QualifiedNameSyntax (external type) → 1. Same-name interface declaration in the same syntax tree → 0; otherwise → 1 |
When semantic calculation fails, SemanticEnricher falls back to syntactic fallback or TypeNodeInfo.BaseType presence (0/1).
Thresholds (code smell)
| Level | Condition |
|---|---|
Warning (DeepInheritance) |
DIT >= 5 |
Constant: SmellThresholds.DeepInheritanceDitWarning
Notes
- The official Metrics engine counts
objectinheritance as 1, producing a uniform offset (see Validation) - Inheritance spanning engine types (
UnityEngine.MonoBehaviour, etc.) requires Semantic analysis;SyntaxOnlyunderestimates
Ca / Ce (Afferent / Efferent Coupling)
Type-level coupling based on Martin's stability analysis.
Formula
Ca(T) = number of unique directed edges with T as dependency target (To)
Ce(T) = number of unique directed edges with T as dependency source (From)
Input is the TypeDependency list from DependencyBuilder.Build (inheritance, interface implementation, member types, constructor/method parameters, generic constraints), plus resolved DI registration edges (VContainer / Zenject) and SerializedReference edges resolved from Unity scene/prefab/.asset YAML ([SerializeField] or public fields matched against Inspector wiring).
Counting conventions
Implementation: CouplingMetricsCalculator.cs
- Count only
FromTypeId/ToTypeIdpresent in the analysis target type set (allTypes) - Exclude self-reference (
From == To) - Each
(From, To)pair counted once (no duplication across multipleDependencyKindvalues) - Exclude edges where
FromTypeIdorToTypeIdis null (unresolved edges to types outside the analysis scope)
No threshold-based code-smell detection for Ca / Ce.
Notes
- Ca / Ce are edge counts on the dependency graph; definition differs from CBO (type-reference set from type-declaration AST)
- DI registrations to types outside the analysis scope are not connected as edges and do not contribute to Ca / Ce
SerializedReferencerepresents concrete-type Inspector wiring. It is distinct fromFieldTypeedges on the declared field type (e.g. base class) and affects Ca/Ce/Instability/TypeRank/cycles/DfMS but not declaration-based CBO
Instability (I)
Martin's Instability. Computed at type level and assembly level with different granularity.
Formula (type level)
I(T) = Ce(T) / (Ca(T) + Ce(T)) when Ca + Ce > 0
I(T) = null when Ca + Ce = 0
Implementation: CouplingMetricsCalculator.cs (per type). JSON output rounds to 2 decimal places.
Formula (assembly level)
I(assembly) = Σ Ce / (Σ Ca + Σ Ce)
Sum Ca / Ce across all types in the assembly. Implementation: AssemblyMetrics.ComputeAssemblyInstability. Distance from Main Sequence uses this assembly-level I.
Interpretation
| Value | Meaning |
|---|---|
| 0.0 | Fully stable (depended on by other types only) |
| 1.0 | Fully unstable (depends on other types only) |
| null (type only) | Zero inbound and outbound coupling |
No code-smell thresholds for Ca / Ce / Instability.
Halstead Complexity Measures
Compliance specification: Halstead, M.H. (1977) "Elements of Software Science"
Base measures
| Symbol | Meaning |
|---|---|
| n1 (UniqueOperators) | Number of unique operators |
| n2 (UniqueOperands) | Number of unique operands |
| N1 (TotalOperators) | Total operator count |
| N2 (TotalOperands) | Total operand count |
Derived metrics
| Metric | Formula | Description |
|---|---|---|
| Volume (V) | (N1 + N2) * log2(n1 + n2) |
Implementation size |
| Difficulty (D) | (n1 / 2) * (N2 / n2) |
Difficulty of understanding. 0 when n2=0 |
| Effort (E) | D * V |
Mental effort required to implement |
| EstimatedBugs (B) | E^(2/3) / 3000 |
Estimated bug count |
Maintainability Index (MI)
Compliance specification: Oman & Hagemeister (1992) — normalized MI in the Visual Studio / Microsoft Code Metrics family
Formula (method level)
loc = max(1, line span of method declaration)
V = Halstead Volume (HalsteadCalculator.cs)
raw = 171 - 5.2 × ln(V) - 0.23 × CycCC - 16.2 × ln(loc)
MI = max(0, raw × 100 / 171) when V > 0
MI = 100 when V <= 0
ln is the natural logarithm (Math.Log). CycCC is unilyze Cyclomatic Complexity (McCabe extended interpretation). MI is computed with CycCC from the initial syntactic parse; MI itself is not recomputed when Semantic enrich updates CycCC.
Type-level aggregation
Implementation: CodeHealthCalculator.cs
AverageMaintainabilityIndex = arithmetic mean of method MI in the type (1 decimal place)
MinMaintainabilityIndex = minimum method MI in the type (1 decimal place)
Types without methods are not MI targets. Project average (statusline / badge) uses only types with methods as the denominator.
Thresholds (code smell)
| Level | Condition |
|---|---|
Warning (LowMaintainability) |
method MI < 60 |
Constant: SmellThresholds.LowMaintainabilityMiWarning
badge / statusline (--show-mi) color bands (reference): green >= 80, yellow >= 60, red < 60 (BadgeFormatter.cs / StatuslineFormatter.cs)
Notes
- Line count spans the full declaration including the signature, not just the method body (
MemberExtractor.cs) - The official Metrics engine aggregates at type level; convention difference from method average yields high correlation but not exact match (see Validation)
- Approximately stable under SyntaxOnly, same as CodeHealth
Validity limits
MI relies on fixed coefficients (171, 5.2, 0.23, 16.2) from a 1992 regression on Visual Basic code and has limited applicability to modern C# / Unity codebases.
- Arie van Deursen "Think Twice Before Using the Maintainability Index" (https://avandeursen.com/2014/08/29/think-twice-before-using-the-maintainability-index/)
- Borg et al. "Ghost Echoes Revealed: Benchmarking Maintainability Metrics and Machine Learning Predictions Against Human Assessments" (ICSME 2024, arXiv:2408.10754)
Recent evaluations, including the latter, show that classical metrics including MI correlate weakly with human maintainability assessments. unilyze uses CodeHealth as the single default surface metric and treats MI as a reference value. MI computation and JSON output are retained for existing consumers and compatibility verification against the official Metrics engine.
TypeRank
PageRank-based type importance score equivalent to NDepend TypeRank.
Resolved DI registration edges (VContainer / Zenject) are included in the TypeDependency graph and counted in CBO (Ca/Ce), cycle detection, and TypeRank. Unresolved edges to types outside the analysis scope are excluded.
Algorithm
- Input: DependencyBuilder TypeDependency list → adjacency list
- damping factor: 0.85
- convergence threshold: 1e-6 (L1 norm)
- max iterations: 100
- Dangling nodes (out-degree 0) distribute rank equally to all nodes
- Result normalized (sum = 1.0)
Interpretation
Higher values indicate types depended on by more other types. Value objects and infrastructure types tend to rank high.
Abstractness (A)
Compliance specification: Martin, R.C. "Agile Software Development" (Stable Abstractions Principle)
Formula
A = (abstract class count + interface count) / total type count
Computed at assembly granularity. 0.0 = all concrete, 1.0 = all abstract.
Distance from Main Sequence (DfMS)
Formula
D = |A + I - 1|
A = Abstractness
I = Instability (assembly granularity: sum of Ce / (sum of Ca + sum of Ce))
Distance from the Main Sequence line (A + I = 1). 0.0 is ideal.
| Position | Meaning |
|---|---|
| D ≈ 0 | Good balance of stability and abstractness |
| A=0, I=0 (D=1) | Stable and concrete → Zone of Pain (hard to change) |
| A=1, I=1 (D=1) | Unstable and abstract → Zone of Uselessness |
Relational Cohesion (H)
Compliance specification: NDepend - Relational Cohesion
Formula
H = (R + 1) / N
R = number of inter-type dependency edges in the assembly (deduplicated, self-reference excluded)
N = number of types in the assembly
null when N <= 1. Higher values indicate tighter collaboration among types in the assembly. Recommended range: 1.5–4.0.
DOTS / ECS
unilyze does not duplicate Roslyn analyzers / source generators inside the com.unity.entities package (SGJE diagnostics: SystemAPI misuse, invalid Entities.ForEach chains, etc.).
Those fail the Editor build and therefore target problems that never reach CI. unilyze detects only what remains after compilation:
| Rule | Target | Intent |
|---|---|---|
| UNI024 MissingBurstCompile | ISystem / IJobEntity / IJobChunk struct |
ECS types eligible for Burst without [BurstCompile] |
| UNI025 ManagedReferenceInComponentData | struct IComponentData |
Component struct with reference-type fields (class IComponentData excluded as intentional managed) |
SystemBase-derived classes are not Burst targets and are not reported by UNI024.
Burst coverage (burstCoverage)
Assembly-level Burst adoption rate. JSON .assemblies[].metrics.burstCoverage.
eligible = count of ISystem struct + IJobEntity/IJobChunk struct
covered = eligible types with [BurstCompile] on the type or all lifecycle methods
burstCoverage = covered / eligible
burstCoverage is null when eligible is 0 (same nullable-when-undefined pattern as RelationalCohesion).
ecsTypeCount is the total of ECS-classified types (EcsSystem / EcsJob / EcsComponentData). null when the assembly has no ECS types.
Analysis-level dependency
| Level | ECS classification | UNI024/UNI025 |
|---|---|---|
Complete (Unity.Entities resolvable via Library/ScriptAssemblies) |
Validates Unity.Entities namespace |
Semantic type detection (reference-type fields via IsReferenceType) |
| SyntaxOnly | Interface name match on base list | Non-Unity ISystem etc. with the same name may false-positive. UNI025 uses a conservative list (string/object/array/BCL collections, etc.) |
Code Health
Proprietary metric. Type-level score (1.0 - 10.0).
v2 definition
CodeHealth v2 uses a non-compensatory piecewise penalty scheme in which favorable factors cannot offset severely degraded factors. Each input is converted to a non-negative penalty, and the final value is calculated using the following formula.
complexity = max(maxCogCCPenalty, maxNestingPenalty)
size = max(lineCountPenalty, methodCountPenalty)
interface = excessiveParameterMethodCountPenalty
codeHealth = clamp(10 - complexity - size - interface, 1, 10)
Taking the max within each axis avoids double-counting due to collinearity observed in Phase 6.
avgCogCC had Spearman ρ = 0.98 with maxCogCC, nesting had ρ = 0.94-0.96 with CogCC, and lineCount had ρ = 0.75 with methodCount.
Each penalty is 0 at or below the safe upper bound, increases to 25% of its maximum from safe to warning, and increases to its maximum from warning to alert. It saturates at alert and above.
| Axis | Input | safe | warning | alert | Maximum penalty |
|---|---|---|---|---|---|
| complexity | maxCogCC | 16 | 19 | 24 | 4.0 |
| complexity | maxNestingDepth | 3 | 4 | 5 | 4.0 |
| size | lineCount | 272 | 391 | 878 | 3.0 |
| size | methodCount | 11 | 16 | 34 | 3.0 |
| interface | excessiveParameterMethodCount | 0 | 1 | 2 | 2.0 |
The thresholds were calibrated from 1,456 types across five Phase 6 projects using the same Alves method as ThresholdCalibrator, with LoC weighting and equal system weighting.
For size, P70/P80/P90 are used directly.
For complexity, residual P70/P80/P90 = 1.166/4.028/9.453 from maxCogCC = -18.908071 + 5.968062 * ln(LOC + 1) were projected onto the reference LOC 272, yielding 16/19/24.
Because directly subtracting the residual at runtime could improve the score as LOC increases, it is projected onto fixed thresholds that preserve monotonicity.
Integer metrics whose quantiles are equal are widened to the next integer to preserve the warning band.
v2 has the following invariants.
- The score is 10.0 when all inputs are in the safe band.
- The score is 1.0 when all axes are saturated.
- Increasing any input never increases the score.
- If any one axis is saturated, the score is below 9.0 even when the others are safe.
Categories
| category | Range |
|---|---|
healthy |
9.0 or higher |
warning |
4.0 or higher and below 9.0 |
alert |
below 4.0 |
Output in lowercase in JSON at typeMetrics[].codeHealthCategory.
highComplexityTypeCount equals the number of alert types.
Project aggregation
averageCodeHealth: simple mean across types.minCodeHealth: minimum value.badge --fail-undercontinues to evaluate this value.locWeightedAverageCodeHealth:sum(codeHealth * lineCount) / sum(lineCount).worstDecileCodeHealth: mean of the bottom 10% of scores. Includes at least one type.
badge and statusline display the simple mean, minimum, LoC-weighted mean, and bottom-decile mean. Colors follow the category boundaries of the LoC-weighted mean.
v1 migration compatibility
The v2 introduction release also outputs the old value in typeMetrics[].codeHealthV1.
--codehealth-v1 for badge, statusline, and diff switches display and gating to the v1 value.
This field and flag will be removed in the first minor release after the v2 introduction.
| Item | v1 | v2 |
|---|---|---|
| Composition | linear weighted sum of 6 factors | sum of saturating piecewise penalties |
| CogCC | avg 25% + max 20% | residual-calibrated thresholds for max; avg excluded |
| nesting | independent 15% | max within the complexity axis |
| size | LOC 15% + methods 10% | max within the size axis |
| interface | linear score of 15% | saturating penalty with a maximum of 2.0 |
| Severe-factor compensation | possible | not possible |
| Categories | none | healthy / warning / alert |
Code Smell
Detects known code smells with rule-based heuristics.
Smell detection is threshold-dependent heuristics, not ground truth.
Paiva, Damasceno, Figueiredo & Sant'Anna (2017) "On the evaluation of code smells and detection tools" (JSERD) report inter-tool agreement of 67–100%, recall 0–58%, precision 0–100%; threshold differences alone can split results.
Thresholds are listed in the table below (default values). Projects can override per smell kind via the smells section in .unilyze.json. Overrides affect runtime detection only; the table below remains the single source of truth for defaults.
For measured-value compatibility, see the Metric Compatibility Policy.
Inline suppression (unilyze-disable)
ESLint-style comments to silence a single occurrence. Can be combined with baseline (project freeze) or rules (rule-wide off); counted in root suppressedCount (same smell matching both counts once).
// unilyze-disable-next-line UNI014 -- intentional guard
catch { }
// unilyze-disable UNI002
void LongButJustified() { /* ... */ }
| Form | Scope |
|---|---|
unilyze-disable-next-line UNIxxx |
Next line after the comment (line-numbered detector smells such as UNI011–UNI025) |
unilyze-disable UNIxxx (leading trivia on type/method declaration) |
Within that declaration scope |
Omitting the rule ID suppresses all rules in scope. Unknown IDs and UNI009 log a stderr warning and are ignored (analysis exit code unchanged). Suppressed smells remain in JSON with "suppressed": true; SARIF uses suppressions: [{ "kind": "inSource" }]. Excluded from statusline / badge / diff --fail-on-regression.
Known constraints
- Same-name overload indistinguishability (metric smells): UNI001–UNI008, UNI010 match directives by method/type name, so all overloads with the same method name are suppressed. Detector smells (UNI011–UNI025) can be distinguished by line position.
- partial types: The suppression index is built from one declaration indexed by
SyntaxLookups.BuildTypeDeclLookup. Place type-scope directives on the indexed partial declaration (scanning all partials is a follow-up). - UNI009: Reported per dependency cycle; inline suppression not supported.
rulesonly.
Threshold profiles (profile)
Source: Aniche, Treude, Zaidman et al., "SATT: Tailoring Code Metric Thresholds for Different Software Architectures" (SCAM 2016) — metric distributions differ by architecture (role); a single threshold over-detects specific roles.
Select a built-in profile via "profile" in .unilyze.json or CLI --profile. CLI --profile overrides project config; project config overrides global config (same higher-scope-wins as baseline). Threshold numeric values: user overrides in the smells section take highest priority over profile base values (profile < user thresholds).
| Profile | Description |
|---|---|
default (when omitted) |
Global default thresholds (as before). profile is not emitted at JSON root. metricsVersion unchanged. |
unity |
Role-specific thresholds for Unity. JSON root records "profile": "unity". |
Type roles (role)
Each type has role in JSON types[] (camelCase enum):
role |
Detection |
|---|---|
MonoBehaviour |
Base-type chain includes UnityEngine.MonoBehaviour |
ScriptableObject |
Base-type chain includes UnityEngine.ScriptableObject |
EditorExtension |
Base is UnityEditor.Editor / EditorWindow, or [CustomEditor] attribute |
PlainCSharp |
None of the above |
SyntaxOnly limitation: Syntactic fallback matches only the direct base name in TypeNodeInfo.BaseType. Indirect derivation such as Player : BaseView (BaseView : MonoBehaviour) is not classified as MonoBehaviour without Semantic analysis.
Role-specific thresholds for unity profile (provisional)
Provisional values from literature (SATT SCAM 2016; Alves ICSM 2010). Final values planned from role-specific distributions via unilyze calibrate (#86).
| Role | GodClass (Warning) | Notes |
|---|---|---|
MonoBehaviour |
lines >= 800 or methods >= 30 | Relaxed to reduce over-detection from lifecycle method proliferation (Nardone et al. TOSEM 2023) |
ScriptableObject |
lines >= 650 or methods >= 25 | |
EditorExtension |
lines >= 700 or methods >= 25 | |
PlainCSharp |
Same as default table |
Other smells (LongMethod, HighCoupling, etc.) use the default table for all roles except PlainCSharp for now.
LowCohesion informational handling (unity profile)
Source: Palomba, Bavota, Di Penta, Oliveto, De Lucia, "Do They Really Smell Bad?" (ICSME 2014) — cohesion smells have low developer problem recognition.
Under the unity profile, LowCohesion (UNI006) is not emitted as a warning smell; when the threshold is met, it is added to typeMetrics[].informationalCount. Not included in badge --fail-over or diff --fail-on-regression warning counts. Under default, remains Warning as before.
unilyze metrics --profile unity displays the active profile and role-specific thresholds.
| Smell | Warning condition | Critical condition |
|---|---|---|
| GodClass | lines >= 500 or methods >= 20 | lines >= 1000 |
| LongMethod | lines >= 80 or CogCC >= 25 | lines >= 150 or CogCC >= 40 |
| ExcessiveParameters | parameter count > 5 | — |
| HighComplexity | CycCC >= 15 or CogCC >= 15 | — |
| DeepNesting | nesting depth >= 4 | nesting depth >= 6 |
| LowCohesion | LCOM >= 0.8 | — |
| HighCoupling | CBO >= 15 | CBO >= 25 |
| LowMaintainability | MI < 60 | — |
| DeepInheritance | DIT >= 5 | — |
| CatchAllException | catch (Exception) without rethrow (excluding when filtered catches) |
— |
| AsyncVoidMethod | async void method (excluding Unity message methods and event handlers) |
— |
| BlockingTaskWait | .Result / .Wait() / .GetAwaiter().GetResult() on Task/ValueTask/UniTask |
— |
| MissingBurstCompile | ISystem / IJobEntity / IJobChunk struct without [BurstCompile] |
— |
| ManagedReferenceInComponentData | struct IComponentData with reference-type field |
— |
Unity hot-path severity escalation
BoxingAllocation, ClosureCapture, and ParamsArrayAllocation are normally Warning-level smells. When the enclosing type derives from UnityEngine.MonoBehaviour and the smell occurs inside a Unity hot-path method, severity escalates to Critical.
Hot-path methods are:
Update,FixedUpdate,LateUpdate,OnGUI- Coroutines: methods whose return type is
System.Collections.IEnumerator
Lifecycle methods such as Awake, Start, OnEnable, OnDisable, and OnDestroy are not hot paths and keep Warning severity.
Escalation rewrites only Severity (Warning → Critical); Kind is unchanged so boxing/closure/params counts and CodeHealth are unaffected.
SyntaxOnly caveats
Under SyntaxOnly analysis:
- ClosureCapture only: Boxing and Params require a
SemanticModeland emit nothing under SyntaxOnly, so hot-path escalation applies to ClosureCapture only. - MonoBehaviour detection: the syntactic fallback matches the direct base-list type name against
MonoBehaviourand cannot see through intermediate project base classes (e.g.Player : BaseViewwhereBaseView : MonoBehaviouris not recognized without semantic resolution).
Energy proxy (hot-path smell density)
energyPressure is a static proxy derived from source-code smell counts.
It is not measured energy, power, battery consumption, joules, or watts.
For Unity projects with at least one detected hot-path method:
energyPressure = hot-path performance smell count / Unity hot-path method count
The numerator counts UNI017-UNI021 plus BoxingAllocation, ClosureCapture, and ParamsArrayAllocation when they occur in a detected hot path.
UNI022 and UNI023 are excluded because they are correctness and responsiveness findings, not per-frame work indicators.
Suppressed and gate-excluded triage findings are excluded; --baseline also excludes baselined findings from badge gates.
The denominator is the number of distinct hot-path method names detected on MonoBehaviour types.
It includes Update, FixedUpdate, LateUpdate, OnGUI, and coroutine methods returning IEnumerator.
Overloads with the same method name are merged, and the analysis does not follow transitive call graphs.
The field is emitted only for Unity projects with a non-zero denominator.
badge --metric energy reports n/a for non-Unity projects or Unity projects without hot paths.
--fail-over <density> passes at or below the threshold and fails above it.
Badge colors are provisional: 0 bright green, below 1.0 yellow, and 1.0 or above red.
The proxy is independent of CodeHealth and is not included in CodeHealth aggregation.
Because Boxing and Params detection requires a SemanticModel, values from different analysisLevel values are not directly comparable.
Old trend snapshots without energy counts render as missing (- or chart gaps), not as 0.00.
Research grounding: Pérez Caseiras, Veron, Perez, Moraga, Calero, and Cetina, "Towards green game software engineering: A comparative analysis of energy consumption between the widespread Unity and Unreal video game engines", Information and Software Technology (2025), arXiv:2402.06346. The study shows that code and engine implementation choices can produce measurable energy differences, but it does not calibrate this static density to physical energy units.
Detection responsibility routing
Each smell's detection responsibility is split between deterministic rule detection (structural, graph, semantic) and LLM delegation (semantic intent judgment). Souza et al. (arXiv:2601.09873) report that the optimal detector differs by smell kind: structural smells favor deterministic rules; semantic smells favor LLMs. Wu, Mu et al. (iSMELL, ASE 2024) report that combining metric tools with LLMs outperforms LLM-only approaches, supporting a split between deterministic detection and LLM interpretation. See quality-audit blind-spots for LLM-delegated items; confirm in the Phase 3 checklist.
| Smell | SARIF rule | Detection responsibility | Rationale |
|---|---|---|---|
| GodClass | UNI001 | Rule detection (metric threshold) | Structural; stable threshold detection |
| LongMethod | UNI002 | Rule detection (metric threshold) | Structural; stable threshold detection |
| ExcessiveParameters | UNI003 | Rule detection (metric threshold) | Structural; stable threshold detection |
| HighComplexity | UNI004 | Rule detection (metric threshold) | Structural; stable threshold detection |
| DeepNesting | UNI005 | Rule detection (metric threshold) | Structural; stable threshold detection |
| LowCohesion | UNI006 | Rule detection (metric threshold) | Structural; stable threshold detection |
| HighCoupling | UNI007 | Rule detection (metric threshold) | Structural; stable threshold detection |
| LowMaintainability | UNI008 | Rule detection (metric threshold) | Structural; stable threshold detection |
| CyclicDependency | UNI009 | Rule detection (graph analysis) | Dependency graph analysis required |
| DeepInheritance | UNI010 | Rule detection (metric threshold) | Structural; stable threshold detection |
| BoxingAllocation | UNI011 | Rule detection (semantic analysis) | SemanticModel required |
| ClosureCapture | UNI012 | Rule detection (semantic analysis) | SemanticModel required |
| ParamsArrayAllocation | UNI013 | Rule detection (semantic analysis) | SemanticModel required |
| CatchAllException | UNI014 | Rule detection (semantic analysis) | SemanticModel required |
| MissingInnerException | UNI015 | Rule detection (semantic analysis) | SemanticModel required |
| ThrowingSystemException | UNI016 | Rule detection (semantic analysis) | SemanticModel required |
| AsyncVoidMethod | UNI022 | Rule detection (syntax + semantic analysis) | Detects async void; excludes Unity message methods and event handlers |
| BlockingTaskWait | UNI023 | Rule detection (syntax + semantic analysis) | Blocking wait on Task/ValueTask/UniTask; SyntaxOnly detects GetAwaiter().GetResult() only |
| MissingBurstCompile | UNI024 | Rule detection (syntax + semantic analysis) | Missing [BurstCompile] on ISystem/IJobEntity/IJobChunk struct. Complete validates Unity.Entities namespace; SyntaxOnly matches interface names (non-Unity interfaces with the same name may false-positive) |
| ManagedReferenceInComponentData | UNI025 | Rule detection (syntax + semantic analysis) | Reference-type fields in struct IComponentData; class IComponentData excluded. SyntaxOnly uses conservative list (string/object/array/BCL collections, etc.) |
| WeakTemporization | UNI021 | Rule detection (syntax analysis, semantic enrichment) | SyntaxOnly capable |
| ExpensiveUnityApiInHotPath | UNI017 | Rule detection (Unity hot-path syntax analysis) | Unity-specific; MonoBehaviour per-frame methods only |
| LinqInHotPath | UNI018 | Rule detection (Unity hot-path syntax analysis) | Unity-specific; MonoBehaviour per-frame methods only |
| CollectionAllocationInHotPath | UNI019 | Rule detection (Unity hot-path syntax analysis) | Unity-specific; MonoBehaviour per-frame methods only |
| StringConcatenationInHotPath | UNI020 | Rule detection (Unity hot-path syntax analysis) | Unity-specific; MonoBehaviour per-frame methods only |
| Feature Envy | — | LLM delegation | Requires intent/context judgment; not thresholdable |
| Naming quality | — | LLM delegation (inputs: --include-api-surface identifiers / publicSignatures) |
Requires intent/context judgment; not thresholdable |
| Intent–code divergence | — | LLM delegation (inputs: --include-api-surface docSummary / identifiers) |
Requires intent/context judgment; not thresholdable |
| Comment–code inconsistency | — | LLM delegation (inputs: --include-api-surface docSummary / publicSignatures) |
Requires intent/context judgment; not thresholdable |
| Top-level statements | — | LLM delegation | Requires intent/context judgment; not thresholdable |
| Runtime risk (Dispose leak / deadlock) | — | LLM delegation | Requires intent/context judgment; not thresholdable |
Validation
Complete vs SyntaxOnly analysis differences
When Unity DLLs cannot be resolved (CI environments, etc.), analysis falls back to SyntaxOnly. Measured differences on the same real project (oculus-samples/Unity-Decommissioned, 283 types) analyzed at Complete and SyntaxOnly:
| Metric | Complete | SyntaxOnly | Notes |
|---|---|---|---|
| CodeHealth avg | 9.6 | 9.6 | Identical (computed from syntax information only) |
| CodeHealth min | 4.8 | 5.0 | Difference due to #if UNITY_EDITOR define presence |
| Dependency count | 452 | 429 | -5% |
| Cyclic dependencies | 6 | 6 | Identical |
| Total smells | 885 | 289 | Breakdown in table below |
| DIT max | 7 | 1 | Inheritance spanning engine types requires semantic analysis |
| CBO avg | 13.7 | 5.7 | Coupling to UnityEngine types invisible |
| Analysis time | 4.6s | 0.6s |
Smell breakdown differences:
| Smell | Complete | SyntaxOnly |
|---|---|---|
| BoxingAllocation | 312 | 0 |
| ClosureCapture | 181 | 81 |
| ParamsArrayAllocation | 30 | 0 |
| DeepInheritance | 38 | 0 |
| HighCoupling | 111 | 19 |
Under SyntaxOnly, SemanticModel-dependent detection (Boxing / Params / DIT / CBO) is underestimated.
Therefore unilyze badge targets CodeHealth / MI (stable across levels) and smells limited to the syntax-level subset.
As shown above, total smell counts vary greatly across levels (885 → 289); documentation states that smell badges must not be used for cross-level comparison.
Cross-validation with Microsoft.CodeAnalysis Metrics (official engine)
Measured src/Unilyze with the official Metrics tool's CodeAnalysisMetricData (Microsoft.CodeAnalysis.AnalyzerUtilities) and cross-validated against unilyze SyntaxOnly analysis
(100 type matches; 2 JsonSerializerContext types from source generators excluded. Reproduction: scripts/crossval):
| Metric | Pearson correlation | Mean absolute difference | Notes |
|---|---|---|---|
| CycCC | 0.983 | 2.0 | Compared as type-level totals. Divergence explainable by convention differences for 97/100 types (below) |
| MI | 0.870 | 5.4 | Official aggregates at type level; unilyze uses method average (43 types without methods are not MI targets in unilyze; statusline / badge MI average also uses only types with methods as denominator) |
| Coupling | 0.817 (rank) | — | Official ClassCoupling avg 14.0 vs unilyze CBO 3.6 (underestimated under SyntaxOnly) |
| DIT | — | — | Official counts object inheritance as 1; uniform offset on all items |
Structure of CycCC divergence (proven on all 339 methods; investigation completed in issue #4):
Divergence Δ = unilyze − official decomposes strictly by convention-difference syntax occurrences (97/100 types: Δ = arm + catch + goto − default − member base difference matches exactly). Top divergent types and decomposition:
| Type | Official | Unilyze | Δ | Breakdown |
|---|---|---|---|---|
| BadgeFormatter | 14 | 28 | +14 | switch arm ×14 |
| HalsteadCalculator | 16 | 30 | +14 | switch arm ×14 |
| DIContainerAnalyzer | 42 | 55 | +13 | switch arm ×14 − default ×1 |
| BadgeSvgRenderer | 12 | 22 | +10 | switch arm ×10 |
| ClosureDetector | 30 | 40 | +10 | switch arm ×10 |
| BloomFilter128 | 26 | 17 | −9 | Official member base (ctor / accessor each 1) ×9 |
Record / DTO types without methods uniformly have Δ = −1 (official counts implicit ctor as base 1).
The 3 types with residual (HalsteadWalker / State / Walker) differ by ±1 due to bool & | not resolvable under SyntaxOnly and nested-type member name matching.
The pre-investigation hypothesis that "?. ?? are unilyze-specific additions" was disproved (the official engine also counts both).
Actual divergence factors are switch expression arms (dominant in this codebase), catch, goto, default, and member base differences.
As a by-product of this investigation, a bug was found and fixed: deconstruction foreach (foreach (var (a, b) in ...)) was not counted in CycCC / CogCC / nesting-depth walkers.
Threshold Calibration (unilyze calibrate)
Source: Alves, Ypma & Visser, "Deriving Metric Thresholds from Benchmark Data", ICSM 2010.
Same-tool principle (Alves Section VII-D)
Thresholds must be derived with the same tool and scope used at application time. unilyze CycCC uses the extended interpretation counting switch expression arms and catch as branches (see Cyclomatic Complexity notes above); CA1502's default 25 and benchmark values from other tools cannot be reused directly. calibrate accepts only JSON snapshots produced by unilyze itself, keeping derivation and analysis within one tool.
Procedure
- Prepare multiple systems (one
unilyze -f jsonsnapshot each). All inputs must share the samemetricsVersion(error exit on mismatch). - Within each system, weight by method LOC ratio (weight = method LOC / total method LOC in that system).
- Across systems, divide weights by system count so each system contributes equally (large repos do not dominate the distribution).
- Read percentiles from the pooled weighted distribution to obtain four risk-band boundaries (low / moderate / high / veryHigh).
- Normal metrics: 70 / 80 / 90 percentiles
- Parameter count: 80 / 90 / 95 percentiles (per paper)
Target metrics:
| Category | Metrics | Used for |
|---|---|---|
| Method | LOC, CycCC, CogCC, max nesting depth, parameter count | LongMethod / HighComplexity / DeepNesting / ExcessiveParameters |
| Type | method count, type LOC | GodClass |
CLI
unilyze calibrate <dir-of-jsons> [-o thresholds.json]
Output JSON includes metrics (percentiles and risk bands per metric), sources (input file names, method counts, etc.), and unilyzeConfigFragment (candidates pasteable into .unilyze.json smells). Built-in defaults (SmellThresholds) are unchanged. Applying calibration results is left to project configuration or future release decisions.
Limitations
The Alves paper uses benchmarks of roughly 100 systems. unilyze's validation Unity OSS corpus (HelloMarioFramework, Boss Room, UniTask, VContainer, etc.) is small; derived values are provisional candidates. Re-run with the same procedure when the corpus grows.
Code Duplication (dup)
unilyze dup produces a duplication report independent of the main analysis JSON. Integration into CodeHealth is planned for CodeHealth v2.
Normalization
Scan Roslyn token sequences per .cs file and normalize with the following rules.
| Token kind | Normalization |
|---|---|
| Identifiers | ID |
| Numeric/string/char/bool literals, interpolated string text | LIT |
| Keywords, operators, punctuation | As-is |
| Trivia (whitespace, comments) | Excluded |
Detects Type-2 (identifier/literal substitution) and Type 3-2 equivalent (identifier/literal blind) clones. Known limitation: statement insertion/deletion can split matches.
Detection algorithm
- Minimum window: 100 tokens (default; override via
--min-tokensor.unilyze.jsondup.minTokens; CLI takes priority) - Rabin-Karp rolling hash buckets candidates; token-sequence exact match on hash collision
- Extend matching windows bidirectionally; merge overlaps within the same file
Duplication rate
duplicationPercent = union of lines covered by clones / total lines in analyzed files × 100. Line-based density like SonarQube Duplicated Lines %.
Third-party suppression
Default third-party roots: Assets/Plugins, Assets/Standard Assets, Assets/AssetStoreTools (extendable via --third-party-dir and .unilyze.json dup.thirdPartyDirs).
Pairs where both ends are within the same third-party root are suppressed by default and counted in suppressedPairCount. first-party ↔ third-party pairs are always reported. Disable suppression with --include-third-party.
CI gate
unilyze badge -p . --metric dup --fail-over 3 # exit 2 when > 3%
Badge colors: green < 3%, yellow 3–10%, red ≥ 10% (aligned with SonarQube default 3% quality gate).
Full operation under SyntaxOnly
dup consumes SyntaxTree only; no dependency on Compilation / SemanticModel. Same results in CI without Unity DLLs as at Complete level.
Known noise
Windows dominated by LIT tokens (large array initializers, etc.) may detect data tables as clones.
Assembly Mapping
Assembly-granularity metrics (Abstractness, Instability, Distance from Main Sequence, Relational Cohesion, assembly cycles) require splitting the analysis scope into assembly units before aggregation.
| Project kind | Split unit | Dependency edges |
|---|---|---|
| Unity | .asmdef name (under Assets/) |
asmdef references (GUID resolution) |
| General .NET (no asmdef, non-Unity) | Discovered .csproj file name (extension stripped) |
ProjectReference |
| Unity without asmdef or csproj | Single Assembly-CSharp |
None |
Nested csproj directories add child csproj directories to the parent assembly's ExcludeDirectories so each .cs file belongs to exactly one assembly (same as nested asmdef). When loose .cs files exist outside all csproj directories, an Assembly-CSharp fallback is added.
typeId uses {assembly}::{namespace.path} format; in general .NET repos the assembly prefix changes after csproj mapping (e.g. Assembly-CSharp::Foo.Bar → MyApp::Foo.Bar). Unity (with asmdef) and paths without csproj/asmdef remain as before.
Metric Compatibility Policy
Multiple patch releases have changed measured values via bugfixes (deconstruction foreach count omission fix, excluding method-less types from MI average denominator, removing DIT I[A-Z] heuristic, etc.).
These are tool-side measurement fluctuations that add noise for users of diff / trend / badge across versions.
The following policy clarifies which release kinds may change measured values.
Metric definition changes must be recorded in CHANGELOG.md with the [metrics] prefix (see the [metrics] tag convention in that file).
Measured-value handling by release kind
| Release kind | Measured-value changes | Permitted changes |
|---|---|---|
| patch | None | Crash fixes, output-format additions (new fields/formats that do not change existing values) |
| minor and above | May occur | Metric definition changes (below) |
Metric definition changes are any of:
- Counting conventions (which syntax to add; e.g. deconstruction foreach, switch expression arm)
- Numerator/denominator composition (e.g. type set for MI average)
- Thresholds (code-smell Warning / Critical boundaries, etc.)
- Composite score weights (Code Health element weights, etc.)
These changes require at least a minor bump. Patch releases must not change measured values.
Procedure when changing definitions
Releases that change metric definitions must:
- Document measurement impact in release notes: which metrics move in which direction (increase / decrease / value-range change)
- Re-run scripts/crossval cross-validation and update validation data in this document's Validation section
- Update any convention-difference descriptions (e.g. "differences from official engine") to match the change
Notes for users
diff / trend are intended to track changes in analyzed code.
However, when the two comparison points were measured with different unilyze versions, metric definition changes (possible in minor and above) can mix in.
If values move without code changes, suspect an unilyze version difference.
Pinning the unilyze version eliminates this effect.
Mechanical detection via metricsVersion
JSON output root includes metricsVersion (int) and toolVersion (string).
metricsVersion is an integer representing measurement-definition compatibility; increment on every change that alters measured values.
toolVersion is the unilyze assembly version at snapshot generation.
diff and trend emit a one-line stderr warning when metricsVersion differs between inputs.
diff --fail-on-version-mismatch exits with code 2 on version mismatch (for CI gates).
metricsVersion increment rules: Any change that alters measured values (counting conventions, numerator/denominator, thresholds, weights) requires
(1) increment AnalysisResult.CurrentMetricsVersion, (2) at least minor bump, (3) add CHANGELOG [metrics] entry,
as a set. Do not raise metricsVersion in a patch release.
Within the same release window ([Unreleased] period), bundle metricsVersion bumps into one increment.
Definitions for unreleased version numbers are fluid; multiple [metrics] changes before tagging do not trigger renumbering.
Reference analysis opt-ins (NuGet / generated code)
--resolve-nuget and --include-generated are off by default. Enabling them can resolve package types and source-generator output for semantic metrics such as CBO / DIT / boxing, which may change values. metricsVersion does not increase (default output remains byte-identical).
--resolve-nuget/"resolveNuget": true— inject NuGet compile assemblies fromobj/project.assets.json(afterdotnet restore). Used with BCL runtime injection (#84).--include-generated/"includeGenerated": true— addobj/<Config>/<TFM>/generated/**/*.csfromEmitCompilerGeneratedFiles=truebuilds to compilation only (not included in type count, LineCount, or smells).--tfm/"targetFramework"— explicit single TFM. When omitted: firstTargetFramework(s)in csproj, then running runtime, then highest version.
JSON output echoes active opt-in settings (resolveNuget, includeGenerated, targetFramework). diff / trend / baseline comparison is valid only between snapshots with identical opt-in settings.
Future work
(Resolved: issue #30 implemented metricsVersion / toolVersion. See "Mechanical detection via metricsVersion" above.)