From 1b0103b185a4b5e90ec38899d681b50050cdeb99 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 2 Sep 2026 01:47:50 +0900 Subject: [PATCH 1/2] Fix monotonic discovery path filters (#5229) --- USER_GUIDE.md | 23 ++++ changelog.d/unreleased/5229.fixed.md | 23 ++++ .../Cli/QueryCommandRunner.ArgumentParser.cs | 4 +- .../Cli/QueryCommandRunner.Discovery.cs | 45 +++++-- src/CodeIndex/Cli/QueryCommandRunner.Map.cs | 9 +- .../Cli/QueryCommandRunner.ResultEnvelopes.cs | 51 ++++++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 2 + src/CodeIndex/Cli/SearchAuditRecipes.cs | 54 +++------ src/CodeIndex/Cli/SourceScopeDefaults.cs | 26 ++++ .../Database/DbReader.FilesStatus.cs | 5 +- src/CodeIndex/Database/DbReader.cs | 12 +- src/CodeIndex/Database/RepoMapBuilder.cs | 22 ++-- tests/CodeIndex.Tests/DbReaderTests.cs | 28 +++++ .../QueryCommandRunnerFilesTests.cs | 113 ++++++++++++++---- 14 files changed, 332 insertions(+), 85 deletions(-) create mode 100644 changelog.d/unreleased/5229.fixed.md create mode 100644 src/CodeIndex/Cli/SourceScopeDefaults.cs diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 351c4785d..0c1624968 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2171,6 +2171,18 @@ A positional value containing an unescaped `*` or `?` is treated exactly like for example `cdidx files '**/*.cs'`. Positionals without those glob metacharacters remain filename-substring queries. +For `files` and `map`, `--exclude-tests` also activates the implicit production +source baseline. Its `src/**` include group is intersected with the user's +repeatable `--path` group; patterns within either group remain OR-combined. +The implicit source exclusions and every explicit `--exclude-path` are then +applied together. Therefore adding a broad or narrow `--path` cannot re-enable +files outside the baseline or files excluded by it. Generated-file policy, +`--since`, path matching semantics, and count/row scope remain unchanged. +Count, compact, summary, and map JSON expose this composition under +`query_context.effective_path_scope`: `include_groups` and `exclude_groups` +identify `implicit_source_baseline` versus `explicit_cli` provenance, while the +operator fields describe how the groups and their patterns are combined. + Output: ``` @@ -5853,6 +5865,17 @@ cdidx files --format compact --max-json-bytes 8000 `cdidx files '**/*.cs'` のように引用してください。これらの glob metacharacter を 含まない positional 値は、従来どおり filename substring query として扱われます。 +`files` と `map` では、`--exclude-tests` によって暗黙の production source baseline +も有効になります。baseline の `src/**` include group は、繰り返し指定できるユーザーの +`--path` group と AND で交差し、各 group 内の pattern は従来どおり OR で結合されます。 +その後、暗黙の source 除外とすべての明示 `--exclude-path` が合わせて適用されます。 +したがって、広いまたは狭い `--path` を追加しても baseline 外や baseline 除外済みの file +が再び有効になることはありません。generated-file policy、`--since`、path match semantics、 +count / row の scope は維持されます。count、compact、summary、map の JSON は、この合成を +`query_context.effective_path_scope` に公開します。`include_groups` と `exclude_groups` は +`implicit_source_baseline` / `explicit_cli` の由来を示し、operator field は group と pattern +の結合方法を示します。 + 出力: ``` diff --git a/changelog.d/unreleased/5229.fixed.md b/changelog.d/unreleased/5229.fixed.md new file mode 100644 index 000000000..cb775766e --- /dev/null +++ b/changelog.d/unreleased/5229.fixed.md @@ -0,0 +1,23 @@ +--- +category: fixed +issues: + - 5229 +affected: + - src/CodeIndex/Cli/SourceScopeDefaults.cs + - src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs + - src/CodeIndex/Cli/QueryCommandRunner.Map.cs + - src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs + - src/CodeIndex/Database/DbReader.cs + - src/CodeIndex/Database/RepoMapBuilder.cs + - tests/CodeIndex.Tests/DbReaderTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs + - USER_GUIDE.md +--- + +## English + +- **`files --exclude-tests` path filters are now monotonic (#5229)** — explicit `--path` values are intersected with the implicit production-source baseline instead of replacing it, so broad or narrow includes cannot re-enable baseline-excluded files. Count, row, and map scopes now share the same composition, and JSON query metadata reports effective include/exclude provenance. + +## 日本語 + +- **`files --exclude-tests` の path filter が単調になりました (#5229)** — 明示した `--path` は暗黙の production-source baseline を置き換えず、その baseline と交差するようになったため、広いまたは狭い include で baseline 除外済みの file が再び有効になることはありません。count、row、map は同じ scope 合成を共有し、JSON query metadata は有効な include / exclude の由来を報告します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index 14963911f..d29cfe377 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -389,8 +389,8 @@ private void ApplySearchSourceOptionDefaults() } if (pathPatterns.Count == 0) - AddDistinct(pathPatterns, SearchAuditRecipes.DefaultSourcePathPatterns); - AddDistinct(excludePaths, SearchAuditRecipes.DefaultSourceExcludePaths); + AddDistinct(pathPatterns, SourceScopeDefaults.IncludePaths); + AddDistinct(excludePaths, SourceScopeDefaults.ExcludePaths); AddSourceOnlyDefaultExcludeOrigin(excludeOrigins, matchOrigins, SearchMatchClassifier.Comment); AddSourceOnlyDefaultExcludeOrigin(excludeOrigins, matchOrigins, SearchMatchClassifier.HelpText); AddSourceOnlyDefaultExcludeOrigin(excludeOrigins, matchOrigins, SearchMatchClassifier.SchemaDescription); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs b/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs index cfa7e9e2a..f075609df 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Discovery.cs @@ -521,7 +521,14 @@ public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) { if (options.CountOnly) { - var counts = reader.CountListFiles(options.Query, options.Lang, filesScope.PathPatterns, filesScope.ExcludePaths, filesScope.ExcludeTests, options.Since); + var counts = reader.CountListFiles( + options.Query, + options.Lang, + filesScope.PathPatterns, + filesScope.ExcludePaths, + filesScope.ExcludeTests, + options.Since, + requiredPathPatterns: filesScope.RequiredPathPatterns); var generatedFileCountExcluded = CountGeneratedFilesExcluded(reader, options, filesScope); if (options.Json) { @@ -561,7 +568,8 @@ public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) filesScope.ExcludeTests, options.Since, orderBySize: options.RawBytes, - offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("files")); + offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("files"), + requiredPathPatterns: filesScope.RequiredPathPatterns); Func rowFactory = result => ToFileDiscoveryJsonNode(result, jsonOptions, options.OutputFormat == OutputFormatCompact); if (results.Count == 0) @@ -599,7 +607,14 @@ public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (IsDiscoveryNdjson(options)) { - var counts = reader.CountListFiles(options.Query, options.Lang, filesScope.PathPatterns, filesScope.ExcludePaths, filesScope.ExcludeTests, options.Since); + var counts = reader.CountListFiles( + options.Query, + options.Lang, + filesScope.PathPatterns, + filesScope.ExcludePaths, + filesScope.ExcludeTests, + options.Since, + requiredPathPatterns: filesScope.RequiredPathPatterns); var stream = WriteDiscoveryNdjson( reader, options, @@ -614,7 +629,14 @@ public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (ShouldWriteBoundedDiscoveryJsonPayload(options)) { - var counts = reader.CountListFiles(options.Query, options.Lang, filesScope.PathPatterns, filesScope.ExcludePaths, filesScope.ExcludeTests, options.Since); + var counts = reader.CountListFiles( + options.Query, + options.Lang, + filesScope.PathPatterns, + filesScope.ExcludePaths, + filesScope.ExcludeTests, + options.Since, + requiredPathPatterns: filesScope.RequiredPathPatterns); return WriteBoundedDiscoveryJsonPayload( reader, options, @@ -682,24 +704,26 @@ internal static (List? Queries, bool HadExplicitInput) BuildSymbolQueryL private sealed record DiscoveryFileScopeFilters( IReadOnlyList PathPatterns, + IReadOnlyList RequiredPathPatterns, IReadOnlyList ExcludePaths, bool ExcludeTests); private static DiscoveryFileScopeFilters BuildDiscoveryFileScopeFilters(QueryCommandOptions options) { - if (!options.ExcludeTests || options.PathPatterns.Count > 0) + if (!options.ExcludeTests) { return new( options.PathPatterns, + [], options.ExcludePaths, options.ExcludeTests); } - var pathPatterns = new List(options.PathPatterns); - AddDistinct(pathPatterns, SearchAuditRecipes.DefaultSourcePathPatterns); var excludePaths = new List(options.ExcludePaths); - AddDistinct(excludePaths, SearchAuditRecipes.DefaultSourceExcludePaths); - return new(pathPatterns, excludePaths, ExcludeTests: true); + AddDistinct(excludePaths, SourceScopeDefaults.ExcludePaths); + options.DiscoveryBaselineIncludePaths = SourceScopeDefaults.IncludePaths; + options.DiscoveryBaselineExcludePaths = SourceScopeDefaults.ExcludePaths; + return new(options.PathPatterns, SourceScopeDefaults.IncludePaths, excludePaths, ExcludeTests: true); } private static int? CountGeneratedFilesExcluded(DbReader reader, QueryCommandOptions options, DiscoveryFileScopeFilters filesScope) @@ -714,7 +738,8 @@ private static DiscoveryFileScopeFilters BuildDiscoveryFileScopeFilters(QueryCom filesScope.ExcludePaths, filesScope.ExcludeTests, options.Since, - generatedOnly: true).Count; + generatedOnly: true, + requiredPathPatterns: filesScope.RequiredPathPatterns).Count; private static void AddGeneratedFileFilterJsonFields( JsonObject payload, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Map.cs b/src/CodeIndex/Cli/QueryCommandRunner.Map.cs index aa3f0222d..71e45ba94 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Map.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Map.cs @@ -80,7 +80,8 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) oversizedByteThreshold: evaluateIssueDraftCandidates ? MapIssueDraftByteThreshold : null, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("map"), requestedCollection: JsonEnvelopeWrapper.GetBoundedMapCollection(), - summaryProjection: JsonEnvelopeWrapper.IsBoundedMapScalarProjection()); + summaryProjection: JsonEnvelopeWrapper.IsBoundedMapScalarProjection(), + requiredPathPatterns: filesScope.RequiredPathPatterns); var generatedFileCountExcluded = options.IncludeGenerated ? 0 : !reader.GeneratedFileFilterAvailable @@ -90,7 +91,8 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) pathPatterns: filesScope.PathPatterns, excludePathPatterns: filesScope.ExcludePaths, excludeTests: filesScope.ExcludeTests, - generatedOnly: true).Count; + generatedOnly: true, + requiredPathPatterns: filesScope.RequiredPathPatterns).Count; WorkspaceMetadataEnricher.Enrich(map, options.DbPath, options.DbPathExplicit); var compactTruncation = options.Compact ? ApplyRepoMapCompactCaps(map, compactLimit, options) : null; @@ -266,6 +268,8 @@ private static JsonObject BuildRepoMapJsonPayload( options, generatedFileCountExcluded, generatedFileFilterAvailable); + if (options.DiscoveryBaselineIncludePaths.Count > 0 || options.DiscoveryBaselineExcludePaths.Count > 0) + payload["query_context"] = BuildQueryContextJson(options, jsonOptions); if (options.MapSummaryOnly) { KeepRepoMapJsonProperties(payload, RepoMapSummaryJsonProperties); @@ -582,6 +586,7 @@ private static string BuildRepoMapIssueDraftBody(RepoFileSummaryResult file, Jso "worktree_head_changed", "head_freshness", "graph_table_available", + "query_context", }; private static readonly IReadOnlyDictionary RepoMapSectionJsonProperties = new Dictionary(StringComparer.Ordinal) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs b/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs index 1d433393b..2750b6e6b 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs @@ -246,6 +246,8 @@ private static JsonObject BuildQueryContextJson(QueryCommandOptions options, Jso query["sort"] = options.SymbolSortMode.ToString().ToLowerInvariant(); if (options.ExcludeTests) query["exclude_tests"] = true; + if (options.DiscoveryBaselineIncludePaths.Count > 0 || options.DiscoveryBaselineExcludePaths.Count > 0) + query["effective_path_scope"] = BuildEffectiveDiscoveryPathScopeJson(options, jsonOptions); if (options.ExcludeComments) query["exclude_comments"] = true; if (options.ExcludeStrings) @@ -321,6 +323,55 @@ private static JsonObject BuildQueryContextJson(QueryCommandOptions options, Jso return query; } + private static JsonObject BuildEffectiveDiscoveryPathScopeJson( + QueryCommandOptions options, + JsonSerializerOptions jsonOptions) + { + var context = CliJsonSerializerContextFactory.Create(jsonOptions); + var includeGroups = new JsonArray + { + new JsonObject + { + ["origin"] = "implicit_source_baseline", + ["patterns"] = JsonSerializer.SerializeToNode(options.DiscoveryBaselineIncludePaths.ToList(), context.ListString), + }, + }; + if (options.PathPatterns.Count > 0) + { + includeGroups.Add(new JsonObject + { + ["origin"] = "explicit_cli", + ["patterns"] = JsonSerializer.SerializeToNode(options.PathPatterns, context.ListString), + }); + } + + var excludeGroups = new JsonArray + { + new JsonObject + { + ["origin"] = "implicit_source_baseline", + ["patterns"] = JsonSerializer.SerializeToNode(options.DiscoveryBaselineExcludePaths.ToList(), context.ListString), + }, + }; + if (options.ExcludePaths.Count > 0) + { + excludeGroups.Add(new JsonObject + { + ["origin"] = "explicit_cli", + ["patterns"] = JsonSerializer.SerializeToNode(options.ExcludePaths, context.ListString), + }); + } + + return new JsonObject + { + ["include_group_operator"] = "and", + ["patterns_within_include_group_operator"] = "or", + ["include_groups"] = includeGroups, + ["exclude_group_operator"] = "or", + ["exclude_groups"] = excludeGroups, + }; + } + private static void AddReferenceRankingQueryContextJson( JsonObject payload, QueryCommandOptions options, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 1065556fb..1a5db743e 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -139,6 +139,8 @@ public sealed class QueryCommandOptions public string? SolutionFilter { get; init; } public List ExcludePaths { get; init; } = []; public bool ExcludeTests { get; init; } + internal IReadOnlyList DiscoveryBaselineIncludePaths { get; set; } = []; + internal IReadOnlyList DiscoveryBaselineExcludePaths { get; set; } = []; public bool IncludeGenerated { get; init; } public bool CountOnly { get; init; } public bool GroupPartials { get; init; } diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 25397ce83..b3216edbc 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -31,24 +31,6 @@ internal static class SearchAuditRecipes private const int MaxRecipeDiagnosticCount = 64; private const int MaxRecipeDiagnosticLength = 512; private static readonly string[] SupportedQuerySeverities = ["info", "low", "medium", "high", "critical"]; - private static readonly string[] DefaultSourcePathPatternsValue = ["src/**"]; - private static readonly string[] DefaultSourceExcludePathsValue = - [ - "src/CodeIndex/Cli/SearchAuditRecipes.cs", - "tests/**", - "docs/**", - "CHANGELOG.md", - "changelog.d/**", - "README.md", - "USER_GUIDE.md", - "DEVELOPER_GUIDE.md", - "TESTING_GUIDE.md", - "AGENT_GUIDE.md", - ".agent_harness/**", - ".claude/**", - ".codex/**", - ".github/**" - ]; private static readonly string[] DefaultExecutableExcludeOriginsValue = [SearchMatchClassifier.HelpText, SearchMatchClassifier.SchemaDescription]; private static readonly SearchRecipeBroadCatchTaxonomyJsonResult BroadExceptionCatchTaxonomy = new( @@ -180,8 +162,8 @@ internal static class SearchAuditRecipes ], "Classify nullable returns by domain before changing behavior. Optional lookup and parse-miss nulls can remain when callers branch explicitly; unsupported capabilities and legacy schema absence need stable diagnostics or documented fallbacks at user-facing boundaries; invariant violations should not be nullable contracts. For null-forgiving suppressions, require nearby tests or contract evidence for reflection/serialization, delayed initialization, or false-state Try* sentinels."); - internal static IReadOnlyList DefaultSourcePathPatterns => DefaultSourcePathPatternsValue; - internal static IReadOnlyList DefaultSourceExcludePaths => DefaultSourceExcludePathsValue; + internal static IReadOnlyList DefaultSourcePathPatterns => SourceScopeDefaults.IncludePaths; + internal static IReadOnlyList DefaultSourceExcludePaths => SourceScopeDefaults.ExcludePaths; private static readonly SearchRecipeClassifierJsonResult SourceOriginClassifier = new( "source_origin", @@ -3514,8 +3496,8 @@ private static SearchAuditRecipeQuery TimestampBoundaryQuery( ["audit", "bug"], "False positives include required event handlers, framework callbacks, and intentionally fire-and-forget boundaries.") { - PathPatterns = [.. DefaultSourcePathPatternsValue], - ExcludePaths = [.. DefaultSourceExcludePathsValue], + PathPatterns = [.. SourceScopeDefaults.IncludePaths], + ExcludePaths = [.. SourceScopeDefaults.ExcludePaths], MatchOrigins = ["code"], RiskEvidence = [ @@ -3530,8 +3512,8 @@ private static SearchAuditRecipeQuery TimestampBoundaryQuery( ["audit", "bug"], "False positives include top-level compatibility shims and temporary placeholders already tracked for typed exception cleanup.") { - PathPatterns = [.. DefaultSourcePathPatternsValue], - ExcludePaths = [.. DefaultSourceExcludePathsValue], + PathPatterns = [.. SourceScopeDefaults.IncludePaths], + ExcludePaths = [.. SourceScopeDefaults.ExcludePaths], MatchOrigins = ["code"], RiskEvidence = [ @@ -3546,8 +3528,8 @@ private static SearchAuditRecipeQuery TimestampBoundaryQuery( ["audit", "bug"], "False positives include DTO, command-result, parse-result, and search-result property access; prioritize hits whose receiver is Task or ValueTask.") { - PathPatterns = [.. DefaultSourcePathPatternsValue], - ExcludePaths = [.. DefaultSourceExcludePathsValue], + PathPatterns = [.. SourceScopeDefaults.IncludePaths], + ExcludePaths = [.. SourceScopeDefaults.ExcludePaths], MatchOrigins = ["code"], ResultKinds = ["identifier"], RiskEvidence = @@ -3564,8 +3546,8 @@ private static SearchAuditRecipeQuery TimestampBoundaryQuery( ["audit", "security"], "False positives include comments about unsafe APIs and safe-handle names; code-origin matches should be reviewed for pointer and buffer safety.") { - PathPatterns = [.. DefaultSourcePathPatternsValue], - ExcludePaths = [.. DefaultSourceExcludePathsValue], + PathPatterns = [.. SourceScopeDefaults.IncludePaths], + ExcludePaths = [.. SourceScopeDefaults.ExcludePaths], MatchOrigins = ["code"], ResultKinds = ["identifier"], RiskEvidence = @@ -3598,8 +3580,8 @@ private static SearchAuditRecipeQuery TimestampBoundaryQuery( ["audit", "performance"], "False positives include bounded helpers or tiny trusted files; prefer this call-site query when bare ReadAllText is noisy.") { - PathPatterns = [.. DefaultSourcePathPatternsValue], - ExcludePaths = [.. DefaultSourceExcludePathsValue], + PathPatterns = [.. SourceScopeDefaults.IncludePaths], + ExcludePaths = [.. SourceScopeDefaults.ExcludePaths], MatchOrigins = ["code"], ResultKinds = ["call_site"], RiskEvidence = @@ -3650,8 +3632,8 @@ private static SearchAuditRecipeQuery TimestampBoundaryQuery( "False positives include intentionally tracked follow-up markers; broad TODO inventory should be requested separately when docs and tests are in scope.") { Severity = "info", - PathPatterns = [.. DefaultSourcePathPatternsValue], - ExcludePaths = [.. DefaultSourceExcludePathsValue], + PathPatterns = [.. SourceScopeDefaults.IncludePaths], + ExcludePaths = [.. SourceScopeDefaults.ExcludePaths], MatchOrigins = ["comment"], ResultKinds = ["comment"], RiskEvidence = @@ -3667,8 +3649,8 @@ private static SearchAuditRecipeQuery TimestampBoundaryQuery( ["audit", "bug"], "False positives include compatibility shims and deliberate API lifecycle annotations; prioritize call sites or declarations that affect runtime paths.") { - PathPatterns = [.. DefaultSourcePathPatternsValue], - ExcludePaths = [.. DefaultSourceExcludePathsValue], + PathPatterns = [.. SourceScopeDefaults.IncludePaths], + ExcludePaths = [.. SourceScopeDefaults.ExcludePaths], MatchOrigins = ["code"], ResultKinds = ["identifier"], RiskEvidence = @@ -3739,8 +3721,8 @@ private static SearchAuditRecipe SourceScopedRecipe( List queries, IReadOnlyList? defaultExcludeOrigins = null) => new(name, description, ApplyDefaultQueryExcludeOrigins(queries, defaultExcludeOrigins)) { - DefaultPathPatterns = [.. DefaultSourcePathPatternsValue], - DefaultExcludePaths = [.. DefaultSourceExcludePathsValue], + DefaultPathPatterns = [.. SourceScopeDefaults.IncludePaths], + DefaultExcludePaths = [.. SourceScopeDefaults.ExcludePaths], }; private static List ApplyDefaultQueryExcludeOrigins( diff --git a/src/CodeIndex/Cli/SourceScopeDefaults.cs b/src/CodeIndex/Cli/SourceScopeDefaults.cs new file mode 100644 index 000000000..831626029 --- /dev/null +++ b/src/CodeIndex/Cli/SourceScopeDefaults.cs @@ -0,0 +1,26 @@ +namespace CodeIndex.Cli; + +internal static class SourceScopeDefaults +{ + private static readonly string[] IncludePathsValue = ["src/**"]; + private static readonly string[] ExcludePathsValue = + [ + "src/CodeIndex/Cli/SearchAuditRecipes.cs", + "tests/**", + "docs/**", + "CHANGELOG.md", + "changelog.d/**", + "README.md", + "USER_GUIDE.md", + "DEVELOPER_GUIDE.md", + "TESTING_GUIDE.md", + "AGENT_GUIDE.md", + ".agent_harness/**", + ".claude/**", + ".codex/**", + ".github/**" + ]; + + internal static IReadOnlyList IncludePaths => IncludePathsValue; + internal static IReadOnlyList ExcludePaths => ExcludePathsValue; +} diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index e9c1d6954..f654121f0 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -610,7 +610,7 @@ GROUP BY s.file_id /// Delegate to RepoMapBuilder for repo-level overview generation. /// RepoMapBuilderに委譲してリポジトリ俯瞰情報を生成する。 /// - public RepoMapResult GetRepoMap(int limit = 10, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, double minEntrypointConfidence = 0, int? moduleDepth = null, int? oversizedLineThreshold = null, long? oversizedByteThreshold = null, int offset = 0, string? requestedCollection = null, bool summaryProjection = false) + public RepoMapResult GetRepoMap(int limit = 10, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, double minEntrypointConfidence = 0, int? moduleDepth = null, int? oversizedLineThreshold = null, long? oversizedByteThreshold = null, int offset = 0, string? requestedCollection = null, bool summaryProjection = false, IReadOnlyList? requiredPathPatterns = null) { var builder = new RepoMapBuilder(_conn, _fileColumns, _hasReferencesTable, GetIndexedPathComparer); return builder.Build( @@ -626,7 +626,8 @@ public RepoMapResult GetRepoMap(int limit = 10, string? lang = null, IReadOnlyLi oversizedByteThreshold, offset, requestedCollection, - summaryProjection); + summaryProjection, + requiredPathPatterns); } private long ExecuteScalar(string sql) diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index 05d3ca101..c21e4d021 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -2056,7 +2056,7 @@ LIMIT 1 /// List indexed files, optionally filtered by name pattern and language. /// インデックス済みファイルを一覧(名前パターン・言語でフィルタ可能)。 /// - public List ListFiles(string? query = null, int limit = 20, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool orderBySize = false, int offset = 0) + public List ListFiles(string? query = null, int limit = 20, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool orderBySize = false, int offset = 0, IReadOnlyList? requiredPathPatterns = null) { if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be non-negative."); @@ -2083,6 +2083,7 @@ FROM files f if (since != null && _fileColumns.Contains("modified")) sql += " AND f.modified >= @since"; AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); + AppendAdditionalPathIncludeFilters(ref sql, requiredPathPatterns, "requiredPathPattern"); sql += $" ORDER BY {orderSql} LIMIT @limit OFFSET @offset"; sql += $@" @@ -2111,6 +2112,7 @@ GROUP BY s.file_id if (since != null && _fileColumns.Contains("modified")) SqliteCommandPolicy.Add(cmd, "@since", since.Value); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); + AddPathIncludeFilterParameters(cmd, requiredPathPatterns, "requiredPathPattern"); SqliteCommandPolicy.Add(cmd, "@limit", limit); SqliteCommandPolicy.Add(cmd, "@offset", offset); @@ -2151,7 +2153,7 @@ ORDER BY f.path yield return new IndexedFileSnapshot(reader.GetString(0), GetNullableString(reader, 1), GetNullableInt32(reader, 2)); } - public QueryCountResult CountListFiles(string? query = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool generatedOnly = false) + public QueryCountResult CountListFiles(string? query = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool generatedOnly = false, IReadOnlyList? requiredPathPatterns = null) { lang = NormalizeQueryLanguage(lang); using var cmd = _conn.CreateCommand(); @@ -2171,6 +2173,7 @@ FROM files f if (generatedOnly) sql += _fileColumns.Contains("generated") ? " AND COALESCE(f.generated, 0) = 1" : " AND 1=0"; AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests, applyGeneratedFilter: !generatedOnly); + AppendAdditionalPathIncludeFilters(ref sql, requiredPathPatterns, "requiredPathPattern"); sql += @" ) SELECT COUNT(*), @@ -2189,6 +2192,7 @@ SELECT COUNT(*), if (since != null && _fileColumns.Contains("modified")) SqliteCommandPolicy.Add(cmd, "@since", since.Value); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); + AddPathIncludeFilterParameters(cmd, requiredPathPatterns, "requiredPathPattern"); return ExecuteFileCountSummary(cmd); } @@ -2263,7 +2267,7 @@ internal static void AddPathFilterParameters(SqliteCommand cmd, IReadOnlyList? pathPatterns, string parameterPrefix) + internal static void AppendAdditionalPathIncludeFilters(ref string sql, IReadOnlyList? pathPatterns, string parameterPrefix) { if (pathPatterns == null || pathPatterns.Count == 0) return; @@ -2275,7 +2279,7 @@ private static void AppendAdditionalPathIncludeFilters(ref string sql, IReadOnly sql += " AND (" + string.Join(" OR ", ors) + ")"; } - private static void AddPathIncludeFilterParameters(SqliteCommand cmd, IReadOnlyList? pathPatterns, string parameterPrefix) + internal static void AddPathIncludeFilterParameters(SqliteCommand cmd, IReadOnlyList? pathPatterns, string parameterPrefix) { if (pathPatterns == null) return; diff --git a/src/CodeIndex/Database/RepoMapBuilder.cs b/src/CodeIndex/Database/RepoMapBuilder.cs index 54ac89d52..0bced3feb 100644 --- a/src/CodeIndex/Database/RepoMapBuilder.cs +++ b/src/CodeIndex/Database/RepoMapBuilder.cs @@ -84,7 +84,8 @@ public RepoMapResult Build(int limit, string? lang, IReadOnlyList? pathP IReadOnlyList? excludePathPatterns, bool excludeTests, double minEntrypointConfidence, Func<(DateTime? IndexedAt, DateTime? LatestModified)> getFreshness, int? moduleDepth = null, int? oversizedLineThreshold = null, long? oversizedByteThreshold = null, - int offset = 0, string? requestedCollection = null, bool summaryProjection = false) + int offset = 0, string? requestedCollection = null, bool summaryProjection = false, + IReadOnlyList? requiredPathPatterns = null) { offset = Math.Max(0, offset); var retainedLimit = checked(Math.Max(limit, 0) + offset); @@ -116,7 +117,7 @@ public RepoMapResult Build(int limit, string? lang, IReadOnlyList? pathP var indexedPathComparer = _getIndexedPathComparer(); var javaModuleDescriptors = includeModules ? LoadJavaModuleDescriptors() : new Dictionary(StringComparer.Ordinal); var aggregate = BuildAggregate( - EnumerateFileStats(lang, pathPatterns, excludePathPatterns, excludeTests), + EnumerateFileStats(lang, pathPatterns, excludePathPatterns, excludeTests, requiredPathPatterns), retainedLimit, javaModuleDescriptors, moduleDepth, @@ -133,10 +134,10 @@ public RepoMapResult Build(int limit, string? lang, IReadOnlyList? pathP var indexedHeadSnapshot = LoadIndexedHeadSnapshot(); HeadMetadataCapturedForTesting.Value?.Invoke(); var entrypointPage = includeEntrypoints - ? GetEntrypoints(aggregate.EntrypointFallbacks, limit, offset, lang, pathPatterns, excludePathPatterns, excludeTests, minEntrypointConfidence, indexedPathComparer) + ? GetEntrypoints(aggregate.EntrypointFallbacks, limit, offset, lang, pathPatterns, excludePathPatterns, excludeTests, minEntrypointConfidence, indexedPathComparer, requiredPathPatterns) : (Results: new List(), TotalCount: 0); var rankedFilePage = useRankedFilePage - ? GetRankedFilePage(requestedCollection!, limit, offset, lang, pathPatterns, excludePathPatterns, excludeTests) + ? GetRankedFilePage(requestedCollection!, limit, offset, lang, pathPatterns, excludePathPatterns, excludeTests, requiredPathPatterns) : null; var result = new RepoMapResult { @@ -173,7 +174,7 @@ private static bool IncludesMapCollection(string? requestedCollection, bool summ && (requestedCollection is null || string.Equals(requestedCollection, collection, StringComparison.Ordinal)); private IEnumerable EnumerateFileStats(string? lang, IReadOnlyList? pathPatterns, - IReadOnlyList? excludePathPatterns, bool excludeTests) + IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? requiredPathPatterns) { using var cmd = _conn.CreateCommand(); var refCountExpr = _hasReferencesTable @@ -192,12 +193,14 @@ FROM files f if (lang != null) sql += " AND f.lang = @lang"; DbReader.AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); + DbReader.AppendAdditionalPathIncludeFilters(ref sql, requiredPathPatterns, "requiredPathPattern"); sql += " ORDER BY f.path"; cmd.CommandText = sql; if (lang != null) SqliteCommandPolicy.Add(cmd, "@lang", lang); DbReader.AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); + DbReader.AddPathIncludeFilterParameters(cmd, requiredPathPatterns, "requiredPathPattern"); using var reader = cmd.ExecuteTrackedReader(); while (reader.TrackedRead()) @@ -224,7 +227,8 @@ private List GetRankedFilePage( string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, - bool excludeTests) + bool excludeTests, + IReadOnlyList? requiredPathPatterns) { using var cmd = _conn.CreateCommand(); var refCountExpr = _hasReferencesTable @@ -243,6 +247,7 @@ FROM files f if (lang != null) sql += " AND f.lang = @lang"; DbReader.AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); + DbReader.AppendAdditionalPathIncludeFilters(ref sql, requiredPathPatterns, "requiredPathPattern"); sql += ") SELECT path, lang, size, lines, symbol_count, reference_count FROM ranked_files ORDER BY "; sql += collection switch { @@ -258,6 +263,7 @@ FROM files f if (lang != null) SqliteCommandPolicy.Add(cmd, "@lang", lang); DbReader.AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); + DbReader.AddPathIncludeFilterParameters(cmd, requiredPathPatterns, "requiredPathPattern"); SqliteCommandPolicy.Add(cmd, "@limit", Math.Max(0, limit)); SqliteCommandPolicy.Add(cmd, "@offset", Math.Max(0, offset)); @@ -556,7 +562,7 @@ FROM files f private (List Results, int TotalCount) GetEntrypoints(IReadOnlyList fallbackEntrypoints, int limit, int offset, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, - double minConfidence, StringComparer indexedPathComparer) + double minConfidence, StringComparer indexedPathComparer, IReadOnlyList? requiredPathPatterns) { using var cmd = _conn.CreateCommand(); var sql = @" @@ -568,12 +574,14 @@ FROM symbols s if (lang != null) sql += " AND f.lang = @lang"; DbReader.AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); + DbReader.AppendAdditionalPathIncludeFilters(ref sql, requiredPathPatterns, "requiredPathPattern"); sql += " ORDER BY f.path, s.line"; cmd.CommandText = sql; if (lang != null) SqliteCommandPolicy.Add(cmd, "@lang", lang); DbReader.AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); + DbReader.AddPathIncludeFilterParameters(cmd, requiredPathPatterns, "requiredPathPattern"); var results = new List(); using var reader = cmd.ExecuteTrackedReader(); diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 20ae7b333..8a41c5e55 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -5949,6 +5949,34 @@ public void ListFiles_MultiplePathPatterns_AreOred() Assert.Contains("docs/notes.md", paths); } + [Fact] + public void ListFiles_RequiredPathPatternsIntersectUserIncludesForRowsCountsAndMap_Issue5229() + { + InsertIndexedFile("src/issue5229/App.cs", "csharp", "class App5229 {}\n"); + InsertIndexedFile("src/issue5229/Nested/Worker.cs", "csharp", "class Worker5229 {}\n"); + InsertIndexedFile("tools/issue5229/Tool.cs", "csharp", "class Tool5229 {}\n"); + + var userIncludes = new[] { "src/issue5229/**", "tools/issue5229/**" }; + var requiredIncludes = new[] { "SRC/**" }; + var excludePaths = new[] { "src/issue5229/Nested/**" }; + var rows = _reader.ListFiles( + pathPatterns: userIncludes, + excludePathPatterns: excludePaths, + requiredPathPatterns: requiredIncludes); + var counts = _reader.CountListFiles( + pathPatterns: userIncludes, + excludePathPatterns: excludePaths, + requiredPathPatterns: requiredIncludes); + var map = _reader.GetRepoMap( + pathPatterns: userIncludes, + excludePathPatterns: excludePaths, + requiredPathPatterns: requiredIncludes); + + Assert.Equal(["src/issue5229/App.cs"], rows.Select(result => result.Path)); + Assert.Equal(rows.Count, counts.Count); + Assert.Equal(rows.Count, map.FileCount); + } + [Fact] public void ListFiles_PathFiltersAndExcludePaths_WorkTogether() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs index f40c2d31f..7bae9273c 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs @@ -4567,53 +4567,122 @@ public void RunFiles_FormatCompactMaxJsonBytesTruncatesRowsWithMetadata_Issue416 } [Fact] - public void RunFilesAndMap_ExcludeTestsApplyTheSameSourceScope_Issues3918_4754() + public void RunFilesAndMap_ExcludeTestsKeepExplicitPathsInsideSourceBaseline_Issues3918_4754_5229() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_files_exclude_tests_source_3918"); try { var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); TestProjectHelper.InsertIndexedFile(dbPath, ".agent_harness/command_guard_core.py", "python", "def guard_harness():\n pass\n"); - TestProjectHelper.InsertIndexedFile(dbPath, ".claude/hooks/bash-guard.py", "python", "def bash_guard():\n pass\n"); TestProjectHelper.InsertIndexedFile(dbPath, "src/App.cs", "csharp", "class App {}\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Feature/Other.cs", "csharp", "class Other {}\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Nested/Worker.cs", "csharp", "class Worker {}\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Generated.g.cs", "csharp", "class Generated {}\n", isGenerated: true); + TestProjectHelper.InsertIndexedFile(dbPath, "src/CodeIndex/Cli/SearchAuditRecipes.cs", "csharp", "class AuditRecipes {}\n"); TestProjectHelper.InsertIndexedFile(dbPath, "tests/AppTests.cs", "csharp", "class AppTests {}\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "tools/Tool.cs", "csharp", "class Tool {}\n"); - var scopes = new[] - { - (AdditionalArgs: Array.Empty(), ExpectedPath: "src/App.cs"), - (AdditionalArgs: new[] { "--path", ".agent_harness/**" }, ExpectedPath: ".agent_harness/command_guard_core.py") - }; - - foreach (var scope in scopes) + HashSet RunFiles(params string[] additionalArgs) { var filesArgs = new[] { "--db", dbPath, "--json=array", "--exclude-tests", "--limit", "10" } - .Concat(scope.AdditionalArgs) + .Concat(additionalArgs) .ToArray(); var (filesExitCode, filesStdout, filesStderr) = CaptureConsole(() => QueryCommandRunner.RunFiles( filesArgs, _jsonOptions)); using var filesDocument = ParseJsonOutput(filesStdout); - var files = filesDocument.RootElement.EnumerateArray().ToArray(); - var file = Assert.Single(files); - Assert.Equal(CommandExitCodes.Success, filesExitCode); Assert.Equal(string.Empty, filesStderr); - Assert.Equal(scope.ExpectedPath, file.GetProperty("path").GetString()); + return filesDocument.RootElement + .EnumerateArray() + .Select(file => file.GetProperty("path").GetString()!) + .ToHashSet(StringComparer.Ordinal); + } - var mapArgs = new[] { "--db", dbPath, "--json", "--sections", "summary", "--exclude-tests" } - .Concat(scope.AdditionalArgs) + JsonElement RunFilesCount(params string[] additionalArgs) + { + var countArgs = new[] { "--db", dbPath, "--json", "--count", "--exclude-tests" } + .Concat(additionalArgs) .ToArray(); - var (mapExitCode, mapStdout, mapStderr) = CaptureConsole(() => QueryCommandRunner.RunMap( - mapArgs, + var (countExitCode, countStdout, countStderr) = CaptureConsole(() => QueryCommandRunner.RunFiles( + countArgs, _jsonOptions)); + using var countDocument = ParseJsonOutput(countStdout); - using var mapDocument = ParseJsonOutput(mapStdout); + Assert.Equal(CommandExitCodes.Success, countExitCode); + Assert.Equal(string.Empty, countStderr); + return countDocument.RootElement.Clone(); + } - Assert.Equal(CommandExitCodes.Success, mapExitCode); - Assert.Equal(string.Empty, mapStderr); - Assert.Equal(files.Length, mapDocument.RootElement.GetProperty("file_count").GetInt32()); + var baseline = RunFiles(); + var broad = RunFiles("--path", "**"); + var sourceBroad = RunFiles("--path", "src/**"); + var narrow = RunFiles("--path", "src/Nested/**"); + var multiple = RunFiles("--path", "src/App.cs", "--path", "src/Feature/**"); + var explicitlyExcluded = RunFiles("--path", "src/**", "--exclude-path", "src/Feature/**"); + var caseVariant = RunFiles("--path", "SRC/NESTED/**"); + var generatedIncluded = RunFiles("--include-generated"); + + Assert.Equal( + new[] { "src/App.cs", "src/Feature/Other.cs", "src/Nested/Worker.cs" }, + baseline.OrderBy(path => path, StringComparer.Ordinal)); + Assert.True(broad.IsSubsetOf(baseline)); + Assert.True(sourceBroad.IsSubsetOf(baseline)); + Assert.Equal(baseline, broad); + Assert.Equal(baseline, sourceBroad); + Assert.Equal(["src/Nested/Worker.cs"], narrow); + Assert.Equal(["src/App.cs", "src/Feature/Other.cs"], multiple); + Assert.Equal(["src/App.cs", "src/Nested/Worker.cs"], explicitlyExcluded); + Assert.Equal(narrow, caseVariant); + Assert.Contains("src/Generated.g.cs", generatedIncluded); + Assert.DoesNotContain("src/CodeIndex/Cli/SearchAuditRecipes.cs", generatedIncluded); + Assert.DoesNotContain("tests/AppTests.cs", generatedIncluded); + Assert.DoesNotContain("tools/Tool.cs", generatedIncluded); + Assert.DoesNotContain(".agent_harness/command_guard_core.py", generatedIncluded); + + foreach (var scope in new[] + { + (Paths: baseline, Args: Array.Empty()), + (Paths: broad, Args: new[] { "--path", "**" }), + (Paths: narrow, Args: new[] { "--path", "src/Nested/**" }), + (Paths: multiple, Args: new[] { "--path", "src/App.cs", "--path", "src/Feature/**" }), + (Paths: explicitlyExcluded, Args: new[] { "--path", "src/**", "--exclude-path", "src/Feature/**" }), + }) + { + Assert.Equal(scope.Paths.Count, RunFilesCount(scope.Args).GetProperty("count").GetInt32()); } + + var explicitExcludeCount = RunFilesCount("--path", "src/**", "--exclude-path", "src/Feature/**"); + var effectiveScope = explicitExcludeCount + .GetProperty("query_context") + .GetProperty("effective_path_scope"); + Assert.Equal("and", effectiveScope.GetProperty("include_group_operator").GetString()); + Assert.Equal("or", effectiveScope.GetProperty("patterns_within_include_group_operator").GetString()); + Assert.Equal("or", effectiveScope.GetProperty("exclude_group_operator").GetString()); + var includeGroups = effectiveScope.GetProperty("include_groups").EnumerateArray().ToArray(); + var excludeGroups = effectiveScope.GetProperty("exclude_groups").EnumerateArray().ToArray(); + Assert.Equal(["implicit_source_baseline", "explicit_cli"], includeGroups.Select(group => group.GetProperty("origin").GetString())); + Assert.Equal(["src/**"], includeGroups[0].GetProperty("patterns").EnumerateArray().Select(item => item.GetString())); + Assert.Equal(["src/**"], includeGroups[1].GetProperty("patterns").EnumerateArray().Select(item => item.GetString())); + Assert.Equal(["implicit_source_baseline", "explicit_cli"], excludeGroups.Select(group => group.GetProperty("origin").GetString())); + Assert.Contains("src/CodeIndex/Cli/SearchAuditRecipes.cs", excludeGroups[0].GetProperty("patterns").EnumerateArray().Select(item => item.GetString())); + Assert.Equal(["src/Feature/**"], excludeGroups[1].GetProperty("patterns").EnumerateArray().Select(item => item.GetString())); + + var (mapExitCode, mapStdout, mapStderr) = CaptureConsole(() => QueryCommandRunner.RunMap( + ["--db", dbPath, "--json", "--sections", "summary", "--exclude-tests", "--path", "src/Nested/**"], + _jsonOptions)); + using var mapDocument = ParseJsonOutput(mapStdout); + Assert.Equal(CommandExitCodes.Success, mapExitCode); + Assert.Equal(string.Empty, mapStderr); + Assert.Equal(narrow.Count, mapDocument.RootElement.GetProperty("file_count").GetInt32()); + Assert.Equal( + "and", + mapDocument.RootElement + .GetProperty("query_context") + .GetProperty("effective_path_scope") + .GetProperty("include_group_operator") + .GetString()); } finally { From a3d563e7a240fe4fbccf633f6b20e84ef1923d6b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 2 Sep 2026 10:29:49 +0900 Subject: [PATCH 2/2] Enforce composed path parameter budget (#5229) --- src/CodeIndex/Database/DbReader.cs | 13 +++++++++++-- src/CodeIndex/Database/RepoMapBuilder.cs | 1 + tests/CodeIndex.Tests/DbReaderTests.cs | 15 +++++++++++++++ tests/CodeIndex.Tests/SqliteDynamicSqlTests.cs | 7 ++++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index ac6205e27..20b6833cb 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -2062,6 +2062,7 @@ public List ListFiles(string? query = null, int limit = 20, string? { if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be non-negative."); + EnsurePathFilterParameterBudget(pathPatterns, excludePathPatterns, requiredPathPatterns); lang = NormalizeQueryLanguage(lang); using var cmd = _conn.CreateCommand(); @@ -2157,6 +2158,7 @@ ORDER BY f.path public QueryCountResult CountListFiles(string? query = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool generatedOnly = false, IReadOnlyList? requiredPathPatterns = null) { + EnsurePathFilterParameterBudget(pathPatterns, excludePathPatterns, requiredPathPatterns); lang = NormalizeQueryLanguage(lang); using var cmd = _conn.CreateCommand(); @@ -2339,8 +2341,15 @@ internal static void AddPathFilterParameterSet(SqliteCommand cmd, string paramet } } - private static void EnsurePathFilterParameterBudget(IReadOnlyCollection? pathPatterns, IReadOnlyCollection? excludePathPatterns) - => SqliteDynamicSql.EnsureParameterBudget(CountPathFilterParameters(pathPatterns) + CountPathFilterParameters(excludePathPatterns), "path filters"); + internal static void EnsurePathFilterParameterBudget( + IReadOnlyCollection? pathPatterns, + IReadOnlyCollection? excludePathPatterns, + IReadOnlyCollection? requiredPathPatterns = null) + => SqliteDynamicSql.EnsureParameterBudget( + CountPathFilterParameters(pathPatterns) + + CountPathFilterParameters(excludePathPatterns) + + CountPathFilterParameters(requiredPathPatterns), + "path filters"); private static int CountPathFilterParameters(IReadOnlyCollection? pathPatterns) => pathPatterns?.Count ?? 0; diff --git a/src/CodeIndex/Database/RepoMapBuilder.cs b/src/CodeIndex/Database/RepoMapBuilder.cs index 0bced3feb..c497ccfdd 100644 --- a/src/CodeIndex/Database/RepoMapBuilder.cs +++ b/src/CodeIndex/Database/RepoMapBuilder.cs @@ -87,6 +87,7 @@ public RepoMapResult Build(int limit, string? lang, IReadOnlyList? pathP int offset = 0, string? requestedCollection = null, bool summaryProjection = false, IReadOnlyList? requiredPathPatterns = null) { + DbReader.EnsurePathFilterParameterBudget(pathPatterns, excludePathPatterns, requiredPathPatterns); offset = Math.Max(0, offset); var retainedLimit = checked(Math.Max(limit, 0) + offset); var includeLanguages = IncludesMapCollection(requestedCollection, summaryProjection, "languages"); diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 8a41c5e55..bab383af2 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -5975,6 +5975,21 @@ public void ListFiles_RequiredPathPatternsIntersectUserIncludesForRowsCountsAndM Assert.Equal(["src/issue5229/App.cs"], rows.Select(result => result.Path)); Assert.Equal(rows.Count, counts.Count); Assert.Equal(rows.Count, map.FileCount); + + var largeIncludes = Enumerable.Range(0, 500).Select(i => $"src/{i}.cs").ToList(); + var largeExcludes = Enumerable.Range(0, 499).Select(i => $"tests/{i}.cs").ToList(); + Assert.Throws(() => _reader.ListFiles( + pathPatterns: largeIncludes, + excludePathPatterns: largeExcludes, + requiredPathPatterns: requiredIncludes)); + Assert.Throws(() => _reader.CountListFiles( + pathPatterns: largeIncludes, + excludePathPatterns: largeExcludes, + requiredPathPatterns: requiredIncludes)); + Assert.Throws(() => _reader.GetRepoMap( + pathPatterns: largeIncludes, + excludePathPatterns: largeExcludes, + requiredPathPatterns: requiredIncludes)); } [Fact] diff --git a/tests/CodeIndex.Tests/SqliteDynamicSqlTests.cs b/tests/CodeIndex.Tests/SqliteDynamicSqlTests.cs index c78d844cc..12fa004f3 100644 --- a/tests/CodeIndex.Tests/SqliteDynamicSqlTests.cs +++ b/tests/CodeIndex.Tests/SqliteDynamicSqlTests.cs @@ -45,7 +45,7 @@ public void PathFilterParameters_AllowLargeListNearSqliteVariableBudget_Issue370 } [Fact] - public void PathFilterParameters_RejectCombinedIncludeExcludeOverBudget_Issue3702() + public void PathFilterParameters_RejectCombinedGroupsOverBudget_Issues3702_5229() { var includePatterns = Enumerable.Range(0, 500).Select(i => $"src/{i}.cs").ToList(); var excludePatterns = Enumerable.Range(0, 500).Select(i => $"tests/{i}.cs").ToList(); @@ -55,6 +55,11 @@ public void PathFilterParameters_RejectCombinedIncludeExcludeOverBudget_Issue370 () => DbReader.AppendPathFilters(ref sql, includePatterns, excludePatterns, excludeTests: false)); Assert.Contains("path filters", ex.Message); + var requiredPatterns = new[] { "src/**" }; + var requiredEx = Assert.Throws( + () => DbReader.EnsurePathFilterParameterBudget(includePatterns, excludePatterns.Take(499).ToList(), requiredPatterns)); + + Assert.Contains("path filters", requiredEx.Message); } [Fact]