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 } }