423 lines
22 KiB
C#
423 lines
22 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="SingleExpression"/>; anything with more than one statement (polling loops, bulk
|
|
/// setup) is <see cref="Statements"/>.
|
|
/// </summary>
|
|
static class EvalClassification
|
|
{
|
|
public const string SingleExpression = "single-expression";
|
|
public const string Statements = "statements";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of analysing one eval body: its structural <see cref="Classification"/> and the ranked
|
|
/// API <see cref="Fingerprints"/> (top-level member-access paths) extracted from the syntax tree.
|
|
/// </summary>
|
|
struct EvalUsageAnalysis
|
|
{
|
|
public string Classification;
|
|
public IReadOnlyList<string> Fingerprints;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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. <c>AssetDatabase.Refresh</c>, <c>PlayerSettings.SetScriptingBackend</c>,
|
|
/// <c>Object.FindFirstObjectByType<T></c>, <c>MatchManager.Instance.State</c> — 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 <see cref="Placeholder"/> (<c>"secret".Length</c> records as <c><expr>.Length</c>);
|
|
/// object creations render as <c>new TypeName</c> (constructor arguments never appear); casts
|
|
/// unwrap to the inner expression (<c>((Foo)o).Bar</c> records as <c>o.Bar</c>); generic type
|
|
/// arguments are normalised to arity placeholders (<c><T></c>). A path that would be nothing
|
|
/// but the placeholder carries no API information and is dropped entirely.
|
|
///
|
|
/// Conditional-access chains (<c>a?.B.C()</c>) are fingerprinted with <c>?.</c> normalised to
|
|
/// <c>.</c>, 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:
|
|
/// <list type="bullet">
|
|
/// <item><description>Arguments nested inside a chain's receiver call are not walked: in
|
|
/// <c>Configure(Settings.Load()).Apply()</c> only <c>Configure().Apply</c> is recorded — the
|
|
/// callee chain is not re-entered, so <c>Settings.Load</c> is missed.</description></item>
|
|
/// <item><description>A bare object creation with no trailing member access (<c>new Foo(...);</c> as a whole
|
|
/// statement) produces no fingerprint.</description></item>
|
|
/// <item><description>Bodies nested deeper than the walker's depth guard are abandoned mid-walk.</description></item>
|
|
/// </list>
|
|
///
|
|
/// Threading: pure Roslyn + BCL — no Unity API calls — so <see cref="Analyze"/> 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 <c>EvalCodeCompiler</c> 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 <c>class PipelineEval_<id> { ... Execute() {
|
|
/// ... } }</c>) under <c>SourceCodeKind.Regular</c> with the project's <c>#if</c> defines, not
|
|
/// of the raw body alone as a top-level <c>SourceCodeKind.Script</c> compilation unit the way
|
|
/// <see cref="Analyze"/> expects it. Sharing that tree would mean threading it out of
|
|
/// <c>RoslynCompilationService</c> (on both the success AND failure path — failures still get
|
|
/// recorded) and locating the wrapped <c>Execute()</c> 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.
|
|
/// </summary>
|
|
static class EvalUsageFingerprinter
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public const int MaxFingerprints = 32;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public const string Placeholder = "<expr>";
|
|
|
|
#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG)
|
|
|
|
private static readonly CSharpParseOptions m_ScriptOptions =
|
|
new CSharpParseOptions(kind: SourceCodeKind.Script);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static EvalUsageAnalysis Analyze(string code)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(code))
|
|
return new EvalUsageAnalysis { Classification = EvalClassification.Statements, Fingerprints = new List<string>() };
|
|
|
|
CompilationUnitSyntax root;
|
|
try
|
|
{
|
|
root = CSharpSyntaxTree.ParseText(code, m_ScriptOptions).GetCompilationUnitRoot();
|
|
}
|
|
catch
|
|
{
|
|
return new EvalUsageAnalysis { Classification = EvalClassification.Statements, Fingerprints = new List<string>() };
|
|
}
|
|
|
|
return new EvalUsageAnalysis
|
|
{
|
|
Classification = Classify(root),
|
|
Fingerprints = ExtractFingerprints(root)
|
|
};
|
|
}
|
|
|
|
/// <summary>Convenience overload used by tests: fingerprints only.</summary>
|
|
public static IReadOnlyList<string> ExtractFingerprints(string code) => Analyze(code).Fingerprints;
|
|
|
|
/// <summary>Convenience overload used by tests: classification only.</summary>
|
|
public static string Classify(string code) => Analyze(code).Classification;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static string Classify(CompilationUnitSyntax root)
|
|
{
|
|
var statements = root.Members
|
|
.OfType<GlobalStatementSyntax>()
|
|
.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<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walks the syntax tree collecting one fingerprint per top-level access site: an invocation's
|
|
/// callee (<c>A.B.C(...)</c> → <c>A.B.C</c>) or a member-access chain that is not itself the
|
|
/// head of an enclosing call/access (<c>A.Instance.State</c>). 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 <see cref="MaxDepth"/> so a pathological body
|
|
/// (machine-generated nesting) can't blow the stack — fingerprinting is best-effort.
|
|
/// </summary>
|
|
private sealed class FingerprintWalker : CSharpSyntaxWalker
|
|
{
|
|
/// <summary>Maximum syntax depth walked; deeper subtrees are abandoned.</summary>
|
|
private const int MaxDepth = 64;
|
|
|
|
public readonly List<string> Fingerprints = new List<string>();
|
|
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<string> 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 "<expr>" 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Render an expression to its fingerprint path: dotted identifiers, with generic method type
|
|
/// arguments normalised to arity placeholders (<c><T></c>, <c><T1, T2></c>) so
|
|
/// <c>FindFirstObjectByType<Foo></c> and <c><Bar></c> 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 <see cref="Placeholder"/> so no raw source can ever leak into
|
|
/// a fingerprint. Casts unwrap to the inner expression; object creations render as
|
|
/// <c>new TypeName</c>. Rendering recursion is bounded by <paramref name="depth"/> 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).
|
|
/// </summary>
|
|
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<T> → new GameObject.AddComponent<T>
|
|
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 "<T>";
|
|
|
|
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
|
|
/// <summary>Runtime compilation (and therefore eval) is unavailable on this platform.</summary>
|
|
public static EvalUsageAnalysis Analyze(string code) =>
|
|
new EvalUsageAnalysis { Classification = EvalClassification.Statements, Fingerprints = new List<string>() };
|
|
|
|
public static IReadOnlyList<string> ExtractFingerprints(string code) => new List<string>();
|
|
|
|
public static string Classify(string code) => EvalClassification.Statements;
|
|
#endif
|
|
}
|
|
}
|