Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ valid encoded U+FFFD literal, while `origin: decode_replacement` means the
decoder inserted U+FFFD for invalid bytes. `severity: info` is used for source
literals, and `severity: warning` is used for likely encoding damage.

Scoped `--files` / `--commits` refreshes reuse the same path filter as full scans. Before scanning a nested project root, `FileIndexer` loads ignore files from the resolved ignore-rule root through each existing ancestor directory down to the project root's parent, then loads the project directory's own rules during the normal walk. Within each directory, `FileIndexer` loads `.gitignore` before `.cdidxignore`, appends both rule sets in that order, and honors later `!` patterns as re-includes. If an ancestor ignore directory cannot be read, scanning fails closed with a scan error instead of silently skipping those rules; `ScanFilesResult.AncestorIgnoreDirectories` records the resolved ancestor list for troubleshooting. If a commit-scoped refresh includes `.gitignore` or `.cdidxignore` changes, `IndexCommandRunner` falls back to a full scan so newly ignored files are purged safely. Malformed ignore lines are reported as scan errors and skipped instead of aborting the whole run. Symlinks default to `--follow-symlinks none`; `internal` follows file and directory targets that resolve under the workspace root, and `all` follows all resolvable targets. Dangling symlinks are counted and warned separately, while permission failures resolving directory targets are reported as scan warnings. On Windows, files and directories with Hidden or System attributes are rejected before language detection; clear those attributes before indexing project-owned sources because ignore rules cannot re-include them.
Scoped `--files` / `--commits` refreshes reuse the same path filter as full scans. Before scanning a nested project root, `FileIndexer` loads ignore files from the resolved ignore-rule root through each existing ancestor directory down to the project root's parent, then loads the project directory's own rules during the normal walk. Within each directory, `FileIndexer` loads `.gitignore` before `.cdidxignore`, appends both rule sets in that order, and honors later `!` patterns as re-includes. If an ancestor ignore directory cannot be read, scanning fails closed with a scan error instead of silently skipping those rules; `ScanFilesResult.AncestorIgnoreDirectories` records the resolved ancestor list for troubleshooting. If a commit-scoped refresh includes `.gitignore` or `.cdidxignore` changes, `IndexCommandRunner` falls back to a full scan so newly ignored files are purged safely. Malformed ignore lines are reported as scan errors and skipped instead of aborting the whole run. Symlinks default to `--follow-symlinks none`; `internal` follows file and directory targets that resolve under the workspace root, and `all` follows all resolvable targets. Discovery, dry-run, C# workspace preflight, and content loading use the same resolved file target identity, so a stable allowed external file target is indexed while a link retargeted after preflight is rejected as source drift. Dangling symlinks are counted and warned separately; index dry-run reports them through `warnings_total` / `warnings`, matching execution severity and successful exit behavior. Permission failures resolving directory targets are also reported as scan warnings. On Windows, files and directories with Hidden or System attributes are rejected before language detection; clear those attributes before indexing project-owned sources because ignore rules cannot re-include them.

Incremental refreshes that mutate `fts_chunks` increment both `codeindex_meta.fts_incremental_writes_since_merge` and `codeindex_meta.fts_incremental_writes_since_optimize`. When the merge counter reaches 25 writes, index runners issue `INSERT INTO fts_chunks(fts_chunks, rank) VALUES('merge', -1000)`: 1,000 pages is a minimum work target, and SQLite's complete-segment granularity may process more pages. The merge resets only its dedicated counter, while the optimize counter continues to support the `cdidx optimize --dry-run` recommendation. Full CLI scans and MCP refreshes switch to trigger-free bulk rewrite, FTS rebuild, and full optimize when dirty source bytes are at least three-fifths of known workspace source bytes; fresh indexes and explicit rebuilds always use that path. Dirty bytes include the larger of the current and persisted sizes for each file that will be rewritten, plus persisted byte sizes for indexed rows planned for deletion, including the old side of a rename. The comparison total includes known readable current-workspace bytes, those planned-deletion bytes, and the positive persisted-minus-current excess for rewritten files that shrank, so both sides describe the same pre-update footprint. A scan error, invalid persisted size, or byte-count overflow makes the estimate incomplete and conservatively keeps trigger synchronization. Stale-file IDs are planned without mutation before selecting the FTS policy, then deleted inside the selected bulk guard. The plan keeps IDs ascending so the C# static-interface workspace prepass can skip purge-planned rows with a binary search and no duplicate deletion set; the reusable-stat snapshot instead loads the IDs into an indexed temporary SQL filter before running eligibility subqueries. This prevents a path that reappears between MCP planning and scanning from reusing a row that the same run will purge. The separate pre-purge contract-presence query runs only for a non-empty plan and still forces C# re-extraction when deletion may remove implicit implementation references. When such contracts exist, MCP also invalidates the C# symbol-name contract at its first mutation; if a scan error leaves an implementer unprocessed after the purge, the next clean run disables stat reuse, repairs its implicit references, and only then restamps the contract. The batched delete transaction checks cancellation throughout and rolls back every delete if cancellation arrives before commit; if cancellation arrives after a committed bulk purge, guard abandonment rebuilds FTS from the surviving chunks and restores its triggers before the run exits. Full scans and MCP refreshes filter reusable-row snapshots with a current-target path set only when non-purged indexed rows unused by the current target set outnumber the current targets; other runs use sorted-ID exclusion without duplicating every current path in a second large set. Scoped `--files` / `--commits` refreshes stay on trigger synchronization and incremental merge maintenance. `cdidx optimize --db <path>` and `cdidx index <projectPath> --optimize` still run an explicit full optimize, reset both counters, and stamp `fts_last_optimized_at`; this may briefly hold the writer lock on large indexes.

Expand Down Expand Up @@ -3406,7 +3406,7 @@ literal、`origin: decode_replacement` が不正 byte に対して decoder が
を意味する。source literal は `severity: info`、エンコーディング破損の可能性は
`severity: warning` として返す。

`--files` / `--commits` の部分更新も、フルスキャンと同じパスフィルタを再利用する。各ディレクトリでは `FileIndexer` が `.gitignore` を `.cdidxignore` より先に読み、この順序でルールを追加し、後続の `!` パターンを再包含として扱う。commit 単位更新に `.gitignore` または `.cdidxignore` の変更が含まれる場合、`IndexCommandRunner` は newly ignored file を安全に purge するため自動でフルスキャンへフォールバックする。malformed な ignore 行は走査エラーとして報告し、その行だけをスキップして index 全体は継続する。Windows では Hidden または System 属性が付いたファイルとディレクトリを言語検出前に拒否する。プロジェクト所有のソースを索引したい場合、ignore ルールでは再包含できないため先にそれらの属性を外す。
`--files` / `--commits` の部分更新も、フルスキャンと同じパスフィルタを再利用する。各ディレクトリでは `FileIndexer` が `.gitignore` を `.cdidxignore` より先に読み、この順序でルールを追加し、後続の `!` パターンを再包含として扱う。commit 単位更新に `.gitignore` または `.cdidxignore` の変更が含まれる場合、`IndexCommandRunner` は newly ignored file を安全に purge するため自動でフルスキャンへフォールバックする。malformed な ignore 行は走査エラーとして報告し、その行だけをスキップして index 全体は継続する。symlink は既定で `--follow-symlinks none` とし、`internal` は workspace root 内へ解決される file / directory target、`all` は解決可能なすべての target を追跡する。discovery、dry-run、C# workspace preflight、content loading は同じ解決済み file target identity を使うため、許可された静的な外部 file target は索引し、preflight 後に retarget された link は source drift として拒否する。dangling symlink は個別に集計して warning とし、index dry-run も実行時と同じく `warnings_total` / `warnings` で報告して成功終了する。directory target の解決時に発生した permission failure も scan warning として報告する。Windows では Hidden または System 属性が付いたファイルとディレクトリを言語検出前に拒否する。プロジェクト所有のソースを索引したい場合、ignore ルールでは再包含できないため先にそれらの属性を外す。

### メタデータ不変条件

Expand Down
25 changes: 25 additions & 0 deletions changelog.d/unreleased/4829.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
category: fixed
issues:
- 4829
affected:
- src/CodeIndex/Indexer/Scanning/FileIndexer.cs
- src/CodeIndex/Indexer/Scanning/FileIndexer.PathFiltering.cs
- src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs
- src/CodeIndex/Indexer/Scanning/FileContentLoader.cs
- src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs
- src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs
- src/CodeIndex/Cli/IndexCommandRunner.FullScan.Targets.cs
- src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpTargets.cs
- src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs
- src/CodeIndex/Cli/JsonOutputContracts.cs
- src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs
---

## English

- **Symlink preflight now agrees with dry-run and execution (#4829)** — allowed external file links use their resolved target metadata consistently, retargeted links still fail source-drift validation, and dangling links remain successful warnings in both execution and dry-run JSON (`warnings_total` / `warnings`).

## 日本語

- **symlink の preflight が dry-run と実行で一致するようになりました (#4829)** — 許可された外部 file link は解決済み target の metadata を一貫して使用し、retarget された link は引き続き source-drift 検証で拒否します。dangling link は実行時と dry-run JSON(`warnings_total` / `warnings`)のどちらでも成功扱いの warning になります。
18 changes: 17 additions & 1 deletion src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public static partial class IndexCommandRunner
{
internal const int DryRunFileSampleLimit = 100;
internal const int DryRunLanguageDetectionLimit = 100;
internal const int DryRunWarningSampleLimit = 100;
internal const int DryRunErrorSampleLimit = 100;
internal const int DefaultDryRunPathLimit = 100_000;
internal const int MaxDryRunPathLimit = 1_000_000;
Expand Down Expand Up @@ -43,6 +44,8 @@ private static int RunDryRun(
bool authoritativeFullScan;
var errorSamples = new List<CliJsonMessage>();
var errorCount = 0;
var warningSamples = new List<CliJsonMessage>();
var warningCount = 0;
var dryScanErrorKeys = new HashSet<string>(StringComparer.Ordinal);
DryRunScanMetadata dryScanMetadata;
var dbSnapshot = ReadDryRunDbSnapshot(resolvedDbPath, options.SymbolKindFilter);
Expand Down Expand Up @@ -104,7 +107,16 @@ void RecordDryRunScanErrors(IEnumerable<FileIndexer.ScanError> scanErrors)
continue;
}

RecordDryRunError(scanError.Path, scanError.Message);
if (scanError.Severity == FileIndexer.ScanIssueSeverity.Warning)
{
warningCount++;
if (warningSamples.Count < DryRunWarningSampleLimit)
warningSamples.Add(new CliJsonMessage(scanError.Path, scanError.Message));
}
else
{
RecordDryRunError(scanError.Path, scanError.Message);
}
if (!options.Json)
ConsoleUi.PrintWarning($"{scanError.Path}: {scanError.Message}");
}
Expand Down Expand Up @@ -300,6 +312,10 @@ void RecordDryRunScanErrors(IEnumerable<FileIndexer.ScanError> scanErrors)
LanguageDetections = languageDetectionSamples.Count > 0 ? languageDetectionSamples : null,
LanguageDetectionsTruncated = languageDetectionTotal > languageDetectionSamples.Count,
LanguageDetectionLimit = DryRunLanguageDetectionLimit,
WarningsTotal = warningCount,
Warnings = warningSamples.Count > 0 ? warningSamples : null,
WarningsTruncated = warningCount > warningSamples.Count,
WarningLimit = DryRunWarningSampleLimit,
ErrorsTotal = errorCount,
Errors = errorSamples.Count > 0 ? errorSamples : null,
ErrorsTruncated = errorCount > errorSamples.Count,
Expand Down
3 changes: 2 additions & 1 deletion src/CodeIndex/Cli/IndexCommandRunner.FullScan.Targets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ private static FullScanTargetPreparation PrepareFullScanTargets(
indexedTarget.DisplayRelativePath,
indexedTarget.IndexPath,
indexedTarget.Language,
indexedTarget.GeneratedExtractionSuppressed));
indexedTarget.GeneratedExtractionSuppressed,
indexer.ResolvesSymlinkTargets));
}

return new FullScanTargetPreparation(fileTargets, csharpPrepassTargets);
Expand Down
3 changes: 2 additions & 1 deletion src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpTargets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ void RememberExistingCSharpTransition(string indexPath)
updateTarget.RelativePath,
updateTarget.DisplayRelativePath,
updateTarget.IndexPath,
language);
language,
ResolveSymlinkTargets: indexer.ResolvesSymlinkTargets);
targets.Add(target with
{
GeneratedExtractionSuppressed =
Expand Down
4 changes: 4 additions & 0 deletions src/CodeIndex/Cli/JsonOutputContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,10 @@ internal sealed class IndexDryRunJsonResult : IVersionedJsonResult
public List<IndexLanguageDetectionJsonResult>? LanguageDetections { get; init; }
public bool LanguageDetectionsTruncated { get; init; }
public int LanguageDetectionLimit { get; init; }
public int WarningsTotal { get; init; }
public List<CliJsonMessage>? Warnings { get; init; }
public bool WarningsTruncated { get; init; }
public int WarningLimit { get; init; }
public int ErrorsTotal { get; init; }
public List<CliJsonMessage>? Errors { get; init; }
public bool ErrorsTruncated { get; init; }
Expand Down
51 changes: 43 additions & 8 deletions src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs
Original file line number Diff line number Diff line change
Expand Up @@ -656,15 +656,22 @@ internal static bool TryCaptureFileStatSnapshots(
try
{
validateTarget?.Invoke(target);
var info = new FileInfo(LongPath.EnsureWindowsPrefix(target.FilePath));
var resolvedPath = ResolveFileStatPath(
target.FilePath,
target.ResolveSymlinkTargets);
var info = new FileInfo(LongPath.EnsureWindowsPrefix(resolvedPath));
info.Refresh();
if (!info.Exists)
{
failedPath = target.DisplayRelativePath;
return false;
}

snapshots[target.IndexPath] = new FileStatSnapshot(info.Length, info.LastWriteTimeUtc);
snapshots[target.IndexPath] = new FileStatSnapshot(
info.Length,
info.LastWriteTimeUtc,
resolvedPath,
target.ResolveSymlinkTargets);
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
Expand All @@ -686,7 +693,10 @@ internal static bool FileStatSnapshotsMatch(
{
foreach (var (path, snapshot) in before)
{
if (!after.TryGetValue(path, out var current) || current != snapshot)
if (!after.TryGetValue(path, out var current)
|| current.Size != snapshot.Size
|| current.ModifiedUtc != snapshot.ModifiedUtc
|| !FileIndexer.FileReadPathsEqual(current.ResolvedPath, snapshot.ResolvedPath))
{
changedPath = path;
return false;
Expand Down Expand Up @@ -722,10 +732,19 @@ internal static bool TryValidateFileStatSnapshots(
try
{
validateTarget?.Invoke(target);
var info = new FileInfo(LongPath.EnsureWindowsPrefix(target.FilePath));
if (!snapshots.TryGetValue(target.IndexPath, out var snapshot))
{
changedPath = target.DisplayRelativePath;
return false;
}

var resolvedPath = ResolveFileStatPath(
target.FilePath,
snapshot.ResolveSymlinkTargets);
var info = new FileInfo(LongPath.EnsureWindowsPrefix(resolvedPath));
info.Refresh();
if (!info.Exists
|| !snapshots.TryGetValue(target.IndexPath, out var snapshot)
|| !FileIndexer.FileReadPathsEqual(resolvedPath, snapshot.ResolvedPath)
|| info.Length != snapshot.Size
|| info.LastWriteTimeUtc != snapshot.ModifiedUtc)
{
Expand Down Expand Up @@ -770,9 +789,13 @@ internal static bool TryValidateLoadedFileStatSnapshot(
try
{
validatePath?.Invoke(filePath);
var info = new FileInfo(LongPath.EnsureWindowsPrefix(filePath));
var resolvedPath = ResolveFileStatPath(
filePath,
snapshot.ResolveSymlinkTargets);
var info = new FileInfo(LongPath.EnsureWindowsPrefix(resolvedPath));
info.Refresh();
if (!info.Exists
|| !FileIndexer.FileReadPathsEqual(resolvedPath, snapshot.ResolvedPath)
|| info.Length != snapshot.Size
|| info.LastWriteTimeUtc != snapshot.ModifiedUtc)
{
Expand All @@ -793,6 +816,13 @@ or NotSupportedException
return true;
}

private static string ResolveFileStatPath(
string filePath,
bool resolveSymlinkTargets)
=> resolveSymlinkTargets
? FileIndexer.ResolveFileReadPath(filePath)
: Path.GetFullPath(filePath);

internal static bool TryCaptureDirectoryStatSnapshots(
IEnumerable<string> directories,
out Dictionary<string, DirectoryStatSnapshot> snapshots,
Expand Down Expand Up @@ -934,7 +964,8 @@ internal readonly record struct FileTarget(
string DisplayRelativePath,
string IndexPath,
string? Language,
bool? GeneratedExtractionSuppressed = null)
bool? GeneratedExtractionSuppressed = null,
bool ResolveSymlinkTargets = false)
{
public static FileTarget CreateFromPath(string projectRoot, string path)
{
Expand All @@ -956,7 +987,11 @@ public static FileTarget Create(string projectRoot, string filePath, string? lan
}
}

internal readonly record struct FileStatSnapshot(long Size, DateTime ModifiedUtc);
internal readonly record struct FileStatSnapshot(
long Size,
DateTime ModifiedUtc,
string ResolvedPath,
bool ResolveSymlinkTargets = false);

internal readonly record struct DirectoryStatSnapshot(DateTime ModifiedUtc);

Expand Down
Loading
Loading