diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index ed2a02a29..35d8af1ae 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -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. @@ -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 実行可能に保つ。 diff --git a/changelog.d/unreleased/5142.fixed.md b/changelog.d/unreleased/5142.fixed.md new file mode 100644 index 000000000..bc761f9b6 --- /dev/null +++ b/changelog.d/unreleased/5142.fixed.md @@ -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 が失敗しなくなりました。 diff --git a/tests/CodeIndex.Tests/LocalJsonlJsonWriterOptionsTests.cs b/tests/CodeIndex.Tests/LocalJsonlJsonWriterOptionsTests.cs index 11f084d8b..89ddcbd61 100644 --- a/tests/CodeIndex.Tests/LocalJsonlJsonWriterOptionsTests.cs +++ b/tests/CodeIndex.Tests/LocalJsonlJsonWriterOptionsTests.cs @@ -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(StringComparer.Ordinal) + { + sourceBinRoot, + testObjRoot, + }; + var directoryChildren = new Dictionary(StringComparer.Ordinal) + { + [sourceRoot] = [sourceFeatureRoot, sourceBinRoot], + [sourceFeatureRoot] = [], + [testRoot] = [testObjRoot], + }; + var filesByDirectory = new Dictionary(StringComparer.Ordinal) + { + [sourceRoot] = [Path.Combine(sourceRoot, "Program.cs")], + [sourceFeatureRoot] = [Path.Combine(sourceFeatureRoot, "Worker.cs")], + [testRoot] = [Path.Combine(testRoot, "GuardTests.cs")], + }; + + IEnumerable EnumerateDirectories(string directory) + { + Assert.DoesNotContain(directory, buildOutputRoots); + return directoryChildren.TryGetValue(directory, out var children) ? children : []; + } + + IEnumerable 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() { @@ -91,30 +147,41 @@ private static void AssertOnlyAllowedFilesContain( Assert.Empty(offenders); } - private static IEnumerable EnumerateSourceFiles(string repositoryRoot) + private static IEnumerable EnumerateSourceFiles( + string repositoryRoot, + Func>? enumerateFiles = null, + Func>? 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(); + 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, '/');