diff --git a/CHANGELOG.md b/CHANGELOG.md index f60eec6..396df9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`unwritten ignore --for `** — bounded mute for a + persistently false rule. Expires after the trigger has changed `n` more + times (default 30; those are the commits that erode or re-confirm the rule), + shows as `suppressed` with the remaining budget rather than disappearing, + is overridden by `check --strict`, and lives in machine-managed + `.unwritten/ignores.json`. `--list` / `--remove` manage entries; expired + entries are pruned automatically. Deliberately CLI-only — no MCP tool, so + agents cannot mute their own warnings. Permanent ignores do not exist by + design: unbounded mutes go stale and eventually hide real omissions. + ## [0.3.0] - 2026-07-12 ### Added diff --git a/README.md b/README.md index a250e66..c5bcd86 100644 --- a/README.md +++ b/README.md @@ -101,12 +101,11 @@ The pattern is clear: below 0.6 the warnings are mostly noise, above 0.6 they qu ## Use -No install step — `dotnet tool execute` (or its alias `dnx`) downloads the tool -on first use and runs it: +No install step — `dotnet tool execute` downloads the tool on first use and +runs it (the shorter alias `dnx` works too, but examples here spell it out): ```bash dotnet tool execute Unwritten --yes -- check --staged -# dnx Unwritten --yes -- check --staged works too ``` Everything before `--` is for the tool runner; everything after it is the @@ -260,6 +259,35 @@ repos: With [Husky](https://typicode.github.io/husky/): `echo "dotnet tool execute Unwritten --yes -- check --staged" > .husky/pre-commit`. +### Muting a false rule — `unwritten ignore` + +Sometimes a high-confidence rule is simply wrong for your situation and keeps +blocking commits. Content-aware suppression already absorbs cosmetic C# and +JSON edits automatically, and `git commit --no-verify` bypasses a single +commit — but for a *persistently* false pairing, mute it: + +```bash +dotnet tool execute Unwritten --yes -- ignore docs/api.md src/openapi.json --for 30 --note "generated separately now" +dotnet tool execute Unwritten --yes -- ignore --list +dotnet tool execute Unwritten --yes -- ignore --remove docs/api.md src/openapi.json +``` + +Ignores are **bounded by design** — permanent mutes go stale and one day hide a +real omission. An ignore expires after the trigger has changed `--for` more +times (default 30): those are exactly the commits that either erode the false +rule (each trigger-alone commit lowers its confidence, so it often dies +naturally before the mute expires) or prove the coupling is real again. Muted +holes still appear as `suppressed` with their remaining budget — never silently +dropped — and `check --strict` overrides them. Ignores live in +`.unwritten/ignores.json` (machine-managed; keep your hands in `config.json`). + +There is deliberately no MCP tool for creating ignores: muting a warning is a +human judgment, not something a coding agent should do to its own findings. +The loop is still closed, though — the `check_holes`/`explain_rule` tool +descriptions and the Stop-hook feedback instruct the agent that when it judges +a rule persistently false, it must say so and point you at `unwritten ignore`, +so the decision reaches you instead of dying in the agent's transcript. + ### Configuration — `.unwritten/config.json` The first index build drops a fully commented template at diff --git a/src/Unwritten.Storage/IgnoreStore.cs b/src/Unwritten.Storage/IgnoreStore.cs new file mode 100644 index 0000000..1803ce9 --- /dev/null +++ b/src/Unwritten.Storage/IgnoreStore.cs @@ -0,0 +1,61 @@ +using System.Text.Json; + +namespace Unwritten.Storage; + +/// +/// One muted rule: the directed pair (trigger → hole) stops alerting until the +/// trigger has reached total changes. Bounded +/// by design — permanent ignores rot, and only commits that touch the trigger +/// can re-confirm or erode the rule, so those are what the expiry counts. +/// +public sealed record IgnoreEntry( + string Trigger, + string Hole, + int UntilTriggerChanges, + DateTimeOffset CreatedAt, + string? Note); + +/// +/// Loads and saves .unwritten/ignores.json. Machine-managed (via +/// unwritten ignore) and kept separate from the hand-edited config.json +/// so the tool can rewrite it freely. +/// +public static class IgnoreStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + + public static string GetPath(string repoPath) => + Path.Combine(repoPath, ".unwritten", "ignores.json"); + + public static IReadOnlyList Load(string repoPath) + { + string path = GetPath(repoPath); + if (!File.Exists(path)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(File.ReadAllText(path), JsonOptions) ?? []; + } + catch (JsonException) + { + // Corrupt mute file: fail toward alerting, never toward silence. + return []; + } + } + + public static void Save(string repoPath, IReadOnlyList entries) + { + string path = GetPath(repoPath); + string directory = Path.GetDirectoryName(path)!; + Directory.CreateDirectory(directory); + IndexStore.EnsureSelfGitignore(directory); + File.WriteAllText(path, JsonSerializer.Serialize(entries, JsonOptions) + "\n"); + } +} diff --git a/src/Unwritten/CheckCommand.cs b/src/Unwritten/CheckCommand.cs index 5146810..7a2bdf8 100644 --- a/src/Unwritten/CheckCommand.cs +++ b/src/Unwritten/CheckCommand.cs @@ -113,17 +113,27 @@ public static int Run(string[] args, IndexManager indexManager, GitTransactionSo var holes = RuleEngine.FindHoles(persisted.Index, entities, minConfidence); var annotated = HoleSuppression.Annotate(persisted.Index, gitSource, repoPath, holes, staged, baseRevision); + var ignores = IgnoreStore.Load(repoPath); + annotated = IgnoreFilter.Apply(persisted.Index, annotated, ignores); + var members = indexManager.GetMembersUpToDate(repoPath); var memberReport = MemberHoleFinder.Find(members, gitSource, repoPath, entities, staged, minConfidence, baseRevision); var active = annotated.Where(a => !a.Suppressed || strict).ToList(); var suppressed = annotated.Where(a => a.Suppressed && !strict).ToList(); - var memberHoles = memberReport?.Holes ?? []; + + IReadOnlyList memberHoles = memberReport?.Holes ?? []; + IReadOnlyList ignoredMemberHoles = []; + if (!strict && members is not null && memberHoles.Count > 0) + { + (memberHoles, ignoredMemberHoles) = IgnoreFilter.SplitMemberHoles(members.Index, memberHoles, ignores); + } if (active.Count == 0 && memberHoles.Count == 0) { output.WriteLine(Inv($"No holes at confidence >= {minConfidence:0.00} for {entities.Length} file(s).")); PrintSuppressed(output, suppressed); + PrintSuppressed(output, ignoredMemberHoles); return 0; } @@ -166,17 +176,53 @@ public static int Run(string[] args, IndexManager indexManager, GitTransactionSo } PrintSuppressed(output, suppressed); + PrintSuppressed(output, ignoredMemberHoles); - bool failing = active.Any(a => a.Hole.Confidence >= failAt) || - memberHoles.Any(h => h.Confidence >= failAt); + var failingFileHoles = active.Where(a => a.Hole.Confidence >= failAt).Select(a => a.Hole).ToList(); + var failingMemberHoles = memberHoles.Where(h => h.Confidence >= failAt).ToList(); + bool failing = failingFileHoles.Count > 0 || failingMemberHoles.Count > 0; if (failing) { output.WriteLine(Inv($"FAIL: at least one hole at confidence >= {failAt:0.00}.")); + PrintDecisionGuide(output, [.. failingFileHoles, .. failingMemberHoles]); } return failing ? 1 : 0; } + /// + /// The exact decision, spelled out per failing hole — the user (possibly via + /// an agent relaying this verbatim) should never have to construct a command. + /// + private static void PrintDecisionGuide(TextWriter output, IReadOnlyList failingHoles) + { + var holes = failingHoles.DistinctBy(h => (h.Trigger, h.Hole)).ToList(); + + output.WriteLine(); + output.WriteLine("Your decision, per hole:"); + output.WriteLine(" 1. The warning is right — you forgot this file. Update it and include it in this commit:"); + foreach (var hole in holes) + { + output.WriteLine($" {hole.Hole} (usually changes together with {hole.Trigger})"); + } + + output.WriteLine(" 2. Not needed for THIS commit, but the rule is valid — bypass once"); + output.WriteLine(" (note: skips ALL pre-commit hooks and every hole above at once):"); + output.WriteLine(" git commit --no-verify"); + output.WriteLine(" 3. The rule itself is no longer valid — mute it for the next 30 changes of the trigger:"); + foreach (var hole in holes) + { + output.WriteLine($" dotnet tool execute Unwritten --yes -- ignore {hole.Trigger} {hole.Hole} --for 30"); + } + + if (holes.Count > 1) + { + output.WriteLine(); + output.WriteLine(" Different decisions for different holes? Fix and/or mute those first,"); + output.WriteLine(" then retry the commit — only if legitimate one-time holes remain, use --no-verify."); + } + } + /// /// Says out loud when a checked file has no or too little history — an empty /// result for such a file means "no data", not "no holes". diff --git a/src/Unwritten/HookCommand.cs b/src/Unwritten/HookCommand.cs index 252af95..e2b2917 100644 --- a/src/Unwritten/HookCommand.cs +++ b/src/Unwritten/HookCommand.cs @@ -102,6 +102,7 @@ public static int Stop(IndexManager indexManager, GitTransactionSource gitSource error.WriteLine("Unwritten found likely-missing companion changes in this session's edits:"); error.WriteLine(buffer.ToString()); error.WriteLine("Fix each hole or state briefly why it does not apply here (the evidence above and the explain_rule tool can help you judge)."); + error.WriteLine("If you judge a rule to be a persistently false pattern, tell the user — they can mute it with 'unwritten ignore --for '. That decision is theirs, not yours."); return 2; } catch (Exception) diff --git a/src/Unwritten/IgnoreCommand.cs b/src/Unwritten/IgnoreCommand.cs new file mode 100644 index 0000000..52a3f50 --- /dev/null +++ b/src/Unwritten/IgnoreCommand.cs @@ -0,0 +1,222 @@ +using System.Globalization; +using Unwritten.Core; +using Unwritten.Storage; + +namespace Unwritten.Tool; + +/// +/// Applies active ignore entries to found holes: matched holes become visible +/// suppressions (never silently dropped), consistent with the content-aware +/// layers — --strict overrides them the same way. +/// +public static class IgnoreFilter +{ + /// Entries still in effect given the trigger's current change count. + public static IReadOnlyList Active(CoChangeIndex index, IReadOnlyList entries) => + [.. entries.Where(e => index.GetEntityCount(e.Trigger) < e.UntilTriggerChanges)]; + + public static IReadOnlyList Apply( + CoChangeIndex index, IReadOnlyList holes, IReadOnlyList entries) + { + var active = Active(index, entries); + if (active.Count == 0) + { + return holes; + } + + return [.. holes.Select(annotated => + FindMatch(active, annotated.Hole) is { } entry && !annotated.Suppressed + ? new AnnotatedHole( + annotated.Hole, + new SuppressionResult(Suppressed: true, ChangedFacets: []), + Reason(index, entry)) + : annotated)]; + } + + /// Splits member holes into (kept, ignored) — the member report has no annotation channel. + public static (IReadOnlyList Kept, IReadOnlyList Ignored) SplitMemberHoles( + CoChangeIndex memberIndex, IReadOnlyList holes, IReadOnlyList entries) + { + var active = Active(memberIndex, entries); + var kept = new List(); + var ignored = new List(); + foreach (var hole in holes) + { + if (FindMatch(active, hole) is { } entry) + { + ignored.Add(new AnnotatedHole( + hole, new SuppressionResult(Suppressed: true, ChangedFacets: []), Reason(memberIndex, entry))); + } + else + { + kept.Add(hole); + } + } + + return (kept, ignored); + } + + private static IgnoreEntry? FindMatch(IReadOnlyList active, HoleResult hole) => + active.FirstOrDefault(e => + string.Equals(e.Trigger, hole.Trigger, StringComparison.Ordinal) && + string.Equals(e.Hole, hole.Hole, StringComparison.Ordinal)); + + private static string Reason(CoChangeIndex index, IgnoreEntry entry) + { + int remaining = entry.UntilTriggerChanges - index.GetEntityCount(entry.Trigger); + return $"ignored via 'unwritten ignore' — expires after {remaining} more change(s) of {entry.Trigger}"; + } +} + +/// +/// unwritten ignore — bounded mute for a false rule. Deliberately CLI-only +/// (no MCP counterpart): muting a warning is a human judgment, not something an +/// agent should do to its own findings. +/// +public static class IgnoreCommand +{ + private const int DefaultForChanges = 30; + + private const string Usage = """ + Usage: + unwritten ignore [--for ] [--note ] [--repo ] + unwritten ignore --list [--repo ] + unwritten ignore --remove [--repo ] + + Mutes the rule "changing expects " until has + changed more times (default 30). Bounded on purpose: permanent + ignores go stale. Muted holes still appear as suppressed (with the + remaining budget); 'check --strict' overrides them. + """; + + public static int Run(string[] args, IndexManager indexManager, TextWriter output) + { + bool list = false; + bool remove = false; + string repoPath = Directory.GetCurrentDirectory(); + int forChanges = DefaultForChanges; + string? note = null; + var positional = new List(); + + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--list": + list = true; + break; + case "--remove": + remove = true; + break; + case "--for" when i + 1 < args.Length: + forChanges = int.Parse(args[++i], CultureInfo.InvariantCulture); + break; + case "--note" when i + 1 < args.Length: + note = args[++i]; + break; + case "--repo" when i + 1 < args.Length: + repoPath = args[++i]; + break; + case "--help" or "-h": + output.WriteLine(Usage); + return 0; + case var unknown when unknown.StartsWith('-'): + output.WriteLine($"Unknown option: {unknown}"); + return 2; + default: + positional.Add(args[i]); + break; + } + } + + repoPath = indexManager.ResolveRepoRoot(repoPath); + var index = indexManager.GetUpToDate(repoPath).Index; + var entries = IgnoreStore.Load(repoPath); + + if (list) + { + return List(output, index, entries); + } + + if (positional.Count != 2) + { + output.WriteLine(Usage); + return 2; + } + + string trigger = EntityPath.Normalize(repoPath, positional[0]); + string hole = EntityPath.Normalize(repoPath, positional[1]); + + return remove + ? Remove(output, repoPath, index, entries, trigger, hole) + : Add(output, repoPath, index, entries, trigger, hole, forChanges, note); + } + + private static int Add( + TextWriter output, string repoPath, CoChangeIndex index, IReadOnlyList entries, + string trigger, string hole, int forChanges, string? note) + { + if (forChanges < 1) + { + output.WriteLine("--for must be at least 1."); + return 2; + } + + int currentChanges = index.GetEntityCount(trigger); + if (currentChanges == 0) + { + output.WriteLine($"note: {trigger} has no history in the index — check the path; the ignore will match nothing until it does."); + } + + var pruned = Prune(index, entries); + var kept = pruned.Where(e => e.Trigger != trigger || e.Hole != hole).ToList(); + var entry = new IgnoreEntry(trigger, hole, currentChanges + forChanges, DateTimeOffset.UtcNow, note); + kept.Add(entry); + IgnoreStore.Save(repoPath, kept); + + output.WriteLine($"Ignoring {trigger} -> {hole} for the next {forChanges} change(s) of {trigger}."); + output.WriteLine("It will show as 'suppressed' with the remaining budget; 'check --strict' overrides it."); + return 0; + } + + private static int Remove( + TextWriter output, string repoPath, CoChangeIndex index, IReadOnlyList entries, + string trigger, string hole) + { + var kept = Prune(index, entries).Where(e => e.Trigger != trigger || e.Hole != hole).ToList(); + if (kept.Count == entries.Count) + { + output.WriteLine($"No ignore found for {trigger} -> {hole}."); + return 1; + } + + IgnoreStore.Save(repoPath, kept); + output.WriteLine($"Removed ignore {trigger} -> {hole}."); + return 0; + } + + private static int List(TextWriter output, CoChangeIndex index, IReadOnlyList entries) + { + if (entries.Count == 0) + { + output.WriteLine("No ignores."); + return 0; + } + + foreach (var entry in entries) + { + int remaining = entry.UntilTriggerChanges - index.GetEntityCount(entry.Trigger); + string status = remaining > 0 + ? $"{remaining} change(s) of the trigger remaining" + : "expired (inactive; removed on the next ignore command)"; + string noteSuffix = entry.Note is null ? "" : $" — {entry.Note}"; + output.WriteLine($"{entry.Trigger} -> {entry.Hole}: {status}{noteSuffix}"); + } + + return 0; + } + + /// Expired entries are dropped whenever the file is rewritten anyway. + private static List Prune(CoChangeIndex index, IReadOnlyList entries) => + [.. entries.Where(e => index.GetEntityCount(e.Trigger) < e.UntilTriggerChanges)]; +} diff --git a/src/Unwritten/Program.cs b/src/Unwritten/Program.cs index 5bb0746..08e61ca 100644 --- a/src/Unwritten/Program.cs +++ b/src/Unwritten/Program.cs @@ -23,6 +23,10 @@ unwritten install-hook [flags] Install hooks that run the check deterministically. Flags: --git (pre-commit), --claude-code (Stop hook), --repo , --force. + unwritten ignore Mute a false rule until the trigger has + changed N more times (--for , + default 30). --list / --remove manage + existing ignores. unwritten --version Print the tool version. Check options: @@ -50,6 +54,8 @@ Defaults can be overridden per repo in .unwritten/config.json. return RunCli(() => StatsCommand.Run(args[1..], indexManager, Console.Out, rebuild: true)); case "install-hook": return RunCli(() => HookCommand.Install(args[1..], indexManager, gitSource, Console.Out)); + case "ignore": + return RunCli(() => IgnoreCommand.Run(args[1..], indexManager, Console.Out)); case "hook" when args.Length >= 2 && args[1] == "stop": // Fails open by design (exit 0 on any infrastructure problem): a broken // hook must never block the agent from finishing a turn. diff --git a/src/Unwritten/UnwrittenTools.cs b/src/Unwritten/UnwrittenTools.cs index 115fbc6..4a7cb91 100644 --- a/src/Unwritten/UnwrittenTools.cs +++ b/src/Unwritten/UnwrittenTools.cs @@ -23,7 +23,7 @@ public sealed class UnwrittenTools(IndexManager indexManager, GitTransactionSour private const double MinUsefulConfidence = 0.3; [McpServerTool(Name = "check_holes")] - [Description("Given the files just changed, flags files that history says are expected to change with them but are absent. Call after editing (or before finishing a task), passing every file you touched — or omit files to auto-detect all uncommitted changes. If you have committed work during this session, pass baseRef (the commit you started from) so committed edits are still seen. Each hole comes with evidence: co-change counts and example commits. checkedFiles reports how much history each input has: for a file with no or too little history an empty result means 'no data', NOT 'all good'. Cosmetic edits (non-predictive JSON keys, comment-only C# changes) come back with suppressed=true plus the evidence. When member-level indexing is enabled, memberHoles reports absent companion METHODS/members of the members you actually changed.")] + [Description("Given the files just changed, flags files that history says are expected to change with them but are absent. Call after editing (or before finishing a task), passing every file you touched — or omit files to auto-detect all uncommitted changes. If you have committed work during this session, pass baseRef (the commit you started from) so committed edits are still seen. Each hole comes with evidence: co-change counts and example commits. checkedFiles reports how much history each input has: for a file with no or too little history an empty result means 'no data', NOT 'all good'. Cosmetic edits (non-predictive JSON keys, comment-only C# changes) come back with suppressed=true plus the evidence. When member-level indexing is enabled, memberHoles reports absent companion METHODS/members of the members you actually changed. If after reviewing the evidence (explain_rule helps) you judge a hole to be a persistently false pattern, DO NOT just dismiss it silently: tell the user and mention that 'unwritten ignore --for ' can mute it for a bounded number of trigger changes — only the human can make that call; there is deliberately no MCP tool for it.")] public string CheckHoles( [Description("Absolute path to the git repository (any path inside it works).")] string repoPath, [Description("Repo-relative paths of the files changed in this edit. Omit or leave empty to auto-detect every uncommitted change (or every change since baseRef).")] string[]? files = null, @@ -65,9 +65,19 @@ baseRef is null var holes = RuleEngine.FindHoles(persisted.Index, entities, floor); var annotated = HoleSuppression.Annotate(persisted.Index, gitSource, repoPath, holes, staged: false, baseRevision); + var ignores = IgnoreStore.Load(repoPath); + annotated = IgnoreFilter.Apply(persisted.Index, annotated, ignores); + var members = indexManager.GetMembersUpToDate(repoPath); var memberReport = MemberHoleFinder.Find(members, gitSource, repoPath, entities, staged: false, floor, baseRevision); + IReadOnlyList memberHoles = memberReport?.Holes ?? []; + IReadOnlyList ignoredMemberHoles = []; + if (members is not null && memberHoles.Count > 0) + { + (memberHoles, ignoredMemberHoles) = IgnoreFilter.SplitMemberHoles(members.Index, memberHoles, ignores); + } + int minSupport = persisted.Index.Config.MinSupport; var checkedFiles = entities.Select(e => { @@ -87,19 +97,26 @@ baseRef is null notes.Add($"{thin} checked file(s) have fewer than {minSupport} historical changes — too little history for rules to exist."); } + object MemberDto(HoleResult h, string? suppressReason) => new + { + h.Hole, + holeLocation = members!.Index.GetEntityLocation(h.Hole), + h.Trigger, + h.Confidence, + h.CoChanges, + h.TotalChanges, + exampleCommits = h.ExampleTransactions.Select(e => new { sha = e.Id, subject = e.Label }), + suppressed = suppressReason is null ? (bool?)null : true, + suppressReason, + }; + return Serialize(new { holes = annotated.Select(ToHoleDto), - memberHoles = memberReport?.Holes.Select(h => new - { - h.Hole, - holeLocation = members!.Index.GetEntityLocation(h.Hole), - h.Trigger, - h.Confidence, - h.CoChanges, - h.TotalChanges, - exampleCommits = h.ExampleTransactions.Select(e => new { sha = e.Id, subject = e.Label }), - }), + memberHoles = memberReport is null + ? null + : memberHoles.Select(h => MemberDto(h, null)) + .Concat(ignoredMemberHoles.Select(a => MemberDto(a.Hole, a.Reason))), changedMembers = memberReport?.ChangedMembers, checkedFiles, minConfidence = floor, @@ -181,7 +198,7 @@ public string ExpectedCompanions( } [McpServerTool(Name = "explain_rule")] - [Description("Full evidence for the co-change rule between two files: counts, confidence in both directions, historical commits where they changed together, and recent commits where fileA changed alone (the exceptions).")] + [Description("Full evidence for the co-change rule between two files: counts, confidence in both directions, historical commits where they changed together, and recent commits where fileA changed alone (the exceptions). Use this to judge whether a flagged hole is a legitimate exception. If the evidence convinces you the rule is persistently false (e.g. the historical reason for the coupling no longer exists), report that conclusion to the user and mention 'unwritten ignore --for ' — muting is the user's decision, not yours.")] public string ExplainRule( [Description("Repo-relative path of the trigger file.")] string fileA, [Description("Repo-relative path of the expected companion file.")] string fileB, @@ -266,6 +283,11 @@ public string ExplainRule( exampleCommits = annotated.Hole.ExampleTransactions.Select(e => new { sha = e.Id, subject = e.Label }), suppressed = annotated.Suppression is null ? (bool?)null : annotated.Suppressed, suppressReason = annotated.Reason, + // The exact command to relay when the agent judges the rule false — + // recommend it to the user verbatim; only the user may run it. + ifFalsePattern = annotated.Suppressed + ? null + : $"recommend to the user (their decision): dotnet tool execute Unwritten --yes -- ignore {annotated.Hole.Trigger} {annotated.Hole.Hole} --for 30", changedFacets = annotated.Suppression?.ChangedFacets.Select(f => new { facet = f.Facet, diff --git a/tests/Unwritten.Integration.Tests/IgnoreRuleTests.cs b/tests/Unwritten.Integration.Tests/IgnoreRuleTests.cs new file mode 100644 index 0000000..08dba0a --- /dev/null +++ b/tests/Unwritten.Integration.Tests/IgnoreRuleTests.cs @@ -0,0 +1,142 @@ +using Unwritten.Git; +using Unwritten.Storage; +using Unwritten.Tool; + +namespace Unwritten.Integration.Tests; + +/// +/// Bounded rule mutes: an ignore suppresses (visibly) instead of blocking, +/// expires after the trigger has changed N more times, and never survives +/// --strict. Permanent ignores don't exist by design. +/// +public class IgnoreRuleTests : IDisposable +{ + private readonly SyntheticRepo _repo = new(); + private readonly GitTransactionSource _source = new(new GitRunner()); + private readonly IndexManager _manager; + + public IgnoreRuleTests() + { + _manager = new IndexManager(_source); + } + + public void Dispose() => _repo.Dispose(); + + private int RunCheck(out string output, params string[] args) + { + using var writer = new StringWriter(); + int exitCode = CheckCommand.Run( + [.. args, "--repo", _repo.Path], _manager, _source, writer); + output = writer.ToString(); + return exitCode; + } + + private int RunIgnore(out string output, params string[] args) + { + using var writer = new StringWriter(); + int exitCode = IgnoreCommand.Run( + [.. args, "--repo", _repo.Path], _manager, writer); + output = writer.ToString(); + return exitCode; + } + + /// 30 co-changes + 1 alone: wilson(30,31) ≈ 0.81, safely above the 0.7 fail floor. + private void BuildStrongCouplingWithHole() + { + for (int i = 0; i < 30; i++) + { + _repo.Commit("api.txt", "api.contract.txt"); + } + + _repo.Commit("api.txt"); + } + + [Fact] + public void IgnoredHoleIsSuppressedNotDropped() + { + BuildStrongCouplingWithHole(); + Assert.Equal(1, RunCheck(out _, "api.txt")); + + Assert.Equal(0, RunIgnore(out string ignoreOutput, "api.txt", "api.contract.txt", "--for", "5")); + Assert.Contains("next 5 change(s)", ignoreOutput); + + int exitCode = RunCheck(out string output, "api.txt"); + + Assert.Equal(0, exitCode); + Assert.Contains("suppressed: api.contract.txt", output); + Assert.Contains("expires after 5 more change(s)", output); + } + + [Fact] + public void IgnoreExpiresAfterTheTriggerChangesEnoughTimes() + { + BuildStrongCouplingWithHole(); + RunIgnore(out _, "api.txt", "api.contract.txt", "--for", "2"); + + _repo.Commit("api.txt"); + _repo.Commit("api.txt"); + + int exitCode = RunCheck(out string output, "api.txt"); + + Assert.Equal(1, exitCode); + Assert.Contains("api.contract.txt", output); + Assert.DoesNotContain("suppressed:", output); + } + + [Fact] + public void StrictOverridesIgnores() + { + BuildStrongCouplingWithHole(); + RunIgnore(out _, "api.txt", "api.contract.txt", "--for", "5"); + + Assert.Equal(1, RunCheck(out _, "api.txt", "--strict")); + } + + [Fact] + public void ListShowsRemainingBudgetAndRemoveRestoresTheAlert() + { + BuildStrongCouplingWithHole(); + RunIgnore(out _, "api.txt", "api.contract.txt", "--for", "5", "--note", "docs-only pairing"); + + Assert.Equal(0, RunIgnore(out string listOutput, "--list")); + Assert.Contains("api.txt -> api.contract.txt", listOutput); + Assert.Contains("5 change(s)", listOutput); + Assert.Contains("docs-only pairing", listOutput); + + Assert.Equal(0, RunIgnore(out _, "--remove", "api.txt", "api.contract.txt")); + Assert.Equal(1, RunCheck(out _, "api.txt")); + } + + [Fact] + public void FailingHoleSpellsOutTheExactDecisionCommands() + { + BuildStrongCouplingWithHole(); + + int exitCode = RunCheck(out string output, "api.txt"); + + Assert.Equal(1, exitCode); + Assert.Contains("Your decision, per hole:", output); + Assert.Contains("api.contract.txt (usually changes together with api.txt)", output); + Assert.Contains("git commit --no-verify", output); + Assert.Contains("ignore api.txt api.contract.txt --for 30", output); + } + + [Fact] + public void RemovingAnUnknownIgnoreSaysSo() + { + BuildStrongCouplingWithHole(); + + Assert.Equal(1, RunIgnore(out string output, "--remove", "api.txt", "nope.txt")); + Assert.Contains("No ignore found", output); + } + + [Fact] + public void CorruptIgnoresFileFailsTowardAlerting() + { + BuildStrongCouplingWithHole(); + Directory.CreateDirectory(Path.Combine(_repo.Path, ".unwritten")); + File.WriteAllText(IgnoreStore.GetPath(_repo.Path), "{ not valid"); + + Assert.Equal(1, RunCheck(out _, "api.txt")); + } +}