using System.Collections.Generic;
using System.Linq;
using System.Text;
#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG)
using UnityPipeline.Microsoft.CodeAnalysis;
using UnityPipeline.Microsoft.CodeAnalysis.CSharp;
using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax;
#endif
namespace Unity.Pipeline.Telemetry
{
///
/// Structural classification of an eval body. Matches the epic's usage buckets: the majority of
/// eval calls are one-liners (a single method invoke / value read / flag set), which we tag as
/// ; anything with more than one statement (polling loops, bulk
/// setup) is .
///
static class EvalClassification
{
public const string SingleExpression = "single-expression";
public const string Statements = "statements";
}
///
/// Result of analysing one eval body: its structural and the ranked
/// API (top-level member-access paths) extracted from the syntax tree.
///
struct EvalUsageAnalysis
{
public string Classification;
public IReadOnlyList Fingerprints;
}
///
/// Extracts a privacy-preserving "fingerprint" of an eval body from its C# syntax tree, without
/// storing the raw source (AUTHAPI-29). A fingerprint is the top-level member-access path of each
/// access site — e.g. AssetDatabase.Refresh, PlayerSettings.SetScriptingBackend,
/// Object.FindFirstObjectByType<T>, MatchManager.Instance.State — which is
/// enough to rank which APIs agents actually reach for (steering the eval-displacement backlog,
/// AUTHAPI-24).
///
/// PRIVACY CONTRACT — strict whitelist rendering. The only source text that can ever appear in a
/// fingerprint is an identifier, type name, or member name taken from the syntax tree. Literals,
/// interpolated strings, and every expression form not on the whitelist are replaced by the
/// neutral ("secret".Length records as <expr>.Length);
/// object creations render as new TypeName (constructor arguments never appear); casts
/// unwrap to the inner expression (((Foo)o).Bar records as o.Bar); generic type
/// arguments are normalised to arity placeholders (<T>). A path that would be nothing
/// but the placeholder carries no API information and is dropped entirely.
///
/// Conditional-access chains (a?.B.C()) are fingerprinted with ?. normalised to
/// ., so they rank together with their unconditional form.
///
/// Known blind spots — the ranking UNDERCOUNTS these forms (deliberate, to keep the walker
/// simple and re-entrancy-free); consumers of the report should not read absence as proof of
/// non-use:
///
/// - Arguments nested inside a chain's receiver call are not walked: in
/// Configure(Settings.Load()).Apply() only Configure().Apply is recorded — the
/// callee chain is not re-entered, so Settings.Load is missed.
/// - A bare object creation with no trailing member access (new Foo(...); as a whole
/// statement) produces no fingerprint.
/// - Bodies nested deeper than the walker's depth guard are abandoned mid-walk.
///
///
/// Threading: pure Roslyn + BCL — no Unity API calls — so is safe to run
/// off the main thread. The telemetry writer relies on this and runs it on a background task
/// from a snapshot captured at eval completion.
///
/// This is a SEPARATE parse from the one EvalCodeCompiler does to actually compile and
/// run the eval, by necessity rather than oversight: that parse is of a generated wrapper
/// source (the raw body embedded inside a class PipelineEval_<id> { ... Execute() {
/// ... } }) under SourceCodeKind.Regular with the project's #if defines, not
/// of the raw body alone as a top-level SourceCodeKind.Script compilation unit the way
/// expects it. Sharing that tree would mean threading it out of
/// RoslynCompilationService (on both the success AND failure path — failures still get
/// recorded) and locating the wrapped Execute() body inside it, coupling this class to
/// the wrapper's exact shape. Not worth it today: the extra parse already runs off the eval
/// response path (see Threading above), so it costs background CPU, not eval latency.
///
static class EvalUsageFingerprinter
{
///
/// Upper bound on fingerprints kept per record, so a pathological body can't bloat the log.
/// Truncation keeps the FIRST N access sites in tree (source) order, not the most frequent —
/// acceptable because bodies with more than 32 access sites are rare outliers.
///
public const int MaxFingerprints = 32;
///
/// Neutral stand-in for any expression not on the rendering whitelist (literals, interpolated
/// strings, arithmetic, lambdas, …). Guarantees no raw source text leaks into a fingerprint.
///
public const string Placeholder = "";
#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG)
private static readonly CSharpParseOptions m_ScriptOptions =
new CSharpParseOptions(kind: SourceCodeKind.Script);
///
/// Analyse an eval body: classify it and extract its API fingerprints. Never throws — a body
/// that fails to parse yields an empty fingerprint list and a best-effort classification.
///
public static EvalUsageAnalysis Analyze(string code)
{
if (string.IsNullOrWhiteSpace(code))
return new EvalUsageAnalysis { Classification = EvalClassification.Statements, Fingerprints = new List() };
CompilationUnitSyntax root;
try
{
root = CSharpSyntaxTree.ParseText(code, m_ScriptOptions).GetCompilationUnitRoot();
}
catch
{
return new EvalUsageAnalysis { Classification = EvalClassification.Statements, Fingerprints = new List() };
}
return new EvalUsageAnalysis
{
Classification = Classify(root),
Fingerprints = ExtractFingerprints(root)
};
}
/// Convenience overload used by tests: fingerprints only.
public static IReadOnlyList ExtractFingerprints(string code) => Analyze(code).Fingerprints;
/// Convenience overload used by tests: classification only.
public static string Classify(string code) => Analyze(code).Classification;
///
/// One-liner iff the body is exactly one statement that is an expression or a value-returning
/// return. A lone loop/if/block (e.g. a polling loop) is a statement body, not a one-liner.
///
private static string Classify(CompilationUnitSyntax root)
{
var statements = root.Members
.OfType()
.Select(m => m.Statement)
.ToList();
if (statements.Count == 1)
{
var only = statements[0];
if (only is ExpressionStatementSyntax ||
(only is ReturnStatementSyntax ret && ret.Expression != null))
{
return EvalClassification.SingleExpression;
}
}
return EvalClassification.Statements;
}
private static IReadOnlyList ExtractFingerprints(CompilationUnitSyntax root)
{
var walker = new FingerprintWalker();
walker.Visit(root);
// Take() keeps the first MaxFingerprints access sites in tree (source) order — see the
// MaxFingerprints doc for why that truncation policy is acceptable.
return walker.Fingerprints.Count > MaxFingerprints
? walker.Fingerprints.Take(MaxFingerprints).ToList()
: walker.Fingerprints;
}
///
/// Walks the syntax tree collecting one fingerprint per top-level access site: an invocation's
/// callee (A.B.C(...) → A.B.C) or a member-access chain that is not itself the
/// head of an enclosing call/access (A.Instance.State). Nested access sites inside call
/// arguments are still collected (they are their own sites); the callee chain is not re-counted.
/// A depth counter abandons subtrees nested past so a pathological body
/// (machine-generated nesting) can't blow the stack — fingerprinting is best-effort.
///
private sealed class FingerprintWalker : CSharpSyntaxWalker
{
/// Maximum syntax depth walked; deeper subtrees are abandoned.
private const int MaxDepth = 64;
public readonly List Fingerprints = new List();
private int m_Depth;
public override void Visit(SyntaxNode node)
{
if (m_Depth >= MaxDepth)
return; // Abandon this subtree — best-effort telemetry, never a liability.
m_Depth++;
try
{
base.Visit(node);
}
finally
{
m_Depth--;
}
}
public override void VisitInvocationExpression(InvocationExpressionSyntax node)
{
AddChainHeadFingerprint(node, node.Expression, () => RenderPath(node.Expression));
// Deliberately do NOT recurse into the callee chain (already captured above), but do
// walk the arguments so nested calls/reads passed as arguments are captured too.
VisitArgumentsIfPresent(node.ArgumentList);
}
public override void VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
{
if (IsChainHead(node))
Add(RenderPath(node));
// Continue into the expression side so nested access sites are still discovered;
// inner chain members are guarded by IsChainHead and won't be double-counted.
base.VisitMemberAccessExpression(node);
}
public override void VisitElementAccessExpression(ElementAccessExpressionSyntax node)
{
// A bare element-access chain head (cache[key]) previously produced NO fingerprint
// at all — no member access for the walker to see — while the conditional form
// (cache?[key]) records "cache[]": the two must rank together, so bare element
// access on a member/name receiver is fingerprinted the same way.
AddChainHeadFingerprint(node, node.Expression, () => RenderPath(node));
// Walk the bracket arguments so nested reads/calls inside the indexer are captured.
VisitArgumentsIfPresent(node.ArgumentList);
}
public override void VisitConditionalAccessExpression(ConditionalAccessExpressionSyntax node)
{
// The whole ?. chain is ONE fingerprint (RenderPath normalizes '?.' to '.'):
// descending into its parts would record the receiver and the bound members as
// separate (and misleading) entries.
if (IsChainHead(node))
Add(RenderPath(node));
// Parity with VisitInvocationExpression/VisitElementAccessExpression: only the head
// access's OWN argument list is walked, scoped to WhenNotNull (not the receiver
// before '?.') — the receiver's nested-call arguments are the same documented
// blind spot the unconditional forms accept, not something '?.' should uncover.
// Only the outermost argument lists are visited — the walker recurses within.
// Covers both parenthesized (ArgumentListSyntax) and bracketed
// (BracketedArgumentListSyntax) forms, so an indexer inside a conditional chain
// (a?.b[SomeCall()]) is captured too — a bracketed list was previously invisible
// to this sweep, silently dropping SomeCall.
if (node.WhenNotNull != null)
{
foreach (var argumentList in node.WhenNotNull
.DescendantNodes(n => !(n is ArgumentListSyntax) && !(n is BracketedArgumentListSyntax))
.Where(n => n is ArgumentListSyntax || n is BracketedArgumentListSyntax))
Visit(argumentList);
}
}
// Shared shape between VisitInvocationExpression and VisitElementAccessExpression: add
// the rendered path only for a chain head whose receiver is renderable, computing the
// path lazily so non-chain-head nodes (the overwhelming majority visited) skip RenderPath
// entirely instead of just discarding its result.
private void AddChainHeadFingerprint(SyntaxNode node, ExpressionSyntax receiver, System.Func render)
{
if (IsChainHead(node) && IsMemberOrName(receiver))
Add(render());
}
private void VisitArgumentsIfPresent(BaseArgumentListSyntax argumentList)
{
if (argumentList != null)
Visit(argumentList);
}
private void Add(string path)
{
// A path that is nothing but the neutral placeholder carries no API information —
// drop the fingerprint rather than pollute the ranking with "" noise.
if (string.IsNullOrEmpty(path) || path == Placeholder)
return;
Fingerprints.Add(path);
}
// A node is the head of its access/call chain when its parent is not another link of the
// same chain (member access, invocation, or element access).
private static bool IsChainHead(SyntaxNode node)
{
var parent = node.Parent;
// See through parentheses: in (a?.b).c the conditional is part of the enclosing
// chain, not a head of its own.
while (parent is ParenthesizedExpressionSyntax paren)
parent = paren.Parent;
return !(parent is MemberAccessExpressionSyntax)
&& !(parent is InvocationExpressionSyntax)
&& !(parent is ElementAccessExpressionSyntax);
}
// Also accepts a call or parenthesized expression as the receiver: RenderPath already
// knows how to render both (GetHandler()() -> "GetHandler()", (A.B)() -> "A.B"), so
// gating them out here was dropping the whole access site instead of rendering it —
// not even the placeholder, since Add() was never reached.
private static bool IsMemberOrName(ExpressionSyntax expr) =>
expr is MemberAccessExpressionSyntax || expr is IdentifierNameSyntax || expr is GenericNameSyntax
|| expr is InvocationExpressionSyntax || expr is ParenthesizedExpressionSyntax;
}
///
/// Render an expression to its fingerprint path: dotted identifiers, with generic method type
/// arguments normalised to arity placeholders (<T>, <T1, T2>) so
/// FindFirstObjectByType<Foo> and <Bar> rank as one API.
///
/// STRICT WHITELIST (the privacy contract of the class doc): only the expression forms listed
/// below render source text, and each renders identifiers/type/member names exclusively.
/// Everything else — literals, interpolated strings, arithmetic, lambdas, conditionals, … —
/// falls through to the neutral so no raw source can ever leak into
/// a fingerprint. Casts unwrap to the inner expression; object creations render as
/// new TypeName. Rendering recursion is bounded by as a second
/// layer of defence against pathological nesting (chains hang below their head, so they are
/// not covered by the walker's descent guard).
///
private static string RenderPath(ExpressionSyntax expr) => RenderPath(expr, 0);
private const int MaxRenderDepth = 64;
private static string RenderPath(ExpressionSyntax expr, int depth)
{
if (depth >= MaxRenderDepth)
return Placeholder;
depth++;
switch (expr)
{
case IdentifierNameSyntax id:
return id.Identifier.Text;
case GenericNameSyntax gn:
return gn.Identifier.Text + RenderArity(gn.TypeArgumentList.Arguments.Count);
case QualifiedNameSyntax qn: // Namespace-qualified type in type position, e.g. new UnityEngine.GameObject
return RenderPath(qn.Left, depth) + "." + RenderSimpleName(qn.Right);
case AliasQualifiedNameSyntax aq: // global::UnityEngine → keep the name, drop the alias
return RenderSimpleName(aq.Name);
case MemberAccessExpressionSyntax ma:
return RenderPath(ma.Expression, depth) + "." + RenderSimpleName(ma.Name);
case InvocationExpressionSyntax inv:
return RenderPath(inv.Expression, depth) + "()";
case ElementAccessExpressionSyntax ea:
return RenderPath(ea.Expression, depth) + "[]";
case ParenthesizedExpressionSyntax pe:
return RenderPath(pe.Expression, depth);
case CastExpressionSyntax ce: // ((Foo)o).Bar → o.Bar: unwrap to the casted expression
return RenderPath(ce.Expression, depth);
case ObjectCreationExpressionSyntax oc: // new GameObject("x").AddComponent → new GameObject.AddComponent
return "new " + RenderPath(oc.Type, depth);
case PredefinedTypeSyntax pt:
return pt.Keyword.Text;
case ThisExpressionSyntax _:
return "this";
case BaseExpressionSyntax _:
return "base";
case ConditionalAccessExpressionSyntax cond:
// a?.B.C renders as a.B.C: '?.' is null-propagation noise for ranking
// purposes. Rendering only the bound member (the old fall-through via the bare
// MemberBinding) silently dropped the receiver, producing a colliding entry
// ("State.Value" for MatchManager.Instance?.State.Value). When the chain ends
// in a call, fingerprint the CALLEE (a?.B() -> a.B) so it ranks together with
// its unconditional form — the walker uses the same callee convention for
// plain invocation heads.
var conditionalTail = cond.WhenNotNull is InvocationExpressionSyntax topCall
? topCall.Expression
: cond.WhenNotNull;
var tailRendered = RenderPath(conditionalTail, depth);
// An element-binding tail (cache?[key] -> "[]") concatenates WITHOUT a dot so
// the fingerprint matches the unconditional cache[key] form ("cache[]").
return tailRendered.Length > 0 && tailRendered[0] == '['
? RenderPath(cond.Expression, depth) + tailRendered
: RenderPath(cond.Expression, depth) + "." + tailRendered;
case MemberBindingExpressionSyntax mb:
return RenderSimpleName(mb.Name);
case ElementBindingExpressionSyntax _: // the ?[...] binding of a conditional chain
return "[]";
default:
// NOT a raw ToString(): anything off the whitelist (string/char/numeric literals,
// interpolated strings, lambdas, arithmetic, …) is reduced to the neutral
// placeholder so source text can never leak into the telemetry log (AUTHAPI-29).
return Placeholder;
}
}
private static string RenderSimpleName(SimpleNameSyntax name)
{
if (name is GenericNameSyntax gn)
return gn.Identifier.Text + RenderArity(gn.TypeArgumentList.Arguments.Count);
return name.Identifier.Text;
}
private static string RenderArity(int count)
{
if (count <= 0)
return string.Empty;
if (count == 1)
return "";
var sb = new StringBuilder("<");
for (var i = 1; i <= count; i++)
{
if (i > 1)
sb.Append(", ");
sb.Append("T").Append(i);
}
sb.Append(">");
return sb.ToString();
}
#else
/// Runtime compilation (and therefore eval) is unavailable on this platform.
public static EvalUsageAnalysis Analyze(string code) =>
new EvalUsageAnalysis { Classification = EvalClassification.Statements, Fingerprints = new List() };
public static IReadOnlyList ExtractFingerprints(string code) => new List();
public static string Classify(string code) => EvalClassification.Statements;
#endif
}
}