-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathCheckCommand.cs
More file actions
416 lines (389 loc) · 19.5 KB
/
CheckCommand.cs
File metadata and controls
416 lines (389 loc) · 19.5 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
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
// `mur check <path>` — fast-feedback wrapper around `dotnet build`.
//
// Goal: give an agent a small, structured error stream instead of a 500-line
// MSBuild dump. For each diagnostic we emit one line:
//
// path:line:col RXNNN short message → hint
//
// where `hint` is a skill-file pointer for known Reactor analyzer IDs (so the
// agent can read 5 lines of guidance instead of grepping the codebase).
//
// `<path>` defaults to `.` and accepts a .csproj or a directory containing
// one. (`dotnet build` does not accept a bare .cs file as a target, so we
// don't either; CompilationLoader's per-file walk-up is for tooling/test
// seams only.)
using System.Diagnostics;
using System.Text.RegularExpressions;
using Microsoft.UI.Reactor.Cli.Check.Rules;
namespace Microsoft.UI.Reactor.Cli.Check;
public static class CheckCommand
{
// Spec 038 §11 risk row + §14 #8 — diagnostic-count gate.
//
// EC1 5×N (2026-05-10) showed Tier-2 setup overhead (~5–8s per `mur check`)
// does not amortize on ~150-LoC projects: calc regressed +21% cost while
// kanban won −24%. The gate suppresses Tier-2 suggestions when the
// invocation surfaces fewer than this many CS-prefixed diagnostics — a
// proxy for "this is a small/simple build the agent can resolve unaided."
//
// Conservative initial value picked against the EC1 data with a small
// observational sample of failing builds per arm. Revisit at Data
// Checkpoint C (≥ 500 pairs) when the full diagnostic-count distribution
// by project size is known. Override via `--suggest-threshold <N>`; 0 = no
// gate.
internal const int DefaultSuggestThreshold = 3;
public static int Run(string[] args)
{
if (args.Any(a => a == "--help" || a == "-h"))
{
Console.Write(CheckArgs.HelpText);
return 0;
}
if (!ArgsParser.TryParse(args, out var parsed, out var error))
{
Console.Error.WriteLine($"mur check: {error}");
return 2;
}
if (parsed.ListRules)
{
// --list-rules short-circuits before `dotnet build`: this is a
// pure introspection of the registered ruleset. We attempt to
// resolve targets against the compilation if the path resolves
// to a buildable project, but fall through to a no-compilation
// listing when it doesn't — `mur check --list-rules` works in any
// directory.
PrintRuleList(parsed);
return 0;
}
var path = parsed.Path;
if (!File.Exists(path) && !Directory.Exists(path))
{
Console.Error.WriteLine($"mur check: '{path}' not found.");
return 1;
}
// --disable-rule references that don't match any registered rule are
// surfaced as warnings (not errors) so a typo doesn't fail a build,
// but the agent / human sees the miss and can correct it.
WarnOnUnknownDisabledRules(parsed.DisabledRules);
// EffectiveBuildArgs already has default-merging applied — `--nologo`,
// `-v:m`, and `-p:Platform={host arch}` are injected by ArgsParser
// only if the user didn't supply the same flag in passthrough. See
// spec 038 §8.
var psi = new ProcessStartInfo("dotnet")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var arg in parsed.EffectiveBuildArgs) psi.ArgumentList.Add(arg);
using var proc = Process.Start(psi)!;
// Drain both pipes concurrently — `dotnet build` can write enough to
// either stream to fill its pipe buffer, so reading them sequentially
// (stdout to end, then stderr) deadlocks when the unread one fills up.
var stdOutTask = proc.StandardOutput.ReadToEndAsync();
var stdErrTask = proc.StandardError.ReadToEndAsync();
Task.WaitAll(stdOutTask, stdErrTask);
proc.WaitForExit();
var combined = stdOutTask.Result + "\n" + stdErrTask.Result;
var projectRoot = ResolveProjectRoot(path);
TraceWriter? trace = null;
try
{
if (parsed.TracePath is not null)
{
var modeTag = parsed.Mode.ToString().ToLowerInvariant();
trace = TraceWriter.Open(parsed.TracePath, projectRoot, modeTag);
// Spec 038 §8 "Tracing": write the effective dotnet build
// command line first so replays can reproduce the invocation
// even if default-merging changes between mur versions.
trace.WriteCommand(parsed.EffectiveBuildArgs, modeTag);
}
var diagnostics = ParseDiagnostics(combined);
// Spec 038 §8 — pre-emit ranker. Drop diagnostics that score
// below the active threshold for the current mode (iteration:
// 0.6, final: 0.0, etc., overridable via --emit-threshold). The
// filter wraps stdout emission only; trace output is unaffected
// — every parsed diagnostic is recorded so replays / mining /
// suppressed-then-resurfaced telemetry (spec §8 "failure modes")
// can see what was suppressed.
var rankerCtx = new Ranker.RankerContext(parsed.Mode, parsed.EmitThreshold);
Func<Diag, bool> stdoutFilter = d => Ranker.Ranker.ShouldEmit(d, rankerCtx);
// Suggest-gate counts the FULL parsed list — not the post-ranker
// emittable list. The gate's question (per spec §14 #8) is "is
// this build complex enough to benefit from Tier-2 help"; that's
// a property of what the compiler emitted, not of what stdout
// shows. Counting against `emittable` over-suppresses: a build
// surfacing 2 CS errors + 3 CS8602 nullable warnings has 5
// unique CS codes — Tier-2 territory — but the ranker filters
// CS8602 out of emittable, dropping the count to 2 and closing
// the gate. EC2 (n=3) measured exactly this: kanban-variant
// Tier-2 firing went from 80% under EC1 to 0% under the bugged
// gate, costing the agent ~4 turns of manual name resolution.
// The suggest-gate is Tier-2-only. Spec 038 EC2 watch-item:
// "Phase-3 rules are the right lever — not Phase-2.x gate
// tuning." Tier-2 fuzzy match has near-0% precision on CS1061
// against Reactor's *Element receivers at small build sizes
// (525-run calibration), so gating it on diagnostic count
// remains correct. Tier-3 rules bind via Roslyn symbols (the
// §3.1a contract), so they should always run when a covered
// diagnostic surfaces — even on a 1-diagnostic build where the
// Tier-2 gate is closed. We always build the orchestrator now
// (load the compilation, wire trace, etc.) and pass the gate
// result in as `tier2Enabled`.
var effectiveThreshold = parsed.SuggestThreshold ?? DefaultSuggestThreshold;
var tier2Enabled = ShouldEmitSuggestions(diagnostics, effectiveThreshold);
Func<Diag, Suggestion?>? suggest = null;
// Pre-check: skip the compilation load entirely on clean builds
// and on builds where no parsed diagnostic could plausibly produce
// a suggestion (Tier-2-applicable + tier2Enabled, or covered by
// some rule). The compilation load — `.cs` enumeration, file-set
// hash, reference resolution including the ProjectReference walk
// — is 50–500 ms cold on a typical Reactor app; paying it on
// every clean `mur check` is wall-time we can give back. The
// pre-check itself is a flat scan over the (small) diagnostic
// list against the union of Tier-2's SupportedCodes and every
// rule's DiagnosticCodes — O(diagnostics × rules), microseconds.
Microsoft.CodeAnalysis.CSharp.CSharpCompilation? compilation = null;
if (SuggesterOrchestrator.AnyDiagnosticIsSuggestable(diagnostics, tier2Enabled, RuleRegistry.Default))
{
// Load the CSharpCompilation once for the whole invocation so
// CompilationLoader.Load — which re-enumerates `.cs` files
// and recomputes the file-set hash on every call — runs O(1)
// per mur check, not O(diagnostics). Cache-hit on the second
// invocation still benefits.
try { compilation = CompilationLoader.Instance.Load(path); }
catch { /* loader is best-effort; fall through to no-suggest */ }
}
if (compilation is not null && !ReferenceEquals(compilation, CompilationLoader.EmptyCompilation))
{
var disabled = ToDisabledSet(parsed.DisabledRules);
// Self-disabled trace hook (spec 038 §3.1a residual).
// Dedup per-invocation: the registry calls back on every
// BestMatch invocation a rule's targets fail to resolve,
// but we only want one row per rule per `mur check` run.
// No trace open = no callback wired; stdout stays clean.
Action<string, string>? onRuleSelfDisabled = null;
if (trace is not null)
{
var traceRef = trace;
var reported = new HashSet<string>(StringComparer.Ordinal);
onRuleSelfDisabled = (name, target) =>
{
if (reported.Add(name))
traceRef.WriteRuleSelfDisabled(name, target);
};
}
var orchestrator = new SuggesterOrchestrator(
rules: RuleRegistry.Default,
disabledRules: disabled,
onRuleSelfDisabled: onRuleSelfDisabled,
tier2Enabled: tier2Enabled);
suggest = diag => orchestrator.SuggestAgainst(diag, compilation);
}
EmitDiagnostics(diagnostics, Console.Out, trace, suggest, stdoutFilter);
if (diagnostics.Count == 0 && proc.ExitCode == 0)
Console.WriteLine("ok");
}
finally
{
trace?.Dispose();
}
return proc.ExitCode;
}
/// <summary>
/// Spec 038 §11 / §14 #8: gate Tier-2 suggestions by per-invocation
/// CS-prefixed diagnostic count. Returns true if suggestions should run,
/// false to skip the suggester for this invocation. Threshold 0 disables
/// the gate. Counts unique (file, line, col, code) tuples — same dedup
/// rule EmitDiagnostics applies — so MSBuild's per-project repeats don't
/// inflate the count.
/// </summary>
internal static bool ShouldEmitSuggestions(IReadOnlyList<Diag> diagnostics, int threshold)
{
if (threshold <= 0) return true;
var seen = new HashSet<string>(StringComparer.Ordinal);
var count = 0;
foreach (var d in diagnostics)
{
if (!d.Code.StartsWith("CS", StringComparison.Ordinal)) continue;
var key = $"{d.File}:{d.Line}:{d.Col}:{d.Code}";
if (!seen.Add(key)) continue;
count++;
if (count >= threshold) return true;
}
return false;
}
internal static List<Diag> ParseDiagnostics(string combinedOutput)
{
var lines = combinedOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var diagnostics = new List<Diag>();
foreach (var raw in lines)
{
var d = Diag.Parse(raw);
if (d is not null) diagnostics.Add(d);
}
return diagnostics;
}
internal static void EmitDiagnostics(IReadOnlyList<Diag> diagnostics, TextWriter stdout, TraceWriter? trace, Func<Diag, Suggestion?>? suggest = null, Func<Diag, bool>? stdoutFilter = null)
{
// Dedupe — MSBuild often prints the same diagnostic twice (per project).
// The dedup pass is shared between stdout and trace, so the trace
// never carries a duplicate row even when the ranker suppresses one
// copy of a duplicate pair (the second copy hits the seen-set first).
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var d in diagnostics)
{
var key = $"{d.File}:{d.Line}:{d.Col}:{d.Code}";
if (!seen.Add(key)) continue;
// Trace gets every unique parsed diagnostic, regardless of the
// ranker (spec §0.3 + §8 "Failure modes the ranker must not
// introduce" — suppressed-but-real diagnostics must be mineable).
trace?.Write(d);
if (stdoutFilter is not null && !stdoutFilter(d)) continue;
var suggestion = suggest?.Invoke(d);
stdout.WriteLine(d.Format(suggestion));
if (suggestion is not null)
{
Telemetry.OnSuggestionEmitted(d.Code, suggestion);
// Spec 038 EC3-final watch-item: structured rule-fire events
// make per-rule firing-rate audits a 1-line grep over the
// trace file. Only emitted for Tier-3 rule hits; Tier-2 fires
// are visible via the opt-in telemetry channel instead.
if (suggestion.IsRule)
{
trace?.WriteRuleFired(
ruleName: suggestion.SuggesterName,
code: d.Code,
confidence: suggestion.Confidence,
evidence: suggestion.Evidence,
file: d.File,
line: d.Line);
}
}
}
}
static ISet<string>? ToDisabledSet(IReadOnlyList<string> disabledRules)
{
if (disabledRules.Count == 0) return null;
return new HashSet<string>(disabledRules, StringComparer.Ordinal);
}
static void WarnOnUnknownDisabledRules(IReadOnlyList<string> disabledRules)
{
if (disabledRules.Count == 0) return;
var registry = RuleRegistry.Default;
foreach (var name in disabledRules)
{
if (!registry.TryGet(name, out _))
Console.Error.WriteLine($"mur check: --disable-rule '{name}' does not match any registered rule (use --list-rules to see available rules).");
}
}
static void PrintRuleList(CheckArgs parsed)
{
var registry = RuleRegistry.Default;
// If the path resolves to a buildable project, attempt target
// resolution so the listing distinguishes Enabled from
// SelfDisabled-due-to-unresolved-target. Otherwise we list the
// registered rules with no resolution status — better than nothing.
Microsoft.CodeAnalysis.CSharp.CSharpCompilation? compilation = null;
try { compilation = CompilationLoader.Instance.Load(parsed.Path); }
catch { /* loader best-effort; render without resolution data. */ }
if (compilation is not null && ReferenceEquals(compilation, CompilationLoader.EmptyCompilation))
compilation = null;
var disabled = ToDisabledSet(parsed.DisabledRules);
var statuses = registry.Statuses(compilation, disabled);
if (statuses.Count == 0)
{
Console.WriteLine("(no rules registered)");
return;
}
// Column widths picked to fit the longest registered Name / Provenance
// with a one-char minimum padding. Recomputed per invocation so adding
// a long rule name doesn't break alignment.
var nameWidth = Math.Max(4, statuses.Max(s => s.Name.Length));
var provWidth = Math.Max(10, statuses.Max(s => s.Provenance.Length));
Console.WriteLine($"{"Name".PadRight(nameWidth)} {"Provenance".PadRight(provWidth)} Status");
Console.WriteLine($"{new string('-', nameWidth)} {new string('-', provWidth)} ------");
foreach (var s in statuses)
{
var status = s.State switch
{
RuleState.Enabled => "enabled",
RuleState.UserDisabled => "disabled (--disable-rule)",
RuleState.SelfDisabled => $"self-disabled (unresolved: {s.UnresolvedTarget})",
_ => "?",
};
Console.WriteLine($"{s.Name.PadRight(nameWidth)} {s.Provenance.PadRight(provWidth)} {status}");
}
}
static string ResolveProjectRoot(string path)
{
try
{
var full = Path.GetFullPath(path);
if (File.Exists(full))
return Path.GetDirectoryName(full) ?? full;
return full;
}
catch
{
return Path.GetFullPath(".");
}
}
internal sealed record Diag(string File, int Line, int Col, string Severity, string Code, string Message)
{
// MSBuild diagnostic line:
// path(line,col): error|warning CODE: message [project]
// File capture is reluctant so paths containing parentheses
// (e.g. `C:\src\Reactor (test)\Program.cs`) still parse — anchor
// is the (line,col): suffix immediately preceding the severity.
static readonly Regex Pattern = new(
@"^(?<file>.+?)\((?<line>\d+),(?<col>\d+)\):\s*(?<sev>error|warning|info)\s+(?<code>[A-Z][A-Z0-9_]*\d):\s*(?<msg>.+?)(?:\s*\[[^\]]+\])?\s*$",
RegexOptions.Compiled);
public static Diag? Parse(string raw)
{
var m = Pattern.Match(raw.Trim());
if (!m.Success) return null;
return new Diag(
m.Groups["file"].Value.Trim(),
int.Parse(m.Groups["line"].Value),
int.Parse(m.Groups["col"].Value),
m.Groups["sev"].Value,
m.Groups["code"].Value,
m.Groups["msg"].Value.Trim());
}
public string Format(Suggestion? suggestion = null)
{
var hint = HintFor(Code);
var msg = Message.Length > 100 ? Message[..97] + "..." : Message;
string suffix;
if (hint is not null)
{
// Tier 1 (analyzer-ID hint table) wins ties (spec §9).
suffix = " → " + hint;
}
else if (suggestion is not null)
{
suffix = $" → try: {suggestion.Text} // [{suggestion.Evidence}]";
}
else
{
suffix = "";
}
return $"{File}:{Line}:{Col} {Severity[..1].ToUpperInvariant()} {Code} {msg}{suffix}";
}
// Known Reactor analyzer IDs → skill-file pointer. Add entries as new
// analyzers ship. Unknown codes (CS*, NU*, IDE*) get no hint.
static string? HintFor(string code) => code switch
{
"REACTOR_HOOKS_001" => "SKILL.md §Hooks (call hooks unconditionally)",
"REACTOR_HOOKS_004" => "SKILL.md §Hooks (memoize deps; never freshly allocated)",
"REACTOR_HOOKS_005" => "SKILL.md §Hooks (only from Render or a Use* method)",
"REACTOR_HOOKS_006" => "skills/async.md §1 (UseResource is reads-only — use UseMutation)",
"REACTOR_THEME_001" => "skills/design.md §1 (use Theme tokens, not hex)",
"REACTOR_THEME_002" => "skills/design.md §1 (use Theme tokens, not hex)",
"REACTOR_A11Y_001" => "skills/design.md §a11y (set AutomationName on icon-only controls)",
"REACTOR_DSL_001" => "SKILL.md gotcha #6 (.WithKey on dynamic list items)",
_ => null,
};
}
}