-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathSuggesterOrchestrator.cs
More file actions
308 lines (279 loc) · 12.7 KB
/
SuggesterOrchestrator.cs
File metadata and controls
308 lines (279 loc) · 12.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
// Wires Tier-2 suggesters into the `mur check` diagnostic pipeline.
// Inputs: a parsed MSBuild `Diag` plus the path of the project under test.
// Outputs: a `Suggestion` to attach to the diagnostic line, or null if no
// suggester wants to claim the diagnostic.
//
// Spec 038 §1.6 wiring:
// - Codes covered: CS1061, CS0103, CS0117, CS1503, CS7036.
// - For CS1061 / CS0117 we only run the suggester when the receiver
// resolves to a Microsoft.UI.Reactor.* symbol — non-Reactor diagnostics
// pass through unchanged. CS0103 / CS1503 / CS7036 self-filter inside
// the suggester (they probe the FactoryIndex / message text directly).
// - The Tier-1 analyzer-ID hint table still wins ties at the format layer
// (spec §9): if HintFor(code) returns a pointer, that wins over a
// Tier-2 suggestion for the same diagnostic.
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using Microsoft.UI.Reactor.Cli.Check.Rules;
using Microsoft.UI.Reactor.Cli.Check.Suggesters;
namespace Microsoft.UI.Reactor.Cli.Check;
internal sealed record Suggestion(string Text, double Confidence, string Evidence, string SuggesterName, bool IsRule = false);
internal sealed class SuggesterOrchestrator
{
static readonly HashSet<string> SupportedCodes = new(StringComparer.Ordinal)
{
"CS1061", "CS0103", "CS0117", "CS1503", "CS7036",
};
/// <summary>
/// Cheap pre-flight check used by <see cref="CheckCommand.Run"/> to
/// decide whether building the orchestrator (which triggers a compilation
/// load — `.cs` enumeration, file-set hash, reference resolution) is
/// worthwhile for this invocation. Returns true iff at least one parsed
/// diagnostic could plausibly produce a suggestion under the active gate:
/// the diag's code is in <see cref="SupportedCodes"/> AND
/// <paramref name="tier2Enabled"/> is true, OR the diag's code is covered
/// by some rule in <paramref name="rules"/> (Tier-3 always runs). On
/// clean builds (no diagnostics) or non-suggestable builds (only CS codes
/// we don't cover), returns false so the caller skips the compilation
/// load and the agent doesn't pay the ~50–500 ms load cost for no value.
/// </summary>
internal static bool AnyDiagnosticIsSuggestable(
IReadOnlyList<CheckCommand.Diag> diagnostics,
bool tier2Enabled,
RuleRegistry? rules)
{
if (diagnostics.Count == 0) return false;
foreach (var diag in diagnostics)
{
if (tier2Enabled && SupportedCodes.Contains(diag.Code)) return true;
if (rules is not null)
{
foreach (var rule in rules.All)
{
// Empty DiagnosticCodes means "applies to every code" —
// unusual but allowed by the IRulePattern contract.
if (rule.DiagnosticCodes.Count == 0) return true;
foreach (var c in rule.DiagnosticCodes)
if (string.Equals(c, diag.Code, StringComparison.Ordinal))
return true;
}
}
}
return false;
}
readonly CompilationLoader _loader;
readonly ISuggester[] _suggesters;
readonly RuleRegistry? _rules;
readonly ISet<string>? _disabledRules;
readonly Action<string, string>? _onRuleSelfDisabled;
readonly bool _tier2Enabled;
public SuggesterOrchestrator(
CompilationLoader? loader = null,
ISuggester[]? suggesters = null,
RuleRegistry? rules = null,
ISet<string>? disabledRules = null,
Action<string, string>? onRuleSelfDisabled = null,
bool tier2Enabled = true)
{
_loader = loader ?? CompilationLoader.Instance;
_suggesters = suggesters ?? new ISuggester[] { new SymbolSuggester() };
_rules = rules;
_disabledRules = disabledRules;
_onRuleSelfDisabled = onRuleSelfDisabled;
// Tier-3 rules always run when they cover a diagnostic code — they
// bind via Roslyn symbols, not fuzzy text match, so the suggest-gate
// (which guards low-precision Tier-2 fuzzy match on small builds) does
// not apply to them. Spec 038 EC2 watch-item: "Phase-3 rules are the
// right lever — not Phase-2.x gate tuning." Without this carve-out,
// a kanban-shape build with 1–2 CS diagnostics never runs any rule,
// which silently nullifies the entire rule registry on iteration-mode
// workflows.
_tier2Enabled = tier2Enabled;
}
/// <summary>
/// Returns the highest-confidence suggestion attached to <paramref name="diag"/>,
/// or null if no suggester produced one above its threshold.
/// </summary>
public Suggestion? Suggest(CheckCommand.Diag diag, string projectPath)
{
var tier2Applies = _tier2Enabled && SupportedCodes.Contains(diag.Code);
var rulesApply = RulesCoverCode(diag.Code);
if (!tier2Applies && !rulesApply) return null;
CSharpCompilation compilation;
try { compilation = _loader.Load(projectPath); }
catch { return null; }
if (ReferenceEquals(compilation, CompilationLoader.EmptyCompilation)) return null;
return SuggestAgainst(diag, compilation);
}
/// <summary>
/// Test seam: same suggestion path but with a caller-provided compilation.
/// Lets unit tests drive the orchestrator without a live project on disk.
/// </summary>
internal Suggestion? SuggestAgainst(CheckCommand.Diag diag, CSharpCompilation compilation)
{
var tier2Applies = _tier2Enabled && SupportedCodes.Contains(diag.Code);
var rulesApply = RulesCoverCode(diag.Code);
if (!tier2Applies && !rulesApply) return null;
// Find the syntax tree that matches the diagnostic's file.
var tree = FindTreeFor(compilation, diag.File);
if (tree is null) return null;
var span = ResolveSpan(tree, diag.Line, diag.Col);
if (span is null) return null;
var root = tree.GetRoot();
var node = PickRelevantNode(root, span.Value);
if (node is null) return null;
var sm = compilation.GetSemanticModel(tree);
var receiver = ResolveReceiver(sm, node);
var rosDiag = SyntheticDiagnostic(diag);
Suggestion? tier2Best = null;
if (tier2Applies && IsReactorTouching(diag.Code, receiver))
{
var factories = FactoryIndex.Build(compilation);
var ctx = new SuggesterContext(compilation, rosDiag, node, receiver, factories);
// ISuggester.Suggest applies the per-code emit threshold from
// Thresholds.For(code) internally; a non-silent result here has
// already cleared the gate.
foreach (var s in _suggesters)
{
SuggestionResult r;
try { r = s.Suggest(ctx); }
catch { continue; }
if (!r.HasSuggestion) continue;
if (tier2Best is null || r.Confidence > tier2Best.Confidence)
tier2Best = new Suggestion(r.Text!, r.Confidence, r.Evidence, s.Name);
}
}
if (rulesApply && _rules is not null)
{
var resolver = RuleSymbolResolver.For(compilation);
var ruleCtx = new RuleContext(node, rosDiag, receiver, sm, compilation, resolver);
var hit = _rules.BestMatch(in ruleCtx, _disabledRules, _onRuleSelfDisabled);
if (hit is not null)
{
// Spec 038 §6: rule wins over Tier-2 fuzzy match. The
// suggestion's Evidence is the rule's evidence string suffixed
// with the provenance tag so a maintainer can grep
// `cluster:C0019` back to its motivating data.
var rule = hit.Value.Rule;
var s = hit.Value.Suggestion;
var evidence = string.IsNullOrEmpty(s.Evidence)
? rule.Provenance
: $"{s.Evidence} ({rule.Provenance})";
return new Suggestion(s.Text!, s.Confidence, evidence, rule.Name, IsRule: true);
}
}
return tier2Best;
}
bool RulesCoverCode(string code)
{
if (_rules is null) return false;
foreach (var rule in _rules.All)
{
if (rule.DiagnosticCodes.Count == 0) return true;
foreach (var c in rule.DiagnosticCodes)
if (string.Equals(c, code, StringComparison.Ordinal)) return true;
}
return false;
}
static SyntaxTree? FindTreeFor(CSharpCompilation c, string file)
{
if (string.IsNullOrEmpty(file)) return null;
// Diagnostic file may be relative or absolute; try the obvious match first.
// For the fallback suffix match, require a path-separator boundary so a
// diagnostic on "Program.cs" doesn't accidentally bind to "MyProgram.cs".
// If the diagnostic carries only a bare filename (no separators), we
// fall back to GetFileName equality so cross-platform separators work.
bool hasSeparator = file.Contains('/') || file.Contains('\\');
string fileName = hasSeparator ? string.Empty : file;
SyntaxTree? exact = null;
SyntaxTree? suffix = null;
foreach (var t in c.SyntaxTrees)
{
if (string.Equals(t.FilePath, file, StringComparison.OrdinalIgnoreCase))
{
exact = t;
break;
}
if (hasSeparator)
{
// Both possible separators — a Windows-built CSharpCompilation
// can host trees with either, depending on how the project
// file specified them.
if (t.FilePath.EndsWith('/' + file, StringComparison.OrdinalIgnoreCase) ||
t.FilePath.EndsWith('\\' + file, StringComparison.OrdinalIgnoreCase))
{
suffix = t;
}
}
else if (string.Equals(Path.GetFileName(t.FilePath), fileName, StringComparison.OrdinalIgnoreCase))
{
suffix = t;
}
}
return exact ?? suffix;
}
static TextSpan? ResolveSpan(SyntaxTree tree, int line1, int col1)
{
// MSBuild emits 1-based; Roslyn uses 0-based linePosition.
try
{
var text = tree.GetText();
if (line1 < 1 || line1 > text.Lines.Count) return null;
var lineSpan = text.Lines[line1 - 1];
var col0 = Math.Max(0, col1 - 1);
var pos = lineSpan.Start + Math.Min(col0, lineSpan.End - lineSpan.Start);
return new TextSpan(pos, 0);
}
catch { return null; }
}
internal static SyntaxNode? PickRelevantNode(SyntaxNode root, TextSpan span)
{
SyntaxNode? node;
try { node = root.FindNode(span, getInnermostNodeForTie: true); }
catch { return null; }
if (node is null) return null;
// Walk upwards to one of the shapes our suggester knows how to read.
for (var n = node; n is not null; n = n.Parent)
{
if (n is MemberAccessExpressionSyntax or InvocationExpressionSyntax or IdentifierNameSyntax or ArgumentSyntax)
return n;
}
return node;
}
internal static ITypeSymbol? ResolveReceiver(SemanticModel sm, SyntaxNode node)
{
if (node is MemberAccessExpressionSyntax m)
return sm.GetTypeInfo(m.Expression).Type;
if (node.Parent is MemberAccessExpressionSyntax mp)
return sm.GetTypeInfo(mp.Expression).Type;
return null;
}
internal static bool IsReactorTouching(string code, ITypeSymbol? receiver)
{
// CS0103 / CS1503 / CS7036 self-filter inside the suggester (they probe
// the FactoryIndex or message text); we always let them through.
if (code is "CS0103" or "CS1503" or "CS7036") return true;
// CS1061 / CS0117 require a Reactor-namespaced receiver.
if (receiver is null) return false;
return SymbolSuggester.IsReactorType(receiver);
}
static Diagnostic SyntheticDiagnostic(CheckCommand.Diag diag)
{
var sev = diag.Severity switch
{
"error" => DiagnosticSeverity.Error,
"warning" => DiagnosticSeverity.Warning,
_ => DiagnosticSeverity.Info,
};
var descriptor = new DiagnosticDescriptor(
id: diag.Code,
title: diag.Code,
messageFormat: "{0}",
category: "compiler",
defaultSeverity: sev,
isEnabledByDefault: true);
return Diagnostic.Create(descriptor, Location.None, diag.Message);
}
}