diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a278f89fe..49ba3c9703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### [Unreleased] #### Fixed +- **`backfill-fold --json` now stays trim-safe instead of aborting on anonymous JSON output (#181)** — `RunBackfillFold` now serializes its success and error envelopes through source-generated DTOs, so the published trimmed release no longer throws `InvalidOperationException` / SIGABRT when `--json` is used. Added a regression that publishes a trimmed `cdidx.dll` and exercises both the success and missing-db JSON paths. Affected: `src/CodeIndex/Cli/IndexCommandRunner.cs`, `src/CodeIndex/Cli/JsonOutputContracts.cs`, `tests/CodeIndex.Tests/IndexCommandRunnerTests.cs`. Fixes #181. - **C/C++ out-of-class member definitions, concepts, inline namespaces, and modules are now indexed (#209)** — `SymbolExtractor` now captures qualified member definitions such as `Foo::bar()`, constructors and destructors, conversion and assignment operators, `template <> int add(...)` specializations, `int Foo::counter = 0;` static member definitions, `concept` declarations, `inline namespace` blocks, and `export module` declarations so the graph tools can see normal C++ impl files again. Added a regression fixture that exercises the core shapes together. Affected: `src/CodeIndex/Indexer/SymbolExtractor.cs`, `tests/CodeIndex.Tests/SymbolExtractorTests.cs`, `DEVELOPER_GUIDE.md`. Fixes #209. - **Dockerfile stage dependencies now show up in `callers` and `impact` (#275)** — `ReferenceExtractor` now records Dockerfile `FROM AS ` and `COPY --from=` edges as `call` references when the source name matches a named stage in the same file, so intermediate build stages no longer look unused. Added regression coverage for stage-to-stage `FROM`, `COPY --from`, and the base-image negative control. Affected: `src/CodeIndex/Indexer/ReferenceExtractor.cs`, `tests/CodeIndex.Tests/ReferenceExtractorTests.cs`, `DEVELOPER_GUIDE.md`, `CLAUDE.md`. Fixes #275. - **`unused` now recognizes class-level reflection attributes and common C# property attributes (#182)** — `DbReader` now promotes public class/struct/interface/enum symbols with nearby reflection/config attributes such as `[Serializable]`, `[JsonSerializable]`, `[Table]`, and `[AttributeUsage]`, and it also recognizes property attributes like `[Key]`, `[Required]`, `[BindProperty]`, `[Parameter]`, `[Inject]`, `[Obsolete]`, and `[BindNever]`. Added regressions for type-level and property-level fixtures. Affected: `src/CodeIndex/Database/DbSymbolReader.cs`, `tests/CodeIndex.Tests/DbReaderTests.cs`, `README.md`. Fixes #182. -- **Dockerfile stage dependencies now show up in `callers` and `impact` (#275)** — `ReferenceExtractor` now records Dockerfile `FROM AS ` and `COPY --from=` edges as `call` references when the source name matches a named stage in the same file, so intermediate build stages no longer look unused. Added regression coverage for stage-to-stage `FROM`, `COPY --from`, and the base-image negative control. Affected: `src/CodeIndex/Indexer/ReferenceExtractor.cs`, `tests/CodeIndex.Tests/ReferenceExtractorTests.cs`, `DEVELOPER_GUIDE.md`, `CLAUDE.md`. Fixes #275. -- **`unused` now recognizes class-level reflection attributes and common C# property attributes (#182)** — `DbReader` now promotes public class/struct/interface/enum symbols with nearby reflection/config attributes such as `[Serializable]`, `[JsonSerializable]`, `[Table]`, and `[AttributeUsage]`, and it also recognizes property attributes like `[Key]`, `[Required]`, `[BindProperty]`, `[Parameter]`, `[Inject]`, `[Obsolete]`, and `[BindNever]`. Added regressions for type-level and property-level fixtures. Affected: `src/CodeIndex/Database/DbSymbolReader.cs`, `tests/CodeIndex.Tests/DbReaderTests.cs`, `README.md`. Fixes #182. - **Makefile variable assignments now stay out of `function` symbols, and `%` pattern rules are indexed (#167)** — `:=` / `::=` / `=` / `?=` / `+=` assignments are now classified as `property` symbols instead of fake Makefile targets, while `%` pattern rules such as `%.o: %.c` are now captured as real `function` targets. Added regression coverage for assignment forms, pattern rules, and the existing target forms. Affected: `src/CodeIndex/Indexer/SymbolExtractor.cs`, `tests/CodeIndex.Tests/SymbolExtractorTests.cs`. Fixes #167. - **C / C++ `#define` macros now index as symbols, and define lines no longer emit phantom `call` edges (#267)** — `SymbolExtractor` now captures both function-like and object-like `#define` macros in C and C++, so macro names such as `MAX`, `VERSION`, and `MAX_BUFFER` show up in `symbols`, `definition`, and `outline`. `ReferenceExtractor` now suppresses the define-line self-match once the macro symbol exists, so `#define MAX(a, b)` no longer produces a fake `call MAX` row while real use sites still do. Added C and C++ regressions for symbol extraction and define-line suppression. Affected: `src/CodeIndex/Indexer/SymbolExtractor.cs`, `tests/CodeIndex.Tests/SymbolExtractorTests.cs`, `tests/CodeIndex.Tests/ReferenceExtractorTests.cs`, `DEVELOPER_GUIDE.md`. Fixes #267. - **MCP notifications now stay silent instead of returning `id: null` errors (#186)** — `McpServer` now short-circuits every JSON-RPC notification before any error envelope is emitted, so unknown `notifications/*` messages no longer get a `-32601 Method not found` response with `id: null`. Known handshake notifications still stay silent, and unknown notifications are logged to stderr for observability. Added a regression test for the silent stdout path and stderr log. Affected: `src/CodeIndex/Mcp/McpServer.cs`, `tests/CodeIndex.Tests/McpServerTests.cs`. Fixes #186. @@ -1487,6 +1486,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **R、PowerShell、Haskell 参照抽出** — R、PowerShell、Haskell でコールグラフクエリ(`references`、`callers`、`callees`)が利用可能に。R は `#` コメントと標準的な括弧付き呼び出し検出。PowerShell は `#` コメント; コマンドレットのハイフン名はハイフンで分割。Haskell は `--` コメント; 括弧付き呼び出しのみ(スペース区切り呼び出しは既知の制限)。各言語のキーワードを除外リストに追加。対象: `ReferenceExtractor.cs`。 #### 変更 +- **`backfill-fold --json` が trim-safe になり、匿名 JSON 出力で abort しなくなりました (#181)** — `RunBackfillFold` が success / error の envelope を source-generated DTO で serialise するようになり、公開済みの trimmed release でも `--json` 使用時に `InvalidOperationException` / SIGABRT で落ちなくなりました。trimmed な `cdidx.dll` を publish して success / missing-db の両方の JSON 経路を叩く regression も追加しました。対象: `src/CodeIndex/Cli/IndexCommandRunner.cs`, `src/CodeIndex/Cli/JsonOutputContracts.cs`, `tests/CodeIndex.Tests/IndexCommandRunnerTests.cs`。Fixes #181。 - **impact 分析が完全一致と切り詰め通知を使用** — `GetTransitiveCallers` が `LIKE %query%` ではなく完全一致(`=`)の専用 caller 検索を使うようになり、`Run` を検索して `RunAsync` や `ShouldRun` の caller まで巻き込む曖昧展開を防止。ホップごとの上限はハードコード 100 ではなく呼び出し元の limit に連動。CLI と MCP の両出力に `truncated` フラグを追加し、AI エージェントがグラフの不完全性を判断できるようになった。対象: `DbReader.cs`、`QueryCommandRunner.cs`、`McpToolHandlers.cs`。 - **Shell をグラフ対応言語から除外** — Shell スクリプトはコマンド形式(`foo arg1 arg2`)で関数を呼び出すため、括弧付き呼び出しを前提とする正規表現ベースの抽出器ではコールエッジを意味のある精度で検出できない。graph-supported として広告すると、空結果を AI クライアントが信頼してしまうため除外。対象: `ReferenceExtractor.cs`。 - **`unused` と `hotspots` に `--count` サポート** — 両コマンドが AI プリフライト用の `--count` フラグに対応。search/definition/symbols/references/callers/callees と同じパターン。対象: `QueryCommandRunner.cs`。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 6f12f9b3fb..1c1bec5a3e 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -344,16 +344,14 @@ public static int RunBackfillFold(string[] cmdArgs, JsonSerializerOptions jsonOp if (options.Json) { - Console.WriteLine(JsonSerializer.Serialize(new - { + Console.WriteLine(JsonSerializer.Serialize(new BackfillFoldJsonResult( symbols, - symbol_references = symbolReferences, - rewrite_all = rewriteAll, + symbolReferences, + rewriteAll, verified, - user_version_before = userVersionBefore, - user_version_after = userVersionAfter, - fold_ready = true, - }, jsonOptions)); + userVersionBefore, + userVersionAfter, + true), CliJsonSerializerContext.Default.BackfillFoldJsonResult)); } else { @@ -1387,7 +1385,7 @@ private static bool TryProbeDryRunFile(FileIndexer indexer, string absolutePath, private static int WriteCommandError(bool json, JsonSerializerOptions jsonOptions, string message, int exitCode, string? hint = null) { if (json) - Console.WriteLine(JsonSerializer.Serialize(new { status = "error", message, hint }, jsonOptions)); + Console.WriteLine(JsonSerializer.Serialize(new CommandErrorJsonResult("error", message, hint), CliJsonSerializerContext.Default.CommandErrorJsonResult)); else { Console.Error.WriteLine($"Error: {message}"); diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs new file mode 100644 index 0000000000..5c26b37155 --- /dev/null +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeIndex.Cli; + +internal sealed record BackfillFoldJsonResult( + [property: JsonPropertyName("symbols")] int Symbols, + [property: JsonPropertyName("symbol_references")] int SymbolReferences, + [property: JsonPropertyName("rewrite_all")] bool RewriteAll, + [property: JsonPropertyName("verified")] bool Verified, + [property: JsonPropertyName("user_version_before")] int UserVersionBefore, + [property: JsonPropertyName("user_version_after")] int UserVersionAfter, + [property: JsonPropertyName("fold_ready")] bool FoldReady); + +internal sealed record CommandErrorJsonResult( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("hint")] string? Hint); + +[JsonSourceGenerationOptions(WriteIndented = false)] +[JsonSerializable(typeof(BackfillFoldJsonResult))] +[JsonSerializable(typeof(CommandErrorJsonResult))] +internal partial class CliJsonSerializerContext : JsonSerializerContext; diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 091f29dcd2..c0952d157d 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Runtime.Versioning; +using System.Runtime.InteropServices; using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Models; @@ -853,6 +854,112 @@ public void RunBackfillFold_MissingDb_JsonIncludesHint() } } + [Fact] + public void RunBackfillFold_PublishedTrimmedBinary_SerializesSuccessAndErrorJson() + { + var publishDir = Path.Combine(Path.GetTempPath(), $"cdidx_trimmed_publish_{Guid.NewGuid():N}"); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_trimmed_backfill_{Guid.NewGuid():N}.db"); + var missingDbPath = Path.Combine(Path.GetTempPath(), $"cdidx_trimmed_missing_{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/app.py", + Lang = "python", + Size = 64, + Lines = 2, + Modified = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord { FileId = fileId, Kind = "function", Name = "café_init", Line = 1, StartLine = 1, EndLine = 1 }, + new SymbolRecord { FileId = fileId, Kind = "function", Name = "bootstrap", Line = 2, StartLine = 2, EndLine = 2 }, + ]); + writer.InsertReferences([ + new ReferenceRecord + { + FileId = fileId, + SymbolName = "CAFÉ_INIT", + ReferenceKind = "call", + Line = 2, + Column = 5, + Context = "CAFÉ_INIT()", + ContainerKind = "function", + ContainerName = "bootstrap", + }, + ]); + writer.MarkGraphReady(); + writer.MarkIssuesReady(); + } + + var publishedDll = PublishTrimmedCli(publishDir); + + JsonElement successJson; + int successExitCode; + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var stdout = new StringWriter(); + try + { + Console.SetOut(stdout); + var (exitCode, stdoutText, stderrText) = RunPublishedCli(publishedDll, publishDir, "backfill-fold", "--db", dbPath, "--json"); + successExitCode = exitCode; + Assert.True(!string.IsNullOrWhiteSpace(stdoutText), $"published backfill-fold produced no stdout. stderr={stderrText}"); + using var document = JsonDocument.Parse(stdoutText); + successJson = document.RootElement.Clone(); + } + finally + { + Console.SetOut(originalOut); + } + } + + Assert.Equal(CommandExitCodes.Success, successExitCode); + Assert.Equal(2, successJson.GetProperty("symbols").GetInt32()); + Assert.Equal(1, successJson.GetProperty("symbol_references").GetInt32()); + Assert.True(successJson.GetProperty("fold_ready").GetBoolean()); + + JsonElement errorJson; + int errorExitCode; + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var stdout = new StringWriter(); + try + { + Console.SetOut(stdout); + var (exitCode, stdoutText, stderrText) = RunPublishedCli(publishedDll, publishDir, "backfill-fold", "--db", missingDbPath, "--json"); + errorExitCode = exitCode; + Assert.True(!string.IsNullOrWhiteSpace(stdoutText), $"published backfill-fold error path produced no stdout. stderr={stderrText}"); + using var document = JsonDocument.Parse(stdoutText); + errorJson = document.RootElement.Clone(); + } + finally + { + Console.SetOut(originalOut); + } + } + + Assert.Equal(CommandExitCodes.NotFound, errorExitCode); + Assert.Equal("error", errorJson.GetProperty("status").GetString()); + Assert.Contains("database not found", errorJson.GetProperty("message").GetString()); + Assert.Contains("Point `--db` at an existing `codeindex.db`", errorJson.GetProperty("hint").GetString()); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteDirectory(publishDir); + if (File.Exists(dbPath)) + File.Delete(dbPath); + if (File.Exists(missingDbPath)) + File.Delete(missingDbPath); + } + } + [Fact] public void Run_ReadOnlyUriDbPath_PrintsActionableErrorInsteadOfCrashing() { @@ -4799,6 +4906,69 @@ private static (int ExitCode, string StdOut, string StdErr) RunCliInSubprocess(s return (process.ExitCode, stdOut, stdErr); } + private static (int ExitCode, string StdOut, string StdErr) RunPublishedCli(string publishedDll, string workingDirectory, params string[] args) + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + psi.ArgumentList.Add(publishedDll); + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + using var process = System.Diagnostics.Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start published cdidx subprocess / 公開済み cdidx サブプロセスの起動に失敗"); + var stdOut = process.StandardOutput.ReadToEnd(); + var stdErr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + return (process.ExitCode, stdOut, stdErr); + } + + private static string PublishTrimmedCli(string outputDir) + { + Directory.CreateDirectory(outputDir); + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = GetRepositoryRoot(), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + psi.ArgumentList.Add("publish"); + psi.ArgumentList.Add(Path.Combine("src", "CodeIndex", "CodeIndex.csproj")); + psi.ArgumentList.Add("--configuration"); + psi.ArgumentList.Add("Debug"); + psi.ArgumentList.Add("--runtime"); + psi.ArgumentList.Add(RuntimeInformation.RuntimeIdentifier); + psi.ArgumentList.Add("--output"); + psi.ArgumentList.Add(outputDir); + psi.ArgumentList.Add("-p:PublishTrimmed=true"); + psi.ArgumentList.Add("-p:SelfContained=true"); + psi.ArgumentList.Add("-p:PublishSingleFile=false"); + + using var process = System.Diagnostics.Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start dotnet publish / dotnet publish の起動に失敗"); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new InvalidOperationException($"dotnet publish failed: {stdout}{stderr}".Trim()); + + var publishedDll = Path.Combine(outputDir, "cdidx.dll"); + if (!File.Exists(publishedDll)) + throw new InvalidOperationException($"Published cdidx.dll not found at {publishedDll}"); + + return publishedDll; + } + private static (int ExitCode, string StdOut, string StdErr, bool TimedOut) RunCliInSubprocessWithTimeout(string[] args, string workingDirectory, TimeSpan timeout) { var psi = new System.Diagnostics.ProcessStartInfo @@ -4849,6 +5019,19 @@ private static string GetBuiltCliDllPath() throw new InvalidOperationException("Could not locate built cdidx.dll from test output path / テスト出力パスから cdidx.dll を特定できませんでした"); } + private static string GetRepositoryRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "CodeIndex.sln")) || Directory.Exists(Path.Combine(dir.FullName, "src", "CodeIndex"))) + return dir.FullName; + dir = dir.Parent; + } + + throw new InvalidOperationException("Could not locate repository root / リポジトリルートを特定できませんでした"); + } + private static SqliteConnection OpenNonPoolingConnection(string dbPath) { var builder = new SqliteConnectionStringBuilder