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
3 changes: 2 additions & 1 deletion DEVELOPER_GUIDE.md

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions changelog.d/unreleased/1964.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
category: fixed
issues:
- 1964
affected:
- src/CodeIndex/Database/DbReader.cs
- src/CodeIndex/Database/DbReader.FilesStatus.cs
- src/CodeIndex/Database/DegradationReasonCodes.cs
- tests/CodeIndex.Tests/DbReaderTests.cs
---

## English

- **Fold readiness can now verify row completeness on demand (#1964)** — when `CDIDX_VERIFY_FOLD_READY_ROWS=1` is set, `status` detects DBs whose fold-ready bit is set even though folded-name rows are incomplete, reports `fold_ready_bit_set_but_rows_incomplete`, and keeps `fold_ready=false`.

## 日本語

- **fold readiness が必要時に行レベルの完全性を検証するようになりました (#1964)** — `CDIDX_VERIFY_FOLD_READY_ROWS=1` のとき、fold-ready bit が立っていても folded-name 行が未完了な DB を `status` が検出し、`fold_ready_bit_set_but_rows_incomplete` を報告して `fold_ready=false` のままにします。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/1973.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: fixed
issues:
- 1973
affected:
- src/CodeIndex/Mcp/McpToolHandlers.cs
- tests/CodeIndex.Tests/McpServerTests.cs
- DEVELOPER_GUIDE.md
---

## English

- **MCP status now mirrors fold degradation remediation (#1973)** — when `fold_ready=false`, MCP `status` includes `degraded_reason`, `recommended_action`, and `alternative_action` alongside `fold_ready_reason`, matching CLI status guidance for Unicode exact-name readiness.

## 日本語

- **MCP status が fold degradation の修復情報を CLI と揃えて返すようになりました (#1973)** — `fold_ready=false` のとき、MCP `status` は `fold_ready_reason` に加えて `degraded_reason`、`recommended_action`、`alternative_action` を返し、Unicode exact-name readiness の案内を CLI status と揃えます。
15 changes: 15 additions & 0 deletions changelog.d/unreleased/1993.internal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
category: internal
issues:
- 1993
affected:
- tests/CodeIndex.Tests/DbReaderTests.cs
---

## English

- **Expanded folded-column backfill regression coverage (#1993)** — tests now cover partial NULL states across `symbols.name_folded`, `symbol_references.symbol_name_folded`, and `symbol_references.container_name_folded`, including deterministic repeated checks.

## 日本語

- **folded-column backfill の回帰テスト範囲を拡張しました (#1993)** — `symbols.name_folded`、`symbol_references.symbol_name_folded`、`symbol_references.container_name_folded` の部分的な NULL 状態と、繰り返し実行時の決定性をテストするようになりました。
3 changes: 2 additions & 1 deletion src/CodeIndex/Database/DbReader.FilesStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ public StatusResult GetStatus()
var sqlGraphContractSignal = GetSqlGraphContractSignal(lang: null);
var hotspotFamilySignal = GetHotspotFamilySignal(lang: null);
var foldReadyReason = ResolveFoldReadyReason();
var foldReady = _foldReady && foldReadyReason == null;

// Language breakdown / 言語別内訳
// Scope the reader in an inner block so it releases its statement handle before
Expand Down Expand Up @@ -472,7 +473,7 @@ public StatusResult GetStatus()
CSharpMetadataTargetDegradedReason = csharpMetadataTargetDegradedReason,
SqlGraphContractReady = sqlGraphContractSignal.Ready,
SqlGraphContractDegradedReason = sqlGraphContractSignal.DegradedReason,
FoldReady = _foldReady,
FoldReady = foldReady,
FoldReadyReason = foldReadyReason,
IndexWriterVersion = _indexWriterVersion,
IndexNewerThanReader = _indexNewerThanReader,
Expand Down
33 changes: 33 additions & 0 deletions src/CodeIndex/Database/DbReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ public readonly record struct SqlGraphContractSignal(
/// </summary>
public partial class DbReader
{
public const string VerifyFoldReadyRowsEnvironmentVariable = "CDIDX_VERIFY_FOLD_READY_ROWS";

private static readonly Regex ImpactSignatureIdentifierRegex = new(@"[\p{L}_][\p{L}\p{Nd}_]*", RegexOptions.Compiled);
private static readonly Regex CSharpUsingStaticImportRegex = new(@"^\s*(?:global\s+)?using\s+static\s+(?<target>[^;]+)", RegexOptions.Compiled);
private static readonly Regex CSharpUsingAliasImportRegex = new(@"^\s*(?:global\s+)?using\s+(?!static\b)(?<alias>[^\s=;]+)\s*=\s*(?<target>[^;]+)", RegexOptions.Compiled);
Expand Down Expand Up @@ -647,7 +649,12 @@ private static int ParseFoldVersion(SqliteConnection conn)
private string? ResolveFoldReadyReason()
{
if (_foldReady)
{
if (ShouldVerifyFoldReadyRows() && HasIncompleteFoldRows())
return DegradationReasonCodes.FoldReadyBitSetButRowsIncomplete;

return null;
}

var storedVersion = ParseFoldVersion(_conn);
var storedFingerprint = ParseFoldFingerprint(_conn);
Expand All @@ -660,6 +667,32 @@ private static int ParseFoldVersion(SqliteConnection conn)
return DegradationReasonCodes.FoldRowsNotRestamped;
}

private static bool ShouldVerifyFoldReadyRows()
{
var value = Environment.GetEnvironmentVariable(VerifyFoldReadyRowsEnvironmentVariable);
return value is "1" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}

private bool HasIncompleteFoldRows()
{
using var cmd = _conn.CreateCommand();
var predicates = new List<string>
{
"EXISTS(SELECT 1 FROM symbols WHERE name IS NOT NULL AND name_folded IS NULL)",
};
if (_hasReferencesTable
&& _referenceColumns.Contains("symbol_name_folded")
&& _referenceColumns.Contains("container_name_folded"))
{
predicates.Add("EXISTS(SELECT 1 FROM symbol_references WHERE symbol_name IS NOT NULL AND symbol_name_folded IS NULL)");
predicates.Add("EXISTS(SELECT 1 FROM symbol_references WHERE container_name IS NOT NULL AND container_name_folded IS NULL)");
}

cmd.CommandText = $"SELECT {string.Join(" OR ", predicates)}";
var raw = cmd.ExecuteScalar();
return raw is long l ? l != 0 : raw is int i && i != 0;
}

private HashSet<string> LoadIndexedHotspotFamilyLanguages()
{
var langs = new HashSet<string>(StringComparer.Ordinal);
Expand Down
8 changes: 8 additions & 0 deletions src/CodeIndex/Database/DegradationReasonCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public static class DegradationReasonCodes
public const string StaleFoldKeyVersion = "stale_fold_key_version";
public const string StaleFoldKeyFingerprint = "stale_fold_key_fingerprint";
public const string FoldRowsNotRestamped = "fold_rows_not_restamped";
public const string FoldReadyBitSetButRowsIncomplete = "fold_ready_bit_set_but_rows_incomplete";
public const string FoldReadyNotReady = "fold_ready=false";
public const string SqlGraphContractNotReady = "sql_graph_contract_ready=false";
public const string HotspotFamilyNotReady = "hotspot_family_ready=false";
Expand All @@ -32,6 +33,7 @@ public static class DegradationReasonCodes
StaleFoldKeyVersion,
StaleFoldKeyFingerprint,
FoldRowsNotRestamped,
FoldReadyBitSetButRowsIncomplete,
FoldReadyNotReady,
SqlGraphContractNotReady,
HotspotFamilyNotReady,
Expand Down Expand Up @@ -78,6 +80,7 @@ public static string NormalizeFoldReason(string? foldReadyReason)
StaleFoldKeyVersion => StaleFoldKeyVersion,
StaleFoldKeyFingerprint => StaleFoldKeyFingerprint,
FoldRowsNotRestamped => FoldRowsNotRestamped,
FoldReadyBitSetButRowsIncomplete => FoldReadyBitSetButRowsIncomplete,
_ => FoldRowsNotRestamped
};

Expand All @@ -104,6 +107,11 @@ private static DegradationReasonMetadata CreateMetadata(string code)
"--exact falls back to ASCII COLLATE NOCASE because some folded-name rows were not restamped under the current runtime.",
"Run `cdidx backfill-fold` to restamp folded-name columns in place.",
"Run `cdidx index <projectPath> --rebuild` for a full rebuild."),
FoldReadyBitSetButRowsIncomplete => new(
code,
"--exact falls back to ASCII COLLATE NOCASE because the fold-ready bit is set but row-level folded-name verification found incomplete rows.",
"Run `cdidx backfill-fold` to restamp folded-name columns in place.",
"Run `cdidx index <projectPath> --rebuild` for a full rebuild."),
FoldReadyNotReady => new(
code,
"Unicode exact-name fold readiness is degraded.",
Expand Down
45 changes: 45 additions & 0 deletions src/CodeIndex/Mcp/McpToolHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,12 @@ private JsonNode ExecuteStatus(JsonNode? id)
.ToList();
}
status.Version = _version;
if (!status.FoldReady)
{
status.DegradedReason = DegradationReasonCodes.BuildFoldNotReadyExplanation(status.FoldReadyReason);
status.RecommendedAction = BuildFoldBackfillCommand(_dbPath, _dbPathExplicit);
status.AlternativeAction = BuildFoldRebuildRepairCommand(status.ProjectRoot, _dbPath, _dbPathExplicit);
}
var structured = JsonSerializer.SerializeToNode(status, _jsonOptions)!.AsObject();
structured["hotspotFamilyReady"] = status.HotspotFamilyReady;
if (status.HotspotFamilyDegradedReason != null)
Expand All @@ -1261,6 +1267,45 @@ private JsonNode ExecuteStatus(JsonNode? id)
});
}

private static string BuildFoldBackfillCommand(string dbPath, bool dbPathExplicit)
{
if (!dbPathExplicit)
return "cdidx backfill-fold";

return $"cdidx backfill-fold --db {QuoteCommandArgument(ResolveWritableDbPathOrPlaceholder(dbPath))}";
}

private static string BuildFoldRebuildRepairCommand(string? projectRoot, string dbPath, bool dbPathExplicit)
{
if (!dbPathExplicit)
return "cdidx index . --rebuild";

var resolvedDbPath = ResolveWritableDbPathOrPlaceholder(dbPath);
var targetProject = string.IsNullOrWhiteSpace(projectRoot)
? "<projectPath>"
: QuoteCommandArgument(projectRoot);
return $"cdidx index {targetProject} --db {QuoteCommandArgument(resolvedDbPath)} --rebuild";
}

private static string ResolveWritableDbPathOrPlaceholder(string dbPath)
=> DbPathResolver.TryResolveWritableMutationDbPath(dbPath, out var writableDbPath)
? writableDbPath
: "<writable-db-path>";

private static string QuoteCommandArgument(string value)
{
if (value.Length >= 2 && value[0] == '<' && value[^1] == '>')
return value;

var fullPath = DbPathResolver.NormalizeDbPath(value);
if (!fullPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
fullPath = Path.GetFullPath(fullPath);

return fullPath.IndexOfAny([' ', '\t', '"']) >= 0
? $"\"{fullPath.Replace("\"", "\\\"", StringComparison.Ordinal)}\""
: fullPath;
}

private JsonNode ExecuteOutline(JsonNode? id, JsonNode? args)
{
if (!TryReadRequiredStringParameter(args, "path", out var path, out var requiredError))
Expand Down
146 changes: 146 additions & 0 deletions tests/CodeIndex.Tests/DbReaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2439,6 +2439,152 @@ public void AllFoldedColumnsBackfilled_DetectsLegacyRowsWithNullFoldedValues()
}
}

[Fact]
public void GetStatus_WithFoldRowVerification_DegradesWhenReadyBitRowsAreIncomplete()
{
using var env = EnvironmentVariableScope.Capture(DbReader.VerifyFoldReadyRowsEnvironmentVariable);
env.Set(DbReader.VerifyFoldReadyRowsEnvironmentVariable, "1");
var dbPath = Path.Combine(Path.GetTempPath(), $"codeindex_fold_status_verify_{Guid.NewGuid():N}.db");
try
{
using var db = new DbContext(dbPath);
db.InitializeSchema();
var writer = new DbWriter(db.Connection);
var fileId = writer.UpsertFile(new FileRecord
{
Path = "src/a.py", Lang = "python", Size = 1, Lines = 1,
Modified = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc),
});
writer.InsertSymbols([
new SymbolRecord { FileId = fileId, Kind = "function", Name = "authenticate", Line = 1, StartLine = 1, EndLine = 1 },
]);
Assert.True(writer.MarkFoldReady());

using (var cmd = db.Connection.CreateCommand())
{
cmd.CommandText = "UPDATE symbols SET name_folded = NULL WHERE name = 'authenticate'";
cmd.ExecuteNonQuery();
}

var status = new DbReader(db.Connection).GetStatus();

Assert.False(status.FoldReady);
Assert.Equal(DegradationReasonCodes.FoldReadyBitSetButRowsIncomplete, status.FoldReadyReason);
}
finally
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
if (File.Exists(dbPath)) File.Delete(dbPath);
}
}

[Fact]
public void GetStatus_WithFoldRowVerification_IgnoresMissingReferenceTable()
{
using var env = EnvironmentVariableScope.Capture(DbReader.VerifyFoldReadyRowsEnvironmentVariable);
env.Set(DbReader.VerifyFoldReadyRowsEnvironmentVariable, "1");
var dbPath = Path.Combine(Path.GetTempPath(), $"codeindex_fold_status_legacy_refs_{Guid.NewGuid():N}.db");
try
{
using var db = new DbContext(dbPath);
db.InitializeSchema();
var writer = new DbWriter(db.Connection);
var fileId = writer.UpsertFile(new FileRecord
{
Path = "src/a.py", Lang = "python", Size = 1, Lines = 1,
Modified = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc),
});
writer.InsertSymbols([
new SymbolRecord { FileId = fileId, Kind = "function", Name = "authenticate", Line = 1, StartLine = 1, EndLine = 1 },
]);
Assert.True(writer.MarkFoldReady());

using (var cmd = db.Connection.CreateCommand())
{
cmd.CommandText = "DROP TABLE symbol_references";
cmd.ExecuteNonQuery();
}
db.RefreshSchemaCache();

var status = new DbReader(db.Connection).GetStatus();

Assert.True(status.FoldReady);
Assert.Null(status.FoldReadyReason);
}
finally
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
if (File.Exists(dbPath)) File.Delete(dbPath);
}
}

[Theory]
[InlineData(true, false, false)]
[InlineData(false, true, false)]
[InlineData(false, false, true)]
[InlineData(true, true, false)]
[InlineData(true, false, true)]
[InlineData(false, true, true)]
[InlineData(true, true, true)]
public void AllFoldedColumnsBackfilled_DetectsEveryPartialFoldColumnState(
bool nullSymbolName,
bool nullReferenceSymbolName,
bool nullReferenceContainerName)
{
var dbPath = Path.Combine(Path.GetTempPath(), $"codeindex_fold_partial_{Guid.NewGuid():N}.db");
try
{
using var db = new DbContext(dbPath);
db.InitializeSchema();
var writer = new DbWriter(db.Connection);
var fileId = writer.UpsertFile(new FileRecord
{
Path = "src/a.py", Lang = "python", Size = 1, Lines = 1,
Modified = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc),
});
writer.InsertSymbols([
new SymbolRecord { FileId = fileId, Kind = "function", Name = "authenticate", Line = 1, StartLine = 1, EndLine = 1 },
]);
writer.InsertReferences([
new ReferenceRecord
{
FileId = fileId,
SymbolName = "authenticate",
ReferenceKind = "call",
Line = 1,
Column = 1,
ContainerName = "login",
},
]);

Assert.True(writer.AllFoldedColumnsBackfilled());

using (var cmd = db.Connection.CreateCommand())
{
cmd.CommandText = $"""
UPDATE symbols
SET name_folded = CASE WHEN @nullSymbolName THEN NULL ELSE name_folded END;
UPDATE symbol_references
SET
symbol_name_folded = CASE WHEN @nullReferenceSymbolName THEN NULL ELSE symbol_name_folded END,
container_name_folded = CASE WHEN @nullReferenceContainerName THEN NULL ELSE container_name_folded END;
""";
cmd.Parameters.AddWithValue("@nullSymbolName", nullSymbolName);
cmd.Parameters.AddWithValue("@nullReferenceSymbolName", nullReferenceSymbolName);
cmd.Parameters.AddWithValue("@nullReferenceContainerName", nullReferenceContainerName);
cmd.ExecuteNonQuery();
}

for (var i = 0; i < 5; i++)
Assert.False(writer.AllFoldedColumnsBackfilled());
}
finally
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
if (File.Exists(dbPath)) File.Delete(dbPath);
}
}

[Fact]
public void GetExactGraphSupportedDefinitionLanguage_DegradesOnLegacyDbMissingContainerKind()
{
Expand Down
5 changes: 5 additions & 0 deletions tests/CodeIndex.Tests/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5163,6 +5163,11 @@ public void ToolsCall_Status_ReturnsCounts()
Assert.NotNull(response["result"]!["structuredContent"]!["projectRoot"]);
Assert.NotNull(response["result"]!["structuredContent"]!["hotspot_family_ready"]);
Assert.NotNull(response["result"]!["structuredContent"]!["hotspotFamilyReady"]);
Assert.False(response["result"]!["structuredContent"]!["foldReady"]!.GetValue<bool>());
Assert.Equal(DegradationReasonCodes.MissingFoldBackfill, response["result"]!["structuredContent"]!["fold_ready_reason"]!.GetValue<string>());
Assert.Contains("--exact falls back", response["result"]!["structuredContent"]!["degraded_reason"]!.GetValue<string>());
Assert.Equal("cdidx backfill-fold", response["result"]!["structuredContent"]!["recommended_action"]!.GetValue<string>());
Assert.Equal("cdidx index . --rebuild", response["result"]!["structuredContent"]!["alternative_action"]!.GetValue<string>());
}

[Fact]
Expand Down
Loading