diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 1575afc67b..b82ef55844 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1480,6 +1480,8 @@ AI agents that query the database directly via SQL need the `sqlite3` CLI. Human-facing output formats file sizes with binary units (`KiB`, `MiB`, `GiB`, ...), so large repositories and `map` / `files` listings are easier to scan. Use `--bytes` on `files` or `map` when you need raw byte counts in the text output for shell pipelines. JSON output (`--json`) always keeps size fields as raw integer bytes for machine consumers. +`map` entrypoint candidates include `match_type`, `confidence` (0.0..1.0), and `hint_rank` alongside the legacy `score`. `match_type` reports whether the candidate matched a conventional file path, a symbol name, or both; `hint_rank` is the 1-based order of the matched language hint. Confidence near `0.8` or higher means a path and symbol/name heuristic agree, around `0.5` means a single weak heuristic matched, and lower values are advisory candidates such as ambiguous repeated names or file-only fallbacks. Use `cdidx map --min-entrypoint-confidence <0.0..1.0>` to suppress weaker entrypoints in both human and JSON output. + CLI JSON (`--json`) and MCP tool responses are both stable integration surfaces, but they are not identical wire envelopes. CLI commands keep CLI-oriented metadata such as `api_version` and command result fields, while MCP tools return JSON-RPC tool results with camelCase field names and may include MCP-specific metadata. Graph tools that group reference rows (`callers`, `callees`, and bundled `analyze_symbol` caller/callee rows) expose a backward-compatible scalar summary kind plus a sorted kind array and mixed-kind flag; CLI JSON uses `reference_kind` / `reference_kinds` / `has_mixed_reference_kinds`, while MCP uses `referenceKind` / `referenceKinds` / `hasMixedReferenceKinds`. Consumers that need every underlying kind should read the array for the surface they call and ignore unknown future fields. See [INTEGRATION_POLICY.md](INTEGRATION_POLICY.md#cli-json-and-mcp-response-compatibility) for the CLI/MCP compatibility table. ## AI Integration @@ -3447,6 +3449,8 @@ AIエージェントがDBを直接SQL検索する場合、`sqlite3` CLIが必要 人間向けの出力では、ファイルサイズを2進単位(`KiB`、`MiB`、`GiB` など)で表示します。大きなリポジトリや `map` / `files` の一覧を読み取りやすくするためです。テキスト出力をシェルパイプラインで扱うなど、生のバイト数が必要な場合は `files` または `map` に `--bytes` を指定してください。JSON 出力(`--json`)では、機械処理向けに size フィールドを常に raw integer bytes のまま返します。 +`map` の entrypoint 候補は、従来の `score` に加えて `match_type`、`confidence`(0.0..1.0)、`hint_rank` を返します。`match_type` は候補が慣例的なファイルパス、シンボル名、またはその両方に一致したかを示し、`hint_rank` は一致した言語別 hint の 1-based 順位です。`0.8` 以上に近い confidence は path と symbol/name heuristic が一致したことを示し、`0.5` 前後は単一の弱い heuristic、さらに低い値は曖昧な重複名や file-only fallback のような参考候補です。弱い entrypoint を human / JSON 出力から除外するには `cdidx map --min-entrypoint-confidence <0.0..1.0>` を指定してください。 + CLI JSON(`--json`)と MCP tool response はどちらも安定した integration surface ですが、wire envelope は同一ではありません。CLI command は `api_version` や command result field など CLI 向けのメタデータを保持し、MCP tool は JSON-RPC tool result と camelCase field name、および MCP 固有のメタデータを返す場合があります。参照行をグループ化する graph tool(`callers`、`callees`、および bundled `analyze_symbol` の caller/callee 行)は、後方互換の scalar summary kind、ソート済み kind array、mixed-kind flag を返します。CLI JSON は `reference_kind` / `reference_kinds` / `has_mixed_reference_kinds`、MCP は `referenceKind` / `referenceKinds` / `hasMixedReferenceKinds` を使います。すべての underlying kind が必要な consumer は、呼び出した surface の array field を読み、将来追加される未知の field は無視してください。CLI/MCP compatibility table は [INTEGRATION_POLICY.md](INTEGRATION_POLICY.md#cli-json-and-mcp-response-compatibility) を参照してください。 遅い検索を調べる場合は、read 系コマンドに `--profile` を追加してください。通常結果の後に `profile.phases`(`name`、`elapsed_ms`、`rows_scanned`)、`profile.query_plan`(`EXPLAIN QUERY PLAN` 行)、`profile.queries`(SQL text)を含む JSON オブジェクトを 1 行追加します。`--slow-query-ms ` を併用すると、閾値以上の profiled SQL を persistent tool log に記録します。 diff --git a/changelog.d/unreleased/1781.fixed.md b/changelog.d/unreleased/1781.fixed.md new file mode 100644 index 0000000000..a6b3259a6b --- /dev/null +++ b/changelog.d/unreleased/1781.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1781 +affected: + - src/CodeIndex/Database/DbContext.cs + - tests/CodeIndex.Tests/DatabaseTests.cs +--- + +## English + +- **Reference context links are cleared instead of dangling after reference-line deletion (#1781)** — `symbol_references.reference_line_id` now uses `ON DELETE SET NULL`, and existing indexes are migrated by nulling already-dangling line-context pointers before rebuilding the table constraint. + +## 日本語 + +- **reference-line 削除後の参照コンテキストリンクが dangling ではなく NULL 化されるようになりました (#1781)** — `symbol_references.reference_line_id` は `ON DELETE SET NULL` を使い、既存 index は既に dangling になっている line-context pointer を NULL 化してからテーブル制約を再構築します。 diff --git a/changelog.d/unreleased/1785.fixed.md b/changelog.d/unreleased/1785.fixed.md new file mode 100644 index 0000000000..94aa64fb03 --- /dev/null +++ b/changelog.d/unreleased/1785.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1785 +affected: + - src/CodeIndex/Database/DbWriter.cs + - tests/CodeIndex.Tests/DatabaseTests.cs +--- + +## English + +- **Stale file purges remove cross-file references to symbols defined only by the purged files (#1785)** — cleanup now drops phantom symbol edges when a deleted file was the only remaining definition for the referenced name. + +## 日本語 + +- **stale file purge が purge 対象ファイルにしか定義が残っていないシンボルへの cross-file reference を削除するようになりました (#1785)** — 削除済みファイルが参照名の唯一の定義だった場合、phantom symbol edge を cleanup で取り除きます。 diff --git a/changelog.d/unreleased/1826.fixed.md b/changelog.d/unreleased/1826.fixed.md new file mode 100644 index 0000000000..2885e9c1fa --- /dev/null +++ b/changelog.d/unreleased/1826.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 1826 +affected: + - src/CodeIndex/Database/DbWriter.cs +--- + +## English + +- **Stale file purges delete file rows in chunked batches (#1826)** — purge paths now issue chunked `DELETE ... IN (...)` statements instead of one delete statement per stale file. + +## 日本語 + +- **stale file purge が file row を chunked batch で削除するようになりました (#1826)** — purge 経路は stale file ごとに個別の DELETE を発行せず、chunked `DELETE ... IN (...)` を使います。 diff --git a/changelog.d/unreleased/2003.added.md b/changelog.d/unreleased/2003.added.md new file mode 100644 index 0000000000..ea54002e31 --- /dev/null +++ b/changelog.d/unreleased/2003.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 2003 +affected: + - src/CodeIndex/Database/RepoMapBuilder.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - USER_GUIDE.md +--- + +## English + +- **Repo map entrypoints now expose confidence metadata (#2003)** — `map` entrypoint candidates now include `match_type`, `confidence`, and `hint_rank`, and `--min-entrypoint-confidence` can filter weaker heuristic matches. + +## 日本語 + +- **repo map の entrypoint が confidence metadata を返すようになりました (#2003)** — `map` の entrypoint 候補に `match_type`、`confidence`、`hint_rank` を追加し、`--min-entrypoint-confidence` で弱い heuristic match を除外できるようにしました。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 595077a477..1f5df9fd5e 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -117,6 +117,7 @@ internal static class CliFlagSchema private static readonly string[] SinceCommands = ["search", "definition", "symbols", "files"]; private static readonly string[] ByteFormatCommands = ["files", "map"]; + private static readonly string[] EntrypointConfidenceCommands = ["map"]; // `--exact` is the legacy shorthand that every name-resolution command accepts. // `--exact` は名前解決系の全コマンドで受け付けるレガシー shorthand。 @@ -214,6 +215,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--strict-not-found", Description = "Return exit code 2 when a valid query has zero rows", Commands = Set(StrictNotFoundCommands) }, new() { Name = "--since", ValuePlaceholder = "", Description = "Filter by modified-since timestamp", Commands = Set(SinceCommands) }, new() { Name = "--bytes", Description = "Show raw byte counts in human output", Commands = Set(ByteFormatCommands) }, + new() { Name = "--min-entrypoint-confidence", ValuePlaceholder = "<0.0..1.0>", Description = "Map: omit entrypoint candidates below this confidence", Commands = Set(EntrypointConfidenceCommands) }, new() { Name = "--query", ValuePlaceholder = "", Description = "Literal query", Commands = Set(QueryCommands) }, new() { Name = "--body", Description = "Include body", Commands = Set(BodyCommands) }, new() { Name = "--exact", Description = "Backward-compatible exact shorthand", Commands = Set(ExactCommands) }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 3a7c115f21..81b34fb23d 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -77,7 +77,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("files", "cdidx files [query|--query |-- ] [--db ] [--json] [--verbose] [--limit ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]"), ("find", "cdidx find --path [--db ] [--json] [--verbose] [--limit ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--max-line-width ] [--exact] [--count]"), ("excerpt", "cdidx excerpt --start [--end ] [--before ] [--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--verbose]"), - ("map", "cdidx map [--db ] [--json] [--verbose] [--limit ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes]"), + ("map", "cdidx map [--db ] [--json] [--verbose] [--limit ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--min-entrypoint-confidence <0.0..1.0>]"), ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--verbose] [--limit ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]"), ("outline", "cdidx outline [--db ] [--json] [--verbose]"), ("status", "cdidx status [--db ] [--json] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config] [--check-updates]"), @@ -753,6 +753,7 @@ void WriteHelpLine(string line = "") Console.WriteLine(" --since Filter to files modified since this timestamp (ISO 8601)"); Console.WriteLine(" --no-dedup search only: return every raw overlapping chunk hit (debug/density)"); Console.WriteLine(" --bytes Show raw byte counts in human output for files/map instead of binary units; JSON always keeps raw integer bytes"); + Console.WriteLine(" --min-entrypoint-confidence map only: omit entrypoint candidates below this 0.0..1.0 confidence"); WriteHelpLine(" --max-hops Max BFS hops for impact analysis, inclusive (default: 5; --max-hops 2 returns callers at hop 1 and 2; --max-hops 0 resolves the symbol without traversing callers)"); Console.WriteLine(" --depth Deprecated alias for --max-hops"); Console.WriteLine(" --reverse Reverse direction for deps (show dependents)"); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index b892c4b9a4..abbb3a7977 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -87,6 +87,7 @@ public static class QueryCommandRunner "--explain", "--rank-by", "--slow-query-ms", + "--min-entrypoint-confidence", ]; private sealed record StatusReadinessField( string FieldName, @@ -1849,7 +1850,7 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) return WithDb(options, jsonOptions, reader => { - var map = reader.GetRepoMap(options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); + var map = reader.GetRepoMap(options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, options.MinEntrypointConfidence); WorkspaceMetadataEnricher.Enrich(map, options.DbPath, options.DbPathExplicit); // Return not-found only when a narrowing filter is active and produces zero files. @@ -1904,7 +1905,7 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) })); WriteRepoMapSection("Symbol-rich files", map.SymbolRichFiles.Select(item => $"{item.Path} [{item.SymbolCount} syms, {item.ReferenceCount} refs]")); WriteRepoMapSection("Reference-rich files", map.ReferenceRichFiles.Select(item => $"{item.Path} [{item.ReferenceCount} refs, {item.SymbolCount} syms]")); - WriteRepoMapSection("Entrypoints", map.Entrypoints.Select(item => $"{item.Kind,-10} {item.Name,-24} {item.Path}:{item.Line} [score {item.Score}]")); + WriteRepoMapSection("Entrypoints", map.Entrypoints.Select(item => $"{item.Kind,-10} {item.Name,-24} {item.Path}:{item.Line} [score {item.Score}, confidence {item.Confidence:0.###}, {item.MatchType}, hint #{item.HintRank}]")); } return CommandExitCodes.Success; @@ -4078,6 +4079,7 @@ public static QueryCommandOptions ParseArgs( bool verbose = false; bool profile = false; int? slowQueryMs = null; + double minEntrypointConfidence = 0; string? statusExplainField = null; bool statusLogPath = false; bool statusConfig = false; @@ -4382,6 +4384,17 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(slowQueryError!); break; + case "--min-entrypoint-confidence": + if (!TryReadRawOptionValue(args, ref i, "--min-entrypoint-confidence", inlineValue, out var minEntrypointConfidenceValue, out var missingMinEntrypointConfidenceError)) + AddParseError(missingMinEntrypointConfidenceError!); + else if (TryParseConfidence(minEntrypointConfidenceValue!, out var parsedMinEntrypointConfidence)) + { + WarnIfDuplicateSingleValueOption("--min-entrypoint-confidence", minEntrypointConfidenceValue!); + minEntrypointConfidence = parsedMinEntrypointConfidence; + } + else + AddParseError($"Error: --min-entrypoint-confidence must be a number from 0.0 through 1.0; got '{minEntrypointConfidenceValue}'."); + break; case "--check": if (allowStatusCheck) { @@ -4723,6 +4736,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) Verbose = verbose, Profile = profile, SlowQueryMs = slowQueryMs, + MinEntrypointConfidence = minEntrypointConfidence, StatusExplainField = statusExplainField, StatusLogPath = statusLogPath, StatusConfig = statusConfig, @@ -4822,6 +4836,21 @@ internal static bool TryParseReferenceRankMode(string value, out ReferenceRankMo } } + private static bool TryParseConfidence(string value, out double confidence) + { + if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out confidence) && + !double.IsNaN(confidence) && + !double.IsInfinity(confidence) && + confidence >= 0 && + confidence <= 1) + { + return true; + } + + confidence = 0; + return false; + } + private static bool TryResolveHotspotsGroupBy(string? requestedGroupBy, string? lang, bool groupByName, out string groupBy, out string error) { groupBy = string.Empty; @@ -6867,6 +6896,7 @@ private static void WriteSqlGraphContractWarningIfNeeded(bool json, SqlGraphCont ["--max-line-width"] = "pass a non-negative integer (`0` disables clamping), e.g. `--max-line-width 512` (default 512).", ["--stale-after"] = "pass a compact positive duration, e.g. `--stale-after 30m`, `--stale-after 2h`, or `--stale-after 7d`.", ["--slow-query-ms"] = "pass a non-negative millisecond threshold, e.g. `--slow-query-ms 500`; use 0 to log every profiled SQL statement.", + ["--min-entrypoint-confidence"] = "pass a decimal from 0.0 through 1.0, e.g. `--min-entrypoint-confidence 0.6`.", }; // Build a missing-value error string with optional caller-supplied hint lines first, then the @@ -7238,6 +7268,7 @@ public sealed class QueryCommandOptions public bool Verbose { get; init; } public bool Profile { get; init; } public int? SlowQueryMs { get; init; } + public double MinEntrypointConfidence { get; init; } public string? StatusExplainField { get; init; } public bool StatusLogPath { get; init; } public bool StatusConfig { get; init; } diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index 72449202f9..533a54e043 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -1354,7 +1354,7 @@ reference_kind TEXT CHECK (reference_kind IN (" + referenceKindCheck + @")), line INTEGER, column_number INTEGER, context TEXT, - reference_line_id INTEGER REFERENCES reference_lines(id), + reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN (" + symbolKindCheck + @")), container_name TEXT )"); @@ -1403,7 +1403,7 @@ value TEXT EnsureColumn( "symbol_references", "reference_line_id", - rebuildsSymbolReferences ? "INTEGER" : "INTEGER REFERENCES reference_lines(id)"); + rebuildsSymbolReferences ? "INTEGER" : "INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL"); // #86: Unicode-aware folded name columns for `--exact` name matching across all // `--exact` command variants. Populated by the writer via NameFold.Fold; NULL on // legacy rows until a full reindex, in which case the reader falls back to the @@ -1415,6 +1415,7 @@ value TEXT EnsureColumn("symbol_references", "is_self_reference", "INTEGER NOT NULL DEFAULT 0"); EnsureColumn("symbol_references", "is_mutual_recursion", "INTEGER NOT NULL DEFAULT 0"); EnforceRequiredFileIdConstraints(); + EnforceReferenceLineSetNullConstraint(); EnsureReferenceLinesContextKey(); // Indexes / インデックス @@ -1513,6 +1514,7 @@ CREATE TRIGGER IF NOT EXISTS fts_chunks_au AFTER UPDATE ON chunks BEGIN private void EnforceRequiredFileIdConstraints() { + var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); Execute("PRAGMA foreign_keys=OFF"); var legacyAlterTable = ExecuteScalar("PRAGMA legacy_alter_table"); Execute("PRAGMA legacy_alter_table=ON"); @@ -1534,11 +1536,11 @@ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, "id, file_id, chunk_index, start_line, end_line, content"); RebuildTableWithRequiredFileId( "symbols", - """ + $""" CREATE TABLE symbols ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - kind TEXT, + kind TEXT CHECK (kind IN ({symbolKindCheck})), sub_kind TEXT, name TEXT, line INTEGER, @@ -1548,7 +1550,7 @@ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, body_start_line INTEGER, body_end_line INTEGER, signature TEXT, - container_kind TEXT, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), container_name TEXT, container_qualified_name TEXT, family_key TEXT, @@ -1588,6 +1590,8 @@ private void RebuildReferenceLineTablesWithRequiredFileId() return; } + var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); + var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); const string referenceLinesCreateSql = """ CREATE TABLE reference_lines ( @@ -1599,18 +1603,18 @@ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, ) """; const string referenceLinesColumns = "id, file_id, line, context"; - const string symbolReferencesCreateSql = - """ + var symbolReferencesCreateSql = + $""" CREATE TABLE symbol_references ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, symbol_name TEXT, - reference_kind TEXT, + reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), line INTEGER, column_number INTEGER, context TEXT, - reference_line_id INTEGER REFERENCES reference_lines(id), - container_kind TEXT, + reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), container_name TEXT, symbol_name_folded TEXT, container_name_folded TEXT, @@ -1636,11 +1640,81 @@ is_mutual_recursion INTEGER NOT NULL DEFAULT 0 Execute($"DROP TABLE {oldReferenceLines}"); } + private void EnforceReferenceLineSetNullConstraint() + { + if (SymbolReferencesReferenceLineDeletesSetNull()) + return; + + var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); + var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); + var symbolReferencesCreateSql = + $""" + CREATE TABLE symbol_references ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + symbol_name TEXT, + reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), + line INTEGER, + column_number INTEGER, + context TEXT, + reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), + container_name TEXT, + symbol_name_folded TEXT, + container_name_folded TEXT, + is_self_reference INTEGER NOT NULL DEFAULT 0, + is_mutual_recursion INTEGER NOT NULL DEFAULT 0 + ) + """; + const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion"; + const string oldSymbolReferences = "_symbol_references_reference_line_delete"; + + Execute($"DROP TABLE IF EXISTS {oldSymbolReferences}"); + Execute(@" + UPDATE symbol_references + SET reference_line_id = NULL + WHERE reference_line_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM reference_lines + WHERE reference_lines.id = symbol_references.reference_line_id + )"); + Execute($"ALTER TABLE symbol_references RENAME TO {oldSymbolReferences}"); + Execute(symbolReferencesCreateSql); + Execute($"INSERT INTO symbol_references ({symbolReferencesColumns}) SELECT {symbolReferencesColumns} FROM {oldSymbolReferences}"); + Execute($"DROP TABLE {oldSymbolReferences}"); + } + + private bool SymbolReferencesReferenceLineDeletesSetNull() + { + using var cmd = _connection.CreateCommand(); + if (_activeMigrationTransaction != null) + cmd.Transaction = _activeMigrationTransaction; + cmd.CommandText = "PRAGMA foreign_key_list('symbol_references')"; + + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + { + var table = reader.GetString(2); + var from = reader.GetString(3); + var onDelete = reader.GetString(6); + if (string.Equals(table, "reference_lines", StringComparison.OrdinalIgnoreCase) + && string.Equals(from, "reference_line_id", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(onDelete, "SET NULL", StringComparison.OrdinalIgnoreCase); + } + } + + return false; + } + private void EnsureReferenceLinesContextKey() { if (ReferenceLinesHasContextUniqueKey()) return; + var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); + var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); const string referenceLinesCreateSql = """ CREATE TABLE reference_lines ( @@ -1652,18 +1726,18 @@ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, ) """; const string referenceLinesColumns = "id, file_id, line, context"; - const string symbolReferencesCreateSql = - """ + var symbolReferencesCreateSql = + $""" CREATE TABLE symbol_references ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, symbol_name TEXT, - reference_kind TEXT, + reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), line INTEGER, column_number INTEGER, context TEXT, - reference_line_id INTEGER REFERENCES reference_lines(id), - container_kind TEXT, + reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), container_name TEXT, symbol_name_folded TEXT, container_name_folded TEXT, @@ -1969,14 +2043,14 @@ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, line INTEGER, column_number INTEGER, context TEXT, - reference_line_id INTEGER REFERENCES reference_lines(id), + reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, container_kind TEXT, container_name TEXT, is_self_reference INTEGER NOT NULL DEFAULT 0, is_mutual_recursion INTEGER NOT NULL DEFAULT 0 )")); yield return ("EnsureColumn symbol_references.reference_line_id", - () => EnsureColumn("symbol_references", "reference_line_id", "INTEGER REFERENCES reference_lines(id)")); + () => EnsureColumn("symbol_references", "reference_line_id", "INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL")); yield return ("EnsureColumn symbol_references.is_self_reference", () => EnsureColumn("symbol_references", "is_self_reference", "INTEGER NOT NULL DEFAULT 0")); yield return ("EnsureColumn symbol_references.is_mutual_recursion", diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index 8fd296b954..c53274c6ff 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -491,10 +491,10 @@ public StatusResult GetStatus() /// 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) + public RepoMapResult GetRepoMap(int limit = 10, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, double minEntrypointConfidence = 0) { var builder = new RepoMapBuilder(_conn, _fileColumns, _hasReferencesTable); - return builder.Build(limit, lang, pathPatterns, excludePathPatterns, excludeTests, GetWorkspaceFreshness); + return builder.Build(limit, lang, pathPatterns, excludePathPatterns, excludeTests, minEntrypointConfidence, GetWorkspaceFreshness); } private long ExecuteScalar(string sql) diff --git a/src/CodeIndex/Database/DbWriter.cs b/src/CodeIndex/Database/DbWriter.cs index 1c30a97194..72f638ad05 100644 --- a/src/CodeIndex/Database/DbWriter.cs +++ b/src/CodeIndex/Database/DbWriter.cs @@ -28,6 +28,7 @@ public class DbWriter private readonly SemaphoreSlim _transactionGate = new(1, 1); private readonly AsyncLocal _currentTransactionGateToken = new(); private const int BatchSize = 500; + private const int DeleteFilesBatchSize = 500; private const int MaxSqlVariables = 999; private const int SqliteConstraintErrorCode = 19; private int _rowSkipSavepointCounter; @@ -617,22 +618,7 @@ public int PurgeStaleFilesSharingChecksum(string projectRoot, string retainedRel ReleaseCommand(cmd); } - if (staleIds.Count == 0) - return 0; - - using var txn = !IsInTransaction() ? BeginTransaction() : null; - using var deleteCmd = _conn.CreateCommand(); - deleteCmd.CommandText = "DELETE FROM files WHERE id = @id"; - var pId = deleteCmd.Parameters.Add("@id", SqliteType.Integer); - deleteCmd.Prepare(); - foreach (var id in staleIds) - { - pId.Value = id; - deleteCmd.ExecuteNonQuery(); - } - txn?.Commit(); - - return staleIds.Count; + return DeleteStaleFileIds(staleIds); } /// @@ -677,18 +663,76 @@ private int DeleteStaleFileIds(IReadOnlyCollection staleIds) return 0; using var txn = !IsInTransaction() ? BeginTransaction() : null; + DeleteFilesByIdBatched(staleIds); + txn?.Commit(); + + return staleIds.Count; + } + + private void DeleteFilesByIdBatched(IEnumerable fileIds, int batchSize = DeleteFilesBatchSize) + { + var batch = new List(batchSize); + foreach (var id in fileIds) + { + batch.Add(id); + if (batch.Count == batchSize) + { + DeleteFileIdBatch(batch); + batch.Clear(); + } + } + + if (batch.Count > 0) + DeleteFileIdBatch(batch); + } + + private void DeleteFileIdBatch(IReadOnlyList fileIds) + { + DeleteCrossFileReferencesToSymbolsDefinedOnlyByFiles(fileIds); + using var deleteCmd = _conn.CreateCommand(); - deleteCmd.CommandText = "DELETE FROM files WHERE id = @id"; - var pId = deleteCmd.Parameters.Add("@id", SqliteType.Integer); - deleteCmd.Prepare(); - foreach (var id in staleIds) + var parameters = new List(fileIds.Count); + for (var i = 0; i < fileIds.Count; i++) { - pId.Value = id; - deleteCmd.ExecuteNonQuery(); + var parameterName = $"@id{i}"; + parameters.Add(parameterName); + deleteCmd.Parameters.Add(parameterName, SqliteType.Integer).Value = fileIds[i]; } - txn?.Commit(); - return staleIds.Count; + deleteCmd.CommandText = $"DELETE FROM files WHERE id IN ({string.Join(", ", parameters)})"; + deleteCmd.ExecuteNonQuery(); + } + + private void DeleteCrossFileReferencesToSymbolsDefinedOnlyByFiles(IReadOnlyList fileIds) + { + using var deleteCmd = _conn.CreateCommand(); + var parameters = new List(fileIds.Count); + for (var i = 0; i < fileIds.Count; i++) + { + var parameterName = $"@id{i}"; + parameters.Add(parameterName); + deleteCmd.Parameters.Add(parameterName, SqliteType.Integer).Value = fileIds[i]; + } + + var idList = string.Join(", ", parameters); + deleteCmd.CommandText = $@" + DELETE FROM symbol_references + WHERE file_id NOT IN ({idList}) + AND symbol_name IS NOT NULL + AND symbol_name <> '' + AND EXISTS ( + SELECT 1 + FROM symbols deleted_symbols + WHERE deleted_symbols.file_id IN ({idList}) + AND deleted_symbols.name = symbol_references.symbol_name + ) + AND NOT EXISTS ( + SELECT 1 + FROM symbols retained_symbols + WHERE retained_symbols.file_id NOT IN ({idList}) + AND retained_symbols.name = symbol_references.symbol_name + )"; + deleteCmd.ExecuteNonQuery(); } private static string GetRelativeDirectory(string relativePath) diff --git a/src/CodeIndex/Database/RepoMapBuilder.cs b/src/CodeIndex/Database/RepoMapBuilder.cs index 78c81fe5b0..eeeb43cfe8 100644 --- a/src/CodeIndex/Database/RepoMapBuilder.cs +++ b/src/CodeIndex/Database/RepoMapBuilder.cs @@ -76,7 +76,7 @@ public RepoMapBuilder(SqliteConnection connection, HashSet fileColumns, /// 深掘り前の把握に使うリポジトリ俯瞰情報を構築する。 /// public RepoMapResult Build(int limit, string? lang, IReadOnlyList? pathPatterns, - IReadOnlyList? excludePathPatterns, bool excludeTests, + IReadOnlyList? excludePathPatterns, bool excludeTests, double minEntrypointConfidence, Func<(DateTime? IndexedAt, DateTime? LatestModified)> getFreshness) { // Query file stats first, then workspace freshness — preserves original @@ -116,7 +116,7 @@ public RepoMapResult Build(int limit, string? lang, IReadOnlyList? pathP LargestFiles = BuildLargestFileResults(aggregate.FileSummaries, limit), SymbolRichFiles = BuildSymbolRichFileResults(aggregate.FileSummaries, limit), ReferenceRichFiles = BuildReferenceRichFileResults(aggregate.FileSummaries, limit), - Entrypoints = GetEntrypoints(fileStats, limit, lang, pathPatterns, excludePathPatterns, excludeTests), + Entrypoints = GetEntrypoints(fileStats, limit, lang, pathPatterns, excludePathPatterns, excludeTests, minEntrypointConfidence), GraphTableAvailable = _hasReferencesTable, }; txn.Commit(); @@ -355,7 +355,8 @@ private static void ApplyJavaModuleGrouping(List fileStats, IReadO } private List GetEntrypoints(IReadOnlyList fileStats, int limit, - string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests) + string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, + double minConfidence) { using var cmd = _conn.CreateCommand(); var sql = @" @@ -383,8 +384,8 @@ FROM symbols s var kind = reader.GetString(2); var name = reader.GetString(3); var line = reader.GetInt32(4); - var score = ScoreEntrypoint(path, candidateLang, kind, name); - if (score <= 0) + var match = ScoreEntrypoint(path, candidateLang, kind, name); + if (match.Score <= 0) continue; results.Add(new RepoEntrypointResult @@ -394,7 +395,10 @@ FROM symbols s Kind = kind, Name = name, Line = line, - Score = score, + Score = match.Score, + MatchType = match.MatchType, + Confidence = match.Confidence, + HintRank = match.HintRank, }); } @@ -409,8 +413,8 @@ FROM symbols s if (filesWithEntrypoints.Contains(file.Path)) continue; - var score = ScoreEntrypointFileFallback(file.Path, file.Lang, file.SymbolCount, file.ReferenceCount); - if (score <= 0) + var match = ScoreEntrypointFileFallback(file.Path, file.Lang, file.SymbolCount, file.ReferenceCount); + if (match.Score <= 0) continue; results.Add(new RepoEntrypointResult @@ -420,12 +424,19 @@ FROM symbols s Kind = "file", Name = Path.GetFileName(file.Path), Line = 1, - Score = score, + Score = match.Score, + MatchType = match.MatchType, + Confidence = match.Confidence, + HintRank = match.HintRank, }); } + ApplyEntrypointAmbiguityPenalty(results); return results + .Where(result => result.Confidence >= minConfidence) .OrderByDescending(result => result.Score) + .ThenByDescending(result => result.Confidence) + .ThenBy(result => result.HintRank) .ThenBy(result => result.Path) .ThenBy(result => result.Line) .Take(limit) @@ -521,21 +532,23 @@ private static string GetModuleKey(RepoFileStat file) return path[..lastSlash]; } - private static int ScoreEntrypoint(string path, string? lang, string kind, string name) + private static EntrypointScore ScoreEntrypoint(string path, string? lang, string kind, string name) { if (lang == null) - return 0; + return EntrypointScore.None; var score = 0; - if (EntrypointNameHints.TryGetValue(lang, out var names) && names.Any(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase))) + var nameRank = GetHintRank(EntrypointNameHints, lang, name); + if (nameRank > 0) score += 4; var fileName = Path.GetFileName(path); - if (EntrypointPathHints.TryGetValue(lang, out var fileHints) && fileHints.Any(candidate => string.Equals(candidate, fileName, StringComparison.OrdinalIgnoreCase))) + var pathRank = GetHintRank(EntrypointPathHints, lang, fileName); + if (pathRank > 0) score += 3; if (score == 0) - return 0; + return EntrypointScore.None; if (kind == "function") score += 1; @@ -543,19 +556,34 @@ private static int ScoreEntrypoint(string path, string? lang, string kind, strin if (kind == "class" && string.Equals(Path.GetFileNameWithoutExtension(fileName), name, StringComparison.OrdinalIgnoreCase)) score += 1; - return score; + score += GetPathLocationBoost(path); + var matchType = pathRank > 0 && nameRank > 0 + ? "path+name" + : pathRank > 0 + ? "path" + : "name"; + var hintRank = pathRank > 0 && nameRank > 0 + ? Math.Min(pathRank, nameRank) + : Math.Max(pathRank, nameRank); + var confidence = pathRank > 0 && nameRank > 0 + ? 0.85 + : pathRank > 0 + ? 0.65 + : 0.5; + + return new EntrypointScore(score, matchType, NormalizeConfidence(confidence + GetPathLocationConfidenceBoost(path)), hintRank); } - private static int ScoreEntrypointFileFallback(string path, string? lang, int symbolCount, int referenceCount) + private static EntrypointScore ScoreEntrypointFileFallback(string path, string? lang, int symbolCount, int referenceCount) { if (lang == null) - return 0; + return EntrypointScore.None; var fileName = Path.GetFileName(path); - if (!EntrypointPathHints.TryGetValue(lang, out var fileHints) || - !fileHints.Any(candidate => string.Equals(candidate, fileName, StringComparison.OrdinalIgnoreCase))) + var pathRank = GetHintRank(EntrypointPathHints, lang, fileName); + if (pathRank <= 0) { - return 0; + return EntrypointScore.None; } var score = 2; @@ -564,7 +592,71 @@ private static int ScoreEntrypointFileFallback(string path, string? lang, int sy if (referenceCount > 0) score += 1; - return score; + score += GetPathLocationBoost(path); + return new EntrypointScore(score, "path", NormalizeConfidence(0.4 + GetPathLocationConfidenceBoost(path)), pathRank); + } + + private static int GetHintRank(IReadOnlyDictionary hintsByLang, string lang, string candidate) + { + if (!hintsByLang.TryGetValue(lang, out var hints)) + return 0; + + for (var i = 0; i < hints.Length; i++) + { + if (string.Equals(hints[i], candidate, StringComparison.OrdinalIgnoreCase)) + return i + 1; + } + + return 0; + } + + private static int GetPathLocationBoost(string path) + { + var slashCount = path.Count(ch => ch == '/'); + if (slashCount == 0) + return 2; + if (path.StartsWith("src/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("app/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("cmd/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("bin/", StringComparison.OrdinalIgnoreCase)) + return 1; + + return 0; + } + + private static double GetPathLocationConfidenceBoost(string path) + { + var slashCount = path.Count(ch => ch == '/'); + if (slashCount == 0) + return 0.1; + if (path.StartsWith("src/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("app/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("cmd/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("bin/", StringComparison.OrdinalIgnoreCase)) + return 0.05; + + return 0; + } + + private static void ApplyEntrypointAmbiguityPenalty(List results) + { + foreach (var group in results.GroupBy(result => $"{result.Lang ?? ""}\0{result.MatchType}\0{result.Name}", StringComparer.OrdinalIgnoreCase)) + { + var count = group.Count(); + if (count <= 1) + continue; + + var penalty = Math.Min(0.3, 0.1 * (count - 1)); + foreach (var result in group) + result.Confidence = NormalizeConfidence(Math.Max(0.2, result.Confidence - penalty)); + } + } + + private static double NormalizeConfidence(double confidence) => Math.Round(Math.Min(confidence, 1.0), 3); + + private readonly record struct EntrypointScore(int Score, string MatchType, double Confidence, int HintRank) + { + public static EntrypointScore None { get; } = new(0, "", 0, 0); } private sealed class RepoMapAggregate diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 047b67d00c..dca527175b 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -786,6 +786,9 @@ public class RepoEntrypointResult public string Name { get; set; } = string.Empty; public int Line { get; set; } public int Score { get; set; } + public string MatchType { get; set; } = string.Empty; + public double Confidence { get; set; } + public int HintRank { get; set; } } public class SymbolAnalysisResult diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index 6a48080274..cf49bae761 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -98,6 +98,92 @@ public void InsertReferences_UsesFoldedNamesForMutualRecursion() Assert.Equal(2L, (long)cmd.ExecuteScalar()!); } + [Fact] + public void DeleteFileData_WhenReferencedLineIsDeleted_PreservesReferenceWithNullLineContext() + { + var callerFileId = UpsertTestFile("src/caller.cs", checksum: "caller"); + var lineOwnerFileId = UpsertTestFile("src/line-owner.cs", checksum: "line-owner"); + + long referenceLineId; + using (var cmd = _db.Connection.CreateCommand()) + { + cmd.CommandText = @" + INSERT INTO reference_lines (file_id, line, context) + VALUES (@fileId, 3, 'Target();') + RETURNING id"; + cmd.Parameters.AddWithValue("@fileId", lineOwnerFileId); + referenceLineId = (long)cmd.ExecuteScalar()!; + } + + using (var cmd = _db.Connection.CreateCommand()) + { + cmd.CommandText = @" + INSERT INTO symbol_references ( + file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id + ) + VALUES (@fileId, 'Target', 'call', 1, 1, NULL, @referenceLineId)"; + cmd.Parameters.AddWithValue("@fileId", callerFileId); + cmd.Parameters.AddWithValue("@referenceLineId", referenceLineId); + cmd.ExecuteNonQuery(); + } + + _writer.DeleteFileData(lineOwnerFileId); + + using var readCmd = _db.Connection.CreateCommand(); + readCmd.CommandText = "SELECT COUNT(*), COUNT(reference_line_id) FROM symbol_references WHERE file_id = @fileId"; + readCmd.Parameters.AddWithValue("@fileId", callerFileId); + using var reader = readCmd.ExecuteReader(); + Assert.True(reader.Read()); + Assert.Equal(1L, reader.GetInt64(0)); + Assert.Equal(0L, reader.GetInt64(1)); + } + + [Fact] + public void PurgeStaleFiles_RemovesCrossFileReferencesToSymbolsDefinedOnlyByDeletedFiles() + { + var projectRoot = TestProjectHelper.CreateTempProject("purge-stale-symbol-ref"); + try + { + Directory.CreateDirectory(Path.Combine(projectRoot, "src")); + File.WriteAllText(Path.Combine(projectRoot, "src", "target.py"), "# retained rename target"); + + var callerFileId = UpsertTestFile("src/caller.cs", checksum: "caller"); + var staleTargetFileId = UpsertTestFile("src/target.cs", checksum: "target"); + _ = UpsertTestFile("src/target.py", checksum: "target"); + _writer.InsertSymbols([ + new SymbolRecord + { + FileId = staleTargetFileId, + Kind = "function", + Name = "DeletedTarget", + Line = 1, + }, + ]); + _writer.InsertReferences([ + new ReferenceRecord + { + FileId = callerFileId, + SymbolName = "DeletedTarget", + ReferenceKind = "call", + Line = 1, + Column = 1, + Context = "DeletedTarget();", + }, + ]); + + var purged = _writer.PurgeStaleFilesSharingDirectoryAndStem(projectRoot, "src/target.py"); + + Assert.Equal(1, purged); + using var cmd = _db.Connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM symbol_references WHERE symbol_name = 'DeletedTarget'"; + Assert.Equal(0L, (long)cmd.ExecuteScalar()!); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void InsertSymbols_UnknownKind_ThrowsBeforePersisting() { @@ -238,6 +324,17 @@ public void OptimizeFts_ResetsIncrementalWriteCounterAndStampsTime() Assert.False(string.IsNullOrWhiteSpace(_db.GetMetaString(DbWriter.FtsLastOptimizedAtMetaKey))); } + private long UpsertTestFile(string path, string checksum) + => _writer.UpsertFile(new FileRecord + { + Path = path, + Lang = "csharp", + Size = 100, + Lines = 4, + Modified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Checksum = checksum, + }); + [Fact] public void OptimizeFtsIfIncrementalWriteThresholdReached_RunsOnlyAtThreshold() { diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index e60aafee2f..67eb4a942d 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -12455,6 +12455,10 @@ public void GetRepoMap_ReturnsOverviewSectionsAndEntrypoints() Assert.NotEmpty(map.SymbolRichFiles); Assert.NotEmpty(map.ReferenceRichFiles); Assert.Contains(map.Entrypoints, item => item.Name == "Main" && item.Path == "src/Program.cs"); + var entrypoint = Assert.Single(map.Entrypoints, item => item.Name == "Main" && item.Path == "src/Program.cs"); + Assert.Equal("path+name", entrypoint.MatchType); + Assert.True(entrypoint.Confidence >= 0.8); + Assert.Equal(1, entrypoint.HintRank); } [Fact] @@ -12502,7 +12506,39 @@ public void GetRepoMap_AddsFileFallbackEntrypointForTopLevelProgram() var map = _reader.GetRepoMap(limit: 5, pathPatterns: new[] { "src/Program.cs" }); - Assert.Contains(map.Entrypoints, item => item.Kind == "file" && item.Name == "Program.cs" && item.Path == "src/Program.cs"); + var entrypoint = Assert.Single(map.Entrypoints, item => item.Kind == "file" && item.Name == "Program.cs" && item.Path == "src/Program.cs"); + Assert.Equal("path", entrypoint.MatchType); + Assert.True(entrypoint.Confidence >= 0.4); + Assert.Equal(1, entrypoint.HintRank); + } + + [Fact] + public void GetRepoMap_MinEntrypointConfidenceFiltersWeakNameOnlyMatches() + { + InsertIndexedFile("src/services/service.py", "python", "def app():\n return True\n"); + InsertIndexedFile("src/main.py", "python", "def main():\n return True\n"); + + var map = _reader.GetRepoMap(limit: 10, lang: "python", minEntrypointConfidence: 0.7); + + Assert.Contains(map.Entrypoints, item => item.Path == "src/main.py" && item.Name == "main"); + Assert.DoesNotContain(map.Entrypoints, item => item.Path == "src/services/service.py" && item.Name == "app"); + } + + [Fact] + public void GetRepoMap_RepeatedWeakEntrypointNamesReduceConfidence() + { + InsertIndexedFile("src/plugins/first.py", "python", "def app():\n return True\n"); + InsertIndexedFile("src/plugins/second.py", "python", "def app():\n return True\n"); + + var map = _reader.GetRepoMap(limit: 10, lang: "python", pathPatterns: new[] { "plugins/" }); + + var entries = map.Entrypoints.Where(item => item.Name == "app").ToList(); + Assert.Equal(2, entries.Count); + Assert.All(entries, entry => + { + Assert.Equal("name", entry.MatchType); + Assert.True(entry.Confidence < 0.5); + }); } [Theory] diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index b6e78dc339..2879bc0753 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -928,6 +928,24 @@ public void ParseArgs_InvalidRankByReportsParseError() Assert.Contains("weighted", options.ParseError); } + [Fact] + public void ParseArgs_MinEntrypointConfidenceFlagParsed() + { + var options = QueryCommandRunner.ParseArgs(["--min-entrypoint-confidence", "0.65"], jsonDefault: false); + + Assert.Equal(0.65, options.MinEntrypointConfidence); + } + + [Fact] + public void ParseArgs_InvalidMinEntrypointConfidenceReportsParseError() + { + var options = QueryCommandRunner.ParseArgs(["--min-entrypoint-confidence", "1.5"], jsonDefault: false); + + Assert.NotNull(options.ParseError); + Assert.Contains("--min-entrypoint-confidence", options.ParseError); + Assert.Contains("0.0 through 1.0", options.ParseError); + } + [Fact] public void ParseArgs_AllowsZeroMaxLineWidthForNoTruncation() {