Skip to content

Commit 79ff64d

Browse files
committed
Fix negative search cursor parsing (#4664)
1 parent f0a1e76 commit 79ff64d

5 files changed

Lines changed: 96 additions & 1 deletion

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ cdidx validate
9292

9393
The default NDJSON output of `search`, `symbols`, and `files` always ends with a bounded `terminal_record` unless `--results-only` explicitly suppresses it; recipe/audit search row streams use the same contract. The record reports returned and observed total counts, whether that total is authoritative or a lower bound, the truncation reason, applied limits, omitted rows, and recovery guidance. `--max-json-bytes` is a hard cap over all stdout bytes, including row newlines and the terminal record. If that record cannot fit by itself, the command fails with a usage error before writing stdout. Capped output rejects `--profile`, `--verbose`, and `--json-envelope`, whose additional serialization would otherwise escape the cap.
9494

95+
Recipe/audit compact pagination returns an opaque `next_cursor`. Replay it unchanged with the same child query and filters as `--cursor <next_cursor>`; a search-score cursor can begin with `-`, and that leading minus is part of the cursor value.
96+
9597
When the byte cap omits rows, these commands return partial-result exit code `11`; pass `--allow-partial` to opt into exit code `0` while retaining the same terminal metadata. Ordinary `--limit` truncation remains a successful, explicitly described stream. Array and compact outputs keep their documented whole-response behavior; check `cdidx <command> --help` before relying on partial output.
9698

9799
High-volume `definition`, `find`, `status`, `hotspots`, `references`, `callers`, `callees`, `impact`, and `map` responses also support an opt-in bounded envelope through `--fields`, `--cursor`, compact output where advertised, and a total `--max-json-bytes` budget. Its metadata reports returned/total/omitted counts and an opaque `next_cursor`; replay that cursor with the same query, filters, and sort arguments. The response also exposes its 10,000-row safety window and reports when that window is exhausted instead of emitting an unusable cursor. Existing compact location responses retain their top-level keys and lightweight `file` / `line` rows while adding the shared metadata; `refs` / `stats` aliases and matching read-only `batch` children use the same envelope and hard cap. `hotspots` and `impact` page their active primary collection, while dotted projections such as `callers.path,callers.depth` select nested rows and report that collection's total. `map --sections` selects whole response sections; a bounded projection such as `--fields top_files.path` instead pages that section's rows and avoids building unrelated ranked sections. For `definition --body`, `body`, `body_content`, and `all` retain the explicit body; projections that exclude it avoid materializing body text.
@@ -476,6 +478,8 @@ cdidx validate
476478

477479
`search``symbols``files` の既定 NDJSON 出力は、`--results-only` で明示的に抑止しない限り、常に上限付きの `terminal_record` で終了し、recipe / audit search の row stream も同じ契約を使います。このレコードは返却件数と観測済み総件数、その総件数が authoritative か lower bound か、切り詰め理由、適用上限、省略行数、復旧案内を返します。`--max-json-bytes` は各行の改行と終端レコードを含む stdout 全体の hard cap です。終端レコード自体が収まらない場合は、stdout を書く前に usage error で失敗します。追加 serialization が cap 外へ出ることを防ぐため、上限付き出力では `--profile``--verbose``--json-envelope` を拒否します。
478480

481+
recipe / audit の compact pagination は opaque な `next_cursor` を返します。同じ child query と filter を指定し、値を変更せず `--cursor <next_cursor>` として再利用してください。search-score cursor は `-` で始まる場合があり、その先頭のマイナス記号も cursor 値の一部です。
482+
479483
byte cap により行を省略した場合、これらのコマンドは partial-result 終了コード `11` を返します。同じ終端 metadata を維持したまま終了コード `0` を明示的に許容するには `--allow-partial` を指定します。通常の `--limit` による切り詰めは、理由が明示された成功 stream のままです。array / compact 出力は文書化済みの whole-response 挙動を維持します。部分出力へ依存する前に `cdidx <command> --help` を確認してください。
480484

481485
高ボリュームな `definition`、`find`、`status`、`hotspots`、`references`、`callers`、`callees`、`impact`、`map` の応答は、`--fields`、`--cursor`、対応 command の compact 出力、応答全体に対する `--max-json-bytes` により opt-in の bounded envelope も利用できます。metadata は返却 / 総 / 省略件数と opaque な `next_cursor` を返します。次ページでは同じ query、filter、sort 引数とともにその cursor を再利用してください。応答は 10,000 row の safety window も公開し、上限到達時には利用不能な cursor を返さず、window の消費完了を報告します。既存の compact location 応答はトップレベル key と軽量な `file` / `line` row を維持したまま共通 metadata を追加し、`refs` / `stats` alias と対応する read-only `batch` 子 command にも同じ envelope と hard cap を適用します。`hotspots` と `impact` は active な主要 collection をページングし、`callers.path,callers.depth` のような dotted projection は nested row とその collection の総件数を返します。`map --sections` は section 全体を選びますが、`--fields top_files.path` のような bounded projection はその section の row をページングし、無関係な ranking section を構築しません。`definition --body` では `body`、`body_content`、`all` が明示的な body を保持し、body を除外する projection では本文を取得しません。

TESTING_GUIDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,10 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding
409409
publishes a trimmed RID-specific CLI and runs whichever entry point the SDK emits (`cdidx.dll` through `dotnet` or the native `cdidx`/`cdidx.exe` apphost). Its publish smoke disables NuGet vulnerability auditing because package advisory validation is covered by the normal build/test workflow's package vulnerability check, not by this runtime serialization test. It is reported as skipped on macOS arm64 while SDK/ILLink can crash before exercising `cdidx` (#2586). Do not assume every SDK/runtime pair writes a `cdidx.dll` into self-contained publish output.
410410
- `QueryCommandRunnerTests.RunPublishedTrimmedCli_SerializesQueryJsonAndSupportsRazorAliases`
411411
uses one trimmed RID-specific publish output for query JSON coverage and both `cshtml` / `razor` C# Razor language aliases, writes publish-specific lock files under the test's temporary intermediate directory, disables NuGet vulnerability auditing for the publish smoke, and runs whichever `cdidx` entry point the SDK emits so the test does not depend on source-tree lock-file mutation, advisory-feed availability, or a DLL-only publish layout. If `dotnet publish` reaches an SDK/ILLink tool that requires an unavailable `Microsoft.NETCore.App` runtime, the test is reported as skipped with that missing-runtime diagnostic instead of failing before it can exercise `cdidx` (#3571). It is also reported as skipped on macOS arm64 because the SDK/ILLink crash happens before the test reaches `cdidx` (#2586).
412+
- `QueryCommandRunnerTests.RunSearch_RecipeAcceptsNegativeScoreCursorAsSeparatedValue_Issue4664`
413+
uses one two-file recipe fixture to prove a compact search can emit a negative-score `next_cursor`, replay that exact cursor as a separated `--cursor` value, and return the next distinct page without an option-parsing error.
414+
- `QueryCommandRunnerTests.RunSearch_CursorDoesNotConsumeRecognizedShortOption_Issue4664`
415+
keeps the negative-cursor exception narrow by proving a recognized short option after `--cursor` remains an option and still produces the missing-cursor-value diagnostic.
412416
- `McpServer*Tests.cs`
413417
MCP JSON-RPC behavior and tool outputs. Large server coverage is split into focused partial suites for tool calls, tool listing, protocol/session handling, and error handling while the root `McpServerTests` part keeps shared seeded fixture state. Request-timeout tests use signal-gated delay hooks instead of fixed sleeps: start the request, confirm the hook has begun, then await the timeout response with a bounded wait so they pay only the configured timeout while still proving in-flight actions drain after the timeout response.
414418
Protocol negotiation coverage keeps `2025-06-18`, `2025-03-26`, and `2024-11-05` in one shared version-echo fixture. The Codex compatibility regression separately uses the lifecycle-enforcing transport to send a `2025-06-18` initialize, `notifications/initialized`, and `tools/list`, because a direct handler assertion would not catch initialization-gate failures.
@@ -1117,6 +1121,10 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests"
11171121
は trimmed な RID 固有 CLI を publish し、SDK が生成した entry point(`dotnet` 経由の `cdidx.dll`、または native の `cdidx`/`cdidx.exe` apphost)を実行します。この publish smoke は NuGet 脆弱性監査を無効化します。package advisory の検証は通常の build/test workflow の package vulnerability check が担い、この runtime serialization テストの責務ではないためです。macOS arm64 では SDK/ILLink が `cdidx` に到達する前にクラッシュし得るため、このテストは skipped として報告されます(#2586)。self-contained publish output に常に `cdidx.dll` が出るとは仮定しないでください。
11181122
- `QueryCommandRunnerTests.RunPublishedTrimmedCli_SerializesQueryJsonAndSupportsRazorAliases`
11191123
は 1 つの trimmed RID 固有 publish output で query JSON coverage と `cshtml` / `razor` の C# Razor 言語 alias を検証し、publish 専用の lock file をテストの一時 intermediate directory 配下に書き、publish smoke の NuGet 脆弱性監査を無効化し、SDK が生成した `cdidx` entry point を実行します。source tree の lock file 変更、advisory feed の可用性、DLL 固定の publish layout には依存しません。`dotnet publish` が、利用できない `Microsoft.NETCore.App` runtime を必要とする SDK/ILLink tool に到達した場合は、`cdidx` を実行する前に失敗させるのではなく、その missing-runtime diagnostic を付けて skipped として報告します(#3571)。このテストも macOS arm64 では、`cdidx` に到達する前に SDK/ILLink がクラッシュし得るため skipped として報告されます(#2586)。
1124+
- `QueryCommandRunnerTests.RunSearch_RecipeAcceptsNegativeScoreCursorAsSeparatedValue_Issue4664`
1125+
は 2 file の recipe fixture を 1 つ使い、compact search が負の score で始まる `next_cursor` を生成し、その値を separated `--cursor` として変更せず再利用しても option parse error にならず、別の次ページを返すことを検証します。
1126+
- `QueryCommandRunnerTests.RunSearch_CursorDoesNotConsumeRecognizedShortOption_Issue4664`
1127+
は負の cursor に対する例外を狭く保ち、`--cursor` の直後にある既知の short option は cursor 値として消費されず、従来どおり cursor 値欠如の診断を返すことを検証します。
11201128
- `McpServer*Tests.cs`
11211129
MCP の JSON-RPC 挙動とツール出力のテスト。大きな server coverage は tool call、tool listing、protocol/session handling、error handling ごとの focused partial suite に分割し、共有の seed 済み fixture 状態は root 側の `McpServerTests` に残します。request-timeout test は固定 sleep ではなく signal-gated delay hook を使います。request を開始し、hook が始まったことを確認してから timeout response を bounded wait で待つことで、timeout response 後に in-flight action が drain されることは保ったまま、設定した timeout 分だけを待つようにします。
11221130
protocol negotiation coverage は `2025-06-18``2025-03-26``2024-11-05` を共通の version-echo fixture にまとめます。Codex 互換性の回帰テストでは別途 lifecycle を強制する transport を使い、`2025-06-18` の initialize、`notifications/initialized``tools/list` までを送ります。direct handler の assertion だけでは initialization gate の失敗を検出できないためです。
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 4664
5+
affected:
6+
- src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs
7+
- tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs
8+
- README.md
9+
- TESTING_GUIDE.md
10+
---
11+
12+
## English
13+
14+
- **Negative-score search cursors can now be replayed with separated `--cursor` syntax (#4664)** — recipe and audit search pagination now consumes an opaque `next_cursor` that begins with `-` as the cursor value, so the generated next-page command no longer reports a missing value and an unsupported option.
15+
16+
## 日本語
17+
18+
- **負の score で始まる search cursor を separated `--cursor` 構文で再利用できるようになりました (#4664)** — recipe / audit search pagination は `-` で始まる opaque な `next_cursor` も cursor 値として消費するため、生成された次ページコマンドが値欠如と未対応 option のエラーを返さなくなりました。

src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -712,7 +712,10 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue)
712712
AddParseError(issueLabelError!);
713713
break;
714714
case "--cursor":
715-
if (TryReadStringOptionValue(args, ref i, "--cursor", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var cursorValue, out var cursorError))
715+
var allowSeparatedDashPrefixedCursorValue = inlineValue is null
716+
&& i + 1 < args.Length
717+
&& TryParseSearchCursor(args[i + 1], out _);
718+
if (TryReadStringOptionValue(args, ref i, "--cursor", inlineValue, allowSeparatedDashPrefixedLiteralValue: allowSeparatedDashPrefixedCursorValue, out var cursorValue, out var cursorError))
716719
{
717720
WarnIfDuplicateSingleValueOption("--cursor", cursorValue!);
718721
if (TryParseSearchCursor(cursorValue!, out var parsedCursor))

tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4498,6 +4498,68 @@ public void RunSearch_RecipeInvalidCursorDomain_ReturnsUsageError_Issue3837(stri
44984498
Assert.Contains("--cursor", stderr, StringComparison.Ordinal);
44994499
}
45004500

4501+
[Fact]
4502+
public void RunSearch_RecipeAcceptsNegativeScoreCursorAsSeparatedValue_Issue4664()
4503+
{
4504+
var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_negative_cursor_4664");
4505+
try
4506+
{
4507+
var dbPath = TestProjectHelper.CreateProjectDb(projectRoot);
4508+
TestProjectHelper.InsertIndexedFile(
4509+
dbPath,
4510+
"src/First.cs",
4511+
"csharp",
4512+
"public sealed class First { private const string Secret = \"first\"; }");
4513+
TestProjectHelper.InsertIndexedFile(
4514+
dbPath,
4515+
"src/Second.cs",
4516+
"csharp",
4517+
"public sealed class Second { private const string Secret = \"second\"; }");
4518+
4519+
var (firstExitCode, firstStdout, firstStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch(
4520+
["--recipe", "risky-code/secret-term", "--db", dbPath, "--format", "compact", "--limit", "1"],
4521+
_jsonOptions));
4522+
4523+
Assert.Equal(CommandExitCodes.Success, firstExitCode);
4524+
Assert.Equal(string.Empty, firstStderr);
4525+
using var firstDocument = ParseJsonOutput(firstStdout);
4526+
var firstQuery = Assert.Single(firstDocument.RootElement.GetProperty("queries").EnumerateArray());
4527+
var firstResult = Assert.Single(firstQuery.GetProperty("results").EnumerateArray());
4528+
var nextCursor = firstQuery.GetProperty("next_cursor").GetString();
4529+
4530+
Assert.False(string.IsNullOrWhiteSpace(nextCursor));
4531+
Assert.True(nextCursor!.StartsWith("-", StringComparison.Ordinal), $"Expected a negative-score cursor, got '{nextCursor}'.");
4532+
4533+
var (secondExitCode, secondStdout, secondStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch(
4534+
["--recipe", "risky-code/secret-term", "--db", dbPath, "--format", "compact", "--limit", "1", "--cursor", nextCursor],
4535+
_jsonOptions));
4536+
4537+
Assert.Equal(CommandExitCodes.Success, secondExitCode);
4538+
Assert.Equal(string.Empty, secondStderr);
4539+
using var secondDocument = ParseJsonOutput(secondStdout);
4540+
var secondResult = Assert.Single(Assert.Single(secondDocument.RootElement.GetProperty("queries").EnumerateArray()).GetProperty("results").EnumerateArray());
4541+
4542+
Assert.NotEqual(firstResult.GetProperty("path").GetString(), secondResult.GetProperty("path").GetString());
4543+
}
4544+
finally
4545+
{
4546+
TestProjectHelper.DeleteDirectory(projectRoot);
4547+
}
4548+
}
4549+
4550+
[Fact]
4551+
public void RunSearch_CursorDoesNotConsumeRecognizedShortOption_Issue4664()
4552+
{
4553+
var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch(
4554+
["--recipe", "risky-code/secret-term", "--cursor", "-q"],
4555+
_jsonOptions));
4556+
4557+
Assert.Equal(CommandExitCodes.UsageError, exitCode);
4558+
Assert.Equal(string.Empty, stdout);
4559+
Assert.Contains("Error: --cursor requires a value.", stderr, StringComparison.Ordinal);
4560+
Assert.DoesNotContain("--cursor must be a search", stderr, StringComparison.Ordinal);
4561+
}
4562+
45014563
[Fact]
45024564
public void RunSearch_RecipeJsonRunsBuiltInQueries_Issue3144()
45034565
{

0 commit comments

Comments
 (0)