Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### [Unreleased]

#### Added

- **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`.

#### Changed

- **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`.
Expand Down Expand Up @@ -1670,6 +1674,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### [Unreleased]

#### 追加

- **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`。

#### 変更

- **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`。
Expand Down
2 changes: 2 additions & 0 deletions FolderDiffIL4DotNet.Tests/CliOptionsTests.Combined.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public void ParseCliOptions_AllFlagsCombined_ParsedCorrectly()
"/old", "/new", "lbl",
"--no-pause", "--no-il-cache", "--skip-il",
"--no-timestamp-warnings", "--creator", "--creator-il-ignore-profile", "buildserver-winforms", "--print-config", "--dry-run",
"--fail-on-diff",
"--coffee", "--bell", "--wizard",
"--log-format", "json",
});
Expand All @@ -33,6 +34,7 @@ public void ParseCliOptions_AllFlagsCombined_ParsedCorrectly()
Assert.Equal("buildserver-winforms", opts.CreatorIlIgnoreProfile);
Assert.True(opts.PrintConfig);
Assert.True(opts.DryRun);
Assert.True(opts.FailOnDiff);
Assert.True(opts.Coffee);
Assert.True(opts.Bell);
Assert.True(opts.Wizard);
Expand Down
13 changes: 13 additions & 0 deletions FolderDiffIL4DotNet.Tests/CliOptionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public void ParseCliOptions_NullArgs_ReturnsAllDefaults()
Assert.Null(opts.CreatorIlIgnoreProfile);
Assert.False(opts.PrintConfig);
Assert.False(opts.DryRun);
Assert.False(opts.FailOnDiff);
Assert.False(opts.Coffee);
Assert.False(opts.Beer);
Assert.False(opts.Matcha);
Expand Down Expand Up @@ -85,6 +86,7 @@ public void ParseCliOptions_PositionalArgsOnly_ReturnsAllDefaultFlags()
Assert.Null(opts.CreatorIlIgnoreProfile);
Assert.False(opts.PrintConfig);
Assert.False(opts.DryRun);
Assert.False(opts.FailOnDiff);
Assert.False(opts.Coffee);
Assert.False(opts.Beer);
Assert.False(opts.Matcha);
Expand Down Expand Up @@ -177,6 +179,17 @@ public void ParseCliOptions_DoctorFlag_SetsDoctor(string arg)
Assert.Null(opts.ParseError);
}

[Theory]
[InlineData("--fail-on-diff")]
[InlineData("--FAIL-ON-DIFF")]
public void ParseCliOptions_FailOnDiffFlag_SetsFailOnDiff(string arg)
{
var opts = CliParser.Parse(new[] { arg });

Assert.True(opts.FailOnDiff);
Assert.Null(opts.ParseError);
}

// -----------------------------------------------------------------------
// --credits
// -----------------------------------------------------------------------
Expand Down
157 changes: 157 additions & 0 deletions FolderDiffIL4DotNet.Tests/ProgramRunnerTests.FailOnDiff.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// ProgramRunnerTests.FailOnDiff.cs — --fail-on-diff CLI gating integration tests
// ProgramRunnerTests.FailOnDiff.cs — --fail-on-diff CLI ゲートの統合テスト

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using FolderDiffIL4DotNet.Services;
using FolderDiffIL4DotNet.Tests.Helpers;
using Xunit;

namespace FolderDiffIL4DotNet.Tests
{
public sealed partial class ProgramRunnerTests
{
[Fact]
public async Task RunAsync_ReportableDifferencesWithoutFailOnDiff_ReturnsSuccess()
{
string tempRoot = CreateFailOnDiffTempRoot();
try
{
string oldDir = CreateDirectory(tempRoot, "old");
string newDir = CreateDirectory(tempRoot, "new");
File.WriteAllText(Path.Combine(newDir, "added.txt"), "added");

await WithConfigFileAsync("""{"SkipIL":true}""", async () =>
{
var result = await RunFailOnDiffComparisonAsync(tempRoot, oldDir, newDir, includeFailOnDiff: false);

Assert.Equal(0, result.ExitCode);
Assert.True(File.Exists(Path.Combine(result.ReportDirectory, "diff_report.md")));
});
}
finally
{
TryDeleteDirectory(tempRoot);
}
}

[Fact]
public async Task RunAsync_FailOnDiffWithoutReportableDifferences_ReturnsSuccess()
{
string tempRoot = CreateFailOnDiffTempRoot();
try
{
string oldDir = CreateDirectory(tempRoot, "old");
string newDir = CreateDirectory(tempRoot, "new");
File.WriteAllText(Path.Combine(oldDir, "same.txt"), "same");
File.WriteAllText(Path.Combine(newDir, "same.txt"), "same");

await WithConfigFileAsync("""{"SkipIL":true}""", async () =>
{
var result = await RunFailOnDiffComparisonAsync(tempRoot, oldDir, newDir, includeFailOnDiff: true);

Assert.Equal(0, result.ExitCode);
Assert.True(File.Exists(Path.Combine(result.ReportDirectory, "diff_report.md")));
});
}
finally
{
TryDeleteDirectory(tempRoot);
}
}

[Fact]
public async Task RunAsync_FailOnDiffWithFinalAddedRemovedAndModifiedEntries_ReturnsFiveAfterGeneratingArtifacts()
{
string tempRoot = CreateFailOnDiffTempRoot();
try
{
string oldDir = CreateDirectory(tempRoot, "old");
string newDir = CreateDirectory(tempRoot, "new");
File.WriteAllText(Path.Combine(oldDir, "removed.txt"), "removed");
File.WriteAllText(Path.Combine(newDir, "added.txt"), "added");
File.WriteAllText(Path.Combine(oldDir, "modified.txt"), "before");
File.WriteAllText(Path.Combine(newDir, "modified.txt"), "after");

await WithConfigFileAsync("""{"SkipIL":true}""", async () =>
{
var result = await RunFailOnDiffComparisonAsync(tempRoot, oldDir, newDir, includeFailOnDiff: true);

Assert.Equal(5, result.ExitCode);
Assert.True(File.Exists(Path.Combine(result.ReportDirectory, "diff_report.md")));
Assert.True(File.Exists(Path.Combine(result.ReportDirectory, "diff_report.html")));
Assert.True(File.Exists(Path.Combine(result.ReportDirectory, AuditLogGenerateService.AUDIT_LOG_FILE_NAME)));
});
}
finally
{
TryDeleteDirectory(tempRoot);
}
}

[Fact]
public async Task RunAsync_FailOnDiffWithOnlyIgnoredExtensionDifference_ReturnsSuccess()
{
string tempRoot = CreateFailOnDiffTempRoot();
try
{
string oldDir = CreateDirectory(tempRoot, "old");
string newDir = CreateDirectory(tempRoot, "new");
File.WriteAllText(Path.Combine(newDir, "ignored.tmp"), "ignored");

await WithConfigFileAsync("""{"SkipIL":true,"IgnoredExtensions":[".tmp"]}""", async () =>
{
var result = await RunFailOnDiffComparisonAsync(tempRoot, oldDir, newDir, includeFailOnDiff: true);

Assert.Equal(0, result.ExitCode);
Assert.True(File.Exists(Path.Combine(result.ReportDirectory, "diff_report.md")));
});
}
finally
{
TryDeleteDirectory(tempRoot);
}
}

private static string CreateFailOnDiffTempRoot()
=> Path.Combine(Path.GetTempPath(), "fd-fail-on-diff-" + Guid.NewGuid().ToString("N"));

private static string CreateDirectory(string root, string name)
{
string path = Path.Combine(root, name);
Directory.CreateDirectory(path);
return path;
}

private static async Task<(int ExitCode, string ReportDirectory)> RunFailOnDiffComparisonAsync(
string tempRoot,
string oldDir,
string newDir,
bool includeFailOnDiff)
{
string reportsRoot = CreateDirectory(tempRoot, "reports");
string reportLabel = "report_" + Guid.NewGuid().ToString("N");
var args = new List<string>
{
oldDir,
newDir,
reportLabel,
"--skip-il",
"--no-pause",
"--no-banner",
"--output",
reportsRoot,
};
if (includeFailOnDiff)
{
args.Add("--fail-on-diff");
}

var runner = new ProgramRunner(new TestLogger(logFileAbsolutePath: "test.log"), new ConfigService());
int exitCode = await runner.RunAsync(args.ToArray());
return (exitCode, Path.Combine(reportsRoot, reportLabel));
}
}
}
2 changes: 2 additions & 0 deletions FolderDiffIL4DotNet.Tests/ProgramRunnerTests.HelpVersion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public async Task RunAsync_HelpFlag_ExitsZeroWithoutInitializingLogger()
Assert.Contains("--skip-il", output, StringComparison.Ordinal);
Assert.Contains("--no-banner", output, StringComparison.Ordinal);
Assert.Contains("--doctor", output, StringComparison.Ordinal);
Assert.Contains("--fail-on-diff", output, StringComparison.Ordinal);
Assert.Contains("5 Reportable differences found", output, StringComparison.Ordinal);
Assert.Contains("env+supported CLI overrides", output, StringComparison.Ordinal);
Assert.Contains("without semantic validation", output, StringComparison.Ordinal);
Assert.Contains("config.json + env overrides before runtime CLI overrides", output, StringComparison.Ordinal);
Expand Down
2 changes: 1 addition & 1 deletion ProgramRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public async Task<int> RunAsync(string[] args)
}

PromptForExitKeyIfNeeded(opts);
return (int)result.ExitCode;
return (int)result.ResolveExitCode(opts.FailOnDiff);
}

/// <summary>
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Common options:
| `--output <path>` | Write reports under a custom output directory. |
| `--skip-il` | Skip IL comparison and use SHA256/text comparison only. |
| `--threads <n>` | Override comparison parallelism for this run. |
| `--fail-on-diff` | After generating all artifacts, exit with code `5` when final Added/Removed/Modified entries remain. |
| `--print-config` | Print the effective builder state after env-var and supported CLI overrides without semantic validation. |
| `--validate-config` | Validate `config.json` plus `FOLDERDIFF_*` environment-variable overrides before runtime CLI overrides are applied. |
| `--open-reports` | Open the reports folder and exit. |
Expand Down Expand Up @@ -188,6 +189,7 @@ nildiff <old-folder> <new-folder> [report-label] [options]
| `--output <path>` | カスタム出力ディレクトリ配下にレポートを書き出します。 |
| `--skip-il` | IL 比較をスキップし、SHA256/text 比較のみを使います。 |
| `--threads <n>` | この実行だけ比較並列度を上書きします。 |
| `--fail-on-diff` | 全成果物の生成後、最終的な Added/Removed/Modified が残る場合にコード `5` で終了します。 |
| `--print-config` | 環境変数と対応 CLI オーバーライドを適用した builder 状態を、セマンティック検証なしでそのまま出力するため、範囲外を含む effective config の診断にも使えます。 |
| `--validate-config` | [`config.json`](config.json) に `FOLDERDIFF_*` 環境変数オーバーライドを適用した状態を、実行時 CLI オーバーライド適用前に検証します。 |
| `--open-reports` | レポートフォルダを開いて終了します。 |
Expand Down
1 change: 1 addition & 0 deletions Runner/CliOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal sealed record CliOptions
internal bool PrintConfig { get; init; }
internal bool ValidateConfig { get; init; }
internal bool DryRun { get; init; }
internal bool FailOnDiff { get; init; }
internal bool Coffee { get; init; }
internal bool Beer { get; init; }
internal bool Matcha { get; init; }
Expand Down
7 changes: 6 additions & 1 deletion Runner/CliParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ internal static class CliParser
private const string OPT_PRINT_CONFIG = "--print-config";
private const string OPT_VALIDATE_CONFIG = "--validate-config";
private const string OPT_DRY_RUN = "--dry-run";
private const string OPT_FAIL_ON_DIFF = "--fail-on-diff";
private const string OPT_COFFEE = "--coffee";
private const string OPT_BEER = "--beer";
private const string OPT_MATCHA = "--matcha";
Expand Down Expand Up @@ -56,7 +57,7 @@ internal static class CliParser
internal static CliOptions Parse(string[] args)
{
bool showHelp = false, showVersion = false, showBanner = false, noBanner = false, doctor = false, noPause = false;
bool noIlCache = false, clearCache = false, skipIl = false, noTimestampWarnings = false, printConfig = false, validateConfig = false, dryRun = false;
bool noIlCache = false, clearCache = false, skipIl = false, noTimestampWarnings = false, printConfig = false, validateConfig = false, dryRun = false, failOnDiff = false;
bool coffee = false, beer = false, matcha = false, whisky = false, wine = false, ramen = false, sushi = false, bell = false, wizard = false, showCredits = false;
bool randomSpinner = false;
bool creator = false;
Expand Down Expand Up @@ -181,6 +182,9 @@ internal static CliOptions Parse(string[] args)
case OPT_DRY_RUN:
dryRun = true;
break;
case OPT_FAIL_ON_DIFF:
failOnDiff = true;
break;
case OPT_COFFEE:
// Last-wins: clear other spinner flags so CLI order determines winner
// 最後勝ち: 他のスピナーフラグをクリアしてCLI引数順で決定
Expand Down Expand Up @@ -314,6 +318,7 @@ internal static CliOptions Parse(string[] args)
PrintConfig = printConfig,
ValidateConfig = validateConfig,
DryRun = dryRun,
FailOnDiff = failOnDiff,
Coffee = coffee,
Beer = beer,
Matcha = matcha,
Expand Down
3 changes: 3 additions & 0 deletions Runner/ProgramRunner.HelpText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public sealed partial class ProgramRunner
" Shows existing report folders before the label prompt; press Enter to auto-generate.\n" +
" Drag-and-drop friendly.\n" +
" --dry-run Enumerate files and show statistics without running comparison.\n" +
" --fail-on-diff Exit with code 5 when final reportable differences remain.\n" +
" Reports and other artifacts are still generated before exit.\n" +
" --coffee Use coffee-themed spinner animation during execution.\n" +
" --beer Use beer-themed spinner animation during execution.\n" +
" --matcha Use matcha tea ceremony spinner animation during execution.\n" +
Expand Down Expand Up @@ -68,6 +70,7 @@ public sealed partial class ProgramRunner
" 2 Invalid arguments or input paths.\n" +
" 3 Configuration load or parse error.\n" +
" 4 Diff execution/report generation failure, or --doctor with no IL disassembler.\n" +
" 5 Reportable differences found with --fail-on-diff.\n" +
" 1 Unexpected internal error.\n\n" +
"Tip:\n" +
" Use --print-config to display the effective configuration\n" +
Expand Down
14 changes: 14 additions & 0 deletions Runner/ProgramRunner.Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ private enum ProgramExitCode
/// </summary>
ExecutionFailed = 4,

/// <summary>
/// Reportable differences were found when requested by --fail-on-diff.
/// --fail-on-diff 指定時にレポート対象の差分が見つかりました。
/// </summary>
DifferencesFound = 5,

/// <summary>
/// Unclassifiable unexpected error. / 分類不能な想定外エラーです。
/// </summary>
Expand Down Expand Up @@ -70,6 +76,14 @@ public static ProgramRunResult Success(RunCompletionState completionState)
public static ProgramRunResult Failure(ProgramExitCode exitCode)
=> new(exitCode, _noWarnings);

public ProgramExitCode ResolveExitCode(bool failOnDiff)
{
bool hasReportableDifferences = AddedCount > 0 || RemovedCount > 0 || ModifiedCount > 0;
return ExitCode == ProgramExitCode.Success && failOnDiff && hasReportableDifferences
? ProgramExitCode.DifferencesFound
: ExitCode;
}

private ProgramRunResult(ProgramExitCode exitCode, RunCompletionState completionState)
{
ExitCode = exitCode;
Expand Down
Loading
Loading