Skip to content

Commit a6452df

Browse files
authored
Add fail-on-diff CI gating (#227)
1 parent b0046cc commit a6452df

14 files changed

Lines changed: 230 additions & 9 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
99

1010
### [Unreleased]
1111

12+
#### Added
13+
14+
- **Opt-in `--fail-on-diff` CI gating** — Normal completed comparisons continue to return `0` by default. With `--fail-on-diff`, nildiff now returns dedicated exit code `5` when the final reportable Added/Removed/Modified sets are non-empty. The decision is made only after every enabled report, audit log, and post-process action completes, and ignored extensions or other suppressed/filtered differences do not trigger the gate. Affected: `Runner/CliParser.cs`, `Runner/CliOptions.cs`, `ProgramRunner.cs`, `Runner/ProgramRunner.Types.cs`, `Runner/ProgramRunner.Wizard.cs`, `Runner/ProgramRunner.HelpText.cs`, `README.md`, `USER_GUIDE.md`. Tests: `CliOptionsTests`, `ProgramRunnerTests`.
15+
1216
#### Changed
1317

1418
- **CLI parsing now returns one structured result and rejects surplus positional arguments** — `CliParser` now separates `oldFolder`, `newFolder`, and optional `reportLabel` while consuming options in one pass. Existing two- and three-positional forms, option placement, automatic labels, and `--creator` behavior are preserved; a fourth positional argument now prints usage and exits with code `2`. `CliOptions` uses named properties with defaults instead of a 35-field positional constructor. Affected: `Runner/CliParser.cs`, `Runner/CliOptions.cs`, `ProgramRunner.cs`, `Runner/ProgramRunner.Wizard.cs`, `Runner/RunPreflightValidator.cs`, `USER_GUIDE.md`. Tests: `CliOptionsTests`, `ProgramRunnerTests`, `CliOverrideApplierTests`, `SpinnerThemesTests`.
@@ -1670,6 +1674,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
16701674

16711675
### [Unreleased]
16721676

1677+
#### 追加
1678+
1679+
- **opt-in の `--fail-on-diff` CI ゲート** — 正常に完了した比較は既定で従来どおり `0` を返します。`--fail-on-diff` を指定した場合は、最終的なレポート対象の Added/Removed/Modified が空でなければ専用終了コード `5` を返します。判定は有効なレポート、監査ログ、ポストプロセス処理をすべて完了した後にだけ行い、無視拡張子やその他の抑制・フィルタ済み差分はゲートを発火させません。対象: `Runner/CliParser.cs`, `Runner/CliOptions.cs`, `ProgramRunner.cs`, `Runner/ProgramRunner.Types.cs`, `Runner/ProgramRunner.Wizard.cs`, `Runner/ProgramRunner.HelpText.cs`, `README.md`, `USER_GUIDE.md`。テスト: `CliOptionsTests`, `ProgramRunnerTests`。
1680+
16731681
#### 変更
16741682

16751683
- **CLI 解析を単一の構造化結果へ集約し、余分な位置引数を拒否** — `CliParser` はオプションを 1 回の走査で消費しながら、`oldFolder`、`newFolder`、任意の `reportLabel` を分離するようになりました。既存の 2/3 位置引数形式、オプション位置、自動ラベル、`--creator` の動作は維持し、4 個目の位置引数は使い方を表示して終了コード `2` で拒否します。`CliOptions` は 35 フィールドの位置指定コンストラクタではなく、既定値付きの名前付きプロパティを使用します。対象: `Runner/CliParser.cs`, `Runner/CliOptions.cs`, `ProgramRunner.cs`, `Runner/ProgramRunner.Wizard.cs`, `Runner/RunPreflightValidator.cs`, `USER_GUIDE.md`。テスト: `CliOptionsTests`, `ProgramRunnerTests`, `CliOverrideApplierTests`, `SpinnerThemesTests`。

FolderDiffIL4DotNet.Tests/CliOptionsTests.Combined.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public void ParseCliOptions_AllFlagsCombined_ParsedCorrectly()
2121
"/old", "/new", "lbl",
2222
"--no-pause", "--no-il-cache", "--skip-il",
2323
"--no-timestamp-warnings", "--creator", "--creator-il-ignore-profile", "buildserver-winforms", "--print-config", "--dry-run",
24+
"--fail-on-diff",
2425
"--coffee", "--bell", "--wizard",
2526
"--log-format", "json",
2627
});
@@ -33,6 +34,7 @@ public void ParseCliOptions_AllFlagsCombined_ParsedCorrectly()
3334
Assert.Equal("buildserver-winforms", opts.CreatorIlIgnoreProfile);
3435
Assert.True(opts.PrintConfig);
3536
Assert.True(opts.DryRun);
37+
Assert.True(opts.FailOnDiff);
3638
Assert.True(opts.Coffee);
3739
Assert.True(opts.Bell);
3840
Assert.True(opts.Wizard);

FolderDiffIL4DotNet.Tests/CliOptionsTests.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public void ParseCliOptions_NullArgs_ReturnsAllDefaults()
3333
Assert.Null(opts.CreatorIlIgnoreProfile);
3434
Assert.False(opts.PrintConfig);
3535
Assert.False(opts.DryRun);
36+
Assert.False(opts.FailOnDiff);
3637
Assert.False(opts.Coffee);
3738
Assert.False(opts.Beer);
3839
Assert.False(opts.Matcha);
@@ -85,6 +86,7 @@ public void ParseCliOptions_PositionalArgsOnly_ReturnsAllDefaultFlags()
8586
Assert.Null(opts.CreatorIlIgnoreProfile);
8687
Assert.False(opts.PrintConfig);
8788
Assert.False(opts.DryRun);
89+
Assert.False(opts.FailOnDiff);
8890
Assert.False(opts.Coffee);
8991
Assert.False(opts.Beer);
9092
Assert.False(opts.Matcha);
@@ -177,6 +179,17 @@ public void ParseCliOptions_DoctorFlag_SetsDoctor(string arg)
177179
Assert.Null(opts.ParseError);
178180
}
179181

182+
[Theory]
183+
[InlineData("--fail-on-diff")]
184+
[InlineData("--FAIL-ON-DIFF")]
185+
public void ParseCliOptions_FailOnDiffFlag_SetsFailOnDiff(string arg)
186+
{
187+
var opts = CliParser.Parse(new[] { arg });
188+
189+
Assert.True(opts.FailOnDiff);
190+
Assert.Null(opts.ParseError);
191+
}
192+
180193
// -----------------------------------------------------------------------
181194
// --credits
182195
// -----------------------------------------------------------------------
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// ProgramRunnerTests.FailOnDiff.cs — --fail-on-diff CLI gating integration tests
2+
// ProgramRunnerTests.FailOnDiff.cs — --fail-on-diff CLI ゲートの統合テスト
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Threading.Tasks;
8+
using FolderDiffIL4DotNet.Services;
9+
using FolderDiffIL4DotNet.Tests.Helpers;
10+
using Xunit;
11+
12+
namespace FolderDiffIL4DotNet.Tests
13+
{
14+
public sealed partial class ProgramRunnerTests
15+
{
16+
[Fact]
17+
public async Task RunAsync_ReportableDifferencesWithoutFailOnDiff_ReturnsSuccess()
18+
{
19+
string tempRoot = CreateFailOnDiffTempRoot();
20+
try
21+
{
22+
string oldDir = CreateDirectory(tempRoot, "old");
23+
string newDir = CreateDirectory(tempRoot, "new");
24+
File.WriteAllText(Path.Combine(newDir, "added.txt"), "added");
25+
26+
await WithConfigFileAsync("""{"SkipIL":true}""", async () =>
27+
{
28+
var result = await RunFailOnDiffComparisonAsync(tempRoot, oldDir, newDir, includeFailOnDiff: false);
29+
30+
Assert.Equal(0, result.ExitCode);
31+
Assert.True(File.Exists(Path.Combine(result.ReportDirectory, "diff_report.md")));
32+
});
33+
}
34+
finally
35+
{
36+
TryDeleteDirectory(tempRoot);
37+
}
38+
}
39+
40+
[Fact]
41+
public async Task RunAsync_FailOnDiffWithoutReportableDifferences_ReturnsSuccess()
42+
{
43+
string tempRoot = CreateFailOnDiffTempRoot();
44+
try
45+
{
46+
string oldDir = CreateDirectory(tempRoot, "old");
47+
string newDir = CreateDirectory(tempRoot, "new");
48+
File.WriteAllText(Path.Combine(oldDir, "same.txt"), "same");
49+
File.WriteAllText(Path.Combine(newDir, "same.txt"), "same");
50+
51+
await WithConfigFileAsync("""{"SkipIL":true}""", async () =>
52+
{
53+
var result = await RunFailOnDiffComparisonAsync(tempRoot, oldDir, newDir, includeFailOnDiff: true);
54+
55+
Assert.Equal(0, result.ExitCode);
56+
Assert.True(File.Exists(Path.Combine(result.ReportDirectory, "diff_report.md")));
57+
});
58+
}
59+
finally
60+
{
61+
TryDeleteDirectory(tempRoot);
62+
}
63+
}
64+
65+
[Fact]
66+
public async Task RunAsync_FailOnDiffWithFinalAddedRemovedAndModifiedEntries_ReturnsFiveAfterGeneratingArtifacts()
67+
{
68+
string tempRoot = CreateFailOnDiffTempRoot();
69+
try
70+
{
71+
string oldDir = CreateDirectory(tempRoot, "old");
72+
string newDir = CreateDirectory(tempRoot, "new");
73+
File.WriteAllText(Path.Combine(oldDir, "removed.txt"), "removed");
74+
File.WriteAllText(Path.Combine(newDir, "added.txt"), "added");
75+
File.WriteAllText(Path.Combine(oldDir, "modified.txt"), "before");
76+
File.WriteAllText(Path.Combine(newDir, "modified.txt"), "after");
77+
78+
await WithConfigFileAsync("""{"SkipIL":true}""", async () =>
79+
{
80+
var result = await RunFailOnDiffComparisonAsync(tempRoot, oldDir, newDir, includeFailOnDiff: true);
81+
82+
Assert.Equal(5, result.ExitCode);
83+
Assert.True(File.Exists(Path.Combine(result.ReportDirectory, "diff_report.md")));
84+
Assert.True(File.Exists(Path.Combine(result.ReportDirectory, "diff_report.html")));
85+
Assert.True(File.Exists(Path.Combine(result.ReportDirectory, AuditLogGenerateService.AUDIT_LOG_FILE_NAME)));
86+
});
87+
}
88+
finally
89+
{
90+
TryDeleteDirectory(tempRoot);
91+
}
92+
}
93+
94+
[Fact]
95+
public async Task RunAsync_FailOnDiffWithOnlyIgnoredExtensionDifference_ReturnsSuccess()
96+
{
97+
string tempRoot = CreateFailOnDiffTempRoot();
98+
try
99+
{
100+
string oldDir = CreateDirectory(tempRoot, "old");
101+
string newDir = CreateDirectory(tempRoot, "new");
102+
File.WriteAllText(Path.Combine(newDir, "ignored.tmp"), "ignored");
103+
104+
await WithConfigFileAsync("""{"SkipIL":true,"IgnoredExtensions":[".tmp"]}""", async () =>
105+
{
106+
var result = await RunFailOnDiffComparisonAsync(tempRoot, oldDir, newDir, includeFailOnDiff: true);
107+
108+
Assert.Equal(0, result.ExitCode);
109+
Assert.True(File.Exists(Path.Combine(result.ReportDirectory, "diff_report.md")));
110+
});
111+
}
112+
finally
113+
{
114+
TryDeleteDirectory(tempRoot);
115+
}
116+
}
117+
118+
private static string CreateFailOnDiffTempRoot()
119+
=> Path.Combine(Path.GetTempPath(), "fd-fail-on-diff-" + Guid.NewGuid().ToString("N"));
120+
121+
private static string CreateDirectory(string root, string name)
122+
{
123+
string path = Path.Combine(root, name);
124+
Directory.CreateDirectory(path);
125+
return path;
126+
}
127+
128+
private static async Task<(int ExitCode, string ReportDirectory)> RunFailOnDiffComparisonAsync(
129+
string tempRoot,
130+
string oldDir,
131+
string newDir,
132+
bool includeFailOnDiff)
133+
{
134+
string reportsRoot = CreateDirectory(tempRoot, "reports");
135+
string reportLabel = "report_" + Guid.NewGuid().ToString("N");
136+
var args = new List<string>
137+
{
138+
oldDir,
139+
newDir,
140+
reportLabel,
141+
"--skip-il",
142+
"--no-pause",
143+
"--no-banner",
144+
"--output",
145+
reportsRoot,
146+
};
147+
if (includeFailOnDiff)
148+
{
149+
args.Add("--fail-on-diff");
150+
}
151+
152+
var runner = new ProgramRunner(new TestLogger(logFileAbsolutePath: "test.log"), new ConfigService());
153+
int exitCode = await runner.RunAsync(args.ToArray());
154+
return (exitCode, Path.Combine(reportsRoot, reportLabel));
155+
}
156+
}
157+
}

FolderDiffIL4DotNet.Tests/ProgramRunnerTests.HelpVersion.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ public async Task RunAsync_HelpFlag_ExitsZeroWithoutInitializingLogger()
4646
Assert.Contains("--skip-il", output, StringComparison.Ordinal);
4747
Assert.Contains("--no-banner", output, StringComparison.Ordinal);
4848
Assert.Contains("--doctor", output, StringComparison.Ordinal);
49+
Assert.Contains("--fail-on-diff", output, StringComparison.Ordinal);
50+
Assert.Contains("5 Reportable differences found", output, StringComparison.Ordinal);
4951
Assert.Contains("env+supported CLI overrides", output, StringComparison.Ordinal);
5052
Assert.Contains("without semantic validation", output, StringComparison.Ordinal);
5153
Assert.Contains("config.json + env overrides before runtime CLI overrides", output, StringComparison.Ordinal);

ProgramRunner.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ public async Task<int> RunAsync(string[] args)
118118
}
119119

120120
PromptForExitKeyIfNeeded(opts);
121-
return (int)result.ExitCode;
121+
return (int)result.ResolveExitCode(opts.FailOnDiff);
122122
}
123123

124124
/// <summary>

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ Common options:
7373
| `--output <path>` | Write reports under a custom output directory. |
7474
| `--skip-il` | Skip IL comparison and use SHA256/text comparison only. |
7575
| `--threads <n>` | Override comparison parallelism for this run. |
76+
| `--fail-on-diff` | After generating all artifacts, exit with code `5` when final Added/Removed/Modified entries remain. |
7677
| `--print-config` | Print the effective builder state after env-var and supported CLI overrides without semantic validation. |
7778
| `--validate-config` | Validate `config.json` plus `FOLDERDIFF_*` environment-variable overrides before runtime CLI overrides are applied. |
7879
| `--open-reports` | Open the reports folder and exit. |
@@ -188,6 +189,7 @@ nildiff <old-folder> <new-folder> [report-label] [options]
188189
| `--output <path>` | カスタム出力ディレクトリ配下にレポートを書き出します。 |
189190
| `--skip-il` | IL 比較をスキップし、SHA256/text 比較のみを使います。 |
190191
| `--threads <n>` | この実行だけ比較並列度を上書きします。 |
192+
| `--fail-on-diff` | 全成果物の生成後、最終的な Added/Removed/Modified が残る場合にコード `5` で終了します。 |
191193
| `--print-config` | 環境変数と対応 CLI オーバーライドを適用した builder 状態を、セマンティック検証なしでそのまま出力するため、範囲外を含む effective config の診断にも使えます。 |
192194
| `--validate-config` | [`config.json`](config.json)`FOLDERDIFF_*` 環境変数オーバーライドを適用した状態を、実行時 CLI オーバーライド適用前に検証します。 |
193195
| `--open-reports` | レポートフォルダを開いて終了します。 |

Runner/CliOptions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ internal sealed record CliOptions
2626
internal bool PrintConfig { get; init; }
2727
internal bool ValidateConfig { get; init; }
2828
internal bool DryRun { get; init; }
29+
internal bool FailOnDiff { get; init; }
2930
internal bool Coffee { get; init; }
3031
internal bool Beer { get; init; }
3132
internal bool Matcha { get; init; }

Runner/CliParser.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ internal static class CliParser
2929
private const string OPT_PRINT_CONFIG = "--print-config";
3030
private const string OPT_VALIDATE_CONFIG = "--validate-config";
3131
private const string OPT_DRY_RUN = "--dry-run";
32+
private const string OPT_FAIL_ON_DIFF = "--fail-on-diff";
3233
private const string OPT_COFFEE = "--coffee";
3334
private const string OPT_BEER = "--beer";
3435
private const string OPT_MATCHA = "--matcha";
@@ -56,7 +57,7 @@ internal static class CliParser
5657
internal static CliOptions Parse(string[] args)
5758
{
5859
bool showHelp = false, showVersion = false, showBanner = false, noBanner = false, doctor = false, noPause = false;
59-
bool noIlCache = false, clearCache = false, skipIl = false, noTimestampWarnings = false, printConfig = false, validateConfig = false, dryRun = false;
60+
bool noIlCache = false, clearCache = false, skipIl = false, noTimestampWarnings = false, printConfig = false, validateConfig = false, dryRun = false, failOnDiff = false;
6061
bool coffee = false, beer = false, matcha = false, whisky = false, wine = false, ramen = false, sushi = false, bell = false, wizard = false, showCredits = false;
6162
bool randomSpinner = false;
6263
bool creator = false;
@@ -181,6 +182,9 @@ internal static CliOptions Parse(string[] args)
181182
case OPT_DRY_RUN:
182183
dryRun = true;
183184
break;
185+
case OPT_FAIL_ON_DIFF:
186+
failOnDiff = true;
187+
break;
184188
case OPT_COFFEE:
185189
// Last-wins: clear other spinner flags so CLI order determines winner
186190
// 最後勝ち: 他のスピナーフラグをクリアしてCLI引数順で決定
@@ -314,6 +318,7 @@ internal static CliOptions Parse(string[] args)
314318
PrintConfig = printConfig,
315319
ValidateConfig = validateConfig,
316320
DryRun = dryRun,
321+
FailOnDiff = failOnDiff,
317322
Coffee = coffee,
318323
Beer = beer,
319324
Matcha = matcha,

Runner/ProgramRunner.HelpText.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ public sealed partial class ProgramRunner
3737
" Shows existing report folders before the label prompt; press Enter to auto-generate.\n" +
3838
" Drag-and-drop friendly.\n" +
3939
" --dry-run Enumerate files and show statistics without running comparison.\n" +
40+
" --fail-on-diff Exit with code 5 when final reportable differences remain.\n" +
41+
" Reports and other artifacts are still generated before exit.\n" +
4042
" --coffee Use coffee-themed spinner animation during execution.\n" +
4143
" --beer Use beer-themed spinner animation during execution.\n" +
4244
" --matcha Use matcha tea ceremony spinner animation during execution.\n" +
@@ -68,6 +70,7 @@ public sealed partial class ProgramRunner
6870
" 2 Invalid arguments or input paths.\n" +
6971
" 3 Configuration load or parse error.\n" +
7072
" 4 Diff execution/report generation failure, or --doctor with no IL disassembler.\n" +
73+
" 5 Reportable differences found with --fail-on-diff.\n" +
7174
" 1 Unexpected internal error.\n\n" +
7275
"Tip:\n" +
7376
" Use --print-config to display the effective configuration\n" +

0 commit comments

Comments
 (0)