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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions changelog.d/unreleased/1617.fixed.md
Original file line number Diff line number Diff line change
@@ -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` で決定論的にテストできるようになりました。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/1618.fixed.md
Original file line number Diff line number Diff line change
@@ -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 に入った時刻と一致するようになりました。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2034.fixed.md
Original file line number Diff line number Diff line change
@@ -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` を使うようになりました。
17 changes: 17 additions & 0 deletions src/CodeIndex/Cli/ConsoleUi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/CodeIndex/Cli/GlobalToolLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<redacted>";
internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System;
private static readonly AsyncLocal<Session?> CurrentSession = new();
private static readonly Regex SensitiveAssignmentPattern = new(
@"^(?<name>--?[^=\s]*(?:token|password|passwd|pwd|secret|auth|apikey|api-key|access-key|credential)[^=\s]*)=(?<value>.+)$",
Expand Down Expand Up @@ -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");

Expand Down
12 changes: 6 additions & 6 deletions src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string?>? detailProvider = null)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -685,7 +685,7 @@ void WriteIndexVerboseStatus(string message)

if (options.Json)
{
Console.Error.WriteLine(message);
ConsoleUi.TryWriteErrorLine(message);
return;
}

Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/CodeIndex/Cli/ProgramRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions src/CodeIndex/Cli/QueryCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 を十分下回る値で頭打ちにし、
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 <projectPath>' to refresh.");
}
Expand Down Expand Up @@ -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;

Expand Down
25 changes: 19 additions & 6 deletions src/CodeIndex/Cli/SuggestionStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -96,12 +97,18 @@ static SuggestionStore()
/// 拡張子なしのデータベースファイル名(任意、デフォルトは "codeindex")。
/// </param>
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");
}

Expand Down Expand Up @@ -144,6 +151,7 @@ record = RedactRecordForPersistence(record);
return false;
}

StampCreatedAt(record);
existing.Add(record);
PruneUnlocked(existing);
SaveUnlocked(existing);
Expand Down Expand Up @@ -220,6 +228,7 @@ record = RedactRecordForPersistence(record);

if (isNew)
{
StampCreatedAt(record);
existing.Add(record);
PruneUnlocked(existing);
SaveUnlocked(existing);
Expand All @@ -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(
Expand Down Expand Up @@ -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);
});
}
Expand Down Expand Up @@ -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<SuggestionRecord> 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();
Expand All @@ -794,7 +807,7 @@ private bool PruneUnlocked(List<SuggestionRecord> 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)
{
Expand Down
Loading
Loading