Skip to content

Commit 80381b0

Browse files
Spec 038 — rule_fired trace event for Tier-3 rule fires (#251)
* Spec 038 — rule_fired trace event for Tier-3 rule fires Closes the EC3-final watch-item: per-rule firing-rate audits are now a 1-line `jq '.kind=="rule_fired"'` over the trace file instead of a multi-step content scan against `events.jsonl` agent tool outputs. - `Suggestion.IsRule` (default false) discriminates Tier-3 from Tier-2. - `TraceWriter.WriteRuleFired` emits `{ts, kind, rule, code, confidence, evidence, file, line, mode}` with the same 1024-char evidence truncation + path sanitization as diag rows. - `CheckCommand.EmitDiagnostics` writes the event whenever a rule-flagged suggestion attaches; Tier-2 hits stay off the trace (their firing rate is visible via the opt-in MUR_TELEMETRY channel). - 5 new unit tests: schema, evidence-truncation, path-sanitization, end-to-end pipeline emit-on-rule + no-emit-on-tier2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address Copilot CR feedback on #251 - Dispose JsonDocument instances explicitly in the new pipeline test; detach the rule_fired row via JsonElement.Clone() so assertions outlive the parsed doc without leaking pooled buffers. - Fix the jq one-liner example: `'.kind=="rule_fired"'` evaluates the expression on every row (outputting a boolean), not filters to matching rows. Correct form is `'select(.kind=="rule_fired")'`. Fixed in both the spec/tasks watch-item entry and the TraceWriter test comment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9e0b012 commit 80381b0

8 files changed

Lines changed: 249 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,15 @@ to land under these conventions; subsequent specs follow this shape.
297297
(Border / Image / ScrollView) with a back-reference to the Controls section
298298
— the prior parenthetical Button carve-out was easy to miss mid-build.
299299
(spec 038 §3 — agent-facing skill updates)
300+
- Spec 038 EC3-final watch-item: `rule_fired` trace event. When a Tier-3
301+
rule attaches a suggestion to a diagnostic, `mur check --trace` now writes
302+
one structured row per fire:
303+
`{kind: "rule_fired", rule, code, confidence, evidence, file, line, mode}`.
304+
Per-rule firing-rate audits collapse from multi-step content scans against
305+
`events.jsonl` agent tool outputs to a 1-line `jq` over the trace file.
306+
Tier-2 suggestions deliberately do not emit this row — Tier-2 firing rates
307+
are visible via the opt-in `MUR_TELEMETRY=1` channel. (spec 038 §0.3,
308+
EC3-final watch-item)
300309
- Spec 038 §3.1a residual: trace-channel structured warning hook for
301310
self-disabled rules. `TraceWriter.WriteRuleSelfDisabled(rule, target)`
302311
emits `{kind: "rule_self_disabled", rule, unresolved_target, mode}`.

docs/reference/mur-check-did-you-mean.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ CheckCommand.Run (CheckCommand.cs)
375375
│ ├─ for each rule: TargetsResolve (else self-disable callback)
376376
│ └─ TryMatch; pick highest confidence
377377
│ RULE WINS over tier2Best (spec §6)
378-
└─ trace.Write (optional, --trace; records all parsed diagnostics + rule self-disables)
378+
└─ trace.Write (optional, --trace; records all parsed diagnostics + rule self-disables + rule fires)
379379
```
380380

381381
The `Suggestion` (highest-confidence above threshold, rule preferred over Tier-2) is attached to the `Diag` for line formatting in `Diag.Format`.
@@ -456,7 +456,11 @@ Phases 0–2 are merged to `main`. Phase 3 is in flight on `eval/spec-038-ec3-20
456456

457457
### Phase 0 — instrumentation (merged)
458458

459-
- `--trace <path>` flag on `mur check`. Writes a JSONL stream of every parsed diagnostic, one row per diagnostic. Schema: `{ts, code, severity, file, line, col, msg, receiver_type?, member?, mode}`. Trace is opt-in, never written by default, never includes source code text, never includes absolute paths outside the project root.
459+
- `--trace <path>` flag on `mur check`. Writes a JSONL stream, one row per parsed diagnostic (plus auxiliary structured events). Trace is opt-in, never written by default, never includes source code text, never includes absolute paths outside the project root. Row kinds:
460+
- **diagnostic row** (default, no `kind` field): `{ts, code, severity, file, line, col, msg, receiver_type?, member?, mode}`.
461+
- **command header** (`kind: "command"`): `{ts, kind, argv, mode}` — full effective `dotnet build` argv at the head of the trace.
462+
- **rule self-disabled** (`kind: "rule_self_disabled"`): `{ts, kind, rule, unresolved_target, mode}` — emitted when a Tier-3 rule's declared target fails to resolve against the live compilation. Dedup'd per-invocation per-rule.
463+
- **rule fired** (`kind: "rule_fired"`): `{ts, kind, rule, code, confidence, evidence, file, line, mode}` — emitted whenever a Tier-3 rule attaches a suggestion to a diagnostic. Tier-2 hits do not emit this row; their firing rate is visible via the opt-in `MUR_TELEMETRY=1` channel.
460464
- Folder structure: `src/Reactor.Cli/Check/{Suggesters,Rules}/` with README pointers; mirrored test folders.
461465
- A smoke fixture (`tests/Reactor.IntegrationTests/MurCheck/Fixtures/SmokeFixture/`) plus a smoke test that drives the end-to-end pipeline.
462466

docs/specs/tasks/038-mur-check-did-you-mean-implementation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,7 @@ Clean rerun after the EC3-original watch-items closed: template typo fix (`Micrs
545545
- **Class-A rule exercise.** Targeted-prompt batch at `C:\temp\mur-targeted-prompt-spec.md` is still the empirical question on the three new Class-A rules. Author or curate prompts that surface CS1955/`GridSize.Auto`, CS0117/`GridSize.Pixel|Pixels|Fixed`, and CS1061/CS0117 on `TextBlockElement.Style`. The clean-PASS doesn't change that this batch left the rules' token impact unmeasured.
546546
- **§11 risk-row guardrail retrofit.** Move EC3-final criterion #3 from "low-confidence audit" to "verified" by adding a post-run analysis pass that runs `mur check --final` against the run's final workspace and compares against iteration-mode suggestions. Same instrument the EC3-original results section called for; still open.
547547
- **Tier-2 SKILL.md trims.** Now empirically de-risked by EC3-final's PASS. The drag-and-drop relocation to `reactor-input` and the Context-pattern trim could push `reactor-getting-started` another ~65 lines lighter. Separate PR; no blockers.
548-
- **`rule_fired` trace event.** One-line addition to `TraceWriter.cs` + the orchestrator emission point would make per-rule firing-rate audits a one-line grep against the trace file instead of multi-step content scan against `events.jsonl` agent tool outputs. Cheap; lands before the targeted-prompt batch ideally.
548+
- **`rule_fired` trace event — LANDED 2026-05-12.** `TraceWriter.WriteRuleFired(rule, code, confidence, evidence, file, line)` emits `{kind: "rule_fired", rule, code, confidence, evidence, file, line, mode}`. `Suggestion.IsRule` (default false) discriminates Tier-3 hits from Tier-2; `CheckCommand.EmitDiagnostics` writes the trace event whenever a rule-flagged suggestion attaches. Three new unit tests in `TraceWriterTests.cs` (schema, evidence-truncation, path-sanitization) + two pipeline tests in `CheckCommandPipelineTests.cs` (writes-on-rule, no-write-on-tier2) lock the contract. Tier-2 hits stay off the trace by design — telemetry channel still owns Tier-2 rate; the trace event exists specifically to make Tier-3 firings discoverable without telemetry opt-in. Targeted-prompt batch is now a 1-line `jq 'select(.kind=="rule_fired")'` over the trace file.
549549

550550
---
551551

src/Reactor.Cli/Check/CheckCommand.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,24 @@ internal static void EmitDiagnostics(IReadOnlyList<Diag> diagnostics, TextWriter
260260
if (stdoutFilter is not null && !stdoutFilter(d)) continue;
261261
var suggestion = suggest?.Invoke(d);
262262
stdout.WriteLine(d.Format(suggestion));
263-
if (suggestion is not null) Telemetry.OnSuggestionEmitted(d.Code, suggestion);
263+
if (suggestion is not null)
264+
{
265+
Telemetry.OnSuggestionEmitted(d.Code, suggestion);
266+
// Spec 038 EC3-final watch-item: structured rule-fire events
267+
// make per-rule firing-rate audits a 1-line grep over the
268+
// trace file. Only emitted for Tier-3 rule hits; Tier-2 fires
269+
// are visible via the opt-in telemetry channel instead.
270+
if (suggestion.IsRule)
271+
{
272+
trace?.WriteRuleFired(
273+
ruleName: suggestion.SuggesterName,
274+
code: d.Code,
275+
confidence: suggestion.Confidence,
276+
evidence: suggestion.Evidence,
277+
file: d.File,
278+
line: d.Line);
279+
}
280+
}
264281
}
265282
}
266283

src/Reactor.Cli/Check/SuggesterOrchestrator.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
namespace Microsoft.UI.Reactor.Cli.Check;
2424

25-
internal sealed record Suggestion(string Text, double Confidence, string Evidence, string SuggesterName);
25+
internal sealed record Suggestion(string Text, double Confidence, string Evidence, string SuggesterName, bool IsRule = false);
2626

2727
internal sealed class SuggesterOrchestrator
2828
{
@@ -180,7 +180,7 @@ public SuggesterOrchestrator(
180180
var evidence = string.IsNullOrEmpty(s.Evidence)
181181
? rule.Provenance
182182
: $"{s.Evidence} ({rule.Provenance})";
183-
return new Suggestion(s.Text!, s.Confidence, evidence, rule.Name);
183+
return new Suggestion(s.Text!, s.Confidence, evidence, rule.Name, IsRule: true);
184184
}
185185
}
186186

src/Reactor.Cli/Check/TraceWriter.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,44 @@ public void WriteRuleSelfDisabled(string ruleName, string unresolvedTarget)
109109
writer.WriteLine(json);
110110
}
111111

112+
/// <summary>
113+
/// Spec 038 EC3-final watch-item: emit one structured row per Tier-3 rule
114+
/// fire so per-rule firing-rate audits are a 1-line grep against the trace
115+
/// JSONL instead of a multi-step content scan against agent tool outputs.
116+
/// Schema:
117+
///
118+
/// { ts, kind: "rule_fired", rule, code, confidence, evidence, file, line, mode }
119+
///
120+
/// `evidence` is truncated to <see cref="MaxMessageChars"/> to keep the row
121+
/// under the 2 KB cap the writer enforces. Tier-2 suggestions deliberately
122+
/// do not emit this row — Tier-2 firing rates are derivable from telemetry
123+
/// (~/.mur/telemetry/*.jsonl); the trace event exists specifically to make
124+
/// Tier-3 rule firings discoverable without telemetry opt-in.
125+
/// </summary>
126+
public void WriteRuleFired(
127+
string ruleName,
128+
string code,
129+
double confidence,
130+
string evidence,
131+
string file,
132+
int line)
133+
{
134+
var ev = evidence;
135+
if (ev.Length > MaxMessageChars) ev = ev[..MaxMessageChars];
136+
var row = new RuleFiredRow(
137+
ts: DateTime.UtcNow.ToString("o"),
138+
kind: "rule_fired",
139+
rule: ruleName,
140+
code: code,
141+
confidence: confidence,
142+
evidence: ev,
143+
file: SanitizePath(file, projectRoot),
144+
line: line,
145+
mode: mode);
146+
var json = JsonSerializer.Serialize(row, JsonOpts);
147+
writer.WriteLine(json);
148+
}
149+
112150
static readonly JsonSerializerOptions JsonOpts = new()
113151
{
114152
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
@@ -187,4 +225,15 @@ internal sealed record RuleSelfDisabledRow(
187225
string rule,
188226
string unresolved_target,
189227
string mode);
228+
229+
internal sealed record RuleFiredRow(
230+
string ts,
231+
string kind,
232+
string rule,
233+
string code,
234+
double confidence,
235+
string evidence,
236+
string file,
237+
int line,
238+
string mode);
190239
}

tests/Reactor.Tests/CheckCommandTests/CheckCommandPipelineTests.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,99 @@ public void Ranker_filter_suppresses_stdout_but_trace_still_records_every_row()
212212
finally { try { File.Delete(tracePath); } catch { } }
213213
}
214214

215+
[Fact]
216+
public void Emit_writes_rule_fired_trace_event_when_rule_suggestion_attached()
217+
{
218+
// Spec 038 EC3-final watch-item: rule fires must show up in the trace
219+
// as a structured `rule_fired` row so per-rule firing-rate audits are
220+
// a 1-line grep. End-to-end through EmitDiagnostics: when the suggest
221+
// callback returns a rule-flagged Suggestion, the trace file gains a
222+
// rule_fired row alongside the diagnostic row.
223+
var diags = CheckCommand.ParseDiagnostics("""
224+
X.cs(1,1): error CS1955: Non-invocable member [P.csproj]
225+
""");
226+
var stdout = new StringWriter();
227+
var tracePath = Path.Combine(Path.GetTempPath(), "mur-check-rulefire-" + Guid.NewGuid() + ".jsonl");
228+
try
229+
{
230+
using (var trace = TraceWriter.Open(tracePath, Path.GetFullPath(".")))
231+
{
232+
CheckCommand.EmitDiagnostics(diags, stdout, trace,
233+
suggest: _ => new Suggestion(
234+
Text: ".Auto (drop parens)",
235+
Confidence: 0.95,
236+
Evidence: "GridSize.Auto is a property (cluster:C0004)",
237+
SuggesterName: "GridSizeFactoryParensRule",
238+
IsRule: true));
239+
}
240+
241+
var lines = File.ReadAllLines(tracePath);
242+
Assert.Equal(2, lines.Length); // 1 diag row + 1 rule_fired row
243+
244+
// Find the rule_fired row and detach its element so the parsed
245+
// JsonDocument can be disposed inside the loop (Copilot CR: avoid
246+
// leaking pooled buffers across the assertion block).
247+
JsonElement ruleFired = default;
248+
var found = false;
249+
foreach (var line in lines)
250+
{
251+
using var doc = JsonDocument.Parse(line);
252+
if (doc.RootElement.TryGetProperty("kind", out var k) &&
253+
k.GetString() == "rule_fired")
254+
{
255+
ruleFired = doc.RootElement.Clone();
256+
found = true;
257+
break;
258+
}
259+
}
260+
Assert.True(found, "trace did not contain a rule_fired row.");
261+
Assert.Equal("GridSizeFactoryParensRule", ruleFired.GetProperty("rule").GetString());
262+
Assert.Equal("CS1955", ruleFired.GetProperty("code").GetString());
263+
Assert.Equal(0.95, ruleFired.GetProperty("confidence").GetDouble());
264+
Assert.Equal("X.cs", ruleFired.GetProperty("file").GetString());
265+
Assert.Equal(1, ruleFired.GetProperty("line").GetInt32());
266+
267+
// The stdout line still carries the → try: suffix — trace event
268+
// is in addition to, not in place of.
269+
Assert.Contains(".Auto (drop parens)", stdout.ToString());
270+
}
271+
finally { try { File.Delete(tracePath); } catch { } }
272+
}
273+
274+
[Fact]
275+
public void Emit_does_not_write_rule_fired_for_tier2_suggestions()
276+
{
277+
// Tier-2 firing rates are visible via the opt-in telemetry channel;
278+
// the trace's rule_fired row is Tier-3-only by design. A Tier-2 hit
279+
// here must leave no rule_fired marker in the trace.
280+
var diags = CheckCommand.ParseDiagnostics("""
281+
X.cs(1,1): error CS1061: missing member [P.csproj]
282+
""");
283+
var stdout = new StringWriter();
284+
var tracePath = Path.Combine(Path.GetTempPath(), "mur-check-tier2-" + Guid.NewGuid() + ".jsonl");
285+
try
286+
{
287+
using (var trace = TraceWriter.Open(tracePath, Path.GetFullPath(".")))
288+
{
289+
CheckCommand.EmitDiagnostics(diags, stdout, trace,
290+
suggest: _ => new Suggestion(
291+
Text: "Button(label, onClick: x)",
292+
Confidence: 0.91,
293+
Evidence: "factory has Action onClick parameter",
294+
SuggesterName: "SymbolSuggester")); // IsRule default = false
295+
}
296+
297+
var lines = File.ReadAllLines(tracePath);
298+
// Exactly one row — the diagnostic — and it is NOT a rule_fired row.
299+
Assert.Single(lines);
300+
using var doc = JsonDocument.Parse(lines[0]);
301+
var hasKind = doc.RootElement.TryGetProperty("kind", out var k);
302+
if (hasKind)
303+
Assert.NotEqual("rule_fired", k.GetString());
304+
}
305+
finally { try { File.Delete(tracePath); } catch { } }
306+
}
307+
215308
[Fact]
216309
public void Ranker_final_mode_emits_everything_to_stdout()
217310
{

tests/Reactor.Tests/CheckCommandTests/TraceWriterTests.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,77 @@ public void Rule_self_disabled_row_has_expected_schema()
186186
Assert.True(line.Length <= 2048, $"trace row was {line.Length} bytes — exceeds 2 KB cap.");
187187
}
188188

189+
[Fact]
190+
public void Rule_fired_row_has_expected_schema()
191+
{
192+
// Spec 038 EC3-final watch-item: per-rule firing-rate audits should
193+
// be a 1-line grep over the trace file. The row carries enough info
194+
// to identify the rule, the diagnostic it fired on, the confidence,
195+
// and the location — so a maintainer can `jq 'select(.kind=="rule_fired")'`
196+
// and reconstruct firing rates without rejoining against the diag rows.
197+
using var tmp = TempFile.Create();
198+
using (var w = TraceWriter.Open(tmp.Path, Path.GetFullPath("."), mode: "iteration"))
199+
{
200+
w.WriteRuleFired(
201+
ruleName: "GridSizeFactoryParensRule",
202+
code: "CS1955",
203+
confidence: 0.95,
204+
evidence: "GridSize.Auto is a property, not a method (cluster:C0004)",
205+
file: "Program.cs",
206+
line: 42);
207+
}
208+
209+
var line = File.ReadAllLines(tmp.Path).Single();
210+
using var doc = JsonDocument.Parse(line);
211+
Assert.Equal("rule_fired", doc.RootElement.GetProperty("kind").GetString());
212+
Assert.Equal("GridSizeFactoryParensRule", doc.RootElement.GetProperty("rule").GetString());
213+
Assert.Equal("CS1955", doc.RootElement.GetProperty("code").GetString());
214+
Assert.Equal(0.95, doc.RootElement.GetProperty("confidence").GetDouble());
215+
Assert.Contains("GridSize.Auto", doc.RootElement.GetProperty("evidence").GetString());
216+
Assert.Equal("Program.cs", doc.RootElement.GetProperty("file").GetString());
217+
Assert.Equal(42, doc.RootElement.GetProperty("line").GetInt32());
218+
Assert.Equal("iteration", doc.RootElement.GetProperty("mode").GetString());
219+
Assert.True(line.Length <= 2048, $"trace row was {line.Length} bytes — exceeds 2 KB cap.");
220+
}
221+
222+
[Fact]
223+
public void Rule_fired_evidence_is_truncated_under_2KB_cap()
224+
{
225+
// Same defensive truncation we apply to diag.msg: a runaway evidence
226+
// string should never blow the 2 KB row budget.
227+
var huge = new string('e', 8192);
228+
using var tmp = TempFile.Create();
229+
using (var w = TraceWriter.Open(tmp.Path, Path.GetFullPath(".")))
230+
w.WriteRuleFired("R", "CS1061", 0.9, huge, "a.cs", 1);
231+
232+
var line = File.ReadAllLines(tmp.Path).Single();
233+
Assert.True(line.Length <= 2048, $"trace row was {line.Length} bytes — exceeds 2 KB cap.");
234+
}
235+
236+
[Fact]
237+
public void Rule_fired_file_is_sanitized_like_diag_rows()
238+
{
239+
// Same path-leak protection that applies to diag rows: a rule fire
240+
// outside the project root must not leak the absolute file path.
241+
var root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "reactor-trace-rf-" + Guid.NewGuid()));
242+
Directory.CreateDirectory(root);
243+
try
244+
{
245+
var outside = Path.Combine(Path.GetTempPath(), "elsewhere-" + Guid.NewGuid(), "Bar.cs");
246+
using var tmp = TempFile.Create();
247+
using (var w = TraceWriter.Open(tmp.Path, root))
248+
w.WriteRuleFired("R", "CS1061", 0.9, "ev", outside, 1);
249+
250+
var line = File.ReadAllLines(tmp.Path).Single();
251+
using var doc = JsonDocument.Parse(line);
252+
Assert.Equal("<external>", doc.RootElement.GetProperty("file").GetString());
253+
}
254+
finally
255+
{
256+
try { Directory.Delete(root, recursive: true); } catch { }
257+
}
258+
}
259+
189260
sealed class TempFile : IDisposable
190261
{
191262
public string Path { get; }

0 commit comments

Comments
 (0)