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
2 changes: 2 additions & 0 deletions TESTING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,7 @@ Use the inventory below before adding or moving a test class:
- Keep freshness diagnostic classification parallel; isolate only the stamped-case probe test that temporarily replaces `GitHelper` and `FileIndexer` test hooks.
- Production source-policy scans for direct environment access are read-only and parallel-safe; isolate the separate `CdidxEnvironment` mutation contract that changes a real process variable.
- Fixtures that intentionally make a directory unreadable must own a unique `TestProjectHelper.CreateTempProject(...)` workspace outside the repository and test output trees. Default multi-target runs can execute target frameworks concurrently, so placing such a fixture under `bin` or `obj` can break another target's read-only source-policy scan before build-output filtering is applied (#5019).
- Repository-wide source guards such as `LocalJsonlJsonWriterOptionsTests` must prune `bin` and `obj` directories before enumerating their children. Keep a traversal-seam regression that fails if either generated directory is opened, so parallel unreadable fixtures cannot make read-only policy scans scheduling-dependent (#5142).
- Dependency-boundary source audits are also read-only and parallel-safe. Keep the CLI command metadata and config-source resolution assertions in the ordinary collection so changes that reconnect rendering, command routing, config loading, and environment access fail without adding process-global test state.
- The reflection-only SQLite collection registration contract is parallel-safe; keep only fixture lifecycle tests that replace the global pool-clear callback in the sensitive collection.
- Isolate config CLI cases that change current directory or real environment variables; ordinary config parsing and validation uses injected environment readers and independent temporary roots, so the main suite remains parallelizable.
Expand Down Expand Up @@ -1991,6 +1992,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests"
- freshness diagnostic classification は parallel 実行し、`GitHelper` / `FileIndexer` test hook を一時的に置き換える stamped-case probe test だけを隔離する。
- production source の direct environment access policy scan は read-only で parallel-safe である。実 process variable を変更する別の `CdidxEnvironment` mutation contract だけを隔離する。
- directory を意図的に unreadable にする fixture は、repository と test output tree の外側にある一意な `TestProjectHelper.CreateTempProject(...)` workspace を所有する。default の multi-target 実行では target framework が並列に動くため、この種の fixture を `bin` / `obj` 配下に置くと、build-output filter が適用される前に別 target の read-only source-policy scan を失敗させる可能性がある (#5019)。
- `LocalJsonlJsonWriterOptionsTests` のような repository-wide source guard は、子要素を列挙する前に `bin` / `obj` directory を prune する。生成 directory のどちらかを開こうとしたら失敗する traversal seam の regression test を維持し、parallel unreadable fixture によって read-only policy scan が scheduling-dependent にならないようにする (#5142)。
- dependency boundary の source audit も read-only で parallel-safe である。CLI command metadata と config-source resolution の assertion は通常の collection に置き、rendering、command routing、config loading、environment access を再接続する変更が process-global test state を追加せず失敗するようにする。
- reflection だけを行う SQLite collection registration contract は parallel-safe である。global pool-clear callback を置き換える fixture lifecycle test だけを sensitive collection に残す。
- current directory または実 environment variable を変更する config CLI case を隔離する。通常の config parse / validation は注入された environment reader と独立 temporary root を使うため、main suite は parallel 実行可能に保つ。
Expand Down
16 changes: 16 additions & 0 deletions changelog.d/unreleased/5142.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 5142
affected:
- tests/CodeIndex.Tests/LocalJsonlJsonWriterOptionsTests.cs
- TESTING_GUIDE.md
---

## English

- **Local JSONL source guards no longer traverse generated test output (#5142)** — repository-wide guard enumeration now prunes `bin` and `obj` directories before opening their descendants, so parallel unreadable fixtures cannot make the Release suite fail based on scheduling.

## 日本語

- **Local JSONL の source guard が生成済み test output を走査しないようになりました (#5142)** — repository-wide guard の列挙は子要素を開く前に `bin` / `obj` directory を prune するため、parallel unreadable fixture の実行順によって Release suite が失敗しなくなりました。
99 changes: 83 additions & 16 deletions tests/CodeIndex.Tests/LocalJsonlJsonWriterOptionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,62 @@ public void RelaxedEncoderAndHelperStayLimitedToLocalJsonlSinks()
});
}

[Fact]
public void SourceEnumerationPrunesBuildOutputBeforeTraversingChildren()
{
var repositoryRoot = Path.Combine(Path.GetTempPath(), "repository");
var sourceRoot = Path.Combine(repositoryRoot, "src", "CodeIndex");
var sourceFeatureRoot = Path.Combine(sourceRoot, "Feature");
var testRoot = Path.Combine(repositoryRoot, "tests", "CodeIndex.Tests");
var sourceBinRoot = Path.Combine(sourceRoot, "bin");
var testObjRoot = Path.Combine(testRoot, "obj");
var buildOutputRoots = new HashSet<string>(StringComparer.Ordinal)
{
sourceBinRoot,
testObjRoot,
};
var directoryChildren = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
[sourceRoot] = [sourceFeatureRoot, sourceBinRoot],
[sourceFeatureRoot] = [],
[testRoot] = [testObjRoot],
};
var filesByDirectory = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
[sourceRoot] = [Path.Combine(sourceRoot, "Program.cs")],
[sourceFeatureRoot] = [Path.Combine(sourceFeatureRoot, "Worker.cs")],
[testRoot] = [Path.Combine(testRoot, "GuardTests.cs")],
};

IEnumerable<string> EnumerateDirectories(string directory)
{
Assert.DoesNotContain(directory, buildOutputRoots);
return directoryChildren.TryGetValue(directory, out var children) ? children : [];
}

IEnumerable<string> EnumerateFiles(string directory)
{
Assert.DoesNotContain(directory, buildOutputRoots);
return filesByDirectory.TryGetValue(directory, out var files) ? files : [];
}

var files = EnumerateSourceFiles(
repositoryRoot,
enumerateFiles: EnumerateFiles,
enumerateDirectories: EnumerateDirectories)
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();

Assert.Equal(
new[]
{
Path.Combine(sourceFeatureRoot, "Worker.cs"),
Path.Combine(sourceRoot, "Program.cs"),
Path.Combine(testRoot, "GuardTests.cs"),
}.OrderBy(path => path, StringComparer.Ordinal),
files);
}

[Fact]
public void Create_PreservesHtmlLikeCharactersWhileKeepingJsonlLineParseable()
{
Expand Down Expand Up @@ -91,30 +147,41 @@ private static void AssertOnlyAllowedFilesContain(
Assert.Empty(offenders);
}

private static IEnumerable<string> EnumerateSourceFiles(string repositoryRoot)
private static IEnumerable<string> EnumerateSourceFiles(
string repositoryRoot,
Func<string, IEnumerable<string>>? enumerateFiles = null,
Func<string, IEnumerable<string>>? enumerateDirectories = null)
{
foreach (var root in new[] { "src/CodeIndex", "tests/CodeIndex.Tests" })
enumerateFiles ??= directory =>
Directory.EnumerateFiles(directory, "*.cs", SearchOption.TopDirectoryOnly);
enumerateDirectories ??= Directory.EnumerateDirectories;

foreach (var root in new[]
{
Path.Combine("src", "CodeIndex"),
Path.Combine("tests", "CodeIndex.Tests"),
})
{
var fullRoot = Path.Combine(repositoryRoot, root);
foreach (var path in Directory.EnumerateFiles(fullRoot, "*.cs", SearchOption.AllDirectories))
var pendingDirectories = new Stack<string>();
pendingDirectories.Push(fullRoot);

while (pendingDirectories.TryPop(out var directory))
{
if (IsBuildOutput(path))
continue;
yield return path;
foreach (var path in enumerateFiles(directory))
yield return path;

foreach (var childDirectory in enumerateDirectories(directory))
{
if (!IsBuildOutputDirectory(childDirectory))
pendingDirectories.Push(childDirectory);
}
}
}
}

private static bool IsBuildOutput(string path)
{
foreach (var segment in path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
{
if (segment is "bin" or "obj")
return true;
}

return false;
}
private static bool IsBuildOutputDirectory(string path) =>
Path.GetFileName(Path.TrimEndingDirectorySeparator(path)) is "bin" or "obj";

private static string NormalizeRelativePath(string path) =>
path.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/');
Expand Down
Loading