From 8c96bf9d7c813ed6612fb6da4e34f02c4620ed9f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 21:10:04 +0900 Subject: [PATCH 1/5] Consolidate bounded JSON streams (#3681) --- changelog.d/unreleased/3681.fixed.md | 18 ++++++ src/CodeIndex/Mcp/AuditLogSink.cs | 62 ++------------------- src/CodeIndex/Mcp/BoundedJsonUtf8Stream.cs | 62 +++++++++++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 64 ++-------------------- tests/CodeIndex.Tests/McpServerTests.cs | 29 ++++++++++ 5 files changed, 118 insertions(+), 117 deletions(-) create mode 100644 changelog.d/unreleased/3681.fixed.md create mode 100644 src/CodeIndex/Mcp/BoundedJsonUtf8Stream.cs diff --git a/changelog.d/unreleased/3681.fixed.md b/changelog.d/unreleased/3681.fixed.md new file mode 100644 index 0000000000..0c8464c113 --- /dev/null +++ b/changelog.d/unreleased/3681.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 3681 +affected: + - src/CodeIndex/Mcp/BoundedJsonUtf8Stream.cs + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/AuditLogSink.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP JSON byte-limit streams are shared across response and audit-log serialization (#3681)** — bounded UTF-8 serialization now uses one helper with tests for unsupported stream operations and partial byte-limit capture. + +## 日本語 + +- **MCP の JSON バイト制限 stream を response と audit-log serialization で共有しました (#3681)** — bounded UTF-8 serialization は 1 つの helper を使うようになり、未対応 stream 操作と byte limit 超過時の部分 capture をテストで固定しました。 diff --git a/src/CodeIndex/Mcp/AuditLogSink.cs b/src/CodeIndex/Mcp/AuditLogSink.cs index 54c896d126..a4e154ab02 100644 --- a/src/CodeIndex/Mcp/AuditLogSink.cs +++ b/src/CodeIndex/Mcp/AuditLogSink.cs @@ -321,7 +321,10 @@ internal static string SerializeEvent(AuditEvent evt, bool includeValues) private static bool TrySerializeEventCore(AuditEvent evt, bool includeValues, out string serialized) { serialized = string.Empty; - using var buffer = new BoundedAuditEventUtf8Stream(MaxSerializedEventBytes); + using var buffer = new BoundedJsonUtf8Stream( + MaxSerializedEventBytes, + captureSerialized: true, + bytes => new AuditEventByteLimitExceededException(bytes)); try { using (var jw = new Utf8JsonWriter(buffer, new JsonWriterOptions @@ -334,7 +337,7 @@ private static bool TrySerializeEventCore(AuditEvent evt, bool includeValues, ou { WriteEventCore(jw, evt, includeValues); } - serialized = buffer.GetCapturedString(); + serialized = buffer.GetCapturedString() ?? string.Empty; return true; } catch (AuditEventByteLimitExceededException) @@ -515,61 +518,6 @@ private sealed class AuditEventByteLimitExceededException(int bytesWritten) : Ex public int BytesWritten { get; } = bytesWritten; } - private sealed class BoundedAuditEventUtf8Stream(int maxBytes) : Stream - { - private readonly MemoryStream _buffer = new(Math.Min(Math.Max(maxBytes, 0), 16 * 1024)); - - public int BytesWritten { get; private set; } - - public override bool CanRead => false; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public string GetCapturedString() - => Encoding.UTF8.GetString(_buffer.GetBuffer(), 0, (int)_buffer.Length); - - public override void Flush() - { - } - - public override int Read(byte[] buffer, int offset, int count) - => throw new NotSupportedException(); - - public override long Seek(long offset, SeekOrigin origin) - => throw new NotSupportedException(); - - public override void SetLength(long value) - => throw new NotSupportedException(); - - public override void Write(byte[] buffer, int offset, int count) - => Write(buffer.AsSpan(offset, count)); - - public override void Write(ReadOnlySpan buffer) - { - if (buffer.Length == 0) - return; - - var remaining = maxBytes - BytesWritten; - if (remaining < buffer.Length) - { - if (remaining > 0) - _buffer.Write(buffer[..remaining]); - BytesWritten = maxBytes == int.MaxValue ? int.MaxValue : maxBytes + 1; - throw new AuditEventByteLimitExceededException(BytesWritten); - } - - _buffer.Write(buffer); - BytesWritten += buffer.Length; - } - } - internal static JsonNode? SanitizeArgValue(string key, JsonNode? value, out bool redacted) { var state = new ArgValueSanitizationState(); diff --git a/src/CodeIndex/Mcp/BoundedJsonUtf8Stream.cs b/src/CodeIndex/Mcp/BoundedJsonUtf8Stream.cs new file mode 100644 index 0000000000..7c2269aa71 --- /dev/null +++ b/src/CodeIndex/Mcp/BoundedJsonUtf8Stream.cs @@ -0,0 +1,62 @@ +using System.Text; + +namespace CodeIndex.Mcp; + +internal sealed class BoundedJsonUtf8Stream(int maxBytes, bool captureSerialized, Func createLimitExceededException) : Stream +{ + private readonly MemoryStream? _buffer = captureSerialized ? new MemoryStream(Math.Min(Math.Max(maxBytes, 0), 16 * 1024)) : null; + + public int BytesWritten { get; private set; } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public string? GetCapturedString() + { + if (_buffer is null) + return null; + return Encoding.UTF8.GetString(_buffer.GetBuffer(), 0, (int)_buffer.Length); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => Write(buffer.AsSpan(offset, count)); + + public override void Write(ReadOnlySpan buffer) + { + if (buffer.Length == 0) + return; + + var remaining = maxBytes - BytesWritten; + if (remaining < buffer.Length) + { + if (remaining > 0) + _buffer?.Write(buffer[..remaining]); + BytesWritten = maxBytes == int.MaxValue ? int.MaxValue : maxBytes + 1; + throw createLimitExceededException(BytesWritten); + } + + _buffer?.Write(buffer); + BytesWritten += buffer.Length; + } +} diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 2a248ae3cb..a4c496cb23 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -3983,7 +3983,10 @@ private static bool TrySerializeJsonNodeWithinByteLimit(JsonNode node, JsonSeria throw new ArgumentOutOfRangeException(nameof(maxBytes), maxBytes, "JSON byte limit must be non-negative."); serialized = null; - using var stream = new BoundedJsonUtf8Stream(maxBytes, captureSerialized); + using var stream = new BoundedJsonUtf8Stream( + maxBytes, + captureSerialized, + bytes => new JsonResponseByteLimitExceededException(bytes)); var writerOptions = new JsonWriterOptions { Encoder = options.Encoder, @@ -4011,65 +4014,6 @@ private sealed class JsonResponseByteLimitExceededException(int bytesWritten) : public int BytesWritten { get; } = bytesWritten; } - private sealed class BoundedJsonUtf8Stream(int maxBytes, bool captureSerialized) : Stream - { - private readonly MemoryStream? _buffer = captureSerialized ? new MemoryStream(Math.Min(Math.Max(maxBytes, 0), 16 * 1024)) : null; - - public int BytesWritten { get; private set; } - - public override bool CanRead => false; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public string? GetCapturedString() - { - if (_buffer is null) - return null; - return Encoding.UTF8.GetString(_buffer.GetBuffer(), 0, (int)_buffer.Length); - } - - public override void Flush() - { - } - - public override int Read(byte[] buffer, int offset, int count) - => throw new NotSupportedException(); - - public override long Seek(long offset, SeekOrigin origin) - => throw new NotSupportedException(); - - public override void SetLength(long value) - => throw new NotSupportedException(); - - public override void Write(byte[] buffer, int offset, int count) - => Write(buffer.AsSpan(offset, count)); - - public override void Write(ReadOnlySpan buffer) - { - if (buffer.Length == 0) - return; - - var remaining = maxBytes - BytesWritten; - if (remaining < buffer.Length) - { - if (remaining > 0) - _buffer?.Write(buffer[..remaining]); - BytesWritten = maxBytes == int.MaxValue ? int.MaxValue : maxBytes + 1; - throw new JsonResponseByteLimitExceededException(BytesWritten); - } - - _buffer?.Write(buffer); - BytesWritten += buffer.Length; - } - } - private static JsonObject CreateResponseTooLargeError(bool hasId, JsonNode? id, int responseBytes, int responseLimit, bool actualBytesExact = true) { return CreateErrorResponse( diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index e502526a15..3e33748812 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -7648,6 +7648,35 @@ public void ResponseLimitSerializer_StopsBeforeFullStringMaterialization_Issue28 Assert.True(bytesWritten < 10_000); } + [Fact] + public void BoundedJsonUtf8Stream_RejectsUnsupportedReadAndSeekOperations_Issue3681() + { + using var stream = new BoundedJsonUtf8Stream(16, captureSerialized: true, bytes => new InvalidOperationException(bytes.ToString())); + + Assert.False(stream.CanRead); + Assert.False(stream.CanSeek); + Assert.True(stream.CanWrite); + Assert.Throws(() => stream.Length); + Assert.Throws(() => stream.Position); + Assert.Throws(() => stream.Position = 0); + Assert.Throws(() => stream.Read([], 0, 0)); + Assert.Throws(() => stream.Seek(0, SeekOrigin.Begin)); + Assert.Throws(() => stream.SetLength(0)); + } + + [Fact] + public void BoundedJsonUtf8Stream_CapturesPartialBytesBeforeLimitException_Issue3681() + { + using var stream = new BoundedJsonUtf8Stream(5, captureSerialized: true, bytes => new InvalidOperationException(bytes.ToString())); + + stream.Write(Encoding.UTF8.GetBytes("abc")); + var ex = Assert.Throws(() => stream.Write(Encoding.UTF8.GetBytes("def"))); + + Assert.Equal("6", ex.Message); + Assert.Equal(6, stream.BytesWritten); + Assert.Equal("abcde", stream.GetCapturedString()); + } + [Fact] public void ClientResponsePayload_RejectsOversizedResultBeforeClone_Issue3098() { From 272436b933ebe91df75b2853b4c3c9a82df34cf2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 21:18:36 +0900 Subject: [PATCH 2/5] Share filesystem write probes (#3689) --- changelog.d/unreleased/3689.fixed.md | 20 +++++++ src/CodeIndex/Cli/GitHelper.cs | 6 +- src/CodeIndex/Cli/GlobalToolLog.cs | 5 +- src/CodeIndex/Cli/PathCasing.cs | 6 +- src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 6 +- .../Indexer/Scanning/FileWriteProbe.cs | 57 +++++++++++++++++++ tests/CodeIndex.Tests/FileIndexerTests.cs | 38 +++++++++++++ 7 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 changelog.d/unreleased/3689.fixed.md create mode 100644 src/CodeIndex/Indexer/Scanning/FileWriteProbe.cs diff --git a/changelog.d/unreleased/3689.fixed.md b/changelog.d/unreleased/3689.fixed.md new file mode 100644 index 0000000000..5aa2652102 --- /dev/null +++ b/changelog.d/unreleased/3689.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 3689 +affected: + - src/CodeIndex/Indexer/Scanning/FileWriteProbe.cs + - src/CodeIndex/Indexer/Scanning/FileIndexer.cs + - src/CodeIndex/Cli/GitHelper.cs + - src/CodeIndex/Cli/PathCasing.cs + - src/CodeIndex/Cli/GlobalToolLog.cs + - tests/CodeIndex.Tests/FileIndexerTests.cs +--- + +## English + +- **Filesystem write probes now share long-path-safe create/delete behavior (#3689)** — case-sensitivity probes and global log directory checks now use one helper, with tests that verify successful cleanup and failure reporting. + +## 日本語 + +- **filesystem write probe が long-path-safe な作成・削除処理を共有するようになりました (#3689)** — case-sensitivity probe と global log directory check は 1 つの helper を使い、成功時 cleanup と失敗報告をテストで確認します。 diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index 5fbc81a2ad..a45a392748 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -1200,8 +1200,7 @@ private static bool ProbeFileSystemIgnoreCase(string projectRoot) using var probe = CaseSensitivityProbeDirectory.CreateProbePathScope(normalizedRoot, "case-probe-"); var probePath = probe.Path; - var ioProbePath = LongPath.EnsureWindowsPrefix(probePath); - File.WriteAllText(ioProbePath, string.Empty); + FileWriteProbe.WriteEmptyFile(probePath); try { if (TryCreateCaseVariant(probePath, out var variant)) @@ -1209,8 +1208,7 @@ private static bool ProbeFileSystemIgnoreCase(string projectRoot) } finally { - if (File.Exists(ioProbePath)) - File.Delete(ioProbePath); + FileWriteProbe.DeleteFileIfExists(probePath); } throw new CaseSensitivityProbeException( diff --git a/src/CodeIndex/Cli/GlobalToolLog.cs b/src/CodeIndex/Cli/GlobalToolLog.cs index be254d8c54..8769f124bc 100644 --- a/src/CodeIndex/Cli/GlobalToolLog.cs +++ b/src/CodeIndex/Cli/GlobalToolLog.cs @@ -5,6 +5,7 @@ using System.Text.RegularExpressions; using System.Threading; using CodeIndex.Diagnostics; +using CodeIndex.Indexer; namespace CodeIndex.Cli; @@ -343,9 +344,7 @@ private static bool CanWriteProbe(string directory) { DataDirectorySecurity.CreateSensitiveDirectory(directory); var probePath = Path.Combine(directory, $".cdidx-write-probe-{Guid.NewGuid():N}.tmp"); - File.WriteAllText(probePath, string.Empty, Encoding.UTF8); - File.Delete(probePath); - return true; + return FileWriteProbe.TryWriteAndDeleteEmptyFile(probePath, Encoding.UTF8); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) { diff --git a/src/CodeIndex/Cli/PathCasing.cs b/src/CodeIndex/Cli/PathCasing.cs index 5a85b425c9..ed033ff2c1 100644 --- a/src/CodeIndex/Cli/PathCasing.cs +++ b/src/CodeIndex/Cli/PathCasing.cs @@ -127,8 +127,7 @@ private static bool ProbeIgnoreCase(string anchor) using var probe = CaseSensitivityProbeDirectory.CreateProbePathScope(anchor, "case-probe-"); var probePath = probe.Path; - var prefixedProbePath = LongPath.EnsureWindowsPrefix(probePath); - File.WriteAllText(prefixedProbePath, string.Empty); + FileWriteProbe.WriteEmptyFile(probePath); try { if (TryCreateCaseVariant(probePath, out var probeVariant)) @@ -136,8 +135,7 @@ private static bool ProbeIgnoreCase(string anchor) } finally { - if (File.Exists(prefixedProbePath)) - File.Delete(prefixedProbePath); + FileWriteProbe.DeleteFileIfExists(probePath); } throw new CaseSensitivityProbeException( diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index 8bc88fe3a0..4bddbb99b5 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -1109,8 +1109,7 @@ private static bool ProbeFileSystemIgnoreCase(string projectRoot) using var probe = CaseSensitivityProbeDirectory.CreateProbePathScope(normalizedRoot, "case-probe-"); var probePath = probe.Path; - var prefixedProbePath = LongPath.EnsureWindowsPrefix(probePath); - File.WriteAllText(prefixedProbePath, string.Empty); + FileWriteProbe.WriteEmptyFile(probePath); try { if (TryCreateCaseVariant(probePath, out var probeVariant)) @@ -1118,8 +1117,7 @@ private static bool ProbeFileSystemIgnoreCase(string projectRoot) } finally { - if (File.Exists(prefixedProbePath)) - File.Delete(prefixedProbePath); + FileWriteProbe.DeleteFileIfExists(probePath); } throw new CaseSensitivityProbeException( diff --git a/src/CodeIndex/Indexer/Scanning/FileWriteProbe.cs b/src/CodeIndex/Indexer/Scanning/FileWriteProbe.cs new file mode 100644 index 0000000000..709901ebef --- /dev/null +++ b/src/CodeIndex/Indexer/Scanning/FileWriteProbe.cs @@ -0,0 +1,57 @@ +using System.Text; + +namespace CodeIndex.Indexer; + +internal static class FileWriteProbe +{ + internal static void WriteEmptyFile(string path, Encoding? encoding = null) + { + var ioPath = LongPath.EnsureWindowsPrefix(path); + if (encoding is null) + File.WriteAllText(ioPath, string.Empty); + else + File.WriteAllText(ioPath, string.Empty, encoding); + } + + internal static bool TryWriteAndDeleteEmptyFile(string path, Encoding? encoding = null) + { + try + { + WriteEmptyFile(path, encoding); + } + catch (Exception ex) when (IsWriteProbeFailure(ex)) + { + TryDeleteFileIfExists(path); + return false; + } + + return TryDeleteFileIfExists(path); + } + + internal static void DeleteFileIfExists(string path) + { + var ioPath = LongPath.EnsureWindowsPrefix(path); + if (File.Exists(ioPath)) + File.Delete(ioPath); + } + + private static bool TryDeleteFileIfExists(string path) + { + try + { + DeleteFileIfExists(path); + return true; + } + catch (Exception ex) when (IsWriteProbeFailure(ex)) + { + return false; + } + } + + private static bool IsWriteProbeFailure(Exception ex) + => ex is ArgumentException + or IOException + or NotSupportedException + or PathTooLongException + or UnauthorizedAccessException; +} diff --git a/tests/CodeIndex.Tests/FileIndexerTests.cs b/tests/CodeIndex.Tests/FileIndexerTests.cs index e4f46ff9bc..7f958649d8 100644 --- a/tests/CodeIndex.Tests/FileIndexerTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerTests.cs @@ -167,6 +167,44 @@ public void CaseSensitivityProbeDirectory_CleanupFailureThrows_Issue3439() } } + [Fact] + public void FileWriteProbe_TryWriteAndDeleteEmptyFile_RemovesProbeAfterSuccess_Issue3689() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx-write-probe-success-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + try + { + var probePath = Path.Combine(tempDir, ".cdidx-write-probe.tmp"); + + var result = FileWriteProbe.TryWriteAndDeleteEmptyFile(probePath, Encoding.UTF8); + + Assert.True(result); + Assert.False(File.Exists(probePath)); + } + finally + { + TestProjectHelper.DeleteDirectory(tempDir); + } + } + + [Fact] + public void FileWriteProbe_TryWriteAndDeleteEmptyFile_ReturnsFalseForDirectoryPath_Issue3689() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx-write-probe-failure-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + try + { + var result = FileWriteProbe.TryWriteAndDeleteEmptyFile(tempDir, Encoding.UTF8); + + Assert.False(result); + Assert.True(Directory.Exists(tempDir)); + } + finally + { + TestProjectHelper.DeleteDirectory(tempDir); + } + } + [Fact] public void ScanFilesDetailed_CaseInsensitiveChildDirectory_SkipsCaseOnlyDuplicatePathWithWarning() { From 0abcdc89731b6973a6f1828add24116d351f13b4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 21:33:41 +0900 Subject: [PATCH 3/5] Report unknown-extension sample limits (#3715) --- changelog.d/unreleased/3715.fixed.md | 16 ++ .../Cli/ExportImportCommandRunner.cs | 103 ++++++++++-- .../ExportImportCommandRunnerTests.cs | 154 ++++++++++++++++++ 3 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 changelog.d/unreleased/3715.fixed.md diff --git a/changelog.d/unreleased/3715.fixed.md b/changelog.d/unreleased/3715.fixed.md new file mode 100644 index 0000000000..0ea094caa7 --- /dev/null +++ b/changelog.d/unreleased/3715.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3715 +affected: + - src/CodeIndex/Cli/ExportImportCommandRunner.cs + - tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs +--- + +## English + +- **Export manifests now report unknown-extension sample list limits explicitly (#3715)** — archives include sample count, limit, and truncation metadata while preserving existing unknown-extension fields for older import/export compatibility. + +## 日本語 + +- **export manifest が unknown-extension sample list の制限を明示するようになりました (#3715)** — archive は sample count、limit、truncation metadata を含み、既存の unknown-extension field は古い import/export 互換性のため維持します。 diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index 147f3958c1..76355fb630 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -98,6 +98,7 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions) string? tempDirectory = null; string? tempPath = null; + ExportManifest? importedManifest = null; var validationPhases = new List(); var phase = PhaseOpenArchive; try @@ -124,6 +125,7 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions) return WriteImportError(wantsJson, jsonOptions, PhaseManifest, "import_manifest_invalid", $"archive manifest is invalid: {manifestError}.", "use an archive produced by `cdidx export `.", ImportUsage); if (!TryValidateManifestHeader(manifest, out var manifestHeaderError)) return WriteImportError(wantsJson, jsonOptions, PhaseManifest, "import_manifest_incompatible", $"archive manifest is invalid: {manifestHeaderError}.", "re-export from a compatible CodeIndex database.", ImportUsage); + importedManifest = manifest; AddImportValidationPhase(validationPhases, PhaseManifest); phase = PhaseDatabaseEntry; @@ -159,6 +161,7 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions) if (dryRun) { AddImportValidationPhase(validationPhases, PhaseReplaceDb, "skipped", "dry-run does not replace the destination database"); + var manifest = importedManifest ?? throw new InvalidDataException("archive manifest was not loaded"); if (wantsJson) { Console.WriteLine(JsonSerializer.Serialize( @@ -171,7 +174,14 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions) prunePaths, prunePaths ? importTargetProjectRoot : null, ReplacementWouldBeAllowed: true, - validationPhases), + validationPhases, + UnknownExtensionFileCount: manifest.UnknownExtensionFileCount, + UnknownExtensionFiles: manifest.UnknownExtensionFiles, + UnknownExtensionFilesTruncated: manifest.UnknownExtensionFilesTruncated, + UnknownExtensionFilePathLimit: manifest.UnknownExtensionFilePathLimit, + UnknownExtensionFileSampleCount: manifest.UnknownExtensionFileSampleCount, + UnknownExtensionFileSampleLimit: manifest.UnknownExtensionFileSampleLimit, + UnknownExtensionFileSampleTruncated: manifest.UnknownExtensionFileSampleTruncated), CliJsonSerializerContextFactory.Create(jsonOptions).ImportDryRunResult)); } else @@ -189,8 +199,20 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions) ReplaceImportedDatabase(tempPath, fullDbPath); if (wantsJson) { + var manifest = importedManifest ?? throw new InvalidDataException("archive manifest was not loaded"); Console.WriteLine(JsonSerializer.Serialize( - new ImportResult("1", fullDbPath, prunePaths, prunePaths ? importTargetProjectRoot : null), + new ImportResult( + "1", + fullDbPath, + prunePaths, + prunePaths ? importTargetProjectRoot : null, + UnknownExtensionFileCount: manifest.UnknownExtensionFileCount, + UnknownExtensionFiles: manifest.UnknownExtensionFiles, + UnknownExtensionFilesTruncated: manifest.UnknownExtensionFilesTruncated, + UnknownExtensionFilePathLimit: manifest.UnknownExtensionFilePathLimit, + UnknownExtensionFileSampleCount: manifest.UnknownExtensionFileSampleCount, + UnknownExtensionFileSampleLimit: manifest.UnknownExtensionFileSampleLimit, + UnknownExtensionFileSampleTruncated: manifest.UnknownExtensionFileSampleTruncated), jsonOptions)); } else @@ -550,6 +572,7 @@ private static ExportManifest BuildManifest(SqliteConnection connection, string var userVersion = ReadSqliteUserVersion(connection); var projectRoot = ReadMetaString(connection, DbContext.IndexedProjectRootMetaKey); var indexedHead = ReadMetaString(connection, DbContext.IndexedHeadShaMetaKey); + var unknownExtensionFiles = ReadUnknownExtensionFileSample(connection); return new ExportManifest( "1", appVersion, @@ -572,9 +595,12 @@ private static ExportManifest BuildManifest(SqliteConnection connection, string SqlGraphContractVersion: ReadMetaInt(connection, DbContext.SqlGraphContractVersionMetaKey), HotspotFamilyVersion: ReadMetaInt(connection, DbContext.HotspotFamilyVersionMetaKey), UnknownExtensionFileCount: ReadMetaLong(connection, DbContext.UnknownExtensionFileCountMetaKey), - UnknownExtensionFiles: ReadUnknownExtensionFiles(connection), + UnknownExtensionFiles: unknownExtensionFiles.Files, UnknownExtensionFilesTruncated: ReadMetaBool(connection, DbContext.UnknownExtensionFilesTruncatedMetaKey), - UnknownExtensionFilePathLimit: ReadMetaInt(connection, DbContext.UnknownExtensionFilePathLimitMetaKey)); + UnknownExtensionFilePathLimit: ReadMetaInt(connection, DbContext.UnknownExtensionFilePathLimitMetaKey), + UnknownExtensionFileSampleCount: unknownExtensionFiles.Count, + UnknownExtensionFileSampleLimit: unknownExtensionFiles.Limit, + UnknownExtensionFileSampleTruncated: unknownExtensionFiles.Truncated); } private static void AddTextEntry(ZipArchive archive, string name, string content) @@ -737,8 +763,28 @@ private static bool TryValidateManifestHeader(ExportManifest manifest, out strin || !ValidateNonNegativeManifestInt(manifest.CSharpSymbolNameContractVersion, "csharp_symbol_name_contract_version", out message) || !ValidateNonNegativeManifestInt(manifest.SqlGraphContractVersion, "sql_graph_contract_version", out message) || !ValidateNonNegativeManifestInt(manifest.HotspotFamilyVersion, "hotspot_family_version", out message) - || !ValidateNonNegativeManifestInt(manifest.UnknownExtensionFilePathLimit, "unknown_extension_file_path_limit", out message)) + || !ValidateNonNegativeManifestInt(manifest.UnknownExtensionFilePathLimit, "unknown_extension_file_path_limit", out message) + || !ValidateNonNegativeManifestInt(manifest.UnknownExtensionFileSampleCount, "unknown_extension_file_sample_count", out message) + || !ValidateNonNegativeManifestInt(manifest.UnknownExtensionFileSampleLimit, "unknown_extension_file_sample_limit", out message)) + { + return false; + } + + if (manifest.UnknownExtensionFileSampleCount.HasValue) + { + var sampleLength = manifest.UnknownExtensionFiles?.Length ?? 0; + if (manifest.UnknownExtensionFileSampleCount.Value != sampleLength) + { + message = "unknown_extension_file_sample_count must match unknown_extension_files length"; + return false; + } + } + + if (manifest.UnknownExtensionFileSampleCount.HasValue + && manifest.UnknownExtensionFileSampleLimit.HasValue + && manifest.UnknownExtensionFileSampleCount.Value > manifest.UnknownExtensionFileSampleLimit.Value) { + message = "unknown_extension_file_sample_count exceeds unknown_extension_file_sample_limit"; return false; } @@ -900,27 +946,36 @@ private static long ReadTableCount(SqliteConnection connection, string tableName return bool.TryParse(value, out var parsed) ? parsed : null; } - private static string[]? ReadUnknownExtensionFiles(SqliteConnection connection) + private readonly record struct UnknownExtensionFileSample(string[]? Files, int? Count, int? Limit, bool? Truncated); + + private static UnknownExtensionFileSample ReadUnknownExtensionFileSample(SqliteConnection connection) { var json = ReadMetaString(connection, DbContext.UnknownExtensionFilePathsMetaKey); if (string.IsNullOrWhiteSpace(json) || json.Length > MaxImportManifestBytes) - return null; + return new(null, null, null, null); try { var files = JsonSerializer.Deserialize(json); - if (files == null || files.Length == 0) - return null; + if (files == null) + return new(null, 0, ManifestUnknownExtensionFileLimit, false); - return files + var validFiles = files .Where(path => !string.IsNullOrWhiteSpace(path)) + .ToArray(); + if (validFiles.Length == 0) + return new(null, 0, ManifestUnknownExtensionFileLimit, false); + + var sample = validFiles .Take(ManifestUnknownExtensionFileLimit) .Select(path => path.Length <= ManifestUnknownExtensionPathCharLimit ? path : path[..ManifestUnknownExtensionPathCharLimit]) .ToArray(); + + return new(sample, sample.Length, ManifestUnknownExtensionFileLimit, validFiles.Length > sample.Length); } catch (JsonException) { - return null; + return new(null, null, null, null); } } @@ -1367,7 +1422,13 @@ internal sealed record ExportManifest( [property: JsonPropertyName("unknown_extension_files_truncated")] bool? UnknownExtensionFilesTruncated = null, [property: JsonPropertyName("unknown_extension_file_path_limit")] - int? UnknownExtensionFilePathLimit = null); + int? UnknownExtensionFilePathLimit = null, + [property: JsonPropertyName("unknown_extension_file_sample_count")] + int? UnknownExtensionFileSampleCount = null, + [property: JsonPropertyName("unknown_extension_file_sample_limit")] + int? UnknownExtensionFileSampleLimit = null, + [property: JsonPropertyName("unknown_extension_file_sample_truncated")] + bool? UnknownExtensionFileSampleTruncated = null); internal sealed record ExportImportErrorResult( [property: JsonPropertyName("api_version")] string ApiVersion, [property: JsonPropertyName("status")] string Status, @@ -1392,7 +1453,14 @@ internal sealed record ImportDryRunResult( [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? PrunedProjectRoot, [property: JsonPropertyName("replacement_would_be_allowed")] bool ReplacementWouldBeAllowed, - [property: JsonPropertyName("validation_phases")] IReadOnlyList ValidationPhases); + [property: JsonPropertyName("validation_phases")] IReadOnlyList ValidationPhases, + [property: JsonPropertyName("unknown_extension_file_count")] long? UnknownExtensionFileCount = null, + [property: JsonPropertyName("unknown_extension_files")] string[]? UnknownExtensionFiles = null, + [property: JsonPropertyName("unknown_extension_files_truncated")] bool? UnknownExtensionFilesTruncated = null, + [property: JsonPropertyName("unknown_extension_file_path_limit")] int? UnknownExtensionFilePathLimit = null, + [property: JsonPropertyName("unknown_extension_file_sample_count")] int? UnknownExtensionFileSampleCount = null, + [property: JsonPropertyName("unknown_extension_file_sample_limit")] int? UnknownExtensionFileSampleLimit = null, + [property: JsonPropertyName("unknown_extension_file_sample_truncated")] bool? UnknownExtensionFileSampleTruncated = null); internal sealed record ExportArchiveResult(string ApiVersion, string ArchivePath, string DbPath); private sealed record CtagsExportOptions( string? Lang, @@ -1423,5 +1491,12 @@ internal sealed record ImportResult( bool PrunedPaths, [property: JsonPropertyName("pruned_project_root")] [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - string? PrunedProjectRoot); + string? PrunedProjectRoot, + [property: JsonPropertyName("unknown_extension_file_count")] long? UnknownExtensionFileCount = null, + [property: JsonPropertyName("unknown_extension_files")] string[]? UnknownExtensionFiles = null, + [property: JsonPropertyName("unknown_extension_files_truncated")] bool? UnknownExtensionFilesTruncated = null, + [property: JsonPropertyName("unknown_extension_file_path_limit")] int? UnknownExtensionFilePathLimit = null, + [property: JsonPropertyName("unknown_extension_file_sample_count")] int? UnknownExtensionFileSampleCount = null, + [property: JsonPropertyName("unknown_extension_file_sample_limit")] int? UnknownExtensionFileSampleLimit = null, + [property: JsonPropertyName("unknown_extension_file_sample_truncated")] bool? UnknownExtensionFileSampleTruncated = null); } diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs index 0b2d4a9e13..e84f36b587 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs @@ -1,4 +1,5 @@ using System.IO.Compression; +using System.Globalization; using System.Security.Cryptography; using System.Text.Json; using CodeIndex.Cli; @@ -665,6 +666,125 @@ public void RunExportArchive_RelativeOutputReportsAndWritesFullPath_Issue3138() } } + [Fact] + public void RunExportArchive_ManifestReportsEmptyUnknownExtensionSampleList_Issue3715() + { + var projectRoot = TestProjectHelper.CreateTempProject("export_unknown_extensions_empty"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + SetUnknownExtensionPathSamples(dbPath, []); + + using var document = ExportArchiveManifest(projectRoot, dbPath); + var root = document.RootElement; + + Assert.Equal(0, root.GetProperty("unknown_extension_file_sample_count").GetInt32()); + Assert.Equal(DbContext.UnknownExtensionFilePathSampleLimit, root.GetProperty("unknown_extension_file_sample_limit").GetInt32()); + Assert.False(root.GetProperty("unknown_extension_file_sample_truncated").GetBoolean()); + Assert.True( + !root.TryGetProperty("unknown_extension_files", out var files) + || files.ValueKind == JsonValueKind.Null); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunExportArchive_ManifestReportsBoundedUnknownExtensionSampleList_Issue3715() + { + var projectRoot = TestProjectHelper.CreateTempProject("export_unknown_extensions_bounded"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + SetUnknownExtensionPathSamples(dbPath, ["tools/custom.foo", "docs/archive.bar"]); + + using var document = ExportArchiveManifest(projectRoot, dbPath); + var root = document.RootElement; + + Assert.Equal(2, root.GetProperty("unknown_extension_file_sample_count").GetInt32()); + Assert.Equal(DbContext.UnknownExtensionFilePathSampleLimit, root.GetProperty("unknown_extension_file_sample_limit").GetInt32()); + Assert.False(root.GetProperty("unknown_extension_file_sample_truncated").GetBoolean()); + Assert.Equal("tools/custom.foo", root.GetProperty("unknown_extension_files")[0].GetString()); + Assert.Equal("docs/archive.bar", root.GetProperty("unknown_extension_files")[1].GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunExportArchive_ManifestReportsTruncatedUnknownExtensionSampleList_Issue3715() + { + var projectRoot = TestProjectHelper.CreateTempProject("export_unknown_extensions_truncated"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sampleLimit = DbContext.UnknownExtensionFilePathSampleLimit; + var paths = Enumerable + .Range(0, sampleLimit + 3) + .Select(index => $"samples/file-{index:D2}.unknown") + .ToArray(); + SetUnknownExtensionPathSamples(dbPath, paths); + + using var document = ExportArchiveManifest(projectRoot, dbPath); + var root = document.RootElement; + var files = root.GetProperty("unknown_extension_files"); + + Assert.Equal(paths.Length, root.GetProperty("unknown_extension_file_count").GetInt64()); + Assert.Equal(sampleLimit, root.GetProperty("unknown_extension_file_sample_count").GetInt32()); + Assert.Equal(sampleLimit, root.GetProperty("unknown_extension_file_sample_limit").GetInt32()); + Assert.True(root.GetProperty("unknown_extension_file_sample_truncated").GetBoolean()); + Assert.Equal(sampleLimit, files.GetArrayLength()); + Assert.Equal(paths[0], files[0].GetString()); + Assert.Equal(paths[sampleLimit - 1], files[sampleLimit - 1].GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunImport_DryRunJsonReportsUnknownExtensionSampleMetadata_Issue3715() + { + var projectRoot = TestProjectHelper.CreateTempProject("import_unknown_extensions_metadata"); + try + { + var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sampleLimit = DbContext.UnknownExtensionFilePathSampleLimit; + var paths = Enumerable + .Range(0, sampleLimit + 2) + .Select(index => $"imports/file-{index:D2}.unknown") + .ToArray(); + SetUnknownExtensionPathSamples(sourceDbPath, paths); + var archivePath = ExportArchive(projectRoot, sourceDbPath); + var importDbPath = Path.Combine(projectRoot, "imported", "codeindex.db"); + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunImport([archivePath, "--db", importDbPath, "--dry-run", "--json"], jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var root = document.RootElement; + var files = root.GetProperty("unknown_extension_files"); + Assert.Equal(paths.Length, root.GetProperty("unknown_extension_file_count").GetInt64()); + Assert.Equal(sampleLimit, root.GetProperty("unknown_extension_file_sample_count").GetInt32()); + Assert.Equal(sampleLimit, root.GetProperty("unknown_extension_file_sample_limit").GetInt32()); + Assert.True(root.GetProperty("unknown_extension_file_sample_truncated").GetBoolean()); + Assert.Equal(sampleLimit, files.GetArrayLength()); + Assert.Equal(paths[sampleLimit - 1], files[sampleLimit - 1].GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void CreateDatabaseSnapshot_AppliesPrivateFileMode() { @@ -1023,6 +1143,40 @@ private static string CreateArchiveWithManifestAndDatabase(string workDir, strin return archivePath; } + private static JsonDocument ExportArchiveManifest(string projectRoot, string dbPath) + { + var archivePath = ExportArchive(projectRoot, dbPath); + using var archive = ZipFile.OpenRead(archivePath); + var manifestEntry = archive.GetEntry("manifest.json") + ?? throw new InvalidOperationException("manifest.json entry was not found"); + using var stream = manifestEntry.Open(); + return JsonDocument.Parse(stream); + } + + private static string ExportArchive(string projectRoot, string dbPath) + { + var archivePath = Path.Combine(projectRoot, $"codeindex-{Guid.NewGuid():N}.cdidx.zip"); + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport([archivePath, "--db", dbPath], new JsonSerializerOptions(), "test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Contains("Exported CodeIndex archive", stdout); + Assert.Equal(string.Empty, stderr); + return archivePath; + } + + private static void SetUnknownExtensionPathSamples(string dbPath, string[] paths) + { + using var db = new DbContext(dbPath); + var writer = new DbWriter(db.Connection); + writer.SetMeta(DbContext.UnknownExtensionFileCountMetaKey, paths.Length.ToString(CultureInfo.InvariantCulture)); + writer.SetMeta(DbContext.UnknownExtensionFilePathsMetaKey, JsonSerializer.Serialize(paths)); + writer.SetMeta(DbContext.UnknownExtensionFilesTruncatedMetaKey, bool.FalseString); + writer.SetMeta( + DbContext.UnknownExtensionFilePathLimitMetaKey, + DbContext.UnknownExtensionFilePathSampleLimit.ToString(CultureInfo.InvariantCulture)); + } + private static void AssertExportImportError(string stdout, string command, string phase, string errorCode) { using var document = JsonDocument.Parse(stdout); From 904853e8c653a663a43610ec66b3867d298bac68 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 21:42:25 +0900 Subject: [PATCH 4/5] Centralize CLI environment reads (#3690) --- changelog.d/unreleased/3690.changed.md | 25 ++++++++++ src/CodeIndex/Cli/CdidxConfigFile.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.cs | 46 +++++++++---------- src/CodeIndex/Cli/GlobalToolLog.cs | 8 ++-- src/CodeIndex/Cli/ProgramRunner.cs | 2 +- src/CodeIndex/Cli/SearchAuditRecipes.cs | 2 +- src/CodeIndex/Cli/UpdateChecker.cs | 6 +-- src/CodeIndex/Database/DbReader.cs | 3 +- src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 3 +- tests/CodeIndex.Tests/ConsoleUiTests.cs | 14 ++++++ tests/CodeIndex.Tests/GlobalToolLogTests.cs | 15 ++++++ tests/CodeIndex.Tests/ProgramRunnerTests.cs | 16 +++++++ 12 files changed, 107 insertions(+), 35 deletions(-) create mode 100644 changelog.d/unreleased/3690.changed.md diff --git a/changelog.d/unreleased/3690.changed.md b/changelog.d/unreleased/3690.changed.md new file mode 100644 index 0000000000..f005de09e8 --- /dev/null +++ b/changelog.d/unreleased/3690.changed.md @@ -0,0 +1,25 @@ +--- +category: changed +issues: + - 3690 +affected: + - src/CodeIndex/Cli/CdidxConfigFile.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/GlobalToolLog.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Cli/UpdateChecker.cs + - src/CodeIndex/Database/DbReader.cs + - src/CodeIndex/Indexer/Scanning/FileIndexer.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - tests/CodeIndex.Tests/GlobalToolLogTests.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **CLI environment variable reads now use scoped `CdidxEnvironment` overrides more consistently (#3690)** — config loading, console policy, logging, update checks, recipe discovery, fold diagnostics, and index file-size limits can be tested without mutating process-global environment variables. + +## 日本語 + +- **CLI の環境変数読み取りが scoped な `CdidxEnvironment` override をより一貫して使うようになりました (#3690)** — config loading、console policy、logging、update check、recipe discovery、fold diagnostics、index file-size limit は process-global な環境変数を変更せずにテストできます。 diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index bf7535e23d..3a6619215e 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -88,7 +88,7 @@ internal sealed record LoadResult(string? Path, string? Error) /// validation errors. No-op when `CDIDX_DISABLE_CONFIG_FILE=1` is set. /// internal static LoadResult Load(string startingDirectory) - => Load(startingDirectory, Environment.GetEnvironmentVariable); + => Load(startingDirectory, CdidxEnvironment.GetEnvironmentVariable); internal static LoadResult Load( string startingDirectory, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 706bb17aa9..6a2617c4d6 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -390,7 +390,7 @@ internal static bool ShouldUseProgressAnimation() if (IsTruthyEnvironmentVariable(DisableProgressEnvironmentVariable)) return false; - var reducedMotion = Environment.GetEnvironmentVariable(PrefersReducedMotionEnvironmentVariable); + var reducedMotion = CdidxEnvironment.GetEnvironmentVariable(PrefersReducedMotionEnvironmentVariable); return string.IsNullOrWhiteSpace(reducedMotion) || !IsTruthyEnvironmentValue(reducedMotion); } @@ -1941,7 +1941,7 @@ public static ColorPalette ResolveColorPalette() if (_explicitPalette is { } explicitPalette) return explicitPalette; - var envPalette = Environment.GetEnvironmentVariable("CDIDX_COLOR_PALETTE"); + var envPalette = CdidxEnvironment.GetEnvironmentVariable("CDIDX_COLOR_PALETTE"); if (!string.IsNullOrWhiteSpace(envPalette) && TryParseColorPalette(envPalette, out var parsed)) return parsed; @@ -1957,7 +1957,7 @@ public static ColorPalette ResolveColorPalette() /// internal static ColorPalette DetectColorPalette() { - var colorTerm = Environment.GetEnvironmentVariable("COLORTERM"); + var colorTerm = CdidxEnvironment.GetEnvironmentVariable("COLORTERM"); if (!string.IsNullOrEmpty(colorTerm)) { var ct = colorTerm.Trim().ToLowerInvariant(); @@ -1965,7 +1965,7 @@ internal static ColorPalette DetectColorPalette() return ColorPalette.Truecolor; } - var term = Environment.GetEnvironmentVariable("TERM"); + var term = CdidxEnvironment.GetEnvironmentVariable("TERM"); if (!string.IsNullOrEmpty(term)) { var t = term.ToLowerInvariant(); @@ -2159,14 +2159,14 @@ public static bool ShouldUseColor() private static bool HasTerminalEnvironmentHint() { - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WT_SESSION"))) + if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("WT_SESSION"))) return true; - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WT_PROFILE_ID"))) + if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("WT_PROFILE_ID"))) return true; - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TERM_PROGRAM"))) + if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("TERM_PROGRAM"))) return true; - var term = Environment.GetEnvironmentVariable("TERM"); + var term = CdidxEnvironment.GetEnvironmentVariable("TERM"); return !string.IsNullOrWhiteSpace(term) && !term.Equals("dumb", StringComparison.OrdinalIgnoreCase); } @@ -2176,7 +2176,7 @@ private static bool IsTerminalEnvironmentDisabled() private static bool IsCiEnvironment() { - var ci = Environment.GetEnvironmentVariable("CI"); + var ci = CdidxEnvironment.GetEnvironmentVariable("CI"); return !string.IsNullOrEmpty(ci) && !ci.Equals("0", StringComparison.OrdinalIgnoreCase) && !ci.Equals("false", StringComparison.OrdinalIgnoreCase) @@ -2227,17 +2227,17 @@ internal static void ResetTerminalCapabilityCacheForTests() private static bool IsForceColorRequested() { - var force = Environment.GetEnvironmentVariable("CLICOLOR_FORCE"); + var force = CdidxEnvironment.GetEnvironmentVariable("CLICOLOR_FORCE"); return !string.IsNullOrEmpty(force) && force != "0"; } private static bool IsNoColorRequested() { - var noColor = Environment.GetEnvironmentVariable("NO_COLOR"); + var noColor = CdidxEnvironment.GetEnvironmentVariable("NO_COLOR"); if (!string.IsNullOrEmpty(noColor)) return true; - var cliColor = Environment.GetEnvironmentVariable("CLICOLOR"); + var cliColor = CdidxEnvironment.GetEnvironmentVariable("CLICOLOR"); return cliColor == "0"; } @@ -2262,29 +2262,29 @@ private static bool IsAsciiOutputRequested() if (_asciiOutputForced) return true; - var ascii = Environment.GetEnvironmentVariable("CDIDX_ASCII"); + var ascii = CdidxEnvironment.GetEnvironmentVariable("CDIDX_ASCII"); if (!string.IsNullOrEmpty(ascii) && ascii != "0") return true; - var noUnicode = Environment.GetEnvironmentVariable("NO_UNICODE"); + var noUnicode = CdidxEnvironment.GetEnvironmentVariable("NO_UNICODE"); if (!string.IsNullOrEmpty(noUnicode) && noUnicode != "0") return true; - var atBridgeType = Environment.GetEnvironmentVariable("AT_BRIDGE_TYPE"); + var atBridgeType = CdidxEnvironment.GetEnvironmentVariable("AT_BRIDGE_TYPE"); if (!string.IsNullOrEmpty(atBridgeType)) return true; - var accessibilityEnabled = Environment.GetEnvironmentVariable("ACCESSIBILITY_ENABLED"); + var accessibilityEnabled = CdidxEnvironment.GetEnvironmentVariable("ACCESSIBILITY_ENABLED"); if (!string.IsNullOrEmpty(accessibilityEnabled) && accessibilityEnabled != "0") return true; - return IsPosixLocale(Environment.GetEnvironmentVariable("LC_ALL")) - || IsPosixLocale(Environment.GetEnvironmentVariable("LC_CTYPE")) - || IsPosixLocale(Environment.GetEnvironmentVariable("LANG")); + return IsPosixLocale(CdidxEnvironment.GetEnvironmentVariable("LC_ALL")) + || IsPosixLocale(CdidxEnvironment.GetEnvironmentVariable("LC_CTYPE")) + || IsPosixLocale(CdidxEnvironment.GetEnvironmentVariable("LANG")); } private static bool IsTruthyEnvironmentVariable(string name) - => IsTruthyEnvironmentValue(Environment.GetEnvironmentVariable(name)); + => IsTruthyEnvironmentValue(CdidxEnvironment.GetEnvironmentVariable(name)); private static bool IsTruthyEnvironmentValue(string? value) { @@ -2295,7 +2295,7 @@ private static bool IsTruthyEnvironmentValue(string? value) } private static bool IsDumbTerminal() - => string.Equals(Environment.GetEnvironmentVariable("TERM"), "dumb", StringComparison.OrdinalIgnoreCase); + => string.Equals(CdidxEnvironment.GetEnvironmentVariable("TERM"), "dumb", StringComparison.OrdinalIgnoreCase); private static bool IsPosixLocale(string? locale) => locale != null @@ -2310,7 +2310,7 @@ private static bool IsUnicodeLocale(string locale) { foreach (var name in names) { - var value = Environment.GetEnvironmentVariable(name); + var value = CdidxEnvironment.GetEnvironmentVariable(name); if (!string.IsNullOrEmpty(value)) return value; } @@ -2368,7 +2368,7 @@ private static int GetFallbackWindowWidth(Exception? exception) private static bool TryGetColumnsEnvironmentWidth(out int width) { - var columns = Environment.GetEnvironmentVariable("COLUMNS"); + var columns = CdidxEnvironment.GetEnvironmentVariable("COLUMNS"); if (int.TryParse(columns, NumberStyles.Integer, CultureInfo.InvariantCulture, out width) && width > 0) return true; diff --git a/src/CodeIndex/Cli/GlobalToolLog.cs b/src/CodeIndex/Cli/GlobalToolLog.cs index 8769f124bc..ed8f1db185 100644 --- a/src/CodeIndex/Cli/GlobalToolLog.cs +++ b/src/CodeIndex/Cli/GlobalToolLog.cs @@ -305,15 +305,15 @@ private static IEnumerable EnumerateLogDirectoryCandidates() if (!string.IsNullOrWhiteSpace(overrideDirectory)) yield return ExpandUserLogDirectory(overrideDirectory); - var xdgStateHome = Environment.GetEnvironmentVariable("XDG_STATE_HOME"); + var xdgStateHome = CdidxEnvironment.GetEnvironmentVariable("XDG_STATE_HOME"); if (!string.IsNullOrWhiteSpace(xdgStateHome)) yield return Path.Combine(xdgStateHome, "cdidx", "logs"); - var xdgCacheHome = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); + var xdgCacheHome = CdidxEnvironment.GetEnvironmentVariable("XDG_CACHE_HOME"); if (!string.IsNullOrWhiteSpace(xdgCacheHome)) yield return Path.Combine(xdgCacheHome, "cdidx", "logs"); - var xdgRuntimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + var xdgRuntimeDir = CdidxEnvironment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); if (!string.IsNullOrWhiteSpace(xdgRuntimeDir)) yield return Path.Combine(xdgRuntimeDir, "cdidx", "logs"); @@ -490,7 +490,7 @@ internal static string FormatArgs(string[] args) private static IEnumerable RedactArgs(string[] args) { - var mode = Environment.GetEnvironmentVariable("CDIDX_LOG_REDACT"); + var mode = CdidxEnvironment.GetEnvironmentVariable("CDIDX_LOG_REDACT"); if (string.Equals(mode, "none", StringComparison.OrdinalIgnoreCase)) return args; diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 2bfc3599ec..3299b5f905 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -1007,7 +1007,7 @@ private static bool TryConsumeValueFlag(string[] args, ref int index, string arg private static bool IsTruthyEnvironmentVariable(string name) { - var value = Environment.GetEnvironmentVariable(name); + var value = CdidxEnvironment.GetEnvironmentVariable(name); return value != null && !string.Equals(value, "0", StringComparison.OrdinalIgnoreCase) && !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 34300c272f..56a4df752b 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -374,7 +374,7 @@ internal static bool TryGet(string name, out SearchAuditRecipe recipe) private static List ReadConfiguredRecipeSourcePaths(List diagnostics) { - var raw = Environment.GetEnvironmentVariable(RecipePathsEnvironmentVariable); + var raw = CdidxEnvironment.GetEnvironmentVariable(RecipePathsEnvironmentVariable); if (string.IsNullOrWhiteSpace(raw)) return []; diff --git a/src/CodeIndex/Cli/UpdateChecker.cs b/src/CodeIndex/Cli/UpdateChecker.cs index ecc6dc0344..f83a390f1e 100644 --- a/src/CodeIndex/Cli/UpdateChecker.cs +++ b/src/CodeIndex/Cli/UpdateChecker.cs @@ -201,7 +201,7 @@ internal static bool IsNewerRelease(string? latestTag, string currentVersion) internal static bool IsDisabled() { - var value = Environment.GetEnvironmentVariable(DisableEnvVar); + var value = CdidxEnvironment.GetEnvironmentVariable(DisableEnvVar); return value is "1" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); } @@ -326,7 +326,7 @@ private static async Task ThrowIfRateLimitedAsync(HttpResponseMessage response, internal static string ResolveDefaultCachePath() { - var xdgCacheHome = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); + var xdgCacheHome = CdidxEnvironment.GetEnvironmentVariable("XDG_CACHE_HOME"); var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); @@ -451,7 +451,7 @@ private static void ReportCacheDiagnostic(string code, string cachePath, Excepti private static bool ShouldEmitCacheDiagnostics() { - var value = Environment.GetEnvironmentVariable(DiagnosticsEnvVar)?.Trim(); + var value = CdidxEnvironment.GetEnvironmentVariable(DiagnosticsEnvVar)?.Trim(); return value is not null && (string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index 0d099d4bf1..55e81707e7 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -1,4 +1,5 @@ using CodeIndex.Indexer; +using CodeIndex.Cli; using Microsoft.Data.Sqlite; using System.Globalization; using System.Text; @@ -875,7 +876,7 @@ private static int ParseFoldVersion(SqliteConnection conn) private static bool ShouldVerifyFoldReadyRows() { - var value = Environment.GetEnvironmentVariable(VerifyFoldReadyRowsEnvironmentVariable); + var value = CdidxEnvironment.GetEnvironmentVariable(VerifyFoldReadyRowsEnvironmentVariable); return value is "1" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); } diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index 4bddbb99b5..5c46d486d2 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -4,6 +4,7 @@ using System.Text; using System.Text.Json; using System.Text.RegularExpressions; +using CodeIndex.Cli; using CodeIndex.Indexer.Extensibility; using CodeIndex.Models; using Microsoft.Win32.SafeHandles; @@ -1089,7 +1090,7 @@ private static long ResolveMaxFileSizeBytes(long? explicitMaxFileSizeBytes) if (explicitMaxFileSizeBytes is > 0 and <= int.MaxValue) return explicitMaxFileSizeBytes.Value; - var envValue = Environment.GetEnvironmentVariable(MaxFileSizeEnvironmentVariable); + var envValue = CdidxEnvironment.GetEnvironmentVariable(MaxFileSizeEnvironmentVariable); return TryParseMaxFileSizeBytes(envValue, out var envBytes) ? envBytes : DefaultMaxFileSizeBytes; diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 3409a6d71b..32fcc00eda 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -531,6 +531,20 @@ public void ShouldUseUnicodeGlyphs_CdidxAsciiEnvVarDisablesUnicode() }); } + [Fact] + public void ShouldUseUnicodeGlyphs_UsesScopedCdidxEnvironmentOverride_Issue3690() + { + var previous = Environment.GetEnvironmentVariable("CDIDX_ASCII"); + using var env = CdidxEnvironment.Push(new Dictionary + { + ["CDIDX_ASCII"] = "1", + ["LANG"] = "en_US.UTF-8", + }); + + Assert.Equal(previous, Environment.GetEnvironmentVariable("CDIDX_ASCII")); + Assert.False(ConsoleUi.ShouldUseUnicodeGlyphs()); + } + [Fact] public void ShouldUseUnicodeGlyphs_PosixLangDisablesUnicode() { diff --git a/tests/CodeIndex.Tests/GlobalToolLogTests.cs b/tests/CodeIndex.Tests/GlobalToolLogTests.cs index b9d94996e9..d07beb82aa 100644 --- a/tests/CodeIndex.Tests/GlobalToolLogTests.cs +++ b/tests/CodeIndex.Tests/GlobalToolLogTests.cs @@ -221,6 +221,21 @@ public void FormatArgs_RedactsSensitiveArgumentsByDefault() Assert.DoesNotContain("0123456789abcdef0123456789abcdef", formatted); } + [Fact] + public void FormatArgs_UsesScopedCdidxEnvironmentRedactionOverride_Issue3690() + { + var previous = Environment.GetEnvironmentVariable("CDIDX_LOG_REDACT"); + using var env = CdidxEnvironment.Push(new Dictionary + { + ["CDIDX_LOG_REDACT"] = "none", + }); + + var formatted = GlobalToolLog.FormatArgs(["--token=abc123"]); + + Assert.Equal(previous, Environment.GetEnvironmentVariable("CDIDX_LOG_REDACT")); + Assert.Contains("abc123", formatted); + } + [Fact] public void FormatArgs_RedactsUnderscoreSeparatedSecretArguments() { diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 27619d66f3..15f11aa13e 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -1132,6 +1132,22 @@ public void UpdateChecker_ResolveDefaultCachePath_IgnoresRelativeXdgCacheHome_Is } } + [Fact] + public void UpdateChecker_ResolveDefaultCachePath_UsesScopedCdidxEnvironmentOverride_Issue3690() + { + var previous = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); + var cacheRoot = Path.Combine(Path.GetTempPath(), $"cdidx_cache_scope_{Guid.NewGuid():N}"); + using var env = CdidxEnvironment.Push(new Dictionary + { + ["XDG_CACHE_HOME"] = cacheRoot, + }); + + var path = UpdateChecker.ResolveDefaultCachePath(); + + Assert.Equal(previous, Environment.GetEnvironmentVariable("XDG_CACHE_HOME")); + Assert.Equal(Path.Combine(Path.GetFullPath(cacheRoot), "cdidx", "update-check.json"), path); + } + [Fact] public void UpdateChecker_Check_RateLimitResponseReportsRetryMetadata_Issue3822() { From 73452838d18e5a5be779b3740d4772a828d87950 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 21:54:28 +0900 Subject: [PATCH 5/5] Route stderr diagnostics through shared sink (#3683) --- changelog.d/unreleased/3683.fixed.md | 24 + src/CodeIndex/Cli/ActiveWorkspace.cs | 2 +- src/CodeIndex/Cli/CdidxConfigFile.cs | 2 +- .../Cli/CodeIndexExceptionFormatter.cs | 6 +- src/CodeIndex/Cli/CommandErrorWriter.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.cs | 10 +- src/CodeIndex/Cli/DbCommandRunner.cs | 8 +- src/CodeIndex/Cli/DiffCommandRunner.cs | 4 +- .../Cli/ExportImportCommandRunner.cs | 6 +- src/CodeIndex/Cli/GitHubIssueReporter.cs | 10 +- src/CodeIndex/Cli/HookCommandRunner.cs | 2 +- src/CodeIndex/Cli/IndexCommandRunner.Parse.cs | 28 +- .../Cli/IndexCommandRunner.Update.cs | 8 +- .../Cli/IndexCommandRunner.UpdateTargets.cs | 2 +- .../Cli/IndexCommandRunner.Validation.cs | 4 +- src/CodeIndex/Cli/IndexCommandRunner.cs | 4 +- src/CodeIndex/Cli/IndexWatchRunner.cs | 10 +- src/CodeIndex/Cli/JsonEnvelopeWrapper.cs | 8 +- src/CodeIndex/Cli/JsonOutputFailure.cs | 4 +- src/CodeIndex/Cli/ProgramRunner.cs | 144 +++--- src/CodeIndex/Cli/QueryCommandRunner.Batch.cs | 28 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 434 +++++++++--------- src/CodeIndex/Cli/SuggestionStore.cs | 4 +- src/CodeIndex/Cli/SuggestionsCommandRunner.cs | 2 +- src/CodeIndex/Cli/UpdateChecker.cs | 2 +- src/CodeIndex/Database/DbConnectionFactory.cs | 2 +- src/CodeIndex/Database/DbContext.cs | 10 +- src/CodeIndex/Database/DbDebug.cs | 6 +- src/CodeIndex/Database/DbWriter.cs | 2 +- .../Diagnostics/BackgroundTaskObserver.cs | 4 +- .../ConfiguredSymbolExtractor.cs | 3 +- .../ExtractorPluginRegistry.Diagnostics.cs | 9 +- .../Indexer/Scanning/LanguageMapOverrides.cs | 3 +- .../SymbolExtractor.TypeScriptPathAliases.cs | 3 +- src/CodeIndex/Mcp/McpEnvironment.cs | 2 +- src/CodeIndex/Mcp/McpServer.cs | 18 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 2 +- src/CodeIndex/Mcp/RateLimiter.cs | 2 +- tests/CodeIndex.Tests/DbDebugTests.cs | 25 + .../CodeIndex.Tests/HookCommandRunnerTests.cs | 13 + 40 files changed, 465 insertions(+), 397 deletions(-) create mode 100644 changelog.d/unreleased/3683.fixed.md diff --git a/changelog.d/unreleased/3683.fixed.md b/changelog.d/unreleased/3683.fixed.md new file mode 100644 index 0000000000..97834dabad --- /dev/null +++ b/changelog.d/unreleased/3683.fixed.md @@ -0,0 +1,24 @@ +--- +category: fixed +issues: + - 3683 +affected: + - src/CodeIndex/Cli/CommandErrorWriter.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/IndexCommandRunner.Parse.cs + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Database/DbDebug.cs + - src/CodeIndex/Diagnostics/BackgroundTaskObserver.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs + - tests/CodeIndex.Tests/HookCommandRunnerTests.cs + - tests/CodeIndex.Tests/DbDebugTests.cs +--- + +## English + +- **Command-facing stderr diagnostics now use the shared CLI error writer (#3683)** — direct stderr writes are centralized behind `CommandErrorWriter`, with tests covering JSON-mode error isolation and redacted diagnostics. + +## 日本語 + +- **コマンド向け stderr 診断が shared CLI error writer を使うようになりました (#3683)** — direct stderr write は `CommandErrorWriter` に集約し、JSON mode の error 分離と redacted diagnostic をテストで固定しました。 diff --git a/src/CodeIndex/Cli/ActiveWorkspace.cs b/src/CodeIndex/Cli/ActiveWorkspace.cs index 9b6e14b24b..aa1d37b1d8 100644 --- a/src/CodeIndex/Cli/ActiveWorkspace.cs +++ b/src/CodeIndex/Cli/ActiveWorkspace.cs @@ -247,5 +247,5 @@ private static bool IsFullyQualifiedPath(string path) }; private static void WriteLoadWarning(string source, string reason) - => Console.Error.WriteLine($"[cdidx] Ignoring active workspace {source}: {ConsoleUi.FormatBoundedValue(reason)}. Hint: inspect or reset the active workspace configuration."); + => CommandErrorWriter.WriteStderr($"[cdidx] Ignoring active workspace {source}: {ConsoleUi.FormatBoundedValue(reason)}. Hint: inspect or reset the active workspace configuration."); } diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index 3a6619215e..0b4b95346a 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -529,7 +529,7 @@ internal static int RunValidate(string[] args, JsonSerializerOptions jsonOptions var result = Load(Environment.CurrentDirectory, name => name == DisableEnvVar ? null : Environment.GetEnvironmentVariable(name)); if (result.Failed) { - Console.Error.WriteLine(result.Error); + CommandErrorWriter.WriteStderr(result.Error); return CommandExitCodes.UsageError; } diff --git a/src/CodeIndex/Cli/CodeIndexExceptionFormatter.cs b/src/CodeIndex/Cli/CodeIndexExceptionFormatter.cs index ed05f5c48a..a00aa118a5 100644 --- a/src/CodeIndex/Cli/CodeIndexExceptionFormatter.cs +++ b/src/CodeIndex/Cli/CodeIndexExceptionFormatter.cs @@ -34,11 +34,11 @@ public static void Write(CodeIndexException ex, string[] args, JsonSerializerOpt // QueryCommandRunner / IndexCommandRunner already emit so downstream // parsers do not need a second format. // 既存の `Error [Exxx]: ...` 形に揃え、parser の差分を最小化する。 - Console.Error.WriteLine($"Error [{ex.Code}]: {ex.Message}"); + CommandErrorWriter.WriteStderr($"Error [{ex.Code}]: {ex.Message}"); if (!string.IsNullOrEmpty(ex.Path)) - Console.Error.WriteLine($"Path: {ex.Path}"); + CommandErrorWriter.WriteStderr($"Path: {ex.Path}"); if (!string.IsNullOrEmpty(ex.Hint)) - Console.Error.WriteLine($"Hint: {ex.Hint}"); + CommandErrorWriter.WriteStderr($"Hint: {ex.Hint}"); } internal static bool HasJsonFlag(string[] args) diff --git a/src/CodeIndex/Cli/CommandErrorWriter.cs b/src/CodeIndex/Cli/CommandErrorWriter.cs index 915d3947f1..e32c022cde 100644 --- a/src/CodeIndex/Cli/CommandErrorWriter.cs +++ b/src/CodeIndex/Cli/CommandErrorWriter.cs @@ -10,7 +10,7 @@ internal static class CommandErrorWriter internal static void WriteStdout(string message = "") => Console.WriteLine(message); - internal static void WriteStderr(string message = "") + internal static void WriteStderr(string? message = "") => Console.Error.WriteLine(message); internal static void WriteWarning(string message) diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 6a2617c4d6..82ae844c89 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -247,9 +247,9 @@ internal static void TryWriteErrorLine(string? value = null) try { if (value == null) - Console.Error.WriteLine(); + CommandErrorWriter.WriteStderr(); else - Console.Error.WriteLine(value); + CommandErrorWriter.WriteStderr(value); } catch (ObjectDisposedException) { @@ -592,7 +592,7 @@ public static void PrintWarning(string message) lock (TerminalLock) { ClearProgressLineCore(); - Console.Error.WriteLine($" [WARN] {message}"); + CommandErrorWriter.WriteStderr($" [WARN] {message}"); Console.Error.Flush(); Console.Out.Flush(); } @@ -1401,7 +1401,7 @@ public static bool PrintCompletions(string shell) } catch (ArgumentOutOfRangeException) { - Console.Error.WriteLine($"Unknown shell: {shell}. Supported: bash, zsh, fish, powershell"); + CommandErrorWriter.WriteStderr($"Unknown shell: {shell}. Supported: bash, zsh, fish, powershell"); return false; } } @@ -2359,7 +2359,7 @@ private static int GetFallbackWindowWidth(Exception? exception) if (_traceWidthDetectionFailures && !_widthDetectionTraceWritten) { var suffix = exception == null ? string.Empty : $" ({exception.GetType().Name}: {exception.Message})"; - Console.Error.WriteLine($"cdidx: console width detection failed; using COLUMNS or 80 columns{suffix}"); + CommandErrorWriter.WriteStderr($"cdidx: console width detection failed; using COLUMNS or 80 columns{suffix}"); _widthDetectionTraceWritten = true; } diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index 72dda58412..0b3405d1e7 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -292,7 +292,7 @@ private static int RunPrune(DbCommandOptions options, JsonSerializerOptions json Console.WriteLine($" orphan symbols : {result.OrphanSymbols:N0}"); Console.WriteLine($" total : {result.Total:N0}"); foreach (var warning in result.Warnings) - Console.Error.WriteLine($"Warning [{warning.Code}]: {warning.Message}"); + CommandErrorWriter.WriteStderr($"Warning [{warning.Code}]: {warning.Message}"); } return CommandExitCodes.Success; @@ -391,7 +391,7 @@ private static int RunListCheckpoints(DbCommandOptions options, JsonSerializerOp } foreach (var diagnostic in result.Diagnostics) - Console.Error.WriteLine($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); + CommandErrorWriter.WriteStderr($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); } return CommandExitCodes.Success; @@ -962,7 +962,7 @@ private static string RestoreCheckpoint(string fullDbPath, string name, string c } catch (Exception rollbackEx) when (IsRecoverableRestoreException(rollbackEx)) { - Console.Error.WriteLine($"Warning: failed to roll back database restore from backup {ConsoleUi.FormatBoundedValue(backupPath)} ({CommandErrorWriter.FormatSanitizedException(rollbackEx)})."); + CommandErrorWriter.WriteStderr($"Warning: failed to roll back database restore from backup {ConsoleUi.FormatBoundedValue(backupPath)} ({CommandErrorWriter.FormatSanitizedException(rollbackEx)})."); } throw; @@ -1070,7 +1070,7 @@ internal static void TryDeleteTemporaryDirectory(string path, string cleanupDesc { if (!TryValidateTemporaryDirectoryCleanupTarget(path, safeRoot, expectedNamePrefix, out var fullPath, out var validationFailure)) { - Console.Error.WriteLine($"Warning: skipped deleting {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({validationFailure})."); + CommandErrorWriter.WriteStderr($"Warning: skipped deleting {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({validationFailure})."); return; } diff --git a/src/CodeIndex/Cli/DiffCommandRunner.cs b/src/CodeIndex/Cli/DiffCommandRunner.cs index f11c24d59d..24d6a59b81 100644 --- a/src/CodeIndex/Cli/DiffCommandRunner.cs +++ b/src/CodeIndex/Cli/DiffCommandRunner.cs @@ -840,9 +840,9 @@ private static int WriteCommandError(bool json, JsonSerializerOptions jsonOption CliJsonSerializerContextFactory.Create(jsonOptions).CommandErrorJsonResult)); else { - Console.Error.WriteLine($"Error [{errorCode ?? CommandErrorCodes.UsageError}]: {message}"); + CommandErrorWriter.WriteStderr($"Error [{errorCode ?? CommandErrorCodes.UsageError}]: {message}"); if (!string.IsNullOrWhiteSpace(hint)) - Console.Error.WriteLine($"Hint: {hint}"); + CommandErrorWriter.WriteStderr($"Hint: {hint}"); } return exitCode; } diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index 76355fb630..865c4a86cb 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -1120,7 +1120,7 @@ internal static void ReplaceImportedDatabase(string tempPath, string fullDbPath) } catch (Exception rollbackEx) when (IsRecoverableReplacementException(rollbackEx)) { - Console.Error.WriteLine($"Warning: failed to roll back imported database replacement ({CommandErrorWriter.FormatSanitizedException(rollbackEx)})."); + CommandErrorWriter.WriteStderr($"Warning: failed to roll back imported database replacement ({CommandErrorWriter.FormatSanitizedException(rollbackEx)})."); } throw new IOException("import database replacement failed; rolled back the previous destination database when possible.", ex); @@ -1213,7 +1213,7 @@ private static void TryDeleteFile(string path, string? cleanupDescription = null catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) { if (!string.IsNullOrWhiteSpace(cleanupDescription)) - Console.Error.WriteLine($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + CommandErrorWriter.WriteStderr($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); } } @@ -1229,7 +1229,7 @@ private static void TryDeleteDirectoryIfEmpty(string path, string? cleanupDescri catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) { if (!string.IsNullOrWhiteSpace(cleanupDescription)) - Console.Error.WriteLine($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + CommandErrorWriter.WriteStderr($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); } } diff --git a/src/CodeIndex/Cli/GitHubIssueReporter.cs b/src/CodeIndex/Cli/GitHubIssueReporter.cs index c832c23fb6..2d5696fdd7 100644 --- a/src/CodeIndex/Cli/GitHubIssueReporter.cs +++ b/src/CodeIndex/Cli/GitHubIssueReporter.cs @@ -140,7 +140,7 @@ private static HttpClient CreateDefaultHttpClient() linkedCts.Token); if (existingLookup.Error != null) { - Console.Error.WriteLine(BuildSubmissionFailureMessage(existingLookup.Error)); + CommandErrorWriter.WriteStderr(BuildSubmissionFailureMessage(existingLookup.Error)); return SuggestionStore.SubmitAttemptResult.Failure(existingLookup.Error); } @@ -152,7 +152,7 @@ private static HttpClient CreateDefaultHttpClient() catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { var detail = $"{ex.GetType().Name}: GitHub submission timed out after {ResolveSubmitTimeout().TotalSeconds:0} seconds"; - Console.Error.WriteLine(BuildSubmissionFailureMessage(detail)); + CommandErrorWriter.WriteStderr(BuildSubmissionFailureMessage(detail)); return SuggestionStore.SubmitAttemptResult.Failure(detail); } catch (Exception ex) when (ShouldTreatAsSubmissionFailure(ex)) @@ -160,7 +160,7 @@ private static HttpClient CreateDefaultHttpClient() // Best-effort: log to stderr but do not propagate. // ベストエフォート: stderr にログ出力するが伝播しない。 var detail = CommandErrorWriter.FormatSanitizedException(ex); - Console.Error.WriteLine(BuildSubmissionFailureMessage(detail)); + CommandErrorWriter.WriteStderr(BuildSubmissionFailureMessage(detail)); return SuggestionStore.SubmitAttemptResult.Failure(detail); } } @@ -581,13 +581,13 @@ private static bool IsHexHash(string value) var rateLimitRetryAt = GetRateLimitRetryAt(response, TimeProvider.GetUtcNow().UtcDateTime); if (rateLimitRetryAt != null) { - Console.Error.WriteLine(BuildRateLimitFailureMessage((int)response.StatusCode, errorBody, rateLimitRetryAt.Value)); + CommandErrorWriter.WriteStderr(BuildRateLimitFailureMessage((int)response.StatusCode, errorBody, rateLimitRetryAt.Value)); return SuggestionStore.SubmitAttemptResult.RetryAfter( BuildRateLimitErrorDetail((int)response.StatusCode, errorBody, rateLimitRetryAt.Value), rateLimitRetryAt.Value); } - Console.Error.WriteLine(BuildApiFailureMessage((int)response.StatusCode, errorBody)); + CommandErrorWriter.WriteStderr(BuildApiFailureMessage((int)response.StatusCode, errorBody)); return SuggestionStore.SubmitAttemptResult.Failure(BuildApiErrorDetail((int)response.StatusCode, errorBody)); } diff --git a/src/CodeIndex/Cli/HookCommandRunner.cs b/src/CodeIndex/Cli/HookCommandRunner.cs index f1643ac07f..5ed1cd932e 100644 --- a/src/CodeIndex/Cli/HookCommandRunner.cs +++ b/src/CodeIndex/Cli/HookCommandRunner.cs @@ -361,7 +361,7 @@ private static int WriteResult( } private static void PrintUsage() - => Console.Error.WriteLine("Usage: cdidx hooks [--project ] [--force] [--json]"); + => CommandErrorWriter.WriteStderr("Usage: cdidx hooks [--project ] [--force] [--json]"); } public sealed record HookCommandOptions(string? Command, string? ProjectPath, bool Json, bool Force, bool ShowHelp, string? ParseError); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs index 664156ee38..c2b9c56435 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs @@ -145,7 +145,7 @@ public static IndexCommandOptions ParseArgs(string[] args) else { var displayValue = ConsoleUi.FormatBoundedValue(args[i + 1]); - Console.Error.WriteLine($"Warning: invalid --debounce value '{displayValue}' (ignored; must be a non-negative integer in milliseconds) / 不正な --debounce 値 '{displayValue}'(無視。ミリ秒の0以上の整数を指定)"); + CommandErrorWriter.WriteStderr($"Warning: invalid --debounce value '{displayValue}' (ignored; must be a non-negative integer in milliseconds) / 不正な --debounce 値 '{displayValue}'(無視。ミリ秒の0以上の整数を指定)"); i++; } break; @@ -198,14 +198,14 @@ public static IndexCommandOptions ParseArgs(string[] args) AddCommitRef(commit, commits, ref parseError); } if (commits.Count == 0) - Console.Error.WriteLine("Warning: --commits specified but no commit refs provided / --commits が指定されましたがコミットrefがありません"); + CommandErrorWriter.WriteStderr("Warning: --commits specified but no commit refs provided / --commits が指定されましたがコミットrefがありません"); break; case "--changed-between": changedBetweenSpecified = true; while (i + 1 < args.Length && !args[i + 1].StartsWith('-') && changedBetweenRefs.Count < 2) changedBetweenRefs.Add(args[++i]); if (changedBetweenRefs.Count != 2) - Console.Error.WriteLine("Warning: --changed-between requires exactly two refs / --changed-between は2つのrefが必要です"); + CommandErrorWriter.WriteStderr("Warning: --changed-between requires exactly two refs / --changed-between は2つのrefが必要です"); break; case "--solution" when i + 1 < args.Length: solutionPath = args[++i]; @@ -255,7 +255,7 @@ public static IndexCommandOptions ParseArgs(string[] args) while (i + 1 < args.Length && !args[i + 1].StartsWith('-')) updateFiles.Add(args[++i]); if (updateFiles.Count == 0) - Console.Error.WriteLine("Warning: --files specified but no file paths provided / --files が指定されましたがファイルパスがありません"); + CommandErrorWriter.WriteStderr("Warning: --files specified but no file paths provided / --files が指定されましたがファイルパスがありません"); break; case "--help" or "-h": return new IndexCommandOptions { ShowHelp = true }; @@ -291,8 +291,8 @@ public static IndexCommandOptions ParseArgs(string[] args) if (spinnerFlagCount > 1) { - Console.Error.WriteLine("\U0001f375 Simultaneous intake of beer and coffee is not recommended. How about some matcha instead?"); - Console.Error.WriteLine(" \u30d3\u30fc\u30eb\u3068\u30b3\u30fc\u30d2\u30fc\u306e\u540c\u6642\u6442\u53d6\u306f\u304a\u3059\u3059\u3081\u3057\u307e\u305b\u3093\u3002\u62b9\u8336\u306f\u3044\u304b\u304c\uff1f"); + CommandErrorWriter.WriteStderr("\U0001f375 Simultaneous intake of beer and coffee is not recommended. How about some matcha instead?"); + CommandErrorWriter.WriteStderr(" \u30d3\u30fc\u30eb\u3068\u30b3\u30fc\u30d2\u30fc\u306e\u540c\u6642\u6442\u53d6\u306f\u304a\u3059\u3059\u3081\u3057\u307e\u305b\u3093\u3002\u62b9\u8336\u306f\u3044\u304b\u304c\uff1f"); easterEgg = "--matcha"; } @@ -501,12 +501,12 @@ private static int ParseIndexParallelism(string value, int fallback, string sour return parsed; var displayValue = ConsoleUi.FormatBoundedValue(value); - Console.Error.WriteLine($"Warning: {source} value '{displayValue}' exceeds the maximum {MaxIndexParallelism}; using {MaxIndexParallelism} / {source} 値 '{displayValue}' は最大 {MaxIndexParallelism} を超えています。{MaxIndexParallelism} を使用します"); + CommandErrorWriter.WriteStderr($"Warning: {source} value '{displayValue}' exceeds the maximum {MaxIndexParallelism}; using {MaxIndexParallelism} / {source} 値 '{displayValue}' は最大 {MaxIndexParallelism} を超えています。{MaxIndexParallelism} を使用します"); return MaxIndexParallelism; } var invalidDisplayValue = ConsoleUi.FormatBoundedValue(value); - Console.Error.WriteLine($"Warning: invalid {source} value '{invalidDisplayValue}' (ignored; use a positive integer) / 不正な {source} 値 '{invalidDisplayValue}'(無視。正の整数を指定)"); + CommandErrorWriter.WriteStderr($"Warning: invalid {source} value '{invalidDisplayValue}' (ignored; use a positive integer) / 不正な {source} 値 '{invalidDisplayValue}'(無視。正の整数を指定)"); return fallback; } @@ -520,7 +520,7 @@ private static int ParseIndexParallelism(string value, int fallback, string sour return parsed; var displayValue = ConsoleUi.FormatBoundedValue(value); - Console.Error.WriteLine($"Warning: invalid {FileIndexer.MaxFileSizeEnvironmentVariable} value '{displayValue}' (ignored; use positive bytes or K/M/G suffixes) / 不正な {FileIndexer.MaxFileSizeEnvironmentVariable} 値 '{displayValue}'(無視。正の byte 数または K/M/G 接尾辞を指定)"); + CommandErrorWriter.WriteStderr($"Warning: invalid {FileIndexer.MaxFileSizeEnvironmentVariable} value '{displayValue}' (ignored; use positive bytes or K/M/G suffixes) / 不正な {FileIndexer.MaxFileSizeEnvironmentVariable} 値 '{displayValue}'(無視。正の byte 数または K/M/G 接尾辞を指定)"); return null; } @@ -530,7 +530,7 @@ private static int ParseIndexParallelism(string value, int fallback, string sour return parsed; var displayValue = ConsoleUi.FormatBoundedValue(value); - Console.Error.WriteLine($"Warning: invalid --max-file-bytes value '{displayValue}' (ignored; use positive bytes or K/M/G suffixes) / 不正な --max-file-bytes 値 '{displayValue}'(無視。正の byte 数または K/M/G 接尾辞を指定)"); + CommandErrorWriter.WriteStderr($"Warning: invalid --max-file-bytes value '{displayValue}' (ignored; use positive bytes or K/M/G suffixes) / 不正な --max-file-bytes 値 '{displayValue}'(無視。正の byte 数または K/M/G 接尾辞を指定)"); return fallback; } @@ -546,7 +546,7 @@ private static int ParseMaxSymbolsPerFile(string value, int fallback, string sou } var displayValue = ConsoleUi.FormatBoundedValue(value); - Console.Error.WriteLine($"Warning: invalid {source} value '{displayValue}' (ignored; use a positive integer) / 不正な {source} 値 '{displayValue}'(無視。正の整数を指定)"); + CommandErrorWriter.WriteStderr($"Warning: invalid {source} value '{displayValue}' (ignored; use a positive integer) / 不正な {source} 値 '{displayValue}'(無視。正の整数を指定)"); return fallback; } @@ -562,7 +562,7 @@ private static int ParseMaxReferencesPerFile(string value, int fallback, string } var displayValue = ConsoleUi.FormatBoundedValue(value); - Console.Error.WriteLine($"Warning: invalid {source} value '{displayValue}' (ignored; use a positive integer) / 不正な {source} 値 '{displayValue}'(無視。正の整数を指定)"); + CommandErrorWriter.WriteStderr($"Warning: invalid {source} value '{displayValue}' (ignored; use a positive integer) / 不正な {source} 値 '{displayValue}'(無視。正の整数を指定)"); return fallback; } @@ -580,7 +580,7 @@ private static DurationOutputFormat ParseDurationFormat(string value, DurationOu private static DurationOutputFormat WarnInvalidDurationFormat(string value, DurationOutputFormat fallback) { var displayValue = ConsoleUi.FormatBoundedValue(value); - Console.Error.WriteLine($"Warning: invalid --duration-format value '{displayValue}' (ignored; use auto, seconds, or hms) / 不正な --duration-format 値 '{displayValue}'(無視。auto, seconds, hms のいずれかを指定)"); + CommandErrorWriter.WriteStderr($"Warning: invalid --duration-format value '{displayValue}' (ignored; use auto, seconds, or hms) / 不正な --duration-format 値 '{displayValue}'(無視。auto, seconds, hms のいずれかを指定)"); return fallback; } @@ -620,7 +620,7 @@ private static CompletionNotificationMode WarnInvalidCompletionNotificationMode( private static void WarnInvalidCompletionNotificationEnvironmentValue(string value) { var displayValue = ConsoleUi.FormatBoundedValue(value); - Console.Error.WriteLine($"Warning: invalid {CompletionNotificationEnvironmentVariable} value '{displayValue}' (ignored; use auto, bell, osc9, desktop, or none) / 不正な {CompletionNotificationEnvironmentVariable} 値 '{displayValue}'(無視。auto, bell, osc9, desktop, none のいずれかを指定)"); + CommandErrorWriter.WriteStderr($"Warning: invalid {CompletionNotificationEnvironmentVariable} value '{displayValue}' (ignored; use auto, bell, osc9, desktop, or none) / 不正な {CompletionNotificationEnvironmentVariable} 値 '{displayValue}'(無視。auto, bell, osc9, desktop, none のいずれかを指定)"); } private static string? AbsolutizePathOption(string? value) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index 7f7269ce8b..58b0392ff6 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -232,7 +232,7 @@ void WriteUpdateVerboseStatus(string message) if (options.Json) { - Console.Error.WriteLine(message); + CommandErrorWriter.WriteStderr(message); return; } @@ -463,9 +463,9 @@ void ThrowIfUpdateCancelled() { PauseUpdateSpinnerForConsoleWrite(); if (options.Verbose) - Console.Error.WriteLine($" [ERR ] {relPath}: Could not probe file for indexability/language."); + CommandErrorWriter.WriteStderr($" [ERR ] {relPath}: Could not probe file for indexability/language."); else - Console.Error.WriteLine($" [ERR ] {relPath}: Could not probe file for indexability/language."); + CommandErrorWriter.WriteStderr($" [ERR ] {relPath}: Could not probe file for indexability/language."); ResumeUpdateSpinnerAfterConsoleWrite(); } continue; @@ -909,7 +909,7 @@ void ThrowIfUpdateCancelled() if (!options.Json) { PauseUpdateSpinnerForConsoleWrite(); - Console.Error.WriteLine(FormatPerFileErrorLine("ERR ", relPath, ex, errorMessage)); + CommandErrorWriter.WriteStderr(FormatPerFileErrorLine("ERR ", relPath, ex, errorMessage)); ResumeUpdateSpinnerAfterConsoleWrite(); } } diff --git a/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs b/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs index 55852adacf..17f00941b9 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs @@ -145,7 +145,7 @@ private static (CancellationTokenSource Cts, Task Task)? StartIndexJsonPhaseHear var detail = detailProvider?.Invoke(); var suffix = string.IsNullOrWhiteSpace(detail) ? string.Empty : $": {detail}"; - Console.Error.WriteLine($"cdidx: still {phase}{suffix}..."); + CommandErrorWriter.WriteStderr($"cdidx: still {phase}{suffix}..."); } }, token); return (cts, task); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Validation.cs b/src/CodeIndex/Cli/IndexCommandRunner.Validation.cs index 8df10c8f85..bc989842a2 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Validation.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Validation.cs @@ -188,8 +188,8 @@ private static int WriteRebuildUpdateModeConflict(IndexCommandOptions options, J } else { - Console.Error.WriteLine($"Error [{CommandErrorCodes.UsageError}]: --rebuild cannot be used with --commits, --changed-between, or --files (rebuild requires a full rescan)"); - Console.Error.WriteLine("Hint: use one of: " + rebuildConflictSynopsis + "."); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.UsageError}]: --rebuild cannot be used with --commits, --changed-between, or --files (rebuild requires a full rescan)"); + CommandErrorWriter.WriteStderr("Hint: use one of: " + rebuildConflictSynopsis + "."); } return CommandExitCodes.UsageError; } diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 957de40c2c..558fd14b3a 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -427,7 +427,7 @@ private static void WarnIfMemoryThresholdExceeded(IndexMemoryTimelineJsonResult? var peakMb = timeline.PeakWorkingSetBytes / (1024 * 1024); if (peakMb >= thresholdMb) - Console.Error.WriteLine($"Warning: cdidx working set reached {peakMb:N0} MB (CDIDX_MEM_WARN_MB={thresholdMb:N0})."); + CommandErrorWriter.WriteStderr($"Warning: cdidx working set reached {peakMb:N0} MB (CDIDX_MEM_WARN_MB={thresholdMb:N0})."); } private static void StampLastIndexRunMetadata( @@ -855,7 +855,7 @@ private static IReadOnlyList NormalizeUpdateFileTargets(string projectRo if (IsOutsideProjectRoot(relPath)) { if (!json) - Console.Error.WriteLine($" [WARN] Skipping file outside project root: {file}. Use a path under the indexed project root or run `cdidx index` from the correct workspace."); + CommandErrorWriter.WriteStderr($" [WARN] Skipping file outside project root: {file}. Use a path under the indexed project root or run `cdidx index` from the correct workspace."); continue; } diff --git a/src/CodeIndex/Cli/IndexWatchRunner.cs b/src/CodeIndex/Cli/IndexWatchRunner.cs index b8eb3bb659..2794df5675 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.cs @@ -314,7 +314,7 @@ private static int InvokeSubRunAndEmit( else { var human = FormatHumanSummary(eventStatus, batchSize, stopwatch.ElapsedMilliseconds, capturedJson, subRunExitCode); - Console.Error.WriteLine(human); + CommandErrorWriter.WriteStderr(human); } return subRunExitCode; @@ -441,8 +441,8 @@ private static void EmitWatchStarted( } else { - Console.Error.WriteLine(); - Console.Error.WriteLine($"[watch] Watching {projectRoot} for changes (debounce {(int)debounce.TotalMilliseconds} ms). Press Ctrl+C to stop."); + CommandErrorWriter.WriteStderr(); + CommandErrorWriter.WriteStderr($"[watch] Watching {projectRoot} for changes (debounce {(int)debounce.TotalMilliseconds} ms). Press Ctrl+C to stop."); } } @@ -466,7 +466,7 @@ private static void EmitWatchOverflow(IndexCommandOptions baseOptions, string? r else { var detail = string.IsNullOrEmpty(reason) ? string.Empty : $" ({reason})"; - Console.Error.WriteLine($"[watch] Watcher buffer overflowed{detail}; falling back to full rescan."); + CommandErrorWriter.WriteStderr($"[watch] Watcher buffer overflowed{detail}; falling back to full rescan."); } } @@ -485,7 +485,7 @@ private static void EmitWatchStopped(IndexCommandOptions baseOptions) } else { - Console.Error.WriteLine("[watch] Stopped."); + CommandErrorWriter.WriteStderr("[watch] Stopped."); } } diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs index 4c97ccaed7..46730d3e27 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs @@ -106,8 +106,8 @@ internal static int RunWrapped( { var message = $"--json-envelope captured output exceeded {captureLimitExceeded.MaxChars} characters."; var hint = "Reduce the result set with --limit/--top or run the command with --json for streaming NDJSON output."; - Console.Error.WriteLine($"Error [{CommandErrorCodes.UsageError}]: {message}"); - Console.Error.WriteLine($"Hint: {hint}"); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.UsageError}]: {message}"); + CommandErrorWriter.WriteStderr($"Hint: {hint}"); var envelopeError = new JsonObject { ["message"] = message, @@ -141,8 +141,8 @@ internal static int RunWrapped( exitCode = CommandExitCodes.InvalidArgument; var message = $"--json-envelope raw JSON item line exceeded {ex.MaxChars} characters."; var hint = "Run the command with --json for streaming NDJSON output or reduce the raw item size."; - Console.Error.WriteLine($"Error [{CommandErrorCodes.UsageError}]: {message}"); - Console.Error.WriteLine($"Hint: {hint}"); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.UsageError}]: {message}"); + CommandErrorWriter.WriteStderr($"Hint: {hint}"); parseError = new JsonObject { ["message"] = message, diff --git a/src/CodeIndex/Cli/JsonOutputFailure.cs b/src/CodeIndex/Cli/JsonOutputFailure.cs index cfa95254bb..a594845290 100644 --- a/src/CodeIndex/Cli/JsonOutputFailure.cs +++ b/src/CodeIndex/Cli/JsonOutputFailure.cs @@ -14,8 +14,8 @@ internal static bool TryHandle(Exception ex, out int exitCode) return false; } - Console.Error.WriteLine($"Error [{CommandErrorCodes.FeatureUnavailable}]: --json is not available on this trimmed build."); - Console.Error.WriteLine("Hint: use `cdidx mcp` for structured output, omit `--json` for human-readable output, or use the NuGet/global-tool build if you need CLI JSON."); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.FeatureUnavailable}]: --json is not available on this trimmed build."); + CommandErrorWriter.WriteStderr("Hint: use `cdidx mcp` for structured output, omit `--json` for human-readable output, or use the NuGet/global-tool build if you need CLI JSON."); exitCode = CommandExitCodes.FeatureUnavailable; return true; } diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 3299b5f905..eaf71fc34e 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -211,7 +211,7 @@ internal static int Run( catch (OperationCanceledException ex) { GlobalToolLog.Error($"command_complete exit_code={CommandExitCodes.CancelledBySignal} operation_cancelled", ex, includeStacks: false); - Console.Error.WriteLine("Error: command cancelled before it could complete."); + CommandErrorWriter.WriteStderr("Error: command cancelled before it could complete."); EmitCommandMetric(args[0], args, context.StartTimestamp, context.Stopwatch, CommandExitCodes.CancelledBySignal, ex.GetType().Name); return CommandExitCodes.CancelledBySignal; } @@ -226,7 +226,7 @@ internal static int Run( var unhandledExitCode = MapUnhandledExceptionExitCode(ex); GlobalToolLog.Error($"command_complete exit_code={unhandledExitCode} unhandled_exception", ex); - Console.Error.WriteLine("Error: command failed before it could complete. Run `cdidx report` for details."); + CommandErrorWriter.WriteStderr("Error: command failed before it could complete. Run `cdidx report` for details."); EmitCommandMetric(args[0], args, context.StartTimestamp, context.Stopwatch, unhandledExitCode, ex.GetType().Name); return unhandledExitCode; } @@ -274,8 +274,8 @@ private static int RunTestExtractor(string[] args, JsonSerializerOptions jsonOpt "Use a shallower expected-symbols JSON fixture."); } - Console.Error.WriteLine("Expected symbols did not match extracted symbols."); - Console.Error.WriteLine(actual); + CommandErrorWriter.WriteStderr("Expected symbols did not match extracted symbols."); + CommandErrorWriter.WriteStderr(actual); return CommandExitCodes.InvalidArgument; } } @@ -1459,7 +1459,7 @@ private static int CheckWorkspaceVersionPin(string appVersion, string startDirec if (!TryReadWorkspaceVersionPin(pinPath, out var required, out var warning)) { - Console.Error.WriteLine(warning); + CommandErrorWriter.WriteStderr(warning); return CommandExitCodes.Success; } @@ -1469,12 +1469,12 @@ private static int CheckWorkspaceVersionPin(string appVersion, string startDirec var message = $"workspace requires cdidx v{NormalizeVersion(required)}, but this binary is v{NormalizeVersion(appVersion)} ({pinPath})."; if (!strictVersion) { - Console.Error.WriteLine($"Warning: {message}"); + CommandErrorWriter.WriteStderr($"Warning: {message}"); return CommandExitCodes.Success; } - Console.Error.WriteLine($"Error: {message}"); - Console.Error.WriteLine("Hint: rerun without --strict-version to warn only, or install the pinned cdidx version for this workspace."); + CommandErrorWriter.WriteStderr($"Error: {message}"); + CommandErrorWriter.WriteStderr("Hint: rerun without --strict-version to warn only, or install the pinned cdidx version for this workspace."); return CommandExitCodes.ExUsage; } @@ -1769,7 +1769,7 @@ private static void EmitQueryTrace(string mode, string commandName, string[] sub var payload = BuildQueryTraceJson(commandName, subArgs, startTimestamp, elapsedMs, exitCode, resultCount); if (mode == "stderr") { - Console.Error.WriteLine(payload); + CommandErrorWriter.WriteStderr(payload); return; } @@ -2078,7 +2078,7 @@ private static int RunLsp( var options = QueryCommandRunner.ParseArgs(cmdArgs, jsonDefault: true); if (options.ParseError != null) { - Console.Error.WriteLine(options.ParseError); + CommandErrorWriter.WriteStderr(options.ParseError); PrintLspUsage(); return CommandExitCodes.UsageError; } @@ -2093,8 +2093,8 @@ private static int RunLsp( continue; } - Console.Error.WriteLine($"Error: {cmdArgs[i]} is not supported for lsp."); - Console.Error.WriteLine("Hint: use `--db ` to point at a specific index."); + CommandErrorWriter.WriteStderr($"Error: {cmdArgs[i]} is not supported for lsp."); + CommandErrorWriter.WriteStderr("Hint: use `--db ` to point at a specific index."); PrintLspUsage(); return CommandExitCodes.UsageError; } @@ -2103,7 +2103,7 @@ private static int RunLsp( { if (string.IsNullOrWhiteSpace(options.DbPath)) { - Console.Error.WriteLine("Error: database path could not be resolved."); + CommandErrorWriter.WriteStderr("Error: database path could not be resolved."); PrintLspUsage(); return CommandExitCodes.UsageError; } @@ -2112,15 +2112,15 @@ private static int RunLsp( && !File.Exists(LongPath.EnsureWindowsPrefix(options.DbPath))) { var resolvedPath = Path.GetFullPath(options.DbPath); - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbNotFound}]: database not found at {resolvedPath}"); - Console.Error.WriteLine("Hint: create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun `cdidx lsp`."); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbNotFound}]: database not found at {resolvedPath}"); + CommandErrorWriter.WriteStderr("Hint: create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun `cdidx lsp`."); return CommandExitCodes.DatabaseError; } using var db = new DbContext(options.DbPath); if (!db.TryValidateIsCodeIndexDb(out var validationReason)) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbError}]: invalid CodeIndex database: {validationReason}"); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbError}]: invalid CodeIndex database: {validationReason}"); return CommandExitCodes.DatabaseError; } @@ -2144,7 +2144,7 @@ private static int RunLsp( catch (Exception ex) { GlobalToolLog.Error("lsp_server_failed " + GlobalToolLog.FormatExceptionChain(ex)); - Console.Error.WriteLine($"Error: LSP server failed ({ex.GetType().Name}: {ex.Message})."); + CommandErrorWriter.WriteStderr($"Error: LSP server failed ({ex.GetType().Name}: {ex.Message})."); Console.Out.Flush(); Console.Error.Flush(); return CommandExitCodes.DatabaseError; @@ -2153,8 +2153,8 @@ private static int RunLsp( private static void PrintLspUsage() { - Console.Error.WriteLine("Usage: cdidx lsp [--db ]"); - Console.Error.WriteLine("Runs a read-only Language Server Protocol server over stdio using an existing CodeIndex database."); + CommandErrorWriter.WriteStderr("Usage: cdidx lsp [--db ]"); + CommandErrorWriter.WriteStderr("Runs a read-only Language Server Protocol server over stdio using an existing CodeIndex database."); } private sealed record McpRunOptions( @@ -2196,7 +2196,7 @@ private static int RunMcp(string[] cmdArgs, string appVersion) } catch (FormatException ex) { - Console.Error.WriteLine($"Error: {ex.Message}"); + CommandErrorWriter.WriteStderr($"Error: {ex.Message}"); PrintMcpUsage(); return CommandExitCodes.UsageError; } @@ -2219,7 +2219,7 @@ private static bool TryPrepareMcpRun(string[] cmdArgs, out McpRunOptions runOpti exitCode = CommandExitCodes.Success; if (!TryConsumeAuditLogFlags(ref cmdArgs, out var auditOptions, out var auditError)) { - Console.Error.WriteLine(auditError); + CommandErrorWriter.WriteStderr(auditError); PrintMcpUsage(); exitCode = CommandExitCodes.UsageError; return false; @@ -2227,7 +2227,7 @@ private static bool TryPrepareMcpRun(string[] cmdArgs, out McpRunOptions runOpti if (!TryConsumeSuggestionDedupThresholdFlag(ref cmdArgs, out var suggestionDedupThreshold, out var thresholdError)) { - Console.Error.WriteLine(thresholdError); + CommandErrorWriter.WriteStderr(thresholdError); PrintMcpUsage(); exitCode = CommandExitCodes.UsageError; return false; @@ -2235,7 +2235,7 @@ private static bool TryPrepareMcpRun(string[] cmdArgs, out McpRunOptions runOpti if (!TryExtractMcpTransportFlags(cmdArgs, out var transportSpec, out var listenSpec, out var transportError)) { - Console.Error.WriteLine(transportError); + CommandErrorWriter.WriteStderr(transportError); PrintMcpUsage(); exitCode = CommandExitCodes.UsageError; return false; @@ -2249,7 +2249,7 @@ private static bool TryPrepareMcpRun(string[] cmdArgs, out McpRunOptions runOpti var options = QueryCommandRunner.ParseArgs(residualArgs, jsonDefault: true); if (options.ParseError != null) { - Console.Error.WriteLine(options.ParseError); + CommandErrorWriter.WriteStderr(options.ParseError); PrintMcpUsage(); exitCode = CommandExitCodes.UsageError; return false; @@ -2283,10 +2283,10 @@ private static bool TryValidateMcpResidualArgs(string[] residualArgs, out int ex } if (residualArgs[i] == "--json") - Console.Error.WriteLine("Error: --json is not supported for mcp; MCP already speaks JSON-RPC over the selected transport."); + CommandErrorWriter.WriteStderr("Error: --json is not supported for mcp; MCP already speaks JSON-RPC over the selected transport."); else - Console.Error.WriteLine($"Error: {residualArgs[i]} is not supported for mcp."); - Console.Error.WriteLine("Hint: use `--db ` to point at a specific index, `--transport stdio|http` to pick a transport, `--http-listen host:port` for HTTP, or `--audit-log ` to enable per-call auditing."); + CommandErrorWriter.WriteStderr($"Error: {residualArgs[i]} is not supported for mcp."); + CommandErrorWriter.WriteStderr("Hint: use `--db ` to point at a specific index, `--transport stdio|http` to pick a transport, `--http-listen host:port` for HTTP, or `--audit-log ` to enable per-call auditing."); PrintMcpUsage(); exitCode = CommandExitCodes.UsageError; return false; @@ -2302,7 +2302,7 @@ private static bool TryResolveMcpTransport(string? transportSpec, string? listen if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase) && !string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase)) { - Console.Error.WriteLine($"Error: --transport '{transport}' is not supported. Use `stdio` (default) or `http`."); + CommandErrorWriter.WriteStderr($"Error: --transport '{transport}' is not supported. Use `stdio` (default) or `http`."); PrintMcpUsage(); exitCode = CommandExitCodes.UsageError; return false; @@ -2310,7 +2310,7 @@ private static bool TryResolveMcpTransport(string? transportSpec, string? listen if (listenSpec != null && !string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase)) { - Console.Error.WriteLine("Error: --http-listen requires `--transport http`."); + CommandErrorWriter.WriteStderr("Error: --http-listen requires `--transport http`."); PrintMcpUsage(); exitCode = CommandExitCodes.UsageError; return false; @@ -2337,8 +2337,8 @@ private static bool TryOpenMcpAuditLog(AuditLogOptions auditOptions, out AuditLo } catch (Exception ex) { - Console.Error.WriteLine($"Error: failed to open audit log '{auditOptions.Path}' ({ex.GetType().Name}: {ex.Message})."); - Console.Error.WriteLine("Hint: pick a writable path or omit --audit-log to disable per-call auditing."); + CommandErrorWriter.WriteStderr($"Error: failed to open audit log '{auditOptions.Path}' ({ex.GetType().Name}: {ex.Message})."); + CommandErrorWriter.WriteStderr("Hint: pick a writable path or omit --audit-log to disable per-call auditing."); exitCode = CommandExitCodes.UsageError; return false; } @@ -2363,7 +2363,7 @@ private static int RunMcpServer(McpServer server, string transport, string? list catch (Exception ex) { GlobalToolLog.Error("mcp_server_failed " + GlobalToolLog.FormatExceptionChain(ex)); - Console.Error.WriteLine($"Error: MCP server failed ({ex.GetType().Name}: {ex.Message})."); + CommandErrorWriter.WriteStderr($"Error: MCP server failed ({ex.GetType().Name}: {ex.Message})."); Console.Out.Flush(); Console.Error.Flush(); return CommandExitCodes.DatabaseError; @@ -2393,7 +2393,7 @@ private static int RunMcpHttp(McpServer server, string listenSpec) } catch (FormatException ex) { - Console.Error.WriteLine($"Error: {ex.Message}"); + CommandErrorWriter.WriteStderr($"Error: {ex.Message}"); PrintMcpUsage(); return CommandExitCodes.UsageError; } @@ -2418,14 +2418,14 @@ private static int RunMcpHttp(McpServer server, string listenSpec) } catch (FormatException ex) { - Console.Error.WriteLine($"Error: {ex.Message}"); + CommandErrorWriter.WriteStderr($"Error: {ex.Message}"); PrintMcpUsage(); return CommandExitCodes.UsageError; } if (!resolved.IsLoopback && bearerToken is null) { - Console.Error.WriteLine($"Error: --transport http refuses to bind to '{resolved.Host}' without a shared secret. Set the `{McpHttpTokenEnvVar}` or `{McpAuthenticatorFactory.AuthTokenEnvVar}` environment variable, or bind to a loopback address."); + CommandErrorWriter.WriteStderr($"Error: --transport http refuses to bind to '{resolved.Host}' without a shared secret. Set the `{McpHttpTokenEnvVar}` or `{McpAuthenticatorFactory.AuthTokenEnvVar}` environment variable, or bind to a loopback address."); PrintMcpUsage(); return CommandExitCodes.UsageError; } @@ -2437,19 +2437,19 @@ private static int RunMcpHttp(McpServer server, string listenSpec) } catch (FormatException ex) { - Console.Error.WriteLine($"Error: {ex.Message}"); + CommandErrorWriter.WriteStderr($"Error: {ex.Message}"); PrintMcpUsage(); return CommandExitCodes.UsageError; } catch (ArgumentOutOfRangeException ex) { - Console.Error.WriteLine($"Error: {ex.Message}"); + CommandErrorWriter.WriteStderr($"Error: {ex.Message}"); PrintMcpUsage(); return CommandExitCodes.UsageError; } catch (HttpListenerException ex) { - Console.Error.WriteLine($"Error: {HttpMcpTransport.FormatBindFailureDiagnostic(resolved, ex)}"); + CommandErrorWriter.WriteStderr($"Error: {HttpMcpTransport.FormatBindFailureDiagnostic(resolved, ex)}"); return CommandExitCodes.UsageError; } @@ -2465,13 +2465,13 @@ private static int RunMcpHttp(McpServer server, string listenSpec) { if (transport.AuthDisabledWarning is { } authWarning) { - Console.Error.WriteLine($"[cdidx-mcp] Warning: {authWarning} Set `{McpHttpTokenEnvVar}` or `{McpAuthenticatorFactory.AuthTokenEnvVar}` to require bearer auth."); - Console.Error.WriteLine($"[cdidx-mcp] HTTP transport listening on {resolved.Prefix} (loopback, no auth)."); + CommandErrorWriter.WriteStderr($"[cdidx-mcp] Warning: {authWarning} Set `{McpHttpTokenEnvVar}` or `{McpAuthenticatorFactory.AuthTokenEnvVar}` to require bearer auth."); + CommandErrorWriter.WriteStderr($"[cdidx-mcp] HTTP transport listening on {resolved.Prefix} (loopback, no auth)."); GlobalToolLog.Info("mcp_http_auth_disabled_warning loopback=true"); } else { - Console.Error.WriteLine($"[cdidx-mcp] HTTP transport listening on {resolved.Prefix} (bearer auth required)."); + CommandErrorWriter.WriteStderr($"[cdidx-mcp] HTTP transport listening on {resolved.Prefix} (bearer auth required)."); } try @@ -2487,7 +2487,7 @@ private static int RunMcpHttp(McpServer server, string listenSpec) catch (Exception ex) { GlobalToolLog.Error("mcp_http_server_failed " + GlobalToolLog.FormatExceptionChain(ex)); - Console.Error.WriteLine($"Error: MCP HTTP server failed ({ex.GetType().Name}: {ex.Message})."); + CommandErrorWriter.WriteStderr($"Error: MCP HTTP server failed ({ex.GetType().Name}: {ex.Message})."); Console.Out.Flush(); Console.Error.Flush(); return CommandExitCodes.DatabaseError; @@ -2533,9 +2533,9 @@ private static string FormatLogValue(string? value) private static void PrintMcpUsage() { - Console.Error.WriteLine("Usage: cdidx mcp [--db ] [--transport stdio|http] [--http-listen ] [--audit-log ] [--audit-log-include-values] [--audit-log-max-bytes ] [--suggestion-dedup-threshold <0..1>]"); - Console.Error.WriteLine("Note: --json is not supported; MCP requests and responses are JSON-RPC over the selected transport."); - Console.Error.WriteLine($"HTTP limits: {HttpMcpTransport.MaxRequestBodyBytesEnvVar}= (1..{HttpMcpTransport.MaxConfiguredRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxQueueDepthEnvVar}= (1..{HttpMcpTransport.MaxConfiguredQueuedRequests.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxQueuedRequests.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxConcurrentHandlersEnvVar}= (1..{HttpMcpTransport.MaxConfiguredConcurrentHandlers.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxConcurrentHandlers.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxEventStreamsEnvVar}= (1..{HttpMcpTransport.MaxConfiguredEventStreams.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxEventStreams.ToString(CultureInfo.InvariantCulture)})."); + CommandErrorWriter.WriteStderr("Usage: cdidx mcp [--db ] [--transport stdio|http] [--http-listen ] [--audit-log ] [--audit-log-include-values] [--audit-log-max-bytes ] [--suggestion-dedup-threshold <0..1>]"); + CommandErrorWriter.WriteStderr("Note: --json is not supported; MCP requests and responses are JSON-RPC over the selected transport."); + CommandErrorWriter.WriteStderr($"HTTP limits: {HttpMcpTransport.MaxRequestBodyBytesEnvVar}= (1..{HttpMcpTransport.MaxConfiguredRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxQueueDepthEnvVar}= (1..{HttpMcpTransport.MaxConfiguredQueuedRequests.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxQueuedRequests.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxConcurrentHandlersEnvVar}= (1..{HttpMcpTransport.MaxConfiguredConcurrentHandlers.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxConcurrentHandlers.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxEventStreamsEnvVar}= (1..{HttpMcpTransport.MaxConfiguredEventStreams.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxEventStreams.ToString(CultureInfo.InvariantCulture)})."); } internal static bool TryConsumeSuggestionDedupThresholdFlag(ref string[] args, out string? thresholdValue, out string error) @@ -2841,8 +2841,8 @@ internal static int RunCheckUpdates( wantsJson = true; continue; } - Console.Error.WriteLine($"Error: --check-updates does not accept '{arg}'."); - Console.Error.WriteLine("Hint: use `cdidx --check-updates` or `cdidx --check-updates --json`."); + CommandErrorWriter.WriteStderr($"Error: --check-updates does not accept '{arg}'."); + CommandErrorWriter.WriteStderr("Hint: use `cdidx --check-updates` or `cdidx --check-updates --json`."); return CommandExitCodes.UsageError; } @@ -2998,10 +2998,10 @@ internal static int RunUpgrade( } else { - Console.Error.WriteLine("Error: cdidx upgrade cannot replace the running Windows binary directly."); - Console.Error.WriteLine($"Hint: update via NuGet global tool: {handoff.Command}"); - Console.Error.WriteLine($"Release page: {handoff.Url}"); - Console.Error.WriteLine($"Manual zip asset: {handoff.Asset} ({handoff.AssetUrl})"); + CommandErrorWriter.WriteStderr("Error: cdidx upgrade cannot replace the running Windows binary directly."); + CommandErrorWriter.WriteStderr($"Hint: update via NuGet global tool: {handoff.Command}"); + CommandErrorWriter.WriteStderr($"Release page: {handoff.Url}"); + CommandErrorWriter.WriteStderr($"Manual zip asset: {handoff.Asset} ({handoff.AssetUrl})"); } return CommandExitCodes.FeatureUnavailable; } @@ -3023,8 +3023,8 @@ internal static int RunUpgrade( } else { - Console.Error.WriteLine("Error: cdidx upgrade currently requires a POSIX shell installer on Linux or macOS."); - Console.Error.WriteLine("Hint: download the latest release asset manually, or rerun install.sh from a shell environment."); + CommandErrorWriter.WriteStderr("Error: cdidx upgrade currently requires a POSIX shell installer on Linux or macOS."); + CommandErrorWriter.WriteStderr("Hint: download the latest release asset manually, or rerun install.sh from a shell environment."); } return CommandExitCodes.FeatureUnavailable; } @@ -3048,10 +3048,10 @@ internal static int RunUpgrade( } else { - Console.Error.WriteLine($"Error: install directory is not writable: {installDir}"); + CommandErrorWriter.WriteStderr($"Error: install directory is not writable: {installDir}"); if (installDirectoryError != null) - Console.Error.WriteLine($"Reason: {installDirectoryError}"); - Console.Error.WriteLine("Hint: rerun with permissions that can write this directory, or reinstall cdidx into a per-user directory."); + CommandErrorWriter.WriteStderr($"Reason: {installDirectoryError}"); + CommandErrorWriter.WriteStderr("Hint: rerun with permissions that can write this directory, or reinstall cdidx into a per-user directory."); } return CommandExitCodes.UsageError; } @@ -3131,8 +3131,8 @@ internal static int RunUpgrade( } else { - Console.Error.WriteLine($"Error: upgrade failed before install.sh completed ({ex.GetType().Name}: {ex.Message})."); - Console.Error.WriteLine("Hint: rerun `install.sh` manually for the desired release."); + CommandErrorWriter.WriteStderr($"Error: upgrade failed before install.sh completed ({ex.GetType().Name}: {ex.Message})."); + CommandErrorWriter.WriteStderr("Hint: rerun `install.sh` manually for the desired release."); } return CommandExitCodes.InstallError; } @@ -3147,8 +3147,8 @@ internal static int RunUpgrade( private static int WriteUpgradeUsageError(string message) { - Console.Error.WriteLine($"Error: {message}"); - Console.Error.WriteLine("Hint: use `cdidx upgrade [--check-only] [--channel stable|prerelease] [--prerelease] [--version vX.Y.Z]`."); + CommandErrorWriter.WriteStderr($"Error: {message}"); + CommandErrorWriter.WriteStderr("Hint: use `cdidx upgrade [--check-only] [--channel stable|prerelease] [--prerelease] [--version vX.Y.Z]`."); return CommandExitCodes.UsageError; } @@ -3339,7 +3339,7 @@ internal static InstallerProcessResult RunInstallerProcessDetailed( if (process == null) { if (!suppressOutput) - Console.Error.WriteLine("Error: failed to start install.sh for upgrade."); + CommandErrorWriter.WriteStderr("Error: failed to start install.sh for upgrade."); return InstallerProcessResult.Failure(CommandExitCodes.InstallError); } @@ -3374,7 +3374,7 @@ internal static InstallerProcessResult RunInstallerProcessDetailed( if (!process.WaitForExit(ToWaitMilliseconds(InstallerKillWaitTimeout))) { if (!suppressOutput) - Console.Error.WriteLine("Error: install.sh was cancelled and did not exit after cancellation."); + CommandErrorWriter.WriteStderr("Error: install.sh was cancelled and did not exit after cancellation."); } else { @@ -3397,16 +3397,16 @@ internal static InstallerProcessResult RunInstallerProcessDetailed( if (!process.WaitForExit(ToWaitMilliseconds(InstallerKillWaitTimeout))) { if (!suppressOutput) - Console.Error.WriteLine("Error: install.sh timed out and did not exit after cancellation."); + CommandErrorWriter.WriteStderr("Error: install.sh timed out and did not exit after cancellation."); } else { outputDrainTask.GetAwaiter().GetResult(); if (!suppressOutput) - Console.Error.WriteLine($"Error: install.sh timed out after {FormatDuration(timeout)}."); + CommandErrorWriter.WriteStderr($"Error: install.sh timed out after {FormatDuration(timeout)}."); } if (!suppressOutput) - Console.Error.WriteLine("Hint: rerun `install.sh` manually for the desired release."); + CommandErrorWriter.WriteStderr("Hint: rerun `install.sh` manually for the desired release."); var timeoutOutput = outputDrainTask.IsCompletedSuccessfully ? outputDrainTask.GetAwaiter().GetResult() : SuppressedInstallerOutputResult.Empty; @@ -3505,7 +3505,7 @@ private static void TryDeleteUpgradeInstallerScript(string scriptPath) } catch (Exception ex) { - Console.Error.WriteLine($"Warning: failed to delete upgrade installer script {ConsoleUi.FormatBoundedValue(scriptPath)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + CommandErrorWriter.WriteStderr($"Warning: failed to delete upgrade installer script {ConsoleUi.FormatBoundedValue(scriptPath)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); } } @@ -3698,7 +3698,7 @@ private static void TryDeleteInstallDirectoryWriteProbe(string probePath) } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - Console.Error.WriteLine($"Warning: failed to delete install directory write probe {ConsoleUi.FormatBoundedValue(probePath)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + CommandErrorWriter.WriteStderr($"Warning: failed to delete install directory write probe {ConsoleUi.FormatBoundedValue(probePath)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); } } @@ -3750,8 +3750,8 @@ internal static int RunVersion( wantsJson = true; continue; } - Console.Error.WriteLine($"Error: --version does not accept '{arg}'."); - Console.Error.WriteLine("Hint: use `cdidx --version` or `cdidx --version --json`."); + CommandErrorWriter.WriteStderr($"Error: --version does not accept '{arg}'."); + CommandErrorWriter.WriteStderr("Hint: use `cdidx --version` or `cdidx --version --json`."); return CommandExitCodes.UsageError; } @@ -3850,17 +3850,17 @@ private static string StripErrorPrefix(string message) private static int ShowError(string[] args, string message) { - Console.Error.WriteLine($"Error: {message}"); + CommandErrorWriter.WriteStderr($"Error: {message}"); var input = args[0]; if (!input.StartsWith('-')) { var best = ConsoleUi.FindClosestCommand(input); if (best != null) - Console.Error.WriteLine($"Did you mean: cdidx {best}?"); + CommandErrorWriter.WriteStderr($"Did you mean: cdidx {best}?"); } - Console.Error.WriteLine("Run 'cdidx --help' for usage information."); + CommandErrorWriter.WriteStderr("Run 'cdidx --help' for usage information."); return CommandExitCodes.UsageError; } } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs b/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs index a0e4ecb435..8a1fa672d8 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs @@ -17,7 +17,7 @@ public static int RunBatch(string[] cmdArgs, JsonSerializerOptions jsonOptions) { if (i + 1 >= cmdArgs.Length || string.IsNullOrWhiteSpace(cmdArgs[i + 1])) { - Console.Error.WriteLine(BuildMissingOptionValueError("--db")); + CommandErrorWriter.WriteStderr(BuildMissingOptionValueError("--db")); return CommandExitCodes.UsageError; } dbPath = cmdArgs[++i]; @@ -30,23 +30,23 @@ public static int RunBatch(string[] cmdArgs, JsonSerializerOptions jsonOptions) dbPath = arg["--db=".Length..]; if (string.IsNullOrWhiteSpace(dbPath)) { - Console.Error.WriteLine(BuildMissingOptionValueError("--db")); + CommandErrorWriter.WriteStderr(BuildMissingOptionValueError("--db")); return CommandExitCodes.UsageError; } dbPathExplicit = true; continue; } - Console.Error.WriteLine($"Error: {ConsoleUi.FormatBoundedValue(arg)} is not supported for batch."); - Console.Error.WriteLine($"Usage: {ConsoleUi.GetUsageLine("batch")}"); + CommandErrorWriter.WriteStderr($"Error: {ConsoleUi.FormatBoundedValue(arg)} is not supported for batch."); + CommandErrorWriter.WriteStderr($"Usage: {ConsoleUi.GetUsageLine("batch")}"); return CommandExitCodes.UsageError; } var isUri = dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase); if (!isUri && !File.Exists(dbPath)) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbNotFound}]: database not found at {FormatDbDiagnosticValue(Path.GetFullPath(dbPath))}"); - Console.Error.WriteLine("Hint: create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun this command."); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbNotFound}]: database not found at {FormatDbDiagnosticValue(Path.GetFullPath(dbPath))}"); + CommandErrorWriter.WriteStderr("Hint: create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun this command."); return CommandExitCodes.DatabaseError; } @@ -67,7 +67,7 @@ public static int RunBatch(string[] cmdArgs, JsonSerializerOptions jsonOptions) lineNumber++; if (lineExceededLimit) { - Console.Error.WriteLine($"Error: batch line {lineNumber} exceeds the {BatchMaxLineChars} character limit."); + CommandErrorWriter.WriteStderr($"Error: batch line {lineNumber} exceeds the {BatchMaxLineChars} character limit."); if (firstFailure == CommandExitCodes.Success) firstFailure = CommandExitCodes.UsageError; continue; @@ -144,12 +144,12 @@ private static bool TryParseBatchLine(string line, int lineNumber, out string co using var document = JsonDocument.Parse(line, BatchJsonDocumentOptions); if (document.RootElement.ValueKind != JsonValueKind.Array || document.RootElement.GetArrayLength() == 0) { - Console.Error.WriteLine($"Error: batch line {lineNumber} must be a non-empty JSON string array."); + CommandErrorWriter.WriteStderr($"Error: batch line {lineNumber} must be a non-empty JSON string array."); return false; } if (document.RootElement.GetArrayLength() > BatchMaxArgumentCount + 1) { - Console.Error.WriteLine($"Error: batch line {lineNumber} must contain at most {BatchMaxArgumentCount} command arguments."); + CommandErrorWriter.WriteStderr($"Error: batch line {lineNumber} must contain at most {BatchMaxArgumentCount} command arguments."); return false; } @@ -158,13 +158,13 @@ private static bool TryParseBatchLine(string line, int lineNumber, out string co { if (element.ValueKind != JsonValueKind.String) { - Console.Error.WriteLine($"Error: batch line {lineNumber} must contain only strings."); + CommandErrorWriter.WriteStderr($"Error: batch line {lineNumber} must contain only strings."); return false; } var value = element.GetString() ?? string.Empty; if (value.Length > BatchMaxArgumentChars) { - Console.Error.WriteLine($"Error: batch line {lineNumber} argument {values.Count + 1} exceeds the {BatchMaxArgumentChars} character limit."); + CommandErrorWriter.WriteStderr($"Error: batch line {lineNumber} argument {values.Count + 1} exceeds the {BatchMaxArgumentChars} character limit."); return false; } values.Add(value); @@ -176,7 +176,7 @@ private static bool TryParseBatchLine(string line, int lineNumber, out string co } catch (JsonException) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.UsageError}]: batch line {lineNumber} {SafeDiagnosticFormatter.FormatCategoryType("invalid_batch_json", nameof(JsonException))}."); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.UsageError}]: batch line {lineNumber} {SafeDiagnosticFormatter.FormatCategoryType("invalid_batch_json", nameof(JsonException))}."); return false; } } @@ -207,8 +207,8 @@ private static int RunBatchQueryCommand(string commandName, string[] subArgs, Js private static int WriteBatchUnsupportedCommand(string commandName) { - Console.Error.WriteLine($"Error: batch only supports query commands; '{commandName}' is not supported."); - Console.Error.WriteLine("Hint: use one of search, definition, references, callers, callees, symbols, files, find, excerpt, map, inspect, outline, status, validate, impact, deps, unused, or hotspots."); + CommandErrorWriter.WriteStderr($"Error: batch only supports query commands; '{commandName}' is not supported."); + CommandErrorWriter.WriteStderr("Hint: use one of search, definition, references, callers, callees, symbols, files, find, excerpt, map, inspect, outline, status, validate, impact, deps, unused, or hotspots."); return CommandExitCodes.UsageError; } } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index bc64bf8e46..c81877b023 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -379,7 +379,7 @@ public static int RunSearch( var previewOptionError = ValidatePreviewOptions("search", cmdArgs, allowMaxLineWidth: true, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true, allowIssueDraftsFormat: true); @@ -389,7 +389,7 @@ public static int RunSearch( return CommandExitCodes.UsageError; if (!TryResolveSearchExactMode(options, out var exact, out var exactError)) { - Console.Error.WriteLine(exactError); + CommandErrorWriter.WriteStderr(exactError); return CommandExitCodes.UsageError; } if (options.OpenIssuesPath != null && options.OutputFormat != OutputFormatIssueDrafts) @@ -861,7 +861,7 @@ public static int RunSearch( } else if (!options.Json) { - Console.Error.WriteLine(BuildZeroResultLine("No results found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No results found", options)); WriteLangHint(options.Lang, reader); WriteExactSubstringHintIfNeeded(exactSubstringHint); var pathHint = BuildSearchPathGlobHint(reader, options); @@ -949,7 +949,7 @@ public static int RunSearch( } } var fileCount = displayRows.Select(row => row.Result.Path).Distinct().Count(); - Console.Error.WriteLine($"({displayRows.Count} results in {fileCount} files)"); + CommandErrorWriter.WriteStderr($"({displayRows.Count} results in {fileCount} files)"); WriteExactSubstringHintIfNeeded(exactSubstringHint); WriteSearchNextSteps(displayRows, options); } @@ -1089,7 +1089,7 @@ private static void WriteSearchGroupedCounts(string groupBy, List rows, QueryComma { if (!options.NextSteps || rows.Count == 0) return; - Console.Error.WriteLine("Next steps:"); + CommandErrorWriter.WriteStderr("Next steps:"); foreach (var row in rows.Take(MaxSearchNextStepLimit)) { var line = row.Compact.MatchLines.Count > 0 ? row.Compact.MatchLines[0] : row.Result.StartLine; - Console.Error.WriteLine($" cdidx inspect --path \"{row.Result.Path}\" --line {line}"); - Console.Error.WriteLine($" cdidx excerpt --path \"{row.Result.Path}\" --start {Math.Max(1, line - 3)} --end {line + 3}"); + CommandErrorWriter.WriteStderr($" cdidx inspect --path \"{row.Result.Path}\" --line {line}"); + CommandErrorWriter.WriteStderr($" cdidx excerpt --path \"{row.Result.Path}\" --start {Math.Max(1, line - 3)} --end {line + 3}"); } } @@ -1396,7 +1396,7 @@ private static int RunSearchNamedBatch(QueryCommandOptions options, JsonSerializ Console.WriteLine(); } - Console.Error.WriteLine($"({total} named-query results across {queryResults.Count} queries)"); + CommandErrorWriter.WriteStderr($"({total} named-query results across {queryResults.Count} queries)"); return CommandExitCodes.Success; }); } @@ -1638,7 +1638,7 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp Console.WriteLine(); } - Console.Error.WriteLine($"({total} recipe results across {selection.Queries.Count} queries)"); + CommandErrorWriter.WriteStderr($"({total} recipe results across {selection.Queries.Count} queries)"); return CommandExitCodes.Success; }); } @@ -3172,7 +3172,7 @@ public static int RunDefinition(string[] cmdArgs, JsonSerializerOptions jsonOpti var previewOptionError = ValidatePreviewOptions("definition", cmdArgs, allowMaxLineWidth: false, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs( @@ -3189,7 +3189,7 @@ public static int RunDefinition(string[] cmdArgs, JsonSerializerOptions jsonOpti return CommandExitCodes.InvalidArgument; if (!TryResolveNameExactMode(options, "definition", out var exact, out var exactError)) { - Console.Error.WriteLine(exactError); + CommandErrorWriter.WriteStderr(exactError); return CommandExitCodes.UsageError; } if (exact && options.Query is not null && IsBareVerbatimQueryToken(options.Query) && options.CountOnly && string.Equals(options.Lang, "csharp", StringComparison.OrdinalIgnoreCase)) @@ -3268,7 +3268,7 @@ public static int RunDefinition(string[] cmdArgs, JsonSerializerOptions jsonOpti return ZeroResultExitCode(options); if (!options.Json) { - Console.Error.WriteLine(BuildZeroResultLine("No definitions found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No definitions found", options)); WriteExactZeroHint(exactZeroHint); WriteKindHint(options.Kind, reader); WriteLangHint(options.Lang, reader); @@ -3331,7 +3331,7 @@ public static int RunDefinition(string[] cmdArgs, JsonSerializerOptions jsonOpti Console.WriteLine(); } var defFileCount = results.Select(r => r.Path).Distinct().Count(); - Console.Error.WriteLine($"({results.Count} definitions in {defFileCount} files)"); + CommandErrorWriter.WriteStderr($"({results.Count} definitions in {defFileCount} files)"); } return CommandExitCodes.Success; }); @@ -3350,7 +3350,7 @@ public static int RunGoto(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.InvalidArgument; if (!TryResolveNameExactMode(options, "goto", out var exact, out var exactError)) { - Console.Error.WriteLine(exactError); + CommandErrorWriter.WriteStderr(exactError); return CommandExitCodes.UsageError; } if (TryWriteBlankQueryError(options, "goto")) @@ -3381,7 +3381,7 @@ public static int RunGoto(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (results.Count == 0) { if (!options.Json) - Console.Error.WriteLine(BuildZeroResultLine("No definitions found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No definitions found", options)); return CommandExitCodes.NotFound; } @@ -3393,8 +3393,8 @@ public static int RunGoto(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (results.Count > 1) { - Console.Error.WriteLine($"Error: goto found {results.Count} matching definitions for '{options.Query}'."); - Console.Error.WriteLine("Hint: narrow the query with --kind, --lang, --path, or pass --all to return all LSP locations."); + CommandErrorWriter.WriteStderr($"Error: goto found {results.Count} matching definitions for '{options.Query}'."); + CommandErrorWriter.WriteStderr("Hint: narrow the query with --kind, --lang, --path, or pass --all to return all LSP locations."); return CommandExitCodes.UsageError; } @@ -3419,7 +3419,7 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti var previewOptionError = ValidatePreviewOptions("references", cmdArgs, allowMaxLineWidth: true, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); @@ -3431,7 +3431,7 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti return CommandExitCodes.UsageError; if (!TryResolveNameExactMode(options, "references", out var exact, out var exactError)) { - Console.Error.WriteLine(exactError); + CommandErrorWriter.WriteStderr(exactError); return CommandExitCodes.UsageError; } if (TryWriteBlankQueryError(options, "references")) @@ -3509,7 +3509,7 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti WriteGraphZeroJsonResult(reader, "references", jsonOptions, graphAvailable: reader._hasReferencesTable, exact ? exactSignal : (ExactQuerySignal?)null, exactZeroHint, queryOptions: options, extraFields: payload => AddSqlGraphContractJsonFields(payload, sqlGraphSignal)); else if (!options.Json) { - Console.Error.WriteLine(BuildZeroResultLine("No references found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No references found", options)); WriteExactZeroHint(exactZeroHint); WriteGraphSupportHint(options.Lang); WriteLangHint(options.Lang, reader); @@ -3558,7 +3558,7 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti WriteOptionalBodyExcerpt(r.BodyStartLine, r.BodyContent); } var refFileCount = results.Select(r => r.Path).Distinct().Count(); - Console.Error.WriteLine($"({results.Count} references in {refFileCount} files)"); + CommandErrorWriter.WriteStderr($"({results.Count} references in {refFileCount} files)"); } return CommandExitCodes.Success; }); @@ -3569,7 +3569,7 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions var previewOptionError = ValidatePreviewOptions("callers", cmdArgs, allowMaxLineWidth: true, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); @@ -3583,7 +3583,7 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.InvalidArgument; if (!TryResolveNameExactMode(options, "callers", out var exact, out var exactError)) { - Console.Error.WriteLine(exactError); + CommandErrorWriter.WriteStderr(exactError); return CommandExitCodes.UsageError; } if (TryWriteBlankQueryError(options, "callers")) @@ -3661,7 +3661,7 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions WriteGraphZeroJsonResult(reader, "callers", jsonOptions, graphAvailable: reader._hasReferencesTable, exact ? exactSignal : (ExactQuerySignal?)null, exactZeroHint, queryOptions: options, extraFields: payload => AddSqlGraphContractJsonFields(payload, sqlGraphSignal)); else if (!options.Json) { - Console.Error.WriteLine(BuildZeroResultLine("No callers found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No callers found", options)); WriteExactZeroHint(exactZeroHint); WriteGraphSupportHint(options.Lang); WriteLangHint(options.Lang, reader); @@ -3710,7 +3710,7 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions WriteOptionalBodyExcerpt(r.BodyStartLine, r.BodyContent); } var callerFileCount = results.Select(r => r.Path).Distinct().Count(); - Console.Error.WriteLine($"({results.Count} callers in {callerFileCount} files)"); + CommandErrorWriter.WriteStderr($"({results.Count} callers in {callerFileCount} files)"); } return CommandExitCodes.Success; }); @@ -3721,7 +3721,7 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions var previewOptionError = ValidatePreviewOptions("callees", cmdArgs, allowMaxLineWidth: true, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); @@ -3735,7 +3735,7 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.InvalidArgument; if (!TryResolveNameExactMode(options, "callees", out var exact, out var exactError)) { - Console.Error.WriteLine(exactError); + CommandErrorWriter.WriteStderr(exactError); return CommandExitCodes.UsageError; } if (TryWriteBlankQueryError(options, "callees")) @@ -3811,7 +3811,7 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions WriteGraphZeroJsonResult(reader, "callees", jsonOptions, graphAvailable: reader._hasReferencesTable, exact ? exactSignal : (ExactQuerySignal?)null, exactZeroHint, queryOptions: options, extraFields: payload => AddSqlGraphContractJsonFields(payload, sqlGraphSignal)); else if (!options.Json) { - Console.Error.WriteLine(BuildZeroResultLine("No callees found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No callees found", options)); WriteExactZeroHint(exactZeroHint); WriteGraphSupportHint(options.Lang); WriteLangHint(options.Lang, reader); @@ -3860,7 +3860,7 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions WriteOptionalBodyExcerpt(r.BodyStartLine, r.BodyContent); } var calleeFileCount = results.Select(r => r.Path).Distinct().Count(); - Console.Error.WriteLine($"({results.Count} callees in {calleeFileCount} files)"); + CommandErrorWriter.WriteStderr($"({results.Count} callees in {calleeFileCount} files)"); } return CommandExitCodes.Success; }); @@ -4109,7 +4109,7 @@ public static int RunSymbols(string[] cmdArgs, JsonSerializerOptions jsonOptions var previewOptionError = ValidatePreviewOptions("symbols", cmdArgs, allowMaxLineWidth: false, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs( @@ -4130,7 +4130,7 @@ public static int RunSymbols(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.UsageError; if (!TryResolveNameExactMode(options, "symbols", out var exact, out var exactError)) { - Console.Error.WriteLine(exactError); + CommandErrorWriter.WriteStderr(exactError); return CommandExitCodes.UsageError; } var exactBareVerbatimOnly = exact && string.Equals(options.Lang, "csharp", StringComparison.OrdinalIgnoreCase) && ( @@ -4151,12 +4151,12 @@ public static int RunSymbols(string[] cmdArgs, JsonSerializerOptions jsonOptions // verbatim prefix (e.g. `|`, `@`, `--name ""`). Returning null here would broaden into // an unfiltered symbol dump. / // 明示入力が正規化で空、または verbatim 接頭辞単独(`|`、`@`、`--name ""` など)になった場合は必ず拒否する。 - Console.Error.WriteLine("Error: symbol name list is empty after normalization. Check for empty --name values, bare verbatim prefixes like `@`, or bare `|` separators. / シンボル名リストが正規化の結果空です。--name の空値、`@` のような verbatim 接頭辞単独、単独の `|` を確認してください。"); + CommandErrorWriter.WriteStderr("Error: symbol name list is empty after normalization. Check for empty --name values, bare verbatim prefixes like `@`, or bare `|` separators. / シンボル名リストが正規化の結果空です。--name の空値、`@` のような verbatim 接頭辞単独、単独の `|` を確認してください。"); return CommandExitCodes.UsageError; } if (symbolQueries != null && symbolQueries.Count > MaxSymbolQueryNames) { - Console.Error.WriteLine($"Error: too many symbol names ({symbolQueries.Count}); maximum is {MaxSymbolQueryNames}. Split the request into smaller batches. / シンボル名が多すぎます({symbolQueries.Count}件、上限は {MaxSymbolQueryNames} 件)。分割してください。"); + CommandErrorWriter.WriteStderr($"Error: too many symbol names ({symbolQueries.Count}); maximum is {MaxSymbolQueryNames}. Split the request into smaller batches. / シンボル名が多すぎます({symbolQueries.Count}件、上限は {MaxSymbolQueryNames} 件)。分割してください。"); return CommandExitCodes.UsageError; } @@ -4222,7 +4222,7 @@ public static int RunSymbols(string[] cmdArgs, JsonSerializerOptions jsonOptions { if (!options.Json) { - Console.Error.WriteLine(BuildZeroResultLine("No symbols found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No symbols found", options)); WriteExactZeroHint(exactZeroHint); WriteKindHint(options.Kind, reader); WriteLangHint(options.Lang, reader); @@ -4253,7 +4253,7 @@ public static int RunSymbols(string[] cmdArgs, JsonSerializerOptions jsonOptions } var symFileCount = results.Select(r => r.Path).Distinct().Count(); var sortSummary = options.SymbolSortMode == SymbolSortMode.Name ? string.Empty : $"; sort={options.SymbolSortMode.ToString().ToLowerInvariant()}"; - Console.Error.WriteLine($"({results.Count} symbols in {symFileCount} files{sortSummary})"); + CommandErrorWriter.WriteStderr($"({results.Count} symbols in {symFileCount} files{sortSummary})"); } return CommandExitCodes.Success; }); @@ -4282,7 +4282,7 @@ public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) var previewOptionError = ValidatePreviewOptions("files", cmdArgs, allowMaxLineWidth: false, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs( @@ -4328,7 +4328,7 @@ public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) } else if (!options.Json) { - Console.Error.WriteLine(BuildZeroResultLine("No files found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No files found", options)); WriteLangHint(options.Lang, reader); WriteZeroResultHints(options, reader); } @@ -4355,7 +4355,7 @@ public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) var size = options.RawBytes ? $"{r.Size.ToString(CultureInfo.InvariantCulture)} bytes" : ConsoleUi.FormatBytes(r.Size); Console.WriteLine($"{r.Lang ?? "?",-12} {r.Lines,6} lines {size,12} {r.Path}"); } - Console.Error.WriteLine($"({results.Count} files)"); + CommandErrorWriter.WriteStderr($"({results.Count} files)"); } return CommandExitCodes.Success; }); @@ -4366,7 +4366,7 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions var previewOptionError = ValidatePreviewOptions("excerpt", cmdArgs, allowMaxLineWidth: true, allowFocusOptions: true); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs( @@ -4425,7 +4425,7 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions var requestedEnd = Math.Min(file.Lines, endLine + options.ContextAfter); if (options.FocusLine.Value < requestedStart || options.FocusLine.Value > requestedEnd) { - Console.Error.WriteLine($"Error: --focus-line ({options.FocusLine.Value}) must be within the returned excerpt range ({requestedStart}-{requestedEnd})."); + CommandErrorWriter.WriteStderr($"Error: --focus-line ({options.FocusLine.Value}) must be within the returned excerpt range ({requestedStart}-{requestedEnd})."); return CommandExitCodes.UsageError; } } @@ -4441,7 +4441,7 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions options.FocusLine ?? options.StartLine.Value); if (focusLineLength.HasValue && options.FocusColumn.Value > focusLineLength.Value) { - Console.Error.WriteLine($"Error: --focus-column ({options.FocusColumn.Value}) must be within the focused line length ({focusLineLength.Value})."); + CommandErrorWriter.WriteStderr($"Error: --focus-column ({options.FocusColumn.Value}) must be within the focused line length ({focusLineLength.Value})."); return CommandExitCodes.UsageError; } } @@ -4459,7 +4459,7 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions if (excerpt == null) { if (!options.Json) - Console.Error.WriteLine("No excerpt found."); + CommandErrorWriter.WriteStderr("No excerpt found."); return ZeroResultExitCode(options); } if (options.Json) @@ -4566,16 +4566,16 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) var preparedFindArgs = PrepareFindArgs(cmdArgs, out var preparationError); if (preparationError != null) { - Console.Error.WriteLine(preparationError); - Console.Error.WriteLine(FindUsage); + CommandErrorWriter.WriteStderr(preparationError); + CommandErrorWriter.WriteStderr(FindUsage); return CommandExitCodes.UsageError; } var findValidationError = ValidateFindArgs(preparedFindArgs); if (findValidationError != null) { - Console.Error.WriteLine(findValidationError); - Console.Error.WriteLine(FindUsage); + CommandErrorWriter.WriteStderr(findValidationError); + CommandErrorWriter.WriteStderr(FindUsage); return CommandExitCodes.UsageError; } @@ -4586,43 +4586,43 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) validateDefaultSnippetLines: false); if (options.ParseError != null) { - Console.Error.WriteLine(options.ParseError); - Console.Error.WriteLine(FindUsage); + CommandErrorWriter.WriteStderr(options.ParseError); + CommandErrorWriter.WriteStderr(FindUsage); return CommandExitCodes.UsageError; } if (options.Query is not null && string.IsNullOrWhiteSpace(options.Query)) { - Console.Error.WriteLine("Error: find query cannot be empty or whitespace-only"); - Console.Error.WriteLine("Hint: Pass a non-empty value after `find`; empty or whitespace-only arguments (e.g. `\"\"` or `\" \"`) are rejected."); - Console.Error.WriteLine(FindUsage); + CommandErrorWriter.WriteStderr("Error: find query cannot be empty or whitespace-only"); + CommandErrorWriter.WriteStderr("Hint: Pass a non-empty value after `find`; empty or whitespace-only arguments (e.g. `\"\"` or `\" \"`) are rejected."); + CommandErrorWriter.WriteStderr(FindUsage); return CommandExitCodes.UsageError; } if (string.IsNullOrWhiteSpace(options.Query)) { - Console.Error.WriteLine("Error: find requires a query argument"); - Console.Error.WriteLine(FindUsage); + CommandErrorWriter.WriteStderr("Error: find requires a query argument"); + CommandErrorWriter.WriteStderr(FindUsage); return CommandExitCodes.UsageError; } if (options.Query.Length > QueryLimits.MaxQueryLength) { - Console.Error.WriteLine($"Error: {QueryLimits.FormatQueryTooLongError()}"); - Console.Error.WriteLine("Hint: Shorten the find text or split generated input into smaller queries before running `cdidx find`."); - Console.Error.WriteLine(FindUsage); + CommandErrorWriter.WriteStderr($"Error: {QueryLimits.FormatQueryTooLongError()}"); + CommandErrorWriter.WriteStderr("Hint: Shorten the find text or split generated input into smaller queries before running `cdidx find`."); + CommandErrorWriter.WriteStderr(FindUsage); return CommandExitCodes.UsageError; } if (options.PathPatterns.Count == 0 && !options.All) { - Console.Error.WriteLine("Error: find requires at least one --path or explicit --all to scope the search"); - Console.Error.WriteLine("Hint: use --path for a bounded file set, or --all to scan all indexed files with safety caps."); - Console.Error.WriteLine(FindUsage); + CommandErrorWriter.WriteStderr("Error: find requires at least one --path or explicit --all to scope the search"); + CommandErrorWriter.WriteStderr("Hint: use --path for a bounded file set, or --all to scan all indexed files with safety caps."); + CommandErrorWriter.WriteStderr(FindUsage); return CommandExitCodes.UsageError; } if (options.PathPatterns.Count > 0 && options.All) { - Console.Error.WriteLine("Error: find accepts either --path or --all, not both"); - Console.Error.WriteLine("Hint: remove --all when using explicit path filters, or remove --path to scan all indexed files with caps."); - Console.Error.WriteLine(FindUsage); + CommandErrorWriter.WriteStderr("Error: find accepts either --path or --all, not both"); + CommandErrorWriter.WriteStderr("Hint: remove --all when using explicit path filters, or remove --path to scan all indexed files with caps."); + CommandErrorWriter.WriteStderr(FindUsage); return CommandExitCodes.UsageError; } @@ -4726,7 +4726,7 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) } else { - Console.Error.WriteLine(BuildZeroResultLine("No matches found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No matches found", options)); if (candidateFileCount > 0) { var fileText = ConsoleUi.Counted(candidateFileCount, "file"); @@ -4774,7 +4774,7 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.WriteLine(); } var fileCount = results.Select(r => r.Path).Distinct().Count(); - Console.Error.WriteLine($"({results.Count} matches in {fileCount} files)"); + CommandErrorWriter.WriteStderr($"({results.Count} matches in {fileCount} files)"); WriteFindScanSummary(findResults.Scan); } return CommandExitCodes.Success; @@ -4783,7 +4783,7 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) private static int WriteFindInvalidRegexError(Exception ex) { - Console.Error.WriteLine($"Error: invalid regular expression: {ex.Message}"); + CommandErrorWriter.WriteStderr($"Error: invalid regular expression: {ex.Message}"); return CommandExitCodes.UsageError; } @@ -4965,7 +4965,7 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) var previewOptionError = ValidatePreviewOptions("map", cmdArgs, allowMaxLineWidth: false, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs( @@ -5012,7 +5012,7 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) } else { - Console.Error.WriteLine("No files found matching the given filters."); + CommandErrorWriter.WriteStderr("No files found matching the given filters."); } return ZeroResultExitCode(options); } @@ -5391,7 +5391,7 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions var previewOptionError = ValidatePreviewOptions("inspect", cmdArgs, allowMaxLineWidth: true, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs( @@ -5407,7 +5407,7 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.UsageError; if (!TryResolveNameExactMode(options, "inspect", out var exact, out var exactError)) { - Console.Error.WriteLine(exactError); + CommandErrorWriter.WriteStderr(exactError); return CommandExitCodes.UsageError; } var pathLineInspectMode = IsInspectPathLineMode(options); @@ -5618,7 +5618,7 @@ public static int RunOutline(string[] cmdArgs, JsonSerializerOptions jsonOptions var previewOptionError = ValidatePreviewOptions("outline", cmdArgs[1..], allowMaxLineWidth: false, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs( @@ -5643,7 +5643,7 @@ public static int RunOutline(string[] cmdArgs, JsonSerializerOptions jsonOptions if (options.Json) Console.WriteLine(JsonSerializer.Serialize(new QueryPathErrorJsonResult(filePath, "file not found in index"), CliJsonSerializerContextFactory.Create(jsonOptions).QueryPathErrorJsonResult)); else - Console.Error.WriteLine($"Error: '{filePath}' not found in index."); + CommandErrorWriter.WriteStderr($"Error: '{filePath}' not found in index."); return CommandExitCodes.NotFound; } @@ -5701,9 +5701,9 @@ public static int RunOutline(string[] cmdArgs, JsonSerializerOptions jsonOptions // 人間向け本体を汚さないよう、理由を短く stderr に出す。 if (LooksLikeCsharpTopLevelStatements(outline, outlineContent)) { - Console.Error.WriteLine(); - Console.Error.WriteLine("Note: no type/namespace declarations found; this file likely uses C# top-level statements."); - Console.Error.WriteLine(" Outline lists imports and local functions only; the executable body is not indexed as symbols."); + CommandErrorWriter.WriteStderr(); + CommandErrorWriter.WriteStderr("Note: no type/namespace declarations found; this file likely uses C# top-level statements."); + CommandErrorWriter.WriteStderr(" Outline lists imports and local functions only; the executable body is not indexed as symbols."); } } return CommandExitCodes.Success; @@ -5815,7 +5815,7 @@ public static int RunStatus( var previewOptionError = ValidatePreviewOptions("status", cmdArgs, allowMaxLineWidth: false, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs( @@ -5835,7 +5835,7 @@ public static int RunStatus( { if (options.CheckWorkspace || options.StatusLogPath || options.StatusExplainField != null) { - Console.Error.WriteLine("Error: status --config cannot be combined with --check, --log-path, or --explain."); + CommandErrorWriter.WriteStderr("Error: status --config cannot be combined with --check, --log-path, or --explain."); return CommandExitCodes.UsageError; } @@ -5846,7 +5846,7 @@ public static int RunStatus( { if (options.CheckWorkspace) { - Console.Error.WriteLine("Error: status --log-path cannot be combined with --check."); + CommandErrorWriter.WriteStderr("Error: status --log-path cannot be combined with --check."); return CommandExitCodes.UsageError; } @@ -5872,7 +5872,7 @@ public static int RunStatus( staleAfter = ResolveStaleAfter(options, CdidxEnvironment.GetEnvironmentVariable(StaleAfterEnvironmentVariable)); if (staleAfter.Error != null) { - Console.Error.WriteLine(staleAfter.Error); + CommandErrorWriter.WriteStderr(staleAfter.Error); return CommandExitCodes.UsageError; } } @@ -6271,8 +6271,8 @@ public static int RunVacuum(string[] cmdArgs, JsonSerializerOptions jsonOptions) var explicitDbPathError = BuildExplicitDbPathParseError(options); if (explicitDbPathError != null && explicitDbPathError.Contains(CommandErrorCodes.DbNotFound, StringComparison.Ordinal)) { - Console.Error.WriteLine(explicitDbPathError); - Console.Error.WriteLine("Hint: point `--db` at an existing `codeindex.db`, or run `cdidx index ` first to create one."); + CommandErrorWriter.WriteStderr(explicitDbPathError); + CommandErrorWriter.WriteStderr("Hint: point `--db` at an existing `codeindex.db`, or run `cdidx index ` first to create one."); return CommandExitCodes.NotFound; } if (TryWriteParseError(options, "vacuum")) @@ -6282,8 +6282,8 @@ public static int RunVacuum(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (!DbContext.TryValidateExistingCodeIndexDb(options.DbPath, out var validationMessage, out var isNotFound)) { - Console.Error.WriteLine($"Error [{(isNotFound ? CommandErrorCodes.DbNotFound : CommandErrorCodes.DbError)}]: {validationMessage}"); - Console.Error.WriteLine(isNotFound + CommandErrorWriter.WriteStderr($"Error [{(isNotFound ? CommandErrorCodes.DbNotFound : CommandErrorCodes.DbError)}]: {validationMessage}"); + CommandErrorWriter.WriteStderr(isNotFound ? "Hint: point `--db` at an existing `codeindex.db`, or run `cdidx index ` first to create one." : "Hint: point `--db` at an existing CodeIndex database created by `cdidx index`, then retry `cdidx vacuum`."); return isNotFound ? CommandExitCodes.NotFound : CommandExitCodes.DatabaseError; @@ -6320,7 +6320,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) var previewOptionError = ValidatePreviewOptions("impact", cmdArgs, allowMaxLineWidth: true, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); @@ -6353,7 +6353,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) { var maxDepth = options.ContextAfterExplicit ? options.ContextAfter : 5; // --max-hops/--depth is parsed into ContextAfter; 0 means resolve-only if (!options.Json && options.ImpactDeprecatedDepthUsed) - Console.Error.WriteLine("Warning: --depth is deprecated for impact; use --max-hops instead."); + CommandErrorWriter.WriteStderr("Warning: --depth is deprecated for impact; use --max-hops instead."); var analysis = reader.AnalyzeImpact(options.Query, maxDepth, options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, options.WithPaths); if (options.IncludeBody) AttachBodyExcerpts(reader, analysis.Callers, options.SnippetLines, options.MaxLineWidth); @@ -6424,7 +6424,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) } else { - Console.Error.WriteLine("Depth 0 requested: resolved the symbol only; callers were not traversed."); + CommandErrorWriter.WriteStderr("Depth 0 requested: resolved the symbol only; callers were not traversed."); WriteImpactResolutionHint(analysis); WriteGraphSupportHint(options.Lang); } @@ -6473,7 +6473,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) { Console.WriteLine("0"); if (!analysis.GraphTableAvailable) - Console.Error.WriteLine("WARN: symbol_references table missing — this count result is degraded, not authoritative."); + CommandErrorWriter.WriteStderr("WARN: symbol_references table missing — this count result is degraded, not authoritative."); } } else if (options.Json) @@ -6523,7 +6523,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) } else if (!options.Json) { - Console.Error.WriteLine($"No impact found for '{analysis.Query}'."); + CommandErrorWriter.WriteStderr($"No impact found for '{analysis.Query}'."); WriteImpactResolutionHint(analysis); WriteGraphSupportHint(options.Lang); WriteDegradedGraphZeroResult(reader, "callers", json: false, graphAvailable: reader._hasReferencesTable, jsonOptions); @@ -6606,11 +6606,11 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) { if (hasHeuristicHints) { - Console.Error.WriteLine($"No symbol-level callers found for '{analysis.ResolvedName}'. Possible file-level dependents follow."); + CommandErrorWriter.WriteStderr($"No symbol-level callers found for '{analysis.ResolvedName}'. Possible file-level dependents follow."); WriteImpactResolutionHint(analysis); - Console.Error.WriteLine("WARN: these file-level dependents are heuristic only; the current graph does not record resolved target file/type for each call."); + CommandErrorWriter.WriteStderr("WARN: these file-level dependents are heuristic only; the current graph does not record resolved target file/type for each call."); if (analysis.Truncated) - Console.Error.WriteLine("WARN: heuristic file-level dependents were truncated by the current limit."); + CommandErrorWriter.WriteStderr("WARN: heuristic file-level dependents were truncated by the current limit."); foreach (var edge in analysis.FileImpacts) Console.WriteLine($" {edge.SourcePath,-40} -> {edge.TargetPath} ({edge.ReferenceCount} refs: {edge.Symbols})"); } @@ -6619,7 +6619,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) var grouped = analysis.Callers.GroupBy(r => r.Depth).OrderBy(g => g.Key); foreach (var group in grouped) { - Console.Error.WriteLine($"--- Depth {group.Key} ---"); + CommandErrorWriter.WriteStderr($"--- Depth {group.Key} ---"); foreach (var r in group) { var indent = new string(' ', (r.Depth - 1) * 2); @@ -6642,9 +6642,9 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) : " [TRUNCATED]" : ""; if (hasHeuristicHints) - Console.Error.WriteLine($"\n({hintCount} heuristic dependency hints across {hintFileCount} files{truncNote})"); + CommandErrorWriter.WriteStderr($"\n({hintCount} heuristic dependency hints across {hintFileCount} files{truncNote})"); else - Console.Error.WriteLine($"\n({confirmedCount} callers across {confirmedFileCount} files, max depth {maxDepth}{truncNote})"); + CommandErrorWriter.WriteStderr($"\n({confirmedCount} callers across {confirmedFileCount} files, max depth {maxDepth}{truncNote})"); } return StrictImpactExitCode(options, analysis, CommandExitCodes.Success); }); @@ -6680,12 +6680,12 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) var previewOptionError = ValidatePreviewOptions("deps", cmdArgs, allowMaxLineWidth: false, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } if (!TryExtractDepsFormat(cmdArgs, out var depsFormat, out var parseArgs, out var depsFormatError)) { - Console.Error.WriteLine(depsFormatError); + CommandErrorWriter.WriteStderr(depsFormatError); return CommandExitCodes.UsageError; } @@ -6720,7 +6720,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.WriteLine(BuildJsonZeroResultPayload(reader, jsonOptions, resultsKey: "edges", graphTableAvailable: true, degraded: !zeroSqlGraphSignal.Ready, queryOptions: options, extraFields: payload => AddSqlGraphContractJsonFields(payload, zeroSqlGraphSignal)).ToJsonString(jsonOptions)); else { - Console.Error.WriteLine(BuildZeroResultLine("No file dependencies found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No file dependencies found", options)); WriteSqlGraphContractWarningIfNeeded(json: false, zeroSqlGraphSignal, reader, options); WriteDegradedGraphZeroResult(reader, "edges", json: false, graphAvailable: reader._hasReferencesTable, jsonOptions); } @@ -6753,7 +6753,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) } else { - Console.Error.WriteLine(BuildZeroResultLine("No dependency cycles found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No dependency cycles found", options)); WriteSqlGraphContractWarningIfNeeded(json: false, sqlGraphSignal, reader, options); } return ZeroResultExitCode(options); @@ -6784,7 +6784,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) { foreach (var cycle in cycles) Console.WriteLine(string.Join(" -> ", cycle.Concat([cycle[0]]))); - Console.Error.WriteLine($"({cycles.Count} dependency cycles)"); + CommandErrorWriter.WriteStderr($"({cycles.Count} dependency cycles)"); WriteSqlGraphContractWarningIfNeeded(json: false, sqlGraphSignal, reader, options); return CommandExitCodes.Success; } @@ -6794,7 +6794,7 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) var syms = r.Symbols.Length > 60 ? r.Symbols[..57] + "..." : r.Symbols; Console.WriteLine($"{r.SourcePath,-45} -> {r.TargetPath,-45} ({r.ReferenceCount} refs: {syms})"); } - Console.Error.WriteLine($"({results.Count} dependency edges)"); + CommandErrorWriter.WriteStderr($"({results.Count} dependency edges)"); WriteSqlGraphContractWarningIfNeeded(json: false, sqlGraphSignal, reader, options); } return CommandExitCodes.Success; @@ -7118,8 +7118,8 @@ private static bool TryWriteWorkspaceDependencyFanOutError(QueryCommandOptions o var maxAdditional = MaxWorkspaceDependencyDatabaseCount - 1; var additionalCount = Math.Max(0, memberDbs.Count - 1); - Console.Error.WriteLine($"Error: deps --workspace-db accepts at most {maxAdditional} distinct additional databases ({MaxWorkspaceDependencyDatabaseCount} total including --db), which is {MaxWorkspaceDependencyDatabasePairCount} ordered cross-database pairs; got {additionalCount} additional ({memberDbs.Count} total, {pairCount} pairs)."); - Console.Error.WriteLine("Hint: pass fewer --workspace-db values or run deps separately for smaller workspace member groups."); + CommandErrorWriter.WriteStderr($"Error: deps --workspace-db accepts at most {maxAdditional} distinct additional databases ({MaxWorkspaceDependencyDatabaseCount} total including --db), which is {MaxWorkspaceDependencyDatabasePairCount} ordered cross-database pairs; got {additionalCount} additional ({memberDbs.Count} total, {pairCount} pairs)."); + CommandErrorWriter.WriteStderr("Hint: pass fewer --workspace-db values or run deps separately for smaller workspace member groups."); return true; } @@ -7273,7 +7273,7 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption var previewOptionError = ValidatePreviewOptions("hotspots", cmdArgs, allowMaxLineWidth: false, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs( @@ -7291,8 +7291,8 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption return CommandExitCodes.UsageError; if (!TryResolveHotspotsGroupBy(options.GroupBy, options.Lang, groupByName, out var groupBy, out var groupByError)) { - Console.Error.WriteLine(groupByError); - Console.Error.WriteLine("Usage: cdidx hotspots [--db ] [--json] [--limit ] [--kind ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]"); + CommandErrorWriter.WriteStderr(groupByError); + CommandErrorWriter.WriteStderr("Usage: cdidx hotspots [--db ] [--json] [--limit ] [--kind ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]"); return CommandExitCodes.UsageError; } @@ -7359,7 +7359,7 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption } else { - Console.Error.WriteLine(BuildZeroResultLine("No symbol hotspots found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No symbol hotspots found", options)); WriteZeroResultHints(options, reader); WriteKindHint(options.Kind, reader); WriteLangHint(options.Lang, reader); @@ -7406,7 +7406,7 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption var multi = g.DefinitionSites > 1 ? $" (×{g.DefinitionSites} sites)" : ""; Console.WriteLine($"{FormatHotspotScore(g.ReferenceScore),5} score {g.ReferenceCount,5} refs {ConsoleUi.ColorizeKind(s.Kind, 12)} {s.Name,-40} {s.Path}:{s.Line}{vis}{multi}"); } - Console.Error.WriteLine($"({groupedResults.Count} unique name/kind groups, {definitionSiteTotal} definition sites)"); + CommandErrorWriter.WriteStderr($"({groupedResults.Count} unique name/kind groups, {definitionSiteTotal} definition sites)"); WriteSqlGraphContractWarningIfNeeded(json: false, effectiveSqlGraphSignal, reader, options); } return CommandExitCodes.Success; @@ -7494,7 +7494,7 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption } else { - Console.Error.WriteLine("No symbol hotspots found."); + CommandErrorWriter.WriteStderr("No symbol hotspots found."); WriteZeroResultHints(options, reader); WriteKindHint(options.Kind, reader); WriteLangHint(options.Lang, reader); @@ -7541,7 +7541,7 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption { Console.WriteLine($"{result.ReferenceCount,5} refs {result.SymbolCount,5} symbols {result.Path}"); } - Console.Error.WriteLine($"({fileResults.Count} file hotspots; grouped_by={groupBy})"); + CommandErrorWriter.WriteStderr($"({fileResults.Count} file hotspots; grouped_by={groupBy})"); WriteHotspotFamilyWarningIfNeeded(json: false, fileHotspotSignal); WriteSqlGraphContractWarningIfNeeded(json: false, effectiveSqlGraphSignal, reader, options); } @@ -7578,7 +7578,7 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption { Console.WriteLine($"{countSummary.Count}"); if (!reader._hasReferencesTable) - Console.Error.WriteLine("WARN: symbol_references table missing — this count result is degraded, not authoritative."); + CommandErrorWriter.WriteStderr("WARN: symbol_references table missing — this count result is degraded, not authoritative."); WriteHotspotFamilyWarningIfNeeded(json: false, hotspotSignal); WriteSqlGraphContractWarningIfNeeded(json: false, countSqlGraphSignal, reader, options); } @@ -7597,7 +7597,7 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption { Console.WriteLine("0"); if (!reader._hasReferencesTable) - Console.Error.WriteLine("WARN: symbol_references table missing — this count result is degraded, not authoritative."); + CommandErrorWriter.WriteStderr("WARN: symbol_references table missing — this count result is degraded, not authoritative."); WriteHotspotFamilyWarningIfNeeded(json: false, hotspotSignal); } else @@ -7639,7 +7639,7 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption }).ToJsonString(jsonOptions)); else if (!options.Json) { - Console.Error.WriteLine(BuildZeroResultLine("No symbol hotspots found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No symbol hotspots found", options)); WriteZeroResultHints(options, reader); WriteKindHint(options.Kind, reader); WriteLangHint(options.Lang, reader); @@ -7681,7 +7681,7 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption var vis = s.Visibility != null ? $" [{s.Visibility}]" : ""; Console.WriteLine($"{FormatHotspotScore(r.ReferenceScore),5} score {r.ReferenceCount,5} refs {ConsoleUi.ColorizeKind(s.Kind, 12)} {s.Name,-40} {s.Path}:{s.Line}{vis}"); } - Console.Error.WriteLine($"({results.Count} symbol hotspots; grouped_by={groupBy})"); + CommandErrorWriter.WriteStderr($"({results.Count} symbol hotspots; grouped_by={groupBy})"); WriteHotspotFamilyWarningIfNeeded(json: false, hotspotSignal); WriteSqlGraphContractWarningIfNeeded(json: false, sqlGraphSignal, reader, options); } @@ -7695,7 +7695,7 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) var previewOptionError = ValidatePreviewOptions("unused", cmdArgs, allowMaxLineWidth: false, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs( @@ -7718,7 +7718,7 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) { // Warn if user specified an unsupported language / 未対応言語の場合は警告 if (options.Lang != null && !ReferenceExtractor.SupportsLanguage(options.Lang) && !options.Json) - Console.Error.WriteLine($"Warning: '{options.Lang}' does not support reference extraction. Unused results are unavailable for this language."); + CommandErrorWriter.WriteStderr($"Warning: '{options.Lang}' does not support reference extraction. Unused results are unavailable for this language."); bool? graphSupported = options.Lang != null ? ReferenceExtractor.SupportsLanguage(options.Lang) : null; var graphSupportReason = ReferenceExtractor.BuildGraphSupportReason(options.Lang, graphSupported); @@ -7807,7 +7807,7 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) } else { - Console.Error.WriteLine(BuildZeroResultLine("No unused symbols found", options)); + CommandErrorWriter.WriteStderr(BuildZeroResultLine("No unused symbols found", options)); WriteZeroResultHints(options, reader); WriteKindHint(options.Kind, reader); WriteLangHint(options.Lang, reader); @@ -7843,7 +7843,7 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) var summaryBuckets = OrderedUnusedBuckets .Where(bucketCounts.ContainsKey) .Select(bucket => $"{GetUnusedBucketHeading(bucket)}: {bucketCounts[bucket]}"); - Console.Error.WriteLine($"({results.Count} returned potentially unused symbols; returned buckets: {string.Join(", ", summaryBuckets)})"); + CommandErrorWriter.WriteStderr($"({results.Count} returned potentially unused symbols; returned buckets: {string.Join(", ", summaryBuckets)})"); WriteSqlGraphContractWarningIfNeeded(json: false, sqlGraphSignal, reader, options); } return CommandExitCodes.Success; @@ -8004,7 +8004,7 @@ public static int RunValidate(string[] cmdArgs, JsonSerializerOptions jsonOption var previewOptionError = ValidatePreviewOptions("validate", cmdArgs, allowMaxLineWidth: false, allowFocusOptions: false); if (previewOptionError != null) { - Console.Error.WriteLine(previewOptionError); + CommandErrorWriter.WriteStderr(previewOptionError); return CommandExitCodes.UsageError; } var options = ParseArgs( @@ -8057,10 +8057,10 @@ public static int RunValidate(string[] cmdArgs, JsonSerializerOptions jsonOption }.ToJsonString(jsonOptions)); } else if (!issuesAvailable) - Console.Error.WriteLine("WARN: file_issues table missing in this index (legacy or read-only DB) — validate output is degraded, not a real clean signal."); + CommandErrorWriter.WriteStderr("WARN: file_issues table missing in this index (legacy or read-only DB) — validate output is degraded, not a real clean signal."); else { - Console.Error.WriteLine("No encoding issues found."); + CommandErrorWriter.WriteStderr("No encoding issues found."); WriteValidateKindHint(options.Kind); } return CommandExitCodes.Success; @@ -8109,7 +8109,7 @@ public static int RunValidate(string[] cmdArgs, JsonSerializerOptions jsonOption Console.WriteLine($" {issue.Kind,-20} {issue.Path}{location} {issue.Message}"); } var kindCounts = issues.GroupBy(i => i.Kind).Select(g => $"{g.Key}: {g.Count()}"); - Console.Error.WriteLine($"\n({issues.Count} issues: {string.Join(", ", kindCounts)})"); + CommandErrorWriter.WriteStderr($"\n({issues.Count} issues: {string.Join(", ", kindCounts)})"); } return CommandExitCodes.Success; }); @@ -8222,7 +8222,7 @@ int WriteLanguages(IEnumerable> langua Console.WriteLine($" Gaps: {string.Join(", ", info.CapabilityGaps)}"); } } - Console.Error.WriteLine($"\n({filtered.Count} languages)"); + CommandErrorWriter.WriteStderr($"\n({filtered.Count} languages)"); } return CommandExitCodes.Success; @@ -8529,7 +8529,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) if (seenSingleValueOptions.Add(canonicalName)) return; var displayValue = ConsoleUi.FormatBoundedValue(newValue); - Console.Error.WriteLine($"Warning: {canonicalName} specified more than once; the rightmost CLI value '{displayValue}' takes precedence over earlier CLI values and any environment/config default."); + CommandErrorWriter.WriteStderr($"Warning: {canonicalName} specified more than once; the rightmost CLI value '{displayValue}' takes precedence over earlier CLI values and any environment/config default."); } for (int i = 0; i < args.Length; i++) @@ -10495,7 +10495,7 @@ private static int WithDb( { if (string.IsNullOrWhiteSpace(dbPath)) { - Console.Error.WriteLine(BuildMissingOptionValueError("--db")); + CommandErrorWriter.WriteStderr(BuildMissingOptionValueError("--db")); return CommandExitCodes.UsageError; } @@ -10512,8 +10512,8 @@ private static int WithDb( if (!DbPathResolver.TryNormalizeDbPath(dbPath, out fileExistsPath, out var parseError)) { var boundedDbPath = FormatDbDiagnosticValue(dbPath); - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbError}]: invalid --db file URI: {SqliteFileUri.FormatParseError(parseError)}"); - Console.Error.WriteLine($"Hint: pass a valid SQLite file URI such as `file:///absolute/path/to/codeindex.db?immutable=1`; the --db value resolved to: {boundedDbPath}"); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbError}]: invalid --db file URI: {SqliteFileUri.FormatParseError(parseError)}"); + CommandErrorWriter.WriteStderr($"Hint: pass a valid SQLite file URI such as `file:///absolute/path/to/codeindex.db?immutable=1`; the --db value resolved to: {boundedDbPath}"); GlobalToolLog.Error($"invalid_db_file_uri db={FormatLogValue(dbPath)} exception={FormatLogValue(parseError?.ToString() ?? "")}"); return CommandExitCodes.DatabaseError; } @@ -10524,10 +10524,10 @@ private static int WithDb( { var resolvedPath = Path.GetFullPath(fileExistsPath); var displayPath = FormatDbDiagnosticValue(resolvedPath); - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbNotFound}]: database not found at {displayPath}"); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbNotFound}]: database not found at {displayPath}"); if (isUri) - Console.Error.WriteLine($"Hint: the --db path resolved to: {displayPath}"); - Console.Error.WriteLine("Hint: create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun this command."); + CommandErrorWriter.WriteStderr($"Hint: the --db path resolved to: {displayPath}"); + CommandErrorWriter.WriteStderr("Hint: create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun this command."); return CommandExitCodes.DatabaseError; } } @@ -10575,27 +10575,27 @@ private static int WithDb( } catch (FtsQuerySyntaxException ex) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.FtsQuerySyntax}]: FTS5 query syntax: {ex.Message}"); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.FtsQuerySyntax}]: FTS5 query syntax: {ex.Message}"); if (ex.Message.Contains("no such column", StringComparison.OrdinalIgnoreCase)) { - Console.Error.WriteLine("Hint: `--fts` passes raw FTS5 syntax, so `:` is treated as a column qualifier. Drop `--fts` if you want literal-safe search."); + CommandErrorWriter.WriteStderr("Hint: `--fts` passes raw FTS5 syntax, so `:` is treated as a column qualifier. Drop `--fts` if you want literal-safe search."); } else { - Console.Error.WriteLine("Hint: `--fts` passes raw FTS5 syntax. Fix the query or drop `--fts` to use literal-safe search."); + CommandErrorWriter.WriteStderr("Hint: `--fts` passes raw FTS5 syntax. Fix the query or drop `--fts` to use literal-safe search."); } return CommandExitCodes.UsageError; } catch (SearchGuardCandidateLimitException ex) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.UsageError}]: guarded search is too broad: {ex.Message}"); - Console.Error.WriteLine("Hint: narrow the search with more specific query text, --lang, --path, or --exclude-tests, or reduce pagination offset before retrying guarded search."); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.UsageError}]: guarded search is too broad: {ex.Message}"); + CommandErrorWriter.WriteStderr("Hint: narrow the search with more specific query text, --lang, --path, or --exclude-tests, or reduce pagination offset before retrying guarded search."); return CommandExitCodes.UsageError; } catch (SearchQueryLimitException ex) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.UsageError}]: {ex.Message}"); - Console.Error.WriteLine("Hint: shorten the search text or split generated input into smaller literal queries."); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.UsageError}]: {ex.Message}"); + CommandErrorWriter.WriteStderr("Hint: shorten the search text or split generated input into smaller literal queries."); return CommandExitCodes.UsageError; } catch (Exception ex) @@ -10607,8 +10607,8 @@ private static int WithDb( { if (sqliteEx.SqliteErrorCode == 13) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.TempStoreExhausted}]: SQLite temp-store exhausted while evaluating this query."); - Console.Error.WriteLine("Hint: narrow the query with `--lang`, `--path`, or `--kind`, then retry with a freshly updated cdidx build if the problem persists."); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.TempStoreExhausted}]: SQLite temp-store exhausted while evaluating this query."); + CommandErrorWriter.WriteStderr("Hint: narrow the query with `--lang`, `--path`, or `--kind`, then retry with a freshly updated cdidx build if the problem persists."); Database.DbDebug.DumpToStderr(ex); return CommandExitCodes.DatabaseError; } @@ -10620,8 +10620,8 @@ private static int WithDb( // E002_DB_LOCKED で機械可読に区別する。 if (sqliteEx.SqliteErrorCode == 5 || sqliteEx.SqliteErrorCode == 6) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbLocked}]: SQLite reported the database is locked or busy: {ex.Message}"); - Console.Error.WriteLine("Hint: another process may be holding the database. Wait for it to finish, or retry with backoff."); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbLocked}]: SQLite reported the database is locked or busy: {ex.Message}"); + CommandErrorWriter.WriteStderr("Hint: another process may be holding the database. Wait for it to finish, or retry with backoff."); Database.DbDebug.DumpToStderr(ex); return CommandExitCodes.DatabaseError; } @@ -10642,8 +10642,8 @@ private static int WithDb( private static int WriteInvalidCodeIndexDbError(string dbPath, string? validationReason) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbError}]: {FormatDbDiagnosticValue(dbPath)} does not appear to be a valid CodeIndex database ({validationReason})."); - Console.Error.WriteLine("Hint: rebuild with `cdidx index --db ` to create a fresh database."); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbError}]: {FormatDbDiagnosticValue(dbPath)} does not appear to be a valid CodeIndex database ({validationReason})."); + CommandErrorWriter.WriteStderr("Hint: rebuild with `cdidx index --db ` to create a fresh database."); return CommandExitCodes.DatabaseError; } @@ -10665,16 +10665,16 @@ private static void WriteDatabaseOpenFailure(Exception ex, string dbPath) var unauthorized = FindException(ex); if (unauthorized != null) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbError}]: database access denied: {unauthorized.Message}"); - Console.Error.WriteLine(MacProfileDetector.BuildDatabaseHint(MacProfileDetector.DetectCurrent())); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbError}]: database access denied: {unauthorized.Message}"); + CommandErrorWriter.WriteStderr(MacProfileDetector.BuildDatabaseHint(MacProfileDetector.DetectCurrent())); return; } var io = FindException(ex); if (io != null) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbError}]: database I/O error: {io.Message}"); - Console.Error.WriteLine(MacProfileDetector.BuildDatabaseHint(MacProfileDetector.DetectCurrent())); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbError}]: database I/O error: {io.Message}"); + CommandErrorWriter.WriteStderr(MacProfileDetector.BuildDatabaseHint(MacProfileDetector.DetectCurrent())); return; } @@ -10683,27 +10683,27 @@ private static void WriteDatabaseOpenFailure(Exception ex, string dbPath) { if (sqlite.SqliteErrorCode == 14) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbError}]: database access/open denied: {sqlite.Message}"); - Console.Error.WriteLine(MacProfileDetector.BuildDatabaseHint(MacProfileDetector.DetectCurrent())); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbError}]: database access/open denied: {sqlite.Message}"); + CommandErrorWriter.WriteStderr(MacProfileDetector.BuildDatabaseHint(MacProfileDetector.DetectCurrent())); return; } if (sqlite.SqliteErrorCode == 11) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbError}]: SQLite reported database corruption: {sqlite.Message}"); - Console.Error.WriteLine("Hint: rebuild the index with `cdidx index --rebuild`, or delete the broken `.cdidx/codeindex.db*` files and run `cdidx index ` again."); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbError}]: SQLite reported database corruption: {sqlite.Message}"); + CommandErrorWriter.WriteStderr("Hint: rebuild the index with `cdidx index --rebuild`, or delete the broken `.cdidx/codeindex.db*` files and run `cdidx index ` again."); return; } - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbError}]: SQLite database error ({sqlite.SqliteErrorCode}): {sqlite.Message}"); - Console.Error.WriteLine(MacProfileDetector.IsPermissionStyleSqliteError(sqlite) + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbError}]: SQLite database error ({sqlite.SqliteErrorCode}): {sqlite.Message}"); + CommandErrorWriter.WriteStderr(MacProfileDetector.IsPermissionStyleSqliteError(sqlite) ? MacProfileDetector.BuildDatabaseHint(MacProfileDetector.DetectCurrent()) : "Hint: check `--db`, verify the index was written by a compatible cdidx version, or rebuild it with `cdidx index --rebuild`."); return; } - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbError}]: database error: {ex.Message}"); - Console.Error.WriteLine("Hint: check `--db`, or rebuild the index with `cdidx index ` if the DB may be stale or corrupted."); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbError}]: database error: {ex.Message}"); + CommandErrorWriter.WriteStderr("Hint: check `--db`, or rebuild the index with `cdidx index ` if the DB may be stale or corrupted."); } private static T? FindException(Exception ex) @@ -10791,11 +10791,11 @@ private static void WriteVerboseQueryDebug(QueryCommandOptions options, IReadOnl var rowsScanned = entries.Sum(entry => entry.RowsScanned); if (!options.Json) { - Console.Error.WriteLine($"DEBUG query: sql_statements={entries.Count} elapsed_ms={elapsedMs.ToString(CultureInfo.InvariantCulture)} rows_scanned={rowsScanned}"); + CommandErrorWriter.WriteStderr($"DEBUG query: sql_statements={entries.Count} elapsed_ms={elapsedMs.ToString(CultureInfo.InvariantCulture)} rows_scanned={rowsScanned}"); for (var i = 0; i < entries.Count; i++) { var entry = entries[i]; - Console.Error.WriteLine( + CommandErrorWriter.WriteStderr( $"DEBUG query sql_{i + 1}: elapsed_ms={Math.Round(entry.ElapsedMs, 3).ToString(CultureInfo.InvariantCulture)} rows_scanned={entry.RowsScanned}"); } return; @@ -11301,7 +11301,7 @@ private static void WriteFindScanSummary(FindScanSummary scan) var summary = $"scanned {scan.FilesScanned}/{scan.CandidateFiles} candidate files, {ConsoleUi.Counted(scan.LinesScanned, "line")}"; if (scan.Truncated) summary += scan.TruncationReason == null ? "; truncated" : $"; truncated by {scan.TruncationReason}"; - Console.Error.WriteLine($"({summary})"); + CommandErrorWriter.WriteStderr($"({summary})"); } // Reject queries that were supplied but resolve to empty / whitespace-only text so the user gets @@ -11370,20 +11370,20 @@ private static void WriteZeroResultHints(QueryCommandOptions options, DbReader r var freshness = reader.GetFreshnessHint(); if (freshness.FileCount == 0) { - Console.Error.WriteLine("Hint: the index is empty. Run 'cdidx index ' first."); + CommandErrorWriter.WriteStderr("Hint: the index is empty. Run 'cdidx index ' first."); return; } if (options.Lang != null || options.PathPatterns.Count > 0 || options.ExcludeTests || options.ExcludeComments || options.ExcludeStrings || options.ExcludeFixtures || options.ExcludePaths.Count > 0) - Console.Error.WriteLine($"Hint: {filterHint ?? "try removing --lang, --path, --exclude-path, --exclude-tests, --exclude-comments, --exclude-strings, or --exclude-fixtures to broaden the search."}"); + CommandErrorWriter.WriteStderr($"Hint: {filterHint ?? "try removing --lang, --path, --exclude-path, --exclude-tests, --exclude-comments, --exclude-strings, or --exclude-fixtures to broaden the search."}"); if (alternativeHint != null) - Console.Error.WriteLine($"Hint: {alternativeHint}"); + CommandErrorWriter.WriteStderr($"Hint: {alternativeHint}"); var staleAfter = ResolveStaleAfter(options, CdidxEnvironment.GetEnvironmentVariable(StaleAfterEnvironmentVariable)); if (staleAfter.Error != null) { - Console.Error.WriteLine(staleAfter.Error); + CommandErrorWriter.WriteStderr(staleAfter.Error); return; } @@ -11391,7 +11391,7 @@ private static void WriteZeroResultHints(QueryCommandOptions options, DbReader r { var age = GetUtcNow() - freshness.IndexedAt.Value; if (age > staleAfter.Value) - Console.Error.WriteLine($"Hint: the index is {FormatDuration(age)} old (threshold: {FormatDuration(staleAfter.Value)}). Run 'cdidx index ' to refresh."); + CommandErrorWriter.WriteStderr($"Hint: the index is {FormatDuration(age)} old (threshold: {FormatDuration(staleAfter.Value)}). Run 'cdidx index ' to refresh."); } } @@ -11452,7 +11452,7 @@ private static void WriteExactSubstringHintIfNeeded(SearchQueryHint? hint) if (hint == null) return; - Console.Error.WriteLine($"Hint: {hint.SuggestedAction}"); + CommandErrorWriter.WriteStderr($"Hint: {hint.SuggestedAction}"); } private static string BuildZeroResultLine(string message, QueryCommandOptions options) @@ -11809,9 +11809,9 @@ private static void WriteExactZeroHint(ExactZeroHintResult? exactZeroHint) ? string.Empty : $" (e.g. {string.Join(", ", exactZeroHint.SampleNames.Select(name => $"`{name}`"))})"; if (exactZeroHint.RelaxedCount.HasValue) - Console.Error.WriteLine($"Hint: --exact found 0 matches, but substring matching would return {exactZeroHint.RelaxedCount}{examples}. Drop --exact or use the exact indexed name."); + CommandErrorWriter.WriteStderr($"Hint: --exact found 0 matches, but substring matching would return {exactZeroHint.RelaxedCount}{examples}. Drop --exact or use the exact indexed name."); else - Console.Error.WriteLine($"Hint: --exact found 0 matches, but substring matching would return results{examples}. Drop --exact or use the exact indexed name."); + CommandErrorWriter.WriteStderr($"Hint: --exact found 0 matches, but substring matching would return results{examples}. Drop --exact or use the exact indexed name."); } private static bool IsSqlGraphContractSignal(ExactQuerySignal signal) @@ -11831,8 +11831,8 @@ private static int WriteStatusReadinessExplanation(string fieldName) var field = FindStatusReadinessField(fieldName); if (field == null) { - Console.Error.WriteLine($"Error: unknown status readiness field `{fieldName}`."); - Console.Error.WriteLine($"Hint: use one of: {string.Join(", ", StatusReadinessFields.Select(f => f.FieldName))}."); + CommandErrorWriter.WriteStderr($"Error: unknown status readiness field `{fieldName}`."); + CommandErrorWriter.WriteStderr($"Hint: use one of: {string.Join(", ", StatusReadinessFields.Select(f => f.FieldName))}."); return CommandExitCodes.UsageError; } @@ -11849,8 +11849,8 @@ private static int WriteStatusReadinessExplanationJson(string fieldName) var field = FindStatusReadinessField(fieldName); if (field == null) { - Console.Error.WriteLine($"Error: unknown status readiness field `{fieldName}`."); - Console.Error.WriteLine($"Hint: use one of: {string.Join(", ", StatusReadinessFields.Select(f => f.FieldName))}."); + CommandErrorWriter.WriteStderr($"Error: unknown status readiness field `{fieldName}`."); + CommandErrorWriter.WriteStderr($"Hint: use one of: {string.Join(", ", StatusReadinessFields.Select(f => f.FieldName))}."); return CommandExitCodes.UsageError; } @@ -12186,7 +12186,7 @@ private static StatusRepairCommand BuildStatusCheckRepairCommand(QueryCommandOpt private static void WriteStatusCheckDiagnostics(IReadOnlyList failures) { foreach (var failure in failures) - Console.Error.WriteLine(failure.Diagnostic); + CommandErrorWriter.WriteStderr(failure.Diagnostic); } private static int GetStatusCheckExitCode(IReadOnlyList failures) @@ -12388,22 +12388,22 @@ private static void WriteExactSymbolWarningIfNeeded(bool exact, bool json, Exact if (signal.HasMissingIndex) { - Console.Error.WriteLine($"WARN: --exact symbol query ran without the supporting index ({signal.DegradedReason}). Results are correct but may be slow."); - Console.Error.WriteLine("Hint: re-index with `cdidx index ` to upgrade the DB layout."); + CommandErrorWriter.WriteStderr($"WARN: --exact symbol query ran without the supporting index ({signal.DegradedReason}). Results are correct but may be slow."); + CommandErrorWriter.WriteStderr("Hint: re-index with `cdidx index ` to upgrade the DB layout."); return; } if (IsCSharpCanonicalNameSignal(signal)) { - Console.Error.WriteLine($"WARN: --exact symbol query may return false negatives ({signal.DegradedReason})."); - Console.Error.WriteLine($"Hint: run `{BuildCSharpCanonicalNameRepairCommand(reader, options)}` to refresh canonical C# symbol names."); + CommandErrorWriter.WriteStderr($"WARN: --exact symbol query may return false negatives ({signal.DegradedReason})."); + CommandErrorWriter.WriteStderr($"Hint: run `{BuildCSharpCanonicalNameRepairCommand(reader, options)}` to refresh canonical C# symbol names."); return; } if (IsSqlGraphContractSignal(signal)) { - Console.Error.WriteLine($"WARN: --exact symbol query may return false negatives ({signal.DegradedReason})."); - Console.Error.WriteLine($"Hint: run `{BuildSqlGraphContractRepairCommand(reader, options)}` to refresh SQL graph rows."); + CommandErrorWriter.WriteStderr($"WARN: --exact symbol query may return false negatives ({signal.DegradedReason})."); + CommandErrorWriter.WriteStderr($"Hint: run `{BuildSqlGraphContractRepairCommand(reader, options)}` to refresh SQL graph rows."); } } @@ -12423,7 +12423,7 @@ private static void WriteLangHint(string? lang, DbReader reader) return; if (status.Languages.Count > 0) - Console.Error.WriteLine($"Hint: '{lang}' not found in index. Available: {string.Join(", ", status.Languages.Keys.OrderBy(l => l))}"); + CommandErrorWriter.WriteStderr($"Hint: '{lang}' not found in index. Available: {string.Join(", ", status.Languages.Keys.OrderBy(l => l))}"); // Recover from `--lang pythno` / `--lang csarp` typos by suggesting the // closest indexed language first; if the typo does not match anything currently @@ -12445,7 +12445,7 @@ private static void WriteLangHint(string? lang, DbReader reader) var suggestion = ConsoleUi.FindClosestMatch(lang, status.Languages.Keys) ?? ConsoleUi.FindClosestMatch(lang, ReferenceExtractor.GetSupportedLanguages()); if (suggestion != null && !string.Equals(suggestion, lang, StringComparison.OrdinalIgnoreCase)) - Console.Error.WriteLine($"Did you mean: --lang {suggestion}?"); + CommandErrorWriter.WriteStderr($"Did you mean: --lang {suggestion}?"); } private static void WriteSymbolExtractionCapabilityHint(string? lang, DbReader reader) @@ -12459,7 +12459,7 @@ private static void WriteSymbolExtractionCapabilityHint(string? lang, DbReader r if (status.Languages.Count == 0 || !status.Languages.ContainsKey(lang)) return; - Console.Error.WriteLine($"Hint: '{lang}' is indexed for full-text search, but symbol extraction is not available for that language. Use `cdidx search --lang {lang}` for text matches or `cdidx languages --capability missing-symbols` to audit capability gaps."); + CommandErrorWriter.WriteStderr($"Hint: '{lang}' is indexed for full-text search, but symbol extraction is not available for that language. Use `cdidx search --lang {lang}` for text matches or `cdidx languages --capability missing-symbols` to audit capability gaps."); } // All valid symbol kinds emitted by SymbolExtractor / SymbolExtractor が出力する全有効シンボル種別 @@ -12491,17 +12491,17 @@ private static void WriteKindHint(string? kind, DbReader reader) if (kind == null) return; if (!AllValidKinds.Contains(kind)) { - Console.Error.WriteLine($"Hint: '{kind}' is not a known kind. Available: {string.Join(", ", AllValidKinds)}"); + CommandErrorWriter.WriteStderr($"Hint: '{kind}' is not a known kind. Available: {string.Join(", ", AllValidKinds)}"); var suggestion = ConsoleUi.FindClosestMatch(kind, AllValidKinds); if (suggestion != null) - Console.Error.WriteLine($"Did you mean: --kind {suggestion}?"); + CommandErrorWriter.WriteStderr($"Did you mean: --kind {suggestion}?"); return; } // Kind is valid but not found in this index — hint that no symbols of this kind exist // 種別は有効だがインデックスに存在しない場合のヒント var existingKinds = reader.GetDistinctKinds(); if (!existingKinds.Contains(kind)) - Console.Error.WriteLine($"Hint: no '{kind}' symbols in the index. Indexed kinds: {string.Join(", ", existingKinds)}"); + CommandErrorWriter.WriteStderr($"Hint: no '{kind}' symbols in the index. Indexed kinds: {string.Join(", ", existingKinds)}"); } private static void WriteValidateKindHint(string? kind) @@ -12517,10 +12517,10 @@ private static void WriteValidateKindHint(string? kind) // `validate --kind` は FileIndexer が出す file_issues kind のみ受理する。 // `--kind replacement_chra` のようなタイプミスは 0 行となり、クリーンな状態と区別が // つかないまま暗黙に握り潰されていた。ヒントと did-you-mean を出すよう改修 (#1582)。 - Console.Error.WriteLine($"Hint: '{kind}' is not a known validate kind. Available: {string.Join(", ", AllValidValidateKinds)}"); + CommandErrorWriter.WriteStderr($"Hint: '{kind}' is not a known validate kind. Available: {string.Join(", ", AllValidValidateKinds)}"); var suggestion = ConsoleUi.FindClosestMatch(kind, AllValidValidateKinds); if (suggestion != null) - Console.Error.WriteLine($"Did you mean: --kind {suggestion}?"); + CommandErrorWriter.WriteStderr($"Did you mean: --kind {suggestion}?"); } private static void WriteGraphReferenceKindHint(string command, string? kind, bool json) @@ -12538,14 +12538,14 @@ private static void WriteGraphReferenceKindHint(string command, string? kind, bo if (AllValidKinds.Contains(kind)) { - Console.Error.WriteLine($"WARN: '{ConsoleUi.FormatBoundedValue(kind)}' is a symbol kind, but --kind on '{command}' filters by reference kind ({string.Join(", ", acceptedKinds)}). Use symbols/definition/hotspots/unused to filter by symbol kind."); + CommandErrorWriter.WriteStderr($"WARN: '{ConsoleUi.FormatBoundedValue(kind)}' is a symbol kind, but --kind on '{command}' filters by reference kind ({string.Join(", ", acceptedKinds)}). Use symbols/definition/hotspots/unused to filter by symbol kind."); return; } - Console.Error.WriteLine($"Hint: '{ConsoleUi.FormatBoundedValue(kind)}' is not a known reference kind for '{command}'. Available reference kinds: {string.Join(", ", acceptedKinds)}"); + CommandErrorWriter.WriteStderr($"Hint: '{ConsoleUi.FormatBoundedValue(kind)}' is not a known reference kind for '{command}'. Available reference kinds: {string.Join(", ", acceptedKinds)}"); var suggestion = ConsoleUi.FindClosestMatch(kind, acceptedKinds); if (suggestion != null) - Console.Error.WriteLine($"Did you mean: --kind {suggestion}?"); + CommandErrorWriter.WriteStderr($"Did you mean: --kind {suggestion}?"); } // Reference kinds that are valid `references --kind` values but NOT valid @@ -12591,19 +12591,19 @@ private static bool TryRejectNonCallGraphKindForGraphCommand(string command, str return false; if (kind == "type_reference") - Console.Error.WriteLine($"Error: '--kind type_reference' is not supported on '{command}'. Type-position references are compile-time edges (declaration types, generic constraints, `is`/`as`/`instanceof`, XML-doc `cref`), not runtime calls, so `{command} --kind type_reference` cannot return accurate call-graph rows."); + CommandErrorWriter.WriteStderr($"Error: '--kind type_reference' is not supported on '{command}'. Type-position references are compile-time edges (declaration types, generic constraints, `is`/`as`/`instanceof`, XML-doc `cref`), not runtime calls, so `{command} --kind type_reference` cannot return accurate call-graph rows."); else if (kind == "import") - Console.Error.WriteLine($"Error: '--kind import' is not supported on '{command}'. Import references are structural dependency edges, not runtime calls, so `{command} --kind import` cannot return accurate call-graph rows."); + CommandErrorWriter.WriteStderr($"Error: '--kind import' is not supported on '{command}'. Import references are structural dependency edges, not runtime calls, so `{command} --kind import` cannot return accurate call-graph rows."); else - Console.Error.WriteLine($"Error: '--kind {kind}' is not supported on '{command}'. Metadata references are attributed to the enclosing body-range symbol rather than the annotated target, so `{command} --kind {kind}` cannot return accurate rows (file-level targets such as `[assembly: ...]` drop entirely)."); - Console.Error.WriteLine($"Hint: use `cdidx references --kind {kind}` instead."); + CommandErrorWriter.WriteStderr($"Error: '--kind {kind}' is not supported on '{command}'. Metadata references are attributed to the enclosing body-range symbol rather than the annotated target, so `{command} --kind {kind}` cannot return accurate rows (file-level targets such as `[assembly: ...]` drop entirely)."); + CommandErrorWriter.WriteStderr($"Hint: use `cdidx references --kind {kind}` instead."); return true; } private static void WriteGraphSupportHint(string? lang) { if (lang != null && !ReferenceExtractor.SupportsLanguage(lang)) - Console.Error.WriteLine($"Note: call-graph queries are not indexed for '{lang}'. Use search, definition, excerpt, or files instead."); + CommandErrorWriter.WriteStderr($"Note: call-graph queries are not indexed for '{lang}'. Use search, definition, excerpt, or files instead."); } private static void WriteImpactResolutionHint(ImpactAnalysisResult analysis) @@ -12619,15 +12619,15 @@ private static void WriteImpactResolutionHint(ImpactAnalysisResult analysis) var extra = analysis.DefinitionFileCount > pathPreview.Count ? $" (+{analysis.DefinitionFileCount - pathPreview.Count} more)" : string.Empty; - Console.Error.WriteLine($"Note: '{analysis.Query}' resolved to '{analysis.ResolvedName}' ({kinds}) as {ConsoleUi.Counted(analysis.DefinitionCount, "definition")} across {ConsoleUi.Counted(analysis.DefinitionFileCount, "file")}: {string.Join(", ", pathPreview)}{extra}"); + CommandErrorWriter.WriteStderr($"Note: '{analysis.Query}' resolved to '{analysis.ResolvedName}' ({kinds}) as {ConsoleUi.Counted(analysis.DefinitionCount, "definition")} across {ConsoleUi.Counted(analysis.DefinitionFileCount, "file")}: {string.Join(", ", pathPreview)}{extra}"); } else if (analysis.ZeroResultReason == "no_matching_definition") { - Console.Error.WriteLine($"Note: no indexed definition matched '{analysis.Query}'."); + CommandErrorWriter.WriteStderr($"Note: no indexed definition matched '{analysis.Query}'."); } if (!string.IsNullOrWhiteSpace(analysis.Suggestion)) - Console.Error.WriteLine($"Hint: {analysis.Suggestion}"); + CommandErrorWriter.WriteStderr($"Hint: {analysis.Suggestion}"); } // Emit a zero-result payload that distinguishes "real 0 hits" from "graph table missing @@ -12646,7 +12646,7 @@ private static void WriteDegradedGraphZeroResult(DbReader reader, string results } else { - Console.Error.WriteLine("WARN: symbol_references table missing — this 0-result is degraded, not authoritative."); + CommandErrorWriter.WriteStderr("WARN: symbol_references table missing — this 0-result is degraded, not authoritative."); } } @@ -12657,22 +12657,22 @@ private static void WriteExactGraphWarningIfNeeded(bool exact, bool json, ExactQ if (signal.HasMissingIndex) { - Console.Error.WriteLine($"WARN: --exact graph query ran without the supporting index ({signal.DegradedReason}). Results are correct but may be slow."); - Console.Error.WriteLine("Hint: re-index with `cdidx index ` to upgrade the DB layout."); + CommandErrorWriter.WriteStderr($"WARN: --exact graph query ran without the supporting index ({signal.DegradedReason}). Results are correct but may be slow."); + CommandErrorWriter.WriteStderr("Hint: re-index with `cdidx index ` to upgrade the DB layout."); return; } if (IsCSharpCanonicalNameSignal(signal)) { - Console.Error.WriteLine($"WARN: --exact graph query may return false negatives ({signal.DegradedReason})."); - Console.Error.WriteLine($"Hint: run `{BuildCSharpCanonicalNameRepairCommand(reader, options)}` to refresh canonical C# symbol names."); + CommandErrorWriter.WriteStderr($"WARN: --exact graph query may return false negatives ({signal.DegradedReason})."); + CommandErrorWriter.WriteStderr($"Hint: run `{BuildCSharpCanonicalNameRepairCommand(reader, options)}` to refresh canonical C# symbol names."); return; } if (IsSqlGraphContractSignal(signal)) { - Console.Error.WriteLine($"WARN: --exact graph query may return false negatives ({signal.DegradedReason})."); - Console.Error.WriteLine($"Hint: run `{BuildSqlGraphContractRepairCommand(reader, options)}` to refresh SQL graph rows."); + CommandErrorWriter.WriteStderr($"WARN: --exact graph query may return false negatives ({signal.DegradedReason})."); + CommandErrorWriter.WriteStderr($"Hint: run `{BuildSqlGraphContractRepairCommand(reader, options)}` to refresh SQL graph rows."); } } @@ -12683,22 +12683,22 @@ private static void WriteExactBundleWarningIfNeeded(bool exact, bool json, Exact if (signal.HasMissingIndex) { - Console.Error.WriteLine($"WARN: --exact inspect bundle ran without all supporting indexes ({signal.DegradedReason}). Results are correct but may be slow."); - Console.Error.WriteLine("Hint: re-index with `cdidx index ` to upgrade the DB layout."); + CommandErrorWriter.WriteStderr($"WARN: --exact inspect bundle ran without all supporting indexes ({signal.DegradedReason}). Results are correct but may be slow."); + CommandErrorWriter.WriteStderr("Hint: re-index with `cdidx index ` to upgrade the DB layout."); return; } if (IsCSharpCanonicalNameSignal(signal)) { - Console.Error.WriteLine($"WARN: --exact inspect bundle may return false negatives ({signal.DegradedReason})."); - Console.Error.WriteLine($"Hint: run `{BuildCSharpCanonicalNameRepairCommand(reader, options)}` to refresh canonical C# symbol names."); + CommandErrorWriter.WriteStderr($"WARN: --exact inspect bundle may return false negatives ({signal.DegradedReason})."); + CommandErrorWriter.WriteStderr($"Hint: run `{BuildCSharpCanonicalNameRepairCommand(reader, options)}` to refresh canonical C# symbol names."); return; } if (IsSqlGraphContractSignal(signal)) { - Console.Error.WriteLine($"WARN: --exact inspect bundle may return false negatives ({signal.DegradedReason})."); - Console.Error.WriteLine($"Hint: run `{BuildSqlGraphContractRepairCommand(reader, options)}` to refresh SQL graph rows."); + CommandErrorWriter.WriteStderr($"WARN: --exact inspect bundle may return false negatives ({signal.DegradedReason})."); + CommandErrorWriter.WriteStderr($"Hint: run `{BuildSqlGraphContractRepairCommand(reader, options)}` to refresh SQL graph rows."); } } @@ -12710,7 +12710,7 @@ private static void WriteGraphCountResult(DbReader reader, int count, int files, Console.WriteLine($"{count}"); WriteGraphSupportOverrideHint(graphSupportOverride); if (!graphAvailable) - Console.Error.WriteLine("WARN: symbol_references table missing — this count result is degraded, not authoritative."); + CommandErrorWriter.WriteStderr("WARN: symbol_references table missing — this count result is degraded, not authoritative."); return; } @@ -12828,7 +12828,7 @@ private static void WriteGraphSupportOverrideHint(GraphSupportOverride? graphSup if (graphSupportOverride == null) return; - Console.Error.WriteLine($"Note: {graphSupportOverride.GraphSupportReason}"); + CommandErrorWriter.WriteStderr($"Note: {graphSupportOverride.GraphSupportReason}"); } private sealed record GraphSupportOverride( @@ -12854,8 +12854,8 @@ private static void WriteHotspotFamilyWarningIfNeeded(bool json, HotspotFamilySi if (json || signal.Ready || signal.DegradedReason == null) return; - Console.Error.WriteLine($"WARN: {signal.DegradedReason}"); - Console.Error.WriteLine("Hint: rerun `cdidx index ` to restore authoritative cross-file hotspot families."); + CommandErrorWriter.WriteStderr($"WARN: {signal.DegradedReason}"); + CommandErrorWriter.WriteStderr("Hint: rerun `cdidx index ` to restore authoritative cross-file hotspot families."); } internal static SqlGraphContractSignal NarrowSqlGraphContractSignal(SqlGraphContractSignal signal, bool relevant) @@ -12902,8 +12902,8 @@ private static void WriteSqlGraphContractWarningIfNeeded(bool json, SqlGraphCont if (json || !signal.Relevant || signal.Ready || signal.DegradedReason == null) return; - Console.Error.WriteLine($"WARN: {signal.DegradedReason}"); - Console.Error.WriteLine($"Hint: run `{BuildSqlGraphContractRepairCommand(reader, options)}` to refresh SQL graph rows before trusting SQL graph/dependency results."); + CommandErrorWriter.WriteStderr($"WARN: {signal.DegradedReason}"); + CommandErrorWriter.WriteStderr($"Hint: run `{BuildSqlGraphContractRepairCommand(reader, options)}` to refresh SQL graph rows before trusting SQL graph/dependency results."); } // Per-flag upper bounds for numeric CLI options. Without a cap, `--limit 2147483647` or @@ -12991,9 +12991,9 @@ private static void WriteSqlGraphContractWarningIfNeeded(bool json, SqlGraphCont // Build a missing-value error string with optional caller-supplied hint lines first, then the // per-flag hint from MissingOptionValueHints. Newline-separated so each Hint stays on its own - // line when written via Console.Error.WriteLine. Returns just the base error if no hint exists. + // line when written via CommandErrorWriter.WriteStderr. Returns just the base error if no hint exists. // 呼び出し元固有のヒント (例: inline-form) を先に、テーブル由来のフラグ別ヒントを後ろに追記する。 - // Console.Error.WriteLine 経由で出力されたとき各 Hint が別行になるよう改行で連結する。 + // CommandErrorWriter.WriteStderr 経由で出力されたとき各 Hint が別行になるよう改行で連結する。 private static string BuildMissingOptionValueError(string optionName, params string?[] extraHintLines) { var sb = new System.Text.StringBuilder(); diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index 7fd9899593..95d6a562db 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -569,7 +569,7 @@ private static void WriteFuzzyDuplicateWarning(string hash, double score, double { try { - Console.Error.WriteLine( + CommandErrorWriter.WriteStderr( $"cdidx: fuzzy suggestion duplicate matched hash {hash} with score {score:0.###} (threshold {threshold:0.###})"); } catch (ObjectDisposedException) @@ -1104,7 +1104,7 @@ private static void WriteRedactionWarning(IReadOnlyCollection redactedTy { try { - Console.Error.WriteLine($"[cdidx] Redacted sensitive suggestion text before local persistence/GitHub submission: {string.Join(", ", redactedTypes)}."); + CommandErrorWriter.WriteStderr($"[cdidx] Redacted sensitive suggestion text before local persistence/GitHub submission: {string.Join(", ", redactedTypes)}."); } catch (ObjectDisposedException) { diff --git a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs index 466b03d254..c66c16a307 100644 --- a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs +++ b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs @@ -101,7 +101,7 @@ private static int RunShow(List records, Options options, Json var record = ResolveById(records, options.Id); if (record == null) { - Console.Error.WriteLine($"Suggestion not found: {options.Id}"); + CommandErrorWriter.WriteStderr($"Suggestion not found: {options.Id}"); return CommandExitCodes.NotFound; } diff --git a/src/CodeIndex/Cli/UpdateChecker.cs b/src/CodeIndex/Cli/UpdateChecker.cs index f83a390f1e..ac57900395 100644 --- a/src/CodeIndex/Cli/UpdateChecker.cs +++ b/src/CodeIndex/Cli/UpdateChecker.cs @@ -446,7 +446,7 @@ private static void ReportCacheDiagnostic(string code, string cachePath, Excepti if (sink != null) sink(message); else - Console.Error.WriteLine(message); + CommandErrorWriter.WriteStderr(message); } private static bool ShouldEmitCacheDiagnostics() diff --git a/src/CodeIndex/Database/DbConnectionFactory.cs b/src/CodeIndex/Database/DbConnectionFactory.cs index 9ba759317a..1fc081655b 100644 --- a/src/CodeIndex/Database/DbConnectionFactory.cs +++ b/src/CodeIndex/Database/DbConnectionFactory.cs @@ -141,7 +141,7 @@ internal static SqliteConnection OpenReadOnly(string dbPath) // trade-off knowingly. // サンドボックスで -shm/-wal に触れない場合の最終手段。hot WAL 誤判定を避けるため、 // ファイルサイズでの拒否はやめ、stderr 警告のみ出してフォールバック。 - Console.Error.WriteLine("Warning: falling back to SQLite immutable=1 read-only open. " + + CommandErrorWriter.WriteStderr("Warning: falling back to SQLite immutable=1 read-only open. " + "If the base DB has uncheckpointed WAL state, the snapshot may be stale. " + "Re-run cdidx on writable storage to checkpoint WAL if this matters."); diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index f26dd41b9e..fbdbcc0971 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -338,7 +338,7 @@ public DbContext(string dbPath, CancellationToken cancellationToken = default) // Enable WAL mode and verify it was applied / WALモードを有効にし適用を確認 var journalMode = ExecuteScalar("PRAGMA journal_mode=WAL"); if (!string.Equals(journalMode, "wal", StringComparison.OrdinalIgnoreCase)) - Console.Error.WriteLine($"Warning: WAL mode not enabled (got '{journalMode}')"); + CommandErrorWriter.WriteStderr($"Warning: WAL mode not enabled (got '{journalMode}')"); ExecuteSynchronousPragmaWithFallback(Execute); Execute($"PRAGMA wal_autocheckpoint={DefaultWalAutocheckpointPages}"); ApplyPrivateDatabaseFileModes(dbPath); @@ -376,7 +376,7 @@ public DbContext(string dbPath, CancellationToken cancellationToken = default) ApplyPrivateDatabaseFileModes(dbPath); var journalMode = ExecuteScalar("PRAGMA journal_mode=WAL"); if (!string.Equals(journalMode, "wal", StringComparison.OrdinalIgnoreCase)) - Console.Error.WriteLine($"Warning: WAL mode not enabled (got '{journalMode}')"); + CommandErrorWriter.WriteStderr($"Warning: WAL mode not enabled (got '{journalMode}')"); ExecuteSynchronousPragmaWithFallback(Execute); Execute($"PRAGMA wal_autocheckpoint={DefaultWalAutocheckpointPages}"); ApplyPrivateDatabaseFileModes(dbPath); @@ -549,7 +549,7 @@ private void WarnIfBatchInProgress() var raw = GetMetaString(BatchInProgressMetaKey); if (string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)) { - Console.Error.WriteLine("Warning: Last batch did not complete; run `cdidx index --rebuild` to re-index from a known clean state."); + CommandErrorWriter.WriteStderr("Warning: Last batch did not complete; run `cdidx index --rebuild` to re-index from a known clean state."); if (!_isReadOnly) Execute("PRAGMA user_version = 0"); } @@ -2292,7 +2292,7 @@ private void EnsureForeignKeysEnabled() Execute("PRAGMA foreign_keys=ON"); var fkResult = ExecuteScalar("PRAGMA foreign_keys"); if (fkResult != "1") - Console.Error.WriteLine("Warning: foreign_keys pragma not enabled"); + CommandErrorWriter.WriteStderr("Warning: foreign_keys pragma not enabled"); } /// @@ -2617,7 +2617,7 @@ private static void EmitMigrationFailureWarning(DbMigrationFailure failure) // Single line so the next read attempt only sees one clear "migration partial" record // even if multiple commands share the same process / log stream. // 1 行に集約し、後続 read エラーと混在しても拾いやすい形にする。 - Console.Error.WriteLine( + CommandErrorWriter.WriteStderr( $"Warning: cdidx schema migration step \"{failure.Step}\" failed " + $"(SQLite error {failure.SqliteErrorCode}: {failure.SqliteMessage.TrimEnd('.')}). " + "Subsequent read queries may fail with 'no such column' until the migration completes. " + diff --git a/src/CodeIndex/Database/DbDebug.cs b/src/CodeIndex/Database/DbDebug.cs index c684812808..15308be22a 100644 --- a/src/CodeIndex/Database/DbDebug.cs +++ b/src/CodeIndex/Database/DbDebug.cs @@ -158,7 +158,7 @@ private static void WarnInvalidDebugValueOnce(string value) if (Interlocked.Exchange(ref _invalidDebugValueWarned, 1) != 0) return; var displayValue = DiagnosticRedactor.FormatEnvironmentValue("CDIDX_DEBUG", value); - Console.Error.WriteLine( + CommandErrorWriter.WriteStderr( $"[cdidx] CDIDX_DEBUG value '{displayValue}' is not recognized. Expected one of: 1, 0, true, false, yes, no, on, off, unsafe, full. Falling back to off."); } @@ -166,7 +166,7 @@ private static void WarnUnsafeDowngradedOnce() { if (Interlocked.Exchange(ref _unsafeDowngradeWarned, 1) != 0) return; - Console.Error.WriteLine( + CommandErrorWriter.WriteStderr( "[cdidx] CDIDX_DEBUG=unsafe was ignored: pass --debug-unsafe on the command line to enable raw text dumps. Falling back to redacted mode for this process."); } @@ -278,7 +278,7 @@ private static void WriteSlowQueryToStderr(SqliteCommand cmd, double elapsedMs, { var sql = FormatSqlForSlowQueryLog(cmd.CommandText ?? string.Empty); var rowText = rowsRead.HasValue ? $" rows={rowsRead.Value}" : string.Empty; - Console.Error.WriteLine($"[cdidx] slow_query elapsed_ms={elapsedMs:0.###}{rowText} sql={sql}"); + CommandErrorWriter.WriteStderr($"[cdidx] slow_query elapsed_ms={elapsedMs:0.###}{rowText} sql={sql}"); } internal static string FormatSqlForSlowQueryLog(string sql) diff --git a/src/CodeIndex/Database/DbWriter.cs b/src/CodeIndex/Database/DbWriter.cs index 4053d8d9dc..7ef1af2af5 100644 --- a/src/CodeIndex/Database/DbWriter.cs +++ b/src/CodeIndex/Database/DbWriter.cs @@ -1182,7 +1182,7 @@ private void WarnSkippedBatchRow(string rowIdentifier, Exception batchException, if (testSink != null) testSink(message); else - Console.Error.WriteLine(message); + CommandErrorWriter.WriteStderr(message); } internal static string BuildBatchRowSkipWarningForTesting(string rowIdentifier, Exception batchException, Exception rowException) diff --git a/src/CodeIndex/Diagnostics/BackgroundTaskObserver.cs b/src/CodeIndex/Diagnostics/BackgroundTaskObserver.cs index 15cc564ff2..08649df8c3 100644 --- a/src/CodeIndex/Diagnostics/BackgroundTaskObserver.cs +++ b/src/CodeIndex/Diagnostics/BackgroundTaskObserver.cs @@ -1,3 +1,5 @@ +using CodeIndex.Cli; + namespace CodeIndex.Diagnostics; internal static class BackgroundTaskObserver @@ -74,7 +76,7 @@ private static void WriteWarning(Action? warningWriter, string message) if (warningWriter is not null) warningWriter(message); else - Console.Error.WriteLine(message); + CommandErrorWriter.WriteStderr(message); } catch (ObjectDisposedException) { diff --git a/src/CodeIndex/Indexer/Extensibility/ConfiguredSymbolExtractor.cs b/src/CodeIndex/Indexer/Extensibility/ConfiguredSymbolExtractor.cs index f421e3ded4..a295568231 100644 --- a/src/CodeIndex/Indexer/Extensibility/ConfiguredSymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Extensibility/ConfiguredSymbolExtractor.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using CodeIndex.Cli; using CodeIndex.Models; namespace CodeIndex.Indexer.Extensibility; @@ -83,7 +84,7 @@ private void DisablePatternAfterTimeout(PatternRule pattern) if (!shouldReport) return; - Console.Error.WriteLine( + CommandErrorWriter.WriteStderr( $"[cdidx] Pattern extractor for language '{Language}' kind '{pattern.Kind}' timed out after {(int)ExtractorPluginRegistry.PatternRegexTimeout.TotalMilliseconds}ms; skipped this pattern."); } } diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs index 10d5ceb17c..174886d475 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using CodeIndex.Cli; using CodeIndex.Diagnostics; namespace CodeIndex.Indexer.Extensibility; @@ -7,7 +8,7 @@ public static partial class ExtractorPluginRegistry { private static void ReportPatternConfigRejected(string path, string reason) { - Console.Error.WriteLine($"[cdidx] Skipped pattern config '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); + CommandErrorWriter.WriteStderr($"[cdidx] Skipped pattern config '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); RecordDiagnostic( "pattern", path, @@ -20,7 +21,7 @@ private static void ReportPatternConfigRejected(string path, string reason) private static void ReportPatternConfigSkipped(string path, string reason) { - Console.Error.WriteLine($"[cdidx] Skipped pattern config '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); + CommandErrorWriter.WriteStderr($"[cdidx] Skipped pattern config '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); RecordDiagnostic( "pattern", path, @@ -33,7 +34,7 @@ private static void ReportPatternConfigSkipped(string path, string reason) private static void ReportPatternDirectoryRejected(string path, string reason) { - Console.Error.WriteLine($"[cdidx] Skipped pattern directory '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); + CommandErrorWriter.WriteStderr($"[cdidx] Skipped pattern directory '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); RecordDiagnostic( "pattern_directory", path, @@ -46,7 +47,7 @@ private static void ReportPatternDirectoryRejected(string path, string reason) private static void ReportPatternDirectorySkipped(string path, string reason) { - Console.Error.WriteLine($"[cdidx] Skipped pattern directory '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); + CommandErrorWriter.WriteStderr($"[cdidx] Skipped pattern directory '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); RecordDiagnostic( "pattern_directory", path, diff --git a/src/CodeIndex/Indexer/Scanning/LanguageMapOverrides.cs b/src/CodeIndex/Indexer/Scanning/LanguageMapOverrides.cs index 40e97e8f19..d057e021ee 100644 --- a/src/CodeIndex/Indexer/Scanning/LanguageMapOverrides.cs +++ b/src/CodeIndex/Indexer/Scanning/LanguageMapOverrides.cs @@ -1,4 +1,5 @@ using System.Text; +using CodeIndex.Cli; namespace CodeIndex.Indexer; @@ -187,7 +188,7 @@ private static void ReportWarningOnce(string message) return; } - Console.Error.WriteLine("cdidx: warning: " + message); + CommandErrorWriter.WriteStderr("cdidx: warning: " + message); } private static bool TryReadScalar(string line, string key, out string value) diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.TypeScriptPathAliases.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.TypeScriptPathAliases.cs index f7e9def6f1..257ff9ddf4 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.TypeScriptPathAliases.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.TypeScriptPathAliases.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.Json; +using CodeIndex.Cli; namespace CodeIndex.Indexer; @@ -382,7 +383,7 @@ private static void ReportTypeScriptPathAliasWarningOnce(string message) return; } - Console.Error.WriteLine("cdidx: warning: " + message); + CommandErrorWriter.WriteStderr("cdidx: warning: " + message); } private static IReadOnlyList SortTypeScriptPathAliasRules(IReadOnlyList rules) => diff --git a/src/CodeIndex/Mcp/McpEnvironment.cs b/src/CodeIndex/Mcp/McpEnvironment.cs index de30c5d75e..f114593cd0 100644 --- a/src/CodeIndex/Mcp/McpEnvironment.cs +++ b/src/CodeIndex/Mcp/McpEnvironment.cs @@ -53,7 +53,7 @@ internal static McpEnvironmentSwitch ReadOptInSwitch(string name) } internal static void WriteWarning(string source, string message) - => Console.Error.WriteLine($"Warning: {source}: {message}"); + => CommandErrorWriter.WriteStderr($"Warning: {source}: {message}"); internal static string FormatDiagnosticValue(string? raw) => DiagnosticRedactor.FormatEnvironmentValue(raw); diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index a4c496cb23..02b7f820f7 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -587,7 +587,7 @@ internal async Task RunAsync(IMcpTransport transport, CancellationToken cancella } } - Console.Error.WriteLine("[cdidx-mcp] Server stopped. Restart `cdidx mcp` when your client reconnects."); + CommandErrorWriter.WriteStderr("[cdidx-mcp] Server stopped. Restart `cdidx mcp` when your client reconnects."); } private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, CancellationToken loopToken) @@ -734,7 +734,7 @@ private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, Cancella } await DrainInFlightTasksAsync(tasks, DefaultEofDrainTimeout, DefaultEofPostCancelDrainTimeout, loopToken).ConfigureAwait(false); - Console.Error.WriteLine("[cdidx-mcp] Server stopped. Restart `cdidx mcp` when your client reconnects."); + CommandErrorWriter.WriteStderr("[cdidx-mcp] Server stopped. Restart `cdidx mcp` when your client reconnects."); } internal async Task DrainInFlightTasksAsync( @@ -758,7 +758,7 @@ internal async Task DrainInFlightTasksAsync( if (graceDelay.IsCanceled) return; - Console.Error.WriteLine($"[cdidx-mcp] EOF reached with {tasks.Count} in-flight request(s); cancelling after {gracePeriod.TotalMilliseconds:0}ms grace period."); + CommandErrorWriter.WriteStderr($"[cdidx-mcp] EOF reached with {tasks.Count} in-flight request(s); cancelling after {gracePeriod.TotalMilliseconds:0}ms grace period."); try { if (!_shutdownCts.IsCancellationRequested) @@ -793,7 +793,7 @@ private static async Task ObserveInFlightTasksAsync(Task tasks) } catch (Exception ex) { - Console.Error.WriteLine($"[cdidx-mcp] In-flight request ended during EOF drain ({ex.GetType().Name})."); + CommandErrorWriter.WriteStderr($"[cdidx-mcp] In-flight request ended during EOF drain ({ex.GetType().Name})."); } } @@ -1235,7 +1235,7 @@ private static void WriteMcpLogLine(string message) var line = AddCorrelationPrefix(message); try { - Console.Error.WriteLine(line); + CommandErrorWriter.WriteStderr(line); } catch (Exception ex) when (ex is IOException or ObjectDisposedException) { @@ -1475,7 +1475,7 @@ private string BuildKeepAliveNotificationJson() || seconds > MaxKeepAliveIntervalSeconds) { var displayValue = DiagnosticRedactor.FormatEnvironmentValue(KeepAliveIntervalEnvironmentVariable, raw); - Console.Error.WriteLine( + CommandErrorWriter.WriteStderr( $"[cdidx-mcp] Ignoring invalid {KeepAliveIntervalEnvironmentVariable}='{displayValue}'. Expected a finite value between {MinKeepAliveIntervalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} and {MaxKeepAliveIntervalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} seconds. Keep-alive notifications stay disabled."); return null; } @@ -1719,7 +1719,7 @@ private void RecordTimedOutIsolatedActionDraining(string requestKey, TimeSpan el elapsedMs, "draining"); } - Console.Error.WriteLine(BuildTimedOutIsolatedActionDrainingLog(requestKey, elapsedMs)); + CommandErrorWriter.WriteStderr(BuildTimedOutIsolatedActionDrainingLog(requestKey, elapsedMs)); } private void RecordTimedOutIsolatedActionDrained(string requestKey, Task task) @@ -4050,14 +4050,14 @@ private static int ReadPositiveIntEnvironmentLimit(string envVar, int defaultVal || limit <= 0) { var displayValue = DiagnosticRedactor.FormatEnvironmentValue(envVar, raw); - Console.Error.WriteLine($"[cdidx-mcp] Ignoring invalid {envVar}='{displayValue}'. Expected a positive integer for {description}. Using default {defaultValue.ToString(System.Globalization.CultureInfo.InvariantCulture)}."); + CommandErrorWriter.WriteStderr($"[cdidx-mcp] Ignoring invalid {envVar}='{displayValue}'. Expected a positive integer for {description}. Using default {defaultValue.ToString(System.Globalization.CultureInfo.InvariantCulture)}."); return defaultValue; } if (limit > maximumValue) { var displayValue = DiagnosticRedactor.FormatEnvironmentValue(envVar, raw); - Console.Error.WriteLine($"[cdidx-mcp] Clamping {envVar}='{displayValue}' to maximum {maximumValue.ToString(System.Globalization.CultureInfo.InvariantCulture)} for {description}."); + CommandErrorWriter.WriteStderr($"[cdidx-mcp] Clamping {envVar}='{displayValue}' to maximum {maximumValue.ToString(System.Globalization.CultureInfo.InvariantCulture)} for {description}."); return maximumValue; } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index a15985fb29..95ac9a1507 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -7354,7 +7354,7 @@ private static void TryDeleteCdidxDirectoryWritableProbe(string probePath) } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - Console.Error.WriteLine($"Warning: failed to delete .cdidx writable probe {ConsoleUi.FormatBoundedValue(probePath)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + CommandErrorWriter.WriteStderr($"Warning: failed to delete .cdidx writable probe {ConsoleUi.FormatBoundedValue(probePath)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); } } diff --git a/src/CodeIndex/Mcp/RateLimiter.cs b/src/CodeIndex/Mcp/RateLimiter.cs index 00778e3d52..067bb7efc4 100644 --- a/src/CodeIndex/Mcp/RateLimiter.cs +++ b/src/CodeIndex/Mcp/RateLimiter.cs @@ -270,7 +270,7 @@ internal sealed class RateLimiterOptions public static RateLimiterOptions FromEnvironment(Func? envReader = null, Action? warningSink = null) { envReader ??= CdidxEnvironment.GetEnvironmentVariable; - warningSink ??= Console.Error.WriteLine; + warningSink ??= CommandErrorWriter.WriteStderr; var rpsRaw = envReader(RpsEnvVar); if (string.IsNullOrWhiteSpace(rpsRaw)) diff --git a/tests/CodeIndex.Tests/DbDebugTests.cs b/tests/CodeIndex.Tests/DbDebugTests.cs index e0f24f7e83..f15c42d258 100644 --- a/tests/CodeIndex.Tests/DbDebugTests.cs +++ b/tests/CodeIndex.Tests/DbDebugTests.cs @@ -291,6 +291,31 @@ public void IsEnabled_InvalidDebugValue_RedactsSecretLookingValue_Issue3403() } } + [Fact] + public void IsEnabled_InvalidDebugValue_RedactsThroughSharedStderrSink_Issue3683() + { + using var env = EnvironmentVariableScope.Capture("CDIDX_DEBUG"); + const string secret = "fedcba9876543210fedcba9876543210"; + env.Set("CDIDX_DEBUG", $"password={secret}"); + try + { + DbDebug.ResetForTesting(); + using var capture = ConsoleCapture.Start(captureOut: true, captureError: true); + + Assert.False(DbDebug.IsEnabled); + + var stdout = capture.Out!.ToString()!; + var stderr = capture.Error!.ToString()!; + Assert.Equal(string.Empty, stdout); + Assert.Contains("CDIDX_DEBUG value 'password=' is not recognized", stderr); + Assert.DoesNotContain(secret, stderr); + } + finally + { + DbDebug.ResetForTesting(); + } + } + [Fact] public void IsEnabled_InvalidDebugValue_RedactsPathAndUrlValue_Issue3403() { diff --git a/tests/CodeIndex.Tests/HookCommandRunnerTests.cs b/tests/CodeIndex.Tests/HookCommandRunnerTests.cs index af1e017487..79bed2ac22 100644 --- a/tests/CodeIndex.Tests/HookCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/HookCommandRunnerTests.cs @@ -103,6 +103,19 @@ public void Hooks_UnknownOption_TruncatesOversizedToken() Assert.DoesNotContain("Warning: unknown option", stderr); } + [Fact] + public void Hooks_UnknownOptionJson_WritesStructuredErrorWithoutStderr_Issue3683() + { + var (exitCode, stdout, stderr) = RunHooksAndCaptureStreams(["--json", "--bogus"]); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + Assert.Equal("error", document.RootElement.GetProperty("status").GetString()); + Assert.Equal("unknown option '--bogus'", document.RootElement.GetProperty("message").GetString()); + Assert.DoesNotContain("Usage: cdidx hooks", stdout, StringComparison.Ordinal); + } + [Fact] public void Hooks_CommandUnknownOption_ReturnsUsageError() {