diff --git a/changelog.d/unreleased/1617.fixed.md b/changelog.d/unreleased/1617.fixed.md new file mode 100644 index 0000000000..9b728d6f37 --- /dev/null +++ b/changelog.d/unreleased/1617.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 1617 +affected: + - src/CodeIndex/Cli/GlobalToolLog.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **Time-sensitive CLI and MCP paths now use injectable clocks (#1617)** — status age calculations, query trace/log naming, MCP ping timestamps, and MCP suggestion persistence can now be driven by `TimeProvider` for deterministic tests. + +## 日本語 + +- **時刻に依存する CLI / MCP 経路が注入可能な clock を使うようになりました (#1617)** — status の経過時間計算、query trace / log のファイル名、MCP ping の timestamp、MCP suggestion の永続化を `TimeProvider` で決定論的にテストできるようになりました。 diff --git a/changelog.d/unreleased/1618.fixed.md b/changelog.d/unreleased/1618.fixed.md new file mode 100644 index 0000000000..4872ca681e --- /dev/null +++ b/changelog.d/unreleased/1618.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1618 +affected: + - src/CodeIndex/Cli/SuggestionStore.cs + - tests/CodeIndex.Tests/SuggestionStoreTests.cs +--- + +## English + +- **Suggestion `CreatedAt` is now stamped at persistence time (#1618)** — new suggestions receive their creation timestamp inside the store's locked write path, aligning the recorded time with when the suggestion enters the local store. + +## 日本語 + +- **suggestion の `CreatedAt` を永続化時に記録するようになりました (#1618)** — 新規 suggestion の作成時刻は store のロックされた書き込み経路内で付与され、ローカル store に入った時刻と一致するようになりました。 diff --git a/changelog.d/unreleased/2034.fixed.md b/changelog.d/unreleased/2034.fixed.md new file mode 100644 index 0000000000..f74648aae8 --- /dev/null +++ b/changelog.d/unreleased/2034.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2034 +affected: + - tests/CodeIndex.Tests/ManualTimeProvider.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - tests/CodeIndex.Tests/SuggestionStoreTests.cs +--- + +## English + +- **Time-dependent tests now use an explicit test clock (#2034)** — MCP timestamp and suggestion lifecycle coverage use `ManualTimeProvider` instead of depending on wall-clock time or hardcoded fixture dates. + +## 日本語 + +- **時刻依存テストが明示的な test clock を使うようになりました (#2034)** — MCP timestamp と suggestion lifecycle のカバレッジは wall-clock や固定 fixture 日付に依存せず `ManualTimeProvider` を使うようになりました。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 8b012fc2e0..d4cdd9f534 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -156,6 +156,23 @@ internal static void EnsureConsoleWritersSynchronized() } } + internal static void TryWriteErrorLine(string? value = null) + { + try + { + if (value == null) + Console.Error.WriteLine(); + else + Console.Error.WriteLine(value); + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + } + internal static IDisposable SuppressAnsiForJsonOutput(bool enabled) { if (!enabled) diff --git a/src/CodeIndex/Cli/GlobalToolLog.cs b/src/CodeIndex/Cli/GlobalToolLog.cs index 048a415ea2..dff462de5f 100644 --- a/src/CodeIndex/Cli/GlobalToolLog.cs +++ b/src/CodeIndex/Cli/GlobalToolLog.cs @@ -18,6 +18,7 @@ internal static class GlobalToolLog internal const string LogRetainEnvironmentVariable = "CDIDX_LOG_RETAIN"; internal const string LogMaxSizeMbEnvironmentVariable = "CDIDX_LOG_MAX_SIZE_MB"; private const string RedactedValue = ""; + internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; private static readonly AsyncLocal CurrentSession = new(); private static readonly Regex SensitiveAssignmentPattern = new( @"^(?--?[^=\s]*(?:token|password|passwd|pwd|secret|auth|apikey|api-key|access-key|credential)[^=\s]*)=(?.+)$", @@ -377,7 +378,7 @@ private static string GetHomeDirectoryOrOriginal(string original) private static string ResolveLogPath(string logDirectory, LogOptions options) { - var date = DateTime.UtcNow.ToString("yyyyMMdd", System.Globalization.CultureInfo.InvariantCulture); + var date = TimeProvider.GetUtcNow().UtcDateTime.ToString("yyyyMMdd", System.Globalization.CultureInfo.InvariantCulture); if (options.MaxSizeBytes <= 0) return Path.Combine(logDirectory, $"stderr-{date}.log"); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 64ce35e144..730b60eca7 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -433,7 +433,7 @@ void WriteJsonLiveness(string message) if (!options.Json || options.Quiet) return; - Console.Error.WriteLine($"cdidx: {message}"); + ConsoleUi.TryWriteErrorLine($"cdidx: {message}"); } (CancellationTokenSource Cts, Task Task)? StartJsonPhaseHeartbeat(string phase, Func? detailProvider = null) @@ -461,7 +461,7 @@ void WriteJsonLiveness(string message) var detail = detailProvider?.Invoke(); var suffix = string.IsNullOrWhiteSpace(detail) ? string.Empty : $": {detail}"; - Console.Error.WriteLine($"cdidx: still {phase}{suffix}..."); + ConsoleUi.TryWriteErrorLine($"cdidx: still {phase}{suffix}..."); } }, token); return (cts, task); @@ -685,7 +685,7 @@ void WriteIndexVerboseStatus(string message) if (options.Json) { - Console.Error.WriteLine(message); + ConsoleUi.TryWriteErrorLine(message); return; } @@ -727,7 +727,7 @@ void ReportJsonIndexProgressIfNeeded() || processed % 100 == 0 || Stopwatch.GetElapsedTime(lastJsonProgressAt, now) >= TimeSpan.FromSeconds(5)) { - Console.Error.WriteLine($"cdidx: indexed {processed:N0}/{files.Count:N0} file(s)..."); + ConsoleUi.TryWriteErrorLine($"cdidx: indexed {processed:N0}/{files.Count:N0} file(s)..."); lastJsonProgressAt = now; } } @@ -759,7 +759,7 @@ void StartJsonHeartbeatIfNeeded() currentJsonIndexFile, activeJsonExtractionPhases.OrderBy(static kvp => kvp.Key).Select(static kvp => kvp.Value)); var fileSuffix = string.IsNullOrEmpty(file) ? string.Empty : $": {file}"; - Console.Error.WriteLine($"cdidx: still indexing {processed:N0}/{files.Count:N0} file(s){fileSuffix}..."); + ConsoleUi.TryWriteErrorLine($"cdidx: still indexing {processed:N0}/{files.Count:N0} file(s){fileSuffix}..."); } }, token); } @@ -1108,7 +1108,7 @@ void StopJsonHeartbeat() { PauseIndexSpinnerForConsoleWrite(); ConsoleUi.ClearProgressLine(); - Console.Error.WriteLine(FormatPerFileErrorLine("ERR ", item.FilePath, ex, errorMessage)); + ConsoleUi.TryWriteErrorLine(FormatPerFileErrorLine("ERR ", item.FilePath, ex, errorMessage)); ResumeIndexSpinnerAfterConsoleWrite(); } } diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 1bc2966af9..77c6df9ba7 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -14,6 +14,7 @@ namespace CodeIndex.Cli; internal static class ProgramRunner { internal const string QuietEnvironmentVariable = "CDIDX_QUIET"; + internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; internal static int Run( string[] args, @@ -87,7 +88,7 @@ internal static int Run( using var jsonAnsiScope = ConsoleUi.SuppressAnsiForJsonOutput(ContainsJsonOutputFlag(args)); var commandStopwatch = Stopwatch.StartNew(); - var commandStartTimestamp = DateTimeOffset.UtcNow; + var commandStartTimestamp = TimeProvider.GetUtcNow(); var versionPinExit = CheckWorkspaceVersionPin(appVersion, configStartDirectory ?? Environment.CurrentDirectory, strictVersion); if (versionPinExit != CommandExitCodes.Success) return versionPinExit; @@ -1132,7 +1133,7 @@ private static void EmitQueryTrace(string mode, string commandName, string[] sub var directory = GlobalToolLog.ResolveLogDirectoryForStatus(); Directory.CreateDirectory(directory); - var path = Path.Combine(directory, $"query-trace-{DateTime.UtcNow:yyyyMMdd}.jsonl"); + var path = Path.Combine(directory, $"query-trace-{TimeProvider.GetUtcNow().UtcDateTime:yyyyMMdd}.jsonl"); File.AppendAllText(path, payload + Environment.NewLine); } catch diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 7270bf0b96..dea6997d84 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -21,9 +21,12 @@ public static class QueryCommandRunner internal const string DefaultMaxLineWidthEnvironmentVariable = "CDIDX_DEFAULT_MAX_LINE_WIDTH"; internal const string StaleAfterEnvironmentVariable = "CDIDX_STALE_AFTER"; internal static readonly TimeSpan DefaultStaleAfter = TimeSpan.FromHours(24); + internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; [ThreadStatic] private static DbReader? s_batchReader; + private static DateTime GetUtcNow() => TimeProvider.GetUtcNow().UtcDateTime; + // Cap OR-joined `symbols` names well below SQLite's 1000 expression-tree depth so oversized // batches fail fast with a clear usage error instead of a confusing SQLite exception. // OR 結合の `symbols` 名は SQLite の式木深さ上限 1000 を十分下回る値で頭打ちにし、 @@ -2638,7 +2641,7 @@ public static int RunStatus(string[] cmdArgs, JsonSerializerOptions jsonOptions, : null; status.StaleAfterSeconds = (long)Math.Round(staleAfter.Value.TotalSeconds, MidpointRounding.AwayFromZero); if (status.IndexedAt.HasValue) - status.IndexAgeSeconds = Math.Max(0, (long)Math.Round((DateTime.UtcNow - status.IndexedAt.Value).TotalSeconds, MidpointRounding.AwayFromZero)); + status.IndexAgeSeconds = Math.Max(0, (long)Math.Round((GetUtcNow() - status.IndexedAt.Value).TotalSeconds, MidpointRounding.AwayFromZero)); } // Attach runtime metadata / ランタイムメタデータを付加 status.SymbolKinds = reader.GetSymbolKindCounts(); @@ -6150,7 +6153,7 @@ private static void WriteZeroResultHints(QueryCommandOptions options, DbReader r if (freshness.IndexedAt.HasValue) { - var age = DateTime.UtcNow - freshness.IndexedAt.Value; + 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."); } @@ -6685,7 +6688,7 @@ private static void WriteStatusAge(StatusResult status, TimeSpan staleAfter) if (!status.IndexedAt.HasValue) return; - var age = DateTime.UtcNow - status.IndexedAt.Value; + var age = GetUtcNow() - status.IndexedAt.Value; if (age < TimeSpan.Zero) age = TimeSpan.Zero; diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index 6d89abce1a..b686b2216d 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -22,6 +22,7 @@ public class SuggestionStore { private readonly string _filePath; private readonly string _lockPath; + private readonly TimeProvider _timeProvider; private readonly string _archivePath; private static readonly TimeSpan s_inFlightSubmitRetryDelay = TimeSpan.FromMinutes(1); internal const FileShare StreamingReadFileShare = FileShare.ReadWrite | FileShare.Delete; @@ -96,12 +97,18 @@ static SuggestionStore() /// 拡張子なしのデータベースファイル名(任意、デフォルトは "codeindex")。 /// public SuggestionStore(string cdidxDir, string? dbName = null) + : this(cdidxDir, dbName, TimeProvider.System) + { + } + + internal SuggestionStore(string cdidxDir, string? dbName, TimeProvider timeProvider) { // Derive a safe store filename from the DB identity. // DB固有の安全なストアファイル名を導出する。 var safeName = string.IsNullOrWhiteSpace(dbName) ? "codeindex" : dbName; _filePath = Path.Combine(cdidxDir, $"suggestions-{safeName}.json"); _lockPath = Path.Combine(cdidxDir, $"suggestions-{safeName}.lock"); + _timeProvider = timeProvider; _archivePath = Path.Combine(cdidxDir, $"suggestions-{safeName}.archive.jsonl"); } @@ -144,6 +151,7 @@ record = RedactRecordForPersistence(record); return false; } + StampCreatedAt(record); existing.Add(record); PruneUnlocked(existing); SaveUnlocked(existing); @@ -220,6 +228,7 @@ record = RedactRecordForPersistence(record); if (isNew) { + StampCreatedAt(record); existing.Add(record); PruneUnlocked(existing); SaveUnlocked(existing); @@ -229,7 +238,7 @@ record = RedactRecordForPersistence(record); var current = found!; if (!alreadySubmitted && submitToGitHub != null && ShouldAttemptSubmit(current)) { - var attemptedAt = DateTime.UtcNow; + var attemptedAt = GetUtcNow(); StampSubmitAttempt(current, attemptedAt, null, attemptedAt.Add(s_inFlightSubmitRetryDelay)); SaveUnlocked(existing); return new SubmitReservation( @@ -403,7 +412,7 @@ public void MarkSubmitted(string hash, string issueUrl) if (record == null) return; - MarkSubmitted(record, issueUrl, DateTime.UtcNow); + MarkSubmitted(record, issueUrl, GetUtcNow()); SaveUnlocked(all); }); } @@ -756,19 +765,23 @@ private static void MarkSubmitted(SuggestionRecord record, string issueUrl, Date record.GitHubIssueUrl = null; } - private static bool ShouldAttemptSubmit(SuggestionRecord record) + private bool ShouldAttemptSubmit(SuggestionRecord record) { if (record.NextRetryAt == null) return true; - return record.NextRetryAt.Value <= DateTime.UtcNow; + return record.NextRetryAt.Value <= GetUtcNow(); } + private void StampCreatedAt(SuggestionRecord record) => record.CreatedAt = GetUtcNow(); + + private DateTime GetUtcNow() => _timeProvider.GetUtcNow().UtcDateTime; + private bool PruneUnlocked(List records) { var maxAge = ResolveMaxAge(); var maxCount = ResolveMaxCount(); - var cutoff = DateTime.UtcNow.Subtract(maxAge); + var cutoff = GetUtcNow().Subtract(maxAge); var pruned = records .Where(record => record.CreatedAt != default && record.CreatedAt < cutoff) .ToList(); @@ -794,7 +807,7 @@ private bool PruneUnlocked(List records) ArchivePrunedRecords(pruned); try { - Console.Error.WriteLine($"[cdidx] Pruned {pruned.Count} stale suggestion record(s) to {_archivePath}."); + ConsoleUi.TryWriteErrorLine($"[cdidx] Pruned {pruned.Count} stale suggestion record(s) to {_archivePath}."); } catch (ObjectDisposedException) { diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 6864093f9e..0ce2cf7a9c 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -33,6 +33,7 @@ public partial class McpServer : IDisposable private readonly Func _serializeResponse; private readonly IMcpAuthenticator _authenticator; private readonly McpToolFilter _toolFilter; + private readonly TimeProvider _timeProvider; // Bounds the number of MCP tool calls in flight at once so an unbounded burst of // requests cannot exhaust memory or wedge the SQLite reader lock (#1567). The // stdio / HTTP loop today only ever has one frame in flight, but the gate @@ -173,17 +174,17 @@ public partial class McpServer : IDisposable internal static readonly TimeSpan DefaultEofPostCancelDrainTimeout = TimeSpan.FromSeconds(5); public McpServer(string dbPath, string version, bool dbPathExplicit = false) - : this(dbPath, version, dbPathExplicit, null, null, null, null, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, null, null, null, null, DefaultMaxConcurrency, null) { } public McpServer(string dbPath, string version, bool dbPathExplicit, IMcpAuthenticator authenticator) - : this(dbPath, version, dbPathExplicit, null, authenticator, null, null, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, null, authenticator, null, null, DefaultMaxConcurrency, null) { } public McpServer(string dbPath, string version, bool dbPathExplicit, McpToolFilter? toolFilter) - : this(dbPath, version, dbPathExplicit, null, null, toolFilter, null, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, null, null, toolFilter, null, DefaultMaxConcurrency, null) { } @@ -191,22 +192,22 @@ public McpServer(string dbPath, string version, bool dbPathExplicit, McpToolFilt // do not need a custom authenticator or tool filter. // serializer 注入だけが必要な既存テスト向けの内部互換 entry。 internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse) - : this(dbPath, version, dbPathExplicit, serializeResponse, null, null, null, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, serializeResponse, null, null, null, DefaultMaxConcurrency, null) { } internal McpServer(string dbPath, string version, bool dbPathExplicit, AuditLogSink? auditLog) - : this(dbPath, version, dbPathExplicit, null, null, null, auditLog, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, null, null, null, auditLog, DefaultMaxConcurrency, null) { } internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse, IMcpAuthenticator? authenticator) - : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, null, null, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, null, null, DefaultMaxConcurrency, null) { } internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse, IMcpAuthenticator? authenticator, McpToolFilter? toolFilter) - : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, null, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, null, DefaultMaxConcurrency, null) { } @@ -214,7 +215,7 @@ internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse, IMcpAuthenticator? authenticator, McpToolFilter? toolFilter, int maxConcurrency) - : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, null, maxConcurrency) + : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, null, maxConcurrency, null) { } @@ -224,16 +225,21 @@ internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse, IMcpAuthenticator? authenticator, McpToolFilter? toolFilter, AuditLogSink? auditLog) - : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, auditLog, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, auditLog, DefaultMaxConcurrency, null) { } internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse, IMcpAuthenticator? authenticator, McpToolFilter? toolFilter, AuditLogSink? auditLog, int maxConcurrency) + : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, auditLog, maxConcurrency, null) + { + } + + internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse, IMcpAuthenticator? authenticator, McpToolFilter? toolFilter, AuditLogSink? auditLog, int maxConcurrency, TimeProvider? timeProvider) { if (maxConcurrency < 1) throw new ArgumentOutOfRangeException(nameof(maxConcurrency), maxConcurrency, "MCP concurrency cap must be at least 1."); @@ -249,6 +255,7 @@ internal McpServer(string dbPath, string version, bool dbPathExplicit, Func node.ToJsonString(_jsonOptions)); _authenticator = authenticator ?? LocalStdioAuthenticator.Instance; _toolFilter = toolFilter ?? McpToolFilter.FromEnvironment(); + _timeProvider = timeProvider ?? TimeProvider.System; _auditLog = auditLog; RateLimiter = new RateLimiter(RateLimiterOptions.FromEnvironment()); _concurrencyGate = new SemaphoreSlim(maxConcurrency, maxConcurrency); @@ -304,6 +311,8 @@ internal void OverrideRateLimiterForTests(RateLimiter limiter) /// internal int MaxConcurrency { get; } + private DateTime GetUtcNow() => _timeProvider.GetUtcNow().UtcDateTime; + internal TimeSpan RequestTimeout { get => _requestTimeout; @@ -427,7 +436,7 @@ internal async Task RunAsync(IMcpTransport transport, CancellationToken cancella // Use stderr for logging so stdout stays clean for JSON-RPC // stdoutをJSON-RPC用にクリーンに保つため、ログはstderrに出力 - Console.Error.WriteLine($"[cdidx-mcp] Starting MCP server v{_version} (db: {_dbPath}, transport: {transport.Name} @ {transport.Endpoint}, max in-flight: {MaxConcurrency})"); + ConsoleUi.TryWriteErrorLine($"[cdidx-mcp] Starting MCP server v{_version} (db: {_dbPath}, transport: {transport.Name} @ {transport.Endpoint}, max in-flight: {MaxConcurrency})"); if (transport is HttpMcpTransport httpTransport) { diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index d4a8191fa5..fecf4d0972 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2717,7 +2717,7 @@ private JsonNode ExecutePing(JsonNode? id) var payload = new JsonObject { ["version"] = _version, - ["timestamp"] = DateTime.UtcNow.ToString("O"), + ["timestamp"] = GetUtcNow().ToString("O"), ["db_path"] = _dbPath, ["db_exists"] = File.Exists(LongPath.EnsureWindowsPrefix(_dbPath)), }; @@ -3114,7 +3114,7 @@ void WriteProjectRootOnce() { var headBranch = GitHelper.TryGetHeadBranch(projectPath); var timestamp = currentHeadCommit != null - ? DateTime.UtcNow.ToString("o", System.Globalization.CultureInfo.InvariantCulture) + ? GetUtcNow().ToString("o", System.Globalization.CultureInfo.InvariantCulture) : null; writer.SetMeta(DbContext.IndexedHeadShaMetaKey, currentHeadCommit); writer.SetMeta(DbContext.IndexedHeadBranchMetaKey, headBranch); @@ -3340,7 +3340,7 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo // Derive DB identity for scoped suggestion storage. // スコープ付き提案蓄積のため DB identity を導出。 var dbName = Path.GetFileNameWithoutExtension(_dbPath); - var store = new SuggestionStore(cdidxDir, dbName); + var store = new SuggestionStore(cdidxDir, dbName, _timeProvider); var record = new SuggestionRecord { Category = category, @@ -3348,7 +3348,6 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo Description = description, Context = context, Hash = hash, - CreatedAt = DateTime.UtcNow, CreatedByAgent = ResolveSuggestionAgent(), SessionId = _sessionId, ClientVersion = _version, diff --git a/tests/CodeIndex.Tests/ConcurrencyTests.cs b/tests/CodeIndex.Tests/ConcurrencyTests.cs index e2863a93c9..c2920c072f 100644 --- a/tests/CodeIndex.Tests/ConcurrencyTests.cs +++ b/tests/CodeIndex.Tests/ConcurrencyTests.cs @@ -32,7 +32,7 @@ public async Task ConcurrentReads_DoNotBlock() var fileId = writer.UpsertFile(new FileRecord { Path = "src/app.cs", Lang = "csharp", Size = 100, Lines = 10, - Modified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, Checksum = "abc", }); writer.InsertChunks([new ChunkRecord { FileId = fileId, ChunkIndex = 0, StartLine = 1, EndLine = 10, Content = "public class App { }" }]); @@ -126,7 +126,7 @@ public async Task GetStatus_ReferencesAndFilesStaySnapshotConsistent_UnderConcur var fileId = writer.UpsertFile(new FileRecord { Path = $"src/seed{seedIndex}.cs", Lang = "csharp", Size = 100, Lines = 10, - Modified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, Checksum = $"seed{seedIndex}", }); writer.InsertReferences(BuildReferenceBatch(fileId, $"seed{seedIndex}", refsPerFile)); @@ -155,7 +155,7 @@ public async Task GetStatus_ReferencesAndFilesStaySnapshotConsistent_UnderConcur var fileId = w.UpsertFile(new FileRecord { Path = $"src/added{extra}.cs", Lang = "csharp", Size = 100, Lines = 10, - Modified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, Checksum = $"added{extra}", }); w.InsertReferences(BuildReferenceBatch(fileId, $"added{extra}", refsPerFile)); @@ -217,8 +217,8 @@ public async Task AnalyzeSymbol_RefsCallersAndFreshnessStaySnapshotConsistent_Un // file A のみで freshness = T0、2 refs なら file B も存在し modified = T1 > T0 // なので freshness = T1。`SearchReferences` / `GetCallers` と // `GetWorkspaceFreshness` の間で writer が commit すると食い違う。 - var fileAModified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); - var fileBModified = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + var fileAModified = ManualTimeProvider.FixtureUtcNow.UtcDateTime; + var fileBModified = ManualTimeProvider.FixtureUtcNow.AddDays(1).UtcDateTime; var writer = new DbWriter(_db.Connection); var fileAId = writer.UpsertFile(new FileRecord { @@ -362,7 +362,7 @@ public async Task GetRepoMap_FreshnessAndEntrypointsStaySnapshotConsistent_Under // 2. `src/Program.cs`(`Main()` 関数を持つ)の存在は freshness と整合する。 // `getFreshness` と `GetEntrypoints` の torn read で破れる。 var writer = new DbWriter(_db.Connection); - var baselineModified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var baselineModified = ManualTimeProvider.FixtureUtcNow.UtcDateTime; for (var seedIndex = 0; seedIndex < 3; seedIndex++) { writer.UpsertFile(new FileRecord @@ -543,7 +543,7 @@ public async Task BeginTransaction_SharedWriterSerializesConcurrentScopes() Lang = "csharp", Size = 10, Lines = 1, - Modified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, Checksum = $"{worker}_{i}", }); txn.Commit(); diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 047c04ba8c..fa39a9f4ed 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -25,6 +25,51 @@ public class ConsoleUiTests .Select(field => (OpCode)field.GetValue(null)!) .ToDictionary(opCode => (short)(opCode.Value & 0xff)); + [Fact] + public void TryWriteErrorLine_IgnoresClosedErrorWriter() + { + lock (TestConsoleLock.Gate) + { + var originalError = Console.Error; + var closedError = new StringWriter(); + closedError.Dispose(); + Console.SetError(closedError); + try + { + ConsoleUi.TryWriteErrorLine("diagnostic"); + } + finally + { + Console.SetError(originalError); + } + } + } + + [Fact] + public void TryWriteErrorLine_IgnoresErrorWriterIoFailures() + { + lock (TestConsoleLock.Gate) + { + var originalError = Console.Error; + Console.SetError(new ThrowingTextWriter()); + try + { + ConsoleUi.TryWriteErrorLine("diagnostic"); + } + finally + { + Console.SetError(originalError); + } + } + } + + private sealed class ThrowingTextWriter : TextWriter + { + public override Encoding Encoding => Encoding.UTF8; + + public override void WriteLine(string? value) => throw new IOException("closed pipe"); + } + [Fact] public void StartSpinner_BackgroundLoop_DoesNotBlockOnTaskWait() { diff --git a/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs b/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs index 779be59dc3..26ad8c04a5 100644 --- a/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs +++ b/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs @@ -91,7 +91,8 @@ line INTEGER // Seed one file and a handful of symbols on the legacy schema. // レガシースキーマに対してファイル1つとシンボルを数件投入。 - Exec(conn, "INSERT INTO files (id, path, lang, size, lines, modified) VALUES (1, 'src/Legacy.cs', 'csharp', 200, 20, '2025-06-01 00:00:00')"); + var modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture); + Exec(conn, $"INSERT INTO files (id, path, lang, size, lines, modified) VALUES (1, 'src/Legacy.cs', 'csharp', 200, 20, '{modified}')"); Exec(conn, @"INSERT INTO chunks (file_id, chunk_index, start_line, end_line, content) VALUES (1, 0, 1, 20, 'class Legacy' || char(10) || '{' || char(10) || diff --git a/tests/CodeIndex.Tests/ManualTimeProvider.cs b/tests/CodeIndex.Tests/ManualTimeProvider.cs new file mode 100644 index 0000000000..c0135b32dc --- /dev/null +++ b/tests/CodeIndex.Tests/ManualTimeProvider.cs @@ -0,0 +1,16 @@ +namespace CodeIndex.Tests; + +internal sealed class ManualTimeProvider : TimeProvider +{ + internal static readonly DateTimeOffset FixtureUtcNow = DateTimeOffset.UnixEpoch.AddDays(20_000); + private DateTimeOffset _utcNow; + + public ManualTimeProvider(DateTimeOffset utcNow) + { + _utcNow = utcNow.ToUniversalTime(); + } + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void SetUtcNow(DateTimeOffset utcNow) => _utcNow = utcNow.ToUniversalTime(); +} diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 87076292a0..d243daa874 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -44,7 +44,7 @@ public McpServerTests() Lang = "csharp", Size = 200, Lines = 10, - Modified = new DateTime(2024, 1, 1), + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, Checksum = "abc123", }); writer.InsertChunks([new ChunkRecord @@ -155,7 +155,7 @@ private void InsertIndexedFile(string path, string lang, string content, bool ge Lang = lang, Size = normalized.Length, Lines = lines.Length, - Modified = new DateTime(2024, 1, 1), + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, Checksum = Guid.NewGuid().ToString("N"), Generated = generated, }); @@ -6058,14 +6058,16 @@ public void ToolsCall_Status_ExplicitExternalCodeIndexDb_UsesPersistedProjectRoo [Fact] public void ToolsCall_Ping_ReturnsVersionAndTimestamp() { + var clock = new ManualTimeProvider(new DateTimeOffset(2032, 4, 5, 6, 7, 8, TimeSpan.Zero)); + using var server = new McpServer(_dbPath, ConsoleUi.LoadVersion(), false, null, null, null, null, McpServer.DefaultMaxConcurrency, clock); var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ping","arguments":{}}}""")!; - var response = _server.HandleMessage(request)!; + var response = server.HandleMessage(request)!; var text = response["result"]!["content"]![0]!["text"]!.GetValue(); Assert.Contains("cdidx v", text); Assert.Contains("is ready", text); Assert.NotNull(response["result"]!["structuredContent"]!["version"]); - Assert.NotNull(response["result"]!["structuredContent"]!["timestamp"]); + Assert.Equal(clock.GetUtcNow().UtcDateTime.ToString("O"), response["result"]!["structuredContent"]!["timestamp"]!.GetValue()); Assert.NotNull(response["result"]!["structuredContent"]!["db_exists"]); } diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.cs index bf7e3e9945..d82ddeb5d2 100644 --- a/tests/CodeIndex.Tests/SuggestionStoreTests.cs +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.cs @@ -90,6 +90,20 @@ public void TryAdd_NewSuggestion_ReturnsTrue() Assert.True(_store.TryAdd(record)); } + [Fact] + public void TryAdd_StampsCreatedAtFromInjectedClockWhenPersisted() + { + var clock = new ManualTimeProvider(new DateTimeOffset(2030, 2, 3, 4, 5, 6, TimeSpan.Zero)); + var store = new SuggestionStore(_tempDir, null, clock); + var record = MakeRecord("symbol_extraction", "csharp", "Missing record support"); + record.CreatedAt = new DateTime(1999, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + Assert.True(store.TryAdd(record)); + + var saved = Assert.Single(store.LoadAll()); + Assert.Equal(clock.GetUtcNow().UtcDateTime, saved.CreatedAt); + } + [Fact] public void TryAdd_Duplicate_ReturnsFalse() { @@ -141,6 +155,22 @@ public void TryAddAndSubmit_FuzzyDuplicate_ReturnsMatchedHashAndScore() Assert.True(second.DuplicateScore >= SuggestionStore.DefaultDedupThreshold); } + [Fact] + public void TryAddAndSubmit_UsesInjectedClockForRetryAndSubmissionTimestamps() + { + var clock = new ManualTimeProvider(new DateTimeOffset(2031, 3, 4, 5, 6, 7, TimeSpan.Zero)); + var store = new SuggestionStore(_tempDir, null, clock); + var record = MakeRecord("other", null, "submit me"); + + var result = store.TryAddAndSubmit(record, _ => SuggestionStore.SubmitAttemptResult.Success("https://github.com/Widthdom/CodeIndex/issues/1")); + + Assert.True(result.IsNew); + var saved = Assert.Single(store.LoadAll()); + Assert.Equal(clock.GetUtcNow().UtcDateTime, saved.CreatedAt); + Assert.Equal(clock.GetUtcNow().UtcDateTime, saved.LastSubmitAttempt); + Assert.Equal(clock.GetUtcNow().UtcDateTime, saved.LastSyncedAt); + } + [Fact] public void TryAddAndSubmit_FuzzyDuplicate_IgnoresClosedDiagnosticStderr() { @@ -481,18 +511,19 @@ public void LoadByStatus_ReturnsOnlyMatchingLifecycleState() [Fact] public void LoadSince_ReturnsSuggestionsAtOrAfterThreshold() { + var clock = new ManualTimeProvider(new DateTimeOffset(2031, 5, 1, 9, 0, 0, TimeSpan.Zero)); + var store = new SuggestionStore(_tempDir, null, clock); var older = MakeRecord("other", null, "Older suggestion"); - older.CreatedAt = new DateTime(2026, 5, 1, 9, 0, 0, DateTimeKind.Utc); var boundary = MakeRecord("other", null, "Boundary suggestion"); - boundary.CreatedAt = new DateTime(2026, 5, 2, 9, 0, 0, DateTimeKind.Utc); var newer = MakeRecord("other", null, "Newer suggestion"); - newer.CreatedAt = new DateTime(2026, 5, 3, 9, 0, 0, DateTimeKind.Utc); - _store.TryAdd(older); - _store.TryAdd(boundary); - _store.TryAdd(newer); + store.TryAdd(older); + clock.SetUtcNow(new DateTimeOffset(2031, 5, 2, 9, 0, 0, TimeSpan.Zero)); + store.TryAdd(boundary); + clock.SetUtcNow(new DateTimeOffset(2031, 5, 3, 9, 0, 0, TimeSpan.Zero)); + store.TryAdd(newer); - var loaded = _store.LoadSince(new DateTimeOffset(2026, 5, 2, 9, 0, 0, TimeSpan.Zero)); + var loaded = store.LoadSince(new DateTimeOffset(2031, 5, 2, 9, 0, 0, TimeSpan.Zero)); Assert.Equal(new[] { boundary.Hash, newer.Hash }, loaded.Select(s => s.Hash)); } @@ -754,14 +785,16 @@ public void AtomicWrite_SurvivesAddAfterCorruption() [Fact] public void TryAdd_PrunesStaleRecordsToArchive() { + var clock = new ManualTimeProvider(new DateTimeOffset(2031, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var store = new SuggestionStore(_tempDir, null, clock); var old = MakeRecord("other", null, "Old suggestion"); - old.CreatedAt = DateTime.UtcNow.AddDays(-400); - Assert.True(_store.TryAdd(old)); + Assert.True(store.TryAdd(old)); + clock.SetUtcNow(new DateTimeOffset(2032, 2, 5, 0, 0, 0, TimeSpan.Zero)); var fresh = MakeRecord("other", null, "Fresh suggestion"); - Assert.True(_store.TryAdd(fresh)); + Assert.True(store.TryAdd(fresh)); - var all = _store.LoadAll(); + var all = store.LoadAll(); Assert.Single(all); Assert.Equal("Fresh suggestion", all[0].Description); @@ -795,15 +828,17 @@ public void TryAdd_PrunesOldestRecordsOverConfiguredMaxCount() [Fact] public void TryAdd_DuplicateStillPersistsPrunedRecords() { + var clock = new ManualTimeProvider(new DateTimeOffset(2031, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var store = new SuggestionStore(_tempDir, null, clock); var old = MakeRecord("other", null, "Old suggestion"); - old.CreatedAt = DateTime.UtcNow.AddDays(-400); var duplicate = MakeRecord("other", null, "Duplicate suggestion"); - Assert.True(_store.TryAdd(old)); - Assert.True(_store.TryAdd(duplicate)); + Assert.True(store.TryAdd(old)); + clock.SetUtcNow(new DateTimeOffset(2032, 2, 5, 0, 0, 0, TimeSpan.Zero)); + Assert.True(store.TryAdd(duplicate)); - Assert.False(_store.TryAdd(MakeRecord("other", null, "Duplicate suggestion"))); + Assert.False(store.TryAdd(MakeRecord("other", null, "Duplicate suggestion"))); - var all = _store.LoadAll(); + var all = store.LoadAll(); Assert.Single(all); Assert.Equal("Duplicate suggestion", all[0].Description); var archivePath = Path.Combine(_tempDir, "suggestions-codeindex.archive.jsonl");