From 68c11830af38aa842ace86e51522305255af8564 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 14 Oct 2026 21:16:38 +0900 Subject: [PATCH 01/33] feat: Add the worker that lets compile-check check only changed files (#3315) --- .../unity-compile-check-and-test-runner.yml | 3 +- .../worker/AnalyzerAssemblyLoader.cs | 92 +++++ .../fastcheck/worker/ChangedFileChecker.cs | 173 ++++++++++ .../worker/DeclarationFingerprint.cs | 220 ++++++++++++ .../fastcheck/worker/FingerprintCommand.cs | 46 +++ .../compilecheck/fastcheck/worker/Program.cs | 109 ++++++ .../worker/ResponseFileCompilationLoader.cs | 214 ++++++++++++ .../SuppressibleCompilerDiagnosticRelay.cs | 60 ++++ .../worker/UncheckedRuleCollector.cs | 90 +++++ .../fastcheck/worker/WorkerRequest.cs | 35 ++ .../fastcheck/worker/WorkerResponse.cs | 67 ++++ ...only-changed-files-after-a-full-compile.md | 85 +++++ .../ChangedFileCheckerTests.cs | 314 ++++++++++++++++++ .../CompileCheckWorker.UnitTests.csproj | 26 ++ .../DeclarationFingerprintTests.cs | 240 +++++++++++++ .../ResponseFileCompilationLoaderTests.cs | 78 +++++ .../TestAnalyzerSources.cs | 213 ++++++++++++ .../WorkerFixture.cs | 138 ++++++++ 18 files changed, 2202 insertions(+), 1 deletion(-) create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/AnalyzerAssemblyLoader.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/ChangedFileChecker.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/DeclarationFingerprint.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/FingerprintCommand.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/Program.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/ResponseFileCompilationLoader.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/SuppressibleCompilerDiagnosticRelay.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/UncheckedRuleCollector.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerRequest.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerResponse.cs create mode 100644 docs/adr/0015-compile-check-checks-only-changed-files-after-a-full-compile.md create mode 100644 tests/CompileCheckWorker.UnitTests/ChangedFileCheckerTests.cs create mode 100644 tests/CompileCheckWorker.UnitTests/CompileCheckWorker.UnitTests.csproj create mode 100644 tests/CompileCheckWorker.UnitTests/DeclarationFingerprintTests.cs create mode 100644 tests/CompileCheckWorker.UnitTests/ResponseFileCompilationLoaderTests.cs create mode 100644 tests/CompileCheckWorker.UnitTests/TestAnalyzerSources.cs create mode 100644 tests/CompileCheckWorker.UnitTests/WorkerFixture.cs diff --git a/.github/workflows/unity-compile-check-and-test-runner.yml b/.github/workflows/unity-compile-check-and-test-runner.yml index d814cc1eae..5bbb597670 100644 --- a/.github/workflows/unity-compile-check-and-test-runner.yml +++ b/.github/workflows/unity-compile-check-and-test-runner.yml @@ -62,7 +62,7 @@ jobs: echo "$CHANGED" # Plugin import settings decide which precompiled assemblies a script assembly references. - if echo "$CHANGED" | grep -qE '^(Packages/src/Editor/.*\.cs|Packages/src/Runtime/.*\.cs|Assets/.*\.cs|tests/.*\.cs|tests/.*\.csproj|Packages/.*\.asmdef|Assets/.*\.asmdef|Packages/src/.*\.dll\.meta|Packages/manifest\.json|Packages/packages-lock\.json|ci/unity-projects/.*|\.github/workflows/unity-compile-check-and-test-runner\.yml)$'; then + if echo "$CHANGED" | grep -qE '^(Packages/src/Editor/.*\.cs|cli/dispatcher/internal/compilecheck/fastcheck/worker/.*\.cs|Packages/src/Runtime/.*\.cs|Assets/.*\.cs|tests/.*\.cs|tests/.*\.csproj|Packages/.*\.asmdef|Assets/.*\.asmdef|Packages/src/.*\.dll\.meta|Packages/manifest\.json|Packages/packages-lock\.json|ci/unity-projects/.*|\.github/workflows/unity-compile-check-and-test-runner\.yml)$'; then echo "csharp=true" >> "$GITHUB_OUTPUT" else echo "csharp=false" >> "$GITHUB_OUTPUT" @@ -85,6 +85,7 @@ jobs: dotnet test tests/SharedRoslynCompilerWorker.UnitTests/SharedRoslynCompilerWorker.UnitTests.csproj --configuration Release dotnet test tests/OutputFileRetention.UnitTests/OutputFileRetention.UnitTests.csproj --configuration Release dotnet test tests/RunTestsExecutionTimeout.UnitTests/RunTestsExecutionTimeout.UnitTests.csproj --configuration Release + dotnet test tests/CompileCheckWorker.UnitTests/CompileCheckWorker.UnitTests.csproj --configuration Release - name: Cache Library folder if: env.HAS_UNITY_LICENSE == 'true' diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/AnalyzerAssemblyLoader.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/AnalyzerAssemblyLoader.cs new file mode 100644 index 0000000000..c10ff208f2 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/AnalyzerAssemblyLoader.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Loads analyzer DLLs into the default context and resolves their dependencies from the + /// directories the analyzers came from. + /// + internal sealed class AnalyzerAssemblyLoader : IAnalyzerAssemblyLoader + { + private readonly object gate = new object(); + private readonly List directories = new List(); + + public void AddDependencyLocation(string fullPath) + { + RememberDirectoryOf(fullPath); + } + + public Assembly LoadFromPath(string fullPath) + { + RememberDirectoryOf(fullPath); + + // Analyzers each ship their own copy of shared dependencies, and the default context + // cannot load a second assembly with the same simple name, so the first one wins. + string simpleName = AssemblyName.GetAssemblyName(fullPath).Name; + Assembly loaded = FindLoaded(simpleName); + if (loaded != null) + { + return loaded; + } + + return AssemblyLoadContext.Default.LoadFromAssemblyPath(fullPath); + } + + public Assembly Resolve(AssemblyLoadContext context, AssemblyName assemblyName) + { + Assembly loaded = FindLoaded(assemblyName.Name); + if (loaded != null) + { + return loaded; + } + + foreach (string directory in SnapshotDirectories()) + { + string candidatePath = Path.Combine(directory, assemblyName.Name + ".dll"); + if (File.Exists(candidatePath)) + { + return context.LoadFromAssemblyPath(candidatePath); + } + } + + return null; + } + + private static Assembly FindLoaded(string simpleName) + { + return AssemblyLoadContext.Default.Assemblies + .FirstOrDefault(assembly => string.Equals(assembly.GetName().Name, simpleName, StringComparison.Ordinal)); + } + + private void RememberDirectoryOf(string fullPath) + { + string directory = Path.GetDirectoryName(fullPath); + if (string.IsNullOrEmpty(directory)) + { + return; + } + + lock (gate) + { + if (!directories.Contains(directory, StringComparer.Ordinal)) + { + directories.Add(directory); + } + } + } + + private List SnapshotDirectories() + { + lock (gate) + { + return new List(directories); + } + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/ChangedFileChecker.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/ChangedFileChecker.cs new file mode 100644 index 0000000000..7d0de75359 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/ChangedFileChecker.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Checks only the changed files of an assembly against the rest of it, reporting what csc + /// would report for those files, or why the assembly needs a full compile instead. + /// + internal static class ChangedFileChecker + { + private const string AnalyzerExceptionDiagnosticId = "AD0001"; + + internal static CheckResponse Check(CheckRequest request) + { + // The declaration check runs first: a changed declaration means a full compile anyway, + // and finding that out costs one parse instead of loading every reference and source. + Dictionary changedBytes = new Dictionary(StringComparer.Ordinal); + CheckResponse declarationChange = FindDeclarationChange(request, changedBytes); + if (declarationChange != null) + { + return declarationChange; + } + + // The changed files are compiled from the bytes their fingerprints were computed from, so + // an edit made after the fingerprint check cannot slip into the result unchecked. + LoadResult load = ResponseFileCompilationLoader.Load( + request.ProjectRoot, request.ResponseFilePath, request.GeneratedDirectory, changedBytes); + if (load.UnsupportedReason != null) + { + return CheckResponse.Unsupported(load.UnsupportedReason); + } + + List changedTrees = new List(); + foreach (string changed in request.ChangedFiles) + { + SyntaxTree tree; + if (!load.SourceTreesByPath.TryGetValue(Path.GetFullPath(changed), out tree)) + { + return CheckResponse.Unsupported("changed file is not a source of the response file: " + changed); + } + + changedTrees.Add(tree); + } + + ImmutableHashSet relayIds = CollectRelayIds(load.Analyzers); + ImmutableArray allAnalyzers = load.Analyzers.Add(new SuppressibleCompilerDiagnosticRelay(relayIds)); + CompilationWithAnalyzersOptions options = new CompilationWithAnalyzersOptions( + new AnalyzerOptions(load.AdditionalTexts), + null, + true, + false, + false); + CompilationWithAnalyzers withAnalyzers = load.Compilation.WithAnalyzers(allAnalyzers, options); + + List collected = new List(); + foreach (SyntaxTree tree in changedTrees) + { + collected.AddRange(CheckTree(withAnalyzers, tree, relayIds)); + } + + CultureInfo culture = load.Arguments.PreferredUILang ?? CultureInfo.CurrentUICulture; + // csc reports an analyzer that threw as AD0001 without a source location, which the + // per-file result would drop; a full compile reports it as csc does. + Diagnostic analyzerFailure = collected.FirstOrDefault(diagnostic => diagnostic.Id == AnalyzerExceptionDiagnosticId); + if (analyzerFailure != null) + { + return CheckResponse.Unsupported(FirstLine(analyzerFailure.GetMessage(culture))); + } + + return new CheckResponse + { + Outcome = CheckResponse.CheckedOutcome, + Diagnostics = ToWorkerDiagnostics(collected, culture), + UncheckedCodes = UncheckedRuleCollector.Collect(withAnalyzers, load.Compilation.Options), + GeneratorsNotRerun = load.GeneratorCount > 0, + }; + } + + private static CheckResponse FindDeclarationChange(CheckRequest request, Dictionary changedBytes) + { + CSharpCommandLineArguments arguments = ResponseFileCompilationLoader.ParseArguments(request.ProjectRoot, request.ResponseFilePath); + string argumentError = ResponseFileCompilationLoader.FirstArgumentError(arguments); + if (argumentError != null) + { + return CheckResponse.Unsupported("response file: " + argumentError); + } + + foreach (string changed in request.ChangedFiles) + { + string baseline; + if (request.BaselineFingerprints == null || !request.BaselineFingerprints.TryGetValue(changed, out baseline)) + { + return CheckResponse.DeclarationChanged("no baseline fingerprint for " + changed); + } + + byte[] bytes = File.ReadAllBytes(changed); + changedBytes[Path.GetFullPath(changed)] = bytes; + SyntaxTree tree = ResponseFileCompilationLoader.ParseSource(changed, bytes, arguments); + if (!string.Equals(DeclarationFingerprint.Compute(tree), baseline, StringComparison.Ordinal)) + { + return CheckResponse.DeclarationChanged(changed); + } + } + + return null; + } + + private static ImmutableHashSet CollectRelayIds(ImmutableArray analyzers) + { + return analyzers + .OfType() + .SelectMany(suppressor => suppressor.SupportedSuppressions) + .Select(suppression => suppression.SuppressedDiagnosticId) + .Where(id => id.StartsWith("CS", StringComparison.Ordinal)) + .ToImmutableHashSet(StringComparer.Ordinal); + } + + private static IEnumerable CheckTree(CompilationWithAnalyzers withAnalyzers, SyntaxTree tree, ImmutableHashSet relayIds) + { + SemanticModel model = withAnalyzers.Compilation.GetSemanticModel(tree); + // The relay reports these again with the suppressors applied, so keeping the originals + // would report them twice. + IEnumerable compilerDiagnostics = model.GetDiagnostics(null, CancellationToken.None) + .Where(diagnostic => !relayIds.Contains(diagnostic.Id)); + AnalysisResult treeResult = withAnalyzers.GetAnalysisResultAsync(tree, CancellationToken.None).Result; + AnalysisResult modelResult = withAnalyzers.GetAnalysisResultAsync(model, null, CancellationToken.None).Result; + return compilerDiagnostics + .Concat(treeResult.GetAllDiagnostics()) + .Concat(modelResult.GetAllDiagnostics()) + .ToList(); + } + + private static string FirstLine(string text) + { + int lineEnd = text.IndexOfAny(new char[] { '\r', '\n' }); + return lineEnd < 0 ? text : text.Substring(0, lineEnd); + } + + private static List ToWorkerDiagnostics(IEnumerable diagnostics, CultureInfo culture) + { + // Matches what csc prints: errors and warnings in source, each location and code once. + return diagnostics + .Where(diagnostic => !diagnostic.IsSuppressed && diagnostic.Location.IsInSource) + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error || diagnostic.Severity == DiagnosticSeverity.Warning) + .Select(diagnostic => ToWorkerDiagnostic(diagnostic, culture)) + .DistinctBy(diagnostic => (diagnostic.File, diagnostic.Line, diagnostic.Column, diagnostic.Code)) + .ToList(); + } + + private static WorkerDiagnostic ToWorkerDiagnostic(Diagnostic diagnostic, CultureInfo culture) + { + FileLinePositionSpan span = diagnostic.Location.GetMappedLineSpan(); + return new WorkerDiagnostic + { + File = Path.GetFullPath(span.Path), + Line = span.StartLinePosition.Line + 1, + Column = span.StartLinePosition.Character + 1, + Severity = diagnostic.Severity == DiagnosticSeverity.Error ? WorkerDiagnostic.ErrorSeverity : WorkerDiagnostic.WarningSeverity, + Code = diagnostic.Id, + Message = diagnostic.GetMessage(culture), + }; + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/DeclarationFingerprint.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/DeclarationFingerprint.cs new file mode 100644 index 0000000000..36f105bd76 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/DeclarationFingerprint.cs @@ -0,0 +1,220 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Hashes the part of a source file that other files and assemblies can see, so an edit that + /// keeps the hash only changed what runs inside bodies and initializers. + /// + internal static class DeclarationFingerprint + { + // The algorithm version. Bump it (fp2, ...) whenever the hashed text changes, so a baseline + // fingerprinted by an older worker never matches by accident. + private const string AlgorithmVersion = "fp1"; + + private static readonly Regex WhitespaceRun = new Regex(@"\s+", RegexOptions.CultureInvariant); + + internal static string Compute(SyntaxTree tree) + { + SyntaxNode stripped = new BodyStripper().Visit(tree.GetRoot()); + StringBuilder builder = new StringBuilder(AlgorithmVersion).Append('\n'); + foreach (SyntaxToken token in stripped.DescendantTokens()) + { + AppendDirectives(builder, token.LeadingTrivia); + builder.Append(token.Text).Append(' '); + AppendDirectives(builder, token.TrailingTrivia); + } + + return Sha256Hex(Encoding.UTF8.GetBytes(builder.ToString())); + } + + internal static string Sha256Hex(byte[] bytes) + { + using (SHA256 sha256 = SHA256.Create()) + { + // Lowercase, because the Go side compares these against hex.EncodeToString. + return string.Concat(sha256.ComputeHash(bytes).Select(b => b.ToString("x2"))); + } + } + + private static void AppendDirectives(StringBuilder builder, SyntaxTriviaList trivia) + { + foreach (SyntaxTrivia item in trivia) + { + // Directives and the text they disable decide which declarations exist, so an edit + // to them outside a body is a declaration edit. + if (!item.IsDirective && !item.IsKind(SyntaxKind.DisabledTextTrivia)) + { + continue; + } + + builder.Append(WhitespaceRun.Replace(item.ToString(), " ").Trim()).Append('\n'); + } + } + + /// + /// Replaces bodies and non-constant initializers with empty placeholders. A body is replaced, + /// not removed, because whether a member has one (an auto property, a default interface + /// method) is part of its declaration. + /// + private sealed class BodyStripper : CSharpSyntaxRewriter + { + public override SyntaxNode VisitMethodDeclaration(MethodDeclarationSyntax node) + { + return StripMethodBody(node, base.VisitMethodDeclaration(node)); + } + + public override SyntaxNode VisitConstructorDeclaration(ConstructorDeclarationSyntax node) + { + // The constructor initializer (`: base(...)`) stays: it is outside the body. + return StripMethodBody(node, base.VisitConstructorDeclaration(node)); + } + + public override SyntaxNode VisitDestructorDeclaration(DestructorDeclarationSyntax node) + { + return StripMethodBody(node, base.VisitDestructorDeclaration(node)); + } + + public override SyntaxNode VisitOperatorDeclaration(OperatorDeclarationSyntax node) + { + return StripMethodBody(node, base.VisitOperatorDeclaration(node)); + } + + public override SyntaxNode VisitConversionOperatorDeclaration(ConversionOperatorDeclarationSyntax node) + { + return StripMethodBody(node, base.VisitConversionOperatorDeclaration(node)); + } + + public override SyntaxNode VisitAccessorDeclaration(AccessorDeclarationSyntax node) + { + AccessorDeclarationSyntax accessor = (AccessorDeclarationSyntax)base.VisitAccessorDeclaration(node); + if (node.Body != null) + { + accessor = accessor.WithBody(EmptyBody(node.Body)); + } + + if (node.ExpressionBody != null) + { + accessor = accessor.WithExpressionBody(EmptyExpressionBody(node.ExpressionBody)); + } + + return accessor; + } + + public override SyntaxNode VisitPropertyDeclaration(PropertyDeclarationSyntax node) + { + PropertyDeclarationSyntax property = (PropertyDeclarationSyntax)base.VisitPropertyDeclaration(node); + if (node.ExpressionBody != null) + { + property = property.WithExpressionBody(EmptyExpressionBody(node.ExpressionBody)); + } + + if (node.Initializer != null && !IsInPartialType(node)) + { + property = property.WithInitializer(EmptyInitializer(node.Initializer)); + } + + return property; + } + + public override SyntaxNode VisitIndexerDeclaration(IndexerDeclarationSyntax node) + { + IndexerDeclarationSyntax indexer = (IndexerDeclarationSyntax)base.VisitIndexerDeclaration(node); + if (node.ExpressionBody != null) + { + indexer = indexer.WithExpressionBody(EmptyExpressionBody(node.ExpressionBody)); + } + + return indexer; + } + + public override SyntaxNode VisitFieldDeclaration(FieldDeclarationSyntax node) + { + // A constant's value is part of its declaration: other files can fold it. + if (node.Modifiers.Any(SyntaxKind.ConstKeyword) || IsInPartialType(node)) + { + return node; + } + + return node.ReplaceNodes( + node.Declaration.Variables.Where(variable => variable.Initializer != null).Select(variable => variable.Initializer), + (original, rewritten) => EmptyInitializer(original)); + } + + public override SyntaxNode VisitEventFieldDeclaration(EventFieldDeclarationSyntax node) + { + return node; + } + + private static bool IsInPartialType(SyntaxNode member) + { + // The nullable analysis of a constructor reads every field and property initializer of + // its type, and reports at the constructor or the field. In a partial type those can be + // in other files, so there an initializer edit counts as a declaration edit. + return member.Ancestors() + .OfType() + .Any(type => type.Modifiers.Any(SyntaxKind.PartialKeyword)); + } + + private static SyntaxNode StripMethodBody(BaseMethodDeclarationSyntax original, SyntaxNode visited) + { + // Local functions and lambdas live inside the body, so replacing the body covers them. + BaseMethodDeclarationSyntax method = (BaseMethodDeclarationSyntax)visited; + if (original.Body != null) + { + method = method.WithBody(EmptyBody(original.Body)); + } + + if (original.ExpressionBody != null) + { + method = method.WithExpressionBody(EmptyExpressionBody(original.ExpressionBody)); + } + + return method; + } + + private static BlockSyntax EmptyBody(BlockSyntax original) + { + SyntaxToken openBrace = SyntaxFactory.Token(SyntaxKind.OpenBraceToken) + .WithTrailingTrivia(NullableDirectivesIn(original)); + return SyntaxFactory.Block().WithOpenBraceToken(openBrace); + } + + private static ArrowExpressionClauseSyntax EmptyExpressionBody(ArrowExpressionClauseSyntax original) + { + return original.WithExpression(Placeholder(original)); + } + + private static EqualsValueClauseSyntax EmptyInitializer(EqualsValueClauseSyntax original) + { + return SyntaxFactory.EqualsValueClause(Placeholder(original)); + } + + private static IdentifierNameSyntax Placeholder(SyntaxNode original) + { + SyntaxToken identifier = SyntaxFactory.Identifier( + SyntaxFactory.TriviaList(), + "_", + NullableDirectivesIn(original)); + return SyntaxFactory.IdentifierName(identifier); + } + + private static SyntaxTriviaList NullableDirectivesIn(SyntaxNode original) + { + // A #nullable inside a body still changes the nullable context of the declarations + // after it, so it stays in the fingerprint at the position of the body it was in. + IEnumerable directives = original + .DescendantTrivia(descendIntoTrivia: true) + .Where(trivia => trivia.IsKind(SyntaxKind.NullableDirectiveTrivia)); + return SyntaxFactory.TriviaList(directives); + } + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/FingerprintCommand.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/FingerprintCommand.cs new file mode 100644 index 0000000000..078c50c75e --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/FingerprintCommand.cs @@ -0,0 +1,46 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Fingerprints every source of an assembly right after its full compile, to record the + /// baseline a later changed-files check compares against. + /// + internal static class FingerprintCommand + { + internal static FingerprintResponse Run(FingerprintRequest request) + { + CSharpCommandLineArguments arguments = ResponseFileCompilationLoader.ParseArguments(request.ProjectRoot, request.ResponseFilePath); + string argumentError = ResponseFileCompilationLoader.FirstArgumentError(arguments); + if (argumentError != null) + { + throw new InvalidOperationException("response file: " + argumentError); + } + + string[] sources = request.Sources.ToArray(); + string[] fingerprints = new string[sources.Length]; + string[] contentHashes = new string[sources.Length]; + Parallel.For(0, sources.Length, index => + { + // One read: the hash must describe the exact bytes the fingerprint was computed from. + byte[] bytes = File.ReadAllBytes(sources[index]); + contentHashes[index] = DeclarationFingerprint.Sha256Hex(bytes); + SyntaxTree tree = ResponseFileCompilationLoader.ParseSource(sources[index], bytes, arguments); + fingerprints[index] = DeclarationFingerprint.Compute(tree); + }); + + FingerprintResponse response = new FingerprintResponse(); + for (int index = 0; index < sources.Length; index++) + { + response.Fingerprints[sources[index]] = fingerprints[index]; + response.ContentHashes[sources[index]] = contentHashes[index]; + } + + return response; + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/Program.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/Program.cs new file mode 100644 index 0000000000..bb47fe7f32 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/Program.cs @@ -0,0 +1,109 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; +using System.Text; +using System.Text.Json; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Entry point of the changed-files check worker that compile-check builds and runs with the + /// Editor's dotnet and Roslyn. + /// + internal static class Program + { + private const int CompletedExitCode = 0; + private const int UsageExitCode = 2; + private const int UnexpectedFailureExitCode = 3; + private const string FingerprintCommandName = "fingerprint"; + private const string CheckCommandName = "check"; + private const string RoslynDirectorySidecarFileName = "roslyn-directory.txt"; + + private static int Main(string[] args) + { + if (args.Length != 3 || (args[0] != FingerprintCommandName && args[0] != CheckCommandName)) + { + Console.Error.WriteLine("usage: worker (fingerprint|check) "); + return UsageExitCode; + } + + try + { + RegisterRoslynResolver(); + RunCommand(args[0], args[1], args[2]); + return CompletedExitCode; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception.ToString()); + return UnexpectedFailureExitCode; + } + } + + // Kept out of Main so no Roslyn type is loaded before the resolver is registered. + [MethodImpl(MethodImplOptions.NoInlining)] + private static void RunCommand(string command, string inputPath, string outputPath) + { + if (command == FingerprintCommandName) + { + FingerprintRequest fingerprintRequest = ReadJson(inputPath); + WriteJson(outputPath, FingerprintCommand.Run(fingerprintRequest)); + return; + } + + CheckRequest checkRequest = ReadJson(inputPath); + WriteJson(outputPath, ChangedFileChecker.Check(checkRequest)); + } + + private static T ReadJson(string path) + { + T value = JsonSerializer.Deserialize(File.ReadAllBytes(path)); + if (value == null) + { + throw new InvalidOperationException("request file is empty: " + path); + } + + return value; + } + + private static void WriteJson(string path, T value) + { + // UTF-8 without a BOM, which the Go side reads on every platform. + File.WriteAllBytes(path, JsonSerializer.SerializeToUtf8Bytes(value)); + } + + private static void RegisterRoslynResolver() + { + // Roslyn ships with the Editor next to csc, not in the shared framework the worker runs on. + string roslynDirectoryPath = ReadRoslynDirectorySidecar(); + AssemblyLoadContext.Default.Resolving += (context, assemblyName) => + { + string candidatePath = Path.Combine(roslynDirectoryPath, assemblyName.Name + ".dll"); + if (File.Exists(candidatePath)) + { + return context.LoadFromAssemblyPath(candidatePath); + } + + return null; + }; + } + + private static string ReadRoslynDirectorySidecar() + { + string sidecarPath = Path.Combine(AppContext.BaseDirectory, RoslynDirectorySidecarFileName); + if (!File.Exists(sidecarPath)) + { + throw new InvalidOperationException("Roslyn directory sidecar not found next to worker.dll: " + sidecarPath); + } + + string roslynDirectoryPath = File.ReadAllText(sidecarPath, new UTF8Encoding(false)).Trim(); + if (string.IsNullOrEmpty(roslynDirectoryPath) || !Directory.Exists(roslynDirectoryPath)) + { + throw new InvalidOperationException("Roslyn directory from sidecar is missing: " + roslynDirectoryPath); + } + + return roslynDirectoryPath; + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/ResponseFileCompilationLoader.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/ResponseFileCompilationLoader.cs new file mode 100644 index 0000000000..71e79b6fe1 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/ResponseFileCompilationLoader.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Runtime.Loader; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Builds the compilation csc would build from a Bee response file, without running generators. + /// + internal static class ResponseFileCompilationLoader + { + private static readonly IReadOnlyDictionary EmptySources = ImmutableDictionary.Empty; + + internal static CSharpCommandLineArguments ParseArguments(string projectRoot, string responseFilePath) + { + // The same arguments compile-check passes to csc, so options such as -nowarn, + // -warnaserror, -ruleset, and -define come out as csc applies them. + string[] arguments = new string[] { "/nostdlib", "/noconfig", "@" + responseFilePath }; + return CSharpCommandLineParser.Default.Parse(arguments, projectRoot, null, null); + } + + internal static string FirstArgumentError(CSharpCommandLineArguments arguments) + { + Diagnostic error = arguments.Errors.FirstOrDefault(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); + return error == null ? null : error.GetMessage(); + } + + internal static SyntaxTree ParseSource(string path, byte[] bytes, CSharpCommandLineArguments arguments) + { + SourceText text = SourceText.From(new MemoryStream(bytes, writable: false), arguments.Encoding, arguments.ChecksumAlgorithm); + return CSharpSyntaxTree.ParseText(text, arguments.ParseOptions, path); + } + + /// Bytes to use instead of reading the file, keyed by full path. + internal static LoadResult Load( + string projectRoot, + string responseFilePath, + string generatedDirectory, + IReadOnlyDictionary preloadedSources) + { + CSharpCommandLineArguments arguments = ParseArguments(projectRoot, responseFilePath); + string argumentError = FirstArgumentError(arguments); + if (argumentError != null) + { + return LoadResult.Unsupported("response file: " + argumentError); + } + + // Editor config files would need the per-tree options csc derives from them. + if (arguments.AnalyzerConfigPaths.Length > 0) + { + return LoadResult.Unsupported("analyzerconfig is not supported by the changed-files check"); + } + + string[] sourcePaths = arguments.SourceFiles.Select(source => source.Path).ToArray(); + SyntaxTree[] sourceTrees = ParseFiles(sourcePaths, arguments, preloadedSources); + SyntaxTree[] generatedTrees = ParseFiles(ListGeneratedFiles(generatedDirectory), arguments, EmptySources); + MetadataReference[] references = CreateReferences(projectRoot, arguments); + + AnalyzerAssemblyLoader loader = new AnalyzerAssemblyLoader(); + AssemblyLoadContext.Default.Resolving += loader.Resolve; + List analyzerReferences = arguments.ResolveAnalyzerReferences(loader).ToList(); + ImmutableArray analyzers = analyzerReferences + .SelectMany(reference => reference.GetAnalyzers(LanguageNames.CSharp)) + .ToImmutableArray(); + // Generators are counted, never run: the output of the last full compile is read instead. + int generatorCount = analyzerReferences.Sum(reference => reference.GetGenerators(LanguageNames.CSharp).Length); + ImmutableArray additionalTexts = arguments.AdditionalFiles + .Select(file => (AdditionalText)new FileAdditionalText(file.Path, arguments.Encoding)) + .ToImmutableArray(); + + CSharpCompilation compilation = CSharpCompilation.Create( + arguments.CompilationName, + sourceTrees.Concat(generatedTrees), + references, + arguments.CompilationOptions.WithAssemblyIdentityComparer(DesktopAssemblyIdentityComparer.Default)); + + Dictionary sourceTreesByPath = new Dictionary(StringComparer.Ordinal); + foreach (SyntaxTree tree in sourceTrees) + { + sourceTreesByPath[Path.GetFullPath(tree.FilePath)] = tree; + } + + return LoadResult.Loaded(compilation, analyzers, additionalTexts, generatorCount, sourceTreesByPath, arguments); + } + + private static SyntaxTree[] ParseFiles( + string[] paths, + CSharpCommandLineArguments arguments, + IReadOnlyDictionary preloadedSources) + { + // Keeps the order of the paths: the order of the trees decides the order csc binds them. + SyntaxTree[] trees = new SyntaxTree[paths.Length]; + Parallel.For(0, paths.Length, index => + { + byte[] bytes; + if (!preloadedSources.TryGetValue(Path.GetFullPath(paths[index]), out bytes)) + { + bytes = File.ReadAllBytes(paths[index]); + } + + trees[index] = ParseSource(paths[index], bytes, arguments); + }); + return trees; + } + + private static string[] ListGeneratedFiles(string generatedDirectory) + { + if (string.IsNullOrEmpty(generatedDirectory) || !Directory.Exists(generatedDirectory)) + { + return Array.Empty(); + } + + string[] files = Directory.GetFiles(generatedDirectory, "*.cs", SearchOption.AllDirectories); + Array.Sort(files, StringComparer.Ordinal); + return files; + } + + private static MetadataReference[] CreateReferences(string projectRoot, CSharpCommandLineArguments arguments) + { + CommandLineReference[] commandLineReferences = arguments.MetadataReferences.ToArray(); + MetadataReference[] references = new MetadataReference[commandLineReferences.Length]; + Parallel.For(0, commandLineReferences.Length, index => + { + CommandLineReference reference = commandLineReferences[index]; + string path = Path.GetFullPath(Path.Combine(projectRoot, reference.Reference)); + references[index] = MetadataReference.CreateFromFile(path, reference.Properties, null); + }); + return references; + } + + /// + /// An additional file (for example an analyzer's settings file) read from disk on demand. + /// + private sealed class FileAdditionalText : AdditionalText + { + private readonly Encoding encoding; + + public FileAdditionalText(string path, Encoding encoding) + { + Path = path; + this.encoding = encoding; + } + + public override string Path { get; } + + public override SourceText GetText(CancellationToken cancellationToken = default) + { + using (FileStream stream = File.OpenRead(Path)) + { + return SourceText.From(stream, encoding); + } + } + } + } + + /// + /// The compilation and analyzers of one assembly, or the reason it could not be built. + /// + internal sealed class LoadResult + { + private LoadResult() + { + } + + public string UnsupportedReason { get; private set; } + + public CSharpCompilation Compilation { get; private set; } + + public ImmutableArray Analyzers { get; private set; } + + public ImmutableArray AdditionalTexts { get; private set; } + + public int GeneratorCount { get; private set; } + + // Keyed by the full path of each source of the response file. + public IReadOnlyDictionary SourceTreesByPath { get; private set; } + + public CSharpCommandLineArguments Arguments { get; private set; } + + public static LoadResult Unsupported(string reason) + { + return new LoadResult { UnsupportedReason = reason }; + } + + public static LoadResult Loaded( + CSharpCompilation compilation, + ImmutableArray analyzers, + ImmutableArray additionalTexts, + int generatorCount, + IReadOnlyDictionary sourceTreesByPath, + CSharpCommandLineArguments arguments) + { + return new LoadResult + { + Compilation = compilation, + Analyzers = analyzers, + AdditionalTexts = additionalTexts, + GeneratorCount = generatorCount, + SourceTreesByPath = sourceTreesByPath, + Arguments = arguments, + }; + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SuppressibleCompilerDiagnosticRelay.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SuppressibleCompilerDiagnosticRelay.cs new file mode 100644 index 0000000000..89980d17cd --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SuppressibleCompilerDiagnosticRelay.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Reports again, from an analyzer, the compiler diagnostics a DiagnosticSuppressor can + /// suppress, so the suppressors of the assembly apply to them in a per-file analysis. + /// + /// + /// A per-file analysis does not hand compiler diagnostics to suppressors. Diagnostics an + /// analyzer reports do get there. The relay uses a syntax tree action, not a semantic model + /// action: without /doc a per-file analysis never completes a compilation unit, so semantic + /// model actions never run. The IDE's internal compiler analyzer is not used because it + /// reports nothing on Roslyn 4.10 and later. + /// + internal sealed class SuppressibleCompilerDiagnosticRelay : DiagnosticAnalyzer + { + private readonly ImmutableHashSet relayedIds; + private readonly ImmutableArray descriptors; + + public SuppressibleCompilerDiagnosticRelay(IEnumerable relayedIds) + { + this.relayedIds = relayedIds.ToImmutableHashSet(); + descriptors = this.relayedIds + .OrderBy(id => id, System.StringComparer.Ordinal) + .Select(id => new DiagnosticDescriptor(id, id, id, "Compiler", DiagnosticSeverity.Warning, isEnabledByDefault: true)) + .ToImmutableArray(); + } + + public override ImmutableArray SupportedDiagnostics => descriptors; + + public override void Initialize(AnalysisContext context) + { + // csc reports compiler diagnostics in generated code too. + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(startContext => + { + Compilation compilation = startContext.Compilation; + startContext.RegisterSyntaxTreeAction(treeContext => Relay(compilation, treeContext)); + }); + } + + private void Relay(Compilation compilation, SyntaxTreeAnalysisContext context) + { + SemanticModel model = compilation.GetSemanticModel(context.Tree); + foreach (Diagnostic diagnostic in model.GetDiagnostics(null, context.CancellationToken)) + { + if (relayedIds.Contains(diagnostic.Id)) + { + context.ReportDiagnostic(diagnostic); + } + } + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/UncheckedRuleCollector.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/UncheckedRuleCollector.cs new file mode 100644 index 0000000000..8d137c5424 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/UncheckedRuleCollector.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Diagnostics.Telemetry; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Lists the diagnostic IDs that a changed-files check cannot report but csc could, so the + /// output can tell the caller what still needs a full compile. + /// + internal static class UncheckedRuleCollector + { + // The compiler reports these unused-member warnings only after it has seen the whole assembly. + private static readonly string[] AssemblyWideCompilerCodes = new string[] { "CS0067", "CS0169", "CS0414", "CS0649" }; + + internal static List Collect(CompilationWithAnalyzers withAnalyzers, CompilationOptions options) + { + SortedSet codes = new SortedSet(StringComparer.Ordinal); + foreach (string id in AssemblyWideCompilerCodes) + { + ReportDiagnostic option; + if (!options.SpecificDiagnosticOptions.TryGetValue(id, out option) || option != ReportDiagnostic.Suppress) + { + codes.Add(id); + } + } + + foreach (DiagnosticAnalyzer analyzer in withAnalyzers.Analyzers) + { + if (analyzer is SuppressibleCompilerDiagnosticRelay || analyzer is DiagnosticSuppressor) + { + continue; + } + + if (!RunsOnlyOnTheWholeCompilation(withAnalyzers, analyzer)) + { + continue; + } + + foreach (DiagnosticDescriptor descriptor in analyzer.SupportedDiagnostics) + { + if (IsReportedAsErrorOrWarning(descriptor, options)) + { + codes.Add(descriptor.Id); + } + } + } + + return codes.ToList(); + } + + private static bool RunsOnlyOnTheWholeCompilation(CompilationWithAnalyzers withAnalyzers, DiagnosticAnalyzer analyzer) + { + // Semantic model actions count too: a per-file analysis without /doc never runs them. So do + // symbol start actions, for the symbol end actions they register: a partial type declared + // across files is not finished by analyzing only the changed ones. Why symbol start rather + // than symbol end: symbol end actions are registered inside symbol start, and the telemetry + // does not count them, so an analyzer that uses one reports zero symbol end actions. + AnalyzerTelemetryInfo info = withAnalyzers.GetAnalyzerTelemetryInfoAsync(analyzer, CancellationToken.None).Result; + return info.CompilationActionsCount + info.CompilationEndActionsCount + + info.SemanticModelActionsCount + info.SymbolStartActionsCount > 0; + } + + private static bool IsReportedAsErrorOrWarning(DiagnosticDescriptor descriptor, CompilationOptions options) + { + ReportDiagnostic option; + if (!options.SpecificDiagnosticOptions.TryGetValue(descriptor.Id, out option)) + { + option = descriptor.IsEnabledByDefault ? ReportDiagnostic.Default : ReportDiagnostic.Suppress; + } + + switch (option) + { + case ReportDiagnostic.Error: + case ReportDiagnostic.Warn: + return true; + case ReportDiagnostic.Default: + // A default-severity warning becomes an error under -warnaserror; both are reported. + return descriptor.DefaultSeverity == DiagnosticSeverity.Error + || descriptor.DefaultSeverity == DiagnosticSeverity.Warning; + default: + return false; + } + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerRequest.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerRequest.cs new file mode 100644 index 0000000000..7ac9b65b73 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerRequest.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +namespace UnityCliLoop.CompileCheckWorker +{ + // The JSON shapes the Go side writes for the worker. Property names stay PascalCase because the + // Go structs marshal their field names as they are. + + /// + /// Asks for the declaration fingerprint and the content hash of every source of one assembly. + /// + internal sealed class FingerprintRequest + { + public string ProjectRoot { get; set; } + + public string ResponseFilePath { get; set; } + + public List Sources { get; set; } + } + + /// + /// Asks to check only the changed files of an assembly whose last full compile succeeded. + /// + internal sealed class CheckRequest + { + public string ProjectRoot { get; set; } + + public string ResponseFilePath { get; set; } + + public List ChangedFiles { get; set; } + + public Dictionary BaselineFingerprints { get; set; } + + public string GeneratedDirectory { get; set; } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerResponse.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerResponse.cs new file mode 100644 index 0000000000..691e82b603 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerResponse.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Fingerprints and content hashes keyed by the absolute source path of the request. + /// + internal sealed class FingerprintResponse + { + public Dictionary Fingerprints { get; set; } = new Dictionary(); + + // The sha256 of the exact bytes the fingerprint was computed from, so the caller can tell + // whether the file it compiled is the file that was fingerprinted. + public Dictionary ContentHashes { get; set; } = new Dictionary(); + } + + /// + /// Result of a changed-files check. Only "Checked" carries diagnostics the caller may report. + /// + internal sealed class CheckResponse + { + public const string CheckedOutcome = "Checked"; + public const string DeclarationChangedOutcome = "DeclarationChanged"; + public const string UnsupportedOutcome = "Unsupported"; + + public string Outcome { get; set; } + + public string Reason { get; set; } = ""; + + public List Diagnostics { get; set; } = new List(); + + public List UncheckedCodes { get; set; } = new List(); + + public bool GeneratorsNotRerun { get; set; } + + public static CheckResponse DeclarationChanged(string reason) + { + return new CheckResponse { Outcome = DeclarationChangedOutcome, Reason = reason }; + } + + public static CheckResponse Unsupported(string reason) + { + return new CheckResponse { Outcome = UnsupportedOutcome, Reason = reason }; + } + } + + /// + /// One error or warning, located the way csc reports it (1-based line and column). + /// + internal sealed class WorkerDiagnostic + { + public const string ErrorSeverity = "error"; + public const string WarningSeverity = "warning"; + + public string File { get; set; } + + public int Line { get; set; } + + public int Column { get; set; } + + public string Severity { get; set; } + + public string Code { get; set; } + + public string Message { get; set; } + } +} diff --git a/docs/adr/0015-compile-check-checks-only-changed-files-after-a-full-compile.md b/docs/adr/0015-compile-check-checks-only-changed-files-after-a-full-compile.md new file mode 100644 index 0000000000..e6bd90222f --- /dev/null +++ b/docs/adr/0015-compile-check-checks-only-changed-files-after-a-full-compile.md @@ -0,0 +1,85 @@ +# Compile Check Checks Only the Changed Files After a Full Compile + +Date: 2026-10-14 + +## Decision + +`uloop compile-check` checks only the changed files of an assembly, instead of compiling the +whole assembly with csc, when both of these hold: + +- The last time compile-check compiled that assembly in full, the compile succeeded, and the + inputs other than the source contents (the compiler, the response file, the references, the + analyzers and their inputs, the output name, and the list of source paths) are still the same. +- Every changed file has the same declaration fingerprint as at that full compile: only method + bodies, accessor bodies, expression bodies, non-constant initializers, comments, or whitespace + changed. Anything another file or assembly can see (signatures, constants, attributes, + parameter defaults, `using` directives, preprocessor directives outside bodies, `#nullable` + anywhere) is part of the fingerprint. So are the initializers of fields and properties of a + partial type: the nullable analysis of a constructor reads every initializer of the type, and + its warnings land in whichever file declares the constructor or the field. + +In every other case it compiles the assembly in full, as before, and reports why. It also falls +back to a full compile when an analyzer throws while checking a changed file, because csc then +reports that failure as AD0001. `--full` turns the changed-files check off for one run. + +The check runs in a C# worker built on the user's machine with the Roslyn that ships with the +Editor, so the worker uses only the public Roslyn API that Roslyn 4.3.1 already has. + +## What It Guarantees + +- The compiler diagnostics and the analyzer diagnostics of the changed files, with the + suppressions of `DiagnosticSuppressor`s applied, and with the options of the response file + (`-nowarn`, `-warnaserror`, `-ruleset`, `-define`, `-nullable`) applied. +- For the files that did not change, the diagnostics of the last full compile. An edit the + fingerprint accepts does not change what the compiler binds or analyzes in another file, so + their binding and flow-analysis diagnostics stay as they were. The exceptions are the + assembly-wide diagnostics listed below. + +## What It Does Not Guarantee + +The output tells the caller to run `--full` before finishing whenever an assembly was checked +this way, and lists by ID the diagnostics of the first two items below: + +- CS0067, CS0169, CS0414, and CS0649. The compiler reports these unused-member warnings only + after it has seen the whole assembly. +- The rules of analyzers that use Compilation, CompilationEnd, SemanticModel, or SymbolStart + actions. A per-file analysis does not run them, or cannot finish a symbol declared across + files. +- Errors the compiler reports only while emitting the assembly, after binding: for example + CS8178 (`await` in an expression that also calls a method returning by reference) and CS0656 + (a missing compiler-required member, such as the runtime binder `dynamic` needs). The check + does not emit, because emitting the whole assembly would cost the time it saves. +- Source generators. The check reads the generator output saved by the last full compile + instead of running them again, and does not report their diagnostics. A generator that reads + method bodies can produce different output after a body-only edit. + +## Context + +compile-check compiles each changed assembly in full with csc. In a large project (about 3,000 +files in one assembly, about 400 references, and about 30 analyzer DLLs) a one-line edit of a +method body took 17–22 seconds, and about 8.5 seconds of that is the analysis of every file in +the assembly, which a whole-assembly compile cannot avoid. A prototype that checked only the +changed file through the Roslyn API, reusing the generator output of the last full compile, +took 2.7 seconds for the same edit, and its per-file diagnostics matched csc for every file of +that assembly except CS0414. + +Compiler diagnostics are not suppressed by `DiagnosticSuppressor`s in a per-file analysis. A +relay analyzer built on the public API (it reports the compiler diagnostics that a suppressor +can suppress again from a syntax tree action) makes the suppressors apply on Roslyn 4.3, 4.10, +and 5.6. + +## Considered Options + +- **Use the internal `CSharpCompilerDiagnosticAnalyzer`, as the IDE does.** Rejected: it works + only on Roslyn 4.3.1; on 4.10 and later it returns no diagnostics at all. +- **Set `DocumentationMode.Parse` so that semantic model actions run per file.** Rejected: it + changes what documentation-comment rules such as StyleCop's SA0001 and SA1600 family report, + so the check would disagree with csc. +- **Keep a resident worker process with the compilation loaded.** Out of scope for now: it held + about 1.3 GB per assembly, and the per-run worker is already fast enough. + +## Revisit When + +- Two bug reports trace back to something the check does not guarantee. Then reconsider a + resident process that checks the whole assembly, or turning the changed-files check off by + default. diff --git a/tests/CompileCheckWorker.UnitTests/ChangedFileCheckerTests.cs b/tests/CompileCheckWorker.UnitTests/ChangedFileCheckerTests.cs new file mode 100644 index 0000000000..c5ff394cf9 --- /dev/null +++ b/tests/CompileCheckWorker.UnitTests/ChangedFileCheckerTests.cs @@ -0,0 +1,314 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Microsoft.CodeAnalysis; +using NUnit.Framework; + +namespace UnityCliLoop.CompileCheckWorker.UnitTests +{ + [TestFixture] + public sealed class ChangedFileCheckerTests + { + private const string ProbeTemplate = + "namespace Sample.Game\n" + + "{\n" + + " public static class Probe\n" + + " {\n" + + " public static void Run()\n" + + " {\n" + + " {0}\n" + + " }\n" + + " }\n" + + "}\n"; + + private WorkerFixture fixture; + + [SetUp] + public void SetUp() + { + fixture = new WorkerFixture(); + } + + [TearDown] + public void TearDown() + { + fixture.Dispose(); + } + + /// + /// Verifies that a compiler error in a changed file is reported with the file, line, and column csc reports. + /// + [Test] + public void ReportsCompilerErrorInChangedFile() + { + string probe = fixture.AddSource("Probe.cs", Probe("int value = \"s\";")); + + CheckResponse response = CheckWithCurrentBaseline(probe); + + Assert.That(response.Outcome, Is.EqualTo(CheckResponse.CheckedOutcome)); + WorkerDiagnostic error = response.Diagnostics.Single(diagnostic => diagnostic.Code == "CS0029"); + Assert.That(error.Severity, Is.EqualTo(WorkerDiagnostic.ErrorSeverity)); + Assert.That(error.File, Is.EqualTo(Path.GetFullPath(probe))); + Assert.That(error.Line, Is.EqualTo(7)); + Assert.That(error.Column, Is.EqualTo(25)); + } + + /// + /// Verifies that a DiagnosticSuppressor of the assembly suppresses a compiler warning in a changed file. + /// + [Test] + public void SuppressorSuppressesRelayedCompilerWarning() + { + fixture.AddAnalyzer(TestAnalyzerSources.SuppressorAssemblyName, TestAnalyzerSources.Suppressor); + string probe = fixture.AddSource("Probe.cs", Probe("int suppressedValue = 1;")); + + CheckResponse response = CheckWithCurrentBaseline(probe); + + Assert.That(response.Outcome, Is.EqualTo(CheckResponse.CheckedOutcome)); + Assert.That(Codes(response), Does.Not.Contain("CS0219")); + } + + /// + /// Verifies that a compiler warning the suppressor declines is still reported once, as a warning. + /// + [Test] + public void UnsuppressedRelayedWarningIsReported() + { + fixture.AddAnalyzer(TestAnalyzerSources.SuppressorAssemblyName, TestAnalyzerSources.Suppressor); + string probe = fixture.AddSource("Probe.cs", Probe("int keptValue = 1;")); + + CheckResponse response = CheckWithCurrentBaseline(probe); + + WorkerDiagnostic warning = response.Diagnostics.Single(diagnostic => diagnostic.Code == "CS0219"); + Assert.That(warning.Severity, Is.EqualTo(WorkerDiagnostic.WarningSeverity)); + } + + /// + /// Verifies that -warnaserror+ in the response file reports a compiler warning as an error. + /// + [Test] + public void WarnAsErrorPromotesWarning() + { + fixture.AddArgument("-warnaserror+"); + string probe = fixture.AddSource("Probe.cs", Probe("int keptValue = 1;")); + + CheckResponse response = CheckWithCurrentBaseline(probe); + + WorkerDiagnostic promoted = response.Diagnostics.Single(diagnostic => diagnostic.Code == "CS0219"); + Assert.That(promoted.Severity, Is.EqualTo(WorkerDiagnostic.ErrorSeverity)); + } + + /// + /// Verifies that -nowarn in the response file drops the warning. + /// + [Test] + public void NowarnDropsWarning() + { + fixture.AddArgument("-nowarn:CS0219"); + string probe = fixture.AddSource("Probe.cs", Probe("int keptValue = 1;")); + + CheckResponse response = CheckWithCurrentBaseline(probe); + + Assert.That(response.Outcome, Is.EqualTo(CheckResponse.CheckedOutcome)); + Assert.That(Codes(response), Does.Not.Contain("CS0219")); + } + + /// + /// Verifies that a syntax node analyzer of the assembly reports its diagnostic in a changed file. + /// + [Test] + public void AnalyzerDiagnosticInChangedFileIsReported() + { + fixture.AddAnalyzer(TestAnalyzerSources.NodeAnalyzerAssemblyName, TestAnalyzerSources.NodeAnalyzer); + string probe = fixture.AddSource("Probe.cs", Probe("int flagged = 1; System.GC.KeepAlive(flagged);")); + + CheckResponse response = CheckWithCurrentBaseline(probe); + + WorkerDiagnostic diagnostic = response.Diagnostics.Single(item => item.Code == "TSA001"); + Assert.That(diagnostic.File, Is.EqualTo(Path.GetFullPath(probe))); + } + + /// + /// Verifies that errors in files that did not change are not reported. + /// + [Test] + public void DiagnosticsInUnchangedFilesAreNotReported() + { + string probe = fixture.AddSource("Probe.cs", Probe("System.GC.KeepAlive(1);")); + fixture.AddSource("Other.cs", "class Other { void M() { int value = \"s\"; } }"); + + CheckResponse response = CheckWithCurrentBaseline(probe); + + Assert.That(response.Outcome, Is.EqualTo(CheckResponse.CheckedOutcome)); + Assert.That(response.Diagnostics, Is.Empty); + } + + /// + /// Verifies that a changed file whose declarations differ from the baseline asks for a full compile. + /// + [Test] + public void DeclarationChangeReturnsDeclarationChanged() + { + string probe = fixture.AddSource("Probe.cs", Probe("System.GC.KeepAlive(1);")); + Dictionary baseline = fixture.FingerprintSources(); + File.WriteAllText(probe, Probe("System.GC.KeepAlive(1); } public static void Added() {")); + + CheckResponse response = fixture.Check(baseline, "", probe); + + Assert.That(response.Outcome, Is.EqualTo(CheckResponse.DeclarationChangedOutcome)); + Assert.That(response.Diagnostics, Is.Empty); + } + + /// + /// Verifies that a changed file with no baseline fingerprint asks for a full compile. + /// + [Test] + public void MissingBaselineFingerprintReturnsDeclarationChanged() + { + string probe = fixture.AddSource("Probe.cs", Probe("System.GC.KeepAlive(1);")); + + CheckResponse response = fixture.Check(new Dictionary(), "", probe); + + Assert.That(response.Outcome, Is.EqualTo(CheckResponse.DeclarationChangedOutcome)); + Assert.That(response.Reason, Does.Contain(probe)); + } + + /// + /// Verifies that a response file with an analyzer config is reported as unsupported. + /// + [Test] + public void AnalyzerConfigReturnsUnsupported() + { + string configPath = fixture.WriteFile(".editorconfig", "root = true\n"); + fixture.AddArgument("-analyzerconfig:\"" + configPath + "\""); + string probe = fixture.AddSource("Probe.cs", Probe("System.GC.KeepAlive(1);")); + + CheckResponse response = CheckWithCurrentBaseline(probe); + + Assert.That(response.Outcome, Is.EqualTo(CheckResponse.UnsupportedOutcome)); + Assert.That(response.Reason, Does.Contain("analyzerconfig")); + } + + /// + /// Verifies that the rules a per-file check cannot run are listed: the rules of analyzers with + /// CompilationEnd, SymbolEnd, SemanticModel, or Compilation actions and the unused-member compiler + /// warnings, minus those turned off with -nowarn. + /// + [Test] + public void UncheckedCodesListCompilationEndAndUnusedFieldRules() + { + fixture.AddAnalyzer(TestAnalyzerSources.CompilationEndAnalyzerAssemblyName, TestAnalyzerSources.CompilationEndAnalyzer); + fixture.AddAnalyzer(TestAnalyzerSources.SymbolEndAnalyzerAssemblyName, TestAnalyzerSources.SymbolEndAnalyzer); + fixture.AddAnalyzer(TestAnalyzerSources.WholeFileAnalyzersAssemblyName, TestAnalyzerSources.WholeFileAnalyzers); + string probe = fixture.AddSource("Probe.cs", Probe("System.GC.KeepAlive(1);")); + + CheckResponse response = CheckWithCurrentBaseline(probe); + + Assert.That(response.UncheckedCodes, Is.EqualTo(new[] { "CS0067", "CS0169", "CS0414", "CS0649", "TSA002", "TSA003", "TSA004", "TSA005" })); + + fixture.AddArgument("-nowarn:CS0414"); + CheckResponse withNowarn = CheckWithCurrentBaseline(probe); + + Assert.That(withNowarn.UncheckedCodes, Is.EqualTo(new[] { "CS0067", "CS0169", "CS0649", "TSA002", "TSA003", "TSA004", "TSA005" })); + } + + /// + /// Verifies that types from the saved generator output are visible to a changed file. + /// + [Test] + public void GeneratedDirectoryTreesAreVisibleToChangedFile() + { + string generatedDirectory = Path.Combine(fixture.Root, "Sample.Game.generated"); + fixture.WriteFile("Sample.Game.generated/Generator/Made.g.cs", "namespace Generated { public static class Made { public static int Value = 1; } }"); + string probe = fixture.AddSource("Probe.cs", Probe("int value = Generated.Made.Value; System.GC.KeepAlive(value);")); + Dictionary baseline = fixture.FingerprintSources(); + + CheckResponse response = fixture.Check(baseline, generatedDirectory, probe); + + Assert.That(response.Outcome, Is.EqualTo(CheckResponse.CheckedOutcome)); + Assert.That(response.Diagnostics, Is.Empty); + } + + /// + /// Verifies that the content hash a fingerprint request returns is the sha256 of the exact bytes on + /// disk, and that the fingerprint is the one computed from those bytes. + /// + [Test] + public void FingerprintReturnsContentHashOfParsedBytes() + { + string probe = fixture.AddSource("Probe.cs", ""); + byte[] bytes = new UTF8Encoding(true).GetPreamble() + .Concat(Encoding.UTF8.GetBytes("class Probe\r\n{\r\n void M() { }\r\n}\r\n")) + .ToArray(); + File.WriteAllBytes(probe, bytes); + fixture.WriteResponseFile(); + + FingerprintResponse response = FingerprintCommand.Run(new FingerprintRequest + { + ProjectRoot = fixture.Root, + ResponseFilePath = fixture.ResponseFilePath, + Sources = new List { probe }, + }); + + string expectedHash; + using (SHA256 sha256 = SHA256.Create()) + { + expectedHash = string.Concat(sha256.ComputeHash(bytes).Select(b => b.ToString("x2"))); + } + + Assert.That(response.ContentHashes[probe], Is.EqualTo(expectedHash)); + SyntaxTree tree = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText("class Probe\n{\n void M() { }\n}\n"); + Assert.That(response.Fingerprints[probe], Is.EqualTo(DeclarationFingerprint.Compute(tree))); + } + + /// + /// Verifies that an analyzer throwing while a changed file is checked sends the assembly to a full + /// compile, naming the analyzer, instead of reporting a clean result. + /// + [Test] + public void AnalyzerExceptionReturnsUnsupported() + { + fixture.AddAnalyzer(TestAnalyzerSources.ThrowingAnalyzerAssemblyName, TestAnalyzerSources.ThrowingAnalyzer); + string probe = fixture.AddSource("Probe.cs", Probe("int boom = 1; System.GC.KeepAlive(boom);")); + + CheckResponse response = CheckWithCurrentBaseline(probe); + + Assert.That(response.Outcome, Is.EqualTo(CheckResponse.UnsupportedOutcome)); + Assert.That(response.Reason, Does.Contain("ThrowingAnalyzer")); + } + + /// + /// Verifies that the response says generators were not run again exactly when the assembly has one. + /// + [Test] + public void GeneratorsNotRerunFollowsTheAssemblyGenerators() + { + string probe = fixture.AddSource("Probe.cs", Probe("System.GC.KeepAlive(1);")); + + CheckResponse withoutGenerator = CheckWithCurrentBaseline(probe); + fixture.AddAnalyzer(TestAnalyzerSources.GeneratorAssemblyName, TestAnalyzerSources.Generator); + CheckResponse withGenerator = CheckWithCurrentBaseline(probe); + + Assert.That(withoutGenerator.GeneratorsNotRerun, Is.False); + Assert.That(withGenerator.GeneratorsNotRerun, Is.True); + } + + private CheckResponse CheckWithCurrentBaseline(string changedFile) + { + Dictionary baseline = fixture.FingerprintSources(); + return fixture.Check(baseline, "", changedFile); + } + + private static List Codes(CheckResponse response) + { + return response.Diagnostics.Select(diagnostic => diagnostic.Code).ToList(); + } + + private static string Probe(string statement) + { + return ProbeTemplate.Replace("{0}", statement); + } + } +} diff --git a/tests/CompileCheckWorker.UnitTests/CompileCheckWorker.UnitTests.csproj b/tests/CompileCheckWorker.UnitTests/CompileCheckWorker.UnitTests.csproj new file mode 100644 index 0000000000..9279afe66a --- /dev/null +++ b/tests/CompileCheckWorker.UnitTests/CompileCheckWorker.UnitTests.csproj @@ -0,0 +1,26 @@ + + + + net6.0 + Major + disable + disable + false + + 10.0 + + false + + + + + + + + + + + + + + diff --git a/tests/CompileCheckWorker.UnitTests/DeclarationFingerprintTests.cs b/tests/CompileCheckWorker.UnitTests/DeclarationFingerprintTests.cs new file mode 100644 index 0000000000..af335e2702 --- /dev/null +++ b/tests/CompileCheckWorker.UnitTests/DeclarationFingerprintTests.cs @@ -0,0 +1,240 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using NUnit.Framework; + +namespace UnityCliLoop.CompileCheckWorker.UnitTests +{ + [TestFixture] + public sealed class DeclarationFingerprintTests + { + /// + /// Verifies that editing a statement inside a method body keeps the fingerprint. + /// + [Test] + public void BodyEditKeepsFingerprint() + { + AssertSame( + "class C { int M() { int a = 1; return a; } }", + "class C { int M() { int b = 2; System.Console.WriteLine(b); return b; } }"); + } + + /// + /// Verifies that editing the expression of an expression-bodied method keeps the fingerprint. + /// + [Test] + public void ExpressionBodyEditKeepsFingerprint() + { + AssertSame("class C { int M() => 1; }", "class C { int M() => 2 + 3; }"); + } + + /// + /// Verifies that editing an accessor body keeps the fingerprint. + /// + [Test] + public void AccessorBodyEditKeepsFingerprint() + { + AssertSame( + "class C { int p; int P { get { return p; } set { p = value; } } }", + "class C { int p; int P { get { return p + 1; } set { p = value * 2; } } }"); + } + + /// + /// Verifies that editing the initializer of a non-constant field or property keeps the fingerprint. + /// + [Test] + public void NonConstInitializerEditKeepsFingerprint() + { + AssertSame( + "class C { int x = 1, y = 2; static readonly string s = \"a\"; int P { get; } = 3; }", + "class C { int x = 10, y = 20; static readonly string s = \"b\"; int P { get; } = 30; }"); + } + + /// + /// Verifies that comments, whitespace, and CRLF versus LF line endings do not change the fingerprint. + /// + [Test] + public void TriviaOnlyEditKeepsFingerprint() + { + AssertSame( + "class C\r\n{\r\n // old comment\r\n void M() { }\r\n}\r\n", + "class C\n{\n /* new comment */\n\n void M() { }\n}\n"); + } + + /// + /// Verifies that editing the inside of a #if region within a body keeps the fingerprint, also when + /// the body holds a #nullable directive that the fingerprint keeps. + /// + [Test] + public void DirectiveInsideBodyEditKeepsFingerprint() + { + AssertSame( + "class C { void M() {\n#nullable enable\n#if FOO\nint a = 1;\n#else\nint b = 2;\n#endif\n} }", + "class C { void M() {\n#nullable enable\n#if FOO\nstring changed = null;\n#else\nint other = 3;\n#endif\n} }"); + } + + /// + /// Verifies that adding a #nullable directive inside a body changes the fingerprint, because it + /// changes the nullable context of the declarations after it. + /// + [Test] + public void NullableDirectiveInsideBodyChangesFingerprint() + { + AssertDifferent( + "class C { void M() { } string N() => null; }", + "class C { void M() {\n#nullable enable\n} string N() => null; }"); + } + + /// + /// Verifies that moving a #nullable directive from one body to a later one changes the fingerprint, + /// because the declarations between the two bodies change nullable context. + /// + [Test] + public void NullableDirectiveMovedBetweenBodiesChangesFingerprint() + { + AssertDifferent( + "class C { void A() {\n#nullable enable\n} string Between() => null; void B() { } }", + "class C { void A() { } string Between() => null; void B() {\n#nullable enable\n} }"); + } + + /// + /// Verifies that turning a computed accessor into an auto accessor changes the fingerprint. + /// + [Test] + public void AccessorBodyPresenceChangesFingerprint() + { + AssertDifferent("class C { int P { get { return 1; } } }", "class C { int P { get; } }"); + } + + /// + /// Verifies that adding or removing a method body (a default interface method versus an abstract one) + /// changes the fingerprint. + /// + [Test] + public void MethodBodyPresenceChangesFingerprint() + { + AssertDifferent("interface I { void M() { } }", "interface I { void M(); }"); + } + + /// + /// Verifies that adding or removing a field initializer changes the fingerprint. + /// + [Test] + public void InitializerPresenceChangesFingerprint() + { + AssertDifferent("class C { int x; }", "class C { int x = 1; }"); + } + + /// + /// Verifies that changing the value of a constant changes the fingerprint. + /// + [Test] + public void ConstValueEditChangesFingerprint() + { + AssertDifferent("class C { const int X = 1; }", "class C { const int X = 2; }"); + } + + /// + /// Verifies that adding, removing, or renaming a method changes the fingerprint. + /// + [Test] + public void MemberAddRemoveRenameChangesFingerprint() + { + const string original = "class C { void M() { } }"; + AssertDifferent(original, "class C { void M() { } void N() { } }"); + AssertDifferent(original, "class C { }"); + AssertDifferent(original, "class C { void Renamed() { } }"); + } + + /// + /// Verifies that changing a parameter type, a parameter default, or a modifier such as async changes + /// the fingerprint. + /// + [Test] + public void SignatureEditChangesFingerprint() + { + const string original = "class C { void M(int a = 1) { } }"; + AssertDifferent(original, "class C { void M(long a = 1) { } }"); + AssertDifferent(original, "class C { void M(int a = 2) { } }"); + AssertDifferent(original, "class C { async void M(int a = 1) { } }"); + } + + /// + /// Verifies that adding an attribute or changing an attribute argument changes the fingerprint. + /// + [Test] + public void AttributeEditChangesFingerprint() + { + const string original = "class C { [System.Obsolete(\"a\")] void M() { } }"; + AssertDifferent(original, "class C { [System.Obsolete(\"b\")] void M() { } }"); + AssertDifferent(original, "class C { [System.Obsolete(\"a\")][System.Serializable] void M() { } }"); + } + + /// + /// Verifies that adding a using directive changes the fingerprint. + /// + [Test] + public void UsingEditChangesFingerprint() + { + AssertDifferent("class C { }", "using System;\nclass C { }"); + } + + /// + /// Verifies that adding a #nullable directive outside any body changes the fingerprint. + /// + [Test] + public void NullableDirectiveOutsideBodyChangesFingerprint() + { + AssertDifferent("class C { string M() => null; }", "#nullable disable\nclass C { string M() => null; }"); + } + + /// + /// Verifies that changing the arguments of a constructor initializer changes the fingerprint. + /// + [Test] + public void ConstructorInitializerEditChangesFingerprint() + { + AssertDifferent( + "class B { public B(int x) { } } class C : B { C() : base(1) { } }", + "class B { public B(int x) { } } class C : B { C() : base(2) { } }"); + } + + /// + /// Verifies that changing the value of an enum member changes the fingerprint. + /// + [Test] + public void EnumValueEditChangesFingerprint() + { + AssertDifferent("enum E { A = 1 }", "enum E { A = 2 }"); + } + + /// + /// Verifies that editing a field or property initializer of a partial type, or of a type nested in + /// one, changes the fingerprint, because the nullable analysis of a constructor in another file of + /// that type reads it. + /// + [TestCase("static partial class C { static int seed = 1; }", "static partial class C { static int seed = 2; }")] + [TestCase("partial class C { int guard = 1; }", "partial class C { int guard = Stop(); }")] + [TestCase("partial class C { int P { get; } = 1; }", "partial class C { int P { get; } = 2; }")] + [TestCase("partial class Outer { class C { int x = 1; } }", "partial class Outer { class C { int x = 2; } }")] + public void PartialTypeInitializerEditChangesFingerprint(string before, string after) + { + AssertDifferent(before, after); + } + + private static void AssertSame(string before, string after) + { + Assert.That(Fingerprint(after), Is.EqualTo(Fingerprint(before))); + } + + private static void AssertDifferent(string before, string after) + { + Assert.That(Fingerprint(after), Is.Not.EqualTo(Fingerprint(before))); + } + + private static string Fingerprint(string source) + { + SyntaxTree tree = CSharpSyntaxTree.ParseText(source); + return DeclarationFingerprint.Compute(tree); + } + } +} diff --git a/tests/CompileCheckWorker.UnitTests/ResponseFileCompilationLoaderTests.cs b/tests/CompileCheckWorker.UnitTests/ResponseFileCompilationLoaderTests.cs new file mode 100644 index 0000000000..79a6c55a59 --- /dev/null +++ b/tests/CompileCheckWorker.UnitTests/ResponseFileCompilationLoaderTests.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using NUnit.Framework; + +namespace UnityCliLoop.CompileCheckWorker.UnitTests +{ + [TestFixture] + public sealed class ResponseFileCompilationLoaderTests + { + private WorkerFixture fixture; + + [SetUp] + public void SetUp() + { + fixture = new WorkerFixture(); + } + + [TearDown] + public void TearDown() + { + fixture.Dispose(); + } + + /// + /// Verifies that the response file options csc applies (defines, nowarn, warnaserror, additional + /// files) come out in the loaded compilation, with one tree per source and per generated file. + /// + [Test] + public void LoadAppliesResponseFileOptions() + { + fixture.AddSource("A.cs", "class A { }"); + fixture.AddSource("Sub/B.cs", "class B { }"); + string generatedDirectory = Path.Combine(fixture.Root, "generated"); + fixture.WriteFile("generated/Nested/G.cs", "class G { }"); + string settingsPath = fixture.WriteFile("settings.txt", "setting"); + fixture.AddArgument("-define:FOO;BAR"); + fixture.AddArgument("-nowarn:CS0219"); + fixture.AddArgument("-warnaserror+"); + fixture.AddArgument("-additionalfile:\"" + settingsPath + "\""); + fixture.WriteResponseFile(); + + LoadResult load = ResponseFileCompilationLoader.Load( + fixture.Root, fixture.ResponseFilePath, generatedDirectory, new Dictionary()); + + Assert.That(load.UnsupportedReason, Is.Null); + CSharpCompilationOptions options = load.Compilation.Options; + Assert.That(options.SpecificDiagnosticOptions["CS0219"], Is.EqualTo(ReportDiagnostic.Suppress)); + Assert.That(options.GeneralDiagnosticOption, Is.EqualTo(ReportDiagnostic.Error)); + CSharpParseOptions parseOptions = (CSharpParseOptions)load.Compilation.SyntaxTrees.First().Options; + Assert.That(parseOptions.PreprocessorSymbolNames, Is.SupersetOf(new[] { "FOO", "BAR" })); + Assert.That(load.Compilation.SyntaxTrees.Count(), Is.EqualTo(3)); + Assert.That(load.SourceTreesByPath.Count, Is.EqualTo(2)); + Assert.That(load.AdditionalTexts.Select(text => text.Path), Is.EqualTo(new[] { settingsPath })); + } + + /// + /// Verifies that a source given as preloaded bytes is parsed from those bytes, not read again from disk. + /// + [Test] + public void LoadParsesPreloadedBytesInsteadOfRereading() + { + string probe = fixture.AddSource("Probe.cs", "class OnDisk { }"); + fixture.WriteResponseFile(); + Dictionary preloaded = new Dictionary + { + { Path.GetFullPath(probe), Encoding.UTF8.GetBytes("class Preloaded { }") }, + }; + + LoadResult load = ResponseFileCompilationLoader.Load(fixture.Root, fixture.ResponseFilePath, "", preloaded); + + Assert.That(load.SourceTreesByPath[Path.GetFullPath(probe)].ToString(), Is.EqualTo("class Preloaded { }")); + } + } +} diff --git a/tests/CompileCheckWorker.UnitTests/TestAnalyzerSources.cs b/tests/CompileCheckWorker.UnitTests/TestAnalyzerSources.cs new file mode 100644 index 0000000000..164749c1b9 --- /dev/null +++ b/tests/CompileCheckWorker.UnitTests/TestAnalyzerSources.cs @@ -0,0 +1,213 @@ +namespace UnityCliLoop.CompileCheckWorker.UnitTests +{ + /// + /// Analyzer sources the tests build into analyzer DLLs and pass to the worker with -analyzer. + /// + internal static class TestAnalyzerSources + { + // Suppresses CS0219 for variables whose name contains "suppressed". + public const string SuppressorAssemblyName = "CompileCheckTestSuppressor"; + + public const string Suppressor = @" +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UnusedProbeSuppressor : DiagnosticSuppressor +{ + private static readonly SuppressionDescriptor Rule = new SuppressionDescriptor(""TSP001"", ""CS0219"", ""test suppression""); + + public override ImmutableArray SupportedSuppressions => ImmutableArray.Create(Rule); + + public override void ReportSuppressions(SuppressionAnalysisContext context) + { + foreach (Diagnostic diagnostic in context.ReportedDiagnostics) + { + SyntaxNode node = diagnostic.Location.SourceTree.GetRoot(context.CancellationToken).FindNode(diagnostic.Location.SourceSpan); + if (node.ToString().Contains(""suppressed"")) + { + context.ReportSuppression(Suppression.Create(Rule, diagnostic)); + } + } + } +} +"; + + // Reports TSA001 on every local declaration whose text contains "flagged". + public const string NodeAnalyzerAssemblyName = "CompileCheckTestNodeAnalyzer"; + + public const string NodeAnalyzer = @" +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class FlaggedLocalAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(""TSA001"", ""flagged"", ""flagged local"", ""Test"", DiagnosticSeverity.Warning, true); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(nodeContext => + { + if (nodeContext.Node.ToString().Contains(""flagged"")) + { + nodeContext.ReportDiagnostic(Diagnostic.Create(Rule, nodeContext.Node.GetLocation())); + } + }, SyntaxKind.LocalDeclarationStatement); + } +} +"; + + // Registers a CompilationEnd action, which a per-file analysis never runs. + public const string CompilationEndAnalyzerAssemblyName = "CompileCheckTestCompilationEndAnalyzer"; + + public const string CompilationEndAnalyzer = @" +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class WholeAssemblyAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(""TSA002"", ""whole"", ""whole assembly"", ""Test"", DiagnosticSeverity.Warning, true); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(startContext => + { + startContext.RegisterCompilationEndAction(endContext => { }); + }); + } +} +"; + + // Registers a SymbolEnd action, which a per-file analysis cannot finish for a type declared + // across several files. + public const string SymbolEndAnalyzerAssemblyName = "CompileCheckTestSymbolEndAnalyzer"; + + public const string SymbolEndAnalyzer = @" +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class WholeTypeAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(""TSA003"", ""whole type"", ""whole type"", ""Test"", DiagnosticSeverity.Warning, true); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSymbolStartAction(startContext => + { + startContext.RegisterSymbolEndAction(endContext => { }); + }, SymbolKind.NamedType); + } +} +"; + + // Throws on every local declaration that contains "boom". + public const string ThrowingAnalyzerAssemblyName = "CompileCheckTestThrowingAnalyzer"; + + public const string ThrowingAnalyzer = @" +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ThrowingAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(""TSA006"", ""throws"", ""throws"", ""Test"", DiagnosticSeverity.Warning, true); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(nodeContext => + { + if (nodeContext.Node.ToString().Contains(""boom"")) + { + throw new InvalidOperationException(""boom""); + } + }, SyntaxKind.LocalDeclarationStatement); + } +} +"; + + // Registers a semantic model action and a compilation action, which a per-file analysis never runs. + public const string WholeFileAnalyzersAssemblyName = "CompileCheckTestWholeFileAnalyzers"; + + public const string WholeFileAnalyzers = @" +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class SemanticModelAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(""TSA004"", ""model"", ""model"", ""Test"", DiagnosticSeverity.Warning, true); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSemanticModelAction(modelContext => { }); + } +} + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class CompilationAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(""TSA005"", ""compilation"", ""compilation"", ""Test"", DiagnosticSeverity.Warning, true); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationAction(compilationContext => { }); + } +} +"; + + // A source generator, which the check counts but never runs. + public const string GeneratorAssemblyName = "CompileCheckTestGenerator"; + + public const string Generator = @" +using Microsoft.CodeAnalysis; + +[Generator] +public sealed class EmptyGenerator : ISourceGenerator +{ + public void Initialize(GeneratorInitializationContext context) + { + } + + public void Execute(GeneratorExecutionContext context) + { + } +} +"; + } +} diff --git a/tests/CompileCheckWorker.UnitTests/WorkerFixture.cs b/tests/CompileCheckWorker.UnitTests/WorkerFixture.cs new file mode 100644 index 0000000000..bb9b2fcd5c --- /dev/null +++ b/tests/CompileCheckWorker.UnitTests/WorkerFixture.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using NUnit.Framework; + +namespace UnityCliLoop.CompileCheckWorker.UnitTests +{ + /// + /// A throwaway project directory with sources, a response file, and analyzer DLLs built from + /// source, laid out the way compile-check hands them to the worker. + /// + internal sealed class WorkerFixture : IDisposable + { + private readonly List sources = new List(); + private readonly List extraArguments = new List(); + + public WorkerFixture() + { + Root = Path.Combine(Path.GetTempPath(), "compile-check-worker-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Root); + } + + public string Root { get; } + + public string ResponseFilePath => Path.Combine(Root, "Sample.Game.rsp"); + + public string AddSource(string relativePath, string content) + { + string path = WriteFile(relativePath, content); + sources.Add(path); + return path; + } + + public string WriteFile(string relativePath, string content) + { + string path = Path.Combine(Root, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, content, new UTF8Encoding(false)); + return path; + } + + public void AddArgument(string argument) + { + extraArguments.Add(argument); + } + + public void AddAnalyzer(string assemblyName, string source) + { + string path = Path.Combine(Root, "analyzers", assemblyName + ".dll"); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName, + new SyntaxTree[] { CSharpSyntaxTree.ParseText(source) }, + AnalyzerBuildReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Microsoft.CodeAnalysis.Emit.EmitResult result = compilation.Emit(path); + Assert.That(result.Success, Is.True, string.Join("\n", result.Diagnostics)); + extraArguments.Add("-analyzer:\"" + path + "\""); + } + + public string WriteResponseFile() + { + List lines = new List + { + "-target:library", + "-out:\"" + Path.Combine(Root, "Sample.Game.dll") + "\"", + }; + lines.AddRange(CompileReferencePaths().Select(path => "-r:\"" + path + "\"")); + lines.AddRange(extraArguments); + lines.AddRange(sources.Select(path => "\"" + path + "\"")); + File.WriteAllText(ResponseFilePath, string.Join("\n", lines), new UTF8Encoding(false)); + return ResponseFilePath; + } + + public Dictionary FingerprintSources() + { + WriteResponseFile(); + FingerprintRequest request = new FingerprintRequest + { + ProjectRoot = Root, + ResponseFilePath = ResponseFilePath, + Sources = new List(sources), + }; + return FingerprintCommand.Run(request).Fingerprints; + } + + public CheckResponse Check(Dictionary baseline, string generatedDirectory, params string[] changedFiles) + { + WriteResponseFile(); + CheckRequest request = new CheckRequest + { + ProjectRoot = Root, + ResponseFilePath = ResponseFilePath, + ChangedFiles = changedFiles.ToList(), + BaselineFingerprints = baseline, + GeneratedDirectory = generatedDirectory, + }; + return ChangedFileChecker.Check(request); + } + + public void Dispose() + { + if (Directory.Exists(Root)) + { + Directory.Delete(Root, true); + } + } + + private static IEnumerable CompileReferencePaths() + { + string runtimeDirectory = Path.GetDirectoryName(typeof(object).Assembly.Location); + return new string[] + { + typeof(object).Assembly.Location, + Path.Combine(runtimeDirectory, "System.Runtime.dll"), + }; + } + + private static IEnumerable AnalyzerBuildReferences() + { + string trustedAssemblies = (string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"); + IEnumerable paths = trustedAssemblies + .Split(Path.PathSeparator) + .Concat(new string[] + { + typeof(DiagnosticAnalyzer).Assembly.Location, + typeof(CSharpCompilation).Assembly.Location, + }) + .Distinct(StringComparer.Ordinal); + return paths.Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)).ToList(); + } + } +} From 72d6e3d70f1d971b6d16e622bac9044082849af6 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 14 Oct 2026 21:26:25 +0900 Subject: [PATCH 02/33] feat: compile-check can build and run its changed-files check worker (#3316) --- .../compilecheck/fastcheck/bootstrap.go | 474 +++++++++++++ .../compilecheck/fastcheck/bootstrap_test.go | 620 ++++++++++++++++++ .../internal/compilecheck/fastcheck/client.go | 187 ++++++ .../compilecheck/fastcheck/client_test.go | 225 +++++++ .../fastcheck/helper_process_test.go | 67 ++ .../compilecheck/fastcheck/sources.go | 13 + 6 files changed, 1586 insertions(+) create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/bootstrap.go create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/bootstrap_test.go create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/client.go create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/client_test.go create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/helper_process_test.go create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/sources.go diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/bootstrap.go b/cli/dispatcher/internal/compilecheck/fastcheck/bootstrap.go new file mode 100644 index 0000000000..e9d7b69c3e --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/bootstrap.go @@ -0,0 +1,474 @@ +package fastcheck + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + workerCacheDirectoryName = "fastcheck-worker" + workerDllFileName = "worker.dll" + workerRuntimeConfigFileName = "worker.runtimeconfig.json" + compilerRuntimeConfigFileName = "csc.runtimeconfig.json" + roslynDirectorySidecarFileName = "roslyn-directory.txt" + buildFailedFileName = "build-failed.txt" + workerResponseFileName = "worker.rsp" + workerSourceSubdirectory = "src" + codeAnalysisDllFileName = "Microsoft.CodeAnalysis.dll" + codeAnalysisCSharpDllFileName = "Microsoft.CodeAnalysis.CSharp.dll" + compilerExecArgument = "exec" + multiLevelLookupSetting = "DOTNET_MULTILEVEL_LOOKUP=0" + workerBuildTimeout = 120 * time.Second + buildFailureReportLimit = 2000 + clrRuntimeHeaderDirectoryIndex = 14 + cacheDirectoryPermissions = 0o755 + cacheFilePermissions = 0o644 + temporaryDirectoryInfix = ".tmp-" + staleDirectoryInfix = ".stale-" + + // workerBuildRecipeVersion names how this file builds the worker: which shared framework files + // it references, the csc arguments, and the runtime config and sidecar it writes. Raise it + // whenever any of those changes, so a failure recorded by an older recipe is not returned for + // a build the new recipe would get right. + workerBuildRecipeVersion = 2 + + peSignatureOffsetPosition = 0x3c + peSignatureSize = 4 + coffHeaderSize = 20 + sizeOfOptionalHeaderOffset = 16 + pe32Magic = 0x10b + pe32PlusMagic = 0x20b + pe32RvaCountOffset = 92 + pe32PlusRvaCountOffset = 108 + dataDirectoryEntrySize = 8 +) + +// CommandRunner runs one process, so tests can stand in for dotnet. It has the same shape as the +// compile-check compiler runner, so the caller can hand the same function to both. +type CommandRunner func(ctx context.Context, cmd *exec.Cmd) (string, string, int, error) + +// ToolchainPaths are the Editor's dotnet and Roslyn the worker is built with and runs on. +type ToolchainPaths struct { + DotnetHostPath string + CompilerDllPath string + CompilerDirectory string // holds Microsoft.CodeAnalysis*.dll and csc.runtimeconfig.json + SharedFrameworkPath string +} + +// WorkerPaths locate a built worker. +type WorkerPaths struct { + // Key names the worker build: the same sources built with the same toolchain share it. + Key string + DllPath string + DotnetHostPath string +} + +// Bootstrap builds the worker once per source and toolchain and caches it under CacheRoot. +type Bootstrap struct { + CompilerPaths ToolchainPaths + CacheRoot string + Run CommandRunner +} + +// Ensure returns a built worker for the toolchain, building it when the cache has none. A build +// that failed once is not retried for the same sources and toolchain: it returns the recorded +// failure instead, so an environment the worker cannot build in pays the build only once. +func (b *Bootstrap) Ensure(ctx context.Context) (WorkerPaths, error) { + key := b.computeKey(workerBuildRecipeVersion) + cacheDirectory := filepath.Join(b.CacheRoot, workerCacheDirectoryName) + workerDirectory := filepath.Join(cacheDirectory, key) + paths := WorkerPaths{ + Key: key, + DllPath: filepath.Join(workerDirectory, workerDllFileName), + DotnetHostPath: b.CompilerPaths.DotnetHostPath, + } + if isCompleteWorker(workerDirectory) { + return paths, nil + } + + if report, readErr := os.ReadFile(filepath.Join(workerDirectory, buildFailedFileName)); readErr == nil { + return WorkerPaths{}, fmt.Errorf( + "changed-files check worker failed to build earlier: %s", truncate(string(report), buildFailureReportLimit)) + } + + if err := os.MkdirAll(cacheDirectory, cacheDirectoryPermissions); err != nil { + return WorkerPaths{}, fmt.Errorf("failed to create the worker cache directory: %w", err) + } + + // Why a private temporary directory: runs that start together each build in their own, so none + // reads another's half-written worker. The first to rename its directory into place wins. + temporaryDirectory, err := temporaryBuildDirectory(cacheDirectory, key) + if err != nil { + return WorkerPaths{}, err + } + defer func() { _ = os.RemoveAll(temporaryDirectory) }() + + if err := b.build(ctx, temporaryDirectory, workerDirectory); err != nil { + return WorkerPaths{}, err + } + + if err := placeWorker(temporaryDirectory, workerDirectory, key); err != nil { + return WorkerPaths{}, err + } + + return paths, nil +} + +// placeWorker moves a built worker into its final directory. When that directory already holds a +// complete worker, another run finished first and its worker is used. +// Why a directory that is neither a worker nor a failure record is set aside and replaced: a rename +// onto an existing directory always fails, so leaving it would make every later run build the +// worker and then fail to move it, with nothing that ever clears it. That happens when someone +// deletes only the failure record to retry, or a run died while writing one. +func placeWorker(temporaryDirectory string, workerDirectory string, key string) error { + renameErr := os.Rename(temporaryDirectory, workerDirectory) + if renameErr == nil || isCompleteWorker(workerDirectory) { + return nil + } + + if _, err := os.Stat(filepath.Join(workerDirectory, buildFailedFileName)); err == nil { + return fmt.Errorf("failed to move the built worker into the cache: %w", renameErr) + } + + // The temporary directory's random suffix makes the set-aside name unique to this run. + suffix := strings.TrimPrefix(filepath.Base(temporaryDirectory), key+temporaryDirectoryInfix) + staleDirectory := workerDirectory + staleDirectoryInfix + suffix + if err := os.Rename(workerDirectory, staleDirectory); err == nil { + _ = os.RemoveAll(staleDirectory) + } + + if err := os.Rename(temporaryDirectory, workerDirectory); err != nil && !isCompleteWorker(workerDirectory) { + return fmt.Errorf("failed to move the built worker into the cache: %w", err) + } + + return nil +} + +// build compiles the worker into temporaryDirectory, recording a failed compile in workerDirectory. +func (b *Bootstrap) build(ctx context.Context, temporaryDirectory string, workerDirectory string) error { + sourcePaths, err := writeWorkerSources(filepath.Join(temporaryDirectory, workerSourceSubdirectory)) + if err != nil { + return err + } + + references, err := managedAssembliesIn(b.CompilerPaths.SharedFrameworkPath) + if err != nil { + return err + } + + references = append(references, + filepath.Join(b.CompilerPaths.CompilerDirectory, codeAnalysisDllFileName), + filepath.Join(b.CompilerPaths.CompilerDirectory, codeAnalysisCSharpDllFileName)) + responseFilePath := filepath.Join(temporaryDirectory, workerResponseFileName) + responseFile := workerResponseFile(filepath.Join(temporaryDirectory, workerDllFileName), references, sourcePaths) + if err := os.WriteFile(responseFilePath, []byte(responseFile), cacheFilePermissions); err != nil { + return fmt.Errorf("failed to write the worker response file: %w", err) + } + + invocationContext, cancel := context.WithTimeout(ctx, workerBuildTimeout) + defer cancel() + + command := exec.CommandContext(invocationContext, + b.CompilerPaths.DotnetHostPath, compilerExecArgument, b.CompilerPaths.CompilerDllPath, "@"+responseFilePath) + command.Dir = temporaryDirectory + command.Env = append(os.Environ(), multiLevelLookupSetting) + stdout, stderr, exitCode, runErr := b.Run(invocationContext, command) + // Why a stopped build leaves no failure record: it says nothing about whether the worker builds + // with this toolchain, and recording it would turn the check off until the sources change. + if contextErr := invocationContext.Err(); contextErr != nil { + return fmt.Errorf("the changed-files check worker build was stopped: %w", contextErr) + } + + // Why only a compiler that ran is recorded: dotnet failing to start (too many processes or open + // files) says nothing about whether the worker builds, and recording it would turn the check off + // until the sources change. + if runErr != nil && !isExitError(runErr) { + return fmt.Errorf("failed to start the changed-files check worker build: %w: %s", runErr, strings.TrimSpace(stderr)) + } + + // Why a compiler killed by a signal is not recorded either: the out-of-memory killer or a user + // stopping dotnet ends one build, and says nothing about the next. + if runErr != nil && !exitedOnItsOwn(runErr) { + return fmt.Errorf("the changed-files check worker build was killed: %w", runErr) + } + + if runErr != nil || exitCode != 0 { + return recordBuildFailure(workerDirectory, stdout+stderr, runErr) + } + + return finishWorkerDirectory(temporaryDirectory, b.CompilerPaths.CompilerDirectory) +} + +// computeKey hashes the build recipe version, the embedded sources, and the toolchain paths: a +// change to any of them is a new worker. +func (b *Bootstrap) computeKey(recipeVersion int) string { + sources := readWorkerSources() + hash := sha256.New() + _, _ = fmt.Fprintf(hash, "recipe\x00%d\n", recipeVersion) + for _, source := range sources { + _, _ = fmt.Fprintf(hash, "source\x00%s\x00%d\x00", source.name, len(source.content)) + _, _ = hash.Write(source.content) + } + + for _, toolchainPath := range []string{ + b.CompilerPaths.CompilerDllPath, + b.CompilerPaths.DotnetHostPath, + b.CompilerPaths.SharedFrameworkPath, + b.CompilerPaths.CompilerDirectory, + } { + _, _ = fmt.Fprintf(hash, "toolchain\x00%s\n", toolchainPath) + } + + return hex.EncodeToString(hash.Sum(nil)) +} + +// workerSource is one embedded worker source file. +type workerSource struct { + name string + content []byte +} + +// readWorkerSources returns the embedded worker sources ordered by file name. Why it panics instead +// of returning an error: the sources are compiled into this binary, so failing to read them is a +// broken build, not a condition a run can recover from. +func readWorkerSources() []workerSource { + entries, err := fs.ReadDir(workerSources, workerSourceDirectory) + if err != nil { + panic(fmt.Sprintf("embedded worker sources are unreadable: %v", err)) + } + + sources := make([]workerSource, 0, len(entries)) + for _, entry := range entries { + content, err := workerSources.ReadFile(path.Join(workerSourceDirectory, entry.Name())) + if err != nil { + panic(fmt.Sprintf("embedded worker source %s is unreadable: %v", entry.Name(), err)) + } + + sources = append(sources, workerSource{name: entry.Name(), content: content}) + } + + sort.Slice(sources, func(i, j int) bool { return sources[i].name < sources[j].name }) + return sources +} + +func writeWorkerSources(sourceDirectory string) ([]string, error) { + sources := readWorkerSources() + if err := os.MkdirAll(sourceDirectory, cacheDirectoryPermissions); err != nil { + return nil, fmt.Errorf("failed to create the worker source directory: %w", err) + } + + sourcePaths := make([]string, 0, len(sources)) + for _, source := range sources { + sourcePath := filepath.Join(sourceDirectory, source.name) + if err := os.WriteFile(sourcePath, source.content, cacheFilePermissions); err != nil { + return nil, fmt.Errorf("failed to write the worker source %s: %w", source.name, err) + } + + sourcePaths = append(sourcePaths, sourcePath) + } + + return sourcePaths, nil +} + +// managedAssembliesIn lists the shared framework DLLs csc can reference. Why native images are left +// out: the framework directory also holds native PE files named *.dll, and csc rejects them (CS0009). +func managedAssembliesIn(sharedFrameworkPath string) ([]string, error) { + matches, err := filepath.Glob(filepath.Join(sharedFrameworkPath, "*.dll")) + if err != nil { + return nil, fmt.Errorf("failed to list the shared framework: %w", err) + } + + sort.Strings(matches) + managed := make([]string, 0, len(matches)) + for _, candidate := range matches { + if isManagedAssembly(candidate) { + managed = append(managed, candidate) + } + } + + return managed, nil +} + +// isManagedAssembly reports whether the PE file has a CLR runtime header. A file too short or +// malformed to hold one is not managed. +// Why the headers are read here instead of with debug/pe: debug/pe refuses machine types it does not +// list, and the ReadyToRun images of the macOS and Linux shared frameworks XOR the machine with an +// OS mask (ARM64 on macOS reads 0xEC20). Leaving those out drops System.Private.CoreLib from the +// references, and every worker build fails. The machine says nothing about managed code, so it is +// not read. +func isManagedAssembly(filePath string) bool { + file, err := os.Open(filePath) + if err != nil { + return false + } + defer func() { _ = file.Close() }() + + optionalHeader, ok := readOptionalHeader(file) + if !ok { + return false + } + + return hasRuntimeHeader(optionalHeader) +} + +// readOptionalHeader reads the PE optional header, following the DOS header to the PE signature. +func readOptionalHeader(file *os.File) ([]byte, bool) { + signatureOffsetBytes := make([]byte, 4) + if _, err := file.ReadAt(signatureOffsetBytes, peSignatureOffsetPosition); err != nil { + return nil, false + } + + signatureOffset := int64(binary.LittleEndian.Uint32(signatureOffsetBytes)) + headers := make([]byte, peSignatureSize+coffHeaderSize) + if _, err := file.ReadAt(headers, signatureOffset); err != nil || string(headers[:peSignatureSize]) != "PE\x00\x00" { + return nil, false + } + + optionalHeaderSize := binary.LittleEndian.Uint16(headers[peSignatureSize+sizeOfOptionalHeaderOffset:]) + optionalHeader := make([]byte, optionalHeaderSize) + if _, err := file.ReadAt(optionalHeader, signatureOffset+int64(len(headers))); err != nil { + return nil, false + } + + return optionalHeader, true +} + +// hasRuntimeHeader reports whether an optional header has a non-empty CLR runtime header directory. +func hasRuntimeHeader(optionalHeader []byte) bool { + if len(optionalHeader) < 2 { + return false + } + + rvaCountOffset := 0 + switch binary.LittleEndian.Uint16(optionalHeader) { + case pe32Magic: + rvaCountOffset = pe32RvaCountOffset + case pe32PlusMagic: + rvaCountOffset = pe32PlusRvaCountOffset + default: + return false + } + + // The data directories follow the count, one entry of address and size each. + entryOffset := rvaCountOffset + 4 + clrRuntimeHeaderDirectoryIndex*dataDirectoryEntrySize + if len(optionalHeader) < entryOffset+dataDirectoryEntrySize { + return false + } + + directoryCount := binary.LittleEndian.Uint32(optionalHeader[rvaCountOffset:]) + return directoryCount > clrRuntimeHeaderDirectoryIndex && binary.LittleEndian.Uint32(optionalHeader[entryOffset:]) != 0 +} + +func workerResponseFile(outputPath string, references []string, sourcePaths []string) string { + lines := []string{"-nologo", "-nostdlib+", "-target:exe", "-out:" + quote(outputPath)} + for _, reference := range references { + lines = append(lines, "-r:"+quote(reference)) + } + + for _, sourcePath := range sourcePaths { + lines = append(lines, quote(sourcePath)) + } + + return strings.Join(lines, "\n") + "\n" +} + +func quote(value string) string { + return `"` + value + `"` +} + +// finishWorkerDirectory adds the files the built worker needs to start next to worker.dll. +func finishWorkerDirectory(temporaryDirectory string, compilerDirectory string) error { + // Why csc's runtime config: it names the framework the Editor's Roslyn runs on, which is the + // one the worker was compiled against. + runtimeConfig, err := os.ReadFile(filepath.Join(compilerDirectory, compilerRuntimeConfigFileName)) + if err != nil { + return fmt.Errorf("failed to read the compiler runtime config: %w", err) + } + + if err := os.WriteFile( + filepath.Join(temporaryDirectory, workerRuntimeConfigFileName), runtimeConfig, cacheFilePermissions); err != nil { + return fmt.Errorf("failed to write the worker runtime config: %w", err) + } + + // UTF-8 without a BOM: the worker reads the directory back from this file to load Roslyn. + if err := os.WriteFile( + filepath.Join(temporaryDirectory, roslynDirectorySidecarFileName), + []byte(compilerDirectory+"\n"), cacheFilePermissions); err != nil { + return fmt.Errorf("failed to write the Roslyn directory sidecar: %w", err) + } + + return nil +} + +func recordBuildFailure(workerDirectory string, output string, runErr error) error { + report := strings.TrimSpace(output) + if report == "" && runErr != nil { + report = runErr.Error() + } + + if err := os.MkdirAll(workerDirectory, cacheDirectoryPermissions); err != nil { + return fmt.Errorf("failed to record the worker build failure: %w", err) + } + + if err := os.WriteFile( + filepath.Join(workerDirectory, buildFailedFileName), []byte(report), cacheFilePermissions); err != nil { + return fmt.Errorf("failed to record the worker build failure: %w", err) + } + + return fmt.Errorf("changed-files check worker failed to build: %s", truncate(report, buildFailureReportLimit)) +} + +func isCompleteWorker(workerDirectory string) bool { + for _, name := range []string{workerDllFileName, workerRuntimeConfigFileName, roslynDirectorySidecarFileName} { + if _, err := os.Stat(filepath.Join(workerDirectory, name)); err != nil { + return false + } + } + + return true +} + +func temporaryBuildDirectory(cacheDirectory string, key string) (string, error) { + directory, err := os.MkdirTemp(cacheDirectory, key+temporaryDirectoryInfix+"*") + if err != nil { + return "", fmt.Errorf("failed to create the worker build directory: %w", err) + } + + return directory, nil +} + +func truncate(text string, limit int) string { + if len(text) <= limit { + return text + } + + return text[:limit] +} + +// isExitError reports whether the process ran and exited with a non-zero code, as opposed to not starting. +func isExitError(err error) bool { + var exitError *exec.ExitError + return errors.As(err, &exitError) +} + +// exitedOnItsOwn reports whether a process that ran returned an exit code itself, as opposed to +// being ended by a signal. +func exitedOnItsOwn(err error) bool { + var exitError *exec.ExitError + return errors.As(err, &exitError) && exitError.ProcessState != nil && exitError.Exited() +} + +// errWorkerNotBuilt is returned by callers that need a worker but were given none. +var errWorkerNotBuilt = errors.New("changed-files check worker is not built") diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/bootstrap_test.go b/cli/dispatcher/internal/compilecheck/fastcheck/bootstrap_test.go new file mode 100644 index 0000000000..06743826aa --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/bootstrap_test.go @@ -0,0 +1,620 @@ +package fastcheck + +import ( + "bytes" + "context" + "debug/pe" + "encoding/binary" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const testRuntimeConfig = `{"runtimeOptions":{"tfm":"net6.0"}}` + +// fakeCompiler stands in for csc: it records each response file and, when it succeeds, writes the +// worker DLL to the -out path the response file names. +type fakeCompiler struct { + calls int + responseFiles []string + exitCode int + output string +} + +func (f *fakeCompiler) run(_ context.Context, cmd *exec.Cmd) (string, string, int, error) { + f.calls++ + responseFilePath := strings.TrimPrefix(cmd.Args[len(cmd.Args)-1], "@") + content, err := os.ReadFile(responseFilePath) + if err != nil { + return "", err.Error(), 1, err + } + + f.responseFiles = append(f.responseFiles, string(content)) + if f.exitCode != 0 { + return f.output, "", f.exitCode, exitErrorWithCode(f.exitCode) + } + + for _, line := range strings.Split(string(content), "\n") { + if strings.HasPrefix(line, "-out:") { + outputPath := strings.Trim(strings.TrimPrefix(line, "-out:"), `"`) + if err := os.WriteFile(outputPath, []byte("worker"), cacheFilePermissions); err != nil { + return "", err.Error(), 1, err + } + } + } + + return f.output, "", 0, nil +} + +// newTestToolchain lays out a compiler directory and an empty shared framework under root. +func newTestToolchain(t *testing.T, root string, name string) ToolchainPaths { + t.Helper() + compilerDirectory := filepath.Join(root, name, "Roslyn") + sharedFramework := filepath.Join(root, name, "shared") + for _, directory := range []string{compilerDirectory, sharedFramework} { + if err := os.MkdirAll(directory, cacheDirectoryPermissions); err != nil { + t.Fatalf("failed to create %s: %v", directory, err) + } + } + + writeTestFile(t, filepath.Join(compilerDirectory, compilerRuntimeConfigFileName), []byte(testRuntimeConfig)) + return ToolchainPaths{ + DotnetHostPath: filepath.Join(root, name, "dotnet"), + CompilerDllPath: filepath.Join(compilerDirectory, "csc.dll"), + CompilerDirectory: compilerDirectory, + SharedFrameworkPath: sharedFramework, + } +} + +func writeTestFile(t *testing.T, filePath string, content []byte) { + t.Helper() + if err := os.WriteFile(filePath, content, cacheFilePermissions); err != nil { + t.Fatalf("failed to write %s: %v", filePath, err) + } +} + +// peImage builds an AMD64 PE32+ image with headers only. clrHeaderAddress is the CLR runtime header +// directory entry: non-zero for a managed assembly, zero for a native image. +func peImage(t *testing.T, clrHeaderAddress uint32) []byte { + t.Helper() + return peImageForMachine(t, pe.IMAGE_FILE_MACHINE_AMD64, clrHeaderAddress) +} + +// peImageForMachine builds a PE32+ image with headers only for the given COFF machine value. +func peImageForMachine(t *testing.T, machine uint16, clrHeaderAddress uint32) []byte { + t.Helper() + const peHeaderOffset = 0x40 + buffer := bytes.Buffer{} + dosHeader := make([]byte, peHeaderOffset) + copy(dosHeader, "MZ") + binary.LittleEndian.PutUint32(dosHeader[0x3c:], peHeaderOffset) + buffer.Write(dosHeader) + buffer.WriteString("PE\x00\x00") + + optionalHeader := pe.OptionalHeader64{Magic: 0x20b, NumberOfRvaAndSizes: 16} + optionalHeader.DataDirectory[clrRuntimeHeaderDirectoryIndex] = pe.DataDirectory{VirtualAddress: clrHeaderAddress, Size: 72} + fileHeader := pe.FileHeader{ + Machine: machine, + SizeOfOptionalHeader: uint16(binary.Size(optionalHeader)), + Characteristics: pe.IMAGE_FILE_DLL, + } + for _, header := range []any{fileHeader, optionalHeader} { + if err := binary.Write(&buffer, binary.LittleEndian, header); err != nil { + t.Fatalf("failed to build the PE image: %v", err) + } + } + + return buffer.Bytes() +} + +// peImage32 builds a PE32 image with headers only and a CLR runtime header, like a 32-bit managed assembly. +func peImage32(t *testing.T) []byte { + t.Helper() + const peHeaderOffset = 0x40 + buffer := bytes.Buffer{} + dosHeader := make([]byte, peHeaderOffset) + copy(dosHeader, "MZ") + binary.LittleEndian.PutUint32(dosHeader[0x3c:], peHeaderOffset) + buffer.Write(dosHeader) + buffer.WriteString("PE\x00\x00") + + optionalHeader := pe.OptionalHeader32{Magic: 0x10b, NumberOfRvaAndSizes: 16} + optionalHeader.DataDirectory[clrRuntimeHeaderDirectoryIndex] = pe.DataDirectory{VirtualAddress: 0x2008, Size: 72} + fileHeader := pe.FileHeader{ + Machine: pe.IMAGE_FILE_MACHINE_I386, + SizeOfOptionalHeader: uint16(binary.Size(optionalHeader)), + Characteristics: pe.IMAGE_FILE_DLL, + } + for _, header := range []any{fileHeader, optionalHeader} { + if err := binary.Write(&buffer, binary.LittleEndian, header); err != nil { + t.Fatalf("failed to build the PE image: %v", err) + } + } + + return buffer.Bytes() +} + +// Verifies that the worker is built once and the second Ensure reuses the cache without running csc. +func TestEnsureBuildsOnceAndReusesCache(t *testing.T) { + root := t.TempDir() + compiler := &fakeCompiler{} + bootstrap := Bootstrap{CompilerPaths: newTestToolchain(t, root, "editor"), CacheRoot: filepath.Join(root, "cache"), Run: compiler.run} + + first, err := bootstrap.Ensure(context.Background()) + if err != nil { + t.Fatalf("expected the first Ensure to build the worker, got error: %v", err) + } + + second, err := bootstrap.Ensure(context.Background()) + if err != nil { + t.Fatalf("expected the second Ensure to reuse the worker, got error: %v", err) + } + + if compiler.calls != 1 { + t.Fatalf("expected csc to run once, ran %d times", compiler.calls) + } + + if first != second { + t.Fatalf("expected both calls to return the same worker, got %+v and %+v", first, second) + } + + workerDirectory := filepath.Dir(first.DllPath) + sidecar, err := os.ReadFile(filepath.Join(workerDirectory, roslynDirectorySidecarFileName)) + if err != nil || string(sidecar) != bootstrap.CompilerPaths.CompilerDirectory+"\n" { + t.Fatalf("expected the Roslyn sidecar to name the compiler directory, got %q (%v)", sidecar, err) + } + + runtimeConfig, err := os.ReadFile(filepath.Join(workerDirectory, workerRuntimeConfigFileName)) + if err != nil || string(runtimeConfig) != testRuntimeConfig { + t.Fatalf("expected the compiler runtime config next to the worker, got %q (%v)", runtimeConfig, err) + } +} + +// Verifies that a failed build is recorded, and the next Ensure reports it without running csc again. +func TestEnsureRecordsBuildFailureAndDoesNotRetry(t *testing.T) { + root := t.TempDir() + compiler := &fakeCompiler{exitCode: 1, output: "Program.cs(1,1): error CS1002: ; expected"} + bootstrap := Bootstrap{CompilerPaths: newTestToolchain(t, root, "editor"), CacheRoot: filepath.Join(root, "cache"), Run: compiler.run} + + _, firstErr := bootstrap.Ensure(context.Background()) + if firstErr == nil || !strings.Contains(firstErr.Error(), "CS1002") { + t.Fatalf("expected the build failure with the compiler output, got %v", firstErr) + } + + _, secondErr := bootstrap.Ensure(context.Background()) + if secondErr == nil || !strings.Contains(secondErr.Error(), "failed to build earlier") || + !strings.Contains(secondErr.Error(), "CS1002") { + t.Fatalf("expected the recorded failure, got %v", secondErr) + } + + if compiler.calls != 1 { + t.Fatalf("expected csc to run once, ran %d times", compiler.calls) + } +} + +// Verifies that a different toolchain path names a different worker build, so each is built separately. +func TestEnsureKeyChangesWithToolchainPath(t *testing.T) { + root := t.TempDir() + compiler := &fakeCompiler{} + cacheRoot := filepath.Join(root, "cache") + older := Bootstrap{CompilerPaths: newTestToolchain(t, root, "editor-a"), CacheRoot: cacheRoot, Run: compiler.run} + newer := Bootstrap{CompilerPaths: newTestToolchain(t, root, "editor-b"), CacheRoot: cacheRoot, Run: compiler.run} + + olderWorker, err := older.Ensure(context.Background()) + if err != nil { + t.Fatalf("expected the first toolchain to build, got error: %v", err) + } + + newerWorker, err := newer.Ensure(context.Background()) + if err != nil { + t.Fatalf("expected the second toolchain to build, got error: %v", err) + } + + if olderWorker.Key == newerWorker.Key || compiler.calls != 2 { + t.Fatalf("expected two builds under different keys, got keys %s and %s after %d builds", + olderWorker.Key, newerWorker.Key, compiler.calls) + } +} + +// Verifies that only managed assemblies of the shared framework are passed to csc as references. +func TestEnsureSkipsNativeImagesInSharedFramework(t *testing.T) { + root := t.TempDir() + compiler := &fakeCompiler{} + toolchain := newTestToolchain(t, root, "editor") + writeTestFile(t, filepath.Join(toolchain.SharedFrameworkPath, "System.Runtime.dll"), peImage(t, 0x2008)) + writeTestFile(t, filepath.Join(toolchain.SharedFrameworkPath, "System.Private.Uri.dll"), peImage32(t)) + // ReadyToRun images of the macOS shared framework XOR the machine with an OS mask: ARM64 reads + // 0xEC20 and AMD64 reads 0xC020. + writeTestFile(t, filepath.Join(toolchain.SharedFrameworkPath, "System.Private.CoreLib.dll"), peImageForMachine(t, 0xEC20, 0x2008)) + writeTestFile(t, filepath.Join(toolchain.SharedFrameworkPath, "System.Collections.Immutable.dll"), peImageForMachine(t, 0xC020, 0x2008)) + writeTestFile(t, filepath.Join(toolchain.SharedFrameworkPath, "NativeHelper.dll"), peImage(t, 0)) + writeTestFile(t, filepath.Join(toolchain.SharedFrameworkPath, "NativeMasked.dll"), peImageForMachine(t, 0xEC20, 0)) + writeTestFile(t, filepath.Join(toolchain.SharedFrameworkPath, "NotAnImage.dll"), []byte("text")) + bootstrap := Bootstrap{CompilerPaths: toolchain, CacheRoot: filepath.Join(root, "cache"), Run: compiler.run} + + if _, err := bootstrap.Ensure(context.Background()); err != nil { + t.Fatalf("expected the worker to build, got error: %v", err) + } + + responseFile := compiler.responseFiles[0] + for _, managed := range []string{ + "System.Runtime.dll", "System.Private.Uri.dll", "System.Private.CoreLib.dll", "System.Collections.Immutable.dll", + } { + if !strings.Contains(responseFile, managed) { + t.Fatalf("expected the managed assembly %s as a reference, got:\n%s", managed, responseFile) + } + } + + for _, rejected := range []string{"NativeHelper.dll", "NativeMasked.dll", "NotAnImage.dll"} { + if strings.Contains(responseFile, rejected) { + t.Fatalf("expected %s to be left out, got:\n%s", rejected, responseFile) + } + } + + for _, roslynAssembly := range []string{codeAnalysisDllFileName, codeAnalysisCSharpDllFileName} { + if !strings.Contains(responseFile, quote(filepath.Join(toolchain.CompilerDirectory, roslynAssembly))) { + t.Fatalf("expected %s as a reference, got:\n%s", roslynAssembly, responseFile) + } + } +} + +// Verifies that a failed build leaves no temporary build directory in the cache. +func TestEnsureLeavesNoTempDirectoryOnFailure(t *testing.T) { + root := t.TempDir() + compiler := &fakeCompiler{exitCode: 1, output: "error"} + cacheRoot := filepath.Join(root, "cache") + bootstrap := Bootstrap{CompilerPaths: newTestToolchain(t, root, "editor"), CacheRoot: cacheRoot, Run: compiler.run} + + if _, err := bootstrap.Ensure(context.Background()); err == nil { + t.Fatal("expected the build to fail") + } + + entries, err := os.ReadDir(filepath.Join(cacheRoot, workerCacheDirectoryName)) + if err != nil { + t.Fatalf("failed to list the worker cache: %v", err) + } + + for _, entry := range entries { + if strings.Contains(entry.Name(), ".tmp-") { + t.Fatalf("expected no temporary build directory, found %s", entry.Name()) + } + } +} + +// Verifies that a build stopped by its context records no failure, so the next run builds again. +func TestEnsureDoesNotRecordStoppedBuild(t *testing.T) { + root := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + bootstrap := Bootstrap{ + CompilerPaths: newTestToolchain(t, root, "editor"), + CacheRoot: filepath.Join(root, "cache"), + Run: func(_ context.Context, _ *exec.Cmd) (string, string, int, error) { + calls++ + cancel() + return "", "", -1, context.Canceled + }, + } + + _, err := bootstrap.Ensure(ctx) + if err == nil || !strings.Contains(err.Error(), "was stopped") { + t.Fatalf("expected a stopped build error, got %v", err) + } + + compiler := &fakeCompiler{} + bootstrap.Run = compiler.run + if _, err := bootstrap.Ensure(context.Background()); err != nil { + t.Fatalf("expected the next Ensure to build, got error: %v", err) + } + + if calls != 1 || compiler.calls != 1 { + t.Fatalf("expected one stopped and one completed build, got %d and %d", calls, compiler.calls) + } +} + +// Verifies that when another run put a complete worker in place first, Ensure uses that worker. +func TestEnsureUsesWorkerAnotherRunFinishedFirst(t *testing.T) { + root := t.TempDir() + compiler := &fakeCompiler{} + bootstrap := Bootstrap{CompilerPaths: newTestToolchain(t, root, "editor"), CacheRoot: filepath.Join(root, "cache"), Run: nil} + finalDirectory := filepath.Join(bootstrap.CacheRoot, workerCacheDirectoryName, bootstrap.computeKey(workerBuildRecipeVersion)) + bootstrap.Run = func(ctx context.Context, cmd *exec.Cmd) (string, string, int, error) { + // The other run renames its directory into place while this one is still compiling. + if err := os.MkdirAll(finalDirectory, cacheDirectoryPermissions); err != nil { + return "", err.Error(), 1, err + } + + for _, name := range []string{workerDllFileName, workerRuntimeConfigFileName, roslynDirectorySidecarFileName} { + writeTestFile(t, filepath.Join(finalDirectory, name), []byte("other")) + } + + return compiler.run(ctx, cmd) + } + + worker, err := bootstrap.Ensure(context.Background()) + if err != nil { + t.Fatalf("expected the other run's worker, got error: %v", err) + } + + content, err := os.ReadFile(worker.DllPath) + if err != nil || string(content) != "other" { + t.Fatalf("expected the worker the other run built, got %q (%v)", content, err) + } +} + +// Verifies that a compiler directory without csc's runtime config fails the build without a cached worker. +func TestEnsureFailsWithoutCompilerRuntimeConfig(t *testing.T) { + root := t.TempDir() + compiler := &fakeCompiler{} + toolchain := newTestToolchain(t, root, "editor") + if err := os.Remove(filepath.Join(toolchain.CompilerDirectory, compilerRuntimeConfigFileName)); err != nil { + t.Fatalf("failed to remove the runtime config: %v", err) + } + + bootstrap := Bootstrap{CompilerPaths: toolchain, CacheRoot: filepath.Join(root, "cache"), Run: compiler.run} + worker, err := bootstrap.Ensure(context.Background()) + if err == nil || !strings.Contains(err.Error(), "runtime config") { + t.Fatalf("expected a missing runtime config error, got %v", err) + } + + if isCompleteWorker(filepath.Dir(worker.DllPath)) { + t.Fatal("expected no cached worker") + } +} + +// Verifies that a cache root that cannot hold directories is reported as an error. +func TestEnsureReportsUnusableCacheRoot(t *testing.T) { + root := t.TempDir() + cacheRoot := filepath.Join(root, "cache") + writeTestFile(t, cacheRoot, []byte("not a directory")) + compiler := &fakeCompiler{} + bootstrap := Bootstrap{CompilerPaths: newTestToolchain(t, root, "editor"), CacheRoot: cacheRoot, Run: compiler.run} + + if _, err := bootstrap.Ensure(context.Background()); err == nil { + t.Fatal("expected an error for a cache root that is a file") + } + + if compiler.calls != 0 { + t.Fatalf("expected csc not to run, ran %d times", compiler.calls) + } +} + +// Verifies that a build that fails without output records the run error, and that the report is cut +// to its limit. +func TestRecordBuildFailureFallsBackToRunErrorAndTruncates(t *testing.T) { + workerDirectory := filepath.Join(t.TempDir(), "worker") + err := recordBuildFailure(workerDirectory, "", errors.New("dotnet not found")) + if err == nil || !strings.Contains(err.Error(), "dotnet not found") { + t.Fatalf("expected the run error in the failure, got %v", err) + } + + recorded, readErr := os.ReadFile(filepath.Join(workerDirectory, buildFailedFileName)) + if readErr != nil || string(recorded) != "dotnet not found" { + t.Fatalf("expected the run error to be recorded, got %q (%v)", recorded, readErr) + } + + long := strings.Repeat("x", buildFailureReportLimit+10) + if got := truncate(long, buildFailureReportLimit); len(got) != buildFailureReportLimit { + t.Fatalf("expected the report cut to %d bytes, got %d", buildFailureReportLimit, len(got)) + } +} + +// Verifies that a leftover worker directory that is neither a worker nor a failure record is +// replaced by the built worker, and that the next Ensure reuses it without building. +func TestEnsureReplacesIncompleteWorkerDirectory(t *testing.T) { + root := t.TempDir() + compiler := &fakeCompiler{} + bootstrap := Bootstrap{CompilerPaths: newTestToolchain(t, root, "editor"), CacheRoot: filepath.Join(root, "cache"), Run: compiler.run} + finalDirectory := filepath.Join(bootstrap.CacheRoot, workerCacheDirectoryName, bootstrap.computeKey(workerBuildRecipeVersion)) + if err := os.MkdirAll(finalDirectory, cacheDirectoryPermissions); err != nil { + t.Fatalf("failed to create the worker directory: %v", err) + } + + writeTestFile(t, filepath.Join(finalDirectory, "leftover.txt"), []byte("leftover")) + worker, err := bootstrap.Ensure(context.Background()) + if err != nil { + t.Fatalf("expected the incomplete directory to be replaced, got error: %v", err) + } + + if !isCompleteWorker(filepath.Dir(worker.DllPath)) { + t.Fatalf("expected a complete worker at %s", worker.DllPath) + } + + if _, err := os.Stat(filepath.Join(finalDirectory, "leftover.txt")); !os.IsNotExist(err) { + t.Fatalf("expected the leftover file to be gone, got %v", err) + } + + entries, err := os.ReadDir(filepath.Join(bootstrap.CacheRoot, workerCacheDirectoryName)) + if err != nil || len(entries) != 1 { + t.Fatalf("expected only the worker directory in the cache, got %v (%v)", entries, err) + } + + if _, err := bootstrap.Ensure(context.Background()); err != nil || compiler.calls != 1 { + t.Fatalf("expected the replaced worker to be reused, got %v after %d builds", err, compiler.calls) + } +} + +// Verifies that a worker directory holding a failure record is not replaced by a build that +// finished later: the record is the answer for this key. +func TestEnsureKeepsFailureRecordAgainstLaterBuild(t *testing.T) { + root := t.TempDir() + compiler := &fakeCompiler{} + bootstrap := Bootstrap{CompilerPaths: newTestToolchain(t, root, "editor"), CacheRoot: filepath.Join(root, "cache"), Run: nil} + finalDirectory := filepath.Join(bootstrap.CacheRoot, workerCacheDirectoryName, bootstrap.computeKey(workerBuildRecipeVersion)) + bootstrap.Run = func(ctx context.Context, cmd *exec.Cmd) (string, string, int, error) { + // Another run records a failure while this build is running. + _ = recordBuildFailure(finalDirectory, "error CS0001", nil) + return compiler.run(ctx, cmd) + } + + if _, err := bootstrap.Ensure(context.Background()); err == nil || !strings.Contains(err.Error(), "failed to move") { + t.Fatalf("expected the move to fail against the failure record, got %v", err) + } + + if _, err := os.Stat(filepath.Join(finalDirectory, buildFailedFileName)); err != nil { + t.Fatalf("expected the failure record to stay, got %v", err) + } +} + +// Verifies that the worker key changes with the build recipe version, so a failure recorded by an +// older recipe is not returned to a newer one. +func TestEnsureKeyChangesWithBuildRecipeVersion(t *testing.T) { + root := t.TempDir() + bootstrap := Bootstrap{CompilerPaths: newTestToolchain(t, root, "editor"), CacheRoot: filepath.Join(root, "cache")} + + if bootstrap.computeKey(1) == bootstrap.computeKey(2) { + t.Fatal("expected different keys for different build recipe versions") + } +} + +// Verifies that a compiler killed by a signal records no failure, so the next run builds again. +func TestEnsureDoesNotRecordSignaledBuild(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("a killed process on Windows reports an exit code, not a signal") + } + + root := t.TempDir() + killed := 0 + bootstrap := Bootstrap{ + CompilerPaths: newTestToolchain(t, root, "editor"), + CacheRoot: filepath.Join(root, "cache"), + Run: func(_ context.Context, _ *exec.Cmd) (string, string, int, error) { + killed++ + return "", "", -1, signaledExitError() + }, + } + + _, err := bootstrap.Ensure(context.Background()) + if err == nil || !strings.Contains(err.Error(), "was killed") { + t.Fatalf("expected a killed build error, got %v", err) + } + + compiler := &fakeCompiler{} + bootstrap.Run = compiler.run + if _, err := bootstrap.Ensure(context.Background()); err != nil { + t.Fatalf("expected the next Ensure to build, got error: %v", err) + } + + if killed != 1 || compiler.calls != 1 { + t.Fatalf("expected one killed and one completed build, got %d and %d", killed, compiler.calls) + } +} + +// Verifies that a PE file without an optional header is not taken for a managed assembly. +func TestIsManagedAssemblyRejectsImageWithoutOptionalHeader(t *testing.T) { + image := peImage(t, 0x2008) + const sizeOfOptionalHeaderOffset = 0x40 + 4 + 16 + binary.LittleEndian.PutUint16(image[sizeOfOptionalHeaderOffset:], 0) + imagePath := filepath.Join(t.TempDir(), "NoOptionalHeader.dll") + writeTestFile(t, imagePath, image[:0x40+4+binary.Size(pe.FileHeader{})]) + + if isManagedAssembly(imagePath) { + t.Fatal("expected an image without an optional header to be rejected") + } +} + +// Verifies that a failure that cannot be recorded is still reported as an error. +func TestRecordBuildFailureReportsUnwritableCache(t *testing.T) { + blocker := filepath.Join(t.TempDir(), "file") + writeTestFile(t, blocker, []byte("file")) + + err := recordBuildFailure(filepath.Join(blocker, "worker"), "error", nil) + if err == nil || !strings.Contains(err.Error(), "failed to record") { + t.Fatalf("expected a record failure, got %v", err) + } +} + +// Verifies that a cache directory where no build directory can be created is reported as an error. +func TestEnsureReportsUnwritableCacheDirectory(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("directory permissions do not stop this user from creating directories") + } + + root := t.TempDir() + cacheDirectory := filepath.Join(root, "cache", workerCacheDirectoryName) + if err := os.MkdirAll(cacheDirectory, cacheDirectoryPermissions); err != nil { + t.Fatalf("failed to create the cache directory: %v", err) + } + + if err := os.Chmod(cacheDirectory, 0o500); err != nil { + t.Fatalf("failed to make the cache directory read-only: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(cacheDirectory, cacheDirectoryPermissions) }) + + compiler := &fakeCompiler{} + bootstrap := Bootstrap{CompilerPaths: newTestToolchain(t, root, "editor"), CacheRoot: filepath.Join(root, "cache"), Run: compiler.run} + if _, err := bootstrap.Ensure(context.Background()); err == nil || !strings.Contains(err.Error(), "build directory") { + t.Fatalf("expected a build directory error, got %v", err) + } +} + +// Verifies that dotnet failing to start records no failure, so the next run builds again. +func TestEnsureDoesNotRecordStartFailure(t *testing.T) { + root := t.TempDir() + starts := 0 + bootstrap := Bootstrap{ + CompilerPaths: newTestToolchain(t, root, "editor"), + CacheRoot: filepath.Join(root, "cache"), + Run: func(_ context.Context, _ *exec.Cmd) (string, string, int, error) { + starts++ + return "", "", 0, errors.New("fork/exec dotnet: resource temporarily unavailable") + }, + } + + _, err := bootstrap.Ensure(context.Background()) + if err == nil || !strings.Contains(err.Error(), "failed to start") { + t.Fatalf("expected a start failure, got %v", err) + } + + compiler := &fakeCompiler{} + bootstrap.Run = compiler.run + if _, err := bootstrap.Ensure(context.Background()); err != nil { + t.Fatalf("expected the next Ensure to build, got error: %v", err) + } + + if starts != 1 || compiler.calls != 1 { + t.Fatalf("expected one failed start and one build, got %d and %d", starts, compiler.calls) + } +} + +// Verifies that the CoreLib of every shared framework bundled with an installed Unity Editor is +// taken for a managed assembly. Skipped where no Editor is installed. +func TestIsManagedAssemblyAcceptsInstalledSharedFrameworkCoreLib(t *testing.T) { + coreLibs, err := filepath.Glob(filepath.Join( + "/Applications/Unity/Hub/Editor", "*", "Unity.app", "Contents", "Resources", "Scripting", + "NetCoreRuntime", "shared", "Microsoft.NETCore.App", "*", "System.Private.CoreLib.dll")) + if err != nil || len(coreLibs) == 0 { + t.Skip("no Unity Editor with a bundled shared framework is installed here") + } + + for _, coreLib := range coreLibs { + if !isManagedAssembly(coreLib) { + t.Errorf("expected %s to be a managed assembly", coreLib) + } + } +} + +// Verifies that files too short or malformed to hold a CLR runtime header are not taken for managed +// assemblies: a truncated DOS header, a missing PE signature, and an unknown optional header magic. +func TestIsManagedAssemblyRejectsMalformedImages(t *testing.T) { + const optionalHeaderOffset = 0x40 + 4 + 20 + noSignature := peImage(t, 0x2008) + copy(noSignature[0x40:], "XX\x00\x00") + unknownMagic := peImage(t, 0x2008) + binary.LittleEndian.PutUint16(unknownMagic[optionalHeaderOffset:], 0x107) + for name, image := range map[string][]byte{ + "Truncated.dll": []byte("MZ"), + "NoSignature.dll": noSignature, + "UnknownMagic.dll": unknownMagic, + } { + imagePath := filepath.Join(t.TempDir(), name) + writeTestFile(t, imagePath, image) + if isManagedAssembly(imagePath) { + t.Errorf("expected %s to be rejected", name) + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/client.go b/cli/dispatcher/internal/compilecheck/fastcheck/client.go new file mode 100644 index 0000000000..67f2ebe488 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/client.go @@ -0,0 +1,187 @@ +package fastcheck + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +const ( + fingerprintCommandName = "fingerprint" + checkCommandName = "check" + defaultWorkerTimeout = 60 * time.Second + requestFileSuffix = ".in.json" + responseFileSuffix = ".out.json" + requestFilePattern = "fastcheck-*" + requestFileSuffix + + // CheckedOutcome means the changed files were checked and the diagnostics are complete for them. + CheckedOutcome = "Checked" + // DeclarationChangedOutcome means a changed file's declarations differ from the baseline. + DeclarationChangedOutcome = "DeclarationChanged" + // UnsupportedOutcome means the assembly uses something the changed-files check cannot handle. + UnsupportedOutcome = "Unsupported" +) + +// The request and response types mirror the worker's JSON contract field for field. Field names +// stay PascalCase in JSON because the worker reads and writes them that way. + +// FingerprintRequest asks for the declaration fingerprint and content hash of each source. +type FingerprintRequest struct { + ProjectRoot string + ResponseFilePath string // absolute + Sources []string // absolute +} + +// FingerprintResponse holds, per absolute source path, the declaration fingerprint and the sha256 +// of the exact bytes it was computed from. +type FingerprintResponse struct { + Fingerprints map[string]string + ContentHashes map[string]string +} + +// CheckRequest asks to check only the changed files of an assembly. +type CheckRequest struct { + ProjectRoot string + ResponseFilePath string // absolute + ChangedFiles []string // absolute + BaselineFingerprints map[string]string // keyed by absolute path + GeneratedDirectory string // absolute, or "" when there is no saved generator output +} + +// CheckResponse is the outcome of a changed-files check. +type CheckResponse struct { + Outcome string + Reason string // why, when Outcome is not CheckedOutcome + Diagnostics []WorkerDiagnostic + UncheckedCodes []string // sorted, unique + GeneratorsNotRerun bool +} + +// WorkerDiagnostic is one error or warning in a changed file, with a 1-based line and column. +type WorkerDiagnostic struct { + File string // absolute + Line int + Column int + Severity string // "error" or "warning" + Code string + Message string +} + +// Client runs one request at a time through a built worker. +type Client struct { + Worker WorkerPaths + // TempDirectory receives the request and response files of each call; they are removed after it. + TempDirectory string + Run CommandRunner + // Timeout bounds each call; zero means one minute. + Timeout time.Duration +} + +// Fingerprint fingerprints every source of an assembly. +func (c Client) Fingerprint(ctx context.Context, request FingerprintRequest) (FingerprintResponse, error) { + response := FingerprintResponse{} + if err := c.invoke(ctx, fingerprintCommandName, request, &response); err != nil { + return FingerprintResponse{}, err + } + + return response, nil +} + +// Check checks only the changed files of an assembly. +func (c Client) Check(ctx context.Context, request CheckRequest) (CheckResponse, error) { + response := CheckResponse{} + if err := c.invoke(ctx, checkCommandName, request, &response); err != nil { + return CheckResponse{}, err + } + + // Rejected here so callers can rely on the three outcomes the worker defines. + switch response.Outcome { + case CheckedOutcome, DeclarationChangedOutcome, UnsupportedOutcome: + return response, nil + default: + return CheckResponse{}, fmt.Errorf("changed-files check worker returned an unknown outcome %q", response.Outcome) + } +} + +// invoke writes the request, runs the worker command, and reads its response into response. +func (c Client) invoke(ctx context.Context, command string, request any, response any) error { + if c.Worker.DllPath == "" { + return errWorkerNotBuilt + } + + requestPath, err := writeRequestFile(c.TempDirectory, request) + if err != nil { + return err + } + defer func() { _ = os.Remove(requestPath) }() + + // Why the response path is derived from the request path: the request file name is already + // unique, so the pair never collides with another call sharing the directory. + responsePath := strings.TrimSuffix(requestPath, requestFileSuffix) + responseFileSuffix + defer func() { _ = os.Remove(responsePath) }() + + invocationContext, cancel := context.WithTimeout(ctx, c.timeout()) + defer cancel() + + cmd := exec.CommandContext(invocationContext, + c.Worker.DotnetHostPath, compilerExecArgument, c.Worker.DllPath, command, requestPath, responsePath) + cmd.Env = append(os.Environ(), multiLevelLookupSetting) + _, stderr, exitCode, runErr := c.Run(invocationContext, cmd) + if contextErr := invocationContext.Err(); contextErr != nil { + return fmt.Errorf("changed-files check worker %s did not finish: %w", command, contextErr) + } + + if runErr != nil || exitCode != 0 { + return fmt.Errorf("changed-files check worker %s failed (exit code %d): %s", + command, exitCode, workerFailureDetail(stderr, runErr)) + } + + content, err := os.ReadFile(responsePath) + if err != nil { + return fmt.Errorf("failed to read the changed-files check worker response: %w", err) + } + + if err := json.Unmarshal(content, response); err != nil { + return fmt.Errorf("failed to parse the changed-files check worker response: %w", err) + } + + return nil +} + +func (c Client) timeout() time.Duration { + if c.Timeout <= 0 { + return defaultWorkerTimeout + } + + return c.Timeout +} + +func writeRequestFile(directory string, request any) (string, error) { + file, err := os.CreateTemp(directory, requestFilePattern) + if err != nil { + return "", fmt.Errorf("failed to create the changed-files check request file: %w", err) + } + + encodeErr := json.NewEncoder(file).Encode(request) + closeErr := file.Close() + if err := errors.Join(encodeErr, closeErr); err != nil { + _ = os.Remove(file.Name()) + return "", fmt.Errorf("failed to write the changed-files check request file: %w", err) + } + + return file.Name(), nil +} + +func workerFailureDetail(stderr string, runErr error) string { + detail := strings.TrimSpace(stderr) + if detail == "" && runErr != nil { + detail = runErr.Error() + } + + return truncate(detail, buildFailureReportLimit) +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/client_test.go b/cli/dispatcher/internal/compilecheck/fastcheck/client_test.go new file mode 100644 index 0000000000..0d6dfeaee2 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/client_test.go @@ -0,0 +1,225 @@ +package fastcheck + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +// newTestClient returns a client whose worker is the given stand-in, with its own temp directory. +func newTestClient(t *testing.T, run CommandRunner) Client { + t.Helper() + return Client{ + Worker: WorkerPaths{Key: "key", DllPath: filepath.Join(t.TempDir(), workerDllFileName), DotnetHostPath: "dotnet"}, + TempDirectory: t.TempDir(), + Run: run, + } +} + +// assertNoRequestFilesLeft fails when a call left its request or response file behind. +func assertNoRequestFilesLeft(t *testing.T, directory string) { + t.Helper() + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatalf("failed to list the temp directory: %v", err) + } + + if len(entries) != 0 { + t.Fatalf("expected the request and response files to be removed, found %d entries", len(entries)) + } +} + +// Verifies that Check sends the request as the worker reads it and parses the worker's response. +func TestCheckParsesResponse(t *testing.T) { + request := CheckRequest{ + ProjectRoot: "/project", + ResponseFilePath: "/project/Sample.Game.fastcheck.rsp", + ChangedFiles: []string{"/project/Probe.cs"}, + BaselineFingerprints: map[string]string{"/project/Probe.cs": "abc"}, + GeneratedDirectory: "/project/Sample.Game.generated", + } + want := CheckResponse{ + Outcome: CheckedOutcome, + Diagnostics: []WorkerDiagnostic{ + {File: "/project/Probe.cs", Line: 7, Column: 25, Severity: "error", Code: "CS0029", Message: "message"}, + }, + UncheckedCodes: []string{"CS0067"}, + GeneratorsNotRerun: true, + } + var sentArguments []string + var sentRequest map[string]any + client := newTestClient(t, func(_ context.Context, cmd *exec.Cmd) (string, string, int, error) { + sentArguments = cmd.Args + content, err := os.ReadFile(cmd.Args[len(cmd.Args)-2]) + if err != nil { + return "", err.Error(), 1, err + } + + if err := json.Unmarshal(content, &sentRequest); err != nil { + return "", err.Error(), 1, err + } + + // The worker's own spelling of the response, not the Go struct's encoding. + response := `{"Outcome":"Checked","Reason":"","Diagnostics":[{"File":"/project/Probe.cs","Line":7,"Column":25,` + + `"Severity":"error","Code":"CS0029","Message":"message"}],"UncheckedCodes":["CS0067"],"GeneratorsNotRerun":true}` + return "", "", 0, os.WriteFile(cmd.Args[len(cmd.Args)-1], []byte(response), cacheFilePermissions) + }) + + got, err := client.Check(context.Background(), request) + if err != nil { + t.Fatalf("expected the check to succeed, got error: %v", err) + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %+v, got %+v", want, got) + } + + if sentArguments[1] != compilerExecArgument || sentArguments[2] != client.Worker.DllPath || sentArguments[3] != checkCommandName { + t.Fatalf("expected dotnet exec check , got %v", sentArguments) + } + + for _, field := range []string{"ProjectRoot", "ResponseFilePath", "ChangedFiles", "BaselineFingerprints", "GeneratedDirectory"} { + if _, found := sentRequest[field]; !found { + t.Fatalf("expected the request to carry %s, got %v", field, sentRequest) + } + } + + assertNoRequestFilesLeft(t, client.TempDirectory) +} + +// Verifies that a worker that cannot start is an error carrying the start failure. +func TestWorkerStartFailureIsError(t *testing.T) { + client := newTestClient(t, func(_ context.Context, _ *exec.Cmd) (string, string, int, error) { + return "", "", 0, errors.New("dotnet not found") + }) + + _, err := client.Check(context.Background(), CheckRequest{}) + if err == nil || !strings.Contains(err.Error(), "dotnet not found") { + t.Fatalf("expected the start failure in the error, got %v", err) + } +} + +// Verifies that a worker exiting with a non-zero code is an error carrying its standard error. +func TestNonZeroExitIsError(t *testing.T) { + client := newTestClient(t, func(_ context.Context, _ *exec.Cmd) (string, string, int, error) { + return "", "System.InvalidOperationException: broken", 3, &exec.ExitError{} + }) + + _, err := client.Fingerprint(context.Background(), FingerprintRequest{}) + if err == nil || !strings.Contains(err.Error(), "exit code 3") || !strings.Contains(err.Error(), "broken") { + t.Fatalf("expected an error with the exit code and standard error, got %v", err) + } + + assertNoRequestFilesLeft(t, client.TempDirectory) +} + +// Verifies that a worker that does not finish within the timeout is an error. +func TestTimeoutIsError(t *testing.T) { + client := newTestClient(t, func(ctx context.Context, _ *exec.Cmd) (string, string, int, error) { + <-ctx.Done() + return "", "", -1, ctx.Err() + }) + client.Timeout = 20 * time.Millisecond + + _, err := client.Check(context.Background(), CheckRequest{}) + if err == nil || !strings.Contains(err.Error(), "did not finish") { + t.Fatalf("expected a timeout error, got %v", err) + } + + assertNoRequestFilesLeft(t, client.TempDirectory) +} + +// Verifies that Fingerprint parses the worker's fingerprints and content hashes. +func TestFingerprintParsesResponse(t *testing.T) { + client := newTestClient(t, func(_ context.Context, cmd *exec.Cmd) (string, string, int, error) { + response := `{"Fingerprints":{"/project/A.cs":"f"},"ContentHashes":{"/project/A.cs":"h"}}` + return "", "", 0, os.WriteFile(cmd.Args[len(cmd.Args)-1], []byte(response), cacheFilePermissions) + }) + + got, err := client.Fingerprint(context.Background(), FingerprintRequest{Sources: []string{"/project/A.cs"}}) + if err != nil { + t.Fatalf("expected the fingerprint to succeed, got error: %v", err) + } + + want := FingerprintResponse{ + Fingerprints: map[string]string{"/project/A.cs": "f"}, + ContentHashes: map[string]string{"/project/A.cs": "h"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %+v, got %+v", want, got) + } +} + +// Verifies that a worker that exits cleanly without writing a response is an error. +func TestMissingResponseIsError(t *testing.T) { + client := newTestClient(t, func(_ context.Context, _ *exec.Cmd) (string, string, int, error) { + return "", "", 0, nil + }) + + _, err := client.Check(context.Background(), CheckRequest{}) + if err == nil || !strings.Contains(err.Error(), "worker response") { + t.Fatalf("expected a response read error, got %v", err) + } + + assertNoRequestFilesLeft(t, client.TempDirectory) +} + +// Verifies that a client without a built worker or a usable temp directory fails before running anything. +func TestClientWithoutWorkerOrTempDirectoryIsError(t *testing.T) { + ran := false + run := func(_ context.Context, _ *exec.Cmd) (string, string, int, error) { + ran = true + return "", "", 0, nil + } + + withoutWorker := newTestClient(t, run) + withoutWorker.Worker = WorkerPaths{} + if _, err := withoutWorker.Check(context.Background(), CheckRequest{}); err == nil { + t.Fatal("expected an error without a built worker") + } + + withoutTempDirectory := newTestClient(t, run) + withoutTempDirectory.TempDirectory = filepath.Join(t.TempDir(), "missing") + if _, err := withoutTempDirectory.Check(context.Background(), CheckRequest{}); err == nil { + t.Fatal("expected an error without a temp directory") + } + + if ran { + t.Fatal("expected the worker not to run") + } +} + +// Verifies that a response that is not JSON is an error. +func TestMalformedResponseIsError(t *testing.T) { + client := newTestClient(t, func(_ context.Context, cmd *exec.Cmd) (string, string, int, error) { + return "", "", 0, os.WriteFile(cmd.Args[len(cmd.Args)-1], []byte("not json"), cacheFilePermissions) + }) + + _, err := client.Check(context.Background(), CheckRequest{}) + if err == nil || !strings.Contains(err.Error(), "parse") { + t.Fatalf("expected a response parse error, got %v", err) + } + + assertNoRequestFilesLeft(t, client.TempDirectory) +} + +// Verifies that a response whose outcome is none of the three the worker defines is an error. +func TestUnknownOutcomeIsError(t *testing.T) { + for _, response := range []string{`{"Outcome":""}`, `{"Outcome":null}`, `{}`, `{"Outcome":"Skipped"}`} { + client := newTestClient(t, func(_ context.Context, cmd *exec.Cmd) (string, string, int, error) { + return "", "", 0, os.WriteFile(cmd.Args[len(cmd.Args)-1], []byte(response), cacheFilePermissions) + }) + + _, err := client.Check(context.Background(), CheckRequest{}) + if err == nil || !strings.Contains(err.Error(), "unknown outcome") { + t.Fatalf("expected an unknown outcome error for %s, got %v", response, err) + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/helper_process_test.go b/cli/dispatcher/internal/compilecheck/fastcheck/helper_process_test.go new file mode 100644 index 0000000000..c9e977f4f4 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/helper_process_test.go @@ -0,0 +1,67 @@ +package fastcheck + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "testing" + "time" +) + +// helperExitCodeVariable makes the test binary exit with the given code instead of running tests, +// so a test can obtain the error a real process exit produces. +const helperExitCodeVariable = "FASTCHECK_TEST_HELPER_EXIT_CODE" + +// helperSleepVariable makes the test binary wait until it is killed instead of running tests. +const helperSleepVariable = "FASTCHECK_TEST_HELPER_SLEEP" + +func TestMain(m *testing.M) { + if code := os.Getenv(helperExitCodeVariable); code != "" { + exitCode, err := strconv.Atoi(code) + if err != nil { + os.Exit(1) + } + + os.Exit(exitCode) + } + + if os.Getenv(helperSleepVariable) != "" { + time.Sleep(time.Minute) + os.Exit(0) + } + + os.Exit(m.Run()) +} + +// exitErrorWithCode returns the error a process that ran and exited with code produces. +func exitErrorWithCode(code int) error { + command := exec.Command(os.Args[0]) + command.Env = append(os.Environ(), fmt.Sprintf("%s=%d", helperExitCodeVariable, code)) + err := command.Run() + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + panic(fmt.Sprintf("expected the helper process to exit with %d, got %v", code, err)) + } + + return err +} + +// signaledExitError returns the error a process killed by a signal produces. +func signaledExitError() error { + command := exec.Command(os.Args[0]) + command.Env = append(os.Environ(), helperSleepVariable+"=1") + if err := command.Start(); err != nil { + panic(fmt.Sprintf("failed to start the helper process: %v", err)) + } + + _ = command.Process.Kill() + err := command.Wait() + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + panic(fmt.Sprintf("expected the killed helper process to report an exit error, got %v", err)) + } + + return err +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/sources.go b/cli/dispatcher/internal/compilecheck/fastcheck/sources.go new file mode 100644 index 0000000000..3cb8dfa24a --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/sources.go @@ -0,0 +1,13 @@ +// Package fastcheck builds and runs the C# worker that checks only the changed files of an +// assembly against the rest of it. +package fastcheck + +import "embed" + +// workerSources holds the worker's C# sources. They ship as source, not as a built DLL, because +// the worker has to compile against the Roslyn of the Editor it runs with. +// +//go:embed worker/*.cs +var workerSources embed.FS + +const workerSourceDirectory = "worker" From 4ced3f20083c29bd8a08cad41cced24eb62ec449 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 14 Oct 2026 22:13:36 +0900 Subject: [PATCH 03/33] feat: compile-check checks only the changed files after a body edit in a large assembly (#3317) --- .../CliOnlyTools~/CompileCheck/Skill/SKILL.md | 9 + .../compilecheck/baseline_record_test.go | 290 +++++++++ .../internal/compilecheck/compiler.go | 44 +- .../internal/compilecheck/fast_path.go | 575 ++++++++++++++++++ .../compilecheck/fast_path_fixture_test.go | 316 ++++++++++ .../internal/compilecheck/fast_path_test.go | 487 +++++++++++++++ .../internal/compilecheck/input_key.go | 66 +- .../internal/compilecheck/input_key_test.go | 92 ++- .../internal/compilecheck/result_cache.go | 52 +- .../internal/compilecheck/session.go | 9 + .../internal/dispatcher/compile_check.go | 176 ++++-- .../compile_check_fast_path_test.go | 131 ++++ .../internal/dispatcher/compile_check_test.go | 6 + docs/compile-check.md | 71 ++- 14 files changed, 2266 insertions(+), 58 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/baseline_record_test.go create mode 100644 cli/dispatcher/internal/compilecheck/fast_path.go create mode 100644 cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go create mode 100644 cli/dispatcher/internal/compilecheck/fast_path_test.go create mode 100644 cli/dispatcher/internal/dispatcher/compile_check_fast_path_test.go diff --git a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md index 542567b5a3..5ff4d5728f 100644 --- a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md @@ -53,11 +53,16 @@ An assembly whose inputs are all exactly as the previous `compile-check` read th again: the run reports the diagnostics recorded then, errors included. `--all` compiles everything from scratch, which is the way out if a reused result ever looks wrong. +In a large assembly whose last compile-check compile succeeded, an edit that keeps every +declaration is checked on the changed files only, in a few seconds. Such a check skips a few +assembly-wide rules, so run once with `--full` before finishing. + ## Parameters | Parameter | Type | Description | |-----------|------|-------------| | `--all` | flag | Compile every assembly instead of only the changed ones and their dependents | +| `--full` | flag | Compile changed assemblies in full instead of checking only their changed files | | `--editor-version ` | string | Use this Unity Editor version's compiler instead of ProjectVersion.txt | | `--jobs ` | number | Compile up to N assemblies at once (default: half the CPUs, at least 1; `1` compiles sequentially) | | `--max-depth ` | number | Search depth when locating the project (default: 3, -1 for unlimited) | @@ -73,6 +78,10 @@ A JSON payload: - `CompiledAssemblies`: the assemblies this run compiled, in dependency order - `ReusedAssemblies`: the assemblies reported from the previous run's recorded result, without compiling - `BlockedAssemblies`: the assemblies not compiled because an assembly they reference has errors, as Unity's own build would stop there; fix those errors and run again +- `FastCheckedAssemblies`: the assemblies checked on their changed files only +- `FastChecks`: per fast-checked assembly, `ChangedFiles`, `UncheckedCodes` (rules only `--full` runs), and `GeneratorsNotRerun` +- `FastPathFallbacks`: `Assembly` and `Reason` for each assembly that was compiled in full instead +- `FastPathBaselineNotes`: `Assembly` and `Reason` for each successful full compile that left no baseline for the next changed-files check - `SkippedAssemblies`: how many assemblies were left out: nothing they compile from changed, or every assembly they reference kept the same public surface - `ResponseFileSet`: the Bee build the run replayed - `ProjectRoot`: resolved project root diff --git a/cli/dispatcher/internal/compilecheck/baseline_record_test.go b/cli/dispatcher/internal/compilecheck/baseline_record_test.go new file mode 100644 index 0000000000..3d4ca7cd8a --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/baseline_record_test.go @@ -0,0 +1,290 @@ +package compilecheck + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" +) + +// Verifies that a successful full compile records a baseline whose content hashes are the ones the +// input key read and whose input key is the manifest's. +func TestBaselineRecordedAfterSuccessfulCompile(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + + project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + + record := project.readBaseline(t) + keyResult, err := computeInputKey(project.root, project.paths, project.plan, project.plan.Units[0]) + if err != nil { + t.Fatalf("expected a key, got error: %v", err) + } + if record.InputKey != project.readManifest(t).InputKey || record.InputKey != keyResult.Key { + t.Errorf("expected the baseline to carry the manifest's input key, got %s", record.InputKey) + } + for source, hash := range keyResult.SourceHashes { + if record.Files[source].ContentHash != hash { + t.Fatalf("expected the content hash of %s to be %s, got %+v", source, hash, record.Files[source]) + } + } +} + +// Verifies that a failed full compile records no baseline. +func TestBaselineNotRecordedForFailedCompile(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + recorder.output["Alpha"] = failingCompilerOutput("Alpha") + + project.run(t, recorder, false) + + if project.hasBaseline() || project.worker.fingerprints != 0 { + t.Errorf("expected no baseline after a failed compile, got %d fingerprint calls", project.worker.fingerprints) + } +} + +// Verifies that a unit picked only as a dependent records no baseline. +func TestBaselineNotRecordedForDependentUnit(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + + project.compileAlphaAsDependent(t) + + if project.hasBaseline() || project.worker.fingerprints != 0 { + t.Error("expected no baseline for a unit picked only as a dependent") + } +} + +// Verifies that an assembly with fewer sources than the minimum records no baseline. +func TestBaselineNotRecordedForSmallAssembly(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources-1) + + project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + + if project.hasBaseline() || project.worker.ensures != 0 { + t.Errorf("expected no baseline and no worker for a small assembly, got %d builds", project.worker.ensures) + } +} + +// Verifies that a baseline that cannot be recorded leaves a note and does not fail the run. +func TestBaselineFailureDoesNotFailRun(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.worker.ensureErr = errors.New("csc failed") + + outcome := project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + + result := resultOf(t, outcome.Units, "Alpha") + if !result.Succeeded || result.FastPathNote != "baseline not recorded: csc failed" { + t.Errorf("expected a successful compile with a note, got %+v", result) + } + if project.hasBaseline() { + t.Error("expected no baseline when the worker is unavailable") + } +} + +// Verifies that a source edited while csc compiled it leaves no baseline, and says why. +func TestBaselineNotRecordedWhenSourceChangedDuringCompile(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + recorder.during = func(name string) { + if name == "Alpha" { + project.editBody(t, 0, "edited during the compile") + } + } + + outcome := project.run(t, recorder, false) + + result := resultOf(t, outcome.Units, "Alpha") + if !strings.Contains(result.FastPathNote, "source changed during the compile: "+project.sourceName(0)) { + t.Errorf("expected a note naming the edited source, got %q", result.FastPathNote) + } + if project.hasBaseline() { + t.Error("expected no baseline when a source changed during the compile") + } +} + +// Verifies that a successful full compile under --full still records a baseline. +func TestBaselineRecordedUnderFullFlag(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + + project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), true) + + if !project.hasBaseline() { + t.Error("expected --full to record a baseline") + } +} + +// Verifies that the full compile writes its generator output where the baseline points. +func TestCompileUnitPassesGeneratedFilesOut(t *testing.T) { + written, _ := compileSecondUnit(t, "", 0) + + plan := newCompilerPlan() + want := quoteFlag(generatedFilesOutFlagPrefix, filepath.ToSlash(generatedDirectory(plan, plan.Units[1]))) + if !strings.Contains(written, want) { + t.Errorf("expected %s in the response file, got:\n%s", want, written) + } +} + +// Verifies that removing a unit's outputs also removes its baseline and its saved generator output. +func TestRemoveUnitOutputsRemovesBaselineAndGeneratedDirectory(t *testing.T) { + outputDirectoryPath, unit := newManifestUnit(t) + writeOutputFile(t, outputDirectoryPath, "Alpha"+baselineRecordExtension, "{}") + generatedPath := filepath.Join(outputDirectoryPath, "Alpha"+generatedDirectoryExtension, "Nested") + if err := os.MkdirAll(generatedPath, outputDirPermissions); err != nil { + t.Fatalf("failed to create the generated directory: %v", err) + } + writeOutputFile(t, generatedPath, "G.cs", "class G {}") + + if err := removeUnitOutputs(outputDirectoryPath, unit); err != nil { + t.Fatalf("expected the outputs to be removed, got error: %v", err) + } + + for _, name := range []string{"Alpha" + baselineRecordExtension, "Alpha" + generatedDirectoryExtension} { + if _, err := os.Stat(filepath.Join(outputDirectoryPath, name)); !os.IsNotExist(err) { + t.Errorf("expected %s to be removed, got %v", name, err) + } + } +} + +// Verifies that the worker build is shared by later callers, and that a cancelled build is tried again. +func TestOnceWithContextSharesTheAnswerButNotACancellation(t *testing.T) { + calls := 0 + ensure := onceWithContext(func(ctx context.Context) (fastcheck.WorkerPaths, error) { + calls++ + if calls == 1 { + return fastcheck.WorkerPaths{}, context.Canceled + } + + return fastcheck.WorkerPaths{Key: "built"}, nil + }) + + if _, err := ensure(context.Background()); !errors.Is(err, context.Canceled) { + t.Fatalf("expected the first call to be cancelled, got %v", err) + } + for range 2 { + paths, err := ensure(context.Background()) + if err != nil || paths.Key != "built" { + t.Fatalf("expected the built worker, got %+v and %v", paths, err) + } + } + if calls != 2 { + t.Errorf("expected two builds, got %d", calls) + } +} + +// Verifies that the worker the run builds is built at most once, even when the build fails, and that +// a failing worker call is an error. +func TestBootstrappedWorkerBuildsAtMostOncePerRun(t *testing.T) { + root := t.TempDir() + starts := 0 + run := func(_ context.Context, _ *exec.Cmd) (string, string, int, error) { + starts++ + return "", "", 0, errors.New("fork/exec dotnet: resource temporarily unavailable") + } + worker := newBootstrappedWorker( + EditorCompilerPaths{DotnetHostPath: "/dotnet", CompilerDllPath: "/csc.dll", CompilerDirectory: root, SharedFrameworkPath: root}, + filepath.Join(root, "cache"), root, run) + + for range 2 { + if _, err := worker.Ensure(context.Background()); err == nil { + t.Fatal("expected the worker build to fail") + } + } + if starts != 1 { + t.Fatalf("expected one build attempt per run, got %d", starts) + } + + paths := fastcheck.WorkerPaths{Key: "k", DllPath: "/worker.dll", DotnetHostPath: "/dotnet"} + if _, err := worker.Fingerprint(context.Background(), paths, fastcheck.FingerprintRequest{}); err == nil { + t.Error("expected a failing fingerprint call to be an error") + } + if _, err := worker.Check(context.Background(), paths, fastcheck.CheckRequest{}); err == nil { + t.Error("expected a failing check call to be an error") + } +} + +// Verifies that a worker diagnostic in a file that is not a source of the assembly, such as saved +// generator output, is spelled relative to the project root. +func TestMergeDiagnosticsSpellsOtherFilesRelativeToTheProjectRoot(t *testing.T) { + projectRoot := t.TempDir() + unit := CompileUnit{Sources: []string{"Assets/A.cs"}} + fresh := []fastcheck.WorkerDiagnostic{{ + File: filepath.Join(projectRoot, "Library", "Gen", "G.cs"), Line: 1, Column: 1, Severity: severityWarning, Code: "CS0168", + }} + + merged := mergeDiagnostics(projectRoot, unit, nil, fresh, []string{"Assets/A.cs"}) + + if len(merged) != 1 || merged[0].File != "Library/Gen/G.cs" { + t.Errorf("expected the generated file relative to the project root, got %+v", merged) + } +} + +// Verifies that the directory csc saves generator output to exists before csc starts, because the +// csc of Roslyn 4.3 creates the per-generator folders under it but not the directory itself. +func TestCompileUnitCreatesGeneratedFilesOutDirectory(t *testing.T) { + projectRoot := t.TempDir() + plan := newCompilerPlan() + makeOutputDirectory(t, projectRoot, plan) + writeFileAt(t, filepath.Join(projectRoot, plan.OutputDir, "A.ref.dll"), "") + existed := false + compiler := Compiler{ + Paths: EditorCompilerPaths{DotnetHostPath: "/dotnet", CompilerDllPath: "/csc.dll"}, + ProjectRoot: projectRoot, + Timeout: time.Minute, + Run: func(_ context.Context, _ *exec.Cmd) (string, string, int, error) { + info, err := os.Stat(filepath.Join(projectRoot, generatedDirectory(plan, plan.Units[1]))) + existed = err == nil && info.IsDir() + return "", "", 0, nil + }, + } + + if _, err := compiler.CompileUnit(context.Background(), plan, plan.Units[1]); err != nil { + t.Fatalf("expected the unit to compile, got error: %v", err) + } + + if !existed { + t.Error("expected the generator output directory to exist when csc starts") + } +} + +// Verifies that a baseline is not recorded when the non-source key could not be read before the +// compile, since nothing then says which inputs the compile saw. +func TestRecordBaselineRefusesAMissingNonSourceKey(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + makeOutputDirectory(t, project.root, project.plan) + unit := project.plan.Units[0] + keyResult, err := computeInputKey(project.root, project.paths, project.plan, unit) + if err != nil { + t.Fatalf("expected a key, got error: %v", err) + } + compiler := Compiler{Paths: project.paths, ProjectRoot: project.root} + + err = project.runner(false).RecordBaseline(context.Background(), compiler, project.plan, unit, keyResult, "") + + if err == nil || project.hasBaseline() { + t.Errorf("expected no baseline without a non-source key, got %v", err) + } +} + +// Verifies that only generated C# files are recorded, keyed relative to the generator output directory. +func TestHashGeneratedFilesRecordsOnlyCSharpFiles(t *testing.T) { + directory := t.TempDir() + if err := os.MkdirAll(filepath.Join(directory, "Gen"), outputDirPermissions); err != nil { + t.Fatalf("failed to create the generator folder: %v", err) + } + writeOutputFile(t, filepath.Join(directory, "Gen"), "A.g.cs", "class A { }") + writeOutputFile(t, filepath.Join(directory, "Gen"), "notes.txt", "not code") + + hashes, err := hashGeneratedFiles(directory) + + if err != nil || len(hashes) != 1 || hashes["Gen/A.g.cs"] == "" { + t.Errorf("expected only Gen/A.g.cs, got %v (%v)", hashes, err) + } + if missing, err := hashGeneratedFiles(filepath.Join(directory, "missing")); err != nil || len(missing) != 0 { + t.Errorf("expected a missing directory to hold no files, got %v (%v)", missing, err) + } +} diff --git a/cli/dispatcher/internal/compilecheck/compiler.go b/cli/dispatcher/internal/compilecheck/compiler.go index 077977876e..e3e98142c5 100644 --- a/cli/dispatcher/internal/compilecheck/compiler.go +++ b/cli/dispatcher/internal/compilecheck/compiler.go @@ -59,6 +59,20 @@ type UnitResult struct { // Reused marks a result this run replayed from the previous one rather than compiled, because // every input the previous compile read is still exactly as it read it. Reused bool + // FastChecked marks a result that came from checking only the changed files against the last + // successful full compile, instead of compiling the assembly. + FastChecked bool + // FastCheckChangedFiles lists the files a fast check checked, spelled as the response file does. + FastCheckChangedFiles []string + // UncheckedCodes lists the diagnostic IDs a fast check could not run; only a full compile does. + UncheckedCodes []string + // GeneratorsNotRerun is set when a fast check read the generator output of the last full + // compile instead of running the source generators. + GeneratorsNotRerun bool + // FastPathFallbackReason says why a unit the fast check was tried on was compiled in full. + FastPathFallbackReason string + // FastPathNote says why a successful full compile left no baseline for the next fast check. + FastPathNote string } // CommandRunner runs one compiler invocation, so tests can stand in for the real process. @@ -70,6 +84,8 @@ type Compiler struct { ProjectRoot string Timeout time.Duration Run CommandRunner + // FastPath checks only the changed files when it can; nil compiles every unit in full. + FastPath *fastPathRunner } // CompileUnit compiles one assembly and reports the diagnostics csc produced for it. @@ -90,7 +106,18 @@ func (c Compiler) CompileUnit( return UnitResult{}, err } - responseFilePath, err := writeRewrittenResponseFile(c.ProjectRoot, plan, unit) + // Why the generator output directory is created here: the csc of Roslyn 4.3 (Unity 2022.3) creates + // the folder of each generator under it but not the directory itself, and fails every generated + // file with CS0016 when it is missing. Newer Roslyn creates it either way. + generatedPath := filepath.Join(c.ProjectRoot, generatedDirectory(plan, unit)) + if err := os.MkdirAll(generatedPath, outputDirPermissions); err != nil { + return UnitResult{}, fmt.Errorf("failed to create %s: %w", generatedPath, err) + } + + // Why the generator output is saved: a later fast check reads it instead of running the generators. + responseFilePath, err := writeRewrittenResponseFile(c.ProjectRoot, plan, unit, + unit.Assembly.AssemblyName+responseFileExtension, + []string{quoteFlag(generatedFilesOutFlagPrefix, filepath.ToSlash(generatedDirectory(plan, unit)))}) if err != nil { return UnitResult{}, err } @@ -136,6 +163,8 @@ func removeUnitOutputs(outputDirectoryPath string, unit CompileUnit) error { // Why the manifest goes with them: it describes those very files, so leaving it behind would // let a later run replay diagnostics for outputs this one removed. unit.Assembly.AssemblyName + resultManifestExtension, + // The baseline and the generator output describe the same compile as the manifest. + unit.Assembly.AssemblyName + baselineRecordExtension, } for _, name := range names { path := filepath.Join(outputDirectoryPath, name) @@ -144,6 +173,11 @@ func removeUnitOutputs(outputDirectoryPath string, unit CompileUnit) error { } } + generatedPath := filepath.Join(outputDirectoryPath, unit.Assembly.AssemblyName+generatedDirectoryExtension) + if err := os.RemoveAll(generatedPath); err != nil { + return fmt.Errorf("failed to remove %s: %w", generatedPath, err) + } + return nil } @@ -234,9 +268,10 @@ func parseDiagnosticLine(line string) (Diagnostic, bool) { } // writeRewrittenResponseFile writes the response file that compiles one unit into the check's own -// output directory, so nothing this run produces can reach the assemblies Unity loads. +// output directory, so nothing this run produces can reach the assemblies Unity loads. It is named +// fileName inside that directory, and extraLines are appended after Bee's own flags. func writeRewrittenResponseFile( - projectRoot string, plan BuildPlan, unit CompileUnit, + projectRoot string, plan BuildPlan, unit CompileUnit, fileName string, extraLines []string, ) (string, error) { rsp := unit.Assembly lines := []string{ @@ -279,8 +314,9 @@ func writeRewrittenResponseFile( // own; synthesizing the flag instead would be wrong for every assembly Bee left it out of. The // diagnostics are untouched: csc prints the paths the response file lists, already project-relative. lines = append(lines, rsp.CompanionFlags...) + lines = append(lines, extraLines...) - path := filepath.Join(projectRoot, plan.OutputDir, rsp.AssemblyName+responseFileExtension) + path := filepath.Join(projectRoot, plan.OutputDir, fileName) if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), responseFilePermissions); err != nil { return "", fmt.Errorf("failed to write %s: %w", path, err) } diff --git a/cli/dispatcher/internal/compilecheck/fast_path.go b/cli/dispatcher/internal/compilecheck/fast_path.go new file mode 100644 index 0000000000..a275440df3 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fast_path.go @@ -0,0 +1,575 @@ +package compilecheck + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" +) + +const ( + baselineRecordFormatVersion = 2 + baselineRecordExtension = ".fastcheck.json" + generatedDirectoryExtension = ".generated" + fastCheckResponseFileSuffix = ".fastcheck.rsp" + generatedFilesOutFlagPrefix = "-generatedfilesout:" + + // fastPathMinimumSources keeps small assemblies on the full compile: csc compiles them in a few + // seconds, so paying the worker's fixed start-up cost (about 1.5 seconds) buys nothing. + fastPathMinimumSources = 300 + // fastPathMaximumChangedFiles keeps a wide edit on the full compile, which a branch switch or a + // rename across many files would otherwise push through a per-file check slower than csc. + fastPathMaximumChangedFiles = 30 +) + +// fastCheckWorker is what the changed-files check needs from its worker, so tests can stand in +// for the real build and process. +type fastCheckWorker interface { + Ensure(ctx context.Context) (fastcheck.WorkerPaths, error) + Fingerprint(ctx context.Context, worker fastcheck.WorkerPaths, request fastcheck.FingerprintRequest) (fastcheck.FingerprintResponse, error) + Check(ctx context.Context, worker fastcheck.WorkerPaths, request fastcheck.CheckRequest) (fastcheck.CheckResponse, error) +} + +// fastPathRunner checks only the changed files of a unit against the last successful full compile, +// and records the baseline that check needs after each successful full compile. +type fastPathRunner struct { + // TryDisabled stops trying the changed-files check (--full) without stopping the recording, so + // the run after a --full run can still use it. + TryDisabled bool + Worker fastCheckWorker +} + +// baselineRecord is what a successful full compile leaves for the next changed-files check. +type baselineRecord struct { + FormatVersion int + // InputKey is the key of the full compile this baseline describes; its manifest carries the same. + InputKey string + // NonSourceKey covers every input but the source contents, so any other change compiles in full. + NonSourceKey string + // WorkerKey names the worker build that computed the fingerprints. + WorkerKey string + GeneratedDirectory string // relative to the project root + // GeneratedFiles maps each generated file, relative to GeneratedDirectory, to its content hash, + // so a check never runs without the generator output its baseline compile had. + GeneratedFiles map[string]string + Files map[string]baselineFile +} + +// baselineFile describes one source as the full compile saw it. +type baselineFile struct { + ContentHash string + Fingerprint string +} + +// fastPathAttempt is the outcome of trying the changed-files check on one unit: a result to use, +// the reason it fell back to a full compile, or an error that stops the run. +type fastPathAttempt struct { + Used bool + Result UnitResult + Reason string + Err error +} + +func fallBack(reason string) fastPathAttempt { + return fastPathAttempt{Reason: reason} +} + +// shouldTry reports whether the changed-files check is worth trying on a unit before compiling it. +// Why a unit picked only as a dependent is left out: nothing it compiles from changed, so it has no +// changed file to check, and what moved under it is a reference the check does not follow. +// Why a small assembly is left out without a reason: it never gets a baseline, so listing it as a +// fallback on every run would only bury the fallbacks that say something. +func (runner *fastPathRunner) shouldTry(plan BuildPlan, unit CompileUnit, key string) bool { + return runner != nil && !runner.TryDisabled && key != "" && !plan.All && !unit.SelectedAsDependent && + len(unit.Sources) >= fastPathMinimumSources +} + +// shouldRecord reports whether a full compile of a unit should leave a baseline. +func (runner *fastPathRunner) shouldRecord(unit CompileUnit, result UnitResult) bool { + return runner != nil && result.Succeeded && !unit.SelectedAsDependent && + len(unit.Sources) >= fastPathMinimumSources +} + +// Try checks only the changed files of a unit when the last full compile left a usable baseline, +// and otherwise says why the unit has to be compiled in full. +// nonSourceKey is the unit's non-source key computed before anything ran, or "" when it could not be. +func (runner *fastPathRunner) Try( + ctx context.Context, c Compiler, plan BuildPlan, unit CompileUnit, keyResult inputKeyResult, nonSourceKey string, +) fastPathAttempt { + outputDirectoryPath := filepath.Join(c.ProjectRoot, plan.OutputDir) + record, manifest, reason := loadBaseline(outputDirectoryPath, unit) + if reason != "" { + return fallBack(reason) + } + + if nonSourceKey == "" || nonSourceKey != record.NonSourceKey { + return fallBack("inputs other than source contents changed") + } + + if !generatedFilesMatch(projectPath(c.ProjectRoot, record.GeneratedDirectory), record.GeneratedFiles) { + return fallBack("generator output of the baseline is gone") + } + + changed := changedSources(unit, keyResult, record) + if len(changed) == 0 { + // The source contents and every other input match the record, so the input key matches it + // too, and reuse would have answered first. Getting here means the record and the manifest + // disagree; the full compile that follows removes both before it records them again. + return fallBack("baseline record does not match its compile result") + } + + if len(changed) > fastPathMaximumChangedFiles { + return fallBack("too many changed files") + } + + return runner.check(ctx, c, plan, unit, record, manifest, changed) +} + +// check runs the worker over the changed files and merges what it found with the diagnostics the +// last full compile reported for the files that did not change. +func (runner *fastPathRunner) check( + ctx context.Context, c Compiler, plan BuildPlan, unit CompileUnit, + record baselineRecord, manifest unitResultManifest, changed []string, +) fastPathAttempt { + startedAt := time.Now() + worker, err := runner.Worker.Ensure(ctx) + if ctxErr := ctx.Err(); ctxErr != nil { + return fastPathAttempt{Err: ctxErr} + } + if err != nil { + return fallBack("worker unavailable: " + err.Error()) + } + + if worker.Key != record.WorkerKey { + return fallBack("baseline was fingerprinted by another worker build") + } + + responseFilePath, err := writeRewrittenResponseFile( + c.ProjectRoot, plan, unit, unit.Assembly.AssemblyName+fastCheckResponseFileSuffix, nil) + if err != nil { + return fallBack("worker failed: " + err.Error()) + } + + response, err := runner.Worker.Check(ctx, worker, buildCheckRequest(c.ProjectRoot, record, responseFilePath, changed)) + if ctxErr := ctx.Err(); ctxErr != nil { + return fastPathAttempt{Err: ctxErr} + } + if err != nil { + return fallBack("worker failed: " + err.Error()) + } + + if response.Outcome != fastcheck.CheckedOutcome { + return fallBack(response.Outcome + ": " + projectRelativeReason(c.ProjectRoot, response.Reason, changed)) + } + + diagnostics := mergeDiagnostics(c.ProjectRoot, unit, manifest.Diagnostics, response.Diagnostics, changed) + + return fastPathAttempt{Used: true, Result: UnitResult{ + Assembly: unit.Assembly.AssemblyName, + Diagnostics: diagnostics, + Succeeded: !containsError(diagnostics), + Duration: time.Since(startedAt), + FastChecked: true, + FastCheckChangedFiles: changed, + UncheckedCodes: response.UncheckedCodes, + GeneratorsNotRerun: response.GeneratorsNotRerun, + }} +} + +// projectRelativeReason rewrites the absolute paths the worker put in a reason the way the output +// spells files. Why: callers paste the output into pull requests and logs, and an absolute path +// would carry the local machine's layout with it. +func projectRelativeReason(projectRoot string, reason string, changed []string) string { + for _, source := range changed { + reason = strings.ReplaceAll(reason, projectPath(projectRoot, source), source) + } + + return strings.ReplaceAll(reason, projectRoot+string(filepath.Separator), "") +} + +// buildCheckRequest names, in absolute paths, what the worker checks and against which baseline. +func buildCheckRequest( + projectRoot string, record baselineRecord, responseFilePath string, changed []string, +) fastcheck.CheckRequest { + request := fastcheck.CheckRequest{ + ProjectRoot: projectRoot, + ResponseFilePath: responseFilePath, + ChangedFiles: make([]string, 0, len(changed)), + BaselineFingerprints: map[string]string{}, + } + if record.GeneratedDirectory != "" { + request.GeneratedDirectory = projectPath(projectRoot, record.GeneratedDirectory) + } + for _, source := range changed { + absolute := projectPath(projectRoot, source) + request.ChangedFiles = append(request.ChangedFiles, absolute) + request.BaselineFingerprints[absolute] = record.Files[source].Fingerprint + } + + return request +} + +// loadBaseline reads the baseline of a unit and the manifest of the full compile it describes, or +// says why there is none to use. +func loadBaseline(outputDirectoryPath string, unit CompileUnit) (baselineRecord, unitResultManifest, string) { + const noBaseline = "no baseline from an earlier full compile" + content, err := os.ReadFile(baselineRecordPath(outputDirectoryPath, unit)) + if err != nil { + return baselineRecord{}, unitResultManifest{}, noBaseline + } + + var record baselineRecord + if err := json.Unmarshal(content, &record); err != nil || record.FormatVersion != baselineRecordFormatVersion { + return baselineRecord{}, unitResultManifest{}, noBaseline + } + + // In the normal flow the record and the manifest are written together under one key, and a full + // compile removes both, so the checks below only catch a state someone changed by hand. + const goneFromDisk = "baseline compile is no longer on disk" + manifestContent, err := os.ReadFile(manifestPath(outputDirectoryPath, unit)) + if err != nil { + return baselineRecord{}, unitResultManifest{}, goneFromDisk + } + + var manifest unitResultManifest + if err := json.Unmarshal(manifestContent, &manifest); err != nil || + manifest.InputKey != record.InputKey || !manifest.Succeeded || + !outputsStillMatch(outputDirectoryPath, manifest.Outputs) { + return baselineRecord{}, unitResultManifest{}, goneFromDisk + } + + return record, manifest, "" +} + +// changedSources lists the sources whose content differs from the baseline, using the hashes the +// input key read rather than reading the files again. +func changedSources(unit CompileUnit, keyResult inputKeyResult, record baselineRecord) []string { + changed := []string{} + for _, source := range unit.Sources { + if keyResult.SourceHashes[source] != record.Files[source].ContentHash { + changed = append(changed, source) + } + } + + return changed +} + +// mergeDiagnostics combines the last full compile's diagnostics for the files that did not change +// with what the worker found in the changed ones. +// Why duplicates are dropped by position and code: the analysis of a changed file can report a +// diagnostic in another file, which the full compile reported as well. +func mergeDiagnostics( + projectRoot string, unit CompileUnit, carried []Diagnostic, fresh []fastcheck.WorkerDiagnostic, changed []string, +) []Diagnostic { + changedSet := map[string]bool{} + for _, source := range changed { + changedSet[normalizeDiagnosticPath(projectRoot, source)] = true + } + + spellings := map[string]string{} + for _, source := range unit.Sources { + spellings[normalizeDiagnosticPath(projectRoot, source)] = source + } + + merged := []Diagnostic{} + seen := map[string]bool{} + add := func(diagnostic Diagnostic) { + key := fmt.Sprintf("%s:%d:%d:%s", + normalizeDiagnosticPath(projectRoot, diagnostic.File), diagnostic.Line, diagnostic.Column, diagnostic.Code) + if seen[key] { + return + } + seen[key] = true + merged = append(merged, diagnostic) + } + + for _, diagnostic := range carried { + if !changedSet[normalizeDiagnosticPath(projectRoot, diagnostic.File)] { + add(diagnostic) + } + } + for _, diagnostic := range fresh { + add(Diagnostic{ + Severity: diagnostic.Severity, + Code: diagnostic.Code, + Message: diagnostic.Message, + File: diagnosticFileSpelling(projectRoot, diagnostic.File, spellings), + Line: diagnostic.Line, + Column: diagnostic.Column, + }) + } + + return merged +} + +// diagnosticFileSpelling writes a worker diagnostic's absolute path the way csc would print it: as +// the response file spells the source, or relative to the project root for anything else. +func diagnosticFileSpelling(projectRoot string, file string, spellings map[string]string) string { + if spelling, found := spellings[normalizeDiagnosticPath(projectRoot, file)]; found { + return spelling + } + + relative, err := filepath.Rel(projectRoot, file) + if err != nil { + return filepath.ToSlash(file) + } + + return filepath.ToSlash(relative) +} + +// normalizeDiagnosticPath turns a path csc printed, a response file spelled, or the worker reported +// into one comparable form. +// Why lowercased on Windows only: its file system ignores case, so two spellings name one file. +func normalizeDiagnosticPath(projectRoot string, path string) string { + normalized := filepath.ToSlash(filepath.Clean(projectPath(projectRoot, path))) + if runtime.GOOS == "windows" { + return strings.ToLower(normalized) + } + + return normalized +} + +// RecordBaseline records what the next changed-files check of a unit compares against, after a +// successful full compile. nonSourceKey is the unit's non-source key computed before the compile. +// Why every content hash must match the one the input key read, and the non-source key the one +// computed before the compile: the manifest describes the inputs csc compiled, and an input changed +// during the compile would otherwise enter the baseline as if that compile had seen it, and its +// effect would never be checked by the next run. +func (runner *fastPathRunner) RecordBaseline( + ctx context.Context, c Compiler, plan BuildPlan, unit CompileUnit, keyResult inputKeyResult, nonSourceKey string, +) error { + if nonSourceKey == "" { + return errors.New("inputs other than source contents could not be read before the compile") + } + currentNonSourceKey, err := computeNonSourceKey(c.ProjectRoot, c.Paths, plan, unit) + if err != nil { + return err + } + if currentNonSourceKey != nonSourceKey { + return errors.New("inputs other than source contents changed during the compile") + } + + worker, err := runner.Worker.Ensure(ctx) + if err != nil { + return err + } + + request := fastcheck.FingerprintRequest{ + ProjectRoot: c.ProjectRoot, + ResponseFilePath: filepath.Join(c.ProjectRoot, plan.OutputDir, unit.Assembly.AssemblyName+responseFileExtension), + Sources: make([]string, 0, len(unit.Sources)), + } + for _, source := range unit.Sources { + request.Sources = append(request.Sources, projectPath(c.ProjectRoot, source)) + } + + fingerprints, err := runner.Worker.Fingerprint(ctx, worker, request) + if err != nil { + return err + } + + files, err := baselineFiles(c.ProjectRoot, unit, keyResult, fingerprints) + if err != nil { + return err + } + + generatedFiles, err := hashGeneratedFiles(filepath.Join(c.ProjectRoot, generatedDirectory(plan, unit))) + if err != nil { + return err + } + + return writeBaselineRecord(filepath.Join(c.ProjectRoot, plan.OutputDir), unit, baselineRecord{ + FormatVersion: baselineRecordFormatVersion, + InputKey: keyResult.Key, + NonSourceKey: nonSourceKey, + WorkerKey: worker.Key, + GeneratedDirectory: filepath.ToSlash(generatedDirectory(plan, unit)), + GeneratedFiles: generatedFiles, + Files: files, + }) +} + +// hashGeneratedFiles hashes every generated C# file under directoryPath, keyed by its slash-separated +// path relative to it. A missing directory holds no files. +func hashGeneratedFiles(directoryPath string) (map[string]string, error) { + hashes := map[string]string{} + err := filepath.WalkDir(directoryPath, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + if os.IsNotExist(walkErr) && path == directoryPath { + return filepath.SkipDir + } + + return walkErr + } + if entry.IsDir() || filepath.Ext(path) != ".cs" { + return nil + } + relative, err := filepath.Rel(directoryPath, path) + if err != nil { + return err + } + hash, err := hashFile(path) + if err != nil { + return err + } + hashes[filepath.ToSlash(relative)] = hash + + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to read the generator output: %w", err) + } + + return hashes, nil +} + +// generatedFilesMatch reports whether the generator output on disk is exactly what the baseline +// recorded: no file missing, added, or changed. +// Why: the worker reads this output instead of running the generators, and checking without it +// reports errors wherever code uses what the generators produce. +func generatedFilesMatch(directoryPath string, recorded map[string]string) bool { + current, err := hashGeneratedFiles(directoryPath) + if err != nil || len(current) != len(recorded) { + return false + } + for name, hash := range recorded { + if current[name] != hash { + return false + } + } + + return true +} + +// baselineFiles pairs each source's content hash, as the input key read it, with its fingerprint. +func baselineFiles( + projectRoot string, unit CompileUnit, keyResult inputKeyResult, fingerprints fastcheck.FingerprintResponse, +) (map[string]baselineFile, error) { + files := make(map[string]baselineFile, len(unit.Sources)) + for _, source := range unit.Sources { + absolute := projectPath(projectRoot, source) + fingerprint, hasFingerprint := fingerprints.Fingerprints[absolute] + contentHash, hasHash := fingerprints.ContentHashes[absolute] + if !hasFingerprint || !hasHash { + return nil, fmt.Errorf("worker returned no fingerprint for %s", source) + } + if contentHash != keyResult.SourceHashes[source] { + return nil, fmt.Errorf("source changed during the compile: %s", source) + } + files[source] = baselineFile{ContentHash: contentHash, Fingerprint: fingerprint} + } + + return files, nil +} + +// writeBaselineRecord writes a baseline through a temporary file, like the manifest. +func writeBaselineRecord(outputDirectoryPath string, unit CompileUnit, record baselineRecord) error { + content, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("failed to record the baseline of %s: %w", unit.Assembly.AssemblyName, err) + } + + return writeFileAtomically(outputDirectoryPath, baselineRecordPath(outputDirectoryPath, unit), content) +} + +// baselineRecordPath names where one unit's baseline lives. +func baselineRecordPath(outputDirectoryPath string, unit CompileUnit) string { + return filepath.Join(outputDirectoryPath, unit.Assembly.AssemblyName+baselineRecordExtension) +} + +// generatedDirectory names, relative to the project root, where a full compile saves the generator +// output of a unit. +func generatedDirectory(plan BuildPlan, unit CompileUnit) string { + return filepath.Join(plan.OutputDir, unit.Assembly.AssemblyName+generatedDirectoryExtension) +} + +// bootstrappedWorker builds the worker on first use and calls it through fastcheck.Client. +// Why the build is shared: units compile in parallel, and each building its own copy of the worker +// on the first run would start one csc per unit for the same output. +type bootstrappedWorker struct { + bootstrap *fastcheck.Bootstrap + tempDirectory string + run fastcheck.CommandRunner + ensure func(ctx context.Context) (fastcheck.WorkerPaths, error) +} + +// newBootstrappedWorker prepares a worker that the Editor's toolchain builds into cacheRoot. +func newBootstrappedWorker(paths EditorCompilerPaths, cacheRoot string, tempDirectory string, run fastcheck.CommandRunner) *bootstrappedWorker { + bootstrap := &fastcheck.Bootstrap{ + CompilerPaths: fastcheck.ToolchainPaths{ + DotnetHostPath: paths.DotnetHostPath, + CompilerDllPath: paths.CompilerDllPath, + CompilerDirectory: paths.CompilerDirectory, + SharedFrameworkPath: paths.SharedFrameworkPath, + }, + CacheRoot: cacheRoot, + Run: run, + } + + return &bootstrappedWorker{ + bootstrap: bootstrap, + tempDirectory: tempDirectory, + run: run, + ensure: onceWithContext(bootstrap.Ensure), + } +} + +func (w *bootstrappedWorker) Ensure(ctx context.Context) (fastcheck.WorkerPaths, error) { + return w.ensure(ctx) +} + +func (w *bootstrappedWorker) Fingerprint( + ctx context.Context, worker fastcheck.WorkerPaths, request fastcheck.FingerprintRequest, +) (fastcheck.FingerprintResponse, error) { + return w.client(worker).Fingerprint(ctx, request) +} + +func (w *bootstrappedWorker) Check( + ctx context.Context, worker fastcheck.WorkerPaths, request fastcheck.CheckRequest, +) (fastcheck.CheckResponse, error) { + return w.client(worker).Check(ctx, request) +} + +func (w *bootstrappedWorker) client(worker fastcheck.WorkerPaths) fastcheck.Client { + return fastcheck.Client{Worker: worker, TempDirectory: w.tempDirectory, Run: w.run} +} + +// onceWithContext runs ensure once and hands its answer to every later caller. +// Why a cancelled call is not remembered: it says nothing about whether the worker builds, and the +// run is ending anyway. +func onceWithContext( + ensure func(ctx context.Context) (fastcheck.WorkerPaths, error), +) func(ctx context.Context) (fastcheck.WorkerPaths, error) { + lock := make(chan struct{}, 1) + done := false + var paths fastcheck.WorkerPaths + var result error + + return func(ctx context.Context) (fastcheck.WorkerPaths, error) { + select { + case lock <- struct{}{}: + case <-ctx.Done(): + return fastcheck.WorkerPaths{}, ctx.Err() + } + defer func() { <-lock }() + + if done { + return paths, result + } + + paths, result = ensure(ctx) + if result == nil || !errors.Is(result, context.Canceled) && !errors.Is(result, context.DeadlineExceeded) { + done = true + } + + return paths, result + } +} diff --git a/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go new file mode 100644 index 0000000000..a00148c27d --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go @@ -0,0 +1,316 @@ +package compilecheck + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" +) + +// fakeDeclarationSeparator splits a fixture source into its declaration part and its body part, so +// the fake worker can tell a body edit from a declaration edit the way the real fingerprint does. +const fakeDeclarationSeparator = "|" + +// fakeFastCheckWorker stands in for the changed-files check worker. +type fakeFastCheckWorker struct { + mutex sync.Mutex + key string + ensureErr error + checkErr error + outcome string + reason string + diagnostics []fastcheck.WorkerDiagnostic + uncheckedCodes []string + onCheck func() + ensures int + fingerprints int + checks int + lastCheck fastcheck.CheckRequest +} + +func newFakeFastCheckWorker() *fakeFastCheckWorker { + return &fakeFastCheckWorker{key: "worker-a", outcome: fastcheck.CheckedOutcome} +} + +func (worker *fakeFastCheckWorker) Ensure(_ context.Context) (fastcheck.WorkerPaths, error) { + worker.mutex.Lock() + defer worker.mutex.Unlock() + worker.ensures++ + + return fastcheck.WorkerPaths{Key: worker.key}, worker.ensureErr +} + +func (worker *fakeFastCheckWorker) Fingerprint( + _ context.Context, _ fastcheck.WorkerPaths, request fastcheck.FingerprintRequest, +) (fastcheck.FingerprintResponse, error) { + worker.mutex.Lock() + worker.fingerprints++ + worker.mutex.Unlock() + + response := fastcheck.FingerprintResponse{Fingerprints: map[string]string{}, ContentHashes: map[string]string{}} + for _, source := range request.Sources { + content, err := os.ReadFile(source) + if err != nil { + return fastcheck.FingerprintResponse{}, err + } + sum := sha256.Sum256(content) + response.Fingerprints[source] = fakeDeclarationOf(string(content)) + response.ContentHashes[source] = hex.EncodeToString(sum[:]) + } + + return response, nil +} + +func (worker *fakeFastCheckWorker) Check( + _ context.Context, _ fastcheck.WorkerPaths, request fastcheck.CheckRequest, +) (fastcheck.CheckResponse, error) { + worker.mutex.Lock() + worker.checks++ + worker.lastCheck = request + onCheck := worker.onCheck + worker.mutex.Unlock() + if onCheck != nil { + onCheck() + } + if worker.checkErr != nil { + return fastcheck.CheckResponse{}, worker.checkErr + } + + for _, changed := range request.ChangedFiles { + content, err := os.ReadFile(changed) + if err != nil { + return fastcheck.CheckResponse{}, err + } + if fakeDeclarationOf(string(content)) != request.BaselineFingerprints[changed] { + return fastcheck.CheckResponse{Outcome: fastcheck.DeclarationChangedOutcome, Reason: changed}, nil + } + } + + return fastcheck.CheckResponse{ + Outcome: worker.outcome, + Reason: worker.reason, + Diagnostics: worker.diagnostics, + UncheckedCodes: worker.uncheckedCodes, + }, nil +} + +// fakeDeclarationOf is the fake fingerprint: everything before the separator. +func fakeDeclarationOf(content string) string { + declaration, _, _ := strings.Cut(content, fakeDeclarationSeparator) + + return declaration +} + +// fastPathProject is a project whose Alpha assembly is large enough for the changed-files check, +// with Beta referencing it. +type fastPathProject struct { + *cachedSchedulerProject + worker *fakeFastCheckWorker +} + +// newFastPathProject lays out Alpha with sourceCount sources and Beta referencing Alpha. +func newFastPathProject(t *testing.T, sourceCount int) *fastPathProject { + t.Helper() + project := &fastPathProject{ + cachedSchedulerProject: newCachedSchedulerProject(t, map[string][]string{"Beta": {"Alpha"}}, "Alpha", "Beta"), + worker: newFakeFastCheckWorker(), + } + + sources := make([]string, 0, sourceCount) + for index := 0; index < sourceCount; index++ { + source := project.sourceName(index) + project.writeFile(t, source, fmt.Sprintf("class File%03d %s body", index, fakeDeclarationSeparator)) + sources = append(sources, source) + } + project.plan.Units[0].Sources = sources + + return project +} + +// sourceName names Alpha's source at index, spelled as a response file spells it. +func (project *fastPathProject) sourceName(index int) string { + return fmt.Sprintf("Assets/Alpha/File%03d.cs", index) +} + +// editBody changes the body part of Alpha's source at index, keeping its declaration. +func (project *fastPathProject) editBody(t *testing.T, index int, body string) { + t.Helper() + project.writeFile(t, project.sourceName(index), fmt.Sprintf("class File%03d %s %s", index, fakeDeclarationSeparator, body)) +} + +// editDeclaration changes the declaration part of Alpha's source at index. +func (project *fastPathProject) editDeclaration(t *testing.T, index int) { + t.Helper() + project.writeFile(t, project.sourceName(index), fmt.Sprintf("class File%03d : Base %s body", index, fakeDeclarationSeparator)) +} + +// absoluteSource names Alpha's source at index as the worker sees it. +func (project *fastPathProject) absoluteSource(index int) string { + return filepath.Join(project.root, project.sourceName(index)) +} + +// runner builds the fast path runner the compile uses. +func (project *fastPathProject) runner(tryDisabled bool) *fastPathRunner { + return &fastPathRunner{TryDisabled: tryDisabled, Worker: project.worker} +} + +// run compiles the plan with the fast path, failing the test when the run fails. +func (project *fastPathProject) run(t *testing.T, recorder *schedulerRecorder, tryDisabled bool) compileOutcome { + t.Helper() + outcome, err := project.runWithContext(context.Background(), recorder, tryDisabled) + if err != nil { + t.Fatalf("expected the run to succeed, got error: %v", err) + } + + return outcome +} + +// runWithContext compiles the plan with the fast path under ctx. +func (project *fastPathProject) runWithContext( + ctx context.Context, recorder *schedulerRecorder, tryDisabled bool, +) (compileOutcome, error) { + compiler := Compiler{ + Paths: project.paths, + ProjectRoot: project.root, + Timeout: time.Minute, + Run: recorder.runner(), + FastPath: project.runner(tryDisabled), + } + + return compileUnits(ctx, compiler, project.plan, 4) +} + +// compileAlphaAsDependent runs Alpha alone through reuseOrCompile, marked as picked only as a +// dependent. Why not through the scheduler: it skips a dependent whose references kept their surface, +// and Alpha references nothing. +func (project *fastPathProject) compileAlphaAsDependent(t *testing.T) UnitResult { + t.Helper() + makeOutputDirectory(t, project.root, project.plan) + unit := project.plan.Units[0] + unit.SelectedAsDependent = true + compiler := Compiler{ + Paths: project.paths, + ProjectRoot: project.root, + Timeout: time.Minute, + Run: newCachedSchedulerRecorder("Alpha").runner(), + FastPath: project.runner(false), + } + result, err := compiler.reuseOrCompile(context.Background(), project.plan, unit) + if err != nil { + t.Fatalf("expected Alpha to compile, got error: %v", err) + } + + return result +} + +// recordBaseline runs the plan once so Alpha has a manifest and a baseline, and checks it does. +func (project *fastPathProject) recordBaseline(t *testing.T) { + t.Helper() + project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + if !project.hasBaseline() { + t.Fatal("expected the first full compile to record a baseline") + } +} + +// baselinePath names Alpha's baseline record. +func (project *fastPathProject) baselinePath() string { + return filepath.Join(project.outputDirectory(), "Alpha"+baselineRecordExtension) +} + +// hasBaseline reports whether Alpha has a baseline record. +func (project *fastPathProject) hasBaseline() bool { + _, err := os.Stat(project.baselinePath()) + + return err == nil +} + +// readBaseline reads Alpha's baseline record. +func (project *fastPathProject) readBaseline(t *testing.T) baselineRecord { + t.Helper() + record := baselineRecord{} + project.readJSON(t, project.baselinePath(), &record) + + return record +} + +// readManifest reads Alpha's result manifest. +func (project *fastPathProject) readManifest(t *testing.T) unitResultManifest { + t.Helper() + manifest := unitResultManifest{} + project.readJSON(t, project.manifestPathOf("Alpha"), &manifest) + + return manifest +} + +func (project *fastPathProject) readJSON(t *testing.T, path string, value any) { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + if err := json.Unmarshal(content, value); err != nil { + t.Fatalf("failed to parse %s: %v", path, err) + } +} + +func (project *fastPathProject) writeJSON(t *testing.T, path string, value any) { + t.Helper() + content, err := json.Marshal(value) + if err != nil { + t.Fatalf("failed to encode %s: %v", path, err) + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("failed to write %s: %v", path, err) + } +} + +// fileHashes hashes Alpha's outputs, manifest, and baseline, to show a run left them alone. +func (project *fastPathProject) fileHashes(t *testing.T) map[string]string { + t.Helper() + hashes := map[string]string{} + for _, name := range []string{ + "Alpha" + assemblyExtension, "Alpha" + referenceAssemblyExtension, + "Alpha" + resultManifestExtension, "Alpha" + baselineRecordExtension, + } { + hash, err := hashFile(filepath.Join(project.outputDirectory(), name)) + if err != nil { + t.Fatalf("failed to hash %s: %v", name, err) + } + hashes[name] = hash + } + + return hashes +} + +// assertFallback checks that Alpha was compiled in full for a reason starting with prefix. +func assertFallback(t *testing.T, outcome compileOutcome, recorder *schedulerRecorder, prefix string) { + t.Helper() + result := resultOf(t, outcome.Units, "Alpha") + if result.FastChecked || result.Reused { + t.Fatalf("expected Alpha to be compiled in full, got %+v", result) + } + if recorder.runs["Alpha"] != 1 { + t.Errorf("expected csc to compile Alpha once, got %d", recorder.runs["Alpha"]) + } + if result.FastPathFallbackReason == "" || !strings.HasPrefix(result.FastPathFallbackReason, prefix) { + t.Errorf("fallback reason = %q, want a reason starting with %q", result.FastPathFallbackReason, prefix) + } +} + +// assertNotTried checks that Alpha was compiled in full without trying the changed-files check. +func assertNotTried(t *testing.T, outcome compileOutcome, worker *fakeFastCheckWorker) { + t.Helper() + result := resultOf(t, outcome.Units, "Alpha") + if result.FastChecked || result.FastPathFallbackReason != "" || worker.checks != 0 { + t.Errorf("expected the changed-files check not to be tried, got %+v after %d checks", result, worker.checks) + } +} diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go new file mode 100644 index 0000000000..a19f4f10ac --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -0,0 +1,487 @@ +package compilecheck + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" +) + +// Verifies that unchanged inputs are reused before the changed-files check is tried. +func TestReuseStillWinsOverFastPath(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + outcome := project.run(t, recorder, false) + + if result := resultOf(t, outcome.Units, "Alpha"); !result.Reused || result.FastPathFallbackReason != "" { + t.Errorf("expected Alpha to be reused without a fallback reason, got %+v", result) + } + if project.worker.checks != 0 { + t.Errorf("expected no check, got %d", project.worker.checks) + } +} + +// Verifies that --all compiles in full without trying the changed-files check. +func TestAllFlagSkipsFastPath(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + project.plan.All = true + + outcome := project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + + assertNotTried(t, outcome, project.worker) +} + +// Verifies that --full compiles in full without trying the changed-files check, and still records +// a baseline for the compile it ran. +func TestFullFlagSkipsFastPathButRecords(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + + outcome := project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), true) + + assertNotTried(t, outcome, project.worker) + if record, manifest := project.readBaseline(t), project.readManifest(t); record.InputKey != manifest.InputKey { + t.Errorf("expected the baseline to describe the --full compile, got %s and %s", record.InputKey, manifest.InputKey) + } +} + +// Verifies that a unit picked only as a dependent is neither checked by the changed-files check nor +// given a baseline. +func TestDependentUnitSkipsFastPath(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + + result := project.compileAlphaAsDependent(t) + + if result.FastChecked || result.FastPathFallbackReason != "" || project.worker.checks != 0 { + t.Errorf("expected the changed-files check not to be tried, got %+v", result) + } + if project.hasBaseline() { + t.Error("expected the full compile of a dependent to leave no baseline") + } +} + +// Verifies that a unit without an input key compiles in full without trying the changed-files check. +func TestUnkeyedUnitSkipsFastPath(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + if err := os.Remove(project.plan.Units[0].Assembly.Path); err != nil { + t.Fatalf("failed to remove the response file: %v", err) + } + + outcome := project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + + assertNotTried(t, outcome, project.worker) +} + +// Verifies that a unit without a baseline compiles in full, says why, and records a baseline. +func TestMissingBaselineFallsBackAndRecords(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "no baseline from an earlier full compile") + record := project.readBaseline(t) + if record.WorkerKey != "worker-a" || len(record.Files) != fastPathMinimumSources { + t.Errorf("expected a baseline of every source from worker-a, got key %s with %d files", record.WorkerKey, len(record.Files)) + } + if file := record.Files[project.sourceName(0)]; file.Fingerprint != "class File000 " { + t.Errorf("expected the worker's fingerprint in the baseline, got %+v", file) + } +} + +// Verifies that a baseline whose full compile outputs were changed by hand is not used. +func TestHandCorruptedStaleManifestFallsBack(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + writeOutputFile(t, project.outputDirectory(), "Alpha"+assemblyExtension, "changed by hand") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "baseline compile is no longer on disk") +} + +// Verifies that a manifest changed by hand to a failed compile stops the baseline from being used. +func TestHandCorruptedFailedManifestFallsBack(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + manifest := project.readManifest(t) + manifest.Succeeded = false + project.writeJSON(t, project.manifestPathOf("Alpha"), manifest) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "baseline compile is no longer on disk") +} + +// Verifies that a failed full compile leaves no baseline, so the next body edit compiles in full. +func TestFailedFullCompileLeavesNoBaseline(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editDeclaration(t, 0) + failing := newCachedSchedulerRecorder("Alpha", "Beta") + failing.output["Alpha"] = failingCompilerOutput("Alpha") + project.run(t, failing, false) + if project.hasBaseline() { + t.Fatal("expected the failed full compile to leave no baseline") + } + + project.editBody(t, 1, "edited") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "no baseline from an earlier full compile") +} + +// Verifies that a change to an input other than source contents, such as a new source, compiles in full. +func TestNonSourceKeyMismatchFallsBack(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + added := "Assets/Alpha/Added.cs" + project.writeFile(t, added, "class Added | body") + project.plan.Units[0].Sources = append(project.plan.Units[0].Sources, added) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "inputs other than source contents changed") +} + +// Verifies that an assembly with fewer sources than the minimum is never tried, so it compiles in +// full without a fallback reason on every run. +func TestSmallAssemblyIsNotTried(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources-1) + project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + project.editBody(t, 0, "edited") + + outcome := project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + + assertNotTried(t, outcome, project.worker) + if project.worker.ensures != 0 || project.hasBaseline() { + t.Errorf("expected no worker and no baseline, got %d builds", project.worker.ensures) + } +} + +// Verifies that an input other than source contents that changed while csc compiled leaves no +// baseline, and says why, so the next body edit compiles in full. +func TestNonSourceInputChangedDuringCompileLeavesNoBaseline(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + recorder.during = func(name string) { + if name == "Alpha" { + touchProjectFile(t, project.root, filepath.Join("Editor", "csc.dll")) + } + } + + outcome := project.run(t, recorder, false) + + if note := resultOf(t, outcome.Units, "Alpha").FastPathNote; note != "baseline not recorded: inputs other than source contents changed during the compile" { + t.Errorf("unexpected note %q", note) + } + if project.hasBaseline() { + t.Fatal("expected no baseline when an input changed during the compile") + } + + project.editBody(t, 0, "edited") + second := newCachedSchedulerRecorder("Alpha", "Beta") + assertFallback(t, project.run(t, second, false), second, "no baseline from an earlier full compile") +} + +// Verifies that a baseline whose saved generator output was deleted or changed is not used, since +// checking without that output reports errors wherever code uses what the generators produce. +func TestMissingOrChangedGeneratorOutputFallsBack(t *testing.T) { + for name, damage := range map[string]func(t *testing.T, generatedPath string){ + "deleted": func(t *testing.T, generatedPath string) { + if err := os.RemoveAll(generatedPath); err != nil { + t.Fatalf("failed to remove the generator output: %v", err) + } + }, + "changed": func(t *testing.T, generatedPath string) { + writeOutputFile(t, filepath.Join(generatedPath, "Gen"), "G.g.cs", "class G { int x; }") + }, + } { + t.Run(name, func(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + generatedPath := filepath.Join(project.root, generatedDirectory(project.plan, project.plan.Units[0])) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + recorder.during = func(unit string) { + if unit == "Alpha" { + project.writeFile(t, filepath.Join(generatedDirectory(project.plan, project.plan.Units[0]), "Gen", "G.g.cs"), "class G { }") + } + } + project.run(t, recorder, false) + if record := project.readBaseline(t); len(record.GeneratedFiles) != 1 { + t.Fatalf("expected the generated file in the baseline, got %v", record.GeneratedFiles) + } + damage(t, generatedPath) + project.editBody(t, 0, "edited") + second := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, second, false) + + assertFallback(t, outcome, second, "generator output of the baseline is gone") + }) + } +} + +// Verifies that more changed files than the maximum compile in full. +func TestTooManyChangedFilesFallsBack(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + for index := 0; index <= fastPathMaximumChangedFiles; index++ { + project.editBody(t, index, "edited") + } + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "too many changed files") +} + +// Verifies that a declaration change compiles in full and records a baseline with the new fingerprint. +func TestDeclarationChangeFallsBackAndRerecords(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editDeclaration(t, 0) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, fastcheck.DeclarationChangedOutcome+": "+project.sourceName(0)) + if file := project.readBaseline(t).Files[project.sourceName(0)]; file.Fingerprint != "class File000 : Base " { + t.Errorf("expected the new declaration in the baseline, got %+v", file) + } +} + +// Verifies that a worker that cannot be built makes the unit compile in full. +func TestWorkerBuildFailureFallsBack(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + project.worker.ensureErr = errors.New("csc failed") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "worker unavailable: csc failed") +} + +// Verifies that a worker that fails while checking makes the unit compile in full. +func TestWorkerRunFailureFallsBack(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + project.worker.checkErr = errors.New("timed out") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "worker failed: timed out") +} + +// Verifies that an Unsupported outcome makes the unit compile in full with the worker's reason. +func TestUnsupportedOutcomeFallsBack(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + project.worker.outcome = fastcheck.UnsupportedOutcome + project.worker.reason = "analyzerconfig" + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "Unsupported: analyzerconfig") +} + +// Verifies that a baseline fingerprinted by another worker build is not used. +func TestWorkerKeyMismatchFallsBack(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + project.worker.key = "worker-b" + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "baseline was fingerprinted by another worker build") + if record := project.readBaseline(t); record.WorkerKey != "worker-b" { + t.Errorf("expected the baseline to be recorded again by worker-b, got %s", record.WorkerKey) + } +} + +// Verifies the clean changed-files check: Alpha is not compiled, its outputs, manifest, and baseline +// stay as they were, the diagnostics of unchanged files are carried over, and a dependent whose +// reference kept its surface is still skipped. +func TestFastPathCleanResultKeepsBaselineFiles(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + markSelectedAsDependent(t, project.plan, "Beta") + first := newCachedSchedulerRecorder("Alpha", "Beta") + first.surfaces["Alpha"] = "unity Alpha" + first.output["Alpha"] = fakeCompilerOutput{stdout: "Assets/Alpha/File001.cs(1,1): warning CS0168: unused"} + project.run(t, first, false) + before := project.fileHashes(t) + project.editBody(t, 0, "edited") + project.worker.uncheckedCodes = []string{"CS0169"} + + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + outcome := project.run(t, recorder, false) + + result := resultOf(t, outcome.Units, "Alpha") + if !result.FastChecked || !result.Succeeded || recorder.runs["Alpha"] != 0 { + t.Fatalf("expected a clean changed-files check without csc, got %+v after %d runs", result, recorder.runs["Alpha"]) + } + assertStrings(t, "changed files", result.FastCheckChangedFiles, []string{project.sourceName(0)}) + assertStrings(t, "unchecked codes", result.UncheckedCodes, []string{"CS0169"}) + if len(result.Diagnostics) != 1 || result.Diagnostics[0].File != "Assets/Alpha/File001.cs" { + t.Errorf("expected the warning of the unchanged file to be carried over, got %+v", result.Diagnostics) + } + if after := project.fileHashes(t); !reflect.DeepEqual(after, before) { + t.Errorf("expected the outputs, manifest, and baseline to stay, got %v then %v", before, after) + } + if recorder.runs["Beta"] != 0 || outcome.Skipped != 1 { + t.Errorf("expected Beta to be skipped, got %d runs and %d skipped", recorder.runs["Beta"], outcome.Skipped) + } + if project.worker.lastCheck.GeneratedDirectory != filepath.Join(project.root, generatedDirectory(project.plan, project.plan.Units[0])) { + t.Errorf("expected the saved generator output to be passed, got %q", project.worker.lastCheck.GeneratedDirectory) + } +} + +// Verifies that an error found by the changed-files check fails the unit and blocks its dependents. +func TestFastPathErrorBlocksDependents(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + markSelectedAsDependent(t, project.plan, "Beta") + project.recordBaseline(t) + project.editBody(t, 0, "int x = \"s\";") + project.worker.diagnostics = []fastcheck.WorkerDiagnostic{{ + File: project.absoluteSource(0), Line: 1, Column: 20, Severity: severityError, Code: "CS0029", Message: "cannot convert", + }} + + outcome := project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + + result := resultOf(t, outcome.Units, "Alpha") + if !result.FastChecked || result.Succeeded { + t.Fatalf("expected a failed changed-files check, got %+v", result) + } + assertStrings(t, "blocked", outcome.Blocked, []string{"Beta"}) +} + +// Verifies that the last full compile's diagnostics of a changed file are replaced by what the check +// found, and that a diagnostic both report for an unchanged file appears once. +func TestChangedFileBaselineDiagnosticsAreReplaced(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + first := newCachedSchedulerRecorder("Alpha", "Beta") + first.output["Alpha"] = fakeCompilerOutput{stdout: strings.Join([]string{ + "Assets/Alpha/File000.cs(1,1): warning CS0168: old warning", + "Assets/Alpha/File001.cs(2,2): warning CS0168: kept warning", + }, "\n")} + project.run(t, first, false) + project.editBody(t, 0, "edited") + project.worker.diagnostics = []fastcheck.WorkerDiagnostic{ + {File: project.absoluteSource(0), Line: 3, Column: 3, Severity: severityWarning, Code: "CS0219", Message: "new warning"}, + {File: project.absoluteSource(1), Line: 2, Column: 2, Severity: severityWarning, Code: "CS0168", Message: "kept warning"}, + } + + outcome := project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + + want := []Diagnostic{ + {Severity: severityWarning, Code: "CS0168", Message: "kept warning", File: "Assets/Alpha/File001.cs", Line: 2, Column: 2}, + {Severity: severityWarning, Code: "CS0219", Message: "new warning", File: "Assets/Alpha/File000.cs", Line: 3, Column: 3}, + } + if got := resultOf(t, outcome.Units, "Alpha").Diagnostics; !reflect.DeepEqual(got, want) { + t.Errorf("diagnostics = %+v, want %+v", got, want) + } +} + +// Verifies that putting an edited file back the way it was is answered by reuse. +func TestRevertedContentIsReused(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + project.editBody(t, 0, "body") + + outcome := project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + + if result := resultOf(t, outcome.Units, "Alpha"); !result.Reused { + t.Errorf("expected the reverted assembly to be reused, got %+v", result) + } +} + +// Verifies that a baseline agreeing with every source but disagreeing with the current key is +// dropped, and the unit compiles in full. +func TestZeroChangedFilesDropsBaselineAndFallsBack(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + record := project.readBaseline(t) + manifest := project.readManifest(t) + record.InputKey = "tampered" + manifest.InputKey = "tampered" + project.writeJSON(t, project.baselinePath(), record) + project.writeJSON(t, project.manifestPathOf("Alpha"), manifest) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "baseline record does not match its compile result") + if project.readBaseline(t).InputKey == "tampered" { + t.Error("expected the disagreeing baseline to be replaced") + } +} + +// Verifies that cancelling the run during the changed-files check stops the run and leaves the +// outputs, manifest, and baseline as they were. +func TestCancelledFastPathLeavesBaselineUntouched(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + before := project.fileHashes(t) + project.editBody(t, 0, "edited") + ctx, cancel := context.WithCancel(context.Background()) + project.worker.onCheck = cancel + project.worker.checkErr = context.Canceled + + _, err := project.runWithContext(ctx, newCachedSchedulerRecorder("Alpha", "Beta"), false) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected the run to stop with a cancellation, got %v", err) + } + if after := project.fileHashes(t); !reflect.DeepEqual(after, before) { + t.Errorf("expected the outputs, manifest, and baseline to stay, got %v then %v", before, after) + } +} + +// Verifies that a reason the worker gives with absolute paths reaches the output with the paths +// spelled relative to the project root, so no local path leaks into it. +func TestFallbackReasonHasNoAbsolutePath(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + project.worker.outcome = fastcheck.UnsupportedOutcome + project.worker.reason = "changed file is not a source: " + project.absoluteSource(0) + " near " + + filepath.Join(project.root, "Library", "Other.cs") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + reason := resultOf(t, outcome.Units, "Alpha").FastPathFallbackReason + if strings.Contains(reason, project.root) { + t.Errorf("expected no absolute path in the reason, got %q", reason) + } + assertFallback(t, outcome, recorder, "Unsupported: changed file is not a source: "+project.sourceName(0)+" near Library") +} diff --git a/cli/dispatcher/internal/compilecheck/input_key.go b/cli/dispatcher/internal/compilecheck/input_key.go index 60fb1b9c30..ee3aad834d 100644 --- a/cli/dispatcher/internal/compilecheck/input_key.go +++ b/cli/dispatcher/internal/compilecheck/input_key.go @@ -26,19 +26,59 @@ var untrackedFileInputFlagNames = []string{ "embed", "link", "addmodule", "appconfig", "sourcelink", } +// inputKeyResult is an input key together with the content hash of each source it was computed from. +type inputKeyResult struct { + Key string + // SourceHashes maps each source, spelled as unit.Sources spells it, to the sha256 of the bytes + // the key read from it. + SourceHashes map[string]string +} + // computeInputKey digests everything that decides what csc reports for one assembly, so that two // runs agreeing on this key would get the same diagnostics out of the compiler. // Why an error rather than a key when an input cannot be read: a key built from what happens to be // readable would match a later run that reads the same subset, and the compile it stands for never // ran. The caller compiles such a unit instead of reusing anything. +// Why the source hashes come back with the key: a baseline recorded for the changed-files check +// must describe the very bytes this key, and so the compile it stands for, saw. Reading the sources +// again after the compile would let an edit made during it into the baseline unchecked. func computeInputKey( projectRoot string, paths EditorCompilerPaths, plan BuildPlan, unit CompileUnit, +) (inputKeyResult, error) { + sourceHashes := map[string]string{} + key, err := computeKeyWith(projectRoot, paths, plan, unit, func(digest hash.Hash) error { + return writeSourceIdentity(digest, projectRoot, unit.Sources, sourceHashes) + }) + if err != nil { + return inputKeyResult{}, err + } + + return inputKeyResult{Key: key, SourceHashes: sourceHashes}, nil +} + +// computeNonSourceKey digests the same inputs as the input key except the source contents, which it +// replaces with the list of source paths. Two runs agreeing on it differ at most in what the +// sources hold; adding or removing a source changes it. +func computeNonSourceKey( + projectRoot string, paths EditorCompilerPaths, plan BuildPlan, unit CompileUnit, +) (string, error) { + return computeKeyWith(projectRoot, paths, plan, unit, func(digest hash.Hash) error { + writeSourceNames(digest, unit.Sources) + + return nil + }) +} + +// computeKeyWith digests every input of one unit, letting the caller decide how the sources go in. +func computeKeyWith( + projectRoot string, paths EditorCompilerPaths, plan BuildPlan, unit CompileUnit, + sourceWriter func(hash.Hash) error, ) (string, error) { digest := sha256.New() writers := []func() error{ func() error { return writeCompilerIdentity(digest, paths) }, func() error { return writeResponseFileIdentity(digest, unit.Assembly) }, - func() error { return writeSourceIdentity(digest, projectRoot, unit.Sources) }, + func() error { return sourceWriter(digest) }, func() error { return writeReferenceIdentity(digest, projectRoot, plan, unit.Assembly.References) }, func() error { return writeAnalyzerIdentity(digest, projectRoot, unit.Assembly.Analyzers) }, func() error { return writeAnalyzerInputIdentity(digest, projectRoot, unit.Assembly) }, @@ -87,22 +127,38 @@ func writeResponseFileIdentity(digest hash.Hash, rsp ResponseFile) error { return nil } -// writeSourceIdentity records what the assembly compiles, by content and in a fixed order. +// writeSourceIdentity records what the assembly compiles, by content and in a fixed order, and +// fills hashes with the content hash of each source it read. // Why the content rather than the modification time: a checkout that puts a file back the way it // was leaves it newer than the build, and recompiling it would report the diagnostics it already // reported. The whole source set of the largest assembly hashes in tens of milliseconds. -func writeSourceIdentity(digest hash.Hash, projectRoot string, sources []string) error { +func writeSourceIdentity( + digest hash.Hash, projectRoot string, sources []string, hashes map[string]string, +) error { ordered := append([]string{}, sources...) sort.Strings(ordered) for _, source := range ordered { - if err := writeFileContentEntry(digest, "source", source, projectPath(projectRoot, source)); err != nil { - return err + path := projectPath(projectRoot, source) + contentHash, err := hashFile(path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) } + hashes[source] = contentHash + _, _ = fmt.Fprintf(digest, "source\x00%s\x00%s\n", filepath.ToSlash(source), contentHash) } return nil } +// writeSourceNames records which sources the assembly compiles, without what they hold. +func writeSourceNames(digest hash.Hash, sources []string) { + ordered := append([]string{}, sources...) + sort.Strings(ordered) + for _, source := range ordered { + _, _ = fmt.Fprintf(digest, "source-name\x00%s\n", filepath.ToSlash(source)) + } +} + // writeReferenceIdentity records the assemblies this one compiles against, resolved the way the // compile itself resolves them. // Why the two rules: a reference this run rebuilds is read by content, because recompiling an diff --git a/cli/dispatcher/internal/compilecheck/input_key_test.go b/cli/dispatcher/internal/compilecheck/input_key_test.go index ca82ee0280..d17ccf9a0a 100644 --- a/cli/dispatcher/internal/compilecheck/input_key_test.go +++ b/cli/dispatcher/internal/compilecheck/input_key_test.go @@ -1,8 +1,11 @@ package compilecheck import ( + "crypto/sha256" + "encoding/hex" "os" "path/filepath" + "reflect" "testing" "time" ) @@ -71,7 +74,8 @@ func newInputKeyProject(t *testing.T) *inputKeyProject { // key computes the key of the unit under test. func (project *inputKeyProject) key(t *testing.T) string { t.Helper() - key, err := computeInputKey(project.root, project.paths, project.plan, project.plan.Units[0]) + keyResult, err := computeInputKey(project.root, project.paths, project.plan, project.plan.Units[0]) + key := keyResult.Key if err != nil { t.Fatalf("expected a key, got error: %v", err) } @@ -241,7 +245,8 @@ func TestComputeInputKeyRefusesAFlagWhoseFileItDoesNotFollow(t *testing.T) { project := newInputKeyProject(t) project.plan.Units[0].Assembly.OtherFlags = []string{flag} - key, err := computeInputKey(project.root, project.paths, project.plan, project.plan.Units[0]) + keyResult, err := computeInputKey(project.root, project.paths, project.plan, project.plan.Units[0]) + key := keyResult.Key if err == nil { t.Errorf("%s names a file that is not followed, so it should refuse a key", flag) } @@ -313,7 +318,8 @@ func TestComputeInputKeyRefusesAnUnfollowedFlagInTheCompanionResponseFile(t *tes project := newInputKeyProject(t) project.plan.Units[0].Assembly.CompanionFlags = []string{`/keyfile:"Keys/Sign.snk"`} - key, err := computeInputKey(project.root, project.paths, project.plan, project.plan.Units[0]) + keyResult, err := computeInputKey(project.root, project.paths, project.plan, project.plan.Units[0]) + key := keyResult.Key if err == nil { t.Error("a keyfile in the companion response file should refuse a key") } @@ -336,7 +342,8 @@ func TestComputeInputKeyRefusesAKeyWhenTheResponseFileIsGone(t *testing.T) { t.Fatal(err) } - key, err := computeInputKey(project.root, project.paths, project.plan, project.plan.Units[0]) + keyResult, err := computeInputKey(project.root, project.paths, project.plan, project.plan.Units[0]) + key := keyResult.Key if err == nil { t.Error("a missing response file should refuse a key") } @@ -344,3 +351,80 @@ func TestComputeInputKeyRefusesAKeyWhenTheResponseFileIsGone(t *testing.T) { t.Errorf("expected no key, got %q", key) } } + +// nonSourceKey computes the non-source key of the unit under test. +func (project *inputKeyProject) nonSourceKey(t *testing.T) string { + t.Helper() + key, err := computeNonSourceKey(project.root, project.paths, project.plan, project.plan.Units[0]) + if err != nil { + t.Fatalf("expected a non-source key, got error: %v", err) + } + + return key +} + +// Verifies the non-source key ignores what the sources hold, so a body edit leaves it alone. +func TestNonSourceKeyIgnoresSourceContent(t *testing.T) { + project := newInputKeyProject(t) + original := project.nonSourceKey(t) + + writeProjectFile(t, project.root, filepath.Join("Assets", "Alpha", "Alpha.cs"), "class Alpha { void M() {} }") + + if edited := project.nonSourceKey(t); edited != original { + t.Error("editing a source should leave the non-source key alone") + } +} + +// Verifies the non-source key moves when a source is added or removed, so a changed source set +// falls back to a full compile. +func TestNonSourceKeyChangesWhenSourceAddedOrRemoved(t *testing.T) { + project := newInputKeyProject(t) + original := project.nonSourceKey(t) + added := filepath.Join("Assets", "Alpha", "Added.cs") + writeProjectFile(t, project.root, added, "class Added {}") + + project.plan.Units[0].Sources = append(project.plan.Units[0].Sources, added) + if withAdded := project.nonSourceKey(t); withAdded == original { + t.Error("adding a source should move the non-source key") + } + + project.plan.Units[0].Sources = []string{added} + if withRemoved := project.nonSourceKey(t); withRemoved == original { + t.Error("removing a source should move the non-source key") + } +} + +// Verifies the non-source key follows the inputs other than source contents: a reference and the +// response file. +func TestNonSourceKeyChangesWithReferenceAndRsp(t *testing.T) { + project := newInputKeyProject(t) + original := project.nonSourceKey(t) + + touchProjectFile(t, project.root, filepath.Join(planDagDirectory, "Gamma"+referenceAssemblyExtension)) + touched := project.nonSourceKey(t) + if touched == original { + t.Error("a rebuilt reference should move the non-source key") + } + + writeProjectFile(t, project.root, filepath.Join(planDagDirectory, "Alpha.rsp"), "-out:\"Alpha.dll\"\n-define:EXTRA\n") + if edited := project.nonSourceKey(t); edited == touched { + t.Error("a changed response file should move the non-source key") + } +} + +// Verifies the input key returns the sha256 of each source it read, keyed as unit.Sources spells it. +func TestInputKeyReturnsHashesOfContentItRead(t *testing.T) { + project := newInputKeyProject(t) + source := filepath.Join("Assets", "Alpha", "Alpha.cs") + + result, err := computeInputKey(project.root, project.paths, project.plan, project.plan.Units[0]) + if err != nil { + t.Fatalf("expected a key, got error: %v", err) + } + + sum := sha256.Sum256([]byte("class Alpha {}")) + expected := map[string]string{source: hex.EncodeToString(sum[:])} + if !reflect.DeepEqual(result.SourceHashes, expected) { + t.Errorf("expected source hashes %v, got %v", expected, result.SourceHashes) + } +} diff --git a/cli/dispatcher/internal/compilecheck/result_cache.go b/cli/dispatcher/internal/compilecheck/result_cache.go index 9a7ebd7367..64f13b7650 100644 --- a/cli/dispatcher/internal/compilecheck/result_cache.go +++ b/cli/dispatcher/internal/compilecheck/result_cache.go @@ -41,7 +41,8 @@ func (c Compiler) reuseOrCompile( outputDirectoryPath := filepath.Join(c.ProjectRoot, plan.OutputDir) // Why an unreadable input is not an error here: the compile that follows reads the same files and // reports what is wrong with them in its own way. All this loses is the chance to reuse. - key, keyErr := computeInputKey(c.ProjectRoot, c.Paths, plan, unit) + keyResult, keyErr := computeInputKey(c.ProjectRoot, c.Paths, plan, unit) + key := keyResult.Key if keyErr != nil { key = "" } @@ -53,10 +54,23 @@ func (c Compiler) reuseOrCompile( } } + // Why a fast check that was used touches nothing on disk: the outputs, the manifest and the + // baseline still describe the last full compile, which is what the next fast check compares against. + nonSourceKey := c.nonSourceKeyBeforeCompile(plan, unit, key) + fallbackReason := "" + if c.FastPath.shouldTry(plan, unit, key) { + attempt := c.FastPath.Try(ctx, c, plan, unit, keyResult, nonSourceKey) + if attempt.Err != nil || attempt.Used { + return attempt.Result, attempt.Err + } + fallbackReason = attempt.Reason + } + // Why nothing is recorded unless csc ran to completion: a compile that was cancelled or never // started says nothing about the inputs, and CompileUnit has already removed the manifest of the // compile this one replaces, so an interrupted run leaves no record at all rather than a stale one. result, err := c.CompileUnit(ctx, plan, unit) + result.FastPathFallbackReason = fallbackReason if err != nil || key == "" || result.RawOutput != "" { return result, err } @@ -64,9 +78,35 @@ func (c Compiler) reuseOrCompile( return UnitResult{}, writeErr } + // Why --full records too: running --full before finishing would otherwise drop the baseline, and + // the first check after the next edit would always compile in full. + // Why a failure to record does not fail the run: the compile itself succeeded, and all it costs + // is a full compile next time. + if c.FastPath.shouldRecord(unit, result) { + if recordErr := c.FastPath.RecordBaseline(ctx, c, plan, unit, keyResult, nonSourceKey); recordErr != nil { + result.FastPathNote = "baseline not recorded: " + recordErr.Error() + } + } + return result, nil } +// nonSourceKeyBeforeCompile reads a unit's non-source key for the changed-files check, or "" when +// there is no check or no key. +// Why it is read before anything runs: a baseline must describe the inputs the compile saw, and +// reading it after the compile would let a change made during it in unchecked. +func (c Compiler) nonSourceKeyBeforeCompile(plan BuildPlan, unit CompileUnit, key string) string { + if c.FastPath == nil || key == "" { + return "" + } + nonSourceKey, err := computeNonSourceKey(c.ProjectRoot, c.Paths, plan, unit) + if err != nil { + return "" + } + + return nonSourceKey +} + // manifestPath names where one unit's manifest lives. func manifestPath(outputDirectoryPath string, unit CompileUnit) string { return filepath.Join(outputDirectoryPath, unit.Assembly.AssemblyName+resultManifestExtension) @@ -98,10 +138,14 @@ func writeUnitResultManifest( return fmt.Errorf("failed to record the result of %s: %w", unit.Assembly.AssemblyName, err) } - path := manifestPath(outputDirectoryPath, unit) + return writeFileAtomically(outputDirectoryPath, manifestPath(outputDirectoryPath, unit), content) +} + +// writeFileAtomically writes content to path through a temporary file in directoryPath. +func writeFileAtomically(directoryPath string, path string, content []byte) error { // Why the temporary name is unique per writer: two runs of the same project writing the same - // manifest at once must not rename each other's half-written file into place. - temporary, err := os.CreateTemp(outputDirectoryPath, filepath.Base(path)+".*"+resultManifestTempSuffix) + // file at once must not rename each other's half-written file into place. + temporary, err := os.CreateTemp(directoryPath, filepath.Base(path)+".*"+resultManifestTempSuffix) if err != nil { return fmt.Errorf("failed to write %s: %w", path, err) } diff --git a/cli/dispatcher/internal/compilecheck/session.go b/cli/dispatcher/internal/compilecheck/session.go index 6d8afadb66..829d882d04 100644 --- a/cli/dispatcher/internal/compilecheck/session.go +++ b/cli/dispatcher/internal/compilecheck/session.go @@ -32,6 +32,9 @@ type Options struct { Jobs int // how many assemblies compile at once; 0 asks for the default // LockTimeout bounds the wait for another run on the same project; 0 asks for the default. LockTimeout time.Duration + // DisableFastPath compiles every changed assembly in full instead of checking only its changed + // files. Successful full compiles still record the baseline the next run checks against. + DisableFastPath bool } // Result is everything one compile-check run produced. @@ -75,6 +78,12 @@ func Run(ctx context.Context, options Options) (Result, error) { ProjectRoot: options.ProjectRoot, Timeout: unitCompileTimeout, Run: defaultRun, + FastPath: &fastPathRunner{ + TryDisabled: options.DisableFastPath, + Worker: newBootstrappedWorker(paths, + filepath.Join(options.ProjectRoot, filepath.FromSlash(compileCheckOutputRoot)), + filepath.Join(options.ProjectRoot, plan.OutputDir), defaultRun), + }, } jobs := options.Jobs diff --git a/cli/dispatcher/internal/dispatcher/compile_check.go b/cli/dispatcher/internal/dispatcher/compile_check.go index da795c8d9b..1c918570fd 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check.go +++ b/cli/dispatcher/internal/dispatcher/compile_check.go @@ -18,6 +18,7 @@ import ( const ( compileCheckNoChangeMessage = "No assemblies changed since the last Unity build." compileCheckAllFlag = "--all" + compileCheckFullFlag = "--full" compileCheckEditorFlag = "--editor-version" compileCheckMaxDepthFlag = "--max-depth" compileCheckJobsFlag = "--jobs" @@ -27,6 +28,7 @@ const ( type compileCheckOptions struct { projectPath string all bool + full bool // compile changed assemblies in full instead of checking only their changed files editorVersion string maxDepth int jobs int // how many assemblies compile at once; 0 leaves the choice to the compiler @@ -58,10 +60,37 @@ type compileCheckResponse struct { // has errors, which is where Unity's own build stops as well. Fixing those errors and running // again is what carries the check on past them. BlockedAssemblies []string `json:"BlockedAssemblies"` - SkippedAssemblies int `json:"SkippedAssemblies"` - ResponseFileSet string `json:"ResponseFileSet"` - ProjectRoot string `json:"ProjectRoot"` - Message string `json:"Message"` + // FastCheckedAssemblies names the assemblies this run checked on their changed files only, + // against the last successful full compile, instead of running csc. They are in neither + // CompiledAssemblies nor ReusedAssemblies. + FastCheckedAssemblies []string `json:"FastCheckedAssemblies"` + // FastChecks says, for each fast-checked assembly, which files were checked and what was not. + FastChecks []compileCheckFastCheck `json:"FastChecks"` + // FastPathFallbacks says why each assembly the changed-files check was tried on compiled in full. + FastPathFallbacks []compileCheckFastPathFallback `json:"FastPathFallbacks"` + // FastPathBaselineNotes says why a successful full compile left no baseline for the next run's + // changed-files check, so the next fallback for lack of one is not a mystery. + FastPathBaselineNotes []compileCheckFastPathFallback `json:"FastPathBaselineNotes"` + SkippedAssemblies int `json:"SkippedAssemblies"` + ResponseFileSet string `json:"ResponseFileSet"` + ProjectRoot string `json:"ProjectRoot"` + Message string `json:"Message"` +} + +// compileCheckFastCheck describes one assembly checked on its changed files only. +type compileCheckFastCheck struct { + Assembly string `json:"Assembly"` + ChangedFiles []string `json:"ChangedFiles"` + // UncheckedCodes lists the diagnostic IDs only a full compile reports. + UncheckedCodes []string `json:"UncheckedCodes"` + // GeneratorsNotRerun is set when the source generators were not run again. + GeneratorsNotRerun bool `json:"GeneratorsNotRerun"` +} + +// compileCheckFastPathFallback says why one assembly compiled in full instead of being fast-checked. +type compileCheckFastPathFallback struct { + Assembly string `json:"Assembly"` + Reason string `json:"Reason"` } // tryHandleCompileCheckRequest answers `uloop compile-check` inside the dispatcher process, because @@ -115,12 +144,7 @@ func runCompileCheck( return 1 } - result, err := compilecheck.Run(ctx, compilecheck.Options{ - ProjectRoot: projectRoot, - EditorExecutablePath: editorPath, - All: options.all, - Jobs: options.jobs, - }) + result, err := compilecheck.Run(ctx, newCompileCheckRunOptions(options, projectRoot, editorPath)) if err != nil { clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ Command: clicore.CompileCheckCommandName, ProjectRoot: projectRoot, @@ -145,6 +169,19 @@ func runCompileCheck( return 0 } +// newCompileCheckRunOptions hands the parsed command line to the offline compile. +func newCompileCheckRunOptions( + options compileCheckOptions, projectRoot string, editorPath string, +) compilecheck.Options { + return compilecheck.Options{ + ProjectRoot: projectRoot, + EditorExecutablePath: editorPath, + All: options.all, + Jobs: options.jobs, + DisableFastPath: options.full, + } +} + // resolveCompileCheckProjectRoot finds the project the same way `launch` does, without connecting. func resolveCompileCheckProjectRoot(startPath string, options compileCheckOptions) (string, error) { if options.projectPath != "" { @@ -178,18 +215,18 @@ func buildCompileCheckResponse(result compilecheck.Result, projectRoot string) c ReusedAssemblies: make([]string, 0, len(result.Units)), // Why the names are copied rather than taken as they are: a run that blocked nothing must // still serialize as an empty list, and the result of such a run carries no list at all. - BlockedAssemblies: append(make([]string, 0, len(result.Blocked)), result.Blocked...), - SkippedAssemblies: result.Skipped, - ResponseFileSet: filepath.Base(result.DagDir), - ProjectRoot: projectRoot, + BlockedAssemblies: append(make([]string, 0, len(result.Blocked)), result.Blocked...), + FastCheckedAssemblies: make([]string, 0), + FastChecks: make([]compileCheckFastCheck, 0), + FastPathFallbacks: make([]compileCheckFastPathFallback, 0), + FastPathBaselineNotes: make([]compileCheckFastPathFallback, 0), + SkippedAssemblies: result.Skipped, + ResponseFileSet: filepath.Base(result.DagDir), + ProjectRoot: projectRoot, } for _, unit := range result.Units { - if unit.Reused { - response.ReusedAssemblies = append(response.ReusedAssemblies, unit.Assembly) - } else { - response.CompiledAssemblies = append(response.CompiledAssemblies, unit.Assembly) - } + addCompileCheckUnitKind(&response, unit) // Why a reused unit's diagnostics go in with the rest: the run reports what the assembly // is, not how the answer was arrived at, and dropping them would turn a broken assembly // into a clean one for every run after the one that compiled it. @@ -218,6 +255,36 @@ func buildCompileCheckResponse(result compilecheck.Result, projectRoot string) c return response } +// addCompileCheckUnitKind lists a unit under how this run answered for it: reused, fast-checked, or +// compiled, and records why a fast check fell back when it did. +func addCompileCheckUnitKind(response *compileCheckResponse, unit compilecheck.UnitResult) { + switch { + case unit.Reused: + response.ReusedAssemblies = append(response.ReusedAssemblies, unit.Assembly) + case unit.FastChecked: + response.FastCheckedAssemblies = append(response.FastCheckedAssemblies, unit.Assembly) + response.FastChecks = append(response.FastChecks, compileCheckFastCheck{ + Assembly: unit.Assembly, + ChangedFiles: append(make([]string, 0, len(unit.FastCheckChangedFiles)), unit.FastCheckChangedFiles...), + UncheckedCodes: append(make([]string, 0, len(unit.UncheckedCodes)), unit.UncheckedCodes...), + GeneratorsNotRerun: unit.GeneratorsNotRerun, + }) + default: + response.CompiledAssemblies = append(response.CompiledAssemblies, unit.Assembly) + } + + if unit.FastPathFallbackReason != "" { + response.FastPathFallbacks = append(response.FastPathFallbacks, compileCheckFastPathFallback{ + Assembly: unit.Assembly, Reason: unit.FastPathFallbackReason, + }) + } + if unit.FastPathNote != "" { + response.FastPathBaselineNotes = append(response.FastPathBaselineNotes, compileCheckFastPathFallback{ + Assembly: unit.Assembly, Reason: unit.FastPathNote, + }) + } +} + // newCompileCheckIssue shapes one diagnostic the way `uloop compile` shapes its own. func newCompileCheckIssue(assembly string, diagnostic compilecheck.Diagnostic) compileCheckIssue { return compileCheckIssue{ @@ -233,36 +300,63 @@ func newCompileCheckIssue(assembly string, diagnostic compilecheck.Diagnostic) c // compileCheckMessage states what the run did in one line. // Why the blocked assemblies are added rather than folded in: without them a run that reports one // error and hundreds of assemblies it never got to reads as a run that lost them. +// Why the fast-checked ones get a reminder: the check skipped rules only a full compile runs, and +// the caller has to know to run --full before finishing. func compileCheckMessage(response compileCheckResponse) string { message := compileCheckRunSummary(response) - if len(response.BlockedAssemblies) == 0 { - return message + if len(response.BlockedAssemblies) > 0 { + message += fmt.Sprintf( + " %d assemblies were not compiled because an assembly they reference has errors.", + len(response.BlockedAssemblies)) + } + if len(response.FastCheckedAssemblies) > 0 { + message += fmt.Sprintf( + " %d assemblies were checked on their changed files only; run with --full before finishing to cover assembly-wide rules.", + len(response.FastCheckedAssemblies)) } - return message + fmt.Sprintf( - " %d assemblies were not compiled because an assembly they reference has errors.", - len(response.BlockedAssemblies)) + return message } -// compileCheckRunSummary states what the run compiled and reused. -// Why three forms rather than always naming both counts: a run that reused nothing reads exactly -// as it did before reuse existed, and a run that compiled nothing should not open by saying so. +// compileCheckRunSummary states what the run compiled, reused, and fast-checked. +// Why only the non-zero counts are named: a run that reused nothing reads exactly as it did before +// reuse existed, and a run that compiled nothing should not open by saying so. func compileCheckRunSummary(response compileCheckResponse) string { - compiled := len(response.CompiledAssemblies) - reused := len(response.ReusedAssemblies) - switch { - case compiled == 0 && reused == 0: + counts := []struct { + verb string + count int + }{ + {"compiled", len(response.CompiledAssemblies)}, + {"reused", len(response.ReusedAssemblies)}, + {"fast-checked", len(response.FastCheckedAssemblies)}, + } + parts := []string{} + for _, entry := range counts { + if entry.count == 0 { + continue + } + if len(parts) == 0 { + parts = append(parts, fmt.Sprintf("%s%s %d assemblies", + strings.ToUpper(entry.verb[:1]), entry.verb[1:], entry.count)) + continue + } + parts = append(parts, fmt.Sprintf("%s %d", entry.verb, entry.count)) + } + if len(parts) == 0 { return compileCheckNoChangeMessage - case reused == 0: - return fmt.Sprintf("Compiled %d assemblies with %d errors and %d warnings.", - compiled, response.ErrorCount, response.WarningCount) - case compiled == 0: - return fmt.Sprintf("Reused %d assemblies with %d errors and %d warnings.", - reused, response.ErrorCount, response.WarningCount) } - return fmt.Sprintf("Compiled %d assemblies and reused %d with %d errors and %d warnings.", - compiled, reused, response.ErrorCount, response.WarningCount) + return fmt.Sprintf("%s with %d errors and %d warnings.", + joinCompileCheckSummaryParts(parts), response.ErrorCount, response.WarningCount) +} + +// joinCompileCheckSummaryParts joins the summary parts as "A and b" or "A, b and c". +func joinCompileCheckSummaryParts(parts []string) string { + if len(parts) == 1 { + return parts[0] + } + + return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1] } // printCompileCheckHelp documents the command and the build it replays. @@ -272,6 +366,7 @@ func printCompileCheckHelp(stdout io.Writer) { clicore.WriteLine(stdout, "") clicore.WriteLine(stdout, "Options:") clicore.WriteLine(stdout, " --all Compile every assembly instead of only the changed ones") + clicore.WriteLine(stdout, " --full Compile changed assemblies in full instead of checking only their changed files") clicore.WriteLine(stdout, " --editor-version ") clicore.WriteLine(stdout, " Use this Unity Editor version instead of ProjectVersion.txt") clicore.WriteLine(stdout, " --jobs Compile up to n assemblies at once, 1 = sequential (default: half the CPUs, at least 1)") @@ -311,6 +406,9 @@ func applyCompileCheckOption(options *compileCheckOptions, args []string, index case arg == compileCheckAllFlag: options.all = true return index, nil + case arg == compileCheckFullFlag: + options.full = true + return index, nil case isCompileCheckKeyedOption(arg, compileCheckEditorFlag): return applyCompileCheckEditorVersion(options, args, index) case isCompileCheckKeyedOption(arg, compileCheckMaxDepthFlag): diff --git a/cli/dispatcher/internal/dispatcher/compile_check_fast_path_test.go b/cli/dispatcher/internal/dispatcher/compile_check_fast_path_test.go new file mode 100644 index 0000000000..8bc9c31422 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/compile_check_fast_path_test.go @@ -0,0 +1,131 @@ +package dispatcher + +import ( + "bytes" + "reflect" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck" +) + +// Verifies that --full is read and turns the changed-files check off for the run. +func TestFullFlagSetsDisableFastPath(t *testing.T) { + options, err := parseCompileCheckOptions([]string{compileCheckFullFlag}, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if runOptions := newCompileCheckRunOptions(options, "/projects/sample", "/editor"); !runOptions.DisableFastPath { + t.Fatal("expected --full to disable the changed-files check") + } + if runOptions := newCompileCheckRunOptions(compileCheckOptions{}, "/projects/sample", "/editor"); runOptions.DisableFastPath { + t.Fatal("expected the changed-files check to stay on without --full") + } +} + +// Verifies that the help lists --full. +func TestCompileCheckHelpListsFullFlag(t *testing.T) { + var stdout bytes.Buffer + printCompileCheckHelp(&stdout) + + if !strings.Contains(stdout.String(), "--full Compile changed assemblies in full instead of checking only their changed files") { + t.Fatalf("expected --full in the help, got:\n%s", stdout.String()) + } +} + +// fastCheckedResult is a run with one compiled, one reused, and one fast-checked assembly. +func fastCheckedResult() compilecheck.Result { + return compilecheck.Result{ + DagDir: "Library/Bee/artifacts/1234.dag", + Units: []compilecheck.UnitResult{ + { + Assembly: "Compiled", Succeeded: true, FastPathFallbackReason: "too many changed files", + FastPathNote: "baseline not recorded: source changed during the compile: Assets/A.cs", + }, + {Assembly: "Reused", Succeeded: true, Reused: true}, + { + Assembly: "Fast", Succeeded: true, FastChecked: true, + FastCheckChangedFiles: []string{"Assets/Fast/A.cs"}, + UncheckedCodes: []string{"CS0169"}, + GeneratorsNotRerun: true, + }, + }, + } +} + +// Verifies that fast-checked assemblies are listed apart from compiled and reused ones, with what +// each check covered. +func TestResponseListsFastCheckedAssembliesApart(t *testing.T) { + response := buildCompileCheckResponse(fastCheckedResult(), "/projects/sample") + + assertCompileCheckNames(t, "compiled", response.CompiledAssemblies, []string{"Compiled"}) + assertCompileCheckNames(t, "reused", response.ReusedAssemblies, []string{"Reused"}) + assertCompileCheckNames(t, "fast-checked", response.FastCheckedAssemblies, []string{"Fast"}) + want := []compileCheckFastCheck{{ + Assembly: "Fast", ChangedFiles: []string{"Assets/Fast/A.cs"}, UncheckedCodes: []string{"CS0169"}, GeneratorsNotRerun: true, + }} + if !reflect.DeepEqual(response.FastChecks, want) { + t.Errorf("fast checks = %+v, want %+v", response.FastChecks, want) + } +} + +// Verifies that only units that fell back with a reason are listed as fallbacks. +func TestResponseListsFallbackReasons(t *testing.T) { + response := buildCompileCheckResponse(fastCheckedResult(), "/projects/sample") + + want := []compileCheckFastPathFallback{{Assembly: "Compiled", Reason: "too many changed files"}} + if !reflect.DeepEqual(response.FastPathFallbacks, want) { + t.Errorf("fallbacks = %+v, want %+v", response.FastPathFallbacks, want) + } +} + +// Verifies that the message counts the fast-checked assemblies and asks for --full before finishing. +func TestMessageMentionsFastCheckedAssemblies(t *testing.T) { + response := buildCompileCheckResponse(fastCheckedResult(), "/projects/sample") + + want := "Compiled 1 assemblies, reused 1 and fast-checked 1 with 0 errors and 0 warnings." + + " 1 assemblies were checked on their changed files only; run with --full before finishing to cover assembly-wide rules." + if response.Message != want { + t.Errorf("message = %q, want %q", response.Message, want) + } +} + +// Verifies that a run answered only by the changed-files check, with an error, reports the error +// count instead of saying no assemblies changed. +func TestFastCheckOnlyRunWithErrorDoesNotSayNoAssembliesChanged(t *testing.T) { + response := buildCompileCheckResponse(compilecheck.Result{ + DagDir: "Library/Bee/artifacts/1234.dag", + Units: []compilecheck.UnitResult{{ + Assembly: "Fast", FastChecked: true, + Diagnostics: []compilecheck.Diagnostic{{Severity: "error", Code: "CS0029", Message: "cannot convert", File: "Assets/Fast/A.cs", Line: 3, Column: 9}}, + }}, + }, "/projects/sample") + + if strings.Contains(response.Message, compileCheckNoChangeMessage) || !strings.HasPrefix(response.Message, "Fast-checked 1 assemblies with 1 errors and 0 warnings.") { + t.Errorf("unexpected message %q", response.Message) + } + if response.ErrorCount != 1 || response.Success { + t.Errorf("expected one error and a failed run, got %d errors and success %t", response.ErrorCount, response.Success) + } +} + +func assertCompileCheckNames(t *testing.T, label string, got []string, want []string) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Errorf("%s = %v, want %v", label, got, want) + } +} + +// Verifies that a baseline that was not recorded is reported with its reason, so the next run's +// fallback for lack of a baseline can be traced back. +func TestResponseListsBaselineNotes(t *testing.T) { + response := buildCompileCheckResponse(fastCheckedResult(), "/projects/sample") + + want := []compileCheckFastPathFallback{{ + Assembly: "Compiled", Reason: "baseline not recorded: source changed during the compile: Assets/A.cs", + }} + if !reflect.DeepEqual(response.FastPathBaselineNotes, want) { + t.Errorf("baseline notes = %+v, want %+v", response.FastPathBaselineNotes, want) + } +} diff --git a/cli/dispatcher/internal/dispatcher/compile_check_test.go b/cli/dispatcher/internal/dispatcher/compile_check_test.go index 251a42fff7..118cfb275c 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_test.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_test.go @@ -21,6 +21,9 @@ func TestParseCompileCheckOptionsDefaultsToTheChangedScope(t *testing.T) { if options.all { t.Fatalf("expected the changed scope, got --all") } + if options.full { + t.Fatalf("expected the changed-files check to be on by default, got --full") + } if options.projectPath != "/projects/sample" { t.Fatalf("expected the global project path, got %q", options.projectPath) } @@ -358,6 +361,9 @@ func TestBuildCompileCheckResponseReportsNoBlockedAssembliesAsAnEmptyList(t *tes if response.BlockedAssemblies == nil { t.Fatal("BlockedAssemblies should be an empty list rather than null in the JSON") } + if response.FastCheckedAssemblies == nil || response.FastChecks == nil || response.FastPathFallbacks == nil || response.FastPathBaselineNotes == nil { + t.Fatal("the fast-check lists should be empty lists rather than null in the JSON") + } } // Verifies that the summary says why assemblies went uncompiled, so a run reporting one error and diff --git a/docs/compile-check.md b/docs/compile-check.md index 48ca466bd3..3b2dfba1c8 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -120,6 +120,66 @@ directory a `Packages/manifest.json` dependency points at with a `file:` path develops locally lives outside the project root and would otherwise look like one whose assembly definitions were deleted. +## Checking only the changed files + +When an assembly would otherwise be compiled in full, compile-check first tries to check only its +changed files against the last successful full compile of that assembly. The decision and the +guarantees are recorded in `docs/adr/0015-compile-check-checks-only-changed-files-after-a-full-compile.md`. + +The check is used when all of these hold: + +- The run is not `--all` or `--full`, the assembly has an input key and at least 300 sources, and + it was picked because its own sources changed, not only because an assembly it references + changed. Smaller assemblies are always compiled in full and are not listed as fallbacks. +- The last full compile of the assembly by compile-check succeeded and left a baseline, and its + outputs, result, and saved generator output are still on disk as it wrote them. +- Every input other than the source contents is the same as at that compile: the compiler, the + response files, the references, the analyzers and the files they read, the output names, and the + list of source paths. +- 1 to 30 of its sources changed. +- Every changed file keeps its declarations: only method bodies, accessor bodies, expression + bodies, non-constant initializers outside partial types, comments, or whitespace changed. + +The changed files are checked by a worker built once per Editor toolchain with the Editor's own +dotnet and Roslyn. It reports their compiler and analyzer diagnostics, with `DiagnosticSuppressor`s +and the response file's options applied, and the diagnostics of the files that did not change are +carried over from the last full compile. The outputs, the result, and the baseline of that compile +are left as they were. + +Otherwise the assembly is compiled in full as before, and `FastPathFallbacks` says why: + +- `no baseline from an earlier full compile` +- `baseline compile is no longer on disk` +- `inputs other than source contents changed` +- `generator output of the baseline is gone` +- `baseline record does not match its compile result` +- `too many changed files` +- `worker unavailable: ...` (the worker could not be built; a build that failed is not retried + until the worker sources or the toolchain change) +- `baseline was fingerprinted by another worker build` +- `worker failed: ...` +- `DeclarationChanged: ...` +- `Unsupported: ...` (for example an analyzer that threw, or a response file option the worker does + not support) + +A full compile that succeeds records a new baseline, `--full` runs included, so the run after it can +use the check again. A source or another input edited while csc was compiling leaves no baseline, +and `FastPathBaselineNotes` says why a successful full compile left none. + +What the check does not guarantee, so run `--full` before finishing: + +- CS0067, CS0169, CS0414, and CS0649, which the compiler reports only after it has seen the whole + assembly, and the rules of analyzers that use Compilation, CompilationEnd, SemanticModel, or + SymbolStart actions. `FastChecks[].UncheckedCodes` lists the IDs it could not run. +- Errors the compiler reports only while emitting, such as CS8178 and CS0656. +- Source generators. The check reads the generator output saved by the last full compile and does + not run them again; `FastChecks[].GeneratorsNotRerun` says so. + +The check keeps its state next to the compile outputs: `.fastcheck.json` (the baseline), +`.generated/` (the saved generator output, whose files the baseline records by content +hash), and the worker build in `Library/uloop/compile-check/fastcheck-worker//`. Deleting or +changing any of them only costs a full compile. + ## One run per project at a time Two runs against the same project would write the same output directory and the same compiler @@ -142,14 +202,21 @@ CPUs and never less than one, and `--jobs 1` compiles them one after another. The command prints a JSON payload with `Success`, `ErrorCount`, `WarningCount`, `Errors`, `Warnings` (each diagnostic carrying `Message`, `Code`, `File`, `Line`, `Column`, `Assembly`), -`CompiledAssemblies`, `ReusedAssemblies`, `BlockedAssemblies`, `SkippedAssemblies`, +`CompiledAssemblies`, `ReusedAssemblies`, `BlockedAssemblies`, `FastCheckedAssemblies`, +`FastChecks`, `FastPathFallbacks`, `FastPathBaselineNotes`, `SkippedAssemblies`, `ResponseFileSet`, `ProjectRoot` and a one-line `Message`. `CompiledAssemblies` names the assemblies this run handed to the compiler and `ReusedAssemblies` the ones it reported on without compiling; the diagnostics of both are in `Errors` and `Warnings` alike. `BlockedAssemblies` names the assemblies the run stopped short of because one they reference has errors — they are not counted in `SkippedAssemblies`, which counts the assemblies the check read and found nothing left to say about: the ones nothing changed for, -and the ones left out because every assembly they reference kept the same public surface. The process exits 1 when +and the ones left out because every assembly they reference kept the same public surface. +`FastCheckedAssemblies` names the assemblies checked on their changed files only, which are in +neither `CompiledAssemblies` nor `ReusedAssemblies`; `FastChecks` gives each one's `ChangedFiles`, +`UncheckedCodes`, and `GeneratorsNotRerun`, and `FastPathFallbacks` gives the `Assembly` and +`Reason` of each assembly the check was tried on and compiled in full. `FastPathBaselineNotes` +gives the `Assembly` and `Reason` of each successful full compile that left no baseline. The +process exits 1 when `ErrorCount` is greater than zero. ## Limitations From 99eff0dd084b7ea65e40ae0b87cbf3738abb24e4 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 14 Oct 2026 22:33:38 +0900 Subject: [PATCH 04/33] chore: split the compile-check changed-files check file under the line limit (#3319) --- .../internal/compilecheck/baseline_record.go | 189 ++++++++++++++++++ .../internal/compilecheck/fast_path.go | 177 ---------------- 2 files changed, 189 insertions(+), 177 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/baseline_record.go diff --git a/cli/dispatcher/internal/compilecheck/baseline_record.go b/cli/dispatcher/internal/compilecheck/baseline_record.go new file mode 100644 index 0000000000..3eeedf6601 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/baseline_record.go @@ -0,0 +1,189 @@ +package compilecheck + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" +) + +// baselineRecord is what a successful full compile leaves for the next changed-files check. +type baselineRecord struct { + FormatVersion int + // InputKey is the key of the full compile this baseline describes; its manifest carries the same. + InputKey string + // NonSourceKey covers every input but the source contents, so any other change compiles in full. + NonSourceKey string + // WorkerKey names the worker build that computed the fingerprints. + WorkerKey string + GeneratedDirectory string // relative to the project root + // GeneratedFiles maps each generated file, relative to GeneratedDirectory, to its content hash, + // so a check never runs without the generator output its baseline compile had. + GeneratedFiles map[string]string + Files map[string]baselineFile +} + +// baselineFile describes one source as the full compile saw it. +type baselineFile struct { + ContentHash string + Fingerprint string +} + +// RecordBaseline records what the next changed-files check of a unit compares against, after a +// successful full compile. nonSourceKey is the unit's non-source key computed before the compile. +// Why every content hash must match the one the input key read, and the non-source key the one +// computed before the compile: the manifest describes the inputs csc compiled, and an input changed +// during the compile would otherwise enter the baseline as if that compile had seen it, and its +// effect would never be checked by the next run. +func (runner *fastPathRunner) RecordBaseline( + ctx context.Context, c Compiler, plan BuildPlan, unit CompileUnit, keyResult inputKeyResult, nonSourceKey string, +) error { + if nonSourceKey == "" { + return errors.New("inputs other than source contents could not be read before the compile") + } + currentNonSourceKey, err := computeNonSourceKey(c.ProjectRoot, c.Paths, plan, unit) + if err != nil { + return err + } + if currentNonSourceKey != nonSourceKey { + return errors.New("inputs other than source contents changed during the compile") + } + + worker, err := runner.Worker.Ensure(ctx) + if err != nil { + return err + } + + request := fastcheck.FingerprintRequest{ + ProjectRoot: c.ProjectRoot, + ResponseFilePath: filepath.Join(c.ProjectRoot, plan.OutputDir, unit.Assembly.AssemblyName+responseFileExtension), + Sources: make([]string, 0, len(unit.Sources)), + } + for _, source := range unit.Sources { + request.Sources = append(request.Sources, projectPath(c.ProjectRoot, source)) + } + + fingerprints, err := runner.Worker.Fingerprint(ctx, worker, request) + if err != nil { + return err + } + + files, err := baselineFiles(c.ProjectRoot, unit, keyResult, fingerprints) + if err != nil { + return err + } + + generatedFiles, err := hashGeneratedFiles(filepath.Join(c.ProjectRoot, generatedDirectory(plan, unit))) + if err != nil { + return err + } + + return writeBaselineRecord(filepath.Join(c.ProjectRoot, plan.OutputDir), unit, baselineRecord{ + FormatVersion: baselineRecordFormatVersion, + InputKey: keyResult.Key, + NonSourceKey: nonSourceKey, + WorkerKey: worker.Key, + GeneratedDirectory: filepath.ToSlash(generatedDirectory(plan, unit)), + GeneratedFiles: generatedFiles, + Files: files, + }) +} + +// hashGeneratedFiles hashes every generated C# file under directoryPath, keyed by its slash-separated +// path relative to it. A missing directory holds no files. +func hashGeneratedFiles(directoryPath string) (map[string]string, error) { + hashes := map[string]string{} + err := filepath.WalkDir(directoryPath, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + if os.IsNotExist(walkErr) && path == directoryPath { + return filepath.SkipDir + } + + return walkErr + } + if entry.IsDir() || filepath.Ext(path) != ".cs" { + return nil + } + relative, err := filepath.Rel(directoryPath, path) + if err != nil { + return err + } + hash, err := hashFile(path) + if err != nil { + return err + } + hashes[filepath.ToSlash(relative)] = hash + + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to read the generator output: %w", err) + } + + return hashes, nil +} + +// generatedFilesMatch reports whether the generator output on disk is exactly what the baseline +// recorded: no file missing, added, or changed. +// Why: the worker reads this output instead of running the generators, and checking without it +// reports errors wherever code uses what the generators produce. +func generatedFilesMatch(directoryPath string, recorded map[string]string) bool { + current, err := hashGeneratedFiles(directoryPath) + if err != nil || len(current) != len(recorded) { + return false + } + for name, hash := range recorded { + if current[name] != hash { + return false + } + } + + return true +} + +// baselineFiles pairs each source's content hash, as the input key read it, with its fingerprint. +func baselineFiles( + projectRoot string, unit CompileUnit, keyResult inputKeyResult, fingerprints fastcheck.FingerprintResponse, +) (map[string]baselineFile, error) { + files := make(map[string]baselineFile, len(unit.Sources)) + for _, source := range unit.Sources { + absolute := projectPath(projectRoot, source) + fingerprint, hasFingerprint := fingerprints.Fingerprints[absolute] + contentHash, hasHash := fingerprints.ContentHashes[absolute] + if !hasFingerprint || !hasHash { + return nil, fmt.Errorf("worker returned no fingerprint for %s", source) + } + if contentHash != keyResult.SourceHashes[source] { + return nil, fmt.Errorf("source changed during the compile: %s", source) + } + files[source] = baselineFile{ContentHash: contentHash, Fingerprint: fingerprint} + } + + return files, nil +} + +// writeBaselineRecord writes a baseline through a temporary file, like the manifest. +func writeBaselineRecord(outputDirectoryPath string, unit CompileUnit, record baselineRecord) error { + content, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("failed to record the baseline of %s: %w", unit.Assembly.AssemblyName, err) + } + + return writeFileAtomically(outputDirectoryPath, baselineRecordPath(outputDirectoryPath, unit), content) +} + +// baselineRecordPath names where one unit's baseline lives. +func baselineRecordPath(outputDirectoryPath string, unit CompileUnit) string { + return filepath.Join(outputDirectoryPath, unit.Assembly.AssemblyName+baselineRecordExtension) +} + +// generatedDirectory names, relative to the project root, where a full compile saves the generator +// output of a unit. +func generatedDirectory(plan BuildPlan, unit CompileUnit) string { + return filepath.Join(plan.OutputDir, unit.Assembly.AssemblyName+generatedDirectoryExtension) +} diff --git a/cli/dispatcher/internal/compilecheck/fast_path.go b/cli/dispatcher/internal/compilecheck/fast_path.go index a275440df3..7c4d7663d2 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path.go +++ b/cli/dispatcher/internal/compilecheck/fast_path.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "io/fs" "os" "path/filepath" "runtime" @@ -47,28 +46,6 @@ type fastPathRunner struct { Worker fastCheckWorker } -// baselineRecord is what a successful full compile leaves for the next changed-files check. -type baselineRecord struct { - FormatVersion int - // InputKey is the key of the full compile this baseline describes; its manifest carries the same. - InputKey string - // NonSourceKey covers every input but the source contents, so any other change compiles in full. - NonSourceKey string - // WorkerKey names the worker build that computed the fingerprints. - WorkerKey string - GeneratedDirectory string // relative to the project root - // GeneratedFiles maps each generated file, relative to GeneratedDirectory, to its content hash, - // so a check never runs without the generator output its baseline compile had. - GeneratedFiles map[string]string - Files map[string]baselineFile -} - -// baselineFile describes one source as the full compile saw it. -type baselineFile struct { - ContentHash string - Fingerprint string -} - // fastPathAttempt is the outcome of trying the changed-files check on one unit: a result to use, // the reason it fell back to a full compile, or an error that stops the run. type fastPathAttempt struct { @@ -337,160 +314,6 @@ func normalizeDiagnosticPath(projectRoot string, path string) string { return normalized } -// RecordBaseline records what the next changed-files check of a unit compares against, after a -// successful full compile. nonSourceKey is the unit's non-source key computed before the compile. -// Why every content hash must match the one the input key read, and the non-source key the one -// computed before the compile: the manifest describes the inputs csc compiled, and an input changed -// during the compile would otherwise enter the baseline as if that compile had seen it, and its -// effect would never be checked by the next run. -func (runner *fastPathRunner) RecordBaseline( - ctx context.Context, c Compiler, plan BuildPlan, unit CompileUnit, keyResult inputKeyResult, nonSourceKey string, -) error { - if nonSourceKey == "" { - return errors.New("inputs other than source contents could not be read before the compile") - } - currentNonSourceKey, err := computeNonSourceKey(c.ProjectRoot, c.Paths, plan, unit) - if err != nil { - return err - } - if currentNonSourceKey != nonSourceKey { - return errors.New("inputs other than source contents changed during the compile") - } - - worker, err := runner.Worker.Ensure(ctx) - if err != nil { - return err - } - - request := fastcheck.FingerprintRequest{ - ProjectRoot: c.ProjectRoot, - ResponseFilePath: filepath.Join(c.ProjectRoot, plan.OutputDir, unit.Assembly.AssemblyName+responseFileExtension), - Sources: make([]string, 0, len(unit.Sources)), - } - for _, source := range unit.Sources { - request.Sources = append(request.Sources, projectPath(c.ProjectRoot, source)) - } - - fingerprints, err := runner.Worker.Fingerprint(ctx, worker, request) - if err != nil { - return err - } - - files, err := baselineFiles(c.ProjectRoot, unit, keyResult, fingerprints) - if err != nil { - return err - } - - generatedFiles, err := hashGeneratedFiles(filepath.Join(c.ProjectRoot, generatedDirectory(plan, unit))) - if err != nil { - return err - } - - return writeBaselineRecord(filepath.Join(c.ProjectRoot, plan.OutputDir), unit, baselineRecord{ - FormatVersion: baselineRecordFormatVersion, - InputKey: keyResult.Key, - NonSourceKey: nonSourceKey, - WorkerKey: worker.Key, - GeneratedDirectory: filepath.ToSlash(generatedDirectory(plan, unit)), - GeneratedFiles: generatedFiles, - Files: files, - }) -} - -// hashGeneratedFiles hashes every generated C# file under directoryPath, keyed by its slash-separated -// path relative to it. A missing directory holds no files. -func hashGeneratedFiles(directoryPath string) (map[string]string, error) { - hashes := map[string]string{} - err := filepath.WalkDir(directoryPath, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - if os.IsNotExist(walkErr) && path == directoryPath { - return filepath.SkipDir - } - - return walkErr - } - if entry.IsDir() || filepath.Ext(path) != ".cs" { - return nil - } - relative, err := filepath.Rel(directoryPath, path) - if err != nil { - return err - } - hash, err := hashFile(path) - if err != nil { - return err - } - hashes[filepath.ToSlash(relative)] = hash - - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to read the generator output: %w", err) - } - - return hashes, nil -} - -// generatedFilesMatch reports whether the generator output on disk is exactly what the baseline -// recorded: no file missing, added, or changed. -// Why: the worker reads this output instead of running the generators, and checking without it -// reports errors wherever code uses what the generators produce. -func generatedFilesMatch(directoryPath string, recorded map[string]string) bool { - current, err := hashGeneratedFiles(directoryPath) - if err != nil || len(current) != len(recorded) { - return false - } - for name, hash := range recorded { - if current[name] != hash { - return false - } - } - - return true -} - -// baselineFiles pairs each source's content hash, as the input key read it, with its fingerprint. -func baselineFiles( - projectRoot string, unit CompileUnit, keyResult inputKeyResult, fingerprints fastcheck.FingerprintResponse, -) (map[string]baselineFile, error) { - files := make(map[string]baselineFile, len(unit.Sources)) - for _, source := range unit.Sources { - absolute := projectPath(projectRoot, source) - fingerprint, hasFingerprint := fingerprints.Fingerprints[absolute] - contentHash, hasHash := fingerprints.ContentHashes[absolute] - if !hasFingerprint || !hasHash { - return nil, fmt.Errorf("worker returned no fingerprint for %s", source) - } - if contentHash != keyResult.SourceHashes[source] { - return nil, fmt.Errorf("source changed during the compile: %s", source) - } - files[source] = baselineFile{ContentHash: contentHash, Fingerprint: fingerprint} - } - - return files, nil -} - -// writeBaselineRecord writes a baseline through a temporary file, like the manifest. -func writeBaselineRecord(outputDirectoryPath string, unit CompileUnit, record baselineRecord) error { - content, err := json.Marshal(record) - if err != nil { - return fmt.Errorf("failed to record the baseline of %s: %w", unit.Assembly.AssemblyName, err) - } - - return writeFileAtomically(outputDirectoryPath, baselineRecordPath(outputDirectoryPath, unit), content) -} - -// baselineRecordPath names where one unit's baseline lives. -func baselineRecordPath(outputDirectoryPath string, unit CompileUnit) string { - return filepath.Join(outputDirectoryPath, unit.Assembly.AssemblyName+baselineRecordExtension) -} - -// generatedDirectory names, relative to the project root, where a full compile saves the generator -// output of a unit. -func generatedDirectory(plan BuildPlan, unit CompileUnit) string { - return filepath.Join(plan.OutputDir, unit.Assembly.AssemblyName+generatedDirectoryExtension) -} - // bootstrappedWorker builds the worker on first use and calls it through fastcheck.Client. // Why the build is shared: units compile in parallel, and each building its own copy of the worker // on the first run would start one csc per unit for the same output. From 9a43fd0bc798a48a15a70da14c091188d67ea5c2 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 14 Oct 2026 23:14:15 +0900 Subject: [PATCH 05/33] fix: compile-check says why it compiled an assembly in full instead of checking only the changed files (#3320) --- .../CliOnlyTools~/CompileCheck/Skill/SKILL.md | 5 +- .../internal/compilecheck/baseline_record.go | 13 ++- .../compilecheck/baseline_record_test.go | 18 +++- .../internal/compilecheck/fast_path.go | 45 +++++++-- .../internal/compilecheck/fast_path_test.go | 68 ++++++++++++-- .../internal/compilecheck/input_key.go | 94 ++++++++++++++++--- .../internal/compilecheck/input_key_test.go | 59 ++++++++++-- .../internal/compilecheck/result_cache.go | 10 +- docs/compile-check.md | 34 ++++++- 9 files changed, 293 insertions(+), 53 deletions(-) diff --git a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md index 5ff4d5728f..01e601daf7 100644 --- a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md @@ -55,7 +55,10 @@ everything from scratch, which is the way out if a reused result ever looks wron In a large assembly whose last compile-check compile succeeded, an edit that keeps every declaration is checked on the changed files only, in a few seconds. Such a check skips a few -assembly-wide rules, so run once with `--full` before finishing. +assembly-wide rules, so run once with `--full` before finishing. The first body edit after a Unity +build, after a `--full` that had errors, after a run that skipped or blocked the assembly, or after +`--all` in an assembly that references another of the project is compiled in full and records what +the next check compares against; `FastPathFallbacks` says why. ## Parameters diff --git a/cli/dispatcher/internal/compilecheck/baseline_record.go b/cli/dispatcher/internal/compilecheck/baseline_record.go index 3eeedf6601..166b554141 100644 --- a/cli/dispatcher/internal/compilecheck/baseline_record.go +++ b/cli/dispatcher/internal/compilecheck/baseline_record.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" ) @@ -18,7 +19,7 @@ type baselineRecord struct { // InputKey is the key of the full compile this baseline describes; its manifest carries the same. InputKey string // NonSourceKey covers every input but the source contents, so any other change compiles in full. - NonSourceKey string + NonSourceKey nonSourceIdentity // WorkerKey names the worker build that computed the fingerprints. WorkerKey string GeneratedDirectory string // relative to the project root @@ -41,17 +42,19 @@ type baselineFile struct { // during the compile would otherwise enter the baseline as if that compile had seen it, and its // effect would never be checked by the next run. func (runner *fastPathRunner) RecordBaseline( - ctx context.Context, c Compiler, plan BuildPlan, unit CompileUnit, keyResult inputKeyResult, nonSourceKey string, + ctx context.Context, c Compiler, plan BuildPlan, unit CompileUnit, keyResult inputKeyResult, + nonSourceKey nonSourceIdentity, ) error { - if nonSourceKey == "" { + if nonSourceKey.Key == "" { return errors.New("inputs other than source contents could not be read before the compile") } currentNonSourceKey, err := computeNonSourceKey(c.ProjectRoot, c.Paths, plan, unit) if err != nil { return err } - if currentNonSourceKey != nonSourceKey { - return errors.New("inputs other than source contents changed during the compile") + if currentNonSourceKey.Key != nonSourceKey.Key { + return errors.New("inputs other than source contents changed during the compile: " + + strings.Join(changedInputKinds(currentNonSourceKey, nonSourceKey), ", ")) } worker, err := runner.Worker.Ensure(ctx) diff --git a/cli/dispatcher/internal/compilecheck/baseline_record_test.go b/cli/dispatcher/internal/compilecheck/baseline_record_test.go index 3d4ca7cd8a..347e0a75e5 100644 --- a/cli/dispatcher/internal/compilecheck/baseline_record_test.go +++ b/cli/dispatcher/internal/compilecheck/baseline_record_test.go @@ -263,7 +263,7 @@ func TestRecordBaselineRefusesAMissingNonSourceKey(t *testing.T) { } compiler := Compiler{Paths: project.paths, ProjectRoot: project.root} - err = project.runner(false).RecordBaseline(context.Background(), compiler, project.plan, unit, keyResult, "") + err = project.runner(false).RecordBaseline(context.Background(), compiler, project.plan, unit, keyResult, nonSourceIdentity{}) if err == nil || project.hasBaseline() { t.Errorf("expected no baseline without a non-source key, got %v", err) @@ -288,3 +288,19 @@ func TestHashGeneratedFilesRecordsOnlyCSharpFiles(t *testing.T) { t.Errorf("expected a missing directory to hold no files, got %v (%v)", missing, err) } } + +// Verifies that a baseline another version of compile-check wrote is not used, and says so rather +// than reporting that no full compile ran. +func TestBaselineOfAnotherFormatFallsBack(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + record := project.readBaseline(t) + record.FormatVersion = baselineRecordFormatVersion - 1 + project.writeJSON(t, project.baselinePath(), record) + project.editBody(t, 0, "edited") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "no baseline: it was recorded by another version of compile-check") +} diff --git a/cli/dispatcher/internal/compilecheck/fast_path.go b/cli/dispatcher/internal/compilecheck/fast_path.go index 7c4d7663d2..13ba460d51 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path.go +++ b/cli/dispatcher/internal/compilecheck/fast_path.go @@ -15,7 +15,7 @@ import ( ) const ( - baselineRecordFormatVersion = 2 + baselineRecordFormatVersion = 3 baselineRecordExtension = ".fastcheck.json" generatedDirectoryExtension = ".generated" fastCheckResponseFileSuffix = ".fastcheck.rsp" @@ -77,9 +77,10 @@ func (runner *fastPathRunner) shouldRecord(unit CompileUnit, result UnitResult) // Try checks only the changed files of a unit when the last full compile left a usable baseline, // and otherwise says why the unit has to be compiled in full. -// nonSourceKey is the unit's non-source key computed before anything ran, or "" when it could not be. +// nonSourceKey is the unit's non-source key computed before anything ran, empty when it could not be. func (runner *fastPathRunner) Try( - ctx context.Context, c Compiler, plan BuildPlan, unit CompileUnit, keyResult inputKeyResult, nonSourceKey string, + ctx context.Context, c Compiler, plan BuildPlan, unit CompileUnit, keyResult inputKeyResult, + nonSourceKey nonSourceIdentity, ) fastPathAttempt { outputDirectoryPath := filepath.Join(c.ProjectRoot, plan.OutputDir) record, manifest, reason := loadBaseline(outputDirectoryPath, unit) @@ -87,8 +88,12 @@ func (runner *fastPathRunner) Try( return fallBack(reason) } - if nonSourceKey == "" || nonSourceKey != record.NonSourceKey { - return fallBack("inputs other than source contents changed") + if nonSourceKey.Key == "" { + return fallBack("inputs other than source contents could not be read") + } + if nonSourceKey.Key != record.NonSourceKey.Key { + return fallBack("inputs other than source contents changed: " + + strings.Join(changedInputKinds(nonSourceKey, record.NonSourceKey), ", ")) } if !generatedFilesMatch(projectPath(c.ProjectRoot, record.GeneratedDirectory), record.GeneratedFiles) { @@ -104,7 +109,7 @@ func (runner *fastPathRunner) Try( } if len(changed) > fastPathMaximumChangedFiles { - return fallBack("too many changed files") + return fallBack(fmt.Sprintf("too many changed files (%d, limit %d)", len(changed), fastPathMaximumChangedFiles)) } return runner.check(ctx, c, plan, unit, record, manifest, changed) @@ -197,15 +202,14 @@ func buildCheckRequest( // loadBaseline reads the baseline of a unit and the manifest of the full compile it describes, or // says why there is none to use. func loadBaseline(outputDirectoryPath string, unit CompileUnit) (baselineRecord, unitResultManifest, string) { - const noBaseline = "no baseline from an earlier full compile" content, err := os.ReadFile(baselineRecordPath(outputDirectoryPath, unit)) if err != nil { - return baselineRecord{}, unitResultManifest{}, noBaseline + return baselineRecord{}, unitResultManifest{}, missingBaselineReason(outputDirectoryPath, unit) } var record baselineRecord if err := json.Unmarshal(content, &record); err != nil || record.FormatVersion != baselineRecordFormatVersion { - return baselineRecord{}, unitResultManifest{}, noBaseline + return baselineRecord{}, unitResultManifest{}, "no baseline: it was recorded by another version of compile-check" } // In the normal flow the record and the manifest are written together under one key, and a full @@ -226,6 +230,29 @@ func loadBaseline(outputDirectoryPath string, unit CompileUnit) (baselineRecord, return record, manifest, "" } +// missingBaselineReason says why a unit has no baseline, from the manifest of its last full compile. +// Why the reasons are told apart: a full compile with errors drops the baseline an earlier compile +// left, and a reason that read as "never compiled" sent users looking for a compile that had run. +// Why a missing manifest says nothing about what happened: a run that skips or blocks the unit +// removes the manifest and the baseline alike, so nothing on disk tells that apart from no compile. +func missingBaselineReason(outputDirectoryPath string, unit CompileUnit) string { + const notOnRecord = "no baseline: no full compile of this assembly is on record" + content, err := os.ReadFile(manifestPath(outputDirectoryPath, unit)) + if err != nil { + return notOnRecord + } + + var manifest unitResultManifest + if err := json.Unmarshal(content, &manifest); err != nil { + return notOnRecord + } + if !manifest.Succeeded { + return "no baseline: the last full compile had errors" + } + + return "no baseline: the last full compile did not record one" +} + // changedSources lists the sources whose content differs from the baseline, using the hashes the // input key read rather than reading the files again. func changedSources(unit CompileUnit, keyResult inputKeyResult, record baselineRecord) []string { diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go index a19f4f10ac..2c84756540 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -3,6 +3,7 @@ package compilecheck import ( "context" "errors" + "fmt" "os" "path/filepath" "reflect" @@ -93,7 +94,7 @@ func TestMissingBaselineFallsBackAndRecords(t *testing.T) { outcome := project.run(t, recorder, false) - assertFallback(t, outcome, recorder, "no baseline from an earlier full compile") + assertFallback(t, outcome, recorder, "no baseline: no full compile of this assembly is on record") record := project.readBaseline(t) if record.WorkerKey != "worker-a" || len(record.Files) != fastPathMinimumSources { t.Errorf("expected a baseline of every source from worker-a, got key %s with %d files", record.WorkerKey, len(record.Files)) @@ -147,7 +148,7 @@ func TestFailedFullCompileLeavesNoBaseline(t *testing.T) { recorder := newCachedSchedulerRecorder("Alpha", "Beta") outcome := project.run(t, recorder, false) - assertFallback(t, outcome, recorder, "no baseline from an earlier full compile") + assertFallback(t, outcome, recorder, "no baseline: the last full compile had errors") } // Verifies that a change to an input other than source contents, such as a new source, compiles in full. @@ -161,7 +162,7 @@ func TestNonSourceKeyMismatchFallsBack(t *testing.T) { outcome := project.run(t, recorder, false) - assertFallback(t, outcome, recorder, "inputs other than source contents changed") + assertFallback(t, outcome, recorder, "inputs other than source contents changed: source list") } // Verifies that an assembly with fewer sources than the minimum is never tried, so it compiles in @@ -192,7 +193,7 @@ func TestNonSourceInputChangedDuringCompileLeavesNoBaseline(t *testing.T) { outcome := project.run(t, recorder, false) - if note := resultOf(t, outcome.Units, "Alpha").FastPathNote; note != "baseline not recorded: inputs other than source contents changed during the compile" { + if note := resultOf(t, outcome.Units, "Alpha").FastPathNote; note != "baseline not recorded: inputs other than source contents changed during the compile: compiler" { t.Errorf("unexpected note %q", note) } if project.hasBaseline() { @@ -201,7 +202,7 @@ func TestNonSourceInputChangedDuringCompileLeavesNoBaseline(t *testing.T) { project.editBody(t, 0, "edited") second := newCachedSchedulerRecorder("Alpha", "Beta") - assertFallback(t, project.run(t, second, false), second, "no baseline from an earlier full compile") + assertFallback(t, project.run(t, second, false), second, "no baseline: the last full compile did not record one") } // Verifies that a baseline whose saved generator output was deleted or changed is not used, since @@ -252,7 +253,7 @@ func TestTooManyChangedFilesFallsBack(t *testing.T) { outcome := project.run(t, recorder, false) - assertFallback(t, outcome, recorder, "too many changed files") + assertFallback(t, outcome, recorder, "too many changed files (31, limit 30)") } // Verifies that a declaration change compiles in full and records a baseline with the new fingerprint. @@ -485,3 +486,58 @@ func TestFallbackReasonHasNoAbsolutePath(t *testing.T) { } assertFallback(t, outcome, recorder, "Unsupported: changed file is not a source: "+project.sourceName(0)+" near Library") } + +// Verifies that an assembly whose baseline was removed because a reference failed and blocked it +// falls back without claiming it was never compiled in full, since nothing on disk tells the two apart. +func TestBaselineRemovedByBlockedRunIsNotCalledNeverCompiled(t *testing.T) { + project := &fastPathProject{ + cachedSchedulerProject: newCachedSchedulerProject(t, map[string][]string{"Beta": {"Alpha"}}, "Alpha", "Beta"), + worker: newFakeFastCheckWorker(), + } + sources := make([]string, 0, fastPathMinimumSources) + for index := 0; index < fastPathMinimumSources; index++ { + source := fmt.Sprintf("Assets/Beta/File%03d.cs", index) + project.writeFile(t, source, fmt.Sprintf("class File%03d %s body", index, fakeDeclarationSeparator)) + sources = append(sources, source) + } + project.plan.Units[1].Sources = sources + project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + if _, err := os.Stat(filepath.Join(project.outputDirectory(), "Beta"+baselineRecordExtension)); err != nil { + t.Fatalf("expected the first run to record Beta's baseline: %v", err) + } + + project.editSource(t, "Alpha", "class Alpha { broken }") + failing := newCachedSchedulerRecorder("Alpha", "Beta") + failing.output["Alpha"] = failingCompilerOutput("Alpha") + assertStrings(t, "blocked", project.run(t, failing, false).Blocked, []string{"Beta"}) + + project.editSource(t, "Alpha", "class Alpha { }") + project.writeFile(t, sources[0], fmt.Sprintf("class File000 %s edited", fakeDeclarationSeparator)) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + outcome := project.run(t, recorder, false) + + if reason := resultOf(t, outcome.Units, "Beta").FastPathFallbackReason; reason != "no baseline: no full compile of this assembly is on record" { + t.Errorf("Beta fallback reason = %q", reason) + } +} + +// Verifies that a unit whose non-source key could not be read before the run falls back with a +// reason of its own rather than listing every kind of input as changed. +func TestUnreadableNonSourceKeyFallsBack(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + makeOutputDirectory(t, project.root, project.plan) + unit := project.plan.Units[0] + keyResult, err := computeInputKey(project.root, project.paths, project.plan, unit) + if err != nil { + t.Fatalf("expected a key, got error: %v", err) + } + compiler := Compiler{Paths: project.paths, ProjectRoot: project.root} + + attempt := project.runner(false).Try(context.Background(), compiler, project.plan, unit, keyResult, nonSourceIdentity{}) + + if attempt.Used || attempt.Reason != "inputs other than source contents could not be read" { + t.Errorf("expected the unreadable-inputs fallback, got %+v", attempt) + } +} diff --git a/cli/dispatcher/internal/compilecheck/input_key.go b/cli/dispatcher/internal/compilecheck/input_key.go index ee3aad834d..12faa0ebad 100644 --- a/cli/dispatcher/internal/compilecheck/input_key.go +++ b/cli/dispatcher/internal/compilecheck/input_key.go @@ -56,39 +56,105 @@ func computeInputKey( return inputKeyResult{Key: key, SourceHashes: sourceHashes}, nil } +// nonSourceIdentity is the non-source key of a unit together with a digest of each kind of input it +// covers, so a fallback can say which kind changed. +type nonSourceIdentity struct { + Key string + // Parts maps each kind of input, named as a fallback reason names it, to its digest. + Parts map[string]string +} + +// keyPart is one kind of input of a unit and how it goes into a digest. +type keyPart struct { + name string + write func(hash.Hash) error +} + // computeNonSourceKey digests the same inputs as the input key except the source contents, which it // replaces with the list of source paths. Two runs agreeing on it differ at most in what the // sources hold; adding or removing a source changes it. func computeNonSourceKey( projectRoot string, paths EditorCompilerPaths, plan BuildPlan, unit CompileUnit, -) (string, error) { - return computeKeyWith(projectRoot, paths, plan, unit, func(digest hash.Hash) error { +) (nonSourceIdentity, error) { + parts := keyParts(projectRoot, paths, plan, unit, func(digest hash.Hash) error { writeSourceNames(digest, unit.Sources) return nil }) + identity := nonSourceIdentity{Parts: make(map[string]string, len(parts))} + digest := sha256.New() + for _, part := range parts { + partDigest := sha256.New() + if err := part.write(partDigest); err != nil { + return nonSourceIdentity{}, err + } + partKey := hex.EncodeToString(partDigest.Sum(nil)) + identity.Parts[part.name] = partKey + _, _ = fmt.Fprintf(digest, "%s\x00%s\n", part.name, partKey) + } + identity.Key = hex.EncodeToString(digest.Sum(nil)) + + return identity, nil } -// computeKeyWith digests every input of one unit, letting the caller decide how the sources go in. +// changedInputKinds names, in a fixed order, the kinds of input whose digest differs between two +// non-source identities of the same unit. +func changedInputKinds(current nonSourceIdentity, recorded nonSourceIdentity) []string { + kinds := []string{} + for _, name := range keyPartNames { + if current.Parts[name] != recorded.Parts[name] { + kinds = append(kinds, name) + } + } + + return kinds +} + +// keyPartNames lists the kinds of input in the order keyParts digests them. +var keyPartNames = []string{ + "compiler", "response file", "source list", "references", "analyzers", "analyzer inputs", "output name", +} + +// keyParts lists every kind of input of one unit, letting the caller decide how the sources go in. +func keyParts( + projectRoot string, paths EditorCompilerPaths, plan BuildPlan, unit CompileUnit, + sourceWriter func(hash.Hash) error, +) []keyPart { + return []keyPart{ + {keyPartNames[0], func(digest hash.Hash) error { return writeCompilerIdentity(digest, paths) }}, + {keyPartNames[1], func(digest hash.Hash) error { return writeResponseFileIdentity(digest, unit.Assembly) }}, + {keyPartNames[2], sourceWriter}, + {keyPartNames[3], func(digest hash.Hash) error { + return writeReferenceIdentity(digest, projectRoot, plan, unit.Assembly.References) + }}, + {keyPartNames[4], func(digest hash.Hash) error { + return writeAnalyzerIdentity(digest, projectRoot, unit.Assembly.Analyzers) + }}, + {keyPartNames[5], func(digest hash.Hash) error { + return writeAnalyzerInputIdentity(digest, projectRoot, unit.Assembly) + }}, + {keyPartNames[6], func(digest hash.Hash) error { + writeOutputIdentity(digest, unit.Assembly) + + return nil + }}, + } +} + +// computeKeyWith digests every input of one unit into one digest, letting the caller decide how the +// sources go in. +// Why one digest rather than the parts: the input key names manifests that earlier runs wrote, and +// keeping its bytes as they were keeps those manifests reusable. func computeKeyWith( projectRoot string, paths EditorCompilerPaths, plan BuildPlan, unit CompileUnit, sourceWriter func(hash.Hash) error, ) (string, error) { digest := sha256.New() - writers := []func() error{ - func() error { return writeCompilerIdentity(digest, paths) }, - func() error { return writeResponseFileIdentity(digest, unit.Assembly) }, - func() error { return sourceWriter(digest) }, - func() error { return writeReferenceIdentity(digest, projectRoot, plan, unit.Assembly.References) }, - func() error { return writeAnalyzerIdentity(digest, projectRoot, unit.Assembly.Analyzers) }, - func() error { return writeAnalyzerInputIdentity(digest, projectRoot, unit.Assembly) }, - } - for _, write := range writers { - if err := write(); err != nil { + for _, part := range keyParts(projectRoot, paths, plan, unit, sourceWriter) { + if err := part.write(digest); err != nil { return "", err } } - writeOutputIdentity(digest, unit.Assembly) return hex.EncodeToString(digest.Sum(nil)), nil } diff --git a/cli/dispatcher/internal/compilecheck/input_key_test.go b/cli/dispatcher/internal/compilecheck/input_key_test.go index d17ccf9a0a..c564ba783d 100644 --- a/cli/dispatcher/internal/compilecheck/input_key_test.go +++ b/cli/dispatcher/internal/compilecheck/input_key_test.go @@ -353,7 +353,7 @@ func TestComputeInputKeyRefusesAKeyWhenTheResponseFileIsGone(t *testing.T) { } // nonSourceKey computes the non-source key of the unit under test. -func (project *inputKeyProject) nonSourceKey(t *testing.T) string { +func (project *inputKeyProject) nonSourceKey(t *testing.T) nonSourceIdentity { t.Helper() key, err := computeNonSourceKey(project.root, project.paths, project.plan, project.plan.Units[0]) if err != nil { @@ -370,7 +370,7 @@ func TestNonSourceKeyIgnoresSourceContent(t *testing.T) { writeProjectFile(t, project.root, filepath.Join("Assets", "Alpha", "Alpha.cs"), "class Alpha { void M() {} }") - if edited := project.nonSourceKey(t); edited != original { + if edited := project.nonSourceKey(t); edited.Key != original.Key { t.Error("editing a source should leave the non-source key alone") } } @@ -384,32 +384,75 @@ func TestNonSourceKeyChangesWhenSourceAddedOrRemoved(t *testing.T) { writeProjectFile(t, project.root, added, "class Added {}") project.plan.Units[0].Sources = append(project.plan.Units[0].Sources, added) - if withAdded := project.nonSourceKey(t); withAdded == original { + if withAdded := project.nonSourceKey(t); withAdded.Key == original.Key { t.Error("adding a source should move the non-source key") } project.plan.Units[0].Sources = []string{added} - if withRemoved := project.nonSourceKey(t); withRemoved == original { + if withRemoved := project.nonSourceKey(t); withRemoved.Key == original.Key { t.Error("removing a source should move the non-source key") } } -// Verifies the non-source key follows the inputs other than source contents: a reference and the -// response file. +// Verifies the non-source key follows the inputs other than source contents, a reference and the +// response file, and names which kind of input moved. func TestNonSourceKeyChangesWithReferenceAndRsp(t *testing.T) { project := newInputKeyProject(t) original := project.nonSourceKey(t) touchProjectFile(t, project.root, filepath.Join(planDagDirectory, "Gamma"+referenceAssemblyExtension)) touched := project.nonSourceKey(t) - if touched == original { + if touched.Key == original.Key { t.Error("a rebuilt reference should move the non-source key") } + if kinds := changedInputKinds(touched, original); !reflect.DeepEqual(kinds, []string{"references"}) { + t.Errorf("changed kinds = %v, want [references]", kinds) + } writeProjectFile(t, project.root, filepath.Join(planDagDirectory, "Alpha.rsp"), "-out:\"Alpha.dll\"\n-define:EXTRA\n") - if edited := project.nonSourceKey(t); edited == touched { + edited := project.nonSourceKey(t) + if edited.Key == touched.Key { t.Error("a changed response file should move the non-source key") } + if kinds := changedInputKinds(edited, touched); !reflect.DeepEqual(kinds, []string{"response file"}) { + t.Errorf("changed kinds = %v, want [response file]", kinds) + } +} + +// Verifies the input key still digests every input into one stream in the order it always did, so +// manifests written before the non-source key was split into parts stay reusable. +func TestInputKeyDigestsEveryPartInOneStream(t *testing.T) { + project := newInputKeyProject(t) + unit := project.plan.Units[0] + result, err := computeInputKey(project.root, project.paths, project.plan, unit) + if err != nil { + t.Fatalf("expected a key, got error: %v", err) + } + + digest := sha256.New() + if err := writeCompilerIdentity(digest, project.paths); err != nil { + t.Fatalf("compiler: %v", err) + } + if err := writeResponseFileIdentity(digest, unit.Assembly); err != nil { + t.Fatalf("response file: %v", err) + } + if err := writeSourceIdentity(digest, project.root, unit.Sources, map[string]string{}); err != nil { + t.Fatalf("sources: %v", err) + } + if err := writeReferenceIdentity(digest, project.root, project.plan, unit.Assembly.References); err != nil { + t.Fatalf("references: %v", err) + } + if err := writeAnalyzerIdentity(digest, project.root, unit.Assembly.Analyzers); err != nil { + t.Fatalf("analyzers: %v", err) + } + if err := writeAnalyzerInputIdentity(digest, project.root, unit.Assembly); err != nil { + t.Fatalf("analyzer inputs: %v", err) + } + writeOutputIdentity(digest, unit.Assembly) + + if want := hex.EncodeToString(digest.Sum(nil)); result.Key != want { + t.Errorf("input key = %s, want %s", result.Key, want) + } } // Verifies the input key returns the sha256 of each source it read, keyed as unit.Sources spells it. diff --git a/cli/dispatcher/internal/compilecheck/result_cache.go b/cli/dispatcher/internal/compilecheck/result_cache.go index 64f13b7650..c3b51561d6 100644 --- a/cli/dispatcher/internal/compilecheck/result_cache.go +++ b/cli/dispatcher/internal/compilecheck/result_cache.go @@ -91,17 +91,17 @@ func (c Compiler) reuseOrCompile( return result, nil } -// nonSourceKeyBeforeCompile reads a unit's non-source key for the changed-files check, or "" when -// there is no check or no key. +// nonSourceKeyBeforeCompile reads a unit's non-source key for the changed-files check, or an empty +// one when there is no check or no key. // Why it is read before anything runs: a baseline must describe the inputs the compile saw, and // reading it after the compile would let a change made during it in unchecked. -func (c Compiler) nonSourceKeyBeforeCompile(plan BuildPlan, unit CompileUnit, key string) string { +func (c Compiler) nonSourceKeyBeforeCompile(plan BuildPlan, unit CompileUnit, key string) nonSourceIdentity { if c.FastPath == nil || key == "" { - return "" + return nonSourceIdentity{} } nonSourceKey, err := computeNonSourceKey(c.ProjectRoot, c.Paths, plan, unit) if err != nil { - return "" + return nonSourceIdentity{} } return nonSourceKey diff --git a/docs/compile-check.md b/docs/compile-check.md index 3b2dfba1c8..fef493c1e4 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -148,12 +148,21 @@ are left as they were. Otherwise the assembly is compiled in full as before, and `FastPathFallbacks` says why: -- `no baseline from an earlier full compile` +- `no baseline: no full compile of this assembly is on record` (none ran yet, or a later run + skipped or blocked the assembly, which removes its result and baseline) +- `no baseline: the last full compile had errors` (a full compile that fails drops the baseline an + earlier one left) +- `no baseline: the last full compile did not record one` (`FastPathBaselineNotes` of that run + says why, or the assembly was compiled only because a reference changed) +- `no baseline: it was recorded by another version of compile-check` - `baseline compile is no longer on disk` -- `inputs other than source contents changed` +- `inputs other than source contents could not be read` +- `inputs other than source contents changed: `, naming which of `compiler`, + `response file`, `source list`, `references`, `analyzers`, `analyzer inputs`, and `output name` + changed - `generator output of the baseline is gone` - `baseline record does not match its compile result` -- `too many changed files` +- `too many changed files (, limit 30)` - `worker unavailable: ...` (the worker could not be built; a build that failed is not retried until the worker sources or the toolchain change) - `baseline was fingerprinted by another worker build` @@ -166,11 +175,28 @@ A full compile that succeeds records a new baseline, `--full` runs included, so use the check again. A source or another input edited while csc was compiling leaves no baseline, and `FastPathBaselineNotes` says why a successful full compile left none. +So the first body edit is compiled in full more often than it looks: + +- Right after a Unity build, compile-check finds nothing to compile and records nothing. The first + body edit after that is compiled in full and records the baseline; the edits after it are checked. +- A run with `--all` records baselines too, but it rebuilds the assemblies the next ordinary run + takes from Unity's build instead, so references to them are read differently. In an assembly + that references another assembly of the project, the first body edit after `--all` falls back + with `inputs other than source contents changed: references`; an assembly that references none + is checked as usual. +- A run that skips an assembly because nothing it compiles from changed, or blocks it because an + assembly it references has errors, removes its result and baseline, so its next body edit is + compiled in full. +- `--full` while the assembly still has errors drops its baseline, so the run after the fix is + compiled in full. Running `--full` once the errors are fixed keeps it. + What the check does not guarantee, so run `--full` before finishing: - CS0067, CS0169, CS0414, and CS0649, which the compiler reports only after it has seen the whole assembly, and the rules of analyzers that use Compilation, CompilationEnd, SemanticModel, or - SymbolStart actions. `FastChecks[].UncheckedCodes` lists the IDs it could not run. + SymbolStart actions. `FastChecks[].UncheckedCodes` lists the IDs it could not run, leaving out + the ones the response file already turns off (with `-nowarn` or a ruleset) and analyzer rules + that are disabled, so an assembly may list fewer than these four. - Errors the compiler reports only while emitting, such as CS8178 and CS0656. - Source generators. The check reads the generator output saved by the last full compile and does not run them again; `FastChecks[].GeneratorsNotRerun` says so. From b3071f0402ff9df1cfb067738eb7f607646cc2b8 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 01:13:50 +0900 Subject: [PATCH 06/33] perf: compile-check reads the project's directories once per run (#3321) --- .../compilecheck/asmdef_contract_test.go | 2 +- .../compilecheck/assembly_reference.go | 51 +--- .../compilecheck/assembly_reference_test.go | 7 +- .../compilecheck/compilecheck_edges_test.go | 4 +- .../compilecheck/local_packages_test.go | 6 +- cli/dispatcher/internal/compilecheck/plan.go | 11 +- .../internal/compilecheck/project_tree.go | 181 +++++++++++++ .../compilecheck/project_tree_index_test.go | 133 ++++++++++ .../compilecheck/project_tree_sources_test.go | 246 ++++++++++++++++++ .../compilecheck/project_tree_test.go | 169 ++++++++++++ .../internal/compilecheck/source_rescue.go | 10 +- .../internal/compilecheck/source_set.go | 104 +++++--- .../internal/compilecheck/source_set_test.go | 13 +- 13 files changed, 831 insertions(+), 106 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/project_tree.go create mode 100644 cli/dispatcher/internal/compilecheck/project_tree_index_test.go create mode 100644 cli/dispatcher/internal/compilecheck/project_tree_sources_test.go create mode 100644 cli/dispatcher/internal/compilecheck/project_tree_test.go diff --git a/cli/dispatcher/internal/compilecheck/asmdef_contract_test.go b/cli/dispatcher/internal/compilecheck/asmdef_contract_test.go index cd08d2dbd6..73c7838a8c 100644 --- a/cli/dispatcher/internal/compilecheck/asmdef_contract_test.go +++ b/cli/dispatcher/internal/compilecheck/asmdef_contract_test.go @@ -47,7 +47,7 @@ func newContractProject( if err != nil { t.Fatalf("failed to load the assembly graph: %v", err) } - assemblyDefinitions, err := IndexAssemblyDefinitions(projectRoot) + assemblyDefinitions, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) if err != nil { t.Fatalf("failed to index the assembly definitions: %v", err) } diff --git a/cli/dispatcher/internal/compilecheck/assembly_reference.go b/cli/dispatcher/internal/compilecheck/assembly_reference.go index 3a8d959643..6ba772c221 100644 --- a/cli/dispatcher/internal/compilecheck/assembly_reference.go +++ b/cli/dispatcher/internal/compilecheck/assembly_reference.go @@ -2,7 +2,6 @@ package compilecheck import ( "fmt" - "io/fs" "path/filepath" "sort" ) @@ -18,17 +17,19 @@ type AssemblyReference struct { } // IndexAssemblyReferences lists every .asmref in the project, ordered by path. -func IndexAssemblyReferences(projectRoot string) ([]AssemblyReference, error) { +func IndexAssemblyReferences(tree projectTree) ([]AssemblyReference, error) { references := []AssemblyReference{} - for _, root := range projectIndexRoots(projectRoot) { - if !directoryExists(root) { - continue + for _, root := range tree.roots { + for _, path := range root.referenceFiles { + reference, readErr := readAssemblyReference(path) + if readErr != nil { + return nil, fmt.Errorf(assemblyReferenceIndexError, root.path, readErr) + } + references = append(references, reference) } - found, err := indexAssemblyReferencesUnder(root) - if err != nil { - return nil, fmt.Errorf(assemblyReferenceIndexError, root, err) + if root.walkErr != nil { + return nil, fmt.Errorf(assemblyReferenceIndexError, root.path, root.walkErr) } - references = append(references, found...) } sort.Slice(references, func(first int, second int) bool { return references[first].Path < references[second].Path @@ -37,38 +38,6 @@ func IndexAssemblyReferences(projectRoot string) ([]AssemblyReference, error) { return references, nil } -// indexAssemblyReferencesUnder collects every .asmref found below one project root. -func indexAssemblyReferencesUnder(root string) ([]AssemblyReference, error) { - references := []AssemblyReference{} - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() { - if path != root && isUnityIgnoredName(entry.Name()) { - return filepath.SkipDir - } - - return nil - } - if isUnityIgnoredName(entry.Name()) || filepath.Ext(path) != assemblyReferenceExtension { - return nil - } - reference, readErr := readAssemblyReference(path) - if readErr != nil { - return readErr - } - references = append(references, reference) - - return nil - }) - if err != nil { - return nil, err - } - - return references, nil -} - // readAssemblyReference reads the assembly one .asmref names. func readAssemblyReference(path string) (AssemblyReference, error) { var content struct { diff --git a/cli/dispatcher/internal/compilecheck/assembly_reference_test.go b/cli/dispatcher/internal/compilecheck/assembly_reference_test.go index c3f204df6b..1daf3ad2ef 100644 --- a/cli/dispatcher/internal/compilecheck/assembly_reference_test.go +++ b/cli/dispatcher/internal/compilecheck/assembly_reference_test.go @@ -51,15 +51,16 @@ func detectReferenceChange( t *testing.T, projectRoot string, graph assemblyGraph, context AssemblyContext, ) error { t.Helper() - references, err := IndexAssemblyReferences(projectRoot) + tree := walkProjectTree(projectRoot) + references, err := IndexAssemblyReferences(tree) if err != nil { t.Fatalf("failed to index the assembly references: %v", err) } - definitions, definitionErr := IndexAssemblyDefinitions(projectRoot) + definitions, definitionErr := IndexAssemblyDefinitions(tree) if definitionErr != nil { t.Fatalf("failed to index the assembly definitions: %v", definitionErr) } - owners := newAssemblyOwnerIndex(definitions, references, context) + owners := newAssemblyOwnerIndex(definitions, references, context, tree) return DetectAssemblyReferenceChange(projectRoot, graph, context, references, owners) } diff --git a/cli/dispatcher/internal/compilecheck/compilecheck_edges_test.go b/cli/dispatcher/internal/compilecheck/compilecheck_edges_test.go index 8b4d7f9d02..5f512fdc48 100644 --- a/cli/dispatcher/internal/compilecheck/compilecheck_edges_test.go +++ b/cli/dispatcher/internal/compilecheck/compilecheck_edges_test.go @@ -93,7 +93,7 @@ func TestIndexAssemblyReferencesReportsAMalformedAsmref(t *testing.T) { path := filepath.Join(projectRoot, "Assets", "Broken", "Broken.asmref") writeFileAt(t, path, "not json") - _, err := IndexAssemblyReferences(projectRoot) + _, err := IndexAssemblyReferences(walkProjectTree(projectRoot)) if err == nil || !strings.HasPrefix(err.Error(), "failed to index assembly references under ") || !strings.Contains(err.Error(), "failed to read "+path) { t.Fatalf("err = %v", err) @@ -140,7 +140,7 @@ func TestIndexAssemblyDefinitionsReportsUnusableAsmdefs(t *testing.T) { path := filepath.Join(projectRoot, "Assets", "Broken", "Broken.asmdef") writeFileAt(t, path, content) - _, err := IndexAssemblyDefinitions(projectRoot) + _, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) if err == nil || !strings.HasPrefix(err.Error(), "failed to index ") || !strings.Contains(err.Error(), path) || !strings.Contains(err.Error(), wantMessage) { t.Fatalf("content %q: err = %v, want %q", content, err, wantMessage) diff --git a/cli/dispatcher/internal/compilecheck/local_packages_test.go b/cli/dispatcher/internal/compilecheck/local_packages_test.go index 5de4ee4de7..e444cb1ac7 100644 --- a/cli/dispatcher/internal/compilecheck/local_packages_test.go +++ b/cli/dispatcher/internal/compilecheck/local_packages_test.go @@ -51,7 +51,7 @@ func newLocalPackageProject(t *testing.T) (string, string) { func TestIndexAssemblyDefinitionsFindsLocallyReferencedPackages(t *testing.T) { projectRoot, packageRoot := newLocalPackageProject(t) - assemblyDefinitions, err := IndexAssemblyDefinitions(projectRoot) + assemblyDefinitions, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) if err != nil { t.Fatalf("failed to index the assembly definitions: %v", err) } @@ -85,7 +85,7 @@ func TestRebuildSourcesKeepsAbsolutePathsOfALocalPackage(t *testing.T) { if err != nil { t.Fatalf("failed to load the assembly graph: %v", err) } - assemblyDefinitions, err := IndexAssemblyDefinitions(projectRoot) + assemblyDefinitions, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) if err != nil { t.Fatalf("failed to index the assembly definitions: %v", err) } @@ -140,7 +140,7 @@ func TestIndexAssemblyDefinitionsReadsAnAssemblyDefinitionWithAByteOrderMark(t * writeFileAt(t, filepath.Join(projectRoot, "Assets", "A", "A.asmdef"), byteOrderMark+`{"name":"A"}`) - assemblyDefinitions, err := IndexAssemblyDefinitions(projectRoot) + assemblyDefinitions, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) if err != nil { t.Fatalf("expected the byte order mark to be tolerated, got error: %v", err) } diff --git a/cli/dispatcher/internal/compilecheck/plan.go b/cli/dispatcher/internal/compilecheck/plan.go index 20a4315eae..6623d9e3e1 100644 --- a/cli/dispatcher/internal/compilecheck/plan.go +++ b/cli/dispatcher/internal/compilecheck/plan.go @@ -163,7 +163,10 @@ func listResponseFiles(dagDirectoryPath string) ([]string, error) { func selectChangedAssemblies( projectRoot string, dagDir string, graph assemblyGraph, all bool, ) (map[string]string, error) { - assemblyDefinitions, err := IndexAssemblyDefinitions(projectRoot) + // Why the project is walked once here: the .asmdef index, the .asmref index and every source + // rebuild below all read the same directories, and walking them for each would repeat the walk. + tree := walkProjectTree(projectRoot) + assemblyDefinitions, err := IndexAssemblyDefinitions(tree) if err != nil { return nil, err } @@ -173,14 +176,14 @@ func selectChangedAssemblies( } // Why the .asmref files are indexed here: both the reference check and every source rebuild need - // them, and walking the project once per assembly would repeat the same walk for each of them. - references, referenceIndexErr := IndexAssemblyReferences(projectRoot) + // them. + references, referenceIndexErr := IndexAssemblyReferences(tree) if referenceIndexErr != nil { return nil, referenceIndexErr } context := NewAssemblyContext(graph, assemblyDefinitions) - owners := newAssemblyOwnerIndex(assemblyDefinitions, references, context) + owners := newAssemblyOwnerIndex(assemblyDefinitions, references, context, tree) if referenceErr := DetectAssemblyReferenceChange( projectRoot, graph, context, references, owners); referenceErr != nil { return nil, referenceErr diff --git a/cli/dispatcher/internal/compilecheck/project_tree.go b/cli/dispatcher/internal/compilecheck/project_tree.go new file mode 100644 index 0000000000..c1f6d4083f --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/project_tree.go @@ -0,0 +1,181 @@ +package compilecheck + +import ( + "io/fs" + "path/filepath" +) + +// projectTree is a listing of the project's index roots taken by one walk at the start of a run. +// Why one walk: indexing the .asmdef files, indexing the .asmref files and re-globbing every +// assembly's sources each walked the same directories again, and on a project of tens of thousands +// of files those walks cost more than the rest of planning together. +// It answers only what the walk saw. A path it does not list is not proof of absence - a file system +// that ignores case or Unicode normalization finds a name spelled differently, and a symbolic link +// can point at nothing - so callers fall back to the disk for every negative answer. +type projectTree struct { + roots []projectTreeRoot + directories map[string]projectTreeDirectory // cleaned absolute path -> its listing +} + +// projectTreeRoot is what the walk found below one index root, in the order it met it. +type projectTreeRoot struct { + path string + definitionFiles []string // .asmdef files, in walk order + referenceFiles []string // .asmref files, in walk order + walkErr error // the error that stopped the walk of this root, if any +} + +// projectTreeDirectory is one directory as the walk listed it, without the names Unity ignores. +type projectTreeDirectory struct { + entries []projectTreeEntry // in walk order, which is name order + byName map[string]projectTreeEntry +} + +// projectTreeEntry is one listed name. +type projectTreeEntry struct { + name string + isDirectory bool + isRegular bool // a regular file, as opposed to a directory, a symbolic link or a device +} + +// walkProjectTree walks each index root once, in root order. +// Why the walk stops at the first root that fails: indexing stopped there before, so a later root +// was never read, and an error found there must not take the place of the one found first. +// Why the roots are walked one after another rather than in parallel: the .asmdef index keeps the +// first of two assemblies declaring the same name, so the order across roots decides the winner. +func walkProjectTree(projectRoot string) projectTree { + tree := projectTree{directories: map[string]projectTreeDirectory{}} + for _, rootPath := range projectIndexRoots(projectRoot) { + if !directoryExists(rootPath) { + continue + } + root, directories := walkProjectTreeRoot(rootPath) + tree.roots = append(tree.roots, root) + if root.walkErr != nil { + break + } + for path, directory := range directories { + tree.directories[path] = directory + } + } + + return tree +} + +// walkProjectTreeRoot lists one root with the same rules the indexes walked it with: names Unity +// ignores are left out, and symbolic links are listed but not followed. +func walkProjectTreeRoot(rootPath string) (projectTreeRoot, map[string]projectTreeDirectory) { + root := projectTreeRoot{path: rootPath} + directories := map[string]projectTreeDirectory{} + root.walkErr = filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == rootPath { + if entry.IsDir() { + directories[filepath.Clean(path)] = newProjectTreeDirectory() + } + + return nil + } + if isUnityIgnoredName(entry.Name()) { + if entry.IsDir() { + return filepath.SkipDir + } + + return nil + } + addProjectTreeEntry(directories, path, entry) + if entry.IsDir() { + directories[filepath.Clean(path)] = newProjectTreeDirectory() + + return nil + } + switch filepath.Ext(path) { + case assemblyDefinitionExtension: + root.definitionFiles = append(root.definitionFiles, path) + case assemblyReferenceExtension: + root.referenceFiles = append(root.referenceFiles, path) + } + + return nil + }) + + return root, directories +} + +// newProjectTreeDirectory starts the listing of one directory. +func newProjectTreeDirectory() projectTreeDirectory { + return projectTreeDirectory{byName: map[string]projectTreeEntry{}} +} + +// addProjectTreeEntry records one name in the listing of its parent. +// Why a missing parent listing is started here: the walk lists a parent before its children, but +// the parent's key is derived by cleaning the child's path, which need not match the key the root +// was stored under - a Windows share root is one such spelling - and writing to a listing that was +// never started would panic. +func addProjectTreeEntry(directories map[string]projectTreeDirectory, path string, entry fs.DirEntry) { + parentPath := filepath.Dir(filepath.Clean(path)) + parent, started := directories[parentPath] + if !started { + parent = newProjectTreeDirectory() + } + listed := projectTreeEntry{ + name: entry.Name(), + isDirectory: entry.IsDir(), + isRegular: entry.Type().IsRegular(), + } + parent.entries = append(parent.entries, listed) + parent.byName[listed.name] = listed + directories[parentPath] = parent +} + +// directory returns the listing of a directory the walk went through. +func (tree projectTree) directory(path string) (projectTreeDirectory, bool) { + directory, listed := tree.directories[filepath.Clean(path)] + + return directory, listed +} + +// hasRegularFile reports whether the walk listed a regular file at exactly this path. A false +// answer means only that the walk did not see one under this spelling. +func (tree projectTree) hasRegularFile(path string) bool { + directory, listed := tree.directory(filepath.Dir(path)) + if !listed { + return false + } + entry, found := directory.byName[filepath.Base(path)] + + return found && entry.isRegular +} + +// fileExists answers what fileExists answers on the disk, from the listing when it holds a regular +// file under exactly this spelling. Every other case - a name the listing lacks, a path outside it, +// an entry that is a symbolic link - asks the disk, because only the disk knows whether a differently +// spelled or linked name resolves to a file. +func (tree projectTree) fileExists(path string) bool { + return tree.hasRegularFile(path) || fileExists(path) +} + +// directoryHoldsAssemblyDefinition reports whether a directory declares an assembly of its own, from +// the listing when the walk went through the directory and from the disk otherwise. +func (tree projectTree) directoryHoldsAssemblyDefinition(path string) bool { + listing, listed := tree.directory(path) + if !listed { + return directoryHoldsAssemblyDefinition(path) + } + + return listing.holdsAssemblyDefinition() +} + +// holdsAssemblyDefinition reports whether a listed directory holds an .asmdef. Any entry that is not +// a directory counts, as it did when the directory was read from the disk. +func (directory projectTreeDirectory) holdsAssemblyDefinition() bool { + for _, entry := range directory.entries { + if !entry.isDirectory && filepath.Ext(entry.name) == assemblyDefinitionExtension { + return true + } + } + + return false +} diff --git a/cli/dispatcher/internal/compilecheck/project_tree_index_test.go b/cli/dispatcher/internal/compilecheck/project_tree_index_test.go new file mode 100644 index 0000000000..f270a1e583 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/project_tree_index_test.go @@ -0,0 +1,133 @@ +package compilecheck + +import ( + "path/filepath" + "strings" + "testing" +) + +// Verifies that of two .asmdef files declaring the same assembly, the one the walk meets first wins: +// root order first, then the walk order inside a root, which is not the order of the full paths. +func TestIndexAssemblyDefinitionsKeepsTheFirstDuplicateInWalkOrder(t *testing.T) { + projectRoot := t.TempDir() + // "a" is walked before "a.b", while the full path ".../a.b/Y.asmdef" sorts before ".../a/X.asmdef". + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a", "X.asmdef"), `{"name":"SiblingDup"}`) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a.b", "Y.asmdef"), `{"name":"SiblingDup"}`) + // The directory "A" is walked before the file "A.asmdef" beside it, which sorts first as a path. + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Z", "A", "X.asmdef"), `{"name":"NestedDup"}`) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Z", "A.asmdef"), `{"name":"NestedDup"}`) + // Packages is walked before the package cache, whose path "Library/..." sorts first. + writeFileAt(t, filepath.Join(projectRoot, "Packages", "Dup", "Dup.asmdef"), `{"name":"RootDup"}`) + writeFileAt(t, filepath.Join(projectRoot, "Library", "PackageCache", "pkg", "Dup", "Dup.asmdef"), `{"name":"RootDup"}`) + + index, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) + if err != nil { + t.Fatalf("IndexAssemblyDefinitions returned an error: %v", err) + } + + want := map[string]string{ + "SiblingDup": filepath.Join(projectRoot, "Assets", "a", "X.asmdef"), + "NestedDup": filepath.Join(projectRoot, "Assets", "Z", "A", "X.asmdef"), + "RootDup": filepath.Join(projectRoot, "Packages", "Dup", "Dup.asmdef"), + } + for name, wantPath := range want { + if got := index[name].Path; got != wantPath { + t.Fatalf("%s resolved to %s, want %s", name, got, wantPath) + } + } +} + +// Verifies a broken .asmdef in an earlier root is reported ahead of a directory a later root cannot +// read, because indexing reads each root's files before it reports that root's walk error. +func TestIndexAssemblyDefinitionsReportsAnEarlierRootsBrokenAsmdefFirst(t *testing.T) { + projectRoot := t.TempDir() + broken := filepath.Join(projectRoot, "Assets", "Broken", "Broken.asmdef") + writeFileAt(t, broken, "not json") + lockDirectory(t, filepath.Join(projectRoot, "Packages", "Locked")) + + _, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) + + wantPrefix := "failed to index assembly definitions under " + filepath.Join(projectRoot, "Assets") + ": failed to read " + broken + if err == nil || !strings.HasPrefix(err.Error(), wantPrefix) { + t.Fatalf("err = %v, want it to start with %q", err, wantPrefix) + } +} + +// Verifies that inside one root a broken .asmdef the walk meets before a directory it cannot read is +// reported, not the unreadable directory. +func TestIndexAssemblyDefinitionsReportsABrokenAsmdefMetBeforeAnUnreadableDirectory(t *testing.T) { + projectRoot := t.TempDir() + broken := filepath.Join(projectRoot, "Assets", "A", "Broken.asmdef") + writeFileAt(t, broken, "not json") + lockDirectory(t, filepath.Join(projectRoot, "Assets", "Z_Locked")) + + _, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) + + wantPrefix := "failed to index assembly definitions under " + filepath.Join(projectRoot, "Assets") + ": failed to read " + broken + if err == nil || !strings.HasPrefix(err.Error(), wantPrefix) { + t.Fatalf("err = %v, want it to start with %q", err, wantPrefix) + } +} + +// Verifies a directory an earlier root cannot read is reported ahead of a broken .asmdef in a later +// root, which indexing never reached. +func TestIndexAssemblyDefinitionsReportsAnEarlierRootsWalkErrorFirst(t *testing.T) { + projectRoot := t.TempDir() + lockDirectory(t, filepath.Join(projectRoot, "Assets", "Locked")) + writeFileAt(t, filepath.Join(projectRoot, "Packages", "Broken", "Broken.asmdef"), "not json") + + _, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) + + wantPrefix := "failed to index assembly definitions under " + filepath.Join(projectRoot, "Assets") + ": " + if err == nil || !strings.HasPrefix(err.Error(), wantPrefix) || !strings.Contains(err.Error(), "Locked") { + t.Fatalf("err = %v, want the unreadable Assets directory reported", err) + } +} + +// Verifies a directory that cannot be read, with nothing else wrong, fails the index under its root. +func TestIndexAssemblyDefinitionsReportsAnUnreadableDirectory(t *testing.T) { + projectRoot := t.TempDir() + writeFileAt(t, filepath.Join(projectRoot, "Assets", "A", "A.asmdef"), `{"name":"A"}`) + lockDirectory(t, filepath.Join(projectRoot, "Packages", "Locked")) + + _, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) + + wantPrefix := "failed to index assembly definitions under " + filepath.Join(projectRoot, "Packages") + ": " + if err == nil || !strings.HasPrefix(err.Error(), wantPrefix) || !strings.Contains(err.Error(), "Locked") { + t.Fatalf("err = %v, want the unreadable Packages directory reported", err) + } +} + +// Verifies .asmref files are listed in path order whatever root and walk order they were met in. +func TestIndexAssemblyReferencesSortsByPath(t *testing.T) { + projectRoot := t.TempDir() + writeFileAt(t, filepath.Join(projectRoot, "Packages", "P", "P.asmref"), `{"reference":"P"}`) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a.b", "B.asmref"), `{"reference":"B"}`) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a", "A.asmref"), `{"reference":"A"}`) + + references, err := IndexAssemblyReferences(walkProjectTree(projectRoot)) + if err != nil { + t.Fatalf("IndexAssemblyReferences returned an error: %v", err) + } + + got := []string{} + for _, reference := range references { + got = append(got, reference.Reference) + } + if strings.Join(got, ",") != "B,A,P" { + t.Fatalf("references = %v, want B,A,P (path order)", got) + } +} + +// Verifies a directory that cannot be read fails the .asmref index under its root. +func TestIndexAssemblyReferencesReportsAnUnreadableDirectory(t *testing.T) { + projectRoot := t.TempDir() + lockDirectory(t, filepath.Join(projectRoot, "Assets", "Locked")) + + _, err := IndexAssemblyReferences(walkProjectTree(projectRoot)) + + wantPrefix := "failed to index assembly references under " + filepath.Join(projectRoot, "Assets") + ": " + if err == nil || !strings.HasPrefix(err.Error(), wantPrefix) { + t.Fatalf("err = %v, want the unreadable Assets directory reported", err) + } +} diff --git a/cli/dispatcher/internal/compilecheck/project_tree_sources_test.go b/cli/dispatcher/internal/compilecheck/project_tree_sources_test.go new file mode 100644 index 0000000000..6c7d31523f --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/project_tree_sources_test.go @@ -0,0 +1,246 @@ +package compilecheck + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +// requireCaseInsensitiveFileSystem skips the test unless the temporary directory finds a file under +// a name that differs from it only in case. +func requireCaseInsensitiveFileSystem(t *testing.T) { + t.Helper() + probe := filepath.Join(t.TempDir(), "CaseProbe") + writeFileAt(t, probe, "") + if !fileExists(filepath.Join(filepath.Dir(probe), "caseprobe")) { + t.Skip("the file system tells names apart by case") + } +} + +// rebuildLooseSources rebuilds the sources of an assembly without an .asmdef, through the listing +// one run takes of the project. +func rebuildLooseSources(t *testing.T, projectRoot string, sources []string) []string { + t.Helper() + got, err := RebuildSources(projectRoot, ResponseFile{Sources: sources}, nil, newOwnerIndexForProject(t, projectRoot)) + if err != nil { + t.Fatalf("RebuildSources returned an error: %v", err) + } + + return got +} + +// Verifies the sources of a package-cache assembly keep the files that still exist and drop the +// ones deleted since the build, without a re-glob. +func TestRebuildSourcesKeepsExistingPackageCacheSources(t *testing.T) { + projectRoot := t.TempDir() + directory := filepath.Join(projectRoot, "Library", "PackageCache", "pkg") + writeFileAt(t, filepath.Join(directory, "Pkg.asmdef"), `{"name":"Pkg"}`) + writeFileAt(t, filepath.Join(directory, "Kept.cs"), "") + writeFileAt(t, filepath.Join(directory, "Unrecorded.cs"), "") + asmdef := AssemblyDefinition{Name: "Pkg", Path: filepath.Join(directory, "Pkg.asmdef"), Directory: directory} + rsp := ResponseFile{Sources: []string{"Library/PackageCache/pkg/Kept.cs", "Library/PackageCache/pkg/Deleted.cs"}} + + sources, err := RebuildSources(projectRoot, rsp, &asmdef, newOwnerIndexForProject(t, projectRoot)) + if err != nil { + t.Fatalf("RebuildSources returned an error: %v", err) + } + + if want := []string{"Library/PackageCache/pkg/Kept.cs"}; !reflect.DeepEqual(sources, want) { + t.Fatalf("sources = %v, want %v", sources, want) + } +} + +// Verifies a recorded source reached through a symbolic link to a directory still counts as there, +// although the walk does not descend through the link. +func TestRebuildSourcesKeepsASourceBehindALinkedDirectory(t *testing.T) { + projectRoot := t.TempDir() + outside := t.TempDir() + writeFileAt(t, filepath.Join(outside, "A.cs"), "") + symlinkOrSkip(t, outside, filepath.Join(projectRoot, "Assets", "Linked")) + + sources := rebuildLooseSources(t, projectRoot, []string{"Assets/Linked/A.cs"}) + + if want := []string{"Assets/Linked/A.cs"}; !reflect.DeepEqual(sources, want) { + t.Fatalf("sources = %v, want %v", sources, want) + } +} + +// Verifies a recorded source that is now a symbolic link to nothing counts as removed, although the +// walk lists a name there. +func TestRebuildSourcesDropsASourceThatIsADanglingLink(t *testing.T) { + projectRoot := t.TempDir() + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Kept.cs"), "") + symlinkOrSkip(t, filepath.Join(projectRoot, "Missing.cs"), filepath.Join(projectRoot, "Assets", "Gone.cs")) + + sources := rebuildLooseSources(t, projectRoot, []string{"Assets/Gone.cs", "Assets/Kept.cs"}) + + if want := []string{"Assets/Kept.cs"}; !reflect.DeepEqual(sources, want) { + t.Fatalf("sources = %v, want %v", sources, want) + } +} + +// Verifies a recorded source whose name only changed case since the build still counts as there for +// an assembly without an .asmdef, as the file system finds it under the recorded spelling. +func TestRebuildSourcesKeepsASourceRenamedOnlyInCase(t *testing.T) { + requireCaseInsensitiveFileSystem(t) + projectRoot := t.TempDir() + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Loose", "A.cs"), "") + + sources := rebuildLooseSources(t, projectRoot, []string{"Assets/Loose/a.cs"}) + + if want := []string{"Assets/Loose/a.cs"}; !reflect.DeepEqual(sources, want) { + t.Fatalf("sources = %v, want %v", sources, want) + } +} + +// Verifies a source renamed only in case inside an assembly with an .asmdef asks for a Unity build, +// because the re-glob finds a spelling the build never recorded. +func TestRebuildSourcesRefusesASourceRenamedOnlyInCaseUnderAnAsmdef(t *testing.T) { + requireCaseInsensitiveFileSystem(t) + projectRoot := t.TempDir() + directory := filepath.Join(projectRoot, "Assets", "Foo") + writeFileAt(t, filepath.Join(directory, "Foo.asmdef"), `{"name":"Foo"}`) + writeFileAt(t, filepath.Join(directory, "A.cs"), "") + asmdef := AssemblyDefinition{Name: "Foo", Path: filepath.Join(directory, "Foo.asmdef"), Directory: directory} + + _, err := RebuildSources(projectRoot, ResponseFile{Sources: []string{"Assets/Foo/a.cs"}}, &asmdef, newOwnerIndexForProject(t, projectRoot)) + + var required UnityBuildRequiredError + if !errors.As(err, &required) { + t.Fatalf("err = %v, want a Unity build required", err) + } +} + +// Verifies a recorded source spelled in another Unicode normalization than the file on disk still +// counts as there, as macOS file systems find it. +func TestRebuildSourcesKeepsASourceInAnotherUnicodeNormalization(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("only macOS file systems ignore Unicode normalization") + } + projectRoot := t.TempDir() + composed := "Café.cs" + decomposed := "Café.cs" + writeFileAt(t, filepath.Join(projectRoot, "Assets", composed), "") + if !fileExists(filepath.Join(projectRoot, "Assets", decomposed)) { + t.Skip("this file system tells Unicode normalizations apart") + } + + sources := rebuildLooseSources(t, projectRoot, []string{"Assets/" + decomposed}) + + if want := []string{"Assets/" + decomposed}; !reflect.DeepEqual(sources, want) { + t.Fatalf("sources = %v, want %v", sources, want) + } +} + +// Verifies recorded sources outside every index root are checked on the disk: an existing one stays +// and a deleted one drops out. +func TestRebuildSourcesChecksSourcesOutsideTheIndexRoots(t *testing.T) { + projectRoot := t.TempDir() + outside := t.TempDir() + kept := filepath.Join(outside, "Kept.cs") + writeFileAt(t, kept, "") + + sources := rebuildLooseSources(t, projectRoot, []string{kept, filepath.Join(outside, "Deleted.cs")}) + + if want := []string{kept}; !reflect.DeepEqual(sources, want) { + t.Fatalf("sources = %v, want %v", sources, want) + } +} + +// Verifies files Unity ignores by name are left out of the glob, alongside ignored directories. +func TestRebuildSourcesLeavesOutIgnoredFiles(t *testing.T) { + projectRoot := newAssemblyProject(t) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Foo", "._A.cs"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Foo", ".Hidden.cs"), "") + asmdef := AssemblyDefinition{ + Name: "Foo", Path: filepath.Join(projectRoot, "Assets", "Foo", "Foo.asmdef"), + Directory: filepath.Join(projectRoot, "Assets", "Foo"), + } + + sources, err := RebuildSources(projectRoot, ResponseFile{Sources: []string{"Assets/Foo/A.cs"}}, &asmdef, newOwnerIndexForProject(t, projectRoot)) + if err != nil { + t.Fatalf("RebuildSources returned an error: %v", err) + } + + if want := []string{"Assets/Foo/A.cs", "Assets/Foo/Sub/B.cs"}; !reflect.DeepEqual(sources, want) { + t.Fatalf("sources = %v, want %v", sources, want) + } +} + +// Verifies the rebuild answers from the listing the run took rather than walking again: a source +// created after the listing is not globbed, a recorded one deleted after it is kept, and an .asmdef +// created after it does not stop the glob. +func TestRebuildSourcesUsesTheListingTakenAtTheStartOfTheRun(t *testing.T) { + projectRoot := newAssemblyProject(t) + owners := newOwnerIndexForProject(t, projectRoot) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Foo", "Late.cs"), "") + if err := os.Remove(filepath.Join(projectRoot, "Assets", "Foo", "A.cs")); err != nil { + t.Fatalf("failed to delete A.cs: %v", err) + } + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Foo", "Sub", "Late.asmdef"), `{"name":"Late"}`) + asmdef := AssemblyDefinition{ + Name: "Foo", Path: filepath.Join(projectRoot, "Assets", "Foo", "Foo.asmdef"), + Directory: filepath.Join(projectRoot, "Assets", "Foo"), + } + + sources, err := RebuildSources(projectRoot, ResponseFile{Sources: []string{"Assets/Foo/A.cs"}}, &asmdef, owners) + if err != nil { + t.Fatalf("RebuildSources returned an error: %v", err) + } + + if want := []string{"Assets/Foo/A.cs", "Assets/Foo/Sub/B.cs"}; !reflect.DeepEqual(sources, want) { + t.Fatalf("sources = %v, want %v", sources, want) + } +} + +// Verifies a directory the listing does not hold is globbed from the disk, so a directory that does +// not exist is still reported with its path. +func TestGlobAssemblySourcesFallsBackToTheDiskOutsideTheListing(t *testing.T) { + projectRoot := newAssemblyProject(t) + owners := newOwnerIndexForProject(t, projectRoot) + outside := t.TempDir() + writeFileAt(t, filepath.Join(outside, "Out.cs"), "") + + sources, err := globAssemblySources(projectRoot, outside, owners) + if err != nil || len(sources) != 1 || !strings.HasSuffix(sources[0], "Out.cs") { + t.Fatalf("sources = %v, err = %v, want Out.cs from the disk", sources, err) + } + + missing := filepath.Join(outside, "Gone") + if _, missingErr := globAssemblySources(projectRoot, missing, owners); missingErr == nil || + !strings.HasPrefix(missingErr.Error(), "failed to list sources under "+missing) { + t.Fatalf("err = %v, want the missing directory reported", missingErr) + } +} + +// Verifies the disk glob, which answers for directories outside the listing, applies the same rules +// as the listing: ignored names are left out, a nested .asmdef or .asmref ends the glob, and a +// directory merely named like an .asmdef does not. +func TestGlobAssemblySourcesGivesTheSameSourcesFromTheDiskAndTheListing(t *testing.T) { + projectRoot := newAssemblyProject(t) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Foo", "._A.cs"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Foo", ".hidden", "H.cs"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Foo", "Ref", "Ref.asmref"), `{"reference":"Nested"}`) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Foo", "Ref", "R.cs"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Foo", "Sub", "Deep", "E.cs"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Foo", "Sub", "Fake.asmdef", "F.cs"), "") + fromListing := newOwnerIndexForProject(t, projectRoot) + fromDisk := fromListing + fromDisk.tree = projectTree{} + directory := filepath.Join(projectRoot, "Assets", "Foo") + + listed, listedErr := globAssemblySources(projectRoot, directory, fromListing) + walked, walkedErr := globAssemblySources(projectRoot, directory, fromDisk) + + if listedErr != nil || walkedErr != nil { + t.Fatalf("errors: listing %v, disk %v", listedErr, walkedErr) + } + want := []string{"Assets/Foo/A.cs", "Assets/Foo/Sub/B.cs", "Assets/Foo/Sub/Deep/E.cs", "Assets/Foo/Sub/Fake.asmdef/F.cs"} + if !reflect.DeepEqual(listed, want) || !reflect.DeepEqual(walked, want) { + t.Fatalf("listing %v, disk %v, want both %v", listed, walked, want) + } +} diff --git a/cli/dispatcher/internal/compilecheck/project_tree_test.go b/cli/dispatcher/internal/compilecheck/project_tree_test.go new file mode 100644 index 0000000000..e6995f5cba --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/project_tree_test.go @@ -0,0 +1,169 @@ +package compilecheck + +import ( + "io/fs" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +// lockDirectory makes a directory unreadable for the rest of the test, restoring it so the +// temporary directory can still be removed. +func lockDirectory(t *testing.T, path string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("directory permissions do not make a directory unreadable on Windows") + } + makeDirAt(t, path) + if err := os.Chmod(path, 0o000); err != nil { + t.Fatalf("failed to lock %s: %v", path, err) + } + t.Cleanup(func() { _ = os.Chmod(path, 0o755) }) + if _, err := os.ReadDir(path); err == nil { + t.Skip("this user can read a directory without permissions") + } +} + +// symlinkOrSkip creates a symbolic link, skipping the test where the platform does not allow one. +func symlinkOrSkip(t *testing.T, target string, link string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + t.Fatalf("failed to create directory for %s: %v", link, err) + } + if err := os.Symlink(target, link); err != nil { + t.Skipf("symbolic links are not available here: %v", err) + } +} + +// Verifies the walk leaves out the names Unity ignores, both as directories and as files, and lists +// the .asmdef and .asmref files in the order the walk met them. +func TestWalkProjectTreeSkipsIgnoredNamesAndKeepsWalkOrder(t *testing.T) { + projectRoot := t.TempDir() + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a", "X.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a.b", "Y.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a", "R.asmref"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Tmp~", "T.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", ".hidden", "H.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a", "._X.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a", "Old.asmref~"), "") + writeFileAt(t, filepath.Join(projectRoot, "Packages", "P", "P.asmdef"), "") + + tree := walkProjectTree(projectRoot) + + gotDefinitions := []string{} + gotReferences := []string{} + for _, root := range tree.roots { + if root.walkErr != nil { + t.Fatalf("walk of %s failed: %v", root.path, root.walkErr) + } + gotDefinitions = append(gotDefinitions, root.definitionFiles...) + gotReferences = append(gotReferences, root.referenceFiles...) + } + wantDefinitions := []string{ + filepath.Join(projectRoot, "Assets", "a", "X.asmdef"), + filepath.Join(projectRoot, "Assets", "a.b", "Y.asmdef"), + filepath.Join(projectRoot, "Packages", "P", "P.asmdef"), + } + if !reflect.DeepEqual(gotDefinitions, wantDefinitions) { + t.Fatalf("definition files = %v, want %v", gotDefinitions, wantDefinitions) + } + if want := []string{filepath.Join(projectRoot, "Assets", "a", "R.asmref")}; !reflect.DeepEqual(gotReferences, want) { + t.Fatalf("reference files = %v, want %v", gotReferences, want) + } + for _, ignored := range []string{"Tmp~", ".hidden"} { + if _, listed := tree.directory(filepath.Join(projectRoot, "Assets", ignored)); listed { + t.Fatalf("ignored directory %s was listed", ignored) + } + } + if tree.hasRegularFile(filepath.Join(projectRoot, "Assets", "a", "._X.asmdef")) { + t.Fatal("an ignored file was listed") + } + if !tree.hasRegularFile(filepath.Join(projectRoot, "Assets", "a", "X.asmdef")) { + t.Fatal("a regular file the walk met was not listed") + } +} + +// Verifies a symbolic link is listed as an entry that is not a regular file, and the walk does not +// descend through a link to a directory. +func TestWalkProjectTreeListsSymbolicLinksAsNonRegularEntries(t *testing.T) { + projectRoot := t.TempDir() + outside := t.TempDir() + writeFileAt(t, filepath.Join(outside, "Real.cs"), "") + symlinkOrSkip(t, outside, filepath.Join(projectRoot, "Assets", "Linked")) + symlinkOrSkip(t, filepath.Join(outside, "Real.cs"), filepath.Join(projectRoot, "Assets", "Link.cs")) + + tree := walkProjectTree(projectRoot) + + if tree.hasRegularFile(filepath.Join(projectRoot, "Assets", "Link.cs")) { + t.Fatal("a symbolic link to a file was listed as a regular file") + } + if _, listed := tree.directory(filepath.Join(projectRoot, "Assets", "Linked")); listed { + t.Fatal("the walk descended through a symbolic link to a directory") + } + assets, listed := tree.directory(filepath.Join(projectRoot, "Assets")) + if !listed { + t.Fatal("Assets was not listed") + } + names := []string{} + for _, entry := range assets.entries { + names = append(names, entry.name) + } + if want := []string{"Link.cs", "Linked"}; !reflect.DeepEqual(names, want) { + t.Fatalf("Assets entries = %v, want %v", names, want) + } +} + +// Verifies a walk error keeps what the failing root listed before it, records the error on that +// root, and leaves every later root unwalked. +func TestWalkProjectTreeStopsAtTheFirstRootThatFails(t *testing.T) { + projectRoot := t.TempDir() + writeFileAt(t, filepath.Join(projectRoot, "Assets", "A", "A.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Packages", "Before", "B.asmdef"), "") + lockDirectory(t, filepath.Join(projectRoot, "Packages", "Locked")) + writeFileAt(t, filepath.Join(projectRoot, "Library", "PackageCache", "pkg", "C.asmdef"), "") + + tree := walkProjectTree(projectRoot) + + if len(tree.roots) != 2 { + t.Fatalf("roots = %+v, want Assets and Packages only", tree.roots) + } + packages := tree.roots[1] + if packages.path != filepath.Join(projectRoot, "Packages") || packages.walkErr == nil { + t.Fatalf("Packages root = %+v, want a walk error", packages) + } + if want := []string{filepath.Join(projectRoot, "Packages", "Before", "B.asmdef")}; !reflect.DeepEqual(packages.definitionFiles, want) { + t.Fatalf("Packages definition files = %v, want %v", packages.definitionFiles, want) + } + if !strings.Contains(packages.walkErr.Error(), "Locked") { + t.Fatalf("walk error = %v, want the locked directory named", packages.walkErr) + } + // Why the failing root's directories are not listed: its walk stopped part way, so a listing of + // it could miss files, and a lookup must then fall back to the disk rather than trust it. + if _, listed := tree.directory(filepath.Join(projectRoot, "Packages", "Before")); listed { + t.Fatal("a directory of the failing root was listed") + } + if _, listed := tree.directory(filepath.Join(projectRoot, "Assets", "A")); !listed { + t.Fatal("a directory of a root that walked cleanly was not listed") + } +} + +// Verifies recording an entry whose parent the walk has not listed starts the parent's listing +// instead of writing to a missing one, which a root whose cleaned parent key differs could cause. +func TestAddProjectTreeEntryStartsAMissingParentListing(t *testing.T) { + path := filepath.Join(t.TempDir(), "A.cs") + writeFileAt(t, path, "") + info, err := os.Stat(path) + if err != nil { + t.Fatalf("failed to stat %s: %v", path, err) + } + directories := map[string]projectTreeDirectory{} + + addProjectTreeEntry(directories, path, fs.FileInfoToDirEntry(info)) + + if entry, found := directories[filepath.Dir(path)].byName["A.cs"]; !found || !entry.isRegular { + t.Fatalf("listing = %+v, want A.cs as a regular file", directories[filepath.Dir(path)]) + } +} diff --git a/cli/dispatcher/internal/compilecheck/source_rescue.go b/cli/dispatcher/internal/compilecheck/source_rescue.go index 2b4f664432..3fe86ee58d 100644 --- a/cli/dispatcher/internal/compilecheck/source_rescue.go +++ b/cli/dispatcher/internal/compilecheck/source_rescue.go @@ -19,6 +19,7 @@ type directoryOwner struct { type assemblyOwnerIndex struct { definitionDirectories map[string]bool referencesByDirectory map[string]string + tree projectTree // the listing the run took, which the source globs read } // newAssemblyOwnerIndex maps every directory that starts an assembly to what it starts. @@ -29,7 +30,7 @@ type assemblyOwnerIndex struct { // own editor assembly. func newAssemblyOwnerIndex( assemblyDefinitions map[string]AssemblyDefinition, references []AssemblyReference, - context AssemblyContext, + context AssemblyContext, tree projectTree, ) assemblyOwnerIndex { definitionDirectories := make(map[string]bool, len(assemblyDefinitions)) for _, definition := range assemblyDefinitions { @@ -48,6 +49,7 @@ func newAssemblyOwnerIndex( return assemblyOwnerIndex{ definitionDirectories: definitionDirectories, referencesByDirectory: referencesByDirectory, + tree: tree, } } @@ -108,8 +110,8 @@ func (index assemblyOwnerIndex) startsAnotherAssembly(directory string) bool { return true } - // Why the .asmdef is still looked for on disk rather than in the index: the index keeps one - // directory per assembly name, so a stale duplicate .asmdef is absent from it while Unity still + // Why the .asmdef is looked for in the directory listing rather than in the index: the index keeps + // one directory per assembly name, so a stale duplicate .asmdef is absent from it while Unity still // stops there. - return directoryHoldsAssemblyDefinition(current) + return index.tree.directoryHoldsAssemblyDefinition(current) } diff --git a/cli/dispatcher/internal/compilecheck/source_set.go b/cli/dispatcher/internal/compilecheck/source_set.go index 1b7fe16fd4..bec236afab 100644 --- a/cli/dispatcher/internal/compilecheck/source_set.go +++ b/cli/dispatcher/internal/compilecheck/source_set.go @@ -49,56 +49,42 @@ func projectIndexRoots(projectRoot string) []string { } // IndexAssemblyDefinitions maps every assembly name in the project to the .asmdef that declares it. -func IndexAssemblyDefinitions(projectRoot string) (map[string]AssemblyDefinition, error) { +// Each root's .asmdef files are read in walk order before that root's walk error is reported, which +// is the order the errors surfaced in when each file was read as the walk met it. +func IndexAssemblyDefinitions(tree projectTree) (map[string]AssemblyDefinition, error) { index := map[string]AssemblyDefinition{} - for _, root := range projectIndexRoots(projectRoot) { - if !directoryExists(root) { - continue + for _, root := range tree.roots { + for _, path := range root.definitionFiles { + if err := addAssemblyDefinition(path, index); err != nil { + return nil, fmt.Errorf(assemblyDefinitionIndexError, root.path, err) + } } - if err := indexAssemblyDefinitionsUnder(root, index); err != nil { - return nil, fmt.Errorf(assemblyDefinitionIndexError, root, err) + if root.walkErr != nil { + return nil, fmt.Errorf(assemblyDefinitionIndexError, root.path, root.walkErr) } } return index, nil } -// indexAssemblyDefinitionsUnder adds every .asmdef found below one project root to the index. -func indexAssemblyDefinitionsUnder(root string, index map[string]AssemblyDefinition) error { - return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() { - if path != root && isUnityIgnoredName(entry.Name()) { - return filepath.SkipDir - } - - return nil - } - if isUnityIgnoredName(entry.Name()) { - return nil - } - if filepath.Ext(path) != assemblyDefinitionExtension { - return nil - } - name, readErr := readAssemblyDefinitionName(path) - if readErr != nil { - return readErr - } - // Why the first wins: Unity itself rejects duplicate assembly names, so a second one means a - // stale copy, and taking it would point the rebuild at the wrong directory. - if _, exists := index[name]; !exists { - index[name] = AssemblyDefinition{ - Name: name, - Path: path, - Directory: filepath.Dir(path), - GUID: readAssemblyDefinitionGUID(path), - } +// addAssemblyDefinition adds one .asmdef to the index unless an earlier one declared the same name. +func addAssemblyDefinition(path string, index map[string]AssemblyDefinition) error { + name, readErr := readAssemblyDefinitionName(path) + if readErr != nil { + return readErr + } + // Why the first wins: Unity itself rejects duplicate assembly names, so a second one means a + // stale copy, and taking it would point the rebuild at the wrong directory. + if _, exists := index[name]; !exists { + index[name] = AssemblyDefinition{ + Name: name, + Path: path, + Directory: filepath.Dir(path), + GUID: readAssemblyDefinitionGUID(path), } + } - return nil - }) + return nil } // readUnityJSONFile reads one of Unity's JSON files into target. @@ -155,7 +141,7 @@ func RebuildSources( ) ([]string, error) { existing := make([]string, 0, len(rsp.Sources)) for _, source := range rsp.Sources { - if fileExists(sourcePath(projectRoot, source)) { + if owners.tree.fileExists(sourcePath(projectRoot, source)) { existing = append(existing, source) } } @@ -244,8 +230,41 @@ func rescueRecordedSources( } // globAssemblySources lists the .cs files an assembly owns, stopping at nested assembly boundaries. +// It reads the listing the run took of the project, and the disk for a directory the listing does +// not hold, which keeps the answer for a directory outside the index roots what it always was. func globAssemblySources( projectRoot string, assemblyDirectory string, owners assemblyOwnerIndex, +) ([]string, error) { + listing, listed := owners.tree.directory(assemblyDirectory) + if !listed { + return globAssemblySourcesOnDisk(projectRoot, assemblyDirectory, owners) + } + + sources := []string{} + for _, entry := range listing.entries { + path := filepath.Join(assemblyDirectory, entry.name) + if !entry.isDirectory { + if filepath.Ext(entry.name) == cSharpSourceExtension { + sources = append(sources, recordSourcePath(projectRoot, path)) + } + continue + } + if owners.startsAnotherAssembly(path) { + continue + } + nested, err := globAssemblySources(projectRoot, path, owners) + if err != nil { + return nil, err + } + sources = append(sources, nested...) + } + + return sources, nil +} + +// globAssemblySourcesOnDisk lists the .cs files an assembly owns by walking the disk. +func globAssemblySourcesOnDisk( + projectRoot string, assemblyDirectory string, owners assemblyOwnerIndex, ) ([]string, error) { sources := []string{} err := filepath.WalkDir(assemblyDirectory, func(path string, entry fs.DirEntry, err error) error { @@ -279,7 +298,8 @@ func globAssemblySources( return sources, nil } -// directoryHoldsAssemblyDefinition reports whether a directory declares an assembly of its own. +// directoryHoldsAssemblyDefinition reports whether a directory declares an assembly of its own, by +// reading it from the disk. func directoryHoldsAssemblyDefinition(path string) bool { entries, err := os.ReadDir(path) if err != nil { diff --git a/cli/dispatcher/internal/compilecheck/source_set_test.go b/cli/dispatcher/internal/compilecheck/source_set_test.go index 53a02dc0ae..87678d2b87 100644 --- a/cli/dispatcher/internal/compilecheck/source_set_test.go +++ b/cli/dispatcher/internal/compilecheck/source_set_test.go @@ -26,11 +26,12 @@ func newAssemblyProject(t *testing.T) string { // an assembly that was never built owns nothing, which the unbuilt-target test covers on its own. func newOwnerIndexForProject(t *testing.T, projectRoot string) assemblyOwnerIndex { t.Helper() - definitions, err := IndexAssemblyDefinitions(projectRoot) + tree := walkProjectTree(projectRoot) + definitions, err := IndexAssemblyDefinitions(tree) if err != nil { t.Fatalf("failed to index the assembly definitions: %v", err) } - references, referenceErr := IndexAssemblyReferences(projectRoot) + references, referenceErr := IndexAssemblyReferences(tree) if referenceErr != nil { t.Fatalf("failed to index the assembly references: %v", referenceErr) } @@ -40,7 +41,7 @@ func newOwnerIndexForProject(t *testing.T, projectRoot string) assemblyOwnerInde graph.byName[name] = ResponseFile{AssemblyName: name} } - return newAssemblyOwnerIndex(definitions, references, NewAssemblyContext(graph, definitions)) + return newAssemblyOwnerIndex(definitions, references, NewAssemblyContext(graph, definitions), tree) } // Verifies deleted sources drop out, new sources appear, and nested or ignored folders stay excluded. @@ -103,7 +104,7 @@ func TestRebuildSourcesWithoutAssemblyDefinitionKeepsExistingSourcesOnly(t *test "Assets/Foo/Deleted.cs", }} - sources, err := RebuildSources(projectRoot, rsp, nil, assemblyOwnerIndex{}) + sources, err := RebuildSources(projectRoot, rsp, nil, newOwnerIndexForProject(t, projectRoot)) if err != nil { t.Fatalf("expected the rebuild to succeed, got error: %v", err) } @@ -146,7 +147,7 @@ func TestIndexAssemblyDefinitionsCoversEveryProjectRoot(t *testing.T) { `{"name":"Pkg"}`) writeFileAt(t, filepath.Join(projectRoot, "Assets", "Foo", "Tmp~", "Hidden.asmdef"), `{"name":"Hidden"}`) - index, err := IndexAssemblyDefinitions(projectRoot) + index, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) if err != nil { t.Fatalf("expected the index to build, got error: %v", err) } @@ -172,7 +173,7 @@ func TestIndexAssemblyDefinitionsSkipsFilesUnityIgnores(t *testing.T) { filepath.Join(projectRoot, "Assets", "Foo", "._Foo.asmdef"), "\x00\x05\x16\x07\x00\x02\x00\x00Mac OS X") - index, err := IndexAssemblyDefinitions(projectRoot) + index, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) if err != nil { t.Fatalf("expected the index to build, got error: %v", err) } From 1e39460498e6a93edbbbe5d85869a0c18bce35cf Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 10:36:20 +0900 Subject: [PATCH 07/33] perf: compile-check reads the project's directories and build records in parallel (#3322) --- cli/dispatcher/internal/compilecheck/plan.go | 27 +- .../internal/compilecheck/plan_inputs.go | 48 ++++ .../internal/compilecheck/plan_inputs_test.go | 76 ++++++ .../internal/compilecheck/project_tree.go | 149 ++++++----- .../compilecheck/project_tree_diff_test.go | 235 ++++++++++++++++++ .../compilecheck/project_tree_oracle_test.go | 90 +++++++ .../compilecheck/project_tree_reader.go | 130 ++++++++++ .../compilecheck/project_tree_test.go | 19 -- 8 files changed, 677 insertions(+), 97 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/plan_inputs.go create mode 100644 cli/dispatcher/internal/compilecheck/plan_inputs_test.go create mode 100644 cli/dispatcher/internal/compilecheck/project_tree_diff_test.go create mode 100644 cli/dispatcher/internal/compilecheck/project_tree_oracle_test.go create mode 100644 cli/dispatcher/internal/compilecheck/project_tree_reader.go diff --git a/cli/dispatcher/internal/compilecheck/plan.go b/cli/dispatcher/internal/compilecheck/plan.go index 6623d9e3e1..b014775dde 100644 --- a/cli/dispatcher/internal/compilecheck/plan.go +++ b/cli/dispatcher/internal/compilecheck/plan.go @@ -50,12 +50,13 @@ type assemblyGraph struct { // BuildCompilePlan decides which assemblies to compile, in which order, for one project. func BuildCompilePlan(projectRoot string, dagDir string, all bool) (BuildPlan, error) { - graph, err := loadAssemblyGraph(projectRoot, dagDir) - if err != nil { - return BuildPlan{}, err + inputs := loadPlanInputs(projectRoot, dagDir, all) + if inputs.graphErr != nil { + return BuildPlan{}, inputs.graphErr } + graph := inputs.graph - reasons, err := selectChangedAssemblies(projectRoot, dagDir, graph, all) + reasons, err := selectChangedAssemblies(projectRoot, dagDir, inputs, all) if err != nil { return BuildPlan{}, err } @@ -160,12 +161,14 @@ func listResponseFiles(dagDirectoryPath string) ([]string, error) { } // selectChangedAssemblies rebuilds each assembly's source list and records why it needs compiling. +// Why it takes the walk and the Bee log rather than reading them: they are read once per run, at the +// same time as the response files, and the .asmdef index, the .asmref index and every source rebuild +// below read the same walk. func selectChangedAssemblies( - projectRoot string, dagDir string, graph assemblyGraph, all bool, + projectRoot string, dagDir string, inputs planInputs, all bool, ) (map[string]string, error) { - // Why the project is walked once here: the .asmdef index, the .asmref index and every source - // rebuild below all read the same directories, and walking them for each would repeat the walk. - tree := walkProjectTree(projectRoot) + graph := inputs.graph + tree := inputs.tree assemblyDefinitions, err := IndexAssemblyDefinitions(tree) if err != nil { return nil, err @@ -189,13 +192,7 @@ func selectChangedAssemblies( return nil, referenceErr } - // Why the Bee log is read once here: it describes the whole of the last build, and reading it per - // assembly would reread tens of megabytes for every response file in the dag. An --all run reads - // nothing, because it selects every assembly whatever the last build made of it. - failedLastBuild := map[string]bool{} - if !all { - failedLastBuild = ReadFailedUnityCompiles(projectRoot, dagDir) - } + failedLastBuild := inputs.failedLastBuild reasons := map[string]string{} for _, name := range graph.names { diff --git a/cli/dispatcher/internal/compilecheck/plan_inputs.go b/cli/dispatcher/internal/compilecheck/plan_inputs.go new file mode 100644 index 0000000000..bdb8a720a7 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/plan_inputs.go @@ -0,0 +1,48 @@ +package compilecheck + +import "sync" + +// planInputs is everything planning reads from the disk before it decides anything. +type planInputs struct { + graph assemblyGraph + graphErr error + tree projectTree + // failedLastBuild names the assemblies the last Unity build failed to compile. It is empty for + // an --all run. + failedLastBuild map[string]bool +} + +// loadPlanInputs reads the response files, walks the project and reads the Bee log at the same time, +// and returns once all three are done. +// Why at the same time: none of the three reads what another produces, and on a large project the +// response files and the Bee log take about as long together as the walk does alone. +// Why it waits for all three even when the response files fail: no read may still be running when +// planning returns, and waiting here rather than at each error exit keeps that true by construction. +func loadPlanInputs(projectRoot string, dagDir string, all bool) planInputs { + inputs := planInputs{failedLastBuild: map[string]bool{}} + runConcurrently( + func() { inputs.graph, inputs.graphErr = loadAssemblyGraph(projectRoot, dagDir) }, + func() { inputs.tree = walkProjectTree(projectRoot) }, + func() { + // Why the Bee log is read once per run: it describes the whole of the last build, and + // reading it per assembly would reread tens of megabytes for every response file. An + // --all run reads none, because it selects every assembly whatever the last build made + // of it. + if !all { + inputs.failedLastBuild = ReadFailedUnityCompiles(projectRoot, dagDir) + } + }, + ) + + return inputs +} + +// runConcurrently runs every task at the same time and returns once all of them have finished. Each +// task must write only to what no other task reads or writes. +func runConcurrently(tasks ...func()) { + var running sync.WaitGroup + for _, task := range tasks { + running.Go(task) + } + running.Wait() +} diff --git a/cli/dispatcher/internal/compilecheck/plan_inputs_test.go b/cli/dispatcher/internal/compilecheck/plan_inputs_test.go new file mode 100644 index 0000000000..cea64ccf9b --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/plan_inputs_test.go @@ -0,0 +1,76 @@ +package compilecheck + +import ( + "testing" + "time" +) + +// runConcurrentlyReturnWait is how long a test waits to see whether runConcurrently has returned. +const runConcurrentlyReturnWait = 100 * time.Millisecond + +// startRunConcurrently runs the tasks in the background and reports on the returned channel when +// runConcurrently returns. +func startRunConcurrently(tasks ...func()) <-chan struct{} { + returned := make(chan struct{}) + go func() { + runConcurrently(tasks...) + close(returned) + }() + + return returned +} + +// Verifies runConcurrently does not return while one task is still running, and returns once every +// task has finished. +func TestRunConcurrentlyWaitsForEveryTask(t *testing.T) { + release := make(chan struct{}) + finished := make(chan struct{}, 2) + returned := startRunConcurrently( + func() { finished <- struct{}{} }, + func() { + <-release + finished <- struct{}{} + }, + ) + + select { + case <-returned: + t.Fatal("runConcurrently returned while a task was still running") + case <-time.After(runConcurrentlyReturnWait): + } + + close(release) + select { + case <-returned: + case <-time.After(10 * time.Second): + t.Fatal("runConcurrently did not return after every task finished") + } + if len(finished) != 2 { + t.Fatalf("finished tasks = %d, want 2", len(finished)) + } +} + +// Verifies runConcurrently runs its tasks at the same time rather than one after another: a task +// that waits for another to start would never finish if they ran in order. +func TestRunConcurrentlyRunsTasksAtTheSameTime(t *testing.T) { + secondStarted := make(chan struct{}) + returned := startRunConcurrently( + func() { <-secondStarted }, + func() { close(secondStarted) }, + ) + + select { + case <-returned: + case <-time.After(10 * time.Second): + t.Fatal("runConcurrently ran its tasks one after another") + } +} + +// Verifies runConcurrently with no tasks returns at once. +func TestRunConcurrentlyReturnsAtOnceWithNoTasks(t *testing.T) { + select { + case <-startRunConcurrently(): + case <-time.After(10 * time.Second): + t.Fatal("runConcurrently with no tasks did not return") + } +} diff --git a/cli/dispatcher/internal/compilecheck/project_tree.go b/cli/dispatcher/internal/compilecheck/project_tree.go index c1f6d4083f..59a1382698 100644 --- a/cli/dispatcher/internal/compilecheck/project_tree.go +++ b/cli/dispatcher/internal/compilecheck/project_tree.go @@ -1,7 +1,7 @@ package compilecheck import ( - "io/fs" + "os" "path/filepath" ) @@ -38,18 +38,42 @@ type projectTreeEntry struct { isRegular bool // a regular file, as opposed to a directory, a symbolic link or a device } -// walkProjectTree walks each index root once, in root order. +// walkProjectTree lists each index root once, as filepath.WalkDir would walk the roots in root order. +// Why the directories are read in parallel but put together in walk order: on a project of tens of +// thousands of files the reads are what take the time, while the order decides results - the +// .asmdef index keeps the first of two assemblies declaring the same name, and the first error a +// root meets is the one reported - so only the reads may run in any order. // Why the walk stops at the first root that fails: indexing stopped there before, so a later root // was never read, and an error found there must not take the place of the one found first. -// Why the roots are walked one after another rather than in parallel: the .asmdef index keeps the -// first of two assemblies declaring the same name, so the order across roots decides the winner. func walkProjectTree(projectRoot string) projectTree { - tree := projectTree{directories: map[string]projectTreeDirectory{}} + return walkProjectTreeReadingAhead(projectRoot, readProjectTreeListings) +} + +// walkProjectTreeReadingAhead is walkProjectTree with the read-ahead given. The tree it returns does +// not depend on what readAhead read: a directory it left out is read when the walk reaches it. +func walkProjectTreeReadingAhead( + projectRoot string, readAhead func(rootPaths []string) map[string]projectTreeListing, +) projectTree { + starts := []projectTreeRootStart{} + readFirst := []string{} for _, rootPath := range projectIndexRoots(projectRoot) { if !directoryExists(rootPath) { continue } - root, directories := walkProjectTreeRoot(rootPath) + // Why Lstat: a root that is a symbolic link is listed without being followed, as WalkDir does. + info, err := os.Lstat(rootPath) + start := projectTreeRootStart{path: rootPath, lstatErr: err} + if err == nil && info.IsDir() { + start.isDirectory = true + readFirst = append(readFirst, rootPath) + } + starts = append(starts, start) + } + listings := readAhead(readFirst) + + tree := projectTree{directories: map[string]projectTreeDirectory{}} + for _, start := range starts { + root, directories := restoreProjectTreeRoot(start, listings) tree.roots = append(tree.roots, root) if root.walkErr != nil { break @@ -62,72 +86,71 @@ func walkProjectTree(projectRoot string) projectTree { return tree } -// walkProjectTreeRoot lists one root with the same rules the indexes walked it with: names Unity -// ignores are left out, and symbolic links are listed but not followed. -func walkProjectTreeRoot(rootPath string) (projectTreeRoot, map[string]projectTreeDirectory) { - root := projectTreeRoot{path: rootPath} +// projectTreeRootStart is what the walk learned about one root before reading it. +type projectTreeRootStart struct { + path string + isDirectory bool + lstatErr error +} + +// restoreProjectTreeRoot puts one root together from the listings read ahead, in the order +// filepath.WalkDir would have met its entries, and stops at the first error the same walk would +// have stopped at. +func restoreProjectTreeRoot( + start projectTreeRootStart, listings map[string]projectTreeListing, +) (projectTreeRoot, map[string]projectTreeDirectory) { + root := projectTreeRoot{path: start.path} directories := map[string]projectTreeDirectory{} - root.walkErr = filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if path == rootPath { - if entry.IsDir() { - directories[filepath.Clean(path)] = newProjectTreeDirectory() - } + if start.lstatErr != nil { + root.walkErr = start.lstatErr - return nil - } - if isUnityIgnoredName(entry.Name()) { - if entry.IsDir() { - return filepath.SkipDir - } + return root, directories + } + if start.isDirectory { + root.walkErr = restoreProjectTreeDirectory(&root, directories, start.path, listings) + } - return nil - } - addProjectTreeEntry(directories, path, entry) - if entry.IsDir() { - directories[filepath.Clean(path)] = newProjectTreeDirectory() + return root, directories +} + +// restoreProjectTreeDirectory lists one directory and everything below it in walk order: each name in +// name order, with a directory walked to the end before the next name. +// Why a listing that failed is not walked: WalkDir stops at the error, so what a failed read did +// return was never seen, and nothing after it in the same root was either. +func restoreProjectTreeDirectory( + root *projectTreeRoot, + directories map[string]projectTreeDirectory, + path string, + listings map[string]projectTreeListing, +) error { + listing, readAhead := listings[path] + if !readAhead { + // Why a directory missing from the read-ahead is read here: whatever rule decided what to + // read ahead, the tree must come out as the walk would have listed it. + listing = readProjectTreeListing(path) + } + if listing.err != nil { + return listing.err + } + directories[filepath.Clean(path)] = listing.directory + for _, entry := range listing.directory.entries { + entryPath := filepath.Join(path, entry.name) + if entry.isDirectory { + if err := restoreProjectTreeDirectory(root, directories, entryPath, listings); err != nil { + return err + } - return nil + continue } - switch filepath.Ext(path) { + switch filepath.Ext(entryPath) { case assemblyDefinitionExtension: - root.definitionFiles = append(root.definitionFiles, path) + root.definitionFiles = append(root.definitionFiles, entryPath) case assemblyReferenceExtension: - root.referenceFiles = append(root.referenceFiles, path) + root.referenceFiles = append(root.referenceFiles, entryPath) } - - return nil - }) - - return root, directories -} - -// newProjectTreeDirectory starts the listing of one directory. -func newProjectTreeDirectory() projectTreeDirectory { - return projectTreeDirectory{byName: map[string]projectTreeEntry{}} -} - -// addProjectTreeEntry records one name in the listing of its parent. -// Why a missing parent listing is started here: the walk lists a parent before its children, but -// the parent's key is derived by cleaning the child's path, which need not match the key the root -// was stored under - a Windows share root is one such spelling - and writing to a listing that was -// never started would panic. -func addProjectTreeEntry(directories map[string]projectTreeDirectory, path string, entry fs.DirEntry) { - parentPath := filepath.Dir(filepath.Clean(path)) - parent, started := directories[parentPath] - if !started { - parent = newProjectTreeDirectory() } - listed := projectTreeEntry{ - name: entry.Name(), - isDirectory: entry.IsDir(), - isRegular: entry.Type().IsRegular(), - } - parent.entries = append(parent.entries, listed) - parent.byName[listed.name] = listed - directories[parentPath] = parent + + return nil } // directory returns the listing of a directory the walk went through. diff --git a/cli/dispatcher/internal/compilecheck/project_tree_diff_test.go b/cli/dispatcher/internal/compilecheck/project_tree_diff_test.go new file mode 100644 index 0000000000..10da7e6c96 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/project_tree_diff_test.go @@ -0,0 +1,235 @@ +package compilecheck + +import ( + "path/filepath" + "reflect" + "testing" +) + +// comparableProjectTree is a projectTree with its errors turned into strings and every empty list +// spelled the same way, so two trees that differ only in nil against empty slices compare equal. +type comparableProjectTree struct { + roots []comparableProjectTreeRoot + directories map[string]comparableProjectTreeDirectory +} + +// comparableProjectTreeRoot is one root of a comparableProjectTree. +type comparableProjectTreeRoot struct { + path string + definitionFiles []string + referenceFiles []string + walkErr string +} + +// comparableProjectTreeDirectory is one directory of a comparableProjectTree. +type comparableProjectTreeDirectory struct { + entries []projectTreeEntry + byName map[string]projectTreeEntry +} + +// toComparableProjectTree normalizes a tree for comparison. +func toComparableProjectTree(tree projectTree) comparableProjectTree { + comparable := comparableProjectTree{ + roots: []comparableProjectTreeRoot{}, + directories: map[string]comparableProjectTreeDirectory{}, + } + for _, root := range tree.roots { + walkErr := "" + if root.walkErr != nil { + walkErr = root.walkErr.Error() + } + comparable.roots = append(comparable.roots, comparableProjectTreeRoot{ + path: root.path, + definitionFiles: append([]string{}, root.definitionFiles...), + referenceFiles: append([]string{}, root.referenceFiles...), + walkErr: walkErr, + }) + } + for path, directory := range tree.directories { + byName := map[string]projectTreeEntry{} + for name, entry := range directory.byName { + byName[name] = entry + } + comparable.directories[path] = comparableProjectTreeDirectory{ + entries: append([]projectTreeEntry{}, directory.entries...), + byName: byName, + } + } + + return comparable +} + +// readNothingAhead is a read-ahead that reads nothing, so the walk reads every directory itself. +func readNothingAhead([]string) map[string]projectTreeListing { + return map[string]projectTreeListing{} +} + +// readOnlyRootsAhead is a read-ahead that reads the roots and nothing below them. +func readOnlyRootsAhead(rootPaths []string) map[string]projectTreeListing { + listings := map[string]projectTreeListing{} + for _, rootPath := range rootPaths { + listings[rootPath] = readProjectTreeListing(rootPath) + } + + return listings +} + +// assertWalkMatchesOracle walks a project with the WalkDir oracle and with the production walk under +// the production read-ahead, one that reads nothing and one that reads only the roots, and fails +// when any of them differs from the oracle in anything a caller can observe. +func assertWalkMatchesOracle(t *testing.T, projectRoot string) { + t.Helper() + want := toComparableProjectTree(oracleWalkProjectTree(projectRoot)) + readAheads := []struct { + name string + readAhead func([]string) map[string]projectTreeListing + }{ + {"the production read-ahead", readProjectTreeListings}, + {"a read-ahead that reads nothing", readNothingAhead}, + {"a read-ahead that reads only the roots", readOnlyRootsAhead}, + } + for _, readAhead := range readAheads { + got := toComparableProjectTree(walkProjectTreeReadingAhead(projectRoot, readAhead.readAhead)) + if !reflect.DeepEqual(got, want) { + t.Errorf("walk with %s differs from the WalkDir oracle\n got: %+v\nwant: %+v", + readAhead.name, got, want) + } + } +} + +// Verifies the walk lists the same tree as filepath.WalkDir over every kind of project layout it +// has to agree on: order, ignored names, links, roots that are missing, linked or unreadable, the +// first error of a root, and roots that overlap. +func TestWalkProjectTreeMatchesTheWalkDirOracle(t *testing.T) { + cases := []struct { + name string + setUp func(t *testing.T, projectRoot string) + }{ + {"nested directories and files in walk order", setUpNestedProjectTree}, + {"ignored directories and files", setUpIgnoredNamesProjectTree}, + {"symbolic links inside a root", setUpSymbolicLinksProjectTree}, + {"a directory named like an asmdef", setUpAsmdefNamedDirectoryProjectTree}, + {"a root that is a symbolic link", setUpLinkedRootProjectTree}, + {"a missing root", setUpMissingRootProjectTree}, + {"an unreadable directory in a later root", setUpUnreadableChildProjectTree}, + {"an unreadable root", setUpUnreadableRootProjectTree}, + {"siblings after an unreadable directory", setUpSiblingsAfterUnreadableProjectTree}, + {"another subtree after an unreadable directory", setUpSubtreeAfterUnreadableProjectTree}, + {"definitions found before an unreadable directory", setUpDefinitionsBeforeUnreadableProjectTree}, + {"a local package inside Packages", setUpOverlappingRootsProjectTree}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + projectRoot := t.TempDir() + testCase.setUp(t, projectRoot) + assertWalkMatchesOracle(t, projectRoot) + }) + } +} + +// setUpNestedProjectTree lays out nested directories whose walk order differs from path order. +func setUpNestedProjectTree(t *testing.T, projectRoot string) { + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a", "X.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a", "b", "c", "C.cs"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a.b", "Y.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Z", "A", "X.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Z", "A.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "R.asmref"), "") + makeDirAt(t, filepath.Join(projectRoot, "Assets", "Empty")) + writeFileAt(t, filepath.Join(projectRoot, "Packages", "P", "P.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Packages", "P", "Q.asmref"), "") + writeFileAt(t, filepath.Join(projectRoot, "Library", "PackageCache", "pkg", "Q.asmdef"), "") +} + +// setUpIgnoredNamesProjectTree lays out directories and files Unity ignores, next to ones it keeps. +func setUpIgnoredNamesProjectTree(t *testing.T, projectRoot string) { + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Tmp~", "T.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Tmp~", "Deep", "D.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", ".hidden", "H.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a", "._X.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a", "Old.asmref~"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a", "Kept.asmdef"), "") +} + +// setUpSymbolicLinksProjectTree lays out a link to a directory, a dangling link and a link named +// like an .asmdef. +func setUpSymbolicLinksProjectTree(t *testing.T, projectRoot string) { + outside := t.TempDir() + writeFileAt(t, filepath.Join(outside, "Inner", "Inner.asmdef"), "") + writeFileAt(t, filepath.Join(outside, "Real.asmdef"), "") + symlinkOrSkip(t, filepath.Join(outside, "Inner"), filepath.Join(projectRoot, "Assets", "Linked")) + symlinkOrSkip(t, filepath.Join(outside, "Missing.cs"), filepath.Join(projectRoot, "Assets", "Dangling.cs")) + symlinkOrSkip(t, filepath.Join(outside, "Real.asmdef"), filepath.Join(projectRoot, "Assets", "Link.asmdef")) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Plain", "Plain.asmdef"), "") +} + +// setUpAsmdefNamedDirectoryProjectTree lays out a directory whose name ends in .asmdef. +func setUpAsmdefNamedDirectoryProjectTree(t *testing.T, projectRoot string) { + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Folder.asmdef", "Inside.cs"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Folder.asmdef", "Nested", "Nested.asmdef"), "") +} + +// setUpLinkedRootProjectTree makes Assets a symbolic link to a directory holding an .asmdef. +func setUpLinkedRootProjectTree(t *testing.T, projectRoot string) { + outside := t.TempDir() + writeFileAt(t, filepath.Join(outside, "A", "A.asmdef"), "") + symlinkOrSkip(t, outside, filepath.Join(projectRoot, "Assets")) + writeFileAt(t, filepath.Join(projectRoot, "Packages", "P", "P.asmdef"), "") +} + +// setUpMissingRootProjectTree leaves out Packages and the package cache. +func setUpMissingRootProjectTree(t *testing.T, projectRoot string) { + writeFileAt(t, filepath.Join(projectRoot, "Assets", "A", "A.asmdef"), "") +} + +// setUpUnreadableChildProjectTree locks a directory of Packages after a clean Assets, with a package +// cache behind it that must not be walked. +func setUpUnreadableChildProjectTree(t *testing.T, projectRoot string) { + writeFileAt(t, filepath.Join(projectRoot, "Assets", "A", "A.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Packages", "Before", "B.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Library", "PackageCache", "pkg", "C.asmdef"), "") + lockDirectory(t, filepath.Join(projectRoot, "Packages", "Locked")) +} + +// setUpUnreadableRootProjectTree locks Assets itself, with Packages behind it. +func setUpUnreadableRootProjectTree(t *testing.T, projectRoot string) { + writeFileAt(t, filepath.Join(projectRoot, "Packages", "P", "P.asmdef"), "") + lockDirectory(t, filepath.Join(projectRoot, "Assets")) +} + +// setUpSiblingsAfterUnreadableProjectTree puts an .asmdef and an .asmref in a sibling the walk +// meets after the unreadable directory. +func setUpSiblingsAfterUnreadableProjectTree(t *testing.T, projectRoot string) { + writeFileAt(t, filepath.Join(projectRoot, "Assets", "M", "M.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "M", "M.asmref"), "") + lockDirectory(t, filepath.Join(projectRoot, "Assets", "Locked")) +} + +// setUpSubtreeAfterUnreadableProjectTree puts an .asmdef in another top-level subtree the walk +// meets after an unreadable directory nested in an earlier one. +func setUpSubtreeAfterUnreadableProjectTree(t *testing.T, projectRoot string) { + writeFileAt(t, filepath.Join(projectRoot, "Assets", "A", "A.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "B", "B.asmdef"), "") + lockDirectory(t, filepath.Join(projectRoot, "Assets", "A", "Locked")) +} + +// setUpDefinitionsBeforeUnreadableProjectTree puts several .asmdef files ahead of an unreadable +// directory in the same root. +func setUpDefinitionsBeforeUnreadableProjectTree(t *testing.T, projectRoot string) { + writeFileAt(t, filepath.Join(projectRoot, "Assets", "A", "A1.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "A", "A2.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "B", "B.asmref"), "") + lockDirectory(t, filepath.Join(projectRoot, "Assets", "Z_Locked")) +} + +// setUpOverlappingRootsProjectTree adds a local package that lives inside Packages, so two roots +// list the same directories. +// Why only one `file:` dependency: the manifest's dependencies are a map, and with two of them the +// order of the local roots would change between the oracle's walk and the production one. +func setUpOverlappingRootsProjectTree(t *testing.T, projectRoot string) { + writeFileAt(t, filepath.Join(projectRoot, "Packages", "manifest.json"), + `{"dependencies":{"com.example.local":"file:Local"}}`) + writeFileAt(t, filepath.Join(projectRoot, "Packages", "Local", "L.asmdef"), "") + writeFileAt(t, filepath.Join(projectRoot, "Packages", "Local", "Sub", "S.asmref"), "") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "A", "A.asmdef"), "") +} diff --git a/cli/dispatcher/internal/compilecheck/project_tree_oracle_test.go b/cli/dispatcher/internal/compilecheck/project_tree_oracle_test.go new file mode 100644 index 0000000000..527d32bb48 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/project_tree_oracle_test.go @@ -0,0 +1,90 @@ +package compilecheck + +import ( + "io/fs" + "path/filepath" +) + +// oracleWalkProjectTree is the single-walk listing as it was before the walk read directories in +// parallel. The parallel walk must produce the same tree, so the differential tests compare the two. +func oracleWalkProjectTree(projectRoot string) projectTree { + tree := projectTree{directories: map[string]projectTreeDirectory{}} + for _, rootPath := range projectIndexRoots(projectRoot) { + if !directoryExists(rootPath) { + continue + } + root, directories := oracleWalkProjectTreeRoot(rootPath) + tree.roots = append(tree.roots, root) + if root.walkErr != nil { + break + } + for path, directory := range directories { + tree.directories[path] = directory + } + } + + return tree +} + +// oracleWalkProjectTreeRoot lists one root with filepath.WalkDir, as the walk did before. +func oracleWalkProjectTreeRoot(rootPath string) (projectTreeRoot, map[string]projectTreeDirectory) { + root := projectTreeRoot{path: rootPath} + directories := map[string]projectTreeDirectory{} + root.walkErr = filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == rootPath { + if entry.IsDir() { + directories[filepath.Clean(path)] = oracleNewProjectTreeDirectory() + } + + return nil + } + if isUnityIgnoredName(entry.Name()) { + if entry.IsDir() { + return filepath.SkipDir + } + + return nil + } + oracleAddProjectTreeEntry(directories, path, entry) + if entry.IsDir() { + directories[filepath.Clean(path)] = oracleNewProjectTreeDirectory() + + return nil + } + switch filepath.Ext(path) { + case assemblyDefinitionExtension: + root.definitionFiles = append(root.definitionFiles, path) + case assemblyReferenceExtension: + root.referenceFiles = append(root.referenceFiles, path) + } + + return nil + }) + + return root, directories +} + +// oracleNewProjectTreeDirectory starts the listing of one directory. +func oracleNewProjectTreeDirectory() projectTreeDirectory { + return projectTreeDirectory{byName: map[string]projectTreeEntry{}} +} + +// oracleAddProjectTreeEntry records one name in the listing of its parent. +func oracleAddProjectTreeEntry(directories map[string]projectTreeDirectory, path string, entry fs.DirEntry) { + parentPath := filepath.Dir(filepath.Clean(path)) + parent, started := directories[parentPath] + if !started { + parent = oracleNewProjectTreeDirectory() + } + listed := projectTreeEntry{ + name: entry.Name(), + isDirectory: entry.IsDir(), + isRegular: entry.Type().IsRegular(), + } + parent.entries = append(parent.entries, listed) + parent.byName[listed.name] = listed + directories[parentPath] = parent +} diff --git a/cli/dispatcher/internal/compilecheck/project_tree_reader.go b/cli/dispatcher/internal/compilecheck/project_tree_reader.go new file mode 100644 index 0000000000..da8d53d8fd --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/project_tree_reader.go @@ -0,0 +1,130 @@ +package compilecheck + +import ( + "os" + "path/filepath" + "sync" +) + +// projectTreeReadConcurrency is how many directories the walk reads at once. +// Why 8: reading a large project's directories with 8 readers took well under half the time of +// one, and 16 took no less than 8; a small CI runner is not crowded by 8 either. +const projectTreeReadConcurrency = 8 + +// projectTreeListing is one directory as read from the disk, or the error reading it gave. +type projectTreeListing struct { + directory projectTreeDirectory + err error +} + +// readProjectTreeListing reads one directory and lists it without the names Unity ignores. +// Why the entries of a failed read are dropped: os.ReadDir can return part of a directory together +// with its error, and the walk stops at that error without looking at any of them. +func readProjectTreeListing(path string) projectTreeListing { + entries, err := os.ReadDir(path) + if err != nil { + return projectTreeListing{err: err} + } + + directory := projectTreeDirectory{byName: map[string]projectTreeEntry{}} + for _, entry := range entries { + if isUnityIgnoredName(entry.Name()) { + continue + } + listed := projectTreeEntry{ + name: entry.Name(), + isDirectory: entry.IsDir(), + isRegular: entry.Type().IsRegular(), + } + directory.entries = append(directory.entries, listed) + directory.byName[listed.name] = listed + } + + return projectTreeListing{directory: directory} +} + +// readProjectTreeListings reads the given directories and every directory below them that the walk +// would descend into, several at a time, and returns once every read has finished. The result is +// keyed by the path each directory was read under. A directory two roots share is read once. +func readProjectTreeListings(rootPaths []string) map[string]projectTreeListing { + reader := &projectTreeReader{ + listings: map[string]projectTreeListing{}, + queued: map[string]bool{}, + } + reader.wake = sync.NewCond(&reader.mu) + for _, rootPath := range rootPaths { + reader.enqueue(rootPath) + } + + var workers sync.WaitGroup + for range projectTreeReadConcurrency { + workers.Go(reader.work) + } + workers.Wait() + + return reader.listings +} + +// projectTreeReader is the shared queue of directories the read-ahead still has to read. +type projectTreeReader struct { + mu sync.Mutex + wake *sync.Cond + queue []string + pending int // directories queued or being read + listings map[string]projectTreeListing + queued map[string]bool +} + +// enqueue adds a directory unless it was queued before. The caller holds mu, or no worker runs yet. +func (reader *projectTreeReader) enqueue(path string) { + if reader.queued[path] { + return + } + reader.queued[path] = true + reader.queue = append(reader.queue, path) + reader.pending++ +} + +// work reads queued directories until none is queued or being read. +func (reader *projectTreeReader) work() { + for { + path, found := reader.next() + if !found { + return + } + reader.finish(path, readProjectTreeListing(path)) + } +} + +// next takes a queued directory, waiting while other workers may still queue more. +func (reader *projectTreeReader) next() (string, bool) { + reader.mu.Lock() + defer reader.mu.Unlock() + for len(reader.queue) == 0 && reader.pending > 0 { + reader.wake.Wait() + } + if len(reader.queue) == 0 { + return "", false + } + path := reader.queue[0] + reader.queue = reader.queue[1:] + + return path, true +} + +// finish stores one listing and queues the directories below it that the walk would descend into. +// Why nothing below a failed read is queued: the walk stops there, so none of it is needed. +func (reader *projectTreeReader) finish(path string, listing projectTreeListing) { + reader.mu.Lock() + defer reader.mu.Unlock() + reader.listings[path] = listing + if listing.err == nil { + for _, entry := range listing.directory.entries { + if entry.isDirectory { + reader.enqueue(filepath.Join(path, entry.name)) + } + } + } + reader.pending-- + reader.wake.Broadcast() +} diff --git a/cli/dispatcher/internal/compilecheck/project_tree_test.go b/cli/dispatcher/internal/compilecheck/project_tree_test.go index e6995f5cba..47ae6666d0 100644 --- a/cli/dispatcher/internal/compilecheck/project_tree_test.go +++ b/cli/dispatcher/internal/compilecheck/project_tree_test.go @@ -1,7 +1,6 @@ package compilecheck import ( - "io/fs" "os" "path/filepath" "reflect" @@ -149,21 +148,3 @@ func TestWalkProjectTreeStopsAtTheFirstRootThatFails(t *testing.T) { t.Fatal("a directory of a root that walked cleanly was not listed") } } - -// Verifies recording an entry whose parent the walk has not listed starts the parent's listing -// instead of writing to a missing one, which a root whose cleaned parent key differs could cause. -func TestAddProjectTreeEntryStartsAMissingParentListing(t *testing.T) { - path := filepath.Join(t.TempDir(), "A.cs") - writeFileAt(t, path, "") - info, err := os.Stat(path) - if err != nil { - t.Fatalf("failed to stat %s: %v", path, err) - } - directories := map[string]projectTreeDirectory{} - - addProjectTreeEntry(directories, path, fs.FileInfoToDirEntry(info)) - - if entry, found := directories[filepath.Dir(path)].byName["A.cs"]; !found || !entry.isRegular { - t.Fatalf("listing = %+v, want A.cs as a regular file", directories[filepath.Dir(path)]) - } -} From 7d1ed15c9d34662d0bfff26551d34f72f0f50290 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 10:57:04 +0900 Subject: [PATCH 08/33] perf: compile-check reads assembly definitions and checks assemblies in parallel (#3323) --- .../internal/compilecheck/parallel_compute.go | 36 ++++++++++ .../compilecheck/parallel_compute_test.go | 66 +++++++++++++++++ cli/dispatcher/internal/compilecheck/plan.go | 70 ++++++++++++++----- .../internal/compilecheck/plan_order_test.go | 62 ++++++++++++++++ .../compilecheck/project_tree_index_test.go | 44 ++++++++++++ .../compilecheck/project_tree_reader.go | 7 +- .../internal/compilecheck/source_set.go | 65 ++++++++++++----- 7 files changed, 308 insertions(+), 42 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/parallel_compute.go create mode 100644 cli/dispatcher/internal/compilecheck/parallel_compute_test.go create mode 100644 cli/dispatcher/internal/compilecheck/plan_order_test.go diff --git a/cli/dispatcher/internal/compilecheck/parallel_compute.go b/cli/dispatcher/internal/compilecheck/parallel_compute.go new file mode 100644 index 0000000000..4a57f1224c --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/parallel_compute.go @@ -0,0 +1,36 @@ +package compilecheck + +import ( + "sync" + "sync/atomic" +) + +// planReadConcurrency is how many reads planning runs at once. +// Why 8: reading a large project's directories with 8 readers took well under half the time of +// one, and 16 took no less than 8; a small CI runner is not crowded by 8 either. +const planReadConcurrency = 8 + +// computeInParallel calls compute for every index from 0 to count-1, up to planReadConcurrency at a +// time, and returns the results in index order once every call has finished. +// compute must not write to anything another call reads or writes; the caller then walks the +// results in order, so whatever decides between them stays as it was when they were computed one by +// one. +func computeInParallel[T any](count int, compute func(index int) T) []T { + results := make([]T, count) + var next atomic.Int64 + var workers sync.WaitGroup + for range min(count, planReadConcurrency) { + workers.Go(func() { + for { + index := int(next.Add(1) - 1) + if index >= count { + return + } + results[index] = compute(index) + } + }) + } + workers.Wait() + + return results +} diff --git a/cli/dispatcher/internal/compilecheck/parallel_compute_test.go b/cli/dispatcher/internal/compilecheck/parallel_compute_test.go new file mode 100644 index 0000000000..a1e4a910cb --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/parallel_compute_test.go @@ -0,0 +1,66 @@ +package compilecheck + +import ( + "reflect" + "sync" + "testing" + "time" +) + +// Verifies each result lands at the index it was computed for, whatever order the computations +// finish in. +func TestComputeInParallelPlacesEachResultAtItsIndex(t *testing.T) { + const count = 50 + results := computeInParallel(count, func(index int) int { + // Why the early indexes wait longer: they then finish after later ones, which a helper that + // appended results as they arrived would put out of place. + time.Sleep(time.Duration(count-index) * 100 * time.Microsecond) + + return index * 10 + }) + + want := make([]int, count) + for index := range want { + want[index] = index * 10 + } + if !reflect.DeepEqual(results, want) { + t.Fatalf("results = %v, want %v", results, want) + } +} + +// Verifies no computation runs for a count of zero and the result is empty. +func TestComputeInParallelReturnsNothingForZero(t *testing.T) { + called := false + results := computeInParallel(0, func(int) int { + called = true + + return 0 + }) + + if called || len(results) != 0 { + t.Fatalf("results = %v, called = %v, want no results and no call", results, called) + } +} + +// Verifies the helper runs computations side by side but never more than its limit at once. +func TestComputeInParallelKeepsWithinItsLimit(t *testing.T) { + var mu sync.Mutex + running := 0 + most := 0 + computeInParallel(planReadConcurrency*4, func(int) struct{} { + mu.Lock() + running++ + most = max(most, running) + mu.Unlock() + time.Sleep(5 * time.Millisecond) + mu.Lock() + running-- + mu.Unlock() + + return struct{}{} + }) + + if most > planReadConcurrency || most < 2 { + t.Fatalf("at most %d computations ran at once, want between 2 and %d", most, planReadConcurrency) + } +} diff --git a/cli/dispatcher/internal/compilecheck/plan.go b/cli/dispatcher/internal/compilecheck/plan.go index b014775dde..dfaad0858d 100644 --- a/cli/dispatcher/internal/compilecheck/plan.go +++ b/cli/dispatcher/internal/compilecheck/plan.go @@ -192,27 +192,32 @@ func selectChangedAssemblies( return nil, referenceErr } - failedLastBuild := inputs.failedLastBuild - - reasons := map[string]string{} - for _, name := range graph.names { - rsp := graph.byName[name] - asmdef := lookupAssemblyDefinition(assemblyDefinitions, name) - if structuralErr := DetectStructuralChange( - projectRoot, rsp, asmdef, dagDir, context); structuralErr != nil { - return nil, structuralErr - } + return decideAssembliesInNameOrder(graph, inputs.failedLastBuild, all, func(name string) assemblyCheck { + return checkAssembly(projectRoot, dagDir, graph.byName[name], + lookupAssemblyDefinition(assemblyDefinitions, name), context, owners) + }) +} - sources, sourceErr := RebuildSources(projectRoot, rsp, asmdef, owners) - if sourceErr != nil { - return nil, sourceErr - } - graph.sources[name] = sources +// decideAssembliesInNameOrder checks every assembly, then records each one's sources and why it needs +// compiling, in name order, returning the first error in that order. +// Why every assembly is checked side by side first: each check only reads the disk and the indexes +// built before it, while the order below decides which error is reported, so only the checks may +// run in any order and the result does not depend on the order they finish in. +func decideAssembliesInNameOrder( + graph assemblyGraph, failedLastBuild map[string]bool, all bool, check func(name string) assemblyCheck, +) (map[string]string, error) { + checks := computeInParallel(len(graph.names), func(index int) assemblyCheck { + return check(graph.names[index]) + }) - report, changeErr := DetectSourceChange(projectRoot, rsp, sources, dagDir) - if changeErr != nil { - return nil, changeErr + reasons := map[string]string{} + for index, name := range graph.names { + result := checks[index] + if result.err != nil { + return nil, result.err } + graph.sources[name] = result.sources + report := result.report // Why a failed build only speaks for an assembly nothing else selected: when a source did // move, that is what this run found itself, and it says more about why the assembly needs // compiling than what the previous build made of it. @@ -233,6 +238,35 @@ func selectChangedAssemblies( return reasons, nil } +// assemblyCheck is what checking one assembly found: its sources and whether they changed, or the +// first error the checks met. +type assemblyCheck struct { + sources []string + report ChangeReport + err error +} + +// checkAssembly runs one assembly's checks in the order whose first error is the one to report: its +// structure, then its sources, then whether they changed since Unity built it. +func checkAssembly( + projectRoot string, dagDir string, rsp ResponseFile, asmdef *AssemblyDefinition, + context AssemblyContext, owners assemblyOwnerIndex, +) assemblyCheck { + if err := DetectStructuralChange(projectRoot, rsp, asmdef, dagDir, context); err != nil { + return assemblyCheck{err: err} + } + sources, err := RebuildSources(projectRoot, rsp, asmdef, owners) + if err != nil { + return assemblyCheck{err: err} + } + report, err := DetectSourceChange(projectRoot, rsp, sources, dagDir) + if err != nil { + return assemblyCheck{err: err} + } + + return assemblyCheck{sources: sources, report: report} +} + // lookupAssemblyDefinition finds the .asmdef for an assembly, if the assembly has one at all. func lookupAssemblyDefinition( assemblyDefinitions map[string]AssemblyDefinition, name string, diff --git a/cli/dispatcher/internal/compilecheck/plan_order_test.go b/cli/dispatcher/internal/compilecheck/plan_order_test.go new file mode 100644 index 0000000000..4096334128 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/plan_order_test.go @@ -0,0 +1,62 @@ +package compilecheck + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// Verifies that when two assemblies each fail their own check, planning reports the one first in +// name order. +func TestBuildCompilePlanReportsTheFirstFailingAssemblyInNameOrder(t *testing.T) { + projectRoot := t.TempDir() + names := []string{} + for index := range 20 { + names = append(names, "A"+string(rune('A'+index))) + } + for _, name := range names { + writePlanAssembly(t, projectRoot, name, nil) + } + settlePlanProject(t, projectRoot, names...) + for _, name := range []string{names[2], names[17]} { + if err := os.Remove(filepath.Join( + projectRoot, planDagDirectory, name+".UnityAdditionalFile.txt")); err != nil { + t.Fatalf("failed to remove the additional file of %s: %v", name, err) + } + } + + _, err := BuildCompilePlan(projectRoot, planDagDirectory, false) + + if err == nil || !strings.Contains(err.Error(), "the Bee artifacts are incomplete for "+names[2]+";") { + t.Fatalf("err = %v, want the incomplete artifacts of %s reported", err, names[2]) + } +} + +// Verifies the first failing assembly in name order is reported even when a later assembly's check +// finishes first: the earlier check is held until the later one has returned its error. +func TestDecideAssembliesInNameOrderReportsTheFirstErrorWhateverFinishesFirst(t *testing.T) { + graph := assemblyGraph{names: []string{"A", "B", "C"}, sources: map[string][]string{}} + laterReturned := make(chan struct{}) + errB := errors.New("B failed") + + _, err := decideAssembliesInNameOrder(graph, map[string]bool{}, false, func(name string) assemblyCheck { + switch name { + case "B": + <-laterReturned + + return assemblyCheck{err: errB} + case "C": + defer close(laterReturned) + + return assemblyCheck{err: errors.New("C failed")} + default: + return assemblyCheck{} + } + }) + + if !errors.Is(err, errB) { + t.Fatalf("err = %v, want the error of B", err) + } +} diff --git a/cli/dispatcher/internal/compilecheck/project_tree_index_test.go b/cli/dispatcher/internal/compilecheck/project_tree_index_test.go index f270a1e583..9e7b1fa950 100644 --- a/cli/dispatcher/internal/compilecheck/project_tree_index_test.go +++ b/cli/dispatcher/internal/compilecheck/project_tree_index_test.go @@ -1,6 +1,7 @@ package compilecheck import ( + "errors" "path/filepath" "strings" "testing" @@ -131,3 +132,46 @@ func TestIndexAssemblyReferencesReportsAnUnreadableDirectory(t *testing.T) { t.Fatalf("err = %v, want the unreadable Assets directory reported", err) } } + +// Verifies that of two broken .asmdef files in one root, the one the walk meets first is reported, +// which is not the one whose full path sorts first. +func TestIndexAssemblyDefinitionsReportsTheFirstBrokenAsmdefInWalkOrder(t *testing.T) { + projectRoot := t.TempDir() + first := filepath.Join(projectRoot, "Assets", "a", "X.asmdef") + writeFileAt(t, first, "not json") + writeFileAt(t, filepath.Join(projectRoot, "Assets", "a.b", "Y.asmdef"), "not json") + + _, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) + + wantPrefix := "failed to index assembly definitions under " + filepath.Join(projectRoot, "Assets") + ": failed to read " + first + if err == nil || !strings.HasPrefix(err.Error(), wantPrefix) { + t.Fatalf("err = %v, want it to start with %q", err, wantPrefix) + } +} + +// Verifies the first broken .asmdef in walk order is reported even when a later file's read finishes +// first: the earlier read is held until the later one has returned its error. +func TestIndexAssemblyDefinitionsReportsTheFirstErrorWhateverFinishesFirst(t *testing.T) { + projectRoot := t.TempDir() + first := filepath.Join(projectRoot, "Assets", "a", "X.asmdef") + later := filepath.Join(projectRoot, "Assets", "a.b", "Y.asmdef") + writeFileAt(t, first, "") + writeFileAt(t, later, "") + laterReturned := make(chan struct{}) + errFirst := errors.New("first failed") + + _, err := indexAssemblyDefinitionsReading(walkProjectTree(projectRoot), func(path string) assemblyDefinitionRead { + if path == first { + <-laterReturned + + return assemblyDefinitionRead{err: errFirst} + } + defer close(laterReturned) + + return assemblyDefinitionRead{err: errors.New("later failed")} + }) + + if !errors.Is(err, errFirst) { + t.Fatalf("err = %v, want the error of the file met first", err) + } +} diff --git a/cli/dispatcher/internal/compilecheck/project_tree_reader.go b/cli/dispatcher/internal/compilecheck/project_tree_reader.go index da8d53d8fd..3d9efac554 100644 --- a/cli/dispatcher/internal/compilecheck/project_tree_reader.go +++ b/cli/dispatcher/internal/compilecheck/project_tree_reader.go @@ -6,11 +6,6 @@ import ( "sync" ) -// projectTreeReadConcurrency is how many directories the walk reads at once. -// Why 8: reading a large project's directories with 8 readers took well under half the time of -// one, and 16 took no less than 8; a small CI runner is not crowded by 8 either. -const projectTreeReadConcurrency = 8 - // projectTreeListing is one directory as read from the disk, or the error reading it gave. type projectTreeListing struct { directory projectTreeDirectory @@ -57,7 +52,7 @@ func readProjectTreeListings(rootPaths []string) map[string]projectTreeListing { } var workers sync.WaitGroup - for range projectTreeReadConcurrency { + for range planReadConcurrency { workers.Go(reader.work) } workers.Wait() diff --git a/cli/dispatcher/internal/compilecheck/source_set.go b/cli/dispatcher/internal/compilecheck/source_set.go index bec236afab..f4a634dee7 100644 --- a/cli/dispatcher/internal/compilecheck/source_set.go +++ b/cli/dispatcher/internal/compilecheck/source_set.go @@ -51,12 +51,44 @@ func projectIndexRoots(projectRoot string) []string { // IndexAssemblyDefinitions maps every assembly name in the project to the .asmdef that declares it. // Each root's .asmdef files are read in walk order before that root's walk error is reported, which // is the order the errors surfaced in when each file was read as the walk met it. +// Why the files are read side by side first: a large project holds hundreds of them, and the +// decisions below - which of two same-named files wins, which error is reported - only need the +// results in walk order, not the reads. func IndexAssemblyDefinitions(tree projectTree) (map[string]AssemblyDefinition, error) { + return indexAssemblyDefinitionsReading(tree, readAssemblyDefinition) +} + +// indexAssemblyDefinitionsReading is IndexAssemblyDefinitions with the read of one .asmdef given. +// The index it returns does not depend on the order the reads finish in. +func indexAssemblyDefinitionsReading( + tree projectTree, read func(path string) assemblyDefinitionRead, +) (map[string]AssemblyDefinition, error) { + paths := []string{} + for _, root := range tree.roots { + paths = append(paths, root.definitionFiles...) + } + reads := computeInParallel(len(paths), func(index int) assemblyDefinitionRead { + return read(paths[index]) + }) + index := map[string]AssemblyDefinition{} + next := 0 for _, root := range tree.roots { for _, path := range root.definitionFiles { - if err := addAssemblyDefinition(path, index); err != nil { - return nil, fmt.Errorf(assemblyDefinitionIndexError, root.path, err) + read := reads[next] + next++ + if read.err != nil { + return nil, fmt.Errorf(assemblyDefinitionIndexError, root.path, read.err) + } + // Why the first wins: Unity itself rejects duplicate assembly names, so a second one means + // a stale copy, and taking it would point the rebuild at the wrong directory. + if _, exists := index[read.name]; !exists { + index[read.name] = AssemblyDefinition{ + Name: read.name, + Path: path, + Directory: filepath.Dir(path), + GUID: read.guid, + } } } if root.walkErr != nil { @@ -67,24 +99,21 @@ func IndexAssemblyDefinitions(tree projectTree) (map[string]AssemblyDefinition, return index, nil } -// addAssemblyDefinition adds one .asmdef to the index unless an earlier one declared the same name. -func addAssemblyDefinition(path string, index map[string]AssemblyDefinition) error { - name, readErr := readAssemblyDefinitionName(path) - if readErr != nil { - return readErr - } - // Why the first wins: Unity itself rejects duplicate assembly names, so a second one means a - // stale copy, and taking it would point the rebuild at the wrong directory. - if _, exists := index[name]; !exists { - index[name] = AssemblyDefinition{ - Name: name, - Path: path, - Directory: filepath.Dir(path), - GUID: readAssemblyDefinitionGUID(path), - } +// assemblyDefinitionRead is what one .asmdef and its .meta say, or why the .asmdef could not be read. +type assemblyDefinitionRead struct { + name string + guid string + err error +} + +// readAssemblyDefinition reads the name one .asmdef declares and the GUID its .meta records. +func readAssemblyDefinition(path string) assemblyDefinitionRead { + name, err := readAssemblyDefinitionName(path) + if err != nil { + return assemblyDefinitionRead{err: err} } - return nil + return assemblyDefinitionRead{name: name, guid: readAssemblyDefinitionGUID(path)} } // readUnityJSONFile reads one of Unity's JSON files into target. From 1c2d6134c91a4c56b5bb99534b3dd3a421a17cfb Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 11:38:27 +0900 Subject: [PATCH 09/33] fix: compile-check walks local packages in a fixed order (#3324) --- .../internal/compilecheck/local_packages.go | 14 +++++- .../compilecheck/local_packages_test.go | 46 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/cli/dispatcher/internal/compilecheck/local_packages.go b/cli/dispatcher/internal/compilecheck/local_packages.go index 20709c3f40..c4db657b5a 100644 --- a/cli/dispatcher/internal/compilecheck/local_packages.go +++ b/cli/dispatcher/internal/compilecheck/local_packages.go @@ -2,6 +2,7 @@ package compilecheck import ( "path/filepath" + "sort" "strings" ) @@ -17,6 +18,9 @@ const ( // the package owns then looks like one Unity built and the project since deleted. // A missing or unreadable manifest yields no roots rather than an error: the built-in roots still // describe the project, and a manifest problem must not turn into a removed-assembly refusal. +// Why they come in dependency name order: the walk meets the roots in this order, and it decides +// which of two same-named assembly definitions wins and which error is reported first, so a map's +// random order would change both from run to run. func localPackageRoots(projectRoot string) []string { packagesDirectory := filepath.Join(projectRoot, packagesDirectoryName) @@ -28,9 +32,15 @@ func localPackageRoots(projectRoot string) []string { return nil } + names := make([]string, 0, len(manifest.Dependencies)) + for name := range manifest.Dependencies { + names = append(names, name) + } + sort.Strings(names) + roots := []string{} - for _, location := range manifest.Dependencies { - root, isLocal := resolveLocalPackagePath(packagesDirectory, location) + for _, name := range names { + root, isLocal := resolveLocalPackagePath(packagesDirectory, manifest.Dependencies[name]) if !isLocal { continue } diff --git a/cli/dispatcher/internal/compilecheck/local_packages_test.go b/cli/dispatcher/internal/compilecheck/local_packages_test.go index e444cb1ac7..6dde142f62 100644 --- a/cli/dispatcher/internal/compilecheck/local_packages_test.go +++ b/cli/dispatcher/internal/compilecheck/local_packages_test.go @@ -2,6 +2,7 @@ package compilecheck import ( "path/filepath" + "strings" "testing" "time" ) @@ -180,3 +181,48 @@ func TestLocalPackageRootsReadsAManifestWithAByteOrderMark(t *testing.T) { t.Errorf("root = %q, want the directory the manifest points at", roots[0]) } } + +// Verifies local package roots come in dependency name order on every run, whatever order the +// manifest lists them in and wherever registry dependencies sit among them. +func TestLocalPackageRootsFollowTheDependencyNameOrder(t *testing.T) { + projectRoot := filepath.Join(t.TempDir(), "Project") + writeFileAt(t, filepath.Join(projectRoot, "Packages", "manifest.json"), + `{"dependencies":{`+ + `"com.example.c":"file:../../C",`+ + `"com.unity.test-framework":"1.1.33",`+ + `"com.example.a":"file:../../A",`+ + `"com.example.d":"file:../../D",`+ + `"com.example.b":"file:../../B"}}`) + + for run := range 20 { + bases := []string{} + for _, root := range localPackageRoots(projectRoot) { + bases = append(bases, filepath.Base(root)) + } + if strings.Join(bases, ",") != "A,B,C,D" { + t.Fatalf("run %d: roots = %v, want A, B, C, D", run, bases) + } + } +} + +// Verifies that of two local packages declaring the same assembly name, the one whose dependency +// name comes first wins on every run. +func TestIndexAssemblyDefinitionsPicksTheSameDuplicateEveryRun(t *testing.T) { + parent := t.TempDir() + projectRoot := filepath.Join(parent, "Project") + writeFileAt(t, filepath.Join(projectRoot, "Packages", "manifest.json"), + `{"dependencies":{"com.example.b":"file:../../B","com.example.a":"file:../../A"}}`) + for _, packageName := range []string{"A", "B"} { + writeFileAt(t, filepath.Join(parent, packageName, "Runtime", "Dup.asmdef"), `{"name":"Dup"}`) + } + + for run := range 20 { + assemblyDefinitions, err := IndexAssemblyDefinitions(walkProjectTree(projectRoot)) + if err != nil { + t.Fatalf("run %d: failed to index the assembly definitions: %v", run, err) + } + if !isUnderDirectory(assemblyDefinitions["Dup"].Path, filepath.Join(parent, "A")) { + t.Fatalf("run %d: Dup = %q, want the one in package A", run, assemblyDefinitions["Dup"].Path) + } + } +} From 38cb0b0ba49fd6d10afa00e44d476273ec4f8726 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 12:15:41 +0900 Subject: [PATCH 10/33] refactor: gather compile-check's records behind a record store (#3325) --- .../internal/compilecheck/baseline_record.go | 116 +-------- .../compilecheck/baseline_record_test.go | 6 +- .../internal/compilecheck/compiler.go | 30 +-- .../internal/compilecheck/fast_path.go | 93 ++------ .../compilecheck/fast_path_order_test.go | 132 +++++++++++ .../internal/compilecheck/fast_path_test.go | 6 +- .../compilecheck/record_format_test.go | 90 +++++++ .../internal/compilecheck/record_store.go | 206 ++++++++++++++++ .../compilecheck/record_store_baseline.go | 221 ++++++++++++++++++ .../internal/compilecheck/result_cache.go | 197 +++------------- .../compilecheck/result_cache_test.go | 39 ++-- .../compilecheck/result_cache_write_test.go | 31 ++- .../internal/compilecheck/scheduler.go | 38 ++- .../compilecheck/scheduler_cache_test.go | 21 ++ 14 files changed, 795 insertions(+), 431 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/fast_path_order_test.go create mode 100644 cli/dispatcher/internal/compilecheck/record_format_test.go create mode 100644 cli/dispatcher/internal/compilecheck/record_store.go create mode 100644 cli/dispatcher/internal/compilecheck/record_store_baseline.go diff --git a/cli/dispatcher/internal/compilecheck/baseline_record.go b/cli/dispatcher/internal/compilecheck/baseline_record.go index 166b554141..360045d574 100644 --- a/cli/dispatcher/internal/compilecheck/baseline_record.go +++ b/cli/dispatcher/internal/compilecheck/baseline_record.go @@ -2,39 +2,14 @@ package compilecheck import ( "context" - "encoding/json" "errors" "fmt" - "io/fs" - "os" "path/filepath" "strings" "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" ) -// baselineRecord is what a successful full compile leaves for the next changed-files check. -type baselineRecord struct { - FormatVersion int - // InputKey is the key of the full compile this baseline describes; its manifest carries the same. - InputKey string - // NonSourceKey covers every input but the source contents, so any other change compiles in full. - NonSourceKey nonSourceIdentity - // WorkerKey names the worker build that computed the fingerprints. - WorkerKey string - GeneratedDirectory string // relative to the project root - // GeneratedFiles maps each generated file, relative to GeneratedDirectory, to its content hash, - // so a check never runs without the generator output its baseline compile had. - GeneratedFiles map[string]string - Files map[string]baselineFile -} - -// baselineFile describes one source as the full compile saw it. -type baselineFile struct { - ContentHash string - Fingerprint string -} - // RecordBaseline records what the next changed-files check of a unit compares against, after a // successful full compile. nonSourceKey is the unit's non-source key computed before the compile. // Why every content hash must match the one the input key read, and the non-source key the one @@ -81,74 +56,14 @@ func (runner *fastPathRunner) RecordBaseline( return err } - generatedFiles, err := hashGeneratedFiles(filepath.Join(c.ProjectRoot, generatedDirectory(plan, unit))) - if err != nil { - return err - } - - return writeBaselineRecord(filepath.Join(c.ProjectRoot, plan.OutputDir), unit, baselineRecord{ - FormatVersion: baselineRecordFormatVersion, - InputKey: keyResult.Key, - NonSourceKey: nonSourceKey, - WorkerKey: worker.Key, - GeneratedDirectory: filepath.ToSlash(generatedDirectory(plan, unit)), - GeneratedFiles: generatedFiles, - Files: files, + return c.records(plan).recordBaseline(unit.Assembly.AssemblyName, baselineInputs{ + InputKey: keyResult.Key, + NonSourceKey: nonSourceKey, + WorkerKey: worker.Key, + Files: files, }) } -// hashGeneratedFiles hashes every generated C# file under directoryPath, keyed by its slash-separated -// path relative to it. A missing directory holds no files. -func hashGeneratedFiles(directoryPath string) (map[string]string, error) { - hashes := map[string]string{} - err := filepath.WalkDir(directoryPath, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - if os.IsNotExist(walkErr) && path == directoryPath { - return filepath.SkipDir - } - - return walkErr - } - if entry.IsDir() || filepath.Ext(path) != ".cs" { - return nil - } - relative, err := filepath.Rel(directoryPath, path) - if err != nil { - return err - } - hash, err := hashFile(path) - if err != nil { - return err - } - hashes[filepath.ToSlash(relative)] = hash - - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to read the generator output: %w", err) - } - - return hashes, nil -} - -// generatedFilesMatch reports whether the generator output on disk is exactly what the baseline -// recorded: no file missing, added, or changed. -// Why: the worker reads this output instead of running the generators, and checking without it -// reports errors wherever code uses what the generators produce. -func generatedFilesMatch(directoryPath string, recorded map[string]string) bool { - current, err := hashGeneratedFiles(directoryPath) - if err != nil || len(current) != len(recorded) { - return false - } - for name, hash := range recorded { - if current[name] != hash { - return false - } - } - - return true -} - // baselineFiles pairs each source's content hash, as the input key read it, with its fingerprint. func baselineFiles( projectRoot string, unit CompileUnit, keyResult inputKeyResult, fingerprints fastcheck.FingerprintResponse, @@ -169,24 +84,3 @@ func baselineFiles( return files, nil } - -// writeBaselineRecord writes a baseline through a temporary file, like the manifest. -func writeBaselineRecord(outputDirectoryPath string, unit CompileUnit, record baselineRecord) error { - content, err := json.Marshal(record) - if err != nil { - return fmt.Errorf("failed to record the baseline of %s: %w", unit.Assembly.AssemblyName, err) - } - - return writeFileAtomically(outputDirectoryPath, baselineRecordPath(outputDirectoryPath, unit), content) -} - -// baselineRecordPath names where one unit's baseline lives. -func baselineRecordPath(outputDirectoryPath string, unit CompileUnit) string { - return filepath.Join(outputDirectoryPath, unit.Assembly.AssemblyName+baselineRecordExtension) -} - -// generatedDirectory names, relative to the project root, where a full compile saves the generator -// output of a unit. -func generatedDirectory(plan BuildPlan, unit CompileUnit) string { - return filepath.Join(plan.OutputDir, unit.Assembly.AssemblyName+generatedDirectoryExtension) -} diff --git a/cli/dispatcher/internal/compilecheck/baseline_record_test.go b/cli/dispatcher/internal/compilecheck/baseline_record_test.go index 347e0a75e5..8381170688 100644 --- a/cli/dispatcher/internal/compilecheck/baseline_record_test.go +++ b/cli/dispatcher/internal/compilecheck/baseline_record_test.go @@ -123,7 +123,7 @@ func TestCompileUnitPassesGeneratedFilesOut(t *testing.T) { written, _ := compileSecondUnit(t, "", 0) plan := newCompilerPlan() - want := quoteFlag(generatedFilesOutFlagPrefix, filepath.ToSlash(generatedDirectory(plan, plan.Units[1]))) + want := quoteFlag(generatedFilesOutFlagPrefix, filepath.ToSlash(openRecordStore("", plan.OutputDir).generatedDirectory(plan.Units[1].Assembly.AssemblyName))) if !strings.Contains(written, want) { t.Errorf("expected %s in the response file, got:\n%s", want, written) } @@ -139,7 +139,7 @@ func TestRemoveUnitOutputsRemovesBaselineAndGeneratedDirectory(t *testing.T) { } writeOutputFile(t, generatedPath, "G.cs", "class G {}") - if err := removeUnitOutputs(outputDirectoryPath, unit); err != nil { + if err := (Compiler{ProjectRoot: outputDirectoryPath}).removeUnitOutputs(BuildPlan{}, unit); err != nil { t.Fatalf("expected the outputs to be removed, got error: %v", err) } @@ -236,7 +236,7 @@ func TestCompileUnitCreatesGeneratedFilesOutDirectory(t *testing.T) { ProjectRoot: projectRoot, Timeout: time.Minute, Run: func(_ context.Context, _ *exec.Cmd) (string, string, int, error) { - info, err := os.Stat(filepath.Join(projectRoot, generatedDirectory(plan, plan.Units[1]))) + info, err := os.Stat(filepath.Join(projectRoot, openRecordStore(projectRoot, plan.OutputDir).generatedDirectory(plan.Units[1].Assembly.AssemblyName))) existed = err == nil && info.IsDir() return "", "", 0, nil }, diff --git a/cli/dispatcher/internal/compilecheck/compiler.go b/cli/dispatcher/internal/compilecheck/compiler.go index e3e98142c5..530362d8bc 100644 --- a/cli/dispatcher/internal/compilecheck/compiler.go +++ b/cli/dispatcher/internal/compilecheck/compiler.go @@ -98,18 +98,17 @@ type Compiler struct { func (c Compiler) CompileUnit( ctx context.Context, plan BuildPlan, unit CompileUnit, ) (UnitResult, error) { - outputDirectoryPath := filepath.Join(c.ProjectRoot, plan.OutputDir) // Why the previous run's outputs go first: csc writes nothing when it fails, so a leftover // reference assembly from an earlier run would keep describing an assembly that no longer // compiles, and every dependent would be checked against an API that is gone. - if err := removeUnitOutputs(outputDirectoryPath, unit); err != nil { + if err := c.removeUnitOutputs(plan, unit); err != nil { return UnitResult{}, err } // Why the generator output directory is created here: the csc of Roslyn 4.3 (Unity 2022.3) creates // the folder of each generator under it but not the directory itself, and fails every generated // file with CS0016 when it is missing. Newer Roslyn creates it either way. - generatedPath := filepath.Join(c.ProjectRoot, generatedDirectory(plan, unit)) + generatedPath := filepath.Join(c.ProjectRoot, c.records(plan).generatedDirectory(unit.Assembly.AssemblyName)) if err := os.MkdirAll(generatedPath, outputDirPermissions); err != nil { return UnitResult{}, fmt.Errorf("failed to create %s: %w", generatedPath, err) } @@ -117,7 +116,7 @@ func (c Compiler) CompileUnit( // Why the generator output is saved: a later fast check reads it instead of running the generators. responseFilePath, err := writeRewrittenResponseFile(c.ProjectRoot, plan, unit, unit.Assembly.AssemblyName+responseFileExtension, - []string{quoteFlag(generatedFilesOutFlagPrefix, filepath.ToSlash(generatedDirectory(plan, unit)))}) + []string{quoteFlag(generatedFilesOutFlagPrefix, filepath.ToSlash(c.records(plan).generatedDirectory(unit.Assembly.AssemblyName)))}) if err != nil { return UnitResult{}, err } @@ -155,30 +154,21 @@ func (c Compiler) CompileUnit( return buildUnitResult(unit.Assembly.AssemblyName, stdout, stderr, exitCode, duration), nil } -// removeUnitOutputs deletes what a previous run left for this assembly in the check's output directory. -func removeUnitOutputs(outputDirectoryPath string, unit CompileUnit) error { - names := []string{ +// removeUnitOutputs deletes what a previous run left for this assembly in the check's output +// directory: its outputs, and then every record of the compile that wrote them. +func (c Compiler) removeUnitOutputs(plan BuildPlan, unit CompileUnit) error { + outputDirectoryPath := filepath.Join(c.ProjectRoot, plan.OutputDir) + for _, name := range []string{ unit.Assembly.AssemblyName + assemblyExtension, unit.Assembly.AssemblyName + referenceAssemblyExtension, - // Why the manifest goes with them: it describes those very files, so leaving it behind would - // let a later run replay diagnostics for outputs this one removed. - unit.Assembly.AssemblyName + resultManifestExtension, - // The baseline and the generator output describe the same compile as the manifest. - unit.Assembly.AssemblyName + baselineRecordExtension, - } - for _, name := range names { + } { path := filepath.Join(outputDirectoryPath, name) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove %s: %w", path, err) } } - generatedPath := filepath.Join(outputDirectoryPath, unit.Assembly.AssemblyName+generatedDirectoryExtension) - if err := os.RemoveAll(generatedPath); err != nil { - return fmt.Errorf("failed to remove %s: %w", generatedPath, err) - } - - return nil + return c.records(plan).forget(unit.Assembly.AssemblyName) } // buildUnitResult turns one compiler invocation's output into the result for its assembly. diff --git a/cli/dispatcher/internal/compilecheck/fast_path.go b/cli/dispatcher/internal/compilecheck/fast_path.go index 13ba460d51..5665398e1a 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path.go +++ b/cli/dispatcher/internal/compilecheck/fast_path.go @@ -2,10 +2,8 @@ package compilecheck import ( "context" - "encoding/json" "errors" "fmt" - "os" "path/filepath" "runtime" "strings" @@ -15,9 +13,6 @@ import ( ) const ( - baselineRecordFormatVersion = 3 - baselineRecordExtension = ".fastcheck.json" - generatedDirectoryExtension = ".generated" fastCheckResponseFileSuffix = ".fastcheck.rsp" generatedFilesOutFlagPrefix = "-generatedfilesout:" @@ -82,8 +77,8 @@ func (runner *fastPathRunner) Try( ctx context.Context, c Compiler, plan BuildPlan, unit CompileUnit, keyResult inputKeyResult, nonSourceKey nonSourceIdentity, ) fastPathAttempt { - outputDirectoryPath := filepath.Join(c.ProjectRoot, plan.OutputDir) - record, manifest, reason := loadBaseline(outputDirectoryPath, unit) + records := c.records(plan) + baseline, reason := records.loadBaseline(unit.Assembly.AssemblyName) if reason != "" { return fallBack(reason) } @@ -91,16 +86,16 @@ func (runner *fastPathRunner) Try( if nonSourceKey.Key == "" { return fallBack("inputs other than source contents could not be read") } - if nonSourceKey.Key != record.NonSourceKey.Key { + if nonSourceKey.Key != baseline.NonSourceKey.Key { return fallBack("inputs other than source contents changed: " + - strings.Join(changedInputKinds(nonSourceKey, record.NonSourceKey), ", ")) + strings.Join(changedInputKinds(nonSourceKey, baseline.NonSourceKey), ", ")) } - if !generatedFilesMatch(projectPath(c.ProjectRoot, record.GeneratedDirectory), record.GeneratedFiles) { + if !records.generatorOutputUnchanged(baseline) { return fallBack("generator output of the baseline is gone") } - changed := changedSources(unit, keyResult, record) + changed := changedSources(unit, keyResult, baseline.Files) if len(changed) == 0 { // The source contents and every other input match the record, so the input key matches it // too, and reuse would have answered first. Getting here means the record and the manifest @@ -112,14 +107,14 @@ func (runner *fastPathRunner) Try( return fallBack(fmt.Sprintf("too many changed files (%d, limit %d)", len(changed), fastPathMaximumChangedFiles)) } - return runner.check(ctx, c, plan, unit, record, manifest, changed) + return runner.check(ctx, c, plan, unit, baseline, changed) } // check runs the worker over the changed files and merges what it found with the diagnostics the // last full compile reported for the files that did not change. func (runner *fastPathRunner) check( ctx context.Context, c Compiler, plan BuildPlan, unit CompileUnit, - record baselineRecord, manifest unitResultManifest, changed []string, + baseline storedBaseline, changed []string, ) fastPathAttempt { startedAt := time.Now() worker, err := runner.Worker.Ensure(ctx) @@ -130,7 +125,7 @@ func (runner *fastPathRunner) check( return fallBack("worker unavailable: " + err.Error()) } - if worker.Key != record.WorkerKey { + if worker.Key != baseline.WorkerKey { return fallBack("baseline was fingerprinted by another worker build") } @@ -140,7 +135,7 @@ func (runner *fastPathRunner) check( return fallBack("worker failed: " + err.Error()) } - response, err := runner.Worker.Check(ctx, worker, buildCheckRequest(c.ProjectRoot, record, responseFilePath, changed)) + response, err := runner.Worker.Check(ctx, worker, buildCheckRequest(c.ProjectRoot, baseline, responseFilePath, changed)) if ctxErr := ctx.Err(); ctxErr != nil { return fastPathAttempt{Err: ctxErr} } @@ -152,7 +147,7 @@ func (runner *fastPathRunner) check( return fallBack(response.Outcome + ": " + projectRelativeReason(c.ProjectRoot, response.Reason, changed)) } - diagnostics := mergeDiagnostics(c.ProjectRoot, unit, manifest.Diagnostics, response.Diagnostics, changed) + diagnostics := mergeDiagnostics(c.ProjectRoot, unit, baseline.Diagnostics, response.Diagnostics, changed) return fastPathAttempt{Used: true, Result: UnitResult{ Assembly: unit.Assembly.AssemblyName, @@ -179,7 +174,7 @@ func projectRelativeReason(projectRoot string, reason string, changed []string) // buildCheckRequest names, in absolute paths, what the worker checks and against which baseline. func buildCheckRequest( - projectRoot string, record baselineRecord, responseFilePath string, changed []string, + projectRoot string, baseline storedBaseline, responseFilePath string, changed []string, ) fastcheck.CheckRequest { request := fastcheck.CheckRequest{ ProjectRoot: projectRoot, @@ -187,78 +182,24 @@ func buildCheckRequest( ChangedFiles: make([]string, 0, len(changed)), BaselineFingerprints: map[string]string{}, } - if record.GeneratedDirectory != "" { - request.GeneratedDirectory = projectPath(projectRoot, record.GeneratedDirectory) + if baseline.GeneratedDirectory != "" { + request.GeneratedDirectory = projectPath(projectRoot, baseline.GeneratedDirectory) } for _, source := range changed { absolute := projectPath(projectRoot, source) request.ChangedFiles = append(request.ChangedFiles, absolute) - request.BaselineFingerprints[absolute] = record.Files[source].Fingerprint + request.BaselineFingerprints[absolute] = baseline.Files[source].Fingerprint } return request } -// loadBaseline reads the baseline of a unit and the manifest of the full compile it describes, or -// says why there is none to use. -func loadBaseline(outputDirectoryPath string, unit CompileUnit) (baselineRecord, unitResultManifest, string) { - content, err := os.ReadFile(baselineRecordPath(outputDirectoryPath, unit)) - if err != nil { - return baselineRecord{}, unitResultManifest{}, missingBaselineReason(outputDirectoryPath, unit) - } - - var record baselineRecord - if err := json.Unmarshal(content, &record); err != nil || record.FormatVersion != baselineRecordFormatVersion { - return baselineRecord{}, unitResultManifest{}, "no baseline: it was recorded by another version of compile-check" - } - - // In the normal flow the record and the manifest are written together under one key, and a full - // compile removes both, so the checks below only catch a state someone changed by hand. - const goneFromDisk = "baseline compile is no longer on disk" - manifestContent, err := os.ReadFile(manifestPath(outputDirectoryPath, unit)) - if err != nil { - return baselineRecord{}, unitResultManifest{}, goneFromDisk - } - - var manifest unitResultManifest - if err := json.Unmarshal(manifestContent, &manifest); err != nil || - manifest.InputKey != record.InputKey || !manifest.Succeeded || - !outputsStillMatch(outputDirectoryPath, manifest.Outputs) { - return baselineRecord{}, unitResultManifest{}, goneFromDisk - } - - return record, manifest, "" -} - -// missingBaselineReason says why a unit has no baseline, from the manifest of its last full compile. -// Why the reasons are told apart: a full compile with errors drops the baseline an earlier compile -// left, and a reason that read as "never compiled" sent users looking for a compile that had run. -// Why a missing manifest says nothing about what happened: a run that skips or blocks the unit -// removes the manifest and the baseline alike, so nothing on disk tells that apart from no compile. -func missingBaselineReason(outputDirectoryPath string, unit CompileUnit) string { - const notOnRecord = "no baseline: no full compile of this assembly is on record" - content, err := os.ReadFile(manifestPath(outputDirectoryPath, unit)) - if err != nil { - return notOnRecord - } - - var manifest unitResultManifest - if err := json.Unmarshal(content, &manifest); err != nil { - return notOnRecord - } - if !manifest.Succeeded { - return "no baseline: the last full compile had errors" - } - - return "no baseline: the last full compile did not record one" -} - // changedSources lists the sources whose content differs from the baseline, using the hashes the // input key read rather than reading the files again. -func changedSources(unit CompileUnit, keyResult inputKeyResult, record baselineRecord) []string { +func changedSources(unit CompileUnit, keyResult inputKeyResult, files map[string]baselineFile) []string { changed := []string{} for _, source := range unit.Sources { - if keyResult.SourceHashes[source] != record.Files[source].ContentHash { + if keyResult.SourceHashes[source] != files[source].ContentHash { changed = append(changed, source) } } diff --git a/cli/dispatcher/internal/compilecheck/fast_path_order_test.go b/cli/dispatcher/internal/compilecheck/fast_path_order_test.go new file mode 100644 index 0000000000..89f6e3253c --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fast_path_order_test.go @@ -0,0 +1,132 @@ +package compilecheck + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +// recordBaselineWithGeneratorOutput runs the plan once with a generator that writes one file into +// Alpha's generator output, and checks the baseline recorded it. +// Why the baseline needs a generated file: with none recorded, an emptied generator output still +// matches the baseline, and the check that it is gone could run anywhere in the order unnoticed. +func (project *fastPathProject) recordBaselineWithGeneratorOutput(t *testing.T) string { + t.Helper() + generatedDirectoryPath := openRecordStore(project.root, project.plan.OutputDir).generatedDirectory("Alpha") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + recorder.during = func(unit string) { + if unit == "Alpha" { + project.writeFile(t, filepath.Join(generatedDirectoryPath, "Gen", "G.g.cs"), "class G { }") + } + } + project.run(t, recorder, false) + if record := project.readBaseline(t); len(record.GeneratedFiles) != 1 { + t.Fatalf("expected the generated file in the baseline, got %v", record.GeneratedFiles) + } + + return filepath.Join(project.root, generatedDirectoryPath) +} + +// removeGeneratorOutput deletes Alpha's saved generator output. +func removeGeneratorOutput(t *testing.T, generatedPath string) { + t.Helper() + if err := os.RemoveAll(generatedPath); err != nil { + t.Fatalf("failed to remove the generator output: %v", err) + } +} + +// addAlphaSource adds a source to Alpha, which changes its non-source key through the source list. +func (project *fastPathProject) addAlphaSource(t *testing.T) { + t.Helper() + added := "Assets/Alpha/Added.cs" + project.writeFile(t, added, "class Added | body") + project.plan.Units[0].Sources = append(project.plan.Units[0].Sources, added) +} + +// tryAlpha calls the changed-files check on Alpha directly with the given non-source key. +// Why not through a run: with every source unchanged the input key matches the last compile, and +// reuse would answer before the check is tried. +func (project *fastPathProject) tryAlpha(t *testing.T, nonSourceKey nonSourceIdentity) fastPathAttempt { + t.Helper() + makeOutputDirectory(t, project.root, project.plan) + unit := project.plan.Units[0] + keyResult, err := computeInputKey(project.root, project.paths, project.plan, unit) + if err != nil { + t.Fatalf("expected a key, got error: %v", err) + } + compiler := Compiler{Paths: project.paths, ProjectRoot: project.root} + + return project.runner(false).Try(context.Background(), compiler, project.plan, unit, keyResult, nonSourceKey) +} + +// Verifies a changed non-source input is reported ahead of a missing generator output. +func TestFastPathReportsAChangedNonSourceInputBeforeAMissingGeneratorOutput(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + generatedPath := project.recordBaselineWithGeneratorOutput(t) + project.addAlphaSource(t) + removeGeneratorOutput(t, generatedPath) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "inputs other than source contents changed:") +} + +// Verifies a baseline whose compile outputs were changed by hand is reported ahead of a changed +// non-source input. +func TestFastPathReportsAGoneBaselineCompileBeforeAChangedNonSourceInput(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + writeOutputFile(t, project.outputDirectory(), "Alpha"+assemblyExtension, "changed by hand") + project.addAlphaSource(t) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "baseline compile is no longer on disk") +} + +// Verifies a missing generator output is reported ahead of too many changed files. +func TestFastPathReportsAMissingGeneratorOutputBeforeTooManyChangedFiles(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + generatedPath := project.recordBaselineWithGeneratorOutput(t) + removeGeneratorOutput(t, generatedPath) + for index := 0; index <= fastPathMaximumChangedFiles; index++ { + project.editBody(t, index, "edited") + } + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "generator output of the baseline is gone") +} + +// Verifies a missing generator output is reported ahead of a baseline that agrees with every source. +func TestFastPathReportsAMissingGeneratorOutputBeforeNoChangedFile(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + generatedPath := project.recordBaselineWithGeneratorOutput(t) + removeGeneratorOutput(t, generatedPath) + nonSourceKey, err := computeNonSourceKey(project.root, project.paths, project.plan, project.plan.Units[0]) + if err != nil { + t.Fatalf("expected a non-source key, got error: %v", err) + } + + attempt := project.tryAlpha(t, nonSourceKey) + + if attempt.Used || attempt.Reason != "generator output of the baseline is gone" { + t.Errorf("expected the missing generator output fallback, got %+v", attempt) + } +} + +// Verifies a missing baseline is reported ahead of a non-source key that could not be read. +func TestFastPathReportsAMissingBaselineBeforeAnUnreadableNonSourceKey(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + + attempt := project.tryAlpha(t, nonSourceIdentity{}) + + if attempt.Used || attempt.Reason != "no baseline: no full compile of this assembly is on record" { + t.Errorf("expected the missing baseline fallback, got %+v", attempt) + } +} diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go index 2c84756540..09afe4ffcf 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -220,11 +220,11 @@ func TestMissingOrChangedGeneratorOutputFallsBack(t *testing.T) { } { t.Run(name, func(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) - generatedPath := filepath.Join(project.root, generatedDirectory(project.plan, project.plan.Units[0])) + generatedPath := filepath.Join(project.root, openRecordStore(project.root, project.plan.OutputDir).generatedDirectory("Alpha")) recorder := newCachedSchedulerRecorder("Alpha", "Beta") recorder.during = func(unit string) { if unit == "Alpha" { - project.writeFile(t, filepath.Join(generatedDirectory(project.plan, project.plan.Units[0]), "Gen", "G.g.cs"), "class G { }") + project.writeFile(t, filepath.Join(openRecordStore(project.root, project.plan.OutputDir).generatedDirectory("Alpha"), "Gen", "G.g.cs"), "class G { }") } } project.run(t, recorder, false) @@ -359,7 +359,7 @@ func TestFastPathCleanResultKeepsBaselineFiles(t *testing.T) { if recorder.runs["Beta"] != 0 || outcome.Skipped != 1 { t.Errorf("expected Beta to be skipped, got %d runs and %d skipped", recorder.runs["Beta"], outcome.Skipped) } - if project.worker.lastCheck.GeneratedDirectory != filepath.Join(project.root, generatedDirectory(project.plan, project.plan.Units[0])) { + if project.worker.lastCheck.GeneratedDirectory != filepath.Join(project.root, openRecordStore(project.root, project.plan.OutputDir).generatedDirectory("Alpha")) { t.Errorf("expected the saved generator output to be passed, got %q", project.worker.lastCheck.GeneratedDirectory) } } diff --git a/cli/dispatcher/internal/compilecheck/record_format_test.go b/cli/dispatcher/internal/compilecheck/record_format_test.go new file mode 100644 index 0000000000..0b48a24dbd --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/record_format_test.go @@ -0,0 +1,90 @@ +package compilecheck + +import ( + "os" + "testing" +) + +// Verifies a baseline of another format is reported as such even when its manifest is gone too, so +// the format is checked before the manifest. +func TestLoadBaselineReportsAnotherFormatBeforeAMissingManifest(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + record := project.readBaseline(t) + record.FormatVersion = baselineRecordFormatVersion - 1 + project.writeJSON(t, project.baselinePath(), record) + if err := os.Remove(project.manifestPathOf("Alpha")); err != nil { + t.Fatalf("failed to remove the manifest: %v", err) + } + + _, reason := openRecordStore(project.root, project.plan.OutputDir).loadBaseline("Alpha") + + if reason != "no baseline: it was recorded by another version of compile-check" { + t.Errorf("reason = %q", reason) + } +} + +// Verifies the bytes a compile result is recorded as, so moving the record keeps its format. +func TestWriteUnitResultManifestWritesTheRecordedFormat(t *testing.T) { + outputDirectoryPath, unit := newManifestUnit(t) + result := recordedCompile{ + Succeeded: false, + Diagnostics: []Diagnostic{{ + Severity: severityError, + Code: "CS0103", + Message: "The name 'Missing' does not exist in the current context", + File: "Assets/Alpha/Alpha.cs", + Line: 3, + Column: 9, + }}, + } + + if err := manifestStore(outputDirectoryPath).recordCompile( + unit.Assembly.AssemblyName, "key", result, unitOutputNames(unit)); err != nil { + t.Fatalf("failed to write the manifest: %v", err) + } + + assertFileContent(t, manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName), `{"FormatVersion":1,"InputKey":"key","Succeeded":false,`+ + `"Diagnostics":[{"Severity":"error","Code":"CS0103","Message":"The name 'Missing' does not exist in the current context",`+ + `"File":"Assets/Alpha/Alpha.cs","Line":3,"Column":9}],`+ + `"Outputs":{"Alpha.dll":"e020bdf568793dee747a220086c5e53c8b3af5abd959383a31297f303577efe6",`+ + `"Alpha.ref.dll":"763cdc62a869262b6ff432a40eae29a00bb96f96f7a3320845abc8cd144d12e2"}}`) +} + +// Verifies the bytes a baseline is recorded as, so moving the record keeps its format. +func TestWriteBaselineRecordWritesTheRecordedFormat(t *testing.T) { + outputDirectoryPath, unit := newManifestUnit(t) + record := baselineRecord{ + FormatVersion: baselineRecordFormatVersion, + InputKey: "input", + NonSourceKey: nonSourceIdentity{ + Key: "non-source", + Parts: map[string]string{"compiler": "c", "source list": "s"}, + }, + WorkerKey: "worker", + GeneratedDirectory: "Library/out/Alpha.generated", + GeneratedFiles: map[string]string{"Gen/G.g.cs": "g"}, + Files: map[string]baselineFile{"Assets/A.cs": {ContentHash: "a", Fingerprint: "f"}}, + } + + if err := writeBaselineRecord(outputDirectoryPath, unit.Assembly.AssemblyName, record); err != nil { + t.Fatalf("failed to write the baseline: %v", err) + } + + assertFileContent(t, baselineRecordPath(outputDirectoryPath, unit.Assembly.AssemblyName), `{"FormatVersion":3,"InputKey":"input",`+ + `"NonSourceKey":{"Key":"non-source","Parts":{"compiler":"c","source list":"s"}},"WorkerKey":"worker",`+ + `"GeneratedDirectory":"Library/out/Alpha.generated","GeneratedFiles":{"Gen/G.g.cs":"g"},`+ + `"Files":{"Assets/A.cs":{"ContentHash":"a","Fingerprint":"f"}}}`) +} + +// assertFileContent fails the test unless the file holds exactly want. +func assertFileContent(t *testing.T, path string, want string) { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + if string(content) != want { + t.Errorf("content of %s =\n%s\nwant\n%s", path, content, want) + } +} diff --git a/cli/dispatcher/internal/compilecheck/record_store.go b/cli/dispatcher/internal/compilecheck/record_store.go new file mode 100644 index 0000000000..5394464509 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/record_store.go @@ -0,0 +1,206 @@ +package compilecheck + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const ( + // resultManifestFormatVersion guards against reading a manifest an older or newer build wrote: + // what belongs in the key is part of this check's code, so a manifest from another version says + // nothing about whether this one would compile the same thing. + resultManifestFormatVersion = 1 + + resultManifestExtension = ".result.json" + resultManifestTempSuffix = ".tmp" +) + +// recordStore reads and writes what earlier runs of the check recorded for each assembly in one +// output directory: the result of its last compile, the baseline the changed-files check compares +// against, and the generator output that baseline was compiled with. +type recordStore struct { + projectRoot string + // outputDir is relative to the project root, which is how the records name the generator output. + outputDir string +} + +// recordedCompile is what one compile of an assembly reported. +type recordedCompile struct { + Succeeded bool + Diagnostics []Diagnostic +} + +// unitResultManifest is what one finished compile left behind for the next run: the inputs it ran +// on, what it reported, and the outputs it wrote. +type unitResultManifest struct { + FormatVersion int + InputKey string + Succeeded bool + Diagnostics []Diagnostic + // Outputs maps the name of each file the compile wrote in the check's output directory to its + // content hash, so a run that finds them changed underneath compiles again. + Outputs map[string]string +} + +// openRecordStore opens the records kept in outputDir, a directory relative to projectRoot. +func openRecordStore(projectRoot string, outputDir string) recordStore { + return recordStore{projectRoot: projectRoot, outputDir: outputDir} +} + +// directoryPath names the output directory the records live in. +func (s recordStore) directoryPath() string { + return filepath.Join(s.projectRoot, s.outputDir) +} + +// manifestPath names where one assembly's manifest lives. +func manifestPath(directoryPath string, assembly string) string { + return filepath.Join(directoryPath, assembly+resultManifestExtension) +} + +// reusableResult reports what a previous compile of the assembly found, when that compile ran on +// the inputs key describes and its outputs are still on disk exactly as it wrote them. +// Why anything unexpected simply means no: the caller compiles the unit, which is what it would +// have done anyway. Nothing here is worth failing a run over. +func (s recordStore) reusableResult(assembly string, key string) (recordedCompile, bool) { + content, err := os.ReadFile(manifestPath(s.directoryPath(), assembly)) + if err != nil { + return recordedCompile{}, false + } + + var manifest unitResultManifest + if err := json.Unmarshal(content, &manifest); err != nil { + return recordedCompile{}, false + } + if manifest.FormatVersion != resultManifestFormatVersion || manifest.InputKey != key || key == "" { + return recordedCompile{}, false + } + if !outputsStillMatch(s.directoryPath(), manifest.Outputs) { + return recordedCompile{}, false + } + + // Why the recorded outcome is replayed rather than assumed successful: a compile that reported + // errors reports them again until one of its inputs moves, and swallowing them here would turn a + // broken project into a clean run. + return recordedCompile{Succeeded: manifest.Succeeded, Diagnostics: manifest.Diagnostics}, true +} + +// outputsStillMatch reports whether every file a compile wrote is still there and still holds what +// it wrote. +func outputsStillMatch(directoryPath string, outputs map[string]string) bool { + for name, recordedHash := range outputs { + currentHash, err := hashFile(filepath.Join(directoryPath, name)) + if err != nil || currentHash != recordedHash { + return false + } + } + + return true +} + +// recordCompile records what a compile of the assembly ran on and what it reported, together with +// the outputs it wrote, named in the output directory. It records nothing when that compile claimed +// success without producing every one of those outputs. +// Why it is written through a temporary file: a run interrupted while writing would otherwise leave +// half a manifest behind, and the next run would read it as a description of outputs it never saw. +func (s recordStore) recordCompile(assembly string, key string, result recordedCompile, outputs []string) error { + hashes, complete, err := hashOutputs(s.directoryPath(), outputs, result.Succeeded) + if err != nil { + return err + } + if !complete { + return nil + } + + content, err := json.Marshal(unitResultManifest{ + FormatVersion: resultManifestFormatVersion, + InputKey: key, + Succeeded: result.Succeeded, + Diagnostics: result.Diagnostics, + Outputs: hashes, + }) + if err != nil { + return fmt.Errorf("failed to record the result of %s: %w", assembly, err) + } + + return writeFileAtomically(s.directoryPath(), manifestPath(s.directoryPath(), assembly), content) +} + +// writeFileAtomically writes content to path through a temporary file in directoryPath. +func writeFileAtomically(directoryPath string, path string, content []byte) error { + // Why the temporary name is unique per writer: two runs of the same project writing the same + // file at once must not rename each other's half-written file into place. + temporary, err := os.CreateTemp(directoryPath, filepath.Base(path)+".*"+resultManifestTempSuffix) + if err != nil { + return fmt.Errorf("failed to write %s: %w", path, err) + } + temporaryPath := temporary.Name() + if _, err := temporary.Write(content); err != nil { + _ = temporary.Close() + _ = os.Remove(temporaryPath) + + return fmt.Errorf("failed to write %s: %w", temporaryPath, err) + } + if err := temporary.Close(); err != nil { + _ = os.Remove(temporaryPath) + + return fmt.Errorf("failed to write %s: %w", temporaryPath, err) + } + if err := os.Rename(temporaryPath, path); err != nil { + _ = os.Remove(temporaryPath) + + return fmt.Errorf("failed to write %s: %w", path, err) + } + + return nil +} + +// hashOutputs hashes the named files a compile wrote in directoryPath. +// Why a missing output is tolerated only for a failed compile: that is the compile which writes +// neither the assembly nor its reference assembly. A successful compile that left one of them out +// is not a compile whose result describes anything - recording it would replay a success backed by +// files no run ever produced, and the outputs check would pass because it has nothing to check. +func hashOutputs(directoryPath string, names []string, succeeded bool) (map[string]string, bool, error) { + outputs := map[string]string{} + for _, name := range names { + hashed, err := hashFile(filepath.Join(directoryPath, name)) + if os.IsNotExist(err) { + if succeeded { + return nil, false, nil + } + + continue + } + if err != nil { + return nil, false, err + } + outputs[name] = hashed + } + + return outputs, true, nil +} + +// forget removes every record of the assembly: its manifest, its baseline, and its generator output. +func (s recordStore) forget(assembly string) error { + names := []string{ + // Why the manifest goes with the outputs: it describes those very files, so leaving it behind + // would let a later run replay diagnostics for outputs this one removed. + assembly + resultManifestExtension, + // The baseline and the generator output describe the same compile as the manifest. + assembly + baselineRecordExtension, + } + for _, name := range names { + path := filepath.Join(s.directoryPath(), name) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove %s: %w", path, err) + } + } + + generatedPath := filepath.Join(s.directoryPath(), assembly+generatedDirectoryExtension) + if err := os.RemoveAll(generatedPath); err != nil { + return fmt.Errorf("failed to remove %s: %w", generatedPath, err) + } + + return nil +} diff --git a/cli/dispatcher/internal/compilecheck/record_store_baseline.go b/cli/dispatcher/internal/compilecheck/record_store_baseline.go new file mode 100644 index 0000000000..7ce838233d --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/record_store_baseline.go @@ -0,0 +1,221 @@ +package compilecheck + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +const ( + baselineRecordFormatVersion = 3 + baselineRecordExtension = ".fastcheck.json" + generatedDirectoryExtension = ".generated" +) + +// baselineRecord is what a successful full compile leaves for the next changed-files check. +type baselineRecord struct { + FormatVersion int + // InputKey is the key of the full compile this baseline describes; its manifest carries the same. + InputKey string + // NonSourceKey covers every input but the source contents, so any other change compiles in full. + NonSourceKey nonSourceIdentity + // WorkerKey names the worker build that computed the fingerprints. + WorkerKey string + GeneratedDirectory string // relative to the project root + // GeneratedFiles maps each generated file, relative to GeneratedDirectory, to its content hash, + // so a check never runs without the generator output its baseline compile had. + GeneratedFiles map[string]string + Files map[string]baselineFile +} + +// baselineFile describes one source as the full compile saw it. +type baselineFile struct { + ContentHash string + Fingerprint string +} + +// baselineInputs is what the caller knows about the full compile a baseline describes: its input +// key, its non-source key, the worker build that fingerprinted its sources, and each source. +type baselineInputs struct { + InputKey string + NonSourceKey nonSourceIdentity + WorkerKey string + Files map[string]baselineFile +} + +// storedBaseline is a recorded baseline together with what its full compile reported. +type storedBaseline struct { + InputKey string + NonSourceKey nonSourceIdentity + WorkerKey string + Files map[string]baselineFile + // GeneratedDirectory is relative to the project root and slash-separated, as recorded. + GeneratedDirectory string + // Diagnostics is what the full compile this baseline describes reported. + Diagnostics []Diagnostic + generatedFiles map[string]string +} + +// generatedDirectory names, relative to the project root, where a full compile saves the generator +// output of an assembly. +func (s recordStore) generatedDirectory(assembly string) string { + return filepath.Join(s.outputDir, assembly+generatedDirectoryExtension) +} + +// loadBaseline reads the baseline of an assembly together with what the full compile it describes +// reported, or says why there is none to use. +func (s recordStore) loadBaseline(assembly string) (storedBaseline, string) { + content, err := os.ReadFile(baselineRecordPath(s.directoryPath(), assembly)) + if err != nil { + return storedBaseline{}, s.missingBaselineReason(assembly) + } + + var record baselineRecord + if err := json.Unmarshal(content, &record); err != nil || record.FormatVersion != baselineRecordFormatVersion { + return storedBaseline{}, "no baseline: it was recorded by another version of compile-check" + } + + // In the normal flow the record and the manifest are written together under one key, and a full + // compile removes both, so the checks below only catch a state someone changed by hand. + const goneFromDisk = "baseline compile is no longer on disk" + manifestContent, err := os.ReadFile(manifestPath(s.directoryPath(), assembly)) + if err != nil { + return storedBaseline{}, goneFromDisk + } + + var manifest unitResultManifest + if err := json.Unmarshal(manifestContent, &manifest); err != nil || + manifest.InputKey != record.InputKey || !manifest.Succeeded || + !outputsStillMatch(s.directoryPath(), manifest.Outputs) { + return storedBaseline{}, goneFromDisk + } + + return storedBaseline{ + InputKey: record.InputKey, + NonSourceKey: record.NonSourceKey, + WorkerKey: record.WorkerKey, + Files: record.Files, + GeneratedDirectory: record.GeneratedDirectory, + Diagnostics: manifest.Diagnostics, + generatedFiles: record.GeneratedFiles, + }, "" +} + +// missingBaselineReason says why an assembly has no baseline, from the manifest of its last full +// compile. +// Why the reasons are told apart: a full compile with errors drops the baseline an earlier compile +// left, and a reason that read as "never compiled" sent users looking for a compile that had run. +// Why a missing manifest says nothing about what happened: a run that skips or blocks the unit +// removes the manifest and the baseline alike, so nothing on disk tells that apart from no compile. +func (s recordStore) missingBaselineReason(assembly string) string { + const notOnRecord = "no baseline: no full compile of this assembly is on record" + content, err := os.ReadFile(manifestPath(s.directoryPath(), assembly)) + if err != nil { + return notOnRecord + } + + var manifest unitResultManifest + if err := json.Unmarshal(content, &manifest); err != nil { + return notOnRecord + } + if !manifest.Succeeded { + return "no baseline: the last full compile had errors" + } + + return "no baseline: the last full compile did not record one" +} + +// generatorOutputUnchanged reports whether the generator output on disk is exactly what the +// baseline recorded. +func (s recordStore) generatorOutputUnchanged(baseline storedBaseline) bool { + return generatedFilesMatch(projectPath(s.projectRoot, baseline.GeneratedDirectory), baseline.generatedFiles) +} + +// recordBaseline records what the next changed-files check of an assembly compares against, with +// the generator output its full compile saved. +func (s recordStore) recordBaseline(assembly string, inputs baselineInputs) error { + generatedDirectory := s.generatedDirectory(assembly) + generatedFiles, err := hashGeneratedFiles(filepath.Join(s.projectRoot, generatedDirectory)) + if err != nil { + return err + } + + return writeBaselineRecord(s.directoryPath(), assembly, baselineRecord{ + FormatVersion: baselineRecordFormatVersion, + InputKey: inputs.InputKey, + NonSourceKey: inputs.NonSourceKey, + WorkerKey: inputs.WorkerKey, + GeneratedDirectory: filepath.ToSlash(generatedDirectory), + GeneratedFiles: generatedFiles, + Files: inputs.Files, + }) +} + +// hashGeneratedFiles hashes every generated C# file under directoryPath, keyed by its slash-separated +// path relative to it. A missing directory holds no files. +func hashGeneratedFiles(directoryPath string) (map[string]string, error) { + hashes := map[string]string{} + err := filepath.WalkDir(directoryPath, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + if os.IsNotExist(walkErr) && path == directoryPath { + return filepath.SkipDir + } + + return walkErr + } + if entry.IsDir() || filepath.Ext(path) != ".cs" { + return nil + } + relative, err := filepath.Rel(directoryPath, path) + if err != nil { + return err + } + hash, err := hashFile(path) + if err != nil { + return err + } + hashes[filepath.ToSlash(relative)] = hash + + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to read the generator output: %w", err) + } + + return hashes, nil +} + +// generatedFilesMatch reports whether the generator output on disk is exactly what the baseline +// recorded: no file missing, added, or changed. +// Why: the worker reads this output instead of running the generators, and checking without it +// reports errors wherever code uses what the generators produce. +func generatedFilesMatch(directoryPath string, recorded map[string]string) bool { + current, err := hashGeneratedFiles(directoryPath) + if err != nil || len(current) != len(recorded) { + return false + } + for name, hash := range recorded { + if current[name] != hash { + return false + } + } + + return true +} + +// writeBaselineRecord writes a baseline through a temporary file, like the manifest. +func writeBaselineRecord(directoryPath string, assembly string, record baselineRecord) error { + content, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("failed to record the baseline of %s: %w", assembly, err) + } + + return writeFileAtomically(directoryPath, baselineRecordPath(directoryPath, assembly), content) +} + +// baselineRecordPath names where one assembly's baseline lives. +func baselineRecordPath(directoryPath string, assembly string) string { + return filepath.Join(directoryPath, assembly+baselineRecordExtension) +} diff --git a/cli/dispatcher/internal/compilecheck/result_cache.go b/cli/dispatcher/internal/compilecheck/result_cache.go index c3b51561d6..ea3c83057e 100644 --- a/cli/dispatcher/internal/compilecheck/result_cache.go +++ b/cli/dispatcher/internal/compilecheck/result_cache.go @@ -4,41 +4,18 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" "io" "os" "path/filepath" ) -const ( - // resultManifestFormatVersion guards against reading a manifest an older or newer build wrote: - // what belongs in the key is part of this check's code, so a manifest from another version says - // nothing about whether this one would compile the same thing. - resultManifestFormatVersion = 1 - - resultManifestExtension = ".result.json" - resultManifestTempSuffix = ".tmp" -) - -// unitResultManifest is what one finished compile left behind for the next run: the inputs it ran -// on, what it reported, and the outputs it wrote. -type unitResultManifest struct { - FormatVersion int - InputKey string - Succeeded bool - Diagnostics []Diagnostic - // Outputs maps the name of each file the compile wrote in the check's output directory to its - // content hash, so a run that finds them changed underneath compiles again. - Outputs map[string]string -} - // reuseOrCompile reports what csc would say about one unit, replaying the previous run's answer when // every input that decides it is still exactly as that run read it, and compiling otherwise. func (c Compiler) reuseOrCompile( ctx context.Context, plan BuildPlan, unit CompileUnit, ) (UnitResult, error) { - outputDirectoryPath := filepath.Join(c.ProjectRoot, plan.OutputDir) + records := c.records(plan) // Why an unreadable input is not an error here: the compile that follows reads the same files and // reports what is wrong with them in its own way. All this loses is the chance to reuse. keyResult, keyErr := computeInputKey(c.ProjectRoot, c.Paths, plan, unit) @@ -49,8 +26,13 @@ func (c Compiler) reuseOrCompile( // Why --all only skips the lookup and still records: it is the way out of a reuse that got // something wrong, and the ordinary run after it should still be able to reuse what it compiled. if key != "" && !plan.All { - if reused, found := loadReusableResult(outputDirectoryPath, unit, key); found { - return reused, nil + if recorded, found := records.reusableResult(unit.Assembly.AssemblyName, key); found { + return UnitResult{ + Assembly: unit.Assembly.AssemblyName, + Diagnostics: recorded.Diagnostics, + Succeeded: recorded.Succeeded, + Reused: true, + }, nil } } @@ -74,7 +56,8 @@ func (c Compiler) reuseOrCompile( if err != nil || key == "" || result.RawOutput != "" { return result, err } - if writeErr := writeUnitResultManifest(outputDirectoryPath, unit, key, result); writeErr != nil { + if writeErr := records.recordCompile(unit.Assembly.AssemblyName, key, + recordedCompile{Succeeded: result.Succeeded, Diagnostics: result.Diagnostics}, unitOutputNames(unit)); writeErr != nil { return UnitResult{}, writeErr } @@ -91,6 +74,25 @@ func (c Compiler) reuseOrCompile( return result, nil } +// records opens the store of what earlier runs of the check recorded in the plan's output directory. +func (c Compiler) records(plan BuildPlan) recordStore { + return openRecordStore(c.ProjectRoot, plan.OutputDir) +} + +// unitOutputNames lists the files a compile of the unit writes in the output directory, by name, +// leaving out an output the response file names no path for. +func unitOutputNames(unit CompileUnit) []string { + names := []string{} + for _, path := range []string{unit.Assembly.OutputPath, unit.Assembly.RefOutputPath} { + if path == "" { + continue + } + names = append(names, filepath.Base(path)) + } + + return names +} + // nonSourceKeyBeforeCompile reads a unit's non-source key for the changed-files check, or an empty // one when there is no check or no key. // Why it is read before anything runs: a baseline must describe the inputs the compile saw, and @@ -107,147 +109,6 @@ func (c Compiler) nonSourceKeyBeforeCompile(plan BuildPlan, unit CompileUnit, ke return nonSourceKey } -// manifestPath names where one unit's manifest lives. -func manifestPath(outputDirectoryPath string, unit CompileUnit) string { - return filepath.Join(outputDirectoryPath, unit.Assembly.AssemblyName+resultManifestExtension) -} - -// writeUnitResultManifest records what a compile ran on and what it reported, and records nothing -// when that compile claimed success without producing the outputs it was told to produce. -// Why it is written through a temporary file: a run interrupted while writing would otherwise leave -// half a manifest behind, and the next run would read it as a description of outputs it never saw. -func writeUnitResultManifest( - outputDirectoryPath string, unit CompileUnit, key string, result UnitResult, -) error { - outputs, complete, err := hashUnitOutputs(outputDirectoryPath, unit, result.Succeeded) - if err != nil { - return err - } - if !complete { - return nil - } - - content, err := json.Marshal(unitResultManifest{ - FormatVersion: resultManifestFormatVersion, - InputKey: key, - Succeeded: result.Succeeded, - Diagnostics: result.Diagnostics, - Outputs: outputs, - }) - if err != nil { - return fmt.Errorf("failed to record the result of %s: %w", unit.Assembly.AssemblyName, err) - } - - return writeFileAtomically(outputDirectoryPath, manifestPath(outputDirectoryPath, unit), content) -} - -// writeFileAtomically writes content to path through a temporary file in directoryPath. -func writeFileAtomically(directoryPath string, path string, content []byte) error { - // Why the temporary name is unique per writer: two runs of the same project writing the same - // file at once must not rename each other's half-written file into place. - temporary, err := os.CreateTemp(directoryPath, filepath.Base(path)+".*"+resultManifestTempSuffix) - if err != nil { - return fmt.Errorf("failed to write %s: %w", path, err) - } - temporaryPath := temporary.Name() - if _, err := temporary.Write(content); err != nil { - _ = temporary.Close() - _ = os.Remove(temporaryPath) - - return fmt.Errorf("failed to write %s: %w", temporaryPath, err) - } - if err := temporary.Close(); err != nil { - _ = os.Remove(temporaryPath) - - return fmt.Errorf("failed to write %s: %w", temporaryPath, err) - } - if err := os.Rename(temporaryPath, path); err != nil { - _ = os.Remove(temporaryPath) - - return fmt.Errorf("failed to write %s: %w", path, err) - } - - return nil -} - -// loadReusableResult reports what a previous compile of this unit found, when that compile ran on -// the same inputs and its outputs are still on disk exactly as it wrote them. -// Why anything unexpected simply means no: the caller compiles the unit, which is what it would -// have done anyway. Nothing here is worth failing a run over. -func loadReusableResult( - outputDirectoryPath string, unit CompileUnit, key string, -) (UnitResult, bool) { - content, err := os.ReadFile(manifestPath(outputDirectoryPath, unit)) - if err != nil { - return UnitResult{}, false - } - - var manifest unitResultManifest - if err := json.Unmarshal(content, &manifest); err != nil { - return UnitResult{}, false - } - if manifest.FormatVersion != resultManifestFormatVersion || manifest.InputKey != key || key == "" { - return UnitResult{}, false - } - if !outputsStillMatch(outputDirectoryPath, manifest.Outputs) { - return UnitResult{}, false - } - - // Why the recorded outcome is replayed rather than assumed successful: a compile that reported - // errors reports them again until one of its inputs moves, and swallowing them here would turn a - // broken project into a clean run. - return UnitResult{ - Assembly: unit.Assembly.AssemblyName, - Diagnostics: manifest.Diagnostics, - Succeeded: manifest.Succeeded, - Reused: true, - }, true -} - -// outputsStillMatch reports whether every file a compile wrote is still there and still holds what -// it wrote. -func outputsStillMatch(outputDirectoryPath string, outputs map[string]string) bool { - for name, recordedHash := range outputs { - currentHash, err := hashFile(filepath.Join(outputDirectoryPath, name)) - if err != nil || currentHash != recordedHash { - return false - } - } - - return true -} - -// hashUnitOutputs hashes the files a compile of this unit wrote. -// Why a missing output is tolerated only for a failed compile: that is the compile which writes -// neither the assembly nor its reference assembly. A successful compile that left one of them out -// is not a compile whose result describes anything - recording it would replay a success backed by -// files no run ever produced, and the outputs check would pass because it has nothing to check. -func hashUnitOutputs( - outputDirectoryPath string, unit CompileUnit, succeeded bool, -) (map[string]string, bool, error) { - outputs := map[string]string{} - for _, outputPath := range []string{unit.Assembly.OutputPath, unit.Assembly.RefOutputPath} { - if outputPath == "" { - continue - } - name := filepath.Base(outputPath) - hashed, err := hashFile(filepath.Join(outputDirectoryPath, name)) - if os.IsNotExist(err) { - if succeeded { - return nil, false, nil - } - - continue - } - if err != nil { - return nil, false, err - } - outputs[name] = hashed - } - - return outputs, true, nil -} - // hashFile reports what a file holds, as a hash. func hashFile(path string) (string, error) { file, err := os.Open(path) diff --git a/cli/dispatcher/internal/compilecheck/result_cache_test.go b/cli/dispatcher/internal/compilecheck/result_cache_test.go index 0222fc0cf9..51bebdf6da 100644 --- a/cli/dispatcher/internal/compilecheck/result_cache_test.go +++ b/cli/dispatcher/internal/compilecheck/result_cache_test.go @@ -31,10 +31,14 @@ func writeOutputFile(t *testing.T, outputDirectoryPath string, name string, cont } } +// manifestStore opens the records kept directly in outputDirectoryPath. +func manifestStore(outputDirectoryPath string) recordStore { + return openRecordStore(outputDirectoryPath, "") +} + // storedResult is the result the tests record and expect to read back unchanged. -func storedResult() UnitResult { - return UnitResult{ - Assembly: "Alpha", +func storedResult() recordedCompile { + return recordedCompile{ Succeeded: false, Diagnostics: []Diagnostic{{ Severity: severityError, @@ -50,7 +54,8 @@ func storedResult() UnitResult { // recordResult stores one compile's outcome under a key. func recordResult(t *testing.T, outputDirectoryPath string, unit CompileUnit, key string) { t.Helper() - if err := writeUnitResultManifest(outputDirectoryPath, unit, key, storedResult()); err != nil { + if err := manifestStore(outputDirectoryPath).recordCompile( + unit.Assembly.AssemblyName, key, storedResult(), unitOutputNames(unit)); err != nil { t.Fatalf("failed to write the manifest: %v", err) } } @@ -61,7 +66,7 @@ func TestLoadReusableResultReplaysTheRecordedDiagnostics(t *testing.T) { outputDirectoryPath, unit := newManifestUnit(t) recordResult(t, outputDirectoryPath, unit, "key") - result, reusable := loadReusableResult(outputDirectoryPath, unit, "key") + result, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key") if !reusable { t.Fatal("expected the recorded result to be reusable") @@ -69,12 +74,6 @@ func TestLoadReusableResultReplaysTheRecordedDiagnostics(t *testing.T) { if result.Succeeded { t.Error("a failed compile must not come back as a successful one") } - if !result.Reused { - t.Error("a replayed result should say it was reused") - } - if result.Assembly != "Alpha" { - t.Errorf("assembly = %q, want Alpha", result.Assembly) - } if len(result.Diagnostics) != 1 || result.Diagnostics[0].Code != "CS0103" { t.Errorf("diagnostics = %+v, want the recorded CS0103", result.Diagnostics) } @@ -85,7 +84,7 @@ func TestLoadReusableResultRejectsAnotherKey(t *testing.T) { outputDirectoryPath, unit := newManifestUnit(t) recordResult(t, outputDirectoryPath, unit, "key") - if _, reusable := loadReusableResult(outputDirectoryPath, unit, "other key"); reusable { + if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "other key"); reusable { t.Error("a result recorded for other inputs should not be reused") } } @@ -98,7 +97,7 @@ func TestLoadReusableResultRejectsAChangedOutput(t *testing.T) { writeOutputFile(t, outputDirectoryPath, "Alpha"+referenceAssemblyExtension, "another surface") - if _, reusable := loadReusableResult(outputDirectoryPath, unit, "key"); reusable { + if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key"); reusable { t.Error("a changed output should stop the reuse") } } @@ -112,7 +111,7 @@ func TestLoadReusableResultRejectsAMissingOutput(t *testing.T) { t.Fatalf("failed to remove the output: %v", err) } - if _, reusable := loadReusableResult(outputDirectoryPath, unit, "key"); reusable { + if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key"); reusable { t.Error("a missing output should stop the reuse") } } @@ -123,7 +122,7 @@ func TestLoadReusableResultRejectsAnUnreadableManifest(t *testing.T) { outputDirectoryPath, unit := newManifestUnit(t) writeOutputFile(t, outputDirectoryPath, "Alpha.result.json", "{not json") - if _, reusable := loadReusableResult(outputDirectoryPath, unit, "key"); reusable { + if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key"); reusable { t.Error("an unreadable manifest should not be reused") } } @@ -132,7 +131,7 @@ func TestLoadReusableResultRejectsAnUnreadableManifest(t *testing.T) { func TestLoadReusableResultRejectsAnotherFormatVersion(t *testing.T) { outputDirectoryPath, unit := newManifestUnit(t) recordResult(t, outputDirectoryPath, unit, "key") - path := manifestPath(outputDirectoryPath, unit) + path := manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName) content, err := os.ReadFile(path) if err != nil { t.Fatalf("failed to read the manifest: %v", err) @@ -150,7 +149,7 @@ func TestLoadReusableResultRejectsAnotherFormatVersion(t *testing.T) { t.Fatalf("failed to write the manifest back: %v", err) } - if _, reusable := loadReusableResult(outputDirectoryPath, unit, "key"); reusable { + if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key"); reusable { t.Error("a manifest of another format version should not be reused") } } @@ -161,11 +160,11 @@ func TestRemoveUnitOutputsRemovesTheManifest(t *testing.T) { outputDirectoryPath, unit := newManifestUnit(t) recordResult(t, outputDirectoryPath, unit, "key") - if err := removeUnitOutputs(outputDirectoryPath, unit); err != nil { + if err := (Compiler{ProjectRoot: outputDirectoryPath}).removeUnitOutputs(BuildPlan{}, unit); err != nil { t.Fatalf("failed to remove the outputs: %v", err) } - if _, err := os.Stat(manifestPath(outputDirectoryPath, unit)); !os.IsNotExist(err) { + if _, err := os.Stat(manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName)); !os.IsNotExist(err) { t.Error("the manifest should have been removed with the outputs") } } @@ -181,7 +180,7 @@ func TestWriteUnitResultManifestRecordsACompileThatWroteNothing(t *testing.T) { } recordResult(t, outputDirectoryPath, unit, "key") - result, reusable := loadReusableResult(outputDirectoryPath, unit, "key") + result, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key") if !reusable { t.Fatal("expected a compile that wrote nothing to be reusable") diff --git a/cli/dispatcher/internal/compilecheck/result_cache_write_test.go b/cli/dispatcher/internal/compilecheck/result_cache_write_test.go index 313f607e94..5f026b3314 100644 --- a/cli/dispatcher/internal/compilecheck/result_cache_write_test.go +++ b/cli/dispatcher/internal/compilecheck/result_cache_write_test.go @@ -21,11 +21,11 @@ func blockManifestPath(t *testing.T, manifestFilePath string) { // no temporary file behind. func TestWriteUnitResultManifestReportsAFailedRename(t *testing.T) { outputDirectoryPath, unit := newManifestUnit(t) - blockManifestPath(t, manifestPath(outputDirectoryPath, unit)) + blockManifestPath(t, manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName)) - err := writeUnitResultManifest(outputDirectoryPath, unit, "key", storedResult()) + err := manifestStore(outputDirectoryPath).recordCompile(unit.Assembly.AssemblyName, "key", storedResult(), unitOutputNames(unit)) - if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(outputDirectoryPath, unit)+":") { + if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName)+":") { t.Fatalf("err = %v", err) } matches, globErr := filepath.Glob(filepath.Join(outputDirectoryPath, "*"+resultManifestTempSuffix)) @@ -40,9 +40,9 @@ func TestWriteUnitResultManifestReportsAMissingOutputDirectory(t *testing.T) { _, unit := newManifestUnit(t) outputDirectoryPath := filepath.Join(t.TempDir(), "missing") - err := writeUnitResultManifest(outputDirectoryPath, unit, "key", storedResult()) + err := manifestStore(outputDirectoryPath).recordCompile(unit.Assembly.AssemblyName, "key", storedResult(), unitOutputNames(unit)) - if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(outputDirectoryPath, unit)+":") { + if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName)+":") { t.Fatalf("err = %v", err) } } @@ -56,19 +56,30 @@ func TestHashUnitOutputsReportsAnUnreadableOutput(t *testing.T) { } makeDirAt(t, unreadablePath) - _, complete, err := hashUnitOutputs(outputDirectoryPath, unit, true) + _, complete, err := hashOutputs(outputDirectoryPath, unitOutputNames(unit), true) if err == nil || complete || !strings.Contains(err.Error(), unreadablePath) { t.Fatalf("complete=%v err=%v, want an error naming %s", complete, err, unreadablePath) } } -// Verifies a unit without a reference assembly is hashed by its assembly alone. -func TestHashUnitOutputsSkipsAnAbsentReferenceAssemblyPath(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) +// Verifies a unit without a reference assembly names its assembly alone as an output. +func TestUnitOutputNamesSkipsAnAbsentReferenceAssemblyPath(t *testing.T) { + _, unit := newManifestUnit(t) unit.Assembly.RefOutputPath = "" - outputs, complete, err := hashUnitOutputs(outputDirectoryPath, unit, true) + names := unitOutputNames(unit) + + if len(names) != 1 || names[0] != "Alpha"+assemblyExtension { + t.Fatalf("names=%v", names) + } +} + +// Verifies only the named outputs are hashed, even when another output is on disk. +func TestHashOutputsHashesOnlyTheNamedOutputs(t *testing.T) { + outputDirectoryPath, _ := newManifestUnit(t) + + outputs, complete, err := hashOutputs(outputDirectoryPath, []string{"Alpha" + assemblyExtension}, true) if err != nil || !complete || len(outputs) != 1 || outputs["Alpha"+assemblyExtension] == "" { t.Fatalf("outputs=%v complete=%v err=%v", outputs, complete, err) diff --git a/cli/dispatcher/internal/compilecheck/scheduler.go b/cli/dispatcher/internal/compilecheck/scheduler.go index d89b0fdc0f..8050e6f874 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler.go +++ b/cli/dispatcher/internal/compilecheck/scheduler.go @@ -61,16 +61,15 @@ func DefaultJobs() int { // schedulerRun is the state of one run of the scheduler: what is running, what has finished, and // what was left out because nothing it compiles against changed. type schedulerRun struct { - runContext context.Context - compiler Compiler - plan BuildPlan - outputDirectoryPath string - state *schedule - results []UnitResult - compiled []bool - completions chan unitCompletion - running int - referenceSkips int + runContext context.Context + compiler Compiler + plan BuildPlan + state *schedule + results []UnitResult + compiled []bool + completions chan unitCompletion + running int + referenceSkips int } // compileUnits compiles a plan's assemblies, starting each one as soon as the assemblies it @@ -96,14 +95,13 @@ func compileUnits( defer cancel() run := &schedulerRun{ - runContext: runContext, - compiler: compiler, - plan: plan, - outputDirectoryPath: outputDirectoryPath, - state: newSchedule(plan), - results: make([]UnitResult, len(plan.Units)), - compiled: make([]bool, len(plan.Units)), - completions: make(chan unitCompletion, len(plan.Units)), + runContext: runContext, + compiler: compiler, + plan: plan, + state: newSchedule(plan), + results: make([]UnitResult, len(plan.Units)), + compiled: make([]bool, len(plan.Units)), + completions: make(chan unitCompletion, len(plan.Units)), } var failure error @@ -173,7 +171,7 @@ func (run *schedulerRun) skipBlockedUnit(index int) (bool, error) { if !run.state.blocked[index] { return false, nil } - if err := removeUnitOutputs(run.outputDirectoryPath, run.plan.Units[index]); err != nil { + if err := run.compiler.removeUnitOutputs(run.plan, run.plan.Units[index]); err != nil { return false, err } run.state.blockDependents(index) @@ -193,7 +191,7 @@ func (run *schedulerRun) skipUnchangedDependent(index int) (bool, error) { if !unit.SelectedAsDependent || run.state.changedInputs[index] != 0 { return false, nil } - if err := removeUnitOutputs(run.outputDirectoryPath, unit); err != nil { + if err := run.compiler.removeUnitOutputs(run.plan, unit); err != nil { return false, err } run.referenceSkips++ diff --git a/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go b/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go index 421c380541..1e0af8bd51 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go @@ -200,6 +200,27 @@ func TestCompileUnitsReusesEveryUnitWhoseInputsDidNotMove(t *testing.T) { } } +// Verifies a unit whose response file names no reference assembly is recorded by its assembly alone +// and reused on the next run, rather than being taken for a successful compile that lost an output. +func TestCompileUnitsReusesAUnitWithoutAReferenceAssembly(t *testing.T) { + project := newCachedSchedulerProject(t, nil, "Alpha") + project.plan.Units[0].Assembly.RefOutputPath = "" + first := newCachedSchedulerRecorder("Alpha") + delete(first.surfaces, "Alpha") + project.compileOrFail(t, first) + if _, err := os.Stat(filepath.Join(project.outputDirectory(), "Alpha"+referenceAssemblyExtension)); !os.IsNotExist(err) { + t.Fatalf("expected the compile to write no reference assembly, got %v", err) + } + + recorder := newCachedSchedulerRecorder("Alpha") + delete(recorder.surfaces, "Alpha") + second := project.compileOrFail(t, recorder) + + if !resultOf(t, second, "Alpha").Reused || len(recorder.runs) != 0 { + t.Errorf("expected Alpha to be reused without a compile, got %+v after %v", resultOf(t, second, "Alpha"), recorder.runs) + } +} + // Verifies the case the cache exists for: editing inside one assembly recompiles it, and the // assembly below it is reused because the reference assembly it reads came out byte-identical - // even though that file was rewritten and now carries a newer timestamp. From f434fceb4c3141a9c6e2c211a32d7a37b4d4e217 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 12:38:29 +0900 Subject: [PATCH 11/33] test: keep record internals in the record store's own tests (#3326) --- .../compilecheck/baseline_record_test.go | 62 +---- .../compilecheck/fast_path_fixture_test.go | 99 ++++--- .../compilecheck/fast_path_order_test.go | 6 +- .../internal/compilecheck/fast_path_test.go | 61 +++-- .../compilecheck/record_format_test.go | 28 +- .../record_store_baseline_test.go | 48 ++++ .../compilecheck/record_store_test.go | 255 ++++++++++++++++++ .../compilecheck/result_cache_test.go | 191 ------------- .../compilecheck/result_cache_write_test.go | 101 +++---- .../compilecheck/scheduler_cache_test.go | 94 ++++--- 10 files changed, 503 insertions(+), 442 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/record_store_baseline_test.go create mode 100644 cli/dispatcher/internal/compilecheck/record_store_test.go delete mode 100644 cli/dispatcher/internal/compilecheck/result_cache_test.go diff --git a/cli/dispatcher/internal/compilecheck/baseline_record_test.go b/cli/dispatcher/internal/compilecheck/baseline_record_test.go index 8381170688..6667ad622b 100644 --- a/cli/dispatcher/internal/compilecheck/baseline_record_test.go +++ b/cli/dispatcher/internal/compilecheck/baseline_record_test.go @@ -20,12 +20,14 @@ func TestBaselineRecordedAfterSuccessfulCompile(t *testing.T) { project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) - record := project.readBaseline(t) + record := project.baseline(t) keyResult, err := computeInputKey(project.root, project.paths, project.plan, project.plan.Units[0]) if err != nil { t.Fatalf("expected a key, got error: %v", err) } - if record.InputKey != project.readManifest(t).InputKey || record.InputKey != keyResult.Key { + // Why a usable baseline shows it carries the manifest's input key: the store hands one back only + // when the two keys agree. + if record.InputKey != keyResult.Key { t.Errorf("expected the baseline to carry the manifest's input key, got %s", record.InputKey) } for source, hash := range keyResult.SourceHashes { @@ -129,27 +131,6 @@ func TestCompileUnitPassesGeneratedFilesOut(t *testing.T) { } } -// Verifies that removing a unit's outputs also removes its baseline and its saved generator output. -func TestRemoveUnitOutputsRemovesBaselineAndGeneratedDirectory(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - writeOutputFile(t, outputDirectoryPath, "Alpha"+baselineRecordExtension, "{}") - generatedPath := filepath.Join(outputDirectoryPath, "Alpha"+generatedDirectoryExtension, "Nested") - if err := os.MkdirAll(generatedPath, outputDirPermissions); err != nil { - t.Fatalf("failed to create the generated directory: %v", err) - } - writeOutputFile(t, generatedPath, "G.cs", "class G {}") - - if err := (Compiler{ProjectRoot: outputDirectoryPath}).removeUnitOutputs(BuildPlan{}, unit); err != nil { - t.Fatalf("expected the outputs to be removed, got error: %v", err) - } - - for _, name := range []string{"Alpha" + baselineRecordExtension, "Alpha" + generatedDirectoryExtension} { - if _, err := os.Stat(filepath.Join(outputDirectoryPath, name)); !os.IsNotExist(err) { - t.Errorf("expected %s to be removed, got %v", name, err) - } - } -} - // Verifies that the worker build is shared by later callers, and that a cancelled build is tried again. func TestOnceWithContextSharesTheAnswerButNotACancellation(t *testing.T) { calls := 0 @@ -269,38 +250,3 @@ func TestRecordBaselineRefusesAMissingNonSourceKey(t *testing.T) { t.Errorf("expected no baseline without a non-source key, got %v", err) } } - -// Verifies that only generated C# files are recorded, keyed relative to the generator output directory. -func TestHashGeneratedFilesRecordsOnlyCSharpFiles(t *testing.T) { - directory := t.TempDir() - if err := os.MkdirAll(filepath.Join(directory, "Gen"), outputDirPermissions); err != nil { - t.Fatalf("failed to create the generator folder: %v", err) - } - writeOutputFile(t, filepath.Join(directory, "Gen"), "A.g.cs", "class A { }") - writeOutputFile(t, filepath.Join(directory, "Gen"), "notes.txt", "not code") - - hashes, err := hashGeneratedFiles(directory) - - if err != nil || len(hashes) != 1 || hashes["Gen/A.g.cs"] == "" { - t.Errorf("expected only Gen/A.g.cs, got %v (%v)", hashes, err) - } - if missing, err := hashGeneratedFiles(filepath.Join(directory, "missing")); err != nil || len(missing) != 0 { - t.Errorf("expected a missing directory to hold no files, got %v (%v)", missing, err) - } -} - -// Verifies that a baseline another version of compile-check wrote is not used, and says so rather -// than reporting that no full compile ran. -func TestBaselineOfAnotherFormatFallsBack(t *testing.T) { - project := newFastPathProject(t, fastPathMinimumSources) - project.recordBaseline(t) - record := project.readBaseline(t) - record.FormatVersion = baselineRecordFormatVersion - 1 - project.writeJSON(t, project.baselinePath(), record) - project.editBody(t, 0, "edited") - recorder := newCachedSchedulerRecorder("Alpha", "Beta") - - outcome := project.run(t, recorder, false) - - assertFallback(t, outcome, recorder, "no baseline: it was recorded by another version of compile-check") -} diff --git a/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go index a00148c27d..cf9ff5458c 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go @@ -4,8 +4,8 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -221,74 +221,71 @@ func (project *fastPathProject) recordBaseline(t *testing.T) { } } -// baselinePath names Alpha's baseline record. -func (project *fastPathProject) baselinePath() string { - return filepath.Join(project.outputDirectory(), "Alpha"+baselineRecordExtension) -} - -// hasBaseline reports whether Alpha has a baseline record. +// hasBaseline reports whether Alpha has a baseline of the current format. func (project *fastPathProject) hasBaseline() bool { - _, err := os.Stat(project.baselinePath()) - - return err == nil -} - -// readBaseline reads Alpha's baseline record. -func (project *fastPathProject) readBaseline(t *testing.T) baselineRecord { - t.Helper() - record := baselineRecord{} - project.readJSON(t, project.baselinePath(), &record) - - return record + return project.hasBaselineOf("Alpha") } -// readManifest reads Alpha's result manifest. -func (project *fastPathProject) readManifest(t *testing.T) unitResultManifest { +// baseline reads Alpha's baseline through the store and fails the test when there is none to use. +func (project *fastPathProject) baseline(t *testing.T) storedBaseline { t.Helper() - manifest := unitResultManifest{} - project.readJSON(t, project.manifestPathOf("Alpha"), &manifest) + baseline, reason := project.records().loadBaseline("Alpha") + if reason != "" { + t.Fatalf("expected a usable baseline for Alpha, got %q", reason) + } - return manifest + return baseline } -func (project *fastPathProject) readJSON(t *testing.T, path string, value any) { +// generatedFileCount counts the files under the generator output a baseline points at. +func (project *fastPathProject) generatedFileCount(t *testing.T, baseline storedBaseline) int { t.Helper() - content, err := os.ReadFile(path) + count := 0 + err := filepath.WalkDir(projectPath(project.root, baseline.GeneratedDirectory), + func(_ string, entry fs.DirEntry, err error) error { + if err == nil && !entry.IsDir() { + count++ + } + + return err + }) if err != nil { - t.Fatalf("failed to read %s: %v", path, err) + t.Fatalf("failed to read the generator output: %v", err) } - if err := json.Unmarshal(content, value); err != nil { - t.Fatalf("failed to parse %s: %v", path, err) - } -} -func (project *fastPathProject) writeJSON(t *testing.T, path string, value any) { - t.Helper() - content, err := json.Marshal(value) - if err != nil { - t.Fatalf("failed to encode %s: %v", path, err) - } - if err := os.WriteFile(path, content, 0o600); err != nil { - t.Fatalf("failed to write %s: %v", path, err) - } + return count } -// fileHashes hashes Alpha's outputs, manifest, and baseline, to show a run left them alone. -func (project *fastPathProject) fileHashes(t *testing.T) map[string]string { +// outputSnapshot hashes every file the check keeps for Alpha, keyed by its path relative to the +// output directory, leaving out the response file the changed-files check writes for itself. +func (project *fastPathProject) outputSnapshot(t *testing.T) map[string]string { t.Helper() - hashes := map[string]string{} - for _, name := range []string{ - "Alpha" + assemblyExtension, "Alpha" + referenceAssemblyExtension, - "Alpha" + resultManifestExtension, "Alpha" + baselineRecordExtension, - } { - hash, err := hashFile(filepath.Join(project.outputDirectory(), name)) + snapshot := map[string]string{} + err := filepath.WalkDir(project.outputDirectory(), func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + relative, err := filepath.Rel(project.outputDirectory(), path) if err != nil { - t.Fatalf("failed to hash %s: %v", name, err) + return err } - hashes[name] = hash + first, _, _ := strings.Cut(filepath.ToSlash(relative), "/") + if !strings.HasPrefix(first, "Alpha.") || first == "Alpha"+fastCheckResponseFileSuffix { + return nil + } + hash, err := hashFile(path) + if err != nil { + return err + } + snapshot[filepath.ToSlash(relative)] = hash + + return nil + }) + if err != nil { + t.Fatalf("failed to read the output directory: %v", err) } - return hashes + return snapshot } // assertFallback checks that Alpha was compiled in full for a reason starting with prefix. diff --git a/cli/dispatcher/internal/compilecheck/fast_path_order_test.go b/cli/dispatcher/internal/compilecheck/fast_path_order_test.go index 89f6e3253c..858fc7362f 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_order_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_order_test.go @@ -13,7 +13,7 @@ import ( // matches the baseline, and the check that it is gone could run anywhere in the order unnoticed. func (project *fastPathProject) recordBaselineWithGeneratorOutput(t *testing.T) string { t.Helper() - generatedDirectoryPath := openRecordStore(project.root, project.plan.OutputDir).generatedDirectory("Alpha") + generatedDirectoryPath := project.records().generatedDirectory("Alpha") recorder := newCachedSchedulerRecorder("Alpha", "Beta") recorder.during = func(unit string) { if unit == "Alpha" { @@ -21,8 +21,8 @@ func (project *fastPathProject) recordBaselineWithGeneratorOutput(t *testing.T) } } project.run(t, recorder, false) - if record := project.readBaseline(t); len(record.GeneratedFiles) != 1 { - t.Fatalf("expected the generated file in the baseline, got %v", record.GeneratedFiles) + if count := project.generatedFileCount(t, project.baseline(t)); count != 1 { + t.Fatalf("expected the generated file in the baseline, got %d files", count) } return filepath.Join(project.root, generatedDirectoryPath) diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go index 09afe4ffcf..4f93890813 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -51,9 +51,9 @@ func TestFullFlagSkipsFastPathButRecords(t *testing.T) { outcome := project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), true) assertNotTried(t, outcome, project.worker) - if record, manifest := project.readBaseline(t), project.readManifest(t); record.InputKey != manifest.InputKey { - t.Errorf("expected the baseline to describe the --full compile, got %s and %s", record.InputKey, manifest.InputKey) - } + // Why a usable baseline is enough: the store hands one back only when its input key is the one + // the manifest of the last compile carries, and the --full compile removed the earlier pair. + project.baseline(t) } // Verifies that a unit picked only as a dependent is neither checked by the changed-files check nor @@ -95,7 +95,7 @@ func TestMissingBaselineFallsBackAndRecords(t *testing.T) { outcome := project.run(t, recorder, false) assertFallback(t, outcome, recorder, "no baseline: no full compile of this assembly is on record") - record := project.readBaseline(t) + record := project.baseline(t) if record.WorkerKey != "worker-a" || len(record.Files) != fastPathMinimumSources { t.Errorf("expected a baseline of every source from worker-a, got key %s with %d files", record.WorkerKey, len(record.Files)) } @@ -122,9 +122,11 @@ func TestHandCorruptedFailedManifestFallsBack(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) project.recordBaseline(t) project.editBody(t, 0, "edited") - manifest := project.readManifest(t) - manifest.Succeeded = false - project.writeJSON(t, project.manifestPathOf("Alpha"), manifest) + baseline := project.baseline(t) + if err := project.records().recordCompile("Alpha", baseline.InputKey, + recordedCompile{Succeeded: false, Diagnostics: baseline.Diagnostics}, unitOutputNames(project.plan.Units[0])); err != nil { + t.Fatalf("failed to record the failed compile: %v", err) + } recorder := newCachedSchedulerRecorder("Alpha", "Beta") outcome := project.run(t, recorder, false) @@ -220,16 +222,16 @@ func TestMissingOrChangedGeneratorOutputFallsBack(t *testing.T) { } { t.Run(name, func(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) - generatedPath := filepath.Join(project.root, openRecordStore(project.root, project.plan.OutputDir).generatedDirectory("Alpha")) + generatedPath := filepath.Join(project.root, project.records().generatedDirectory("Alpha")) recorder := newCachedSchedulerRecorder("Alpha", "Beta") recorder.during = func(unit string) { if unit == "Alpha" { - project.writeFile(t, filepath.Join(openRecordStore(project.root, project.plan.OutputDir).generatedDirectory("Alpha"), "Gen", "G.g.cs"), "class G { }") + project.writeFile(t, filepath.Join(project.records().generatedDirectory("Alpha"), "Gen", "G.g.cs"), "class G { }") } } project.run(t, recorder, false) - if record := project.readBaseline(t); len(record.GeneratedFiles) != 1 { - t.Fatalf("expected the generated file in the baseline, got %v", record.GeneratedFiles) + if count := project.generatedFileCount(t, project.baseline(t)); count != 1 { + t.Fatalf("expected the generated file in the baseline, got %d files", count) } damage(t, generatedPath) project.editBody(t, 0, "edited") @@ -266,7 +268,7 @@ func TestDeclarationChangeFallsBackAndRerecords(t *testing.T) { outcome := project.run(t, recorder, false) assertFallback(t, outcome, recorder, fastcheck.DeclarationChangedOutcome+": "+project.sourceName(0)) - if file := project.readBaseline(t).Files[project.sourceName(0)]; file.Fingerprint != "class File000 : Base " { + if file := project.baseline(t).Files[project.sourceName(0)]; file.Fingerprint != "class File000 : Base " { t.Errorf("expected the new declaration in the baseline, got %+v", file) } } @@ -322,7 +324,7 @@ func TestWorkerKeyMismatchFallsBack(t *testing.T) { outcome := project.run(t, recorder, false) assertFallback(t, outcome, recorder, "baseline was fingerprinted by another worker build") - if record := project.readBaseline(t); record.WorkerKey != "worker-b" { + if record := project.baseline(t); record.WorkerKey != "worker-b" { t.Errorf("expected the baseline to be recorded again by worker-b, got %s", record.WorkerKey) } } @@ -337,7 +339,7 @@ func TestFastPathCleanResultKeepsBaselineFiles(t *testing.T) { first.surfaces["Alpha"] = "unity Alpha" first.output["Alpha"] = fakeCompilerOutput{stdout: "Assets/Alpha/File001.cs(1,1): warning CS0168: unused"} project.run(t, first, false) - before := project.fileHashes(t) + before := project.outputSnapshot(t) project.editBody(t, 0, "edited") project.worker.uncheckedCodes = []string{"CS0169"} @@ -353,13 +355,13 @@ func TestFastPathCleanResultKeepsBaselineFiles(t *testing.T) { if len(result.Diagnostics) != 1 || result.Diagnostics[0].File != "Assets/Alpha/File001.cs" { t.Errorf("expected the warning of the unchanged file to be carried over, got %+v", result.Diagnostics) } - if after := project.fileHashes(t); !reflect.DeepEqual(after, before) { + if after := project.outputSnapshot(t); !reflect.DeepEqual(after, before) { t.Errorf("expected the outputs, manifest, and baseline to stay, got %v then %v", before, after) } if recorder.runs["Beta"] != 0 || outcome.Skipped != 1 { t.Errorf("expected Beta to be skipped, got %d runs and %d skipped", recorder.runs["Beta"], outcome.Skipped) } - if project.worker.lastCheck.GeneratedDirectory != filepath.Join(project.root, openRecordStore(project.root, project.plan.OutputDir).generatedDirectory("Alpha")) { + if project.worker.lastCheck.GeneratedDirectory != filepath.Join(project.root, project.records().generatedDirectory("Alpha")) { t.Errorf("expected the saved generator output to be passed, got %q", project.worker.lastCheck.GeneratedDirectory) } } @@ -430,18 +432,23 @@ func TestRevertedContentIsReused(t *testing.T) { func TestZeroChangedFilesDropsBaselineAndFallsBack(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) project.recordBaseline(t) - record := project.readBaseline(t) - manifest := project.readManifest(t) - record.InputKey = "tampered" - manifest.InputKey = "tampered" - project.writeJSON(t, project.baselinePath(), record) - project.writeJSON(t, project.manifestPathOf("Alpha"), manifest) + baseline := project.baseline(t) + records := project.records() + if err := records.recordCompile("Alpha", "tampered", + recordedCompile{Succeeded: true, Diagnostics: baseline.Diagnostics}, unitOutputNames(project.plan.Units[0])); err != nil { + t.Fatalf("failed to record the tampered compile: %v", err) + } + if err := records.recordBaseline("Alpha", baselineInputs{ + InputKey: "tampered", NonSourceKey: baseline.NonSourceKey, WorkerKey: baseline.WorkerKey, Files: baseline.Files, + }); err != nil { + t.Fatalf("failed to record the tampered baseline: %v", err) + } recorder := newCachedSchedulerRecorder("Alpha", "Beta") outcome := project.run(t, recorder, false) assertFallback(t, outcome, recorder, "baseline record does not match its compile result") - if project.readBaseline(t).InputKey == "tampered" { + if project.baseline(t).InputKey == "tampered" { t.Error("expected the disagreeing baseline to be replaced") } } @@ -451,7 +458,7 @@ func TestZeroChangedFilesDropsBaselineAndFallsBack(t *testing.T) { func TestCancelledFastPathLeavesBaselineUntouched(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) project.recordBaseline(t) - before := project.fileHashes(t) + before := project.outputSnapshot(t) project.editBody(t, 0, "edited") ctx, cancel := context.WithCancel(context.Background()) project.worker.onCheck = cancel @@ -462,7 +469,7 @@ func TestCancelledFastPathLeavesBaselineUntouched(t *testing.T) { if !errors.Is(err, context.Canceled) { t.Fatalf("expected the run to stop with a cancellation, got %v", err) } - if after := project.fileHashes(t); !reflect.DeepEqual(after, before) { + if after := project.outputSnapshot(t); !reflect.DeepEqual(after, before) { t.Errorf("expected the outputs, manifest, and baseline to stay, got %v then %v", before, after) } } @@ -502,8 +509,8 @@ func TestBaselineRemovedByBlockedRunIsNotCalledNeverCompiled(t *testing.T) { } project.plan.Units[1].Sources = sources project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) - if _, err := os.Stat(filepath.Join(project.outputDirectory(), "Beta"+baselineRecordExtension)); err != nil { - t.Fatalf("expected the first run to record Beta's baseline: %v", err) + if !project.hasBaselineOf("Beta") { + t.Fatal("expected the first run to record Beta's baseline") } project.editSource(t, "Alpha", "class Alpha { broken }") diff --git a/cli/dispatcher/internal/compilecheck/record_format_test.go b/cli/dispatcher/internal/compilecheck/record_format_test.go index 0b48a24dbd..cb09bda6d4 100644 --- a/cli/dispatcher/internal/compilecheck/record_format_test.go +++ b/cli/dispatcher/internal/compilecheck/record_format_test.go @@ -8,16 +8,15 @@ import ( // Verifies a baseline of another format is reported as such even when its manifest is gone too, so // the format is checked before the manifest. func TestLoadBaselineReportsAnotherFormatBeforeAMissingManifest(t *testing.T) { - project := newFastPathProject(t, fastPathMinimumSources) - project.recordBaseline(t) - record := project.readBaseline(t) - record.FormatVersion = baselineRecordFormatVersion - 1 - project.writeJSON(t, project.baselinePath(), record) - if err := os.Remove(project.manifestPathOf("Alpha")); err != nil { - t.Fatalf("failed to remove the manifest: %v", err) + directoryPath := newRecordedOutputs(t) + if err := writeBaselineRecord(directoryPath, "Alpha", baselineRecord{ + FormatVersion: baselineRecordFormatVersion - 1, + InputKey: "input", + }); err != nil { + t.Fatalf("failed to write the baseline: %v", err) } - _, reason := openRecordStore(project.root, project.plan.OutputDir).loadBaseline("Alpha") + _, reason := directoryStore(directoryPath).loadBaseline("Alpha") if reason != "no baseline: it was recorded by another version of compile-check" { t.Errorf("reason = %q", reason) @@ -26,7 +25,7 @@ func TestLoadBaselineReportsAnotherFormatBeforeAMissingManifest(t *testing.T) { // Verifies the bytes a compile result is recorded as, so moving the record keeps its format. func TestWriteUnitResultManifestWritesTheRecordedFormat(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) + directoryPath := newRecordedOutputs(t) result := recordedCompile{ Succeeded: false, Diagnostics: []Diagnostic{{ @@ -39,12 +38,11 @@ func TestWriteUnitResultManifestWritesTheRecordedFormat(t *testing.T) { }}, } - if err := manifestStore(outputDirectoryPath).recordCompile( - unit.Assembly.AssemblyName, "key", result, unitOutputNames(unit)); err != nil { + if err := directoryStore(directoryPath).recordCompile("Alpha", "key", result, alphaOutputs()); err != nil { t.Fatalf("failed to write the manifest: %v", err) } - assertFileContent(t, manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName), `{"FormatVersion":1,"InputKey":"key","Succeeded":false,`+ + assertFileContent(t, manifestPath(directoryPath, "Alpha"), `{"FormatVersion":1,"InputKey":"key","Succeeded":false,`+ `"Diagnostics":[{"Severity":"error","Code":"CS0103","Message":"The name 'Missing' does not exist in the current context",`+ `"File":"Assets/Alpha/Alpha.cs","Line":3,"Column":9}],`+ `"Outputs":{"Alpha.dll":"e020bdf568793dee747a220086c5e53c8b3af5abd959383a31297f303577efe6",`+ @@ -53,7 +51,7 @@ func TestWriteUnitResultManifestWritesTheRecordedFormat(t *testing.T) { // Verifies the bytes a baseline is recorded as, so moving the record keeps its format. func TestWriteBaselineRecordWritesTheRecordedFormat(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) + directoryPath := t.TempDir() record := baselineRecord{ FormatVersion: baselineRecordFormatVersion, InputKey: "input", @@ -67,11 +65,11 @@ func TestWriteBaselineRecordWritesTheRecordedFormat(t *testing.T) { Files: map[string]baselineFile{"Assets/A.cs": {ContentHash: "a", Fingerprint: "f"}}, } - if err := writeBaselineRecord(outputDirectoryPath, unit.Assembly.AssemblyName, record); err != nil { + if err := writeBaselineRecord(directoryPath, "Alpha", record); err != nil { t.Fatalf("failed to write the baseline: %v", err) } - assertFileContent(t, baselineRecordPath(outputDirectoryPath, unit.Assembly.AssemblyName), `{"FormatVersion":3,"InputKey":"input",`+ + assertFileContent(t, baselineRecordPath(directoryPath, "Alpha"), `{"FormatVersion":3,"InputKey":"input",`+ `"NonSourceKey":{"Key":"non-source","Parts":{"compiler":"c","source list":"s"}},"WorkerKey":"worker",`+ `"GeneratedDirectory":"Library/out/Alpha.generated","GeneratedFiles":{"Gen/G.g.cs":"g"},`+ `"Files":{"Assets/A.cs":{"ContentHash":"a","Fingerprint":"f"}}}`) diff --git a/cli/dispatcher/internal/compilecheck/record_store_baseline_test.go b/cli/dispatcher/internal/compilecheck/record_store_baseline_test.go new file mode 100644 index 0000000000..53309e8c7b --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/record_store_baseline_test.go @@ -0,0 +1,48 @@ +package compilecheck + +import ( + "os" + "path/filepath" + "testing" +) + +// Verifies that only generated C# files are recorded, keyed relative to the generator output directory. +func TestHashGeneratedFilesRecordsOnlyCSharpFiles(t *testing.T) { + directory := t.TempDir() + if err := os.MkdirAll(filepath.Join(directory, "Gen"), outputDirPermissions); err != nil { + t.Fatalf("failed to create the generator folder: %v", err) + } + writeOutputFile(t, filepath.Join(directory, "Gen"), "A.g.cs", "class A { }") + writeOutputFile(t, filepath.Join(directory, "Gen"), "notes.txt", "not code") + + hashes, err := hashGeneratedFiles(directory) + + if err != nil || len(hashes) != 1 || hashes["Gen/A.g.cs"] == "" { + t.Errorf("expected only Gen/A.g.cs, got %v (%v)", hashes, err) + } + if missing, err := hashGeneratedFiles(filepath.Join(directory, "missing")); err != nil || len(missing) != 0 { + t.Errorf("expected a missing directory to hold no files, got %v (%v)", missing, err) + } +} + +// Verifies that a baseline another version of compile-check wrote is not used, and says so rather +// than reporting that no full compile ran, even when the compile it describes is still on disk. +func TestLoadBaselineReportsABaselineOfAnotherFormat(t *testing.T) { + directoryPath := newRecordedOutputs(t) + store := directoryStore(directoryPath) + if err := store.recordCompile("Alpha", "input", recordedCompile{Succeeded: true}, alphaOutputs()); err != nil { + t.Fatalf("failed to write the manifest: %v", err) + } + if err := writeBaselineRecord(directoryPath, "Alpha", baselineRecord{ + FormatVersion: baselineRecordFormatVersion - 1, + InputKey: "input", + }); err != nil { + t.Fatalf("failed to write the baseline: %v", err) + } + + _, reason := store.loadBaseline("Alpha") + + if reason != "no baseline: it was recorded by another version of compile-check" { + t.Errorf("reason = %q", reason) + } +} diff --git a/cli/dispatcher/internal/compilecheck/record_store_test.go b/cli/dispatcher/internal/compilecheck/record_store_test.go new file mode 100644 index 0000000000..5601ad07b4 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/record_store_test.go @@ -0,0 +1,255 @@ +package compilecheck + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// alphaOutputs names the two files a compile of Alpha writes in the output directory. +func alphaOutputs() []string { + return []string{"Alpha" + assemblyExtension, "Alpha" + referenceAssemblyExtension} +} + +// newRecordedOutputs prepares an output directory holding the outputs a compile of Alpha left behind. +func newRecordedOutputs(t *testing.T) string { + t.Helper() + directoryPath := t.TempDir() + writeOutputFile(t, directoryPath, "Alpha"+assemblyExtension, "assembly") + writeOutputFile(t, directoryPath, "Alpha"+referenceAssemblyExtension, "surface") + + return directoryPath +} + +// directoryStore opens the records kept directly in directoryPath. +func directoryStore(directoryPath string) recordStore { + return openRecordStore(directoryPath, "") +} + +// storedResult is the result the tests record and expect to read back unchanged. +func storedResult() recordedCompile { + return recordedCompile{ + Succeeded: false, + Diagnostics: []Diagnostic{{ + Severity: severityError, + Code: "CS0103", + Message: "The name 'Missing' does not exist in the current context", + File: filepath.Join("Assets", "Alpha", "Alpha.cs"), + Line: 3, + Column: 9, + }}, + } +} + +// recordResult stores one compile of Alpha's outcome under a key. +func recordResult(t *testing.T, directoryPath string, key string) { + t.Helper() + if err := directoryStore(directoryPath).recordCompile("Alpha", key, storedResult(), alphaOutputs()); err != nil { + t.Fatalf("failed to write the manifest: %v", err) + } +} + +// blockManifestPath puts a non-empty directory where a unit's manifest belongs, so renaming the +// finished manifest into place fails on every platform. +func blockManifestPath(t *testing.T, manifestFilePath string) { + t.Helper() + writeFileAt(t, filepath.Join(manifestFilePath, "occupied"), "") +} + +// Verifies a recorded compile is handed back with the diagnostics and the outcome it had, rather +// than as a success: an assembly that failed must keep failing until something about it changes. +func TestReusableResultReplaysTheRecordedDiagnostics(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordResult(t, directoryPath, "key") + + result, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key") + + if !reusable { + t.Fatal("expected the recorded result to be reusable") + } + if result.Succeeded { + t.Error("a failed compile must not come back as a successful one") + } + if len(result.Diagnostics) != 1 || result.Diagnostics[0].Code != "CS0103" { + t.Errorf("diagnostics = %+v, want the recorded CS0103", result.Diagnostics) + } +} + +// Verifies a result recorded for other inputs is not reused. +func TestReusableResultRejectsAnotherKey(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordResult(t, directoryPath, "key") + + if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "other key"); reusable { + t.Error("a result recorded for other inputs should not be reused") + } +} + +// Verifies an output that no longer holds what the compile wrote stops the reuse: the assemblies +// below this one read those bytes, so replaying diagnostics over them would check nothing. +func TestReusableResultRejectsAChangedOutput(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordResult(t, directoryPath, "key") + + writeOutputFile(t, directoryPath, "Alpha"+referenceAssemblyExtension, "another surface") + + if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key"); reusable { + t.Error("a changed output should stop the reuse") + } +} + +// Verifies a missing output stops the reuse. +func TestReusableResultRejectsAMissingOutput(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordResult(t, directoryPath, "key") + + if err := os.Remove(filepath.Join(directoryPath, "Alpha"+assemblyExtension)); err != nil { + t.Fatalf("failed to remove the output: %v", err) + } + + if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key"); reusable { + t.Error("a missing output should stop the reuse") + } +} + +// Verifies a manifest that cannot be read is treated as no manifest at all rather than as a +// failure: the assembly simply compiles again. +func TestReusableResultRejectsAnUnreadableManifest(t *testing.T) { + directoryPath := newRecordedOutputs(t) + writeOutputFile(t, directoryPath, "Alpha.result.json", "{not json") + + if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key"); reusable { + t.Error("an unreadable manifest should not be reused") + } +} + +// Verifies a manifest written by another version of this check is not reused. +func TestReusableResultRejectsAnotherFormatVersion(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordResult(t, directoryPath, "key") + path := manifestPath(directoryPath, "Alpha") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read the manifest: %v", err) + } + var manifest unitResultManifest + if err := json.Unmarshal(content, &manifest); err != nil { + t.Fatalf("failed to parse the manifest: %v", err) + } + manifest.FormatVersion = resultManifestFormatVersion + 1 + rewritten, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("failed to write the manifest back: %v", err) + } + if err := os.WriteFile(path, rewritten, 0o600); err != nil { + t.Fatalf("failed to write the manifest back: %v", err) + } + + if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key"); reusable { + t.Error("a manifest of another format version should not be reused") + } +} + +// Verifies a compile that wrote no outputs still records what it reported, so the next run can tell +// a failure it already knows from one it has not seen. +func TestRecordCompileRecordsACompileThatWroteNothing(t *testing.T) { + directoryPath := newRecordedOutputs(t) + for _, name := range alphaOutputs() { + if err := os.Remove(filepath.Join(directoryPath, name)); err != nil { + t.Fatalf("failed to remove %s: %v", name, err) + } + } + recordResult(t, directoryPath, "key") + + result, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key") + + if !reusable { + t.Fatal("expected a compile that wrote nothing to be reusable") + } + if len(result.Diagnostics) != 1 { + t.Errorf("diagnostics = %+v, want the recorded one", result.Diagnostics) + } +} + +// Verifies a manifest that cannot be renamed into place is reported as a write failure and leaves +// no temporary file behind. +func TestRecordCompileReportsAFailedRename(t *testing.T) { + directoryPath := newRecordedOutputs(t) + blockManifestPath(t, manifestPath(directoryPath, "Alpha")) + + err := directoryStore(directoryPath).recordCompile("Alpha", "key", storedResult(), alphaOutputs()) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(directoryPath, "Alpha")+":") { + t.Fatalf("err = %v", err) + } + matches, globErr := filepath.Glob(filepath.Join(directoryPath, "*"+resultManifestTempSuffix)) + if globErr != nil || len(matches) != 0 { + t.Fatalf("temporary manifests left behind: %v (glob error %v)", matches, globErr) + } +} + +// Verifies a manifest cannot be written into an output directory that does not exist, which is +// reported with the manifest path. +func TestRecordCompileReportsAMissingOutputDirectory(t *testing.T) { + directoryPath := filepath.Join(t.TempDir(), "missing") + + err := directoryStore(directoryPath).recordCompile("Alpha", "key", storedResult(), alphaOutputs()) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(directoryPath, "Alpha")+":") { + t.Fatalf("err = %v", err) + } +} + +// Verifies an output that exists but cannot be read is reported instead of being treated as absent. +func TestHashOutputsReportsAnUnreadableOutput(t *testing.T) { + directoryPath := newRecordedOutputs(t) + unreadablePath := filepath.Join(directoryPath, "Alpha"+assemblyExtension) + if err := os.Remove(unreadablePath); err != nil { + t.Fatalf("failed to remove the assembly: %v", err) + } + makeDirAt(t, unreadablePath) + + _, complete, err := hashOutputs(directoryPath, alphaOutputs(), true) + + if err == nil || complete || !strings.Contains(err.Error(), unreadablePath) { + t.Fatalf("complete=%v err=%v, want an error naming %s", complete, err, unreadablePath) + } +} + +// Verifies only the named outputs are hashed, even when another output is on disk. +func TestHashOutputsHashesOnlyTheNamedOutputs(t *testing.T) { + directoryPath := newRecordedOutputs(t) + + outputs, complete, err := hashOutputs(directoryPath, []string{"Alpha" + assemblyExtension}, true) + + if err != nil || !complete || len(outputs) != 1 || outputs["Alpha"+assemblyExtension] == "" { + t.Fatalf("outputs=%v complete=%v err=%v", outputs, complete, err) + } +} + +// Verifies forgetting an assembly removes its manifest, its baseline, and its saved generator output, +// so nothing describes a compile whose outputs are gone. +func TestForgetRemovesTheManifestTheBaselineAndTheGeneratorOutput(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordResult(t, directoryPath, "key") + writeOutputFile(t, directoryPath, "Alpha"+baselineRecordExtension, "{}") + generatedPath := filepath.Join(directoryPath, "Alpha"+generatedDirectoryExtension, "Nested") + if err := os.MkdirAll(generatedPath, outputDirPermissions); err != nil { + t.Fatalf("failed to create the generated directory: %v", err) + } + writeOutputFile(t, generatedPath, "G.cs", "class G {}") + + if err := directoryStore(directoryPath).forget("Alpha"); err != nil { + t.Fatalf("expected the records to be removed, got error: %v", err) + } + + for _, name := range []string{ + "Alpha" + resultManifestExtension, "Alpha" + baselineRecordExtension, "Alpha" + generatedDirectoryExtension, + } { + if _, err := os.Stat(filepath.Join(directoryPath, name)); !os.IsNotExist(err) { + t.Errorf("expected %s to be removed, got %v", name, err) + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/result_cache_test.go b/cli/dispatcher/internal/compilecheck/result_cache_test.go deleted file mode 100644 index 51bebdf6da..0000000000 --- a/cli/dispatcher/internal/compilecheck/result_cache_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package compilecheck - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -// newManifestUnit prepares one compiled unit together with the outputs its compile left behind. -func newManifestUnit(t *testing.T) (string, CompileUnit) { - t.Helper() - outputDirectoryPath := t.TempDir() - unit := CompileUnit{Assembly: ResponseFile{ - AssemblyName: "Alpha", - OutputPath: filepath.Join(planDagDirectory, "Alpha"+assemblyExtension), - RefOutputPath: filepath.Join(planDagDirectory, "Alpha"+referenceAssemblyExtension), - }} - writeOutputFile(t, outputDirectoryPath, "Alpha"+assemblyExtension, "assembly") - writeOutputFile(t, outputDirectoryPath, "Alpha"+referenceAssemblyExtension, "surface") - - return outputDirectoryPath, unit -} - -// writeOutputFile writes one file into the check's own output directory. -func writeOutputFile(t *testing.T, outputDirectoryPath string, name string, content string) { - t.Helper() - if err := os.WriteFile( - filepath.Join(outputDirectoryPath, name), []byte(content), 0o600); err != nil { - t.Fatalf("failed to write %s: %v", name, err) - } -} - -// manifestStore opens the records kept directly in outputDirectoryPath. -func manifestStore(outputDirectoryPath string) recordStore { - return openRecordStore(outputDirectoryPath, "") -} - -// storedResult is the result the tests record and expect to read back unchanged. -func storedResult() recordedCompile { - return recordedCompile{ - Succeeded: false, - Diagnostics: []Diagnostic{{ - Severity: severityError, - Code: "CS0103", - Message: "The name 'Missing' does not exist in the current context", - File: filepath.Join("Assets", "Alpha", "Alpha.cs"), - Line: 3, - Column: 9, - }}, - } -} - -// recordResult stores one compile's outcome under a key. -func recordResult(t *testing.T, outputDirectoryPath string, unit CompileUnit, key string) { - t.Helper() - if err := manifestStore(outputDirectoryPath).recordCompile( - unit.Assembly.AssemblyName, key, storedResult(), unitOutputNames(unit)); err != nil { - t.Fatalf("failed to write the manifest: %v", err) - } -} - -// Verifies a recorded compile is handed back with the diagnostics and the outcome it had, rather -// than as a success: an assembly that failed must keep failing until something about it changes. -func TestLoadReusableResultReplaysTheRecordedDiagnostics(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - recordResult(t, outputDirectoryPath, unit, "key") - - result, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key") - - if !reusable { - t.Fatal("expected the recorded result to be reusable") - } - if result.Succeeded { - t.Error("a failed compile must not come back as a successful one") - } - if len(result.Diagnostics) != 1 || result.Diagnostics[0].Code != "CS0103" { - t.Errorf("diagnostics = %+v, want the recorded CS0103", result.Diagnostics) - } -} - -// Verifies a result recorded for other inputs is not reused. -func TestLoadReusableResultRejectsAnotherKey(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - recordResult(t, outputDirectoryPath, unit, "key") - - if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "other key"); reusable { - t.Error("a result recorded for other inputs should not be reused") - } -} - -// Verifies an output that no longer holds what the compile wrote stops the reuse: the assemblies -// below this one read those bytes, so replaying diagnostics over them would check nothing. -func TestLoadReusableResultRejectsAChangedOutput(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - recordResult(t, outputDirectoryPath, unit, "key") - - writeOutputFile(t, outputDirectoryPath, "Alpha"+referenceAssemblyExtension, "another surface") - - if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key"); reusable { - t.Error("a changed output should stop the reuse") - } -} - -// Verifies a missing output stops the reuse. -func TestLoadReusableResultRejectsAMissingOutput(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - recordResult(t, outputDirectoryPath, unit, "key") - - if err := os.Remove(filepath.Join(outputDirectoryPath, "Alpha"+assemblyExtension)); err != nil { - t.Fatalf("failed to remove the output: %v", err) - } - - if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key"); reusable { - t.Error("a missing output should stop the reuse") - } -} - -// Verifies a manifest that cannot be read is treated as no manifest at all rather than as a -// failure: the assembly simply compiles again. -func TestLoadReusableResultRejectsAnUnreadableManifest(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - writeOutputFile(t, outputDirectoryPath, "Alpha.result.json", "{not json") - - if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key"); reusable { - t.Error("an unreadable manifest should not be reused") - } -} - -// Verifies a manifest written by another version of this check is not reused. -func TestLoadReusableResultRejectsAnotherFormatVersion(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - recordResult(t, outputDirectoryPath, unit, "key") - path := manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName) - content, err := os.ReadFile(path) - if err != nil { - t.Fatalf("failed to read the manifest: %v", err) - } - var manifest unitResultManifest - if err := json.Unmarshal(content, &manifest); err != nil { - t.Fatalf("failed to parse the manifest: %v", err) - } - manifest.FormatVersion = resultManifestFormatVersion + 1 - rewritten, err := json.Marshal(manifest) - if err != nil { - t.Fatalf("failed to write the manifest back: %v", err) - } - if err := os.WriteFile(path, rewritten, 0o600); err != nil { - t.Fatalf("failed to write the manifest back: %v", err) - } - - if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key"); reusable { - t.Error("a manifest of another format version should not be reused") - } -} - -// Verifies removing a unit's outputs removes the manifest with them, so nothing describes a compile -// whose outputs are gone. -func TestRemoveUnitOutputsRemovesTheManifest(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - recordResult(t, outputDirectoryPath, unit, "key") - - if err := (Compiler{ProjectRoot: outputDirectoryPath}).removeUnitOutputs(BuildPlan{}, unit); err != nil { - t.Fatalf("failed to remove the outputs: %v", err) - } - - if _, err := os.Stat(manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName)); !os.IsNotExist(err) { - t.Error("the manifest should have been removed with the outputs") - } -} - -// Verifies a compile that wrote no outputs still records what it reported, so the next run can tell -// a failure it already knows from one it has not seen. -func TestWriteUnitResultManifestRecordsACompileThatWroteNothing(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - for _, name := range []string{"Alpha" + assemblyExtension, "Alpha" + referenceAssemblyExtension} { - if err := os.Remove(filepath.Join(outputDirectoryPath, name)); err != nil { - t.Fatalf("failed to remove %s: %v", name, err) - } - } - recordResult(t, outputDirectoryPath, unit, "key") - - result, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key") - - if !reusable { - t.Fatal("expected a compile that wrote nothing to be reusable") - } - if len(result.Diagnostics) != 1 { - t.Errorf("diagnostics = %+v, want the recorded one", result.Diagnostics) - } -} diff --git a/cli/dispatcher/internal/compilecheck/result_cache_write_test.go b/cli/dispatcher/internal/compilecheck/result_cache_write_test.go index 5f026b3314..a9c2869cdd 100644 --- a/cli/dispatcher/internal/compilecheck/result_cache_write_test.go +++ b/cli/dispatcher/internal/compilecheck/result_cache_write_test.go @@ -10,63 +10,12 @@ import ( "time" ) -// blockManifestPath puts a non-empty directory where a unit's manifest belongs, so renaming the -// finished manifest into place fails on every platform. -func blockManifestPath(t *testing.T, manifestFilePath string) { - t.Helper() - writeFileAt(t, filepath.Join(manifestFilePath, "occupied"), "") -} - -// Verifies a manifest that cannot be renamed into place is reported as a write failure and leaves -// no temporary file behind. -func TestWriteUnitResultManifestReportsAFailedRename(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - blockManifestPath(t, manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName)) - - err := manifestStore(outputDirectoryPath).recordCompile(unit.Assembly.AssemblyName, "key", storedResult(), unitOutputNames(unit)) - - if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName)+":") { - t.Fatalf("err = %v", err) - } - matches, globErr := filepath.Glob(filepath.Join(outputDirectoryPath, "*"+resultManifestTempSuffix)) - if globErr != nil || len(matches) != 0 { - t.Fatalf("temporary manifests left behind: %v (glob error %v)", matches, globErr) - } -} - -// Verifies a manifest cannot be written into an output directory that does not exist, which is -// reported with the manifest path. -func TestWriteUnitResultManifestReportsAMissingOutputDirectory(t *testing.T) { - _, unit := newManifestUnit(t) - outputDirectoryPath := filepath.Join(t.TempDir(), "missing") - - err := manifestStore(outputDirectoryPath).recordCompile(unit.Assembly.AssemblyName, "key", storedResult(), unitOutputNames(unit)) - - if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName)+":") { - t.Fatalf("err = %v", err) - } -} - -// Verifies an output that exists but cannot be read is reported instead of being treated as absent. -func TestHashUnitOutputsReportsAnUnreadableOutput(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - unreadablePath := filepath.Join(outputDirectoryPath, "Alpha"+assemblyExtension) - if err := os.Remove(unreadablePath); err != nil { - t.Fatalf("failed to remove the assembly: %v", err) - } - makeDirAt(t, unreadablePath) - - _, complete, err := hashOutputs(outputDirectoryPath, unitOutputNames(unit), true) - - if err == nil || complete || !strings.Contains(err.Error(), unreadablePath) { - t.Fatalf("complete=%v err=%v, want an error naming %s", complete, err, unreadablePath) - } -} - // Verifies a unit without a reference assembly names its assembly alone as an output. func TestUnitOutputNamesSkipsAnAbsentReferenceAssemblyPath(t *testing.T) { - _, unit := newManifestUnit(t) - unit.Assembly.RefOutputPath = "" + unit := CompileUnit{Assembly: ResponseFile{ + AssemblyName: "Alpha", + OutputPath: filepath.Join(planDagDirectory, "Alpha"+assemblyExtension), + }} names := unitOutputNames(unit) @@ -75,14 +24,31 @@ func TestUnitOutputNamesSkipsAnAbsentReferenceAssemblyPath(t *testing.T) { } } -// Verifies only the named outputs are hashed, even when another output is on disk. -func TestHashOutputsHashesOnlyTheNamedOutputs(t *testing.T) { - outputDirectoryPath, _ := newManifestUnit(t) +// Verifies removing a unit's outputs removes both outputs and leaves no record of the compile that +// wrote them: no compile result, no baseline, and no saved generator output. +func TestRemoveUnitOutputsLeavesNoRecords(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + generatedPath := filepath.Join(project.root, project.records().generatedDirectory("Alpha")) + if _, err := os.Stat(generatedPath); err != nil { + t.Fatalf("expected the compile to leave its generator output directory: %v", err) + } + compiler := Compiler{Paths: project.paths, ProjectRoot: project.root} - outputs, complete, err := hashOutputs(outputDirectoryPath, []string{"Alpha" + assemblyExtension}, true) + if err := compiler.removeUnitOutputs(project.plan, project.plan.Units[0]); err != nil { + t.Fatalf("expected the outputs to be removed, got error: %v", err) + } - if err != nil || !complete || len(outputs) != 1 || outputs["Alpha"+assemblyExtension] == "" { - t.Fatalf("outputs=%v complete=%v err=%v", outputs, complete, err) + for _, name := range unitOutputNames(project.plan.Units[0]) { + if _, err := os.Stat(filepath.Join(project.outputDirectory(), name)); !os.IsNotExist(err) { + t.Errorf("expected %s to be removed, got %v", name, err) + } + } + if project.hasRecordedCompile("Alpha") || project.hasBaseline() { + t.Error("expected no compile result and no baseline after the outputs were removed") + } + if _, err := os.Stat(generatedPath); !os.IsNotExist(err) { + t.Errorf("expected the generator output to be removed, got %v", err) } } @@ -91,23 +57,26 @@ func TestHashOutputsHashesOnlyTheNamedOutputs(t *testing.T) { func TestCompileUnitsFailsWhenAResultCannotBeRecorded(t *testing.T) { project := newCachedSchedulerProject(t, nil, "Alpha") recorder := newCachedSchedulerRecorder("Alpha") + recorder.output["Alpha"] = failingCompilerOutput("Alpha") compileRunner := recorder.runner() compiler := Compiler{ Paths: project.paths, ProjectRoot: project.root, Timeout: time.Minute, - // Why the block is placed during the compile: the compile removes any previous manifest before - // it starts, so only a path occupied after that point reaches the manifest write. + // Why the output directory goes during the compile: the compile failed, so its missing outputs + // are allowed, and only writing the record finds nowhere to go. Run: func(ctx context.Context, cmd *exec.Cmd) (string, string, int, error) { stdout, stderr, exitCode, err := compileRunner(ctx, cmd) - blockManifestPath(t, project.manifestPathOf("Alpha")) + if removeErr := os.RemoveAll(project.outputDirectory()); removeErr != nil { + t.Errorf("failed to remove the output directory: %v", removeErr) + } return stdout, stderr, exitCode, err }, } _, err := compileUnits(context.Background(), compiler, project.plan, 1) - if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+project.manifestPathOf("Alpha")+":") { - t.Fatalf("err = %v, want the manifest write failure", err) + if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+project.outputDirectory()+string(filepath.Separator)) { + t.Fatalf("err = %v, want the record write failure", err) } } diff --git a/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go b/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go index 1e0af8bd51..8eb8c39f38 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go @@ -108,16 +108,38 @@ func (project *cachedSchedulerProject) outputDirectory() string { return filepath.Join(project.root, project.plan.OutputDir) } -// manifestPathOf names one assembly's result manifest. -func (project *cachedSchedulerProject) manifestPathOf(name string) string { - return filepath.Join(project.outputDirectory(), name+resultManifestExtension) +// writeOutputFile writes one file into the check's own output directory. +func writeOutputFile(t *testing.T, outputDirectoryPath string, name string, content string) { + t.Helper() + if err := os.WriteFile( + filepath.Join(outputDirectoryPath, name), []byte(content), 0o600); err != nil { + t.Fatalf("failed to write %s: %v", name, err) + } +} + +// records opens the store the runs of this test project write to. +func (project *cachedSchedulerProject) records() recordStore { + return openRecordStore(project.root, project.plan.OutputDir) } -// hasManifest reports whether one assembly has a recorded result right now. -func (project *cachedSchedulerProject) hasManifest(name string) bool { - _, err := os.Stat(project.manifestPathOf(name)) +// hasBaselineOf reports whether a baseline of the current format is on disk for the assembly. +// Why the "no baseline:" prefix tells: the store gives it only when the baseline file is missing or +// was written by another version; a baseline file that disagrees with its compile result is +// reported as "baseline compile is no longer on disk" instead. +func (project *cachedSchedulerProject) hasBaselineOf(name string) bool { + _, reason := project.records().loadBaseline(name) - return err == nil + return !strings.HasPrefix(reason, "no baseline:") +} + +// hasRecordedCompile reports whether a compile result of the assembly is on record. +// Why the baseline reason tells: these assemblies have fewer sources than the changed-files check +// needs, so they never get a baseline, and the store explains the missing baseline from the +// compile result alone - this reason is the one it gives when there is no compile result. +func (project *cachedSchedulerProject) hasRecordedCompile(name string) bool { + _, reason := project.records().loadBaseline(name) + + return reason != "no baseline: no full compile of this assembly is on record" } // compile runs the whole plan through the scheduler with a fake compiler. @@ -306,20 +328,20 @@ func TestCompileUnitsBlocksTheDependentsOfAReplayedFailure(t *testing.T) { func TestCompileUnitsHasNoManifestWhileAUnitIsCompiling(t *testing.T) { project := newCachedSchedulerProject(t, nil, "Alpha") project.compileOrFail(t, newCachedSchedulerRecorder("Alpha")) - if !project.hasManifest("Alpha") { + if !project.hasRecordedCompile("Alpha") { t.Fatal("the first run should have recorded a result") } project.editSource(t, "Alpha", "class Alpha { void Body() { } }") recorder := newCachedSchedulerRecorder("Alpha") present := map[string]bool{} - recorder.during = func(name string) { present[name] = project.hasManifest(name) } + recorder.during = func(name string) { present[name] = project.hasRecordedCompile(name) } project.compileOrFail(t, recorder) if present["Alpha"] { t.Error("the manifest of the previous compile should be gone while the unit compiles") } - if !project.hasManifest("Alpha") { + if !project.hasRecordedCompile("Alpha") { t.Error("the finished compile should have been recorded") } } @@ -333,7 +355,7 @@ func TestCompileUnitsRecordsNothingForAUnitThatCouldNotBeStarted(t *testing.T) { if _, err := project.compile(context.Background(), failing); err == nil { t.Fatal("expected the failure to start the compiler to be reported") } - if project.hasManifest("Alpha") { + if project.hasRecordedCompile("Alpha") { t.Error("a unit whose compiler never ran should have no recorded result") } @@ -356,7 +378,7 @@ func TestCompileUnitsRecordsNothingForACancelledUnit(t *testing.T) { t.Fatal("expected the cancelled run to be reported as a failure") } cancel() - if project.hasManifest("Alpha") { + if project.hasRecordedCompile("Alpha") { t.Error("a cancelled compile should have no recorded result") } @@ -398,7 +420,7 @@ func TestCompileUnitsRecordsNothingWhenTheCompilerFailedWithoutADiagnostic(t *te broken.output["Alpha"] = fakeCompilerOutput{stdout: "csc : fatal error", exitCode: 1} project.compileOrFail(t, broken) - if project.hasManifest("Alpha") { + if project.hasRecordedCompile("Alpha") { t.Error("a compile that failed without a diagnostic should have no recorded result") } @@ -410,21 +432,31 @@ func TestCompileUnitsRecordsNothingWhenTheCompilerFailedWithoutADiagnostic(t *te } } -// Verifies an output that no longer holds what the compile wrote sends the unit back through the -// compiler: the assemblies below it read those bytes. +// Verifies that either output - the assembly or the reference assembly - no longer holding what the +// compile wrote sends the unit back through the compiler: the assemblies below it read those bytes. func TestCompileUnitsCompilesAgainWhenAnOutputWasChanged(t *testing.T) { - project := newCachedSchedulerProject(t, nil, "Alpha") - project.compileOrFail(t, newCachedSchedulerRecorder("Alpha")) - - assemblyPath := filepath.Join(project.outputDirectory(), "Alpha"+assemblyExtension) - if err := os.WriteFile(assemblyPath, []byte("something else"), 0o600); err != nil { - t.Fatalf("failed to change the output: %v", err) - } - recorder := newCachedSchedulerRecorder("Alpha") - project.compileOrFail(t, recorder) - - if recorder.runs["Alpha"] != 1 { - t.Errorf("a unit whose output changed should have compiled, got %d runs", recorder.runs["Alpha"]) + for name, output := range map[string]string{ + "assembly": "Alpha" + assemblyExtension, + "reference assembly": "Alpha" + referenceAssemblyExtension, + } { + t.Run(name, func(t *testing.T) { + project := newCachedSchedulerProject(t, nil, "Alpha") + project.compileOrFail(t, newCachedSchedulerRecorder("Alpha")) + + outputPath := filepath.Join(project.outputDirectory(), output) + if _, err := os.Stat(outputPath); err != nil { + t.Fatalf("expected the compile to write %s: %v", output, err) + } + if err := os.WriteFile(outputPath, []byte("something else"), 0o600); err != nil { + t.Fatalf("failed to change the output: %v", err) + } + recorder := newCachedSchedulerRecorder("Alpha") + project.compileOrFail(t, recorder) + + if recorder.runs["Alpha"] != 1 { + t.Errorf("a unit whose %s changed should have compiled, got %d runs", name, recorder.runs["Alpha"]) + } + }) } } @@ -433,7 +465,7 @@ func TestCompileUnitsCompilesAgainWhenAnOutputWasChanged(t *testing.T) { func TestCompileUnitsLeavesNoManifestForASkippedDependent(t *testing.T) { project := newCachedSchedulerProject(t, map[string][]string{"Beta": {"Alpha"}}, "Alpha", "Beta") project.compileOrFail(t, newCachedSchedulerRecorder("Alpha", "Beta")) - if !project.hasManifest("Beta") { + if !project.hasRecordedCompile("Beta") { t.Fatal("the first run should have recorded a result for the dependent") } @@ -454,7 +486,7 @@ func TestCompileUnitsLeavesNoManifestForASkippedDependent(t *testing.T) { if recorder.runs["Beta"] != 0 { t.Errorf("the skipped dependent should not have compiled, got %d runs", recorder.runs["Beta"]) } - if project.hasManifest("Beta") { + if project.hasRecordedCompile("Beta") { t.Error("the skipped dependent's recorded result should have been removed with its outputs") } } @@ -478,7 +510,7 @@ func TestCompileUnitsRecordsNothingForAUnitKilledWhileItWasReportingDiagnostics( } cancel() - if project.hasManifest("Alpha") { + if project.hasRecordedCompile("Alpha") { t.Fatal("a killed compile must leave no recorded result to replay") } @@ -500,7 +532,7 @@ func TestCompileUnitsRecordsNothingWhenASuccessfulCompileWroteNoOutput(t *testin project.compileOrFail(t, silent) - if project.hasManifest("Alpha") { + if project.hasRecordedCompile("Alpha") { t.Fatal("a compile that produced no output must leave no recorded result") } From 8c60da84216be4bc5686036e133b0b608d1b3134 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 12:41:31 +0900 Subject: [PATCH 12/33] refactor: move compile-check's records into a resultstore package (#3327) --- .../internal/compilecheck/baseline_record.go | 11 +- .../compilecheck/baseline_record_test.go | 5 +- .../internal/compilecheck/compiler.go | 6 +- .../compilecheck/diagnostic_format_test.go | 31 +++ .../internal/compilecheck/fast_path.go | 13 +- .../compilecheck/fast_path_fixture_test.go | 7 +- .../compilecheck/fast_path_order_test.go | 2 +- .../internal/compilecheck/fast_path_test.go | 17 +- .../internal/compilecheck/record_store.go | 206 ----------------- .../internal/compilecheck/result_cache.go | 12 +- .../compilecheck/result_cache_write_test.go | 2 +- .../baseline.go} | 99 ++++---- .../resultstore/baseline_contract_test.go | 213 ++++++++++++++++++ .../baseline_test.go} | 8 +- .../resultstore/compile_result.go | 133 +++++++++++ .../compile_result_test.go} | 40 ++-- .../compilecheck/resultstore/fixture_test.go | 52 +++++ .../{ => resultstore}/record_format_test.go | 16 +- .../compilecheck/resultstore/store.go | 134 +++++++++++ .../compilecheck/scheduler_cache_test.go | 10 +- 20 files changed, 696 insertions(+), 321 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/diagnostic_format_test.go delete mode 100644 cli/dispatcher/internal/compilecheck/record_store.go rename cli/dispatcher/internal/compilecheck/{record_store_baseline.go => resultstore/baseline.go} (77%) create mode 100644 cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go rename cli/dispatcher/internal/compilecheck/{record_store_baseline_test.go => resultstore/baseline_test.go} (84%) create mode 100644 cli/dispatcher/internal/compilecheck/resultstore/compile_result.go rename cli/dispatcher/internal/compilecheck/{record_store_test.go => resultstore/compile_result_test.go} (87%) create mode 100644 cli/dispatcher/internal/compilecheck/resultstore/fixture_test.go rename cli/dispatcher/internal/compilecheck/{ => resultstore}/record_format_test.go (90%) create mode 100644 cli/dispatcher/internal/compilecheck/resultstore/store.go diff --git a/cli/dispatcher/internal/compilecheck/baseline_record.go b/cli/dispatcher/internal/compilecheck/baseline_record.go index 360045d574..38be92048f 100644 --- a/cli/dispatcher/internal/compilecheck/baseline_record.go +++ b/cli/dispatcher/internal/compilecheck/baseline_record.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/resultstore" ) // RecordBaseline records what the next changed-files check of a unit compares against, after a @@ -56,9 +57,9 @@ func (runner *fastPathRunner) RecordBaseline( return err } - return c.records(plan).recordBaseline(unit.Assembly.AssemblyName, baselineInputs{ + return c.records(plan).RecordBaseline(unit.Assembly.AssemblyName, resultstore.BaselineInputs{ InputKey: keyResult.Key, - NonSourceKey: nonSourceKey, + NonSourceKey: resultstore.KeyDigest(nonSourceKey), WorkerKey: worker.Key, Files: files, }) @@ -67,8 +68,8 @@ func (runner *fastPathRunner) RecordBaseline( // baselineFiles pairs each source's content hash, as the input key read it, with its fingerprint. func baselineFiles( projectRoot string, unit CompileUnit, keyResult inputKeyResult, fingerprints fastcheck.FingerprintResponse, -) (map[string]baselineFile, error) { - files := make(map[string]baselineFile, len(unit.Sources)) +) (map[string]resultstore.BaselineFile, error) { + files := make(map[string]resultstore.BaselineFile, len(unit.Sources)) for _, source := range unit.Sources { absolute := projectPath(projectRoot, source) fingerprint, hasFingerprint := fingerprints.Fingerprints[absolute] @@ -79,7 +80,7 @@ func baselineFiles( if contentHash != keyResult.SourceHashes[source] { return nil, fmt.Errorf("source changed during the compile: %s", source) } - files[source] = baselineFile{ContentHash: contentHash, Fingerprint: fingerprint} + files[source] = resultstore.BaselineFile{ContentHash: contentHash, Fingerprint: fingerprint} } return files, nil diff --git a/cli/dispatcher/internal/compilecheck/baseline_record_test.go b/cli/dispatcher/internal/compilecheck/baseline_record_test.go index 6667ad622b..b84ce7279f 100644 --- a/cli/dispatcher/internal/compilecheck/baseline_record_test.go +++ b/cli/dispatcher/internal/compilecheck/baseline_record_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/resultstore" ) // Verifies that a successful full compile records a baseline whose content hashes are the ones the @@ -125,7 +126,7 @@ func TestCompileUnitPassesGeneratedFilesOut(t *testing.T) { written, _ := compileSecondUnit(t, "", 0) plan := newCompilerPlan() - want := quoteFlag(generatedFilesOutFlagPrefix, filepath.ToSlash(openRecordStore("", plan.OutputDir).generatedDirectory(plan.Units[1].Assembly.AssemblyName))) + want := quoteFlag(generatedFilesOutFlagPrefix, filepath.ToSlash(resultstore.Open[Diagnostic]("", plan.OutputDir).GeneratedDirectory(plan.Units[1].Assembly.AssemblyName))) if !strings.Contains(written, want) { t.Errorf("expected %s in the response file, got:\n%s", want, written) } @@ -217,7 +218,7 @@ func TestCompileUnitCreatesGeneratedFilesOutDirectory(t *testing.T) { ProjectRoot: projectRoot, Timeout: time.Minute, Run: func(_ context.Context, _ *exec.Cmd) (string, string, int, error) { - info, err := os.Stat(filepath.Join(projectRoot, openRecordStore(projectRoot, plan.OutputDir).generatedDirectory(plan.Units[1].Assembly.AssemblyName))) + info, err := os.Stat(filepath.Join(projectRoot, resultstore.Open[Diagnostic](projectRoot, plan.OutputDir).GeneratedDirectory(plan.Units[1].Assembly.AssemblyName))) existed = err == nil && info.IsDir() return "", "", 0, nil }, diff --git a/cli/dispatcher/internal/compilecheck/compiler.go b/cli/dispatcher/internal/compilecheck/compiler.go index 530362d8bc..aa2ffacaa3 100644 --- a/cli/dispatcher/internal/compilecheck/compiler.go +++ b/cli/dispatcher/internal/compilecheck/compiler.go @@ -108,7 +108,7 @@ func (c Compiler) CompileUnit( // Why the generator output directory is created here: the csc of Roslyn 4.3 (Unity 2022.3) creates // the folder of each generator under it but not the directory itself, and fails every generated // file with CS0016 when it is missing. Newer Roslyn creates it either way. - generatedPath := filepath.Join(c.ProjectRoot, c.records(plan).generatedDirectory(unit.Assembly.AssemblyName)) + generatedPath := filepath.Join(c.ProjectRoot, c.records(plan).GeneratedDirectory(unit.Assembly.AssemblyName)) if err := os.MkdirAll(generatedPath, outputDirPermissions); err != nil { return UnitResult{}, fmt.Errorf("failed to create %s: %w", generatedPath, err) } @@ -116,7 +116,7 @@ func (c Compiler) CompileUnit( // Why the generator output is saved: a later fast check reads it instead of running the generators. responseFilePath, err := writeRewrittenResponseFile(c.ProjectRoot, plan, unit, unit.Assembly.AssemblyName+responseFileExtension, - []string{quoteFlag(generatedFilesOutFlagPrefix, filepath.ToSlash(c.records(plan).generatedDirectory(unit.Assembly.AssemblyName)))}) + []string{quoteFlag(generatedFilesOutFlagPrefix, filepath.ToSlash(c.records(plan).GeneratedDirectory(unit.Assembly.AssemblyName)))}) if err != nil { return UnitResult{}, err } @@ -168,7 +168,7 @@ func (c Compiler) removeUnitOutputs(plan BuildPlan, unit CompileUnit) error { } } - return c.records(plan).forget(unit.Assembly.AssemblyName) + return c.records(plan).Forget(unit.Assembly.AssemblyName) } // buildUnitResult turns one compiler invocation's output into the result for its assembly. diff --git a/cli/dispatcher/internal/compilecheck/diagnostic_format_test.go b/cli/dispatcher/internal/compilecheck/diagnostic_format_test.go new file mode 100644 index 0000000000..b66a5872e7 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/diagnostic_format_test.go @@ -0,0 +1,31 @@ +package compilecheck + +import ( + "encoding/json" + "testing" +) + +// Verifies the JSON form of a diagnostic, field names and order included. The result store records +// diagnostics exactly as it is handed them, so changing this form would change how the records an +// earlier run left are read, without anything marking those records as written by another version. +func TestDiagnosticKeepsTheJSONFormTheResultStoreRecords(t *testing.T) { + diagnostic := Diagnostic{ + Severity: severityError, + Code: "CS0103", + Message: "The name 'Missing' does not exist in the current context", + File: "Assets/Alpha/Alpha.cs", + Line: 3, + Column: 9, + } + + content, err := json.Marshal(diagnostic) + if err != nil { + t.Fatalf("failed to encode the diagnostic: %v", err) + } + + want := `{"Severity":"error","Code":"CS0103","Message":"The name 'Missing' does not exist in the current context",` + + `"File":"Assets/Alpha/Alpha.cs","Line":3,"Column":9}` + if string(content) != want { + t.Errorf("JSON form =\n%s\nwant\n%s", content, want) + } +} diff --git a/cli/dispatcher/internal/compilecheck/fast_path.go b/cli/dispatcher/internal/compilecheck/fast_path.go index 5665398e1a..5c9dfaa9ac 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path.go +++ b/cli/dispatcher/internal/compilecheck/fast_path.go @@ -10,6 +10,7 @@ import ( "time" "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/resultstore" ) const ( @@ -78,7 +79,7 @@ func (runner *fastPathRunner) Try( nonSourceKey nonSourceIdentity, ) fastPathAttempt { records := c.records(plan) - baseline, reason := records.loadBaseline(unit.Assembly.AssemblyName) + baseline, reason := records.LoadBaseline(unit.Assembly.AssemblyName) if reason != "" { return fallBack(reason) } @@ -88,10 +89,10 @@ func (runner *fastPathRunner) Try( } if nonSourceKey.Key != baseline.NonSourceKey.Key { return fallBack("inputs other than source contents changed: " + - strings.Join(changedInputKinds(nonSourceKey, baseline.NonSourceKey), ", ")) + strings.Join(changedInputKinds(nonSourceKey, nonSourceIdentity(baseline.NonSourceKey)), ", ")) } - if !records.generatorOutputUnchanged(baseline) { + if !records.GeneratorOutputUnchanged(baseline) { return fallBack("generator output of the baseline is gone") } @@ -114,7 +115,7 @@ func (runner *fastPathRunner) Try( // last full compile reported for the files that did not change. func (runner *fastPathRunner) check( ctx context.Context, c Compiler, plan BuildPlan, unit CompileUnit, - baseline storedBaseline, changed []string, + baseline resultstore.Baseline[Diagnostic], changed []string, ) fastPathAttempt { startedAt := time.Now() worker, err := runner.Worker.Ensure(ctx) @@ -174,7 +175,7 @@ func projectRelativeReason(projectRoot string, reason string, changed []string) // buildCheckRequest names, in absolute paths, what the worker checks and against which baseline. func buildCheckRequest( - projectRoot string, baseline storedBaseline, responseFilePath string, changed []string, + projectRoot string, baseline resultstore.Baseline[Diagnostic], responseFilePath string, changed []string, ) fastcheck.CheckRequest { request := fastcheck.CheckRequest{ ProjectRoot: projectRoot, @@ -196,7 +197,7 @@ func buildCheckRequest( // changedSources lists the sources whose content differs from the baseline, using the hashes the // input key read rather than reading the files again. -func changedSources(unit CompileUnit, keyResult inputKeyResult, files map[string]baselineFile) []string { +func changedSources(unit CompileUnit, keyResult inputKeyResult, files map[string]resultstore.BaselineFile) []string { changed := []string{} for _, source := range unit.Sources { if keyResult.SourceHashes[source] != files[source].ContentHash { diff --git a/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go index cf9ff5458c..312bd215bf 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/resultstore" ) // fakeDeclarationSeparator splits a fixture source into its declaration part and its body part, so @@ -227,9 +228,9 @@ func (project *fastPathProject) hasBaseline() bool { } // baseline reads Alpha's baseline through the store and fails the test when there is none to use. -func (project *fastPathProject) baseline(t *testing.T) storedBaseline { +func (project *fastPathProject) baseline(t *testing.T) resultstore.Baseline[Diagnostic] { t.Helper() - baseline, reason := project.records().loadBaseline("Alpha") + baseline, reason := project.records().LoadBaseline("Alpha") if reason != "" { t.Fatalf("expected a usable baseline for Alpha, got %q", reason) } @@ -238,7 +239,7 @@ func (project *fastPathProject) baseline(t *testing.T) storedBaseline { } // generatedFileCount counts the files under the generator output a baseline points at. -func (project *fastPathProject) generatedFileCount(t *testing.T, baseline storedBaseline) int { +func (project *fastPathProject) generatedFileCount(t *testing.T, baseline resultstore.Baseline[Diagnostic]) int { t.Helper() count := 0 err := filepath.WalkDir(projectPath(project.root, baseline.GeneratedDirectory), diff --git a/cli/dispatcher/internal/compilecheck/fast_path_order_test.go b/cli/dispatcher/internal/compilecheck/fast_path_order_test.go index 858fc7362f..e1002a4f70 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_order_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_order_test.go @@ -13,7 +13,7 @@ import ( // matches the baseline, and the check that it is gone could run anywhere in the order unnoticed. func (project *fastPathProject) recordBaselineWithGeneratorOutput(t *testing.T) string { t.Helper() - generatedDirectoryPath := project.records().generatedDirectory("Alpha") + generatedDirectoryPath := project.records().GeneratedDirectory("Alpha") recorder := newCachedSchedulerRecorder("Alpha", "Beta") recorder.during = func(unit string) { if unit == "Alpha" { diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go index 4f93890813..d2b7149f00 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/resultstore" ) // Verifies that unchanged inputs are reused before the changed-files check is tried. @@ -123,8 +124,8 @@ func TestHandCorruptedFailedManifestFallsBack(t *testing.T) { project.recordBaseline(t) project.editBody(t, 0, "edited") baseline := project.baseline(t) - if err := project.records().recordCompile("Alpha", baseline.InputKey, - recordedCompile{Succeeded: false, Diagnostics: baseline.Diagnostics}, unitOutputNames(project.plan.Units[0])); err != nil { + if err := project.records().RecordCompile("Alpha", baseline.InputKey, + resultstore.CompileResult[Diagnostic]{Succeeded: false, Diagnostics: baseline.Diagnostics}, unitOutputNames(project.plan.Units[0])); err != nil { t.Fatalf("failed to record the failed compile: %v", err) } recorder := newCachedSchedulerRecorder("Alpha", "Beta") @@ -222,11 +223,11 @@ func TestMissingOrChangedGeneratorOutputFallsBack(t *testing.T) { } { t.Run(name, func(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) - generatedPath := filepath.Join(project.root, project.records().generatedDirectory("Alpha")) + generatedPath := filepath.Join(project.root, project.records().GeneratedDirectory("Alpha")) recorder := newCachedSchedulerRecorder("Alpha", "Beta") recorder.during = func(unit string) { if unit == "Alpha" { - project.writeFile(t, filepath.Join(project.records().generatedDirectory("Alpha"), "Gen", "G.g.cs"), "class G { }") + project.writeFile(t, filepath.Join(project.records().GeneratedDirectory("Alpha"), "Gen", "G.g.cs"), "class G { }") } } project.run(t, recorder, false) @@ -361,7 +362,7 @@ func TestFastPathCleanResultKeepsBaselineFiles(t *testing.T) { if recorder.runs["Beta"] != 0 || outcome.Skipped != 1 { t.Errorf("expected Beta to be skipped, got %d runs and %d skipped", recorder.runs["Beta"], outcome.Skipped) } - if project.worker.lastCheck.GeneratedDirectory != filepath.Join(project.root, project.records().generatedDirectory("Alpha")) { + if project.worker.lastCheck.GeneratedDirectory != filepath.Join(project.root, project.records().GeneratedDirectory("Alpha")) { t.Errorf("expected the saved generator output to be passed, got %q", project.worker.lastCheck.GeneratedDirectory) } } @@ -434,11 +435,11 @@ func TestZeroChangedFilesDropsBaselineAndFallsBack(t *testing.T) { project.recordBaseline(t) baseline := project.baseline(t) records := project.records() - if err := records.recordCompile("Alpha", "tampered", - recordedCompile{Succeeded: true, Diagnostics: baseline.Diagnostics}, unitOutputNames(project.plan.Units[0])); err != nil { + if err := records.RecordCompile("Alpha", "tampered", + resultstore.CompileResult[Diagnostic]{Succeeded: true, Diagnostics: baseline.Diagnostics}, unitOutputNames(project.plan.Units[0])); err != nil { t.Fatalf("failed to record the tampered compile: %v", err) } - if err := records.recordBaseline("Alpha", baselineInputs{ + if err := records.RecordBaseline("Alpha", resultstore.BaselineInputs{ InputKey: "tampered", NonSourceKey: baseline.NonSourceKey, WorkerKey: baseline.WorkerKey, Files: baseline.Files, }); err != nil { t.Fatalf("failed to record the tampered baseline: %v", err) diff --git a/cli/dispatcher/internal/compilecheck/record_store.go b/cli/dispatcher/internal/compilecheck/record_store.go deleted file mode 100644 index 5394464509..0000000000 --- a/cli/dispatcher/internal/compilecheck/record_store.go +++ /dev/null @@ -1,206 +0,0 @@ -package compilecheck - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" -) - -const ( - // resultManifestFormatVersion guards against reading a manifest an older or newer build wrote: - // what belongs in the key is part of this check's code, so a manifest from another version says - // nothing about whether this one would compile the same thing. - resultManifestFormatVersion = 1 - - resultManifestExtension = ".result.json" - resultManifestTempSuffix = ".tmp" -) - -// recordStore reads and writes what earlier runs of the check recorded for each assembly in one -// output directory: the result of its last compile, the baseline the changed-files check compares -// against, and the generator output that baseline was compiled with. -type recordStore struct { - projectRoot string - // outputDir is relative to the project root, which is how the records name the generator output. - outputDir string -} - -// recordedCompile is what one compile of an assembly reported. -type recordedCompile struct { - Succeeded bool - Diagnostics []Diagnostic -} - -// unitResultManifest is what one finished compile left behind for the next run: the inputs it ran -// on, what it reported, and the outputs it wrote. -type unitResultManifest struct { - FormatVersion int - InputKey string - Succeeded bool - Diagnostics []Diagnostic - // Outputs maps the name of each file the compile wrote in the check's output directory to its - // content hash, so a run that finds them changed underneath compiles again. - Outputs map[string]string -} - -// openRecordStore opens the records kept in outputDir, a directory relative to projectRoot. -func openRecordStore(projectRoot string, outputDir string) recordStore { - return recordStore{projectRoot: projectRoot, outputDir: outputDir} -} - -// directoryPath names the output directory the records live in. -func (s recordStore) directoryPath() string { - return filepath.Join(s.projectRoot, s.outputDir) -} - -// manifestPath names where one assembly's manifest lives. -func manifestPath(directoryPath string, assembly string) string { - return filepath.Join(directoryPath, assembly+resultManifestExtension) -} - -// reusableResult reports what a previous compile of the assembly found, when that compile ran on -// the inputs key describes and its outputs are still on disk exactly as it wrote them. -// Why anything unexpected simply means no: the caller compiles the unit, which is what it would -// have done anyway. Nothing here is worth failing a run over. -func (s recordStore) reusableResult(assembly string, key string) (recordedCompile, bool) { - content, err := os.ReadFile(manifestPath(s.directoryPath(), assembly)) - if err != nil { - return recordedCompile{}, false - } - - var manifest unitResultManifest - if err := json.Unmarshal(content, &manifest); err != nil { - return recordedCompile{}, false - } - if manifest.FormatVersion != resultManifestFormatVersion || manifest.InputKey != key || key == "" { - return recordedCompile{}, false - } - if !outputsStillMatch(s.directoryPath(), manifest.Outputs) { - return recordedCompile{}, false - } - - // Why the recorded outcome is replayed rather than assumed successful: a compile that reported - // errors reports them again until one of its inputs moves, and swallowing them here would turn a - // broken project into a clean run. - return recordedCompile{Succeeded: manifest.Succeeded, Diagnostics: manifest.Diagnostics}, true -} - -// outputsStillMatch reports whether every file a compile wrote is still there and still holds what -// it wrote. -func outputsStillMatch(directoryPath string, outputs map[string]string) bool { - for name, recordedHash := range outputs { - currentHash, err := hashFile(filepath.Join(directoryPath, name)) - if err != nil || currentHash != recordedHash { - return false - } - } - - return true -} - -// recordCompile records what a compile of the assembly ran on and what it reported, together with -// the outputs it wrote, named in the output directory. It records nothing when that compile claimed -// success without producing every one of those outputs. -// Why it is written through a temporary file: a run interrupted while writing would otherwise leave -// half a manifest behind, and the next run would read it as a description of outputs it never saw. -func (s recordStore) recordCompile(assembly string, key string, result recordedCompile, outputs []string) error { - hashes, complete, err := hashOutputs(s.directoryPath(), outputs, result.Succeeded) - if err != nil { - return err - } - if !complete { - return nil - } - - content, err := json.Marshal(unitResultManifest{ - FormatVersion: resultManifestFormatVersion, - InputKey: key, - Succeeded: result.Succeeded, - Diagnostics: result.Diagnostics, - Outputs: hashes, - }) - if err != nil { - return fmt.Errorf("failed to record the result of %s: %w", assembly, err) - } - - return writeFileAtomically(s.directoryPath(), manifestPath(s.directoryPath(), assembly), content) -} - -// writeFileAtomically writes content to path through a temporary file in directoryPath. -func writeFileAtomically(directoryPath string, path string, content []byte) error { - // Why the temporary name is unique per writer: two runs of the same project writing the same - // file at once must not rename each other's half-written file into place. - temporary, err := os.CreateTemp(directoryPath, filepath.Base(path)+".*"+resultManifestTempSuffix) - if err != nil { - return fmt.Errorf("failed to write %s: %w", path, err) - } - temporaryPath := temporary.Name() - if _, err := temporary.Write(content); err != nil { - _ = temporary.Close() - _ = os.Remove(temporaryPath) - - return fmt.Errorf("failed to write %s: %w", temporaryPath, err) - } - if err := temporary.Close(); err != nil { - _ = os.Remove(temporaryPath) - - return fmt.Errorf("failed to write %s: %w", temporaryPath, err) - } - if err := os.Rename(temporaryPath, path); err != nil { - _ = os.Remove(temporaryPath) - - return fmt.Errorf("failed to write %s: %w", path, err) - } - - return nil -} - -// hashOutputs hashes the named files a compile wrote in directoryPath. -// Why a missing output is tolerated only for a failed compile: that is the compile which writes -// neither the assembly nor its reference assembly. A successful compile that left one of them out -// is not a compile whose result describes anything - recording it would replay a success backed by -// files no run ever produced, and the outputs check would pass because it has nothing to check. -func hashOutputs(directoryPath string, names []string, succeeded bool) (map[string]string, bool, error) { - outputs := map[string]string{} - for _, name := range names { - hashed, err := hashFile(filepath.Join(directoryPath, name)) - if os.IsNotExist(err) { - if succeeded { - return nil, false, nil - } - - continue - } - if err != nil { - return nil, false, err - } - outputs[name] = hashed - } - - return outputs, true, nil -} - -// forget removes every record of the assembly: its manifest, its baseline, and its generator output. -func (s recordStore) forget(assembly string) error { - names := []string{ - // Why the manifest goes with the outputs: it describes those very files, so leaving it behind - // would let a later run replay diagnostics for outputs this one removed. - assembly + resultManifestExtension, - // The baseline and the generator output describe the same compile as the manifest. - assembly + baselineRecordExtension, - } - for _, name := range names { - path := filepath.Join(s.directoryPath(), name) - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to remove %s: %w", path, err) - } - } - - generatedPath := filepath.Join(s.directoryPath(), assembly+generatedDirectoryExtension) - if err := os.RemoveAll(generatedPath); err != nil { - return fmt.Errorf("failed to remove %s: %w", generatedPath, err) - } - - return nil -} diff --git a/cli/dispatcher/internal/compilecheck/result_cache.go b/cli/dispatcher/internal/compilecheck/result_cache.go index ea3c83057e..4e8e8c97f2 100644 --- a/cli/dispatcher/internal/compilecheck/result_cache.go +++ b/cli/dispatcher/internal/compilecheck/result_cache.go @@ -8,6 +8,8 @@ import ( "io" "os" "path/filepath" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/resultstore" ) // reuseOrCompile reports what csc would say about one unit, replaying the previous run's answer when @@ -26,7 +28,7 @@ func (c Compiler) reuseOrCompile( // Why --all only skips the lookup and still records: it is the way out of a reuse that got // something wrong, and the ordinary run after it should still be able to reuse what it compiled. if key != "" && !plan.All { - if recorded, found := records.reusableResult(unit.Assembly.AssemblyName, key); found { + if recorded, found := records.Reusable(unit.Assembly.AssemblyName, key); found { return UnitResult{ Assembly: unit.Assembly.AssemblyName, Diagnostics: recorded.Diagnostics, @@ -56,8 +58,8 @@ func (c Compiler) reuseOrCompile( if err != nil || key == "" || result.RawOutput != "" { return result, err } - if writeErr := records.recordCompile(unit.Assembly.AssemblyName, key, - recordedCompile{Succeeded: result.Succeeded, Diagnostics: result.Diagnostics}, unitOutputNames(unit)); writeErr != nil { + if writeErr := records.RecordCompile(unit.Assembly.AssemblyName, key, + resultstore.CompileResult[Diagnostic]{Succeeded: result.Succeeded, Diagnostics: result.Diagnostics}, unitOutputNames(unit)); writeErr != nil { return UnitResult{}, writeErr } @@ -75,8 +77,8 @@ func (c Compiler) reuseOrCompile( } // records opens the store of what earlier runs of the check recorded in the plan's output directory. -func (c Compiler) records(plan BuildPlan) recordStore { - return openRecordStore(c.ProjectRoot, plan.OutputDir) +func (c Compiler) records(plan BuildPlan) resultstore.Store[Diagnostic] { + return resultstore.Open[Diagnostic](c.ProjectRoot, plan.OutputDir) } // unitOutputNames lists the files a compile of the unit writes in the output directory, by name, diff --git a/cli/dispatcher/internal/compilecheck/result_cache_write_test.go b/cli/dispatcher/internal/compilecheck/result_cache_write_test.go index a9c2869cdd..a7b3488576 100644 --- a/cli/dispatcher/internal/compilecheck/result_cache_write_test.go +++ b/cli/dispatcher/internal/compilecheck/result_cache_write_test.go @@ -29,7 +29,7 @@ func TestUnitOutputNamesSkipsAnAbsentReferenceAssemblyPath(t *testing.T) { func TestRemoveUnitOutputsLeavesNoRecords(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) project.recordBaseline(t) - generatedPath := filepath.Join(project.root, project.records().generatedDirectory("Alpha")) + generatedPath := filepath.Join(project.root, project.records().GeneratedDirectory("Alpha")) if _, err := os.Stat(generatedPath); err != nil { t.Fatalf("expected the compile to leave its generator output directory: %v", err) } diff --git a/cli/dispatcher/internal/compilecheck/record_store_baseline.go b/cli/dispatcher/internal/compilecheck/resultstore/baseline.go similarity index 77% rename from cli/dispatcher/internal/compilecheck/record_store_baseline.go rename to cli/dispatcher/internal/compilecheck/resultstore/baseline.go index 7ce838233d..4364b3f370 100644 --- a/cli/dispatcher/internal/compilecheck/record_store_baseline.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/baseline.go @@ -1,4 +1,4 @@ -package compilecheck +package resultstore import ( "encoding/json" @@ -14,67 +14,76 @@ const ( generatedDirectoryExtension = ".generated" ) -// baselineRecord is what a successful full compile leaves for the next changed-files check. -type baselineRecord struct { - FormatVersion int - // InputKey is the key of the full compile this baseline describes; its manifest carries the same. - InputKey string - // NonSourceKey covers every input but the source contents, so any other change compiles in full. - NonSourceKey nonSourceIdentity - // WorkerKey names the worker build that computed the fingerprints. - WorkerKey string - GeneratedDirectory string // relative to the project root - // GeneratedFiles maps each generated file, relative to GeneratedDirectory, to its content hash, - // so a check never runs without the generator output its baseline compile had. - GeneratedFiles map[string]string - Files map[string]baselineFile +// KeyDigest is a key over several kinds of input together with a digest of each kind, so a change +// of the key can be told by kind. +type KeyDigest struct { + Key string + // Parts maps each kind of input to its digest. + Parts map[string]string } -// baselineFile describes one source as the full compile saw it. -type baselineFile struct { +// BaselineFile describes one source as the full compile saw it. +type BaselineFile struct { ContentHash string Fingerprint string } -// baselineInputs is what the caller knows about the full compile a baseline describes: its input -// key, its non-source key, the worker build that fingerprinted its sources, and each source. -type baselineInputs struct { +// BaselineInputs is what the caller knows about the full compile a baseline describes: its input +// key, its key over every input but the source contents, the worker build that fingerprinted its +// sources, and each source. +type BaselineInputs struct { InputKey string - NonSourceKey nonSourceIdentity + NonSourceKey KeyDigest WorkerKey string - Files map[string]baselineFile + Files map[string]BaselineFile } -// storedBaseline is a recorded baseline together with what its full compile reported. -type storedBaseline struct { +// Baseline is a recorded baseline together with what its full compile reported. +type Baseline[D any] struct { InputKey string - NonSourceKey nonSourceIdentity + NonSourceKey KeyDigest WorkerKey string - Files map[string]baselineFile + Files map[string]BaselineFile // GeneratedDirectory is relative to the project root and slash-separated, as recorded. GeneratedDirectory string // Diagnostics is what the full compile this baseline describes reported. - Diagnostics []Diagnostic + Diagnostics []D generatedFiles map[string]string } -// generatedDirectory names, relative to the project root, where a full compile saves the generator +// baselineRecord is what a successful full compile leaves for the next changed-files check. +type baselineRecord struct { + FormatVersion int + // InputKey is the key of the full compile this baseline describes; its manifest carries the same. + InputKey string + // NonSourceKey covers every input but the source contents, so any other change compiles in full. + NonSourceKey KeyDigest + // WorkerKey names the worker build that computed the fingerprints. + WorkerKey string + GeneratedDirectory string // relative to the project root + // GeneratedFiles maps each generated file, relative to GeneratedDirectory, to its content hash, + // so a check never runs without the generator output its baseline compile had. + GeneratedFiles map[string]string + Files map[string]BaselineFile +} + +// GeneratedDirectory names, relative to the project root, where a full compile saves the generator // output of an assembly. -func (s recordStore) generatedDirectory(assembly string) string { +func (s Store[D]) GeneratedDirectory(assembly string) string { return filepath.Join(s.outputDir, assembly+generatedDirectoryExtension) } -// loadBaseline reads the baseline of an assembly together with what the full compile it describes +// LoadBaseline reads the baseline of an assembly together with what the full compile it describes // reported, or says why there is none to use. -func (s recordStore) loadBaseline(assembly string) (storedBaseline, string) { +func (s Store[D]) LoadBaseline(assembly string) (Baseline[D], string) { content, err := os.ReadFile(baselineRecordPath(s.directoryPath(), assembly)) if err != nil { - return storedBaseline{}, s.missingBaselineReason(assembly) + return Baseline[D]{}, s.missingBaselineReason(assembly) } var record baselineRecord if err := json.Unmarshal(content, &record); err != nil || record.FormatVersion != baselineRecordFormatVersion { - return storedBaseline{}, "no baseline: it was recorded by another version of compile-check" + return Baseline[D]{}, "no baseline: it was recorded by another version of compile-check" } // In the normal flow the record and the manifest are written together under one key, and a full @@ -82,17 +91,17 @@ func (s recordStore) loadBaseline(assembly string) (storedBaseline, string) { const goneFromDisk = "baseline compile is no longer on disk" manifestContent, err := os.ReadFile(manifestPath(s.directoryPath(), assembly)) if err != nil { - return storedBaseline{}, goneFromDisk + return Baseline[D]{}, goneFromDisk } - var manifest unitResultManifest + var manifest unitResultManifest[D] if err := json.Unmarshal(manifestContent, &manifest); err != nil || manifest.InputKey != record.InputKey || !manifest.Succeeded || !outputsStillMatch(s.directoryPath(), manifest.Outputs) { - return storedBaseline{}, goneFromDisk + return Baseline[D]{}, goneFromDisk } - return storedBaseline{ + return Baseline[D]{ InputKey: record.InputKey, NonSourceKey: record.NonSourceKey, WorkerKey: record.WorkerKey, @@ -109,14 +118,14 @@ func (s recordStore) loadBaseline(assembly string) (storedBaseline, string) { // left, and a reason that read as "never compiled" sent users looking for a compile that had run. // Why a missing manifest says nothing about what happened: a run that skips or blocks the unit // removes the manifest and the baseline alike, so nothing on disk tells that apart from no compile. -func (s recordStore) missingBaselineReason(assembly string) string { +func (s Store[D]) missingBaselineReason(assembly string) string { const notOnRecord = "no baseline: no full compile of this assembly is on record" content, err := os.ReadFile(manifestPath(s.directoryPath(), assembly)) if err != nil { return notOnRecord } - var manifest unitResultManifest + var manifest unitResultManifest[D] if err := json.Unmarshal(content, &manifest); err != nil { return notOnRecord } @@ -127,16 +136,16 @@ func (s recordStore) missingBaselineReason(assembly string) string { return "no baseline: the last full compile did not record one" } -// generatorOutputUnchanged reports whether the generator output on disk is exactly what the -// baseline recorded. -func (s recordStore) generatorOutputUnchanged(baseline storedBaseline) bool { +// GeneratorOutputUnchanged reports whether the generator output on disk is exactly what the +// baseline recorded: no file missing, added, or changed. +func (s Store[D]) GeneratorOutputUnchanged(baseline Baseline[D]) bool { return generatedFilesMatch(projectPath(s.projectRoot, baseline.GeneratedDirectory), baseline.generatedFiles) } -// recordBaseline records what the next changed-files check of an assembly compares against, with +// RecordBaseline records what the next changed-files check of an assembly compares against, with // the generator output its full compile saved. -func (s recordStore) recordBaseline(assembly string, inputs baselineInputs) error { - generatedDirectory := s.generatedDirectory(assembly) +func (s Store[D]) RecordBaseline(assembly string, inputs BaselineInputs) error { + generatedDirectory := s.GeneratedDirectory(assembly) generatedFiles, err := hashGeneratedFiles(filepath.Join(s.projectRoot, generatedDirectory)) if err != nil { return err diff --git a/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go b/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go new file mode 100644 index 0000000000..ea841f0cd2 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go @@ -0,0 +1,213 @@ +package resultstore + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// projectStore is a store over an output directory inside a project root, laid out the way the +// check lays it out, with Alpha's outputs already written. +type projectStore struct { + root string + store Store[testDiagnostic] +} + +// newProjectStore lays out a project root with an output directory holding Alpha's outputs. +func newProjectStore(t *testing.T) projectStore { + t.Helper() + root := t.TempDir() + outputDir := filepath.Join("Library", "out") + makeDirAt(t, filepath.Join(root, outputDir)) + project := projectStore{root: root, store: Open[testDiagnostic](root, outputDir)} + writeOutputFile(t, project.outputPath(), "Alpha"+assemblyExtension, "assembly") + writeOutputFile(t, project.outputPath(), "Alpha"+referenceAssemblyExtension, "surface") + + return project +} + +// outputPath names the output directory on disk. +func (project projectStore) outputPath() string { + return filepath.Join(project.root, "Library", "out") +} + +// generatedPath names Alpha's generator output directory on disk. +func (project projectStore) generatedPath() string { + return filepath.Join(project.root, project.store.GeneratedDirectory("Alpha")) +} + +// recordCompile records a compile of Alpha under key, failing the test when it cannot. +func (project projectStore) recordCompile(t *testing.T, key string, succeeded bool) { + t.Helper() + result := CompileResult[testDiagnostic]{ + Succeeded: succeeded, + Diagnostics: []testDiagnostic{{Severity: "warning", Code: "CS0168", File: "Assets/A.cs", Line: 1, Column: 1}}, + } + if err := project.store.RecordCompile("Alpha", key, result, alphaOutputs()); err != nil { + t.Fatalf("failed to record the compile: %v", err) + } +} + +// recordBaseline records a baseline of Alpha under key, with one generated file on disk. +func (project projectStore) recordBaseline(t *testing.T, key string) BaselineInputs { + t.Helper() + writeFileAt(t, filepath.Join(project.generatedPath(), "Gen", "G.g.cs"), "class G { }") + inputs := BaselineInputs{ + InputKey: key, + NonSourceKey: KeyDigest{Key: "non-source", Parts: map[string]string{"compiler": "c"}}, + WorkerKey: "worker", + Files: map[string]BaselineFile{"Assets/A.cs": {ContentHash: "a", Fingerprint: "f"}}, + } + if err := project.store.RecordBaseline("Alpha", inputs); err != nil { + t.Fatalf("failed to record the baseline: %v", err) + } + + return inputs +} + +// Verifies a recorded baseline comes back with what it was recorded with, the diagnostics of the +// compile it describes, and its generator output named relative to the project root. +func TestLoadBaselineHandsBackWhatWasRecorded(t *testing.T) { + project := newProjectStore(t) + project.recordCompile(t, "input", true) + inputs := project.recordBaseline(t, "input") + + baseline, reason := project.store.LoadBaseline("Alpha") + + if reason != "" { + t.Fatalf("reason = %q, want a usable baseline", reason) + } + if baseline.InputKey != inputs.InputKey || baseline.WorkerKey != inputs.WorkerKey || + !reflect.DeepEqual(baseline.NonSourceKey, inputs.NonSourceKey) || !reflect.DeepEqual(baseline.Files, inputs.Files) { + t.Errorf("baseline = %+v, want what was recorded: %+v", baseline, inputs) + } + if baseline.GeneratedDirectory != "Library/out/Alpha.generated" { + t.Errorf("generated directory = %q", baseline.GeneratedDirectory) + } + if len(baseline.Diagnostics) != 1 || baseline.Diagnostics[0].Code != "CS0168" { + t.Errorf("diagnostics = %+v, want the recorded compile's", baseline.Diagnostics) + } + if !project.store.GeneratorOutputUnchanged(baseline) { + t.Error("expected the generator output to match right after recording") + } +} + +// Verifies the generator output stops matching its baseline when a generated file is changed, +// removed, or added. +func TestGeneratorOutputUnchangedNoticesEveryChange(t *testing.T) { + for name, damage := range map[string]func(t *testing.T, generatedPath string){ + "changed": func(t *testing.T, generatedPath string) { + writeFileAt(t, filepath.Join(generatedPath, "Gen", "G.g.cs"), "class G { int x; }") + }, + "removed": func(t *testing.T, generatedPath string) { + if err := os.RemoveAll(generatedPath); err != nil { + t.Fatalf("failed to remove the generator output: %v", err) + } + }, + "added": func(t *testing.T, generatedPath string) { + writeFileAt(t, filepath.Join(generatedPath, "Gen", "H.g.cs"), "class H { }") + }, + } { + t.Run(name, func(t *testing.T) { + project := newProjectStore(t) + project.recordCompile(t, "input", true) + project.recordBaseline(t, "input") + baseline, reason := project.store.LoadBaseline("Alpha") + if reason != "" { + t.Fatalf("reason = %q, want a usable baseline", reason) + } + + damage(t, project.generatedPath()) + + if project.store.GeneratorOutputUnchanged(baseline) { + t.Error("expected the damaged generator output not to match") + } + }) + } +} + +// Verifies a baseline is not handed out once the compile it describes is no longer what is on disk. +func TestLoadBaselineRefusesABaselineWhoseCompileIsGone(t *testing.T) { + for name, damage := range map[string]func(t *testing.T, project projectStore){ + "compile result removed": func(t *testing.T, project projectStore) { + if err := os.Remove(manifestPath(project.outputPath(), "Alpha")); err != nil { + t.Fatalf("failed to remove the compile result: %v", err) + } + }, + "compile result unreadable": func(t *testing.T, project projectStore) { + writeOutputFile(t, project.outputPath(), "Alpha"+resultManifestExtension, "{not json") + }, + "compile of another key": func(t *testing.T, project projectStore) { + project.recordCompile(t, "other", true) + }, + "failed compile": func(t *testing.T, project projectStore) { + project.recordCompile(t, "input", false) + }, + "output changed": func(t *testing.T, project projectStore) { + writeOutputFile(t, project.outputPath(), "Alpha"+assemblyExtension, "changed by hand") + }, + } { + t.Run(name, func(t *testing.T) { + project := newProjectStore(t) + project.recordCompile(t, "input", true) + project.recordBaseline(t, "input") + + damage(t, project) + + if _, reason := project.store.LoadBaseline("Alpha"); reason != "baseline compile is no longer on disk" { + t.Errorf("reason = %q", reason) + } + }) + } +} + +// Verifies a missing baseline is explained from the compile result recorded without it. +func TestLoadBaselineExplainsAMissingBaseline(t *testing.T) { + for name, testCase := range map[string]struct { + record func(t *testing.T, project projectStore) + want string + }{ + "no compile result": { + record: func(*testing.T, projectStore) {}, + want: "no baseline: no full compile of this assembly is on record", + }, + "unreadable compile result": { + record: func(t *testing.T, project projectStore) { + writeOutputFile(t, project.outputPath(), "Alpha"+resultManifestExtension, "{not json") + }, + want: "no baseline: no full compile of this assembly is on record", + }, + "failed compile": { + record: func(t *testing.T, project projectStore) { project.recordCompile(t, "input", false) }, + want: "no baseline: the last full compile had errors", + }, + "successful compile": { + record: func(t *testing.T, project projectStore) { project.recordCompile(t, "input", true) }, + want: "no baseline: the last full compile did not record one", + }, + } { + t.Run(name, func(t *testing.T) { + project := newProjectStore(t) + testCase.record(t, project) + + if _, reason := project.store.LoadBaseline("Alpha"); reason != testCase.want { + t.Errorf("reason = %q, want %q", reason, testCase.want) + } + }) + } +} + +// Verifies a baseline that cannot be written is reported with the baseline's path. +func TestRecordBaselineReportsAMissingOutputDirectory(t *testing.T) { + root := t.TempDir() + store := Open[testDiagnostic](root, "missing") + + err := store.RecordBaseline("Alpha", BaselineInputs{InputKey: "input"}) + + want := "failed to write " + baselineRecordPath(filepath.Join(root, "missing"), "Alpha") + ":" + if err == nil || !strings.HasPrefix(err.Error(), want) { + t.Fatalf("err = %v, want it to start with %q", err, want) + } +} diff --git a/cli/dispatcher/internal/compilecheck/record_store_baseline_test.go b/cli/dispatcher/internal/compilecheck/resultstore/baseline_test.go similarity index 84% rename from cli/dispatcher/internal/compilecheck/record_store_baseline_test.go rename to cli/dispatcher/internal/compilecheck/resultstore/baseline_test.go index 53309e8c7b..8557a2d412 100644 --- a/cli/dispatcher/internal/compilecheck/record_store_baseline_test.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/baseline_test.go @@ -1,4 +1,4 @@ -package compilecheck +package resultstore import ( "os" @@ -9,7 +9,7 @@ import ( // Verifies that only generated C# files are recorded, keyed relative to the generator output directory. func TestHashGeneratedFilesRecordsOnlyCSharpFiles(t *testing.T) { directory := t.TempDir() - if err := os.MkdirAll(filepath.Join(directory, "Gen"), outputDirPermissions); err != nil { + if err := os.MkdirAll(filepath.Join(directory, "Gen"), testDirectoryPermissions); err != nil { t.Fatalf("failed to create the generator folder: %v", err) } writeOutputFile(t, filepath.Join(directory, "Gen"), "A.g.cs", "class A { }") @@ -30,7 +30,7 @@ func TestHashGeneratedFilesRecordsOnlyCSharpFiles(t *testing.T) { func TestLoadBaselineReportsABaselineOfAnotherFormat(t *testing.T) { directoryPath := newRecordedOutputs(t) store := directoryStore(directoryPath) - if err := store.recordCompile("Alpha", "input", recordedCompile{Succeeded: true}, alphaOutputs()); err != nil { + if err := store.RecordCompile("Alpha", "input", CompileResult[testDiagnostic]{Succeeded: true}, alphaOutputs()); err != nil { t.Fatalf("failed to write the manifest: %v", err) } if err := writeBaselineRecord(directoryPath, "Alpha", baselineRecord{ @@ -40,7 +40,7 @@ func TestLoadBaselineReportsABaselineOfAnotherFormat(t *testing.T) { t.Fatalf("failed to write the baseline: %v", err) } - _, reason := store.loadBaseline("Alpha") + _, reason := store.LoadBaseline("Alpha") if reason != "no baseline: it was recorded by another version of compile-check" { t.Errorf("reason = %q", reason) diff --git a/cli/dispatcher/internal/compilecheck/resultstore/compile_result.go b/cli/dispatcher/internal/compilecheck/resultstore/compile_result.go new file mode 100644 index 0000000000..6387507efd --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/resultstore/compile_result.go @@ -0,0 +1,133 @@ +package resultstore + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const ( + // resultManifestFormatVersion guards against reading a manifest an older or newer build wrote: + // what belongs in the key is part of this check's code, so a manifest from another version says + // nothing about whether this one would compile the same thing. + resultManifestFormatVersion = 1 + + resultManifestExtension = ".result.json" +) + +// CompileResult is what one compile of an assembly reported. +type CompileResult[D any] struct { + Succeeded bool + Diagnostics []D +} + +// unitResultManifest is what one finished compile left behind for the next run: the inputs it ran +// on, what it reported, and the outputs it wrote. +type unitResultManifest[D any] struct { + FormatVersion int + InputKey string + Succeeded bool + Diagnostics []D + // Outputs maps the name of each file the compile wrote in the check's output directory to its + // content hash, so a run that finds them changed underneath compiles again. + Outputs map[string]string +} + +// manifestPath names where one assembly's manifest lives. +func manifestPath(directoryPath string, assembly string) string { + return filepath.Join(directoryPath, assembly+resultManifestExtension) +} + +// Reusable reports what a previous compile of the assembly found, when that compile ran on the +// inputs key describes and its outputs are still on disk exactly as it wrote them. +// Why anything unexpected simply means no: the caller compiles the unit, which is what it would +// have done anyway. Nothing here is worth failing a run over. +func (s Store[D]) Reusable(assembly string, key string) (CompileResult[D], bool) { + content, err := os.ReadFile(manifestPath(s.directoryPath(), assembly)) + if err != nil { + return CompileResult[D]{}, false + } + + var manifest unitResultManifest[D] + if err := json.Unmarshal(content, &manifest); err != nil { + return CompileResult[D]{}, false + } + if manifest.FormatVersion != resultManifestFormatVersion || manifest.InputKey != key || key == "" { + return CompileResult[D]{}, false + } + if !outputsStillMatch(s.directoryPath(), manifest.Outputs) { + return CompileResult[D]{}, false + } + + // Why the recorded outcome is replayed rather than assumed successful: a compile that reported + // errors reports them again until one of its inputs moves, and swallowing them here would turn a + // broken project into a clean run. + return CompileResult[D]{Succeeded: manifest.Succeeded, Diagnostics: manifest.Diagnostics}, true +} + +// outputsStillMatch reports whether every file a compile wrote is still there and still holds what +// it wrote. +func outputsStillMatch(directoryPath string, outputs map[string]string) bool { + for name, recordedHash := range outputs { + currentHash, err := hashFile(filepath.Join(directoryPath, name)) + if err != nil || currentHash != recordedHash { + return false + } + } + + return true +} + +// RecordCompile records what a compile of the assembly ran on (key) and what it reported, together +// with the outputs it wrote, named in the output directory. It records nothing when that compile +// claimed success without producing every one of those outputs. +// Why it is written through a temporary file: a run interrupted while writing would otherwise leave +// half a manifest behind, and the next run would read it as a description of outputs it never saw. +func (s Store[D]) RecordCompile(assembly string, key string, result CompileResult[D], outputs []string) error { + hashes, complete, err := hashOutputs(s.directoryPath(), outputs, result.Succeeded) + if err != nil { + return err + } + if !complete { + return nil + } + + content, err := json.Marshal(unitResultManifest[D]{ + FormatVersion: resultManifestFormatVersion, + InputKey: key, + Succeeded: result.Succeeded, + Diagnostics: result.Diagnostics, + Outputs: hashes, + }) + if err != nil { + return fmt.Errorf("failed to record the result of %s: %w", assembly, err) + } + + return writeFileAtomically(s.directoryPath(), manifestPath(s.directoryPath(), assembly), content) +} + +// hashOutputs hashes the named files a compile wrote in directoryPath. +// Why a missing output is tolerated only for a failed compile: that is the compile which writes +// neither the assembly nor its reference assembly. A successful compile that left one of them out +// is not a compile whose result describes anything - recording it would replay a success backed by +// files no run ever produced, and the outputs check would pass because it has nothing to check. +func hashOutputs(directoryPath string, names []string, succeeded bool) (map[string]string, bool, error) { + outputs := map[string]string{} + for _, name := range names { + hashed, err := hashFile(filepath.Join(directoryPath, name)) + if os.IsNotExist(err) { + if succeeded { + return nil, false, nil + } + + continue + } + if err != nil { + return nil, false, err + } + outputs[name] = hashed + } + + return outputs, true, nil +} diff --git a/cli/dispatcher/internal/compilecheck/record_store_test.go b/cli/dispatcher/internal/compilecheck/resultstore/compile_result_test.go similarity index 87% rename from cli/dispatcher/internal/compilecheck/record_store_test.go rename to cli/dispatcher/internal/compilecheck/resultstore/compile_result_test.go index 5601ad07b4..659f959b4c 100644 --- a/cli/dispatcher/internal/compilecheck/record_store_test.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/compile_result_test.go @@ -1,4 +1,4 @@ -package compilecheck +package resultstore import ( "encoding/json" @@ -24,16 +24,16 @@ func newRecordedOutputs(t *testing.T) string { } // directoryStore opens the records kept directly in directoryPath. -func directoryStore(directoryPath string) recordStore { - return openRecordStore(directoryPath, "") +func directoryStore(directoryPath string) Store[testDiagnostic] { + return Open[testDiagnostic](directoryPath, "") } // storedResult is the result the tests record and expect to read back unchanged. -func storedResult() recordedCompile { - return recordedCompile{ +func storedResult() CompileResult[testDiagnostic] { + return CompileResult[testDiagnostic]{ Succeeded: false, - Diagnostics: []Diagnostic{{ - Severity: severityError, + Diagnostics: []testDiagnostic{{ + Severity: "error", Code: "CS0103", Message: "The name 'Missing' does not exist in the current context", File: filepath.Join("Assets", "Alpha", "Alpha.cs"), @@ -46,7 +46,7 @@ func storedResult() recordedCompile { // recordResult stores one compile of Alpha's outcome under a key. func recordResult(t *testing.T, directoryPath string, key string) { t.Helper() - if err := directoryStore(directoryPath).recordCompile("Alpha", key, storedResult(), alphaOutputs()); err != nil { + if err := directoryStore(directoryPath).RecordCompile("Alpha", key, storedResult(), alphaOutputs()); err != nil { t.Fatalf("failed to write the manifest: %v", err) } } @@ -64,7 +64,7 @@ func TestReusableResultReplaysTheRecordedDiagnostics(t *testing.T) { directoryPath := newRecordedOutputs(t) recordResult(t, directoryPath, "key") - result, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key") + result, reusable := directoryStore(directoryPath).Reusable("Alpha", "key") if !reusable { t.Fatal("expected the recorded result to be reusable") @@ -82,7 +82,7 @@ func TestReusableResultRejectsAnotherKey(t *testing.T) { directoryPath := newRecordedOutputs(t) recordResult(t, directoryPath, "key") - if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "other key"); reusable { + if _, reusable := directoryStore(directoryPath).Reusable("Alpha", "other key"); reusable { t.Error("a result recorded for other inputs should not be reused") } } @@ -95,7 +95,7 @@ func TestReusableResultRejectsAChangedOutput(t *testing.T) { writeOutputFile(t, directoryPath, "Alpha"+referenceAssemblyExtension, "another surface") - if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key"); reusable { + if _, reusable := directoryStore(directoryPath).Reusable("Alpha", "key"); reusable { t.Error("a changed output should stop the reuse") } } @@ -109,7 +109,7 @@ func TestReusableResultRejectsAMissingOutput(t *testing.T) { t.Fatalf("failed to remove the output: %v", err) } - if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key"); reusable { + if _, reusable := directoryStore(directoryPath).Reusable("Alpha", "key"); reusable { t.Error("a missing output should stop the reuse") } } @@ -120,7 +120,7 @@ func TestReusableResultRejectsAnUnreadableManifest(t *testing.T) { directoryPath := newRecordedOutputs(t) writeOutputFile(t, directoryPath, "Alpha.result.json", "{not json") - if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key"); reusable { + if _, reusable := directoryStore(directoryPath).Reusable("Alpha", "key"); reusable { t.Error("an unreadable manifest should not be reused") } } @@ -134,7 +134,7 @@ func TestReusableResultRejectsAnotherFormatVersion(t *testing.T) { if err != nil { t.Fatalf("failed to read the manifest: %v", err) } - var manifest unitResultManifest + var manifest unitResultManifest[testDiagnostic] if err := json.Unmarshal(content, &manifest); err != nil { t.Fatalf("failed to parse the manifest: %v", err) } @@ -147,7 +147,7 @@ func TestReusableResultRejectsAnotherFormatVersion(t *testing.T) { t.Fatalf("failed to write the manifest back: %v", err) } - if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key"); reusable { + if _, reusable := directoryStore(directoryPath).Reusable("Alpha", "key"); reusable { t.Error("a manifest of another format version should not be reused") } } @@ -163,7 +163,7 @@ func TestRecordCompileRecordsACompileThatWroteNothing(t *testing.T) { } recordResult(t, directoryPath, "key") - result, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key") + result, reusable := directoryStore(directoryPath).Reusable("Alpha", "key") if !reusable { t.Fatal("expected a compile that wrote nothing to be reusable") @@ -179,7 +179,7 @@ func TestRecordCompileReportsAFailedRename(t *testing.T) { directoryPath := newRecordedOutputs(t) blockManifestPath(t, manifestPath(directoryPath, "Alpha")) - err := directoryStore(directoryPath).recordCompile("Alpha", "key", storedResult(), alphaOutputs()) + err := directoryStore(directoryPath).RecordCompile("Alpha", "key", storedResult(), alphaOutputs()) if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(directoryPath, "Alpha")+":") { t.Fatalf("err = %v", err) @@ -195,7 +195,7 @@ func TestRecordCompileReportsAFailedRename(t *testing.T) { func TestRecordCompileReportsAMissingOutputDirectory(t *testing.T) { directoryPath := filepath.Join(t.TempDir(), "missing") - err := directoryStore(directoryPath).recordCompile("Alpha", "key", storedResult(), alphaOutputs()) + err := directoryStore(directoryPath).RecordCompile("Alpha", "key", storedResult(), alphaOutputs()) if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(directoryPath, "Alpha")+":") { t.Fatalf("err = %v", err) @@ -236,12 +236,12 @@ func TestForgetRemovesTheManifestTheBaselineAndTheGeneratorOutput(t *testing.T) recordResult(t, directoryPath, "key") writeOutputFile(t, directoryPath, "Alpha"+baselineRecordExtension, "{}") generatedPath := filepath.Join(directoryPath, "Alpha"+generatedDirectoryExtension, "Nested") - if err := os.MkdirAll(generatedPath, outputDirPermissions); err != nil { + if err := os.MkdirAll(generatedPath, testDirectoryPermissions); err != nil { t.Fatalf("failed to create the generated directory: %v", err) } writeOutputFile(t, generatedPath, "G.cs", "class G {}") - if err := directoryStore(directoryPath).forget("Alpha"); err != nil { + if err := directoryStore(directoryPath).Forget("Alpha"); err != nil { t.Fatalf("expected the records to be removed, got error: %v", err) } diff --git a/cli/dispatcher/internal/compilecheck/resultstore/fixture_test.go b/cli/dispatcher/internal/compilecheck/resultstore/fixture_test.go new file mode 100644 index 0000000000..8c3c842c85 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/resultstore/fixture_test.go @@ -0,0 +1,52 @@ +package resultstore + +import ( + "os" + "path/filepath" + "testing" +) + +const ( + assemblyExtension = ".dll" + referenceAssemblyExtension = ".ref.dll" + testDirectoryPermissions = 0o755 +) + +// testDiagnostic stands in for the caller's diagnostic type. Its fields are in the same order as the +// caller's, so a recorded compile result keeps the bytes the caller's type gives it. +type testDiagnostic struct { + Severity string + Code string + Message string + File string + Line int + Column int +} + +// writeOutputFile writes one file into the check's own output directory. +func writeOutputFile(t *testing.T, outputDirectoryPath string, name string, content string) { + t.Helper() + if err := os.WriteFile( + filepath.Join(outputDirectoryPath, name), []byte(content), 0o600); err != nil { + t.Fatalf("failed to write %s: %v", name, err) + } +} + +// writeFileAt creates a file and every parent directory it needs. +func writeFileAt(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), testDirectoryPermissions); err != nil { + t.Fatalf("failed to create directory for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", path, err) + } +} + +// makeDirAt creates a directory and every parent directory it needs. +func makeDirAt(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, testDirectoryPermissions); err != nil { + t.Fatalf("failed to create directory %s: %v", path, err) + } +} diff --git a/cli/dispatcher/internal/compilecheck/record_format_test.go b/cli/dispatcher/internal/compilecheck/resultstore/record_format_test.go similarity index 90% rename from cli/dispatcher/internal/compilecheck/record_format_test.go rename to cli/dispatcher/internal/compilecheck/resultstore/record_format_test.go index cb09bda6d4..5bd69cb30d 100644 --- a/cli/dispatcher/internal/compilecheck/record_format_test.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/record_format_test.go @@ -1,4 +1,4 @@ -package compilecheck +package resultstore import ( "os" @@ -16,7 +16,7 @@ func TestLoadBaselineReportsAnotherFormatBeforeAMissingManifest(t *testing.T) { t.Fatalf("failed to write the baseline: %v", err) } - _, reason := directoryStore(directoryPath).loadBaseline("Alpha") + _, reason := directoryStore(directoryPath).LoadBaseline("Alpha") if reason != "no baseline: it was recorded by another version of compile-check" { t.Errorf("reason = %q", reason) @@ -26,10 +26,10 @@ func TestLoadBaselineReportsAnotherFormatBeforeAMissingManifest(t *testing.T) { // Verifies the bytes a compile result is recorded as, so moving the record keeps its format. func TestWriteUnitResultManifestWritesTheRecordedFormat(t *testing.T) { directoryPath := newRecordedOutputs(t) - result := recordedCompile{ + result := CompileResult[testDiagnostic]{ Succeeded: false, - Diagnostics: []Diagnostic{{ - Severity: severityError, + Diagnostics: []testDiagnostic{{ + Severity: "error", Code: "CS0103", Message: "The name 'Missing' does not exist in the current context", File: "Assets/Alpha/Alpha.cs", @@ -38,7 +38,7 @@ func TestWriteUnitResultManifestWritesTheRecordedFormat(t *testing.T) { }}, } - if err := directoryStore(directoryPath).recordCompile("Alpha", "key", result, alphaOutputs()); err != nil { + if err := directoryStore(directoryPath).RecordCompile("Alpha", "key", result, alphaOutputs()); err != nil { t.Fatalf("failed to write the manifest: %v", err) } @@ -55,14 +55,14 @@ func TestWriteBaselineRecordWritesTheRecordedFormat(t *testing.T) { record := baselineRecord{ FormatVersion: baselineRecordFormatVersion, InputKey: "input", - NonSourceKey: nonSourceIdentity{ + NonSourceKey: KeyDigest{ Key: "non-source", Parts: map[string]string{"compiler": "c", "source list": "s"}, }, WorkerKey: "worker", GeneratedDirectory: "Library/out/Alpha.generated", GeneratedFiles: map[string]string{"Gen/G.g.cs": "g"}, - Files: map[string]baselineFile{"Assets/A.cs": {ContentHash: "a", Fingerprint: "f"}}, + Files: map[string]BaselineFile{"Assets/A.cs": {ContentHash: "a", Fingerprint: "f"}}, } if err := writeBaselineRecord(directoryPath, "Alpha", record); err != nil { diff --git a/cli/dispatcher/internal/compilecheck/resultstore/store.go b/cli/dispatcher/internal/compilecheck/resultstore/store.go new file mode 100644 index 0000000000..929d4dc229 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/resultstore/store.go @@ -0,0 +1,134 @@ +// Package resultstore keeps what earlier runs of compile-check recorded for each assembly in one +// output directory, and is the only code that reads or writes those records. +// +// A store keeps three records per assembly: +// - the result of its last compile: the inputs it ran on (as a key), what it reported, and a hash +// of every output it wrote; +// - the baseline of the changed-files check: what the last successful full compile saw of each +// source, its non-source inputs and the worker build that fingerprinted it; +// - the generator output that full compile saved. +// +// Contract: +// - Records are written through a temporary file and renamed into place, so a run stopped while +// writing never leaves half a record behind. +// - A compile result is reusable only under the key it was recorded with, and only while every +// output it hashed still holds those bytes. +// - A baseline is handed out only when the compile result recorded with it is still on disk: the +// same key, a successful compile, and outputs that still match. Otherwise LoadBaseline says why +// there is none, in one of these reasons: +// "no baseline: no full compile of this assembly is on record", +// "no baseline: the last full compile had errors", +// "no baseline: the last full compile did not record one", +// "no baseline: it was recorded by another version of compile-check", +// "baseline compile is no longer on disk". +// - Forget removes every kind of record of an assembly. It does not touch the assembly's outputs, +// and the store never creates the generator output directory; it only names where it is. +package resultstore + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" +) + +const resultManifestTempSuffix = ".tmp" + +// Store reads and writes the records kept in one output directory. D is the type of a diagnostic a +// compile reported; the store keeps diagnostics as they were given and never reads them. +type Store[D any] struct { + projectRoot string + // outputDir is relative to the project root, which is how the records name the generator output. + outputDir string +} + +// Open opens the records kept in outputDir, a directory relative to projectRoot. +func Open[D any](projectRoot string, outputDir string) Store[D] { + return Store[D]{projectRoot: projectRoot, outputDir: outputDir} +} + +// directoryPath names the output directory the records live in. +func (s Store[D]) directoryPath() string { + return filepath.Join(s.projectRoot, s.outputDir) +} + +// Forget removes every record of the assembly: its compile result, its baseline, and its saved +// generator output. +func (s Store[D]) Forget(assembly string) error { + names := []string{ + // Why the compile result goes with the outputs: it describes those very files, so leaving it + // behind would let a later run replay diagnostics for outputs this one removed. + assembly + resultManifestExtension, + // The baseline and the generator output describe the same compile as the compile result. + assembly + baselineRecordExtension, + } + for _, name := range names { + path := filepath.Join(s.directoryPath(), name) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove %s: %w", path, err) + } + } + + generatedPath := filepath.Join(s.directoryPath(), assembly+generatedDirectoryExtension) + if err := os.RemoveAll(generatedPath); err != nil { + return fmt.Errorf("failed to remove %s: %w", generatedPath, err) + } + + return nil +} + +// writeFileAtomically writes content to path through a temporary file in directoryPath. +func writeFileAtomically(directoryPath string, path string, content []byte) error { + // Why the temporary name is unique per writer: two runs of the same project writing the same + // file at once must not rename each other's half-written file into place. + temporary, err := os.CreateTemp(directoryPath, filepath.Base(path)+".*"+resultManifestTempSuffix) + if err != nil { + return fmt.Errorf("failed to write %s: %w", path, err) + } + temporaryPath := temporary.Name() + if _, err := temporary.Write(content); err != nil { + _ = temporary.Close() + _ = os.Remove(temporaryPath) + + return fmt.Errorf("failed to write %s: %w", temporaryPath, err) + } + if err := temporary.Close(); err != nil { + _ = os.Remove(temporaryPath) + + return fmt.Errorf("failed to write %s: %w", temporaryPath, err) + } + if err := os.Rename(temporaryPath, path); err != nil { + _ = os.Remove(temporaryPath) + + return fmt.Errorf("failed to write %s: %w", path, err) + } + + return nil +} + +// hashFile reports what a file holds, as a hash. +func hashFile(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer func() { _ = file.Close() }() + + digest := sha256.New() + if _, err := io.Copy(digest, file); err != nil { + return "", fmt.Errorf("failed to read %s: %w", path, err) + } + + return hex.EncodeToString(digest.Sum(nil)), nil +} + +// projectPath resolves a path relative to the project root, leaving an absolute one as it is. +func projectPath(projectRoot string, path string) string { + if filepath.IsAbs(path) { + return path + } + + return filepath.Join(projectRoot, path) +} diff --git a/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go b/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go index 8eb8c39f38..27661be284 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" "time" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/resultstore" ) // cachedSchedulerProject is a project laid out on disk in enough detail for the input key to read @@ -118,8 +120,8 @@ func writeOutputFile(t *testing.T, outputDirectoryPath string, name string, cont } // records opens the store the runs of this test project write to. -func (project *cachedSchedulerProject) records() recordStore { - return openRecordStore(project.root, project.plan.OutputDir) +func (project *cachedSchedulerProject) records() resultstore.Store[Diagnostic] { + return resultstore.Open[Diagnostic](project.root, project.plan.OutputDir) } // hasBaselineOf reports whether a baseline of the current format is on disk for the assembly. @@ -127,7 +129,7 @@ func (project *cachedSchedulerProject) records() recordStore { // was written by another version; a baseline file that disagrees with its compile result is // reported as "baseline compile is no longer on disk" instead. func (project *cachedSchedulerProject) hasBaselineOf(name string) bool { - _, reason := project.records().loadBaseline(name) + _, reason := project.records().LoadBaseline(name) return !strings.HasPrefix(reason, "no baseline:") } @@ -137,7 +139,7 @@ func (project *cachedSchedulerProject) hasBaselineOf(name string) bool { // needs, so they never get a baseline, and the store explains the missing baseline from the // compile result alone - this reason is the one it gives when there is no compile result. func (project *cachedSchedulerProject) hasRecordedCompile(name string) bool { - _, reason := project.records().loadBaseline(name) + _, reason := project.records().LoadBaseline(name) return reason != "no baseline: no full compile of this assembly is on record" } From 626c5d80119d713657bf2591a26b17f5370f9cbc Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 13:05:36 +0900 Subject: [PATCH 13/33] feat: compile-check's record store can keep the last changed-files check (#3328) --- .../compilecheck/resultstore/fast_check.go | 121 +++++++++ .../resultstore/fast_check_test.go | 255 ++++++++++++++++++ .../compilecheck/resultstore/store.go | 18 +- 3 files changed, 390 insertions(+), 4 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/resultstore/fast_check.go create mode 100644 cli/dispatcher/internal/compilecheck/resultstore/fast_check_test.go diff --git a/cli/dispatcher/internal/compilecheck/resultstore/fast_check.go b/cli/dispatcher/internal/compilecheck/resultstore/fast_check.go new file mode 100644 index 0000000000..e31bed27b2 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/resultstore/fast_check.go @@ -0,0 +1,121 @@ +package resultstore + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const ( + // fastResultFormatVersion guards against replaying a check an older or newer build recorded: what + // the record holds is part of this check's code. + fastResultFormatVersion = 1 + + fastResultExtension = ".fastresult.json" +) + +// FastCheckResult is what one check of an assembly's changed files reported. +type FastCheckResult[D any] struct { + Succeeded bool + Diagnostics []D + ChangedFiles []string + UncheckedCodes []string + GeneratorsNotRerun bool +} + +// fastCheckRecord is what one check of an assembly's changed files left behind for the next run: the +// inputs, the baseline and the worker build it checked with, and what it reported. +type fastCheckRecord[D any] struct { + FormatVersion int + InputKey string + BaselineInputKey string + WorkerKey string + Succeeded bool + Diagnostics []D + ChangedFiles []string + UncheckedCodes []string + GeneratorsNotRerun bool +} + +// fastResultPath names where one assembly's recorded check lives. +func fastResultPath(directoryPath string, assembly string) string { + return filepath.Join(directoryPath, assembly+fastResultExtension) +} + +// ReusableFastCheck reports the recorded check of the assembly when it answered for these inputs +// (key), against this baseline, with this worker build. Anything unexpected means no. +// Why anything unexpected simply means no: the caller checks the changed files again, which is what +// it would have done anyway. +func (s Store[D]) ReusableFastCheck( + assembly string, key string, baseline Baseline[D], workerKey string, +) (FastCheckResult[D], bool) { + if key == "" { + return FastCheckResult[D]{}, false + } + content, err := os.ReadFile(fastResultPath(s.directoryPath(), assembly)) + if err != nil { + return FastCheckResult[D]{}, false + } + + var record fastCheckRecord[D] + if err := json.Unmarshal(content, &record); err != nil { + return FastCheckResult[D]{}, false + } + if record.FormatVersion != fastResultFormatVersion || record.InputKey != key || + record.BaselineInputKey != baseline.InputKey || record.WorkerKey != workerKey { + return FastCheckResult[D]{}, false + } + + return FastCheckResult[D]{ + Succeeded: record.Succeeded, + Diagnostics: record.Diagnostics, + ChangedFiles: record.ChangedFiles, + UncheckedCodes: record.UncheckedCodes, + GeneratorsNotRerun: record.GeneratorsNotRerun, + }, true +} + +// RecordFastCheck records what a check of the assembly's changed files reported for these inputs, +// against this baseline, with this worker build, through a temporary file. +// Why a missing key is refused rather than recorded: a record under an empty key would later match +// another empty key, and replay a check of inputs nobody can name. +func (s Store[D]) RecordFastCheck( + assembly string, key string, baseline Baseline[D], workerKey string, result FastCheckResult[D], +) error { + if missing := missingFastCheckKey(key, baseline.InputKey, workerKey); missing != "" { + return fmt.Errorf("refusing to record a changed-files check of %s without %s", assembly, missing) + } + + content, err := json.Marshal(fastCheckRecord[D]{ + FormatVersion: fastResultFormatVersion, + InputKey: key, + BaselineInputKey: baseline.InputKey, + WorkerKey: workerKey, + Succeeded: result.Succeeded, + Diagnostics: result.Diagnostics, + ChangedFiles: result.ChangedFiles, + UncheckedCodes: result.UncheckedCodes, + GeneratorsNotRerun: result.GeneratorsNotRerun, + }) + if err != nil { + return fmt.Errorf("failed to record the changed-files check of %s: %w", assembly, err) + } + + return writeFileAtomically(s.directoryPath(), fastResultPath(s.directoryPath(), assembly), content) +} + +// missingFastCheckKey names the first of the keys a check is looked up by that is empty, or returns +// "" when all three are there. +func missingFastCheckKey(key string, baselineInputKey string, workerKey string) string { + switch { + case key == "": + return "an input key" + case baselineInputKey == "": + return "a baseline" + case workerKey == "": + return "a worker build" + default: + return "" + } +} diff --git a/cli/dispatcher/internal/compilecheck/resultstore/fast_check_test.go b/cli/dispatcher/internal/compilecheck/resultstore/fast_check_test.go new file mode 100644 index 0000000000..08855665ac --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/resultstore/fast_check_test.go @@ -0,0 +1,255 @@ +package resultstore + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// checkedBaseline is the baseline the tests' checks ran against. +func checkedBaseline() Baseline[testDiagnostic] { + return Baseline[testDiagnostic]{InputKey: "baseline"} +} + +// checkedResult is a check the tests record, with every field set so a field the store drops shows. +func checkedResult() FastCheckResult[testDiagnostic] { + return FastCheckResult[testDiagnostic]{ + Succeeded: true, + Diagnostics: []testDiagnostic{{ + Severity: "warning", Code: "CS0168", Message: "unused", File: "Assets/Alpha/Alpha.cs", Line: 3, Column: 9, + }}, + ChangedFiles: []string{"Assets/Alpha/Alpha.cs"}, + UncheckedCodes: []string{"CS0169"}, + GeneratorsNotRerun: true, + } +} + +// recordCheck records checkedResult for Alpha under the input key "input", the checked baseline and +// the worker build "worker". +func recordCheck(t *testing.T, directoryPath string) { + t.Helper() + err := directoryStore(directoryPath).RecordFastCheck("Alpha", "input", checkedBaseline(), "worker", checkedResult()) + if err != nil { + t.Fatalf("failed to record the check: %v", err) + } +} + +// rewriteCheckRecord reads Alpha's recorded check, lets change alter it, and writes it back. +func rewriteCheckRecord(t *testing.T, directoryPath string, change func(record *fastCheckRecord[testDiagnostic])) { + t.Helper() + path := fastResultPath(directoryPath, "Alpha") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read the recorded check: %v", err) + } + var record fastCheckRecord[testDiagnostic] + if err := json.Unmarshal(content, &record); err != nil { + t.Fatalf("failed to parse the recorded check: %v", err) + } + change(&record) + rewritten, err := json.Marshal(record) + if err != nil { + t.Fatalf("failed to encode the recorded check: %v", err) + } + if err := os.WriteFile(path, rewritten, 0o600); err != nil { + t.Fatalf("failed to write the recorded check back: %v", err) + } +} + +// Verifies a recorded check comes back exactly as it was recorded. +func TestReusableFastCheckReturnsTheRecordedCheck(t *testing.T) { + directoryPath := t.TempDir() + recordCheck(t, directoryPath) + + result, found := directoryStore(directoryPath).ReusableFastCheck("Alpha", "input", checkedBaseline(), "worker") + + if !found { + t.Fatal("expected the recorded check to be found") + } + if !reflect.DeepEqual(result, checkedResult()) { + t.Errorf("result = %+v, want %+v", result, checkedResult()) + } +} + +// Verifies a check recorded for other inputs is not handed out. +func TestReusableFastCheckRejectsAnotherInputKey(t *testing.T) { + directoryPath := t.TempDir() + recordCheck(t, directoryPath) + + if _, found := directoryStore(directoryPath).ReusableFastCheck("Alpha", "other", checkedBaseline(), "worker"); found { + t.Error("a check recorded for other inputs should not be found") + } +} + +// Verifies a check recorded against another baseline is not handed out. +func TestReusableFastCheckRejectsAnotherBaseline(t *testing.T) { + directoryPath := t.TempDir() + recordCheck(t, directoryPath) + baseline := Baseline[testDiagnostic]{InputKey: "other baseline"} + + if _, found := directoryStore(directoryPath).ReusableFastCheck("Alpha", "input", baseline, "worker"); found { + t.Error("a check recorded against another baseline should not be found") + } +} + +// Verifies a check recorded by another worker build is not handed out. +func TestReusableFastCheckRejectsAnotherWorkerBuild(t *testing.T) { + directoryPath := t.TempDir() + recordCheck(t, directoryPath) + + if _, found := directoryStore(directoryPath).ReusableFastCheck("Alpha", "input", checkedBaseline(), "other"); found { + t.Error("a check recorded by another worker build should not be found") + } +} + +// Verifies a check recorded by another version of compile-check is not handed out. +func TestReusableFastCheckRejectsAnotherFormatVersion(t *testing.T) { + directoryPath := t.TempDir() + recordCheck(t, directoryPath) + rewriteCheckRecord(t, directoryPath, func(record *fastCheckRecord[testDiagnostic]) { + record.FormatVersion = fastResultFormatVersion - 1 + }) + + if _, found := directoryStore(directoryPath).ReusableFastCheck("Alpha", "input", checkedBaseline(), "worker"); found { + t.Error("a check of another format version should not be found") + } +} + +// Verifies a record that cannot be read is treated as no record: one that is not JSON, and one whose +// version and keys match but one field holds a value of the wrong type, which decoding would +// otherwise fill in only partly. +func TestReusableFastCheckRejectsAnUnreadableRecord(t *testing.T) { + for name, write := range map[string]func(t *testing.T, directoryPath string){ + "not json": func(t *testing.T, directoryPath string) { + writeOutputFile(t, directoryPath, "Alpha"+fastResultExtension, "{not json") + }, + "wrong field type": func(t *testing.T, directoryPath string) { + recordCheck(t, directoryPath) + path := fastResultPath(directoryPath, "Alpha") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read the recorded check: %v", err) + } + rewritten := strings.Replace(string(content), `"Line":3`, `"Line":"three"`, 1) + if err := os.WriteFile(path, []byte(rewritten), 0o600); err != nil { + t.Fatalf("failed to write the recorded check back: %v", err) + } + }, + } { + t.Run(name, func(t *testing.T) { + directoryPath := t.TempDir() + write(t, directoryPath) + + if _, found := directoryStore(directoryPath).ReusableFastCheck("Alpha", "input", checkedBaseline(), "worker"); found { + t.Error("an unreadable record should not be found") + } + }) + } +} + +// Verifies an empty input key never matches, even a record that holds an empty input key. +func TestReusableFastCheckRejectsAnEmptyInputKey(t *testing.T) { + directoryPath := t.TempDir() + recordCheck(t, directoryPath) + rewriteCheckRecord(t, directoryPath, func(record *fastCheckRecord[testDiagnostic]) { + record.InputKey = "" + }) + + if _, found := directoryStore(directoryPath).ReusableFastCheck("Alpha", "", checkedBaseline(), "worker"); found { + t.Error("an empty input key should not find a record") + } +} + +// Verifies a check is not recorded without each of the three keys it is looked up by, and nothing is +// written in that case. +func TestRecordFastCheckRefusesAMissingKey(t *testing.T) { + for name, testCase := range map[string]struct { + key string + baseline Baseline[testDiagnostic] + workerKey string + want string + }{ + "input key": {key: "", baseline: checkedBaseline(), workerKey: "worker", want: "an input key"}, + "baseline": {key: "input", baseline: Baseline[testDiagnostic]{}, workerKey: "worker", want: "a baseline"}, + "worker": {key: "input", baseline: checkedBaseline(), workerKey: "", want: "a worker build"}, + } { + t.Run(name, func(t *testing.T) { + directoryPath := t.TempDir() + + err := directoryStore(directoryPath).RecordFastCheck( + "Alpha", testCase.key, testCase.baseline, testCase.workerKey, checkedResult()) + + want := "refusing to record a changed-files check of Alpha without " + testCase.want + if err == nil || err.Error() != want { + t.Errorf("err = %v, want %q", err, want) + } + if _, statErr := os.Stat(fastResultPath(directoryPath, "Alpha")); !os.IsNotExist(statErr) { + t.Errorf("expected no record to be written, got %v", statErr) + } + }) + } +} + +// Verifies a check that cannot be renamed into place is reported as a write failure and leaves no +// temporary file behind. +func TestRecordFastCheckReportsAFailedRename(t *testing.T) { + directoryPath := t.TempDir() + blockManifestPath(t, fastResultPath(directoryPath, "Alpha")) + + err := directoryStore(directoryPath).RecordFastCheck("Alpha", "input", checkedBaseline(), "worker", checkedResult()) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+fastResultPath(directoryPath, "Alpha")+":") { + t.Fatalf("err = %v", err) + } + matches, globErr := filepath.Glob(filepath.Join(directoryPath, "*"+resultManifestTempSuffix)) + if globErr != nil || len(matches) != 0 { + t.Fatalf("temporary records left behind: %v (glob error %v)", matches, globErr) + } +} + +// Verifies forgetting an assembly removes its recorded check too, so no check outlives the compile +// it was checked against. +func TestForgetRemovesTheRecordedCheck(t *testing.T) { + directoryPath := t.TempDir() + recordCheck(t, directoryPath) + + if err := directoryStore(directoryPath).Forget("Alpha"); err != nil { + t.Fatalf("expected the records to be removed, got error: %v", err) + } + + if _, err := os.Stat(fastResultPath(directoryPath, "Alpha")); !os.IsNotExist(err) { + t.Errorf("expected the recorded check to be removed, got %v", err) + } +} + +// Verifies the bytes a check is recorded as, so the record keeps its format. +func TestRecordFastCheckWritesTheRecordedFormat(t *testing.T) { + directoryPath := t.TempDir() + result := FastCheckResult[testDiagnostic]{ + Succeeded: false, + Diagnostics: []testDiagnostic{{ + Severity: "error", + Code: "CS0103", + Message: "The name 'Missing' does not exist in the current context", + File: "Assets/Alpha/Alpha.cs", + Line: 3, + Column: 9, + }}, + ChangedFiles: []string{"Assets/Alpha/Alpha.cs"}, + UncheckedCodes: []string{"CS0169"}, + GeneratorsNotRerun: true, + } + + if err := directoryStore(directoryPath).RecordFastCheck("Alpha", "input", checkedBaseline(), "worker", result); err != nil { + t.Fatalf("failed to record the check: %v", err) + } + + assertFileContent(t, fastResultPath(directoryPath, "Alpha"), `{"FormatVersion":1,"InputKey":"input",`+ + `"BaselineInputKey":"baseline","WorkerKey":"worker","Succeeded":false,`+ + `"Diagnostics":[{"Severity":"error","Code":"CS0103","Message":"The name 'Missing' does not exist in the current context",`+ + `"File":"Assets/Alpha/Alpha.cs","Line":3,"Column":9}],`+ + `"ChangedFiles":["Assets/Alpha/Alpha.cs"],"UncheckedCodes":["CS0169"],"GeneratorsNotRerun":true}`) +} diff --git a/cli/dispatcher/internal/compilecheck/resultstore/store.go b/cli/dispatcher/internal/compilecheck/resultstore/store.go index 929d4dc229..ab0b17bdc6 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/store.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/store.go @@ -1,12 +1,14 @@ // Package resultstore keeps what earlier runs of compile-check recorded for each assembly in one // output directory, and is the only code that reads or writes those records. // -// A store keeps three records per assembly: +// A store keeps four records per assembly: // - the result of its last compile: the inputs it ran on (as a key), what it reported, and a hash // of every output it wrote; // - the baseline of the changed-files check: what the last successful full compile saw of each // source, its non-source inputs and the worker build that fingerprinted it; -// - the generator output that full compile saved. +// - the generator output that full compile saved; +// - the result of its last changed-files check: the inputs, the baseline and the worker build it +// checked with, and what it reported. // // Contract: // - Records are written through a temporary file and renamed into place, so a run stopped while @@ -21,6 +23,8 @@ // "no baseline: the last full compile did not record one", // "no baseline: it was recorded by another version of compile-check", // "baseline compile is no longer on disk". +// - A changed-files check is reusable only under the input key, the baseline and the worker build +// it was recorded with, and is never recorded without all three. // - Forget removes every kind of record of an assembly. It does not touch the assembly's outputs, // and the store never creates the generator output directory; it only names where it is. package resultstore @@ -54,8 +58,8 @@ func (s Store[D]) directoryPath() string { return filepath.Join(s.projectRoot, s.outputDir) } -// Forget removes every record of the assembly: its compile result, its baseline, and its saved -// generator output. +// Forget removes every record of the assembly: its compile result, its baseline, its saved generator +// output, and its last changed-files check. func (s Store[D]) Forget(assembly string) error { names := []string{ // Why the compile result goes with the outputs: it describes those very files, so leaving it @@ -76,6 +80,12 @@ func (s Store[D]) Forget(assembly string) error { return fmt.Errorf("failed to remove %s: %w", generatedPath, err) } + // The last changed-files check was checked against the compile whose records are going. + fastResult := fastResultPath(s.directoryPath(), assembly) + if err := os.Remove(fastResult); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove %s: %w", fastResult, err) + } + return nil } From f46b2a4d43ca198309832c985243a3d747baca64 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 13:20:51 +0900 Subject: [PATCH 14/33] feat: compile-check replays a changed-files check whose inputs did not change (#3329) --- .../CliOnlyTools~/CompileCheck/Skill/SKILL.md | 2 +- .../internal/compilecheck/compiler.go | 4 + .../internal/compilecheck/fast_path.go | 53 ++++- .../compilecheck/fast_path_fixture_test.go | 28 ++- .../compilecheck/fast_path_replay_test.go | 196 ++++++++++++++++++ .../internal/compilecheck/fast_path_test.go | 14 +- .../internal/compilecheck/input_key.go | 7 + .../internal/dispatcher/compile_check.go | 4 + .../compile_check_fast_path_test.go | 27 +++ ...only-changed-files-after-a-full-compile.md | 7 + docs/compile-check.md | 13 +- 11 files changed, 326 insertions(+), 29 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/fast_path_replay_test.go diff --git a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md index 01e601daf7..b14092db3a 100644 --- a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md @@ -82,7 +82,7 @@ A JSON payload: - `ReusedAssemblies`: the assemblies reported from the previous run's recorded result, without compiling - `BlockedAssemblies`: the assemblies not compiled because an assembly they reference has errors, as Unity's own build would stop there; fix those errors and run again - `FastCheckedAssemblies`: the assemblies checked on their changed files only -- `FastChecks`: per fast-checked assembly, `ChangedFiles`, `UncheckedCodes` (rules only `--full` runs), and `GeneratorsNotRerun` +- `FastChecks`: per fast-checked assembly, `ChangedFiles`, `UncheckedCodes` (rules only `--full` runs), `GeneratorsNotRerun`, and `Reused` (the previous run's check of the same edit, reported again) - `FastPathFallbacks`: `Assembly` and `Reason` for each assembly that was compiled in full instead - `FastPathBaselineNotes`: `Assembly` and `Reason` for each successful full compile that left no baseline for the next changed-files check - `SkippedAssemblies`: how many assemblies were left out: nothing they compile from changed, or every assembly they reference kept the same public surface diff --git a/cli/dispatcher/internal/compilecheck/compiler.go b/cli/dispatcher/internal/compilecheck/compiler.go index aa2ffacaa3..0ff09958e6 100644 --- a/cli/dispatcher/internal/compilecheck/compiler.go +++ b/cli/dispatcher/internal/compilecheck/compiler.go @@ -62,6 +62,10 @@ type UnitResult struct { // FastChecked marks a result that came from checking only the changed files against the last // successful full compile, instead of compiling the assembly. FastChecked bool + // FastCheckReused marks a fast check this run replayed from the previous run's check of the same + // changed files, because the inputs, the baseline and the worker build were all as that check saw + // them. + FastCheckReused bool // FastCheckChangedFiles lists the files a fast check checked, spelled as the response file does. FastCheckChangedFiles []string // UncheckedCodes lists the diagnostic IDs a fast check could not run; only a full compile does. diff --git a/cli/dispatcher/internal/compilecheck/fast_path.go b/cli/dispatcher/internal/compilecheck/fast_path.go index 5c9dfaa9ac..571e719309 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path.go +++ b/cli/dispatcher/internal/compilecheck/fast_path.go @@ -108,14 +108,16 @@ func (runner *fastPathRunner) Try( return fallBack(fmt.Sprintf("too many changed files (%d, limit %d)", len(changed), fastPathMaximumChangedFiles)) } - return runner.check(ctx, c, plan, unit, baseline, changed) + return runner.check(ctx, c, plan, unit, baseline, changed, keyResult.Key) } // check runs the worker over the changed files and merges what it found with the diagnostics the -// last full compile reported for the files that did not change. +// last full compile reported for the files that did not change. When the previous run checked the +// same inputs against the same baseline with the same worker build, it reports that check instead. +// key is the unit's input key, read before the check. func (runner *fastPathRunner) check( ctx context.Context, c Compiler, plan BuildPlan, unit CompileUnit, - baseline resultstore.Baseline[Diagnostic], changed []string, + baseline resultstore.Baseline[Diagnostic], changed []string, key string, ) fastPathAttempt { startedAt := time.Now() worker, err := runner.Worker.Ensure(ctx) @@ -130,6 +132,13 @@ func (runner *fastPathRunner) check( return fallBack("baseline was fingerprinted by another worker build") } + // Why the record is looked up only here: everything above is what a check needs, so a check is + // replayed only where it would have run, for the same inputs it ran on. + records := c.records(plan) + if recorded, found := records.ReusableFastCheck(unit.Assembly.AssemblyName, key, baseline, worker.Key); found { + return fastPathAttempt{Used: true, Result: fastCheckedUnitResult(unit, recorded, time.Since(startedAt), true)} + } + responseFilePath, err := writeRewrittenResponseFile( c.ProjectRoot, plan, unit, unit.Assembly.AssemblyName+fastCheckResponseFileSuffix, nil) if err != nil { @@ -149,17 +158,39 @@ func (runner *fastPathRunner) check( } diagnostics := mergeDiagnostics(c.ProjectRoot, unit, baseline.Diagnostics, response.Diagnostics, changed) + checked := resultstore.FastCheckResult[Diagnostic]{ + Succeeded: !containsError(diagnostics), + Diagnostics: diagnostics, + ChangedFiles: changed, + UncheckedCodes: response.UncheckedCodes, + GeneratorsNotRerun: response.GeneratorsNotRerun, + } + // Why the key is read again before recording: the worker reads the sources while it checks, so an + // edit made during the check would otherwise be recorded under the key of the bytes before it. + if c.keyStillHolds(plan, unit, key) { + if err := records.RecordFastCheck(unit.Assembly.AssemblyName, key, baseline, worker.Key, checked); err != nil { + return fastPathAttempt{Err: err} + } + } + + return fastPathAttempt{Used: true, Result: fastCheckedUnitResult(unit, checked, time.Since(startedAt), false)} +} - return fastPathAttempt{Used: true, Result: UnitResult{ +// fastCheckedUnitResult turns a check of the changed files into the unit's result. +func fastCheckedUnitResult( + unit CompileUnit, checked resultstore.FastCheckResult[Diagnostic], duration time.Duration, reused bool, +) UnitResult { + return UnitResult{ Assembly: unit.Assembly.AssemblyName, - Diagnostics: diagnostics, - Succeeded: !containsError(diagnostics), - Duration: time.Since(startedAt), + Diagnostics: checked.Diagnostics, + Succeeded: checked.Succeeded, + Duration: duration, FastChecked: true, - FastCheckChangedFiles: changed, - UncheckedCodes: response.UncheckedCodes, - GeneratorsNotRerun: response.GeneratorsNotRerun, - }} + FastCheckReused: reused, + FastCheckChangedFiles: checked.ChangedFiles, + UncheckedCodes: checked.UncheckedCodes, + GeneratorsNotRerun: checked.GeneratorsNotRerun, + } } // projectRelativeReason rewrites the absolute paths the worker put in a reason the way the output diff --git a/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go index 312bd215bf..2a23fec558 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go @@ -31,11 +31,13 @@ type fakeFastCheckWorker struct { reason string diagnostics []fastcheck.WorkerDiagnostic uncheckedCodes []string - onCheck func() - ensures int - fingerprints int - checks int - lastCheck fastcheck.CheckRequest + // generatorsNotRerun is what Check reports about the source generators. + generatorsNotRerun bool + onCheck func() + ensures int + fingerprints int + checks int + lastCheck fastcheck.CheckRequest } func newFakeFastCheckWorker() *fakeFastCheckWorker { @@ -97,10 +99,11 @@ func (worker *fakeFastCheckWorker) Check( } return fastcheck.CheckResponse{ - Outcome: worker.outcome, - Reason: worker.reason, - Diagnostics: worker.diagnostics, - UncheckedCodes: worker.uncheckedCodes, + Outcome: worker.outcome, + Reason: worker.reason, + Diagnostics: worker.diagnostics, + UncheckedCodes: worker.uncheckedCodes, + GeneratorsNotRerun: worker.generatorsNotRerun, }, nil } @@ -145,7 +148,12 @@ func (project *fastPathProject) sourceName(index int) string { // editBody changes the body part of Alpha's source at index, keeping its declaration. func (project *fastPathProject) editBody(t *testing.T, index int, body string) { t.Helper() - project.writeFile(t, project.sourceName(index), fmt.Sprintf("class File%03d %s %s", index, fakeDeclarationSeparator, body)) + project.writeFile(t, project.sourceName(index), bodyContent(index, body)) +} + +// bodyContent is what Alpha's source at index holds once its body is body. +func bodyContent(index int, body string) string { + return fmt.Sprintf("class File%03d %s %s", index, fakeDeclarationSeparator, body) } // editDeclaration changes the declaration part of Alpha's source at index. diff --git a/cli/dispatcher/internal/compilecheck/fast_path_replay_test.go b/cli/dispatcher/internal/compilecheck/fast_path_replay_test.go new file mode 100644 index 0000000000..6580af18f5 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fast_path_replay_test.go @@ -0,0 +1,196 @@ +package compilecheck + +import ( + "context" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" +) + +// newEditedFastPathProject records a baseline and edits the body of Alpha's first source, with the +// worker reporting an error in it, an unchecked code, and generators it did not rerun - every field +// a check reports holds something other than its zero value. +func newEditedFastPathProject(t *testing.T) *fastPathProject { + t.Helper() + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "int x = \"s\";") + project.worker.diagnostics = []fastcheck.WorkerDiagnostic{{ + File: project.absoluteSource(0), Line: 1, Column: 20, Severity: severityError, Code: "CS0029", Message: "cannot convert", + }} + project.worker.uncheckedCodes = []string{"CS0169"} + project.worker.generatorsNotRerun = true + + return project +} + +// runFastChecked runs the plan and fails the test unless Alpha was fast-checked without csc. +func (project *fastPathProject) runFastChecked(t *testing.T) UnitResult { + t.Helper() + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + result := resultOf(t, project.run(t, recorder, false).Units, "Alpha") + if !result.FastChecked || result.Reused || recorder.runs["Alpha"] != 0 { + t.Fatalf("expected Alpha to be fast-checked without csc, got %+v after %d runs", result, recorder.runs["Alpha"]) + } + + return result +} + +// Verifies a second run over the same edit reports the first run's check without calling the worker, +// and reports exactly what that check reported. +func TestFastCheckReplaysTheLastCheckOfTheSameEdit(t *testing.T) { + project := newEditedFastPathProject(t) + first := project.runFastChecked(t) + if first.FastCheckReused || project.worker.checks != 1 { + t.Fatalf("expected the first run to check, got %+v after %d checks", first, project.worker.checks) + } + + second := project.runFastChecked(t) + + if !second.FastCheckReused || project.worker.checks != 1 { + t.Fatalf("expected the second run to replay the check, got %+v after %d checks", second, project.worker.checks) + } + if first.Succeeded || second.Succeeded { + t.Errorf("expected both runs to report the error, got %t then %t", first.Succeeded, second.Succeeded) + } + if !hasDiagnosticCode(second.Diagnostics, "CS0029") || !reflect.DeepEqual(second.Diagnostics, first.Diagnostics) { + t.Errorf("diagnostics = %+v, want the first run's %+v", second.Diagnostics, first.Diagnostics) + } + assertStrings(t, "changed files", second.FastCheckChangedFiles, first.FastCheckChangedFiles) + assertStrings(t, "unchecked codes", second.UncheckedCodes, []string{"CS0169"}) + if !second.GeneratorsNotRerun { + t.Error("expected the replay to keep the generators the check did not rerun") + } +} + +// Verifies a different edit is checked again, and that check then replaces the recorded one. +func TestFastCheckChecksAgainAfterAnotherEdit(t *testing.T) { + project := newEditedFastPathProject(t) + project.runFastChecked(t) + project.editBody(t, 0, "int y = \"s\";") + + edited := project.runFastChecked(t) + if edited.FastCheckReused || project.worker.checks != 2 { + t.Fatalf("expected another edit to be checked, got %+v after %d checks", edited, project.worker.checks) + } + + again := project.runFastChecked(t) + if !again.FastCheckReused || project.worker.checks != 2 { + t.Errorf("expected the new check to be replayed, got %+v after %d checks", again, project.worker.checks) + } +} + +// Verifies a check is not replayed once the worker build changed. +func TestFastCheckDoesNotReplayAfterTheWorkerBuildChanged(t *testing.T) { + project := newEditedFastPathProject(t) + project.runFastChecked(t) + project.worker.key = "worker-b" + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "baseline was fingerprinted by another worker build") +} + +// Verifies a check is not replayed once the generator output it read is gone, so the replay is +// looked up only after everything the check needs has been confirmed. +func TestFastCheckDoesNotReplayWhenTheGeneratorOutputIsGone(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + generatedPath := project.recordBaselineWithGeneratorOutput(t) + project.editBody(t, 0, "edited") + project.runFastChecked(t) + removeGeneratorOutput(t, generatedPath) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "generator output of the baseline is gone") +} + +// Verifies a full compile leaves no record of the check that came before it: the files the check +// keeps for Alpha are the ones the first full compile left. +func TestFullCompileLeavesNoRecordOfAnEarlierFastCheck(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + recorded := snapshotPaths(project.outputSnapshot(t)) + project.editBody(t, 0, "edited") + project.runFastChecked(t) + + project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), true) + + assertStrings(t, "files kept for Alpha", snapshotPaths(project.outputSnapshot(t)), recorded) +} + +// Verifies a check whose result cannot be recorded fails the run, as a compile whose result cannot +// be recorded does. +func TestFastCheckFailsTheRunWhenItsResultCannotBeRecorded(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "edited") + var removeErr error + project.worker.onCheck = func() { removeErr = os.RemoveAll(project.outputDirectory()) } + + _, err := project.runWithContext(context.Background(), newCachedSchedulerRecorder("Alpha", "Beta"), false) + + if removeErr != nil { + t.Fatalf("failed to remove the output directory: %v", removeErr) + } + want := "failed to write " + project.outputDirectory() + string(filepath.Separator) + if err == nil || !strings.HasPrefix(err.Error(), want) { + t.Errorf("err = %v, want it to start with %q", err, want) + } +} + +// Verifies a check of sources edited while it ran is not recorded under the key of the bytes before +// the edit, so putting those bytes back checks them again rather than replaying the edited file. +func TestFastCheckDoesNotRecordACheckOfSourcesEditedDuringIt(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + project.editBody(t, 0, "X") + var editErr error + project.worker.onCheck = func() { + editErr = os.WriteFile(project.absoluteSource(0), []byte(bodyContent(0, "Y")), 0o600) + } + project.runFastChecked(t) + if editErr != nil { + t.Fatalf("failed to edit the source during the check: %v", editErr) + } + if project.worker.checks != 1 { + t.Fatalf("expected one check, got %d", project.worker.checks) + } + project.worker.onCheck = nil + project.editBody(t, 0, "X") + + result := project.runFastChecked(t) + + if result.FastCheckReused || project.worker.checks != 2 { + t.Errorf("expected the reverted source to be checked again, got %+v after %d checks", result, project.worker.checks) + } +} + +// hasDiagnosticCode reports whether any of the diagnostics has the code. +func hasDiagnosticCode(diagnostics []Diagnostic, code string) bool { + for _, diagnostic := range diagnostics { + if diagnostic.Code == code { + return true + } + } + + return false +} + +// snapshotPaths lists the paths of a snapshot in order. +func snapshotPaths(snapshot map[string]string) []string { + paths := make([]string, 0, len(snapshot)) + for path := range snapshot { + paths = append(paths, path) + } + slices.Sort(paths) + + return paths +} diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go index d2b7149f00..88c7fc273c 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -331,8 +331,8 @@ func TestWorkerKeyMismatchFallsBack(t *testing.T) { } // Verifies the clean changed-files check: Alpha is not compiled, its outputs, manifest, and baseline -// stay as they were, the diagnostics of unchanged files are carried over, and a dependent whose -// reference kept its surface is still skipped. +// stay as they were with only the record of the check itself added, the diagnostics of unchanged +// files are carried over, and a dependent whose reference kept its surface is still skipped. func TestFastPathCleanResultKeepsBaselineFiles(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) markSelectedAsDependent(t, project.plan, "Beta") @@ -356,8 +356,14 @@ func TestFastPathCleanResultKeepsBaselineFiles(t *testing.T) { if len(result.Diagnostics) != 1 || result.Diagnostics[0].File != "Assets/Alpha/File001.cs" { t.Errorf("expected the warning of the unchanged file to be carried over, got %+v", result.Diagnostics) } - if after := project.outputSnapshot(t); !reflect.DeepEqual(after, before) { - t.Errorf("expected the outputs, manifest, and baseline to stay, got %v then %v", before, after) + after := project.outputSnapshot(t) + for path, hash := range before { + if after[path] != hash { + t.Errorf("expected %s to stay as it was, got %q then %q", path, hash, after[path]) + } + } + if len(after) != len(before)+1 { + t.Errorf("expected only the record of the check to be added, got %v then %v", before, after) } if recorder.runs["Beta"] != 0 || outcome.Skipped != 1 { t.Errorf("expected Beta to be skipped, got %d runs and %d skipped", recorder.runs["Beta"], outcome.Skipped) diff --git a/cli/dispatcher/internal/compilecheck/input_key.go b/cli/dispatcher/internal/compilecheck/input_key.go index 12faa0ebad..8bd8500a29 100644 --- a/cli/dispatcher/internal/compilecheck/input_key.go +++ b/cli/dispatcher/internal/compilecheck/input_key.go @@ -56,6 +56,13 @@ func computeInputKey( return inputKeyResult{Key: key, SourceHashes: sourceHashes}, nil } +// keyStillHolds reports whether the unit's inputs still give the key the run started from. +func (c Compiler) keyStillHolds(plan BuildPlan, unit CompileUnit, key string) bool { + rechecked, err := computeInputKey(c.ProjectRoot, c.Paths, plan, unit) + + return err == nil && rechecked.Key == key +} + // nonSourceIdentity is the non-source key of a unit together with a digest of each kind of input it // covers, so a fallback can say which kind changed. type nonSourceIdentity struct { diff --git a/cli/dispatcher/internal/dispatcher/compile_check.go b/cli/dispatcher/internal/dispatcher/compile_check.go index 1c918570fd..9b9b791da6 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check.go +++ b/cli/dispatcher/internal/dispatcher/compile_check.go @@ -85,6 +85,9 @@ type compileCheckFastCheck struct { UncheckedCodes []string `json:"UncheckedCodes"` // GeneratorsNotRerun is set when the source generators were not run again. GeneratorsNotRerun bool `json:"GeneratorsNotRerun"` + // Reused is set when this run reported the previous run's check of the same changed files instead + // of checking them again. + Reused bool `json:"Reused"` } // compileCheckFastPathFallback says why one assembly compiled in full instead of being fast-checked. @@ -268,6 +271,7 @@ func addCompileCheckUnitKind(response *compileCheckResponse, unit compilecheck.U ChangedFiles: append(make([]string, 0, len(unit.FastCheckChangedFiles)), unit.FastCheckChangedFiles...), UncheckedCodes: append(make([]string, 0, len(unit.UncheckedCodes)), unit.UncheckedCodes...), GeneratorsNotRerun: unit.GeneratorsNotRerun, + Reused: unit.FastCheckReused, }) default: response.CompiledAssemblies = append(response.CompiledAssemblies, unit.Assembly) diff --git a/cli/dispatcher/internal/dispatcher/compile_check_fast_path_test.go b/cli/dispatcher/internal/dispatcher/compile_check_fast_path_test.go index 8bc9c31422..c68e0f09c2 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_fast_path_test.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_fast_path_test.go @@ -110,6 +110,33 @@ func TestFastCheckOnlyRunWithErrorDoesNotSayNoAssembliesChanged(t *testing.T) { } } +// Verifies a fast check this run replayed is still listed and reported as fast-checked - with the +// --full reminder - and says it was replayed, while one checked afresh says it was not. +func TestResponseMarksAReplayedFastCheck(t *testing.T) { + for name, reused := range map[string]bool{"replayed": true, "checked": false} { + t.Run(name, func(t *testing.T) { + response := buildCompileCheckResponse(compilecheck.Result{ + DagDir: "Library/Bee/artifacts/1234.dag", + Units: []compilecheck.UnitResult{{ + Assembly: "Fast", Succeeded: true, FastChecked: true, FastCheckReused: reused, + FastCheckChangedFiles: []string{"Assets/Fast/A.cs"}, + }}, + }, "/projects/sample") + + assertCompileCheckNames(t, "fast-checked", response.FastCheckedAssemblies, []string{"Fast"}) + if len(response.ReusedAssemblies) != 0 { + t.Errorf("reused = %v, want none", response.ReusedAssemblies) + } + if len(response.FastChecks) != 1 || response.FastChecks[0].Reused != reused { + t.Errorf("fast checks = %+v, want Reused %t", response.FastChecks, reused) + } + if !strings.Contains(response.Message, "run with --full before finishing") { + t.Errorf("message = %q, want the --full reminder", response.Message) + } + }) + } +} + func assertCompileCheckNames(t *testing.T, label string, got []string, want []string) { t.Helper() if !reflect.DeepEqual(got, want) { diff --git a/docs/adr/0015-compile-check-checks-only-changed-files-after-a-full-compile.md b/docs/adr/0015-compile-check-checks-only-changed-files-after-a-full-compile.md index e6bd90222f..6757e7c133 100644 --- a/docs/adr/0015-compile-check-checks-only-changed-files-after-a-full-compile.md +++ b/docs/adr/0015-compile-check-checks-only-changed-files-after-a-full-compile.md @@ -25,6 +25,10 @@ reports that failure as AD0001. `--full` turns the changed-files check off for o The check runs in a C# worker built on the user's machine with the Roslyn that ships with the Editor, so the worker uses only the public Roslyn API that Roslyn 4.3.1 already has. +When an assembly's input key, its baseline, and the worker build are the same as at the previous +check of its changed files, and everything above still holds, compile-check reports the record of +that check without calling the worker. `FastChecks[].Reused` says it did. + ## What It Guarantees - The compiler diagnostics and the analyzer diagnostics of the changed files, with the @@ -34,6 +38,9 @@ Editor, so the worker uses only the public Roslyn API that Roslyn 4.3.1 already fingerprint accepts does not change what the compiler binds or analyzes in another file, so their binding and flow-analysis diagnostics stay as they were. The exceptions are the assembly-wide diagnostics listed below. +- A replayed check reports what checking the same files again would report: the record is looked + up only after every condition the check needs has been confirmed, and only for the same inputs, + baseline, and worker build it was recorded with. ## What It Does Not Guarantee diff --git a/docs/compile-check.md b/docs/compile-check.md index fef493c1e4..e404c9b8ef 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -146,6 +146,10 @@ and the response file's options applied, and the diagnostics of the files that d carried over from the last full compile. The outputs, the result, and the baseline of that compile are left as they were. +A second run over the same changed files - the same inputs, the same baseline, and the same worker +build - does not call the worker again: it reports what the previous check reported, and +`FastChecks[].Reused` says so. + Otherwise the assembly is compiled in full as before, and `FastPathFallbacks` says why: - `no baseline: no full compile of this assembly is on record` (none ran yet, or a later run @@ -173,7 +177,8 @@ Otherwise the assembly is compiled in full as before, and `FastPathFallbacks` sa A full compile that succeeds records a new baseline, `--full` runs included, so the run after it can use the check again. A source or another input edited while csc was compiling leaves no baseline, -and `FastPathBaselineNotes` says why a successful full compile left none. +and `FastPathBaselineNotes` says why a successful full compile left none. An input edited while the +changed files were being checked leaves no record of that check, so the next run checks them again. So the first body edit is compiled in full more often than it looks: @@ -203,7 +208,8 @@ What the check does not guarantee, so run `--full` before finishing: The check keeps its state next to the compile outputs: `.fastcheck.json` (the baseline), `.generated/` (the saved generator output, whose files the baseline records by content -hash), and the worker build in `Library/uloop/compile-check/fastcheck-worker//`. Deleting or +hash), `.fastresult.json` (the result of the last changed-files check, reported again +while nothing it read has changed), and the worker build in `Library/uloop/compile-check/fastcheck-worker//`. Deleting or changing any of them only costs a full compile. ## One run per project at a time @@ -239,7 +245,8 @@ the assemblies the check read and found nothing left to say about: the ones noth and the ones left out because every assembly they reference kept the same public surface. `FastCheckedAssemblies` names the assemblies checked on their changed files only, which are in neither `CompiledAssemblies` nor `ReusedAssemblies`; `FastChecks` gives each one's `ChangedFiles`, -`UncheckedCodes`, and `GeneratorsNotRerun`, and `FastPathFallbacks` gives the `Assembly` and +`UncheckedCodes`, `GeneratorsNotRerun`, and `Reused` (set when the run reported the previous run's +check of the same changed files instead of checking them again), and `FastPathFallbacks` gives the `Assembly` and `Reason` of each assembly the check was tried on and compiled in full. `FastPathBaselineNotes` gives the `Assembly` and `Reason` of each successful full compile that left no baseline. The process exits 1 when From eb7989a398bc6fdc81e36bb94646ae43b9f6a8aa Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 13:22:15 +0900 Subject: [PATCH 15/33] fix: compile-check does not record a compile whose inputs changed while it ran (#3330) --- .../internal/compilecheck/fast_path_test.go | 7 ++-- .../internal/compilecheck/result_cache.go | 12 +++++-- .../compilecheck/scheduler_cache_test.go | 32 +++++++++++++++++++ docs/compile-check.md | 10 +++--- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go index 88c7fc273c..dc651ba5b1 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -183,8 +183,9 @@ func TestSmallAssemblyIsNotTried(t *testing.T) { } } -// Verifies that an input other than source contents that changed while csc compiled leaves no -// baseline, and says why, so the next body edit compiles in full. +// Verifies that an input other than source contents that changed while csc compiled leaves neither a +// baseline nor a record of the compile to reuse, and the note says why, so the next body edit +// compiles in full. func TestNonSourceInputChangedDuringCompileLeavesNoBaseline(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) recorder := newCachedSchedulerRecorder("Alpha", "Beta") @@ -205,7 +206,7 @@ func TestNonSourceInputChangedDuringCompileLeavesNoBaseline(t *testing.T) { project.editBody(t, 0, "edited") second := newCachedSchedulerRecorder("Alpha", "Beta") - assertFallback(t, project.run(t, second, false), second, "no baseline: the last full compile did not record one") + assertFallback(t, project.run(t, second, false), second, "no baseline: no full compile of this assembly is on record") } // Verifies that a baseline whose saved generator output was deleted or changed is not used, since diff --git a/cli/dispatcher/internal/compilecheck/result_cache.go b/cli/dispatcher/internal/compilecheck/result_cache.go index 4e8e8c97f2..4592285d0d 100644 --- a/cli/dispatcher/internal/compilecheck/result_cache.go +++ b/cli/dispatcher/internal/compilecheck/result_cache.go @@ -58,9 +58,15 @@ func (c Compiler) reuseOrCompile( if err != nil || key == "" || result.RawOutput != "" { return result, err } - if writeErr := records.RecordCompile(unit.Assembly.AssemblyName, key, - resultstore.CompileResult[Diagnostic]{Succeeded: result.Succeeded, Diagnostics: result.Diagnostics}, unitOutputNames(unit)); writeErr != nil { - return UnitResult{}, writeErr + // Why the key is read again before recording: csc reads the sources while it compiles, so an edit + // made during the compile would otherwise be recorded under the key of the bytes before it, and + // putting the file back would replay what the edited file reported. CompileUnit has already + // removed the previous record, so leaving this one out makes the next run compile again. + if c.keyStillHolds(plan, unit, key) { + if writeErr := records.RecordCompile(unit.Assembly.AssemblyName, key, + resultstore.CompileResult[Diagnostic]{Succeeded: result.Succeeded, Diagnostics: result.Diagnostics}, unitOutputNames(unit)); writeErr != nil { + return UnitResult{}, writeErr + } } // Why --full records too: running --full before finishing would otherwise drop the baseline, and diff --git a/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go b/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go index 27661be284..fb66ff6e8c 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go @@ -544,3 +544,35 @@ func TestCompileUnitsRecordsNothingWhenASuccessfulCompileWroteNoOutput(t *testin t.Errorf("the next run should have compiled the unit, got %d runs", recorder.runs["Alpha"]) } } + +// Verifies a compile of sources edited while csc ran is not recorded under the key of the bytes +// before the edit, so putting those bytes back compiles them again rather than reusing what the +// edited file reported. +func TestCompileUnitsDoesNotRecordACompileOfSourcesEditedDuringIt(t *testing.T) { + project := newCachedSchedulerProject(t, nil, "Alpha") + project.compileOrFail(t, newCachedSchedulerRecorder("Alpha")) + project.editSource(t, "Alpha", "class Alpha { void X() { } }") + edited := newCachedSchedulerRecorder("Alpha") + var editErr error + edited.during = func(name string) { + if name == "Alpha" { + editErr = os.WriteFile(filepath.Join(project.root, "Assets", "Alpha", "Alpha.cs"), + []byte("class Alpha { void Y() { } }"), 0o600) + } + } + project.compileOrFail(t, edited) + if editErr != nil { + t.Fatalf("failed to edit the source during the compile: %v", editErr) + } + if edited.runs["Alpha"] != 1 { + t.Fatalf("expected the edited unit to compile, got %d runs", edited.runs["Alpha"]) + } + project.editSource(t, "Alpha", "class Alpha { void X() { } }") + + recorder := newCachedSchedulerRecorder("Alpha") + project.compileOrFail(t, recorder) + + if recorder.runs["Alpha"] != 1 { + t.Errorf("expected the reverted source to compile again, got %d runs", recorder.runs["Alpha"]) + } +} diff --git a/docs/compile-check.md b/docs/compile-check.md index e404c9b8ef..fe3eb77b80 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -152,8 +152,9 @@ build - does not call the worker again: it reports what the previous check repor Otherwise the assembly is compiled in full as before, and `FastPathFallbacks` says why: -- `no baseline: no full compile of this assembly is on record` (none ran yet, or a later run - skipped or blocked the assembly, which removes its result and baseline) +- `no baseline: no full compile of this assembly is on record` (none ran yet, a later run + skipped or blocked the assembly, which removes its result and baseline, or an input changed while + the last full compile ran, so it left no record; `FastPathBaselineNotes` of that run says which) - `no baseline: the last full compile had errors` (a full compile that fails drops the baseline an earlier one left) - `no baseline: the last full compile did not record one` (`FastPathBaselineNotes` of that run @@ -176,8 +177,9 @@ Otherwise the assembly is compiled in full as before, and `FastPathFallbacks` sa not support) A full compile that succeeds records a new baseline, `--full` runs included, so the run after it can -use the check again. A source or another input edited while csc was compiling leaves no baseline, -and `FastPathBaselineNotes` says why a successful full compile left none. An input edited while the +use the check again. A source or another input edited while csc was compiling leaves no baseline +and no record of that compile to reuse, so the next run compiles the assembly again; +`FastPathBaselineNotes` says why a successful full compile left no baseline. An input edited while the changed files were being checked leaves no record of that check, so the next run checks them again. So the first body edit is compiled in full more often than it looks: From a148fde43b7a391752d1638c963ac912f9e3d851 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 15:11:09 +0900 Subject: [PATCH 16/33] fix: compile-check keeps the records of assemblies it blocks (#3331) --- .../CliOnlyTools~/CompileCheck/Skill/SKILL.md | 2 +- .../compilecheck/blocked_records_test.go | 172 ++++++++++++++++++ .../internal/compilecheck/fast_path_test.go | 35 ---- .../compilecheck/resultstore/baseline.go | 2 +- .../internal/compilecheck/scheduler.go | 24 ++- .../internal/compilecheck/scheduler_test.go | 6 +- docs/compile-check.md | 10 +- 7 files changed, 195 insertions(+), 56 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/blocked_records_test.go diff --git a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md index b14092db3a..059d473714 100644 --- a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md @@ -56,7 +56,7 @@ everything from scratch, which is the way out if a reused result ever looks wron In a large assembly whose last compile-check compile succeeded, an edit that keeps every declaration is checked on the changed files only, in a few seconds. Such a check skips a few assembly-wide rules, so run once with `--full` before finishing. The first body edit after a Unity -build, after a `--full` that had errors, after a run that skipped or blocked the assembly, or after +build, after a `--full` that had errors, after a run that skipped the assembly, or after `--all` in an assembly that references another of the project is compiled in full and records what the next check compares against; `FastPathFallbacks` says why. diff --git a/cli/dispatcher/internal/compilecheck/blocked_records_test.go b/cli/dispatcher/internal/compilecheck/blocked_records_test.go new file mode 100644 index 0000000000..8dc0ed1ce7 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/blocked_records_test.go @@ -0,0 +1,172 @@ +package compilecheck + +import ( + "fmt" + "testing" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" +) + +// blockAlphaOnce makes Alpha fail to compile, which blocks Beta, and checks that it did. +// Why Alpha's source is edited first: an untouched Alpha would be replayed from the previous run's +// record, and the failure set up here would never be read. +func blockAlphaOnce(t *testing.T, project *cachedSchedulerProject) { + t.Helper() + project.editSource(t, "Alpha", "class Alpha { broken }") + failing := newCachedSchedulerRecorder("Alpha", "Beta") + failing.output["Alpha"] = failingCompilerOutput("Alpha") + // Why the failing compile writes no reference assembly: csc writes none when it reports errors. + delete(failing.surfaces, "Alpha") + outcome, err := project.compile(t.Context(), failing) + if err != nil { + t.Fatalf("expected the failing run to finish, got error: %v", err) + } + assertStrings(t, "blocked", outcome.Blocked, []string{"Beta"}) +} + +// newBlockableProject lays out Alpha and Beta, Beta referencing Alpha and picked only as its +// dependent, and compiles both once. Why Alpha's surface differs from Unity's: when it matches, +// Beta is skipped for that reason and the run never asks whether Beta's records can be reused. +func newBlockableProject(t *testing.T) *cachedSchedulerProject { + t.Helper() + project := newCachedSchedulerProject(t, map[string][]string{"Beta": {"Alpha"}}, "Alpha", "Beta") + markSelectedAsDependent(t, project.plan, "Beta") + project.compileOrFail(t, newCachedSchedulerRecorder("Alpha", "Beta")) + + return project +} + +// Verifies a dependent blocked by one failed run is reused, without a compile, once the assembly it +// references comes back with the same reference assembly it was compiled against. +func TestCompileUnitsReusesABlockedDependentWhenItsReferenceComesBackTheSame(t *testing.T) { + project := newBlockableProject(t) + blockAlphaOnce(t, project) + + project.editSource(t, "Alpha", "class Alpha { }") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + results := project.compileOrFail(t, recorder) + + if recorder.runs["Alpha"] != 1 { + t.Errorf("Alpha should have compiled once, got %d", recorder.runs["Alpha"]) + } + if recorder.runs["Beta"] != 0 { + t.Errorf("Beta should not have compiled, got %d runs", recorder.runs["Beta"]) + } + if !resultOf(t, results, "Beta").Reused { + t.Errorf("Beta should have been reused, got %+v", resultOf(t, results, "Beta")) + } +} + +// Verifies a dependent blocked by one failed run is compiled again when the assembly it references +// comes back with a different reference assembly, since its record was made against the old one. +func TestCompileUnitsRecompilesABlockedDependentWhenItsReferenceComesBackChanged(t *testing.T) { + project := newBlockableProject(t) + blockAlphaOnce(t, project) + + project.editSource(t, "Alpha", "class Alpha { public void Added() { } }") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + recorder.surfaces["Alpha"] = "changed surface Alpha" + results := project.compileOrFail(t, recorder) + + if recorder.runs["Beta"] != 1 { + t.Errorf("Beta should have compiled once, got %d runs", recorder.runs["Beta"]) + } + if resultOf(t, results, "Beta").Reused { + t.Error("Beta should not have been reused against a changed reference assembly") + } +} + +// failChangedFilesCheckOfAlpha edits one of Alpha's bodies into an error the changed-files check +// reports, runs the plan, and checks that the error blocked Beta. +func failChangedFilesCheckOfAlpha(t *testing.T, project *fastPathProject) { + t.Helper() + project.editBody(t, 0, "int x = \"s\";") + project.worker.diagnostics = []fastcheck.WorkerDiagnostic{{ + File: project.absoluteSource(0), Line: 1, Column: 20, Severity: severityError, Code: "CS0029", Message: "cannot convert", + }} + outcome := project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + if result := resultOf(t, outcome.Units, "Alpha"); !result.FastChecked || result.Succeeded { + t.Fatalf("expected a failed changed-files check, got %+v", result) + } + assertStrings(t, "blocked", outcome.Blocked, []string{"Beta"}) + project.worker.diagnostics = nil +} + +// newBlockableFastPathProject lays out a fast path project whose Beta is picked only as Alpha's +// dependent, and records Alpha's baseline. Alpha's surface differs from Unity's for the reason +// newBlockableProject gives. +func newBlockableFastPathProject(t *testing.T) *fastPathProject { + t.Helper() + project := newFastPathProject(t, fastPathMinimumSources) + markSelectedAsDependent(t, project.plan, "Beta") + project.recordBaseline(t) + + return project +} + +// Verifies fixing a typo the changed-files check reported leaves the dependent it blocked reused: +// the check writes no output, so Beta reads the reference assembly it was compiled against. +func TestFastPathFixAfterAnErrorReusesTheBlockedDependents(t *testing.T) { + project := newBlockableFastPathProject(t) + failChangedFilesCheckOfAlpha(t, project) + + project.editBody(t, 0, "fixed") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + outcome := project.run(t, recorder, false) + + if result := resultOf(t, outcome.Units, "Alpha"); !result.FastChecked || !result.Succeeded { + t.Fatalf("expected a clean changed-files check of Alpha, got %+v", result) + } + if recorder.runs["Beta"] != 0 || !resultOf(t, outcome.Units, "Beta").Reused { + t.Errorf("expected Beta to be reused, got %d runs and %+v", recorder.runs["Beta"], resultOf(t, outcome.Units, "Beta")) + } +} + +// Verifies undoing a typo the changed-files check reported, so the body is back to the baseline, +// leaves both Alpha and the dependent it blocked reused. +func TestFastPathRevertAfterAnErrorReusesTheBlockedDependents(t *testing.T) { + project := newBlockableFastPathProject(t) + failChangedFilesCheckOfAlpha(t, project) + + project.editBody(t, 0, "body") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + outcome := project.run(t, recorder, false) + + if result := resultOf(t, outcome.Units, "Alpha"); !result.Reused { + t.Fatalf("expected Alpha to be reused, got %+v", result) + } + if recorder.runs["Beta"] != 0 || !resultOf(t, outcome.Units, "Beta").Reused { + t.Errorf("expected Beta to be reused, got %d runs and %+v", recorder.runs["Beta"], resultOf(t, outcome.Units, "Beta")) + } +} + +// Verifies a run that blocks an assembly keeps its baseline, so once the failed reference comes +// back with the same reference assembly, a body edit in the blocked assembly is answered by the +// changed-files check instead of a full compile. +func TestBlockedRunKeepsTheBaselineOfTheBlockedAssembly(t *testing.T) { + project := &fastPathProject{ + cachedSchedulerProject: newCachedSchedulerProject(t, map[string][]string{"Beta": {"Alpha"}}, "Alpha", "Beta"), + worker: newFakeFastCheckWorker(), + } + sources := make([]string, 0, fastPathMinimumSources) + for index := 0; index < fastPathMinimumSources; index++ { + source := fmt.Sprintf("Assets/Beta/File%03d.cs", index) + project.writeFile(t, source, fmt.Sprintf("class File%03d %s body", index, fakeDeclarationSeparator)) + sources = append(sources, source) + } + project.plan.Units[1].Sources = sources + project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + if !project.hasBaselineOf("Beta") { + t.Fatal("expected the first run to record Beta's baseline") + } + blockAlphaOnce(t, project.cachedSchedulerProject) + + project.editSource(t, "Alpha", "class Alpha { }") + project.writeFile(t, sources[0], fmt.Sprintf("class File000 %s edited", fakeDeclarationSeparator)) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + outcome := project.run(t, recorder, false) + + if result := resultOf(t, outcome.Units, "Beta"); !result.FastChecked || recorder.runs["Beta"] != 0 { + t.Errorf("expected Beta to be answered by the changed-files check, got %d runs and %+v", recorder.runs["Beta"], result) + } +} diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go index dc651ba5b1..452382cea1 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -3,7 +3,6 @@ package compilecheck import ( "context" "errors" - "fmt" "os" "path/filepath" "reflect" @@ -502,40 +501,6 @@ func TestFallbackReasonHasNoAbsolutePath(t *testing.T) { assertFallback(t, outcome, recorder, "Unsupported: changed file is not a source: "+project.sourceName(0)+" near Library") } -// Verifies that an assembly whose baseline was removed because a reference failed and blocked it -// falls back without claiming it was never compiled in full, since nothing on disk tells the two apart. -func TestBaselineRemovedByBlockedRunIsNotCalledNeverCompiled(t *testing.T) { - project := &fastPathProject{ - cachedSchedulerProject: newCachedSchedulerProject(t, map[string][]string{"Beta": {"Alpha"}}, "Alpha", "Beta"), - worker: newFakeFastCheckWorker(), - } - sources := make([]string, 0, fastPathMinimumSources) - for index := 0; index < fastPathMinimumSources; index++ { - source := fmt.Sprintf("Assets/Beta/File%03d.cs", index) - project.writeFile(t, source, fmt.Sprintf("class File%03d %s body", index, fakeDeclarationSeparator)) - sources = append(sources, source) - } - project.plan.Units[1].Sources = sources - project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) - if !project.hasBaselineOf("Beta") { - t.Fatal("expected the first run to record Beta's baseline") - } - - project.editSource(t, "Alpha", "class Alpha { broken }") - failing := newCachedSchedulerRecorder("Alpha", "Beta") - failing.output["Alpha"] = failingCompilerOutput("Alpha") - assertStrings(t, "blocked", project.run(t, failing, false).Blocked, []string{"Beta"}) - - project.editSource(t, "Alpha", "class Alpha { }") - project.writeFile(t, sources[0], fmt.Sprintf("class File000 %s edited", fakeDeclarationSeparator)) - recorder := newCachedSchedulerRecorder("Alpha", "Beta") - outcome := project.run(t, recorder, false) - - if reason := resultOf(t, outcome.Units, "Beta").FastPathFallbackReason; reason != "no baseline: no full compile of this assembly is on record" { - t.Errorf("Beta fallback reason = %q", reason) - } -} - // Verifies that a unit whose non-source key could not be read before the run falls back with a // reason of its own rather than listing every kind of input as changed. func TestUnreadableNonSourceKeyFallsBack(t *testing.T) { diff --git a/cli/dispatcher/internal/compilecheck/resultstore/baseline.go b/cli/dispatcher/internal/compilecheck/resultstore/baseline.go index 4364b3f370..32ae1e41ee 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/baseline.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/baseline.go @@ -116,7 +116,7 @@ func (s Store[D]) LoadBaseline(assembly string) (Baseline[D], string) { // compile. // Why the reasons are told apart: a full compile with errors drops the baseline an earlier compile // left, and a reason that read as "never compiled" sent users looking for a compile that had run. -// Why a missing manifest says nothing about what happened: a run that skips or blocks the unit +// Why a missing manifest says nothing about what happened: a run that skips the unit // removes the manifest and the baseline alike, so nothing on disk tells that apart from no compile. func (s Store[D]) missingBaselineReason(assembly string) string { const notOnRecord = "no baseline: no full compile of this assembly is on record" diff --git a/cli/dispatcher/internal/compilecheck/scheduler.go b/cli/dispatcher/internal/compilecheck/scheduler.go index 8050e6f874..0bc5124dcd 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler.go +++ b/cli/dispatcher/internal/compilecheck/scheduler.go @@ -140,11 +140,7 @@ func (run *schedulerRun) startReadyUnits(jobs int) error { run.state.ready = run.state.ready[1:] // Why the block is read before the skip: a blocked unit has no result to compare against // anything, so asking whether its references kept their surface answers nothing. - blocked, err := run.skipBlockedUnit(index) - if err != nil { - return err - } - if blocked { + if run.skipBlockedUnit(index) { continue } skipped, err := run.skipUnchangedDependent(index) @@ -165,19 +161,21 @@ func (run *schedulerRun) startReadyUnits(jobs int) error { // to what waits on this one, which is where Unity's own build stops as well: the reference assembly // this unit compiles against was never produced, so all it could report is the metadata it cannot // find rather than anything about itself. -// Why its earlier outputs are removed, as for a skipped dependent: a reference assembly an earlier -// run wrote would otherwise be picked up as this run's own output. -func (run *schedulerRun) skipBlockedUnit(index int) (bool, error) { +// Why its earlier outputs and records are kept: nothing in this run reads them, since every unit +// that references this one is blocked too, and a later run uses them only when the records still +// match: a replayed compile needs its input key, which reads every reference this run rebuilds by +// content and the others by timestamp, and outputs that still hash as recorded; a changed-files +// check needs the baseline's compile result on disk and the same non-source key. +// Removing them made the first successful run after an error compile every dependent again even +// when the assembly with errors came back with the same reference assembly. +func (run *schedulerRun) skipBlockedUnit(index int) bool { if !run.state.blocked[index] { - return false, nil - } - if err := run.compiler.removeUnitOutputs(run.plan, run.plan.Units[index]); err != nil { - return false, err + return false } run.state.blockDependents(index) run.state.release(index, false) - return true, nil + return true } // skipUnchangedDependent leaves out a unit this run picked up only because something it references diff --git a/cli/dispatcher/internal/compilecheck/scheduler_test.go b/cli/dispatcher/internal/compilecheck/scheduler_test.go index d285fc3a01..a562f96599 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_test.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_test.go @@ -494,8 +494,10 @@ func TestCompileUnitsBlocksTheDependentsOfAnAssemblyThatFailedToCompile(t *testi t.Errorf("%s should not have compiled against an assembly that failed", name) } } - if _, statErr := os.Stat(staleOutput); !os.IsNotExist(statErr) { - t.Error("the blocked unit's earlier output should have been removed") + // Why the earlier output stays: nothing in this run reads it, and a later run checks it against + // the unit's record before using it. + if _, statErr := os.Stat(staleOutput); statErr != nil { + t.Errorf("the blocked unit's earlier output should have been kept, got %v", statErr) } } diff --git a/docs/compile-check.md b/docs/compile-check.md index fe3eb77b80..7f9895350b 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -153,7 +153,7 @@ build - does not call the worker again: it reports what the previous check repor Otherwise the assembly is compiled in full as before, and `FastPathFallbacks` says why: - `no baseline: no full compile of this assembly is on record` (none ran yet, a later run - skipped or blocked the assembly, which removes its result and baseline, or an input changed while + skipped the assembly, which removes its result and baseline, or an input changed while the last full compile ran, so it left no record; `FastPathBaselineNotes` of that run says which) - `no baseline: the last full compile had errors` (a full compile that fails drops the baseline an earlier one left) @@ -191,9 +191,11 @@ So the first body edit is compiled in full more often than it looks: that references another assembly of the project, the first body edit after `--all` falls back with `inputs other than source contents changed: references`; an assembly that references none is checked as usual. -- A run that skips an assembly because nothing it compiles from changed, or blocks it because an - assembly it references has errors, removes its result and baseline, so its next body edit is - compiled in full. +- A run that skips an assembly because nothing it compiles from changed removes its result and + baseline, so its next body edit is compiled in full. A run that blocks it because an assembly it + references has errors keeps them: the next run checks them against the inputs and output hashes + they were recorded with before using them, so a reference that comes back the same leaves the + assembly reused or checked on its changed files, and one that comes back changed compiles it. - `--full` while the assembly still has errors drops its baseline, so the run after the fix is compiled in full. Running `--full` once the errors are fixed keeps it. From aebad284b7805fff1ae44dae24a2d7d928717088 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 15:13:35 +0900 Subject: [PATCH 17/33] fix: compile-check says how to get its changed-files check back (#3332) --- .../Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md | 6 ++++-- .../internal/compilecheck/fast_path_test.go | 2 +- .../internal/compilecheck/resultstore/baseline.go | 2 +- .../resultstore/baseline_contract_test.go | 2 +- .../internal/compilecheck/resultstore/store.go | 2 +- docs/compile-check.md | 12 ++++++++---- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md index 059d473714..5652dd0742 100644 --- a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md @@ -54,8 +54,10 @@ again: the run reports the diagnostics recorded then, errors included. `--all` c everything from scratch, which is the way out if a reused result ever looks wrong. In a large assembly whose last compile-check compile succeeded, an edit that keeps every -declaration is checked on the changed files only, in a few seconds. Such a check skips a few -assembly-wide rules, so run once with `--full` before finishing. The first body edit after a Unity +declaration is checked on the changed files only, in a few seconds. Adding or removing a using +directive counts as a declaration change. Such a check skips a few assembly-wide rules, so run once +with `--full` before finishing. When the project treats warnings as errors, a rule listed in +`UncheckedCodes`, such as CS0414, can fail that `--full` run only. The first body edit after a Unity build, after a `--full` that had errors, after a run that skipped the assembly, or after `--all` in an assembly that references another of the project is compiled in full and records what the next check compares against; `FastPathFallbacks` says why. diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go index 452382cea1..60a2b6845b 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -150,7 +150,7 @@ func TestFailedFullCompileLeavesNoBaseline(t *testing.T) { recorder := newCachedSchedulerRecorder("Alpha", "Beta") outcome := project.run(t, recorder, false) - assertFallback(t, outcome, recorder, "no baseline: the last full compile had errors") + assertFallback(t, outcome, recorder, "no baseline: the last full compile had errors; the first full compile without errors records one") } // Verifies that a change to an input other than source contents, such as a new source, compiles in full. diff --git a/cli/dispatcher/internal/compilecheck/resultstore/baseline.go b/cli/dispatcher/internal/compilecheck/resultstore/baseline.go index 32ae1e41ee..629cd78852 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/baseline.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/baseline.go @@ -130,7 +130,7 @@ func (s Store[D]) missingBaselineReason(assembly string) string { return notOnRecord } if !manifest.Succeeded { - return "no baseline: the last full compile had errors" + return "no baseline: the last full compile had errors; the first full compile without errors records one" } return "no baseline: the last full compile did not record one" diff --git a/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go b/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go index ea841f0cd2..41cb9687ad 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go @@ -181,7 +181,7 @@ func TestLoadBaselineExplainsAMissingBaseline(t *testing.T) { }, "failed compile": { record: func(t *testing.T, project projectStore) { project.recordCompile(t, "input", false) }, - want: "no baseline: the last full compile had errors", + want: "no baseline: the last full compile had errors; the first full compile without errors records one", }, "successful compile": { record: func(t *testing.T, project projectStore) { project.recordCompile(t, "input", true) }, diff --git a/cli/dispatcher/internal/compilecheck/resultstore/store.go b/cli/dispatcher/internal/compilecheck/resultstore/store.go index ab0b17bdc6..2de3bd0301 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/store.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/store.go @@ -19,7 +19,7 @@ // same key, a successful compile, and outputs that still match. Otherwise LoadBaseline says why // there is none, in one of these reasons: // "no baseline: no full compile of this assembly is on record", -// "no baseline: the last full compile had errors", +// "no baseline: the last full compile had errors; the first full compile without errors records one", // "no baseline: the last full compile did not record one", // "no baseline: it was recorded by another version of compile-check", // "baseline compile is no longer on disk". diff --git a/docs/compile-check.md b/docs/compile-check.md index 7f9895350b..31e77675e8 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -138,7 +138,9 @@ The check is used when all of these hold: list of source paths. - 1 to 30 of its sources changed. - Every changed file keeps its declarations: only method bodies, accessor bodies, expression - bodies, non-constant initializers outside partial types, comments, or whitespace changed. + bodies, non-constant initializers outside partial types, comments, or whitespace changed. Adding + or removing a `using` directive counts as a declaration change, because it can change what every + name in the file resolves to. The changed files are checked by a worker built once per Editor toolchain with the Editor's own dotnet and Roslyn. It reports their compiler and analyzer diagnostics, with `DiagnosticSuppressor`s @@ -155,8 +157,9 @@ Otherwise the assembly is compiled in full as before, and `FastPathFallbacks` sa - `no baseline: no full compile of this assembly is on record` (none ran yet, a later run skipped the assembly, which removes its result and baseline, or an input changed while the last full compile ran, so it left no record; `FastPathBaselineNotes` of that run says which) -- `no baseline: the last full compile had errors` (a full compile that fails drops the baseline an - earlier one left) +- `no baseline: the last full compile had errors; the first full compile without errors records one` + (a full compile that fails drops the baseline an earlier one left, and the assembly is + compiled in full until a compile succeeds) - `no baseline: the last full compile did not record one` (`FastPathBaselineNotes` of that run says why, or the assembly was compiled only because a reference changed) - `no baseline: it was recorded by another version of compile-check` @@ -205,7 +208,8 @@ What the check does not guarantee, so run `--full` before finishing: assembly, and the rules of analyzers that use Compilation, CompilationEnd, SemanticModel, or SymbolStart actions. `FastChecks[].UncheckedCodes` lists the IDs it could not run, leaving out the ones the response file already turns off (with `-nowarn` or a ruleset) and analyzer rules - that are disabled, so an assembly may list fewer than these four. + that are disabled, so an assembly may list fewer than these four. In a project that treats + warnings as errors (`-warnaserror`), these are errors that only `--full` reports. - Errors the compiler reports only while emitting, such as CS8178 and CS0656. - Source generators. The check reads the generator output saved by the last full compile and does not run them again; `FastChecks[].GeneratorsNotRerun` says so. From 6e309c9c725b4027d0974cacfedd20007ce74a94 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 15:18:57 +0900 Subject: [PATCH 18/33] feat: compile-check writes what each run did to the CLI VibeLog (#3333) --- .../internal/dispatcher/compile_check.go | 5 + .../dispatcher/compile_check_vibe_log.go | 155 ++++++++++++++ .../dispatcher/compile_check_vibe_log_test.go | 196 ++++++++++++++++++ docs/compile-check.md | 5 + docs/vibe-logs.md | 20 +- 5 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go create mode 100644 cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go diff --git a/cli/dispatcher/internal/dispatcher/compile_check.go b/cli/dispatcher/internal/dispatcher/compile_check.go index 9b9b791da6..ee88cd659b 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check.go +++ b/cli/dispatcher/internal/dispatcher/compile_check.go @@ -138,9 +138,11 @@ func runCompileCheck( stderr, err, clierrors.ErrorContext{Command: clicore.CompileCheckCommandName}) return 1 } + logRun := startCompileCheckVibeLog(projectRoot, options) editorPath, err := resolveCompileCheckEditorPath(projectRoot, options) if err != nil { + logRun.failed(err) clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ Command: clicore.CompileCheckCommandName, ProjectRoot: projectRoot, }) @@ -149,6 +151,7 @@ func runCompileCheck( result, err := compilecheck.Run(ctx, newCompileCheckRunOptions(options, projectRoot, editorPath)) if err != nil { + logRun.failed(err) clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ Command: clicore.CompileCheckCommandName, ProjectRoot: projectRoot, }) @@ -158,11 +161,13 @@ func runCompileCheck( response := buildCompileCheckResponse(result, projectRoot) encoded, err := json.Marshal(response) if err != nil { + logRun.failed(err) clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ Command: clicore.CompileCheckCommandName, ProjectRoot: projectRoot, }) return 1 } + logRun.completed(response, result) clicore.WriteJSON(stdout, encoded) if response.ErrorCount > 0 { diff --git a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go new file mode 100644 index 0000000000..df2eb49ee9 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go @@ -0,0 +1,155 @@ +package dispatcher + +import ( + "time" + + "github.com/hatayama/unity-cli-loop/common/clicore" + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/vibelog" + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck" +) + +const ( + compileCheckVibeStartOperation = "cli_compile_check_start" + compileCheckVibeCompleteOperation = "cli_compile_check_complete" +) + +// compileCheckVibeLog writes what one compile-check run did to the CLI VibeLog: a start entry, and a +// complete entry under the same correlation id on every way out of the run. +// Why compile-check needs its own entries: it never talks to the Editor, so nothing on the Editor +// side records which runs compiled, which were checked on their changed files, and why. +type compileCheckVibeLog struct { + enabled bool + projectRoot string + correlationID string + startedAt time.Time +} + +// startCompileCheckVibeLog writes the start entry of a run and returns what writes its complete +// entry. With ULOOP_DEBUG unset it writes nothing and builds no context. +func startCompileCheckVibeLog(projectRoot string, options compileCheckOptions) compileCheckVibeLog { + if !vibelog.IsCLIVibeLogEnabled() { + return compileCheckVibeLog{} + } + + logRun := compileCheckVibeLog{ + enabled: true, + projectRoot: projectRoot, + correlationID: vibelog.NewCLIVibeCorrelationID(), + startedAt: time.Now(), + } + logRun.write("INFO", compileCheckVibeStartOperation, "Starting compile-check.", map[string]any{ + "command": clicore.CompileCheckCommandName, + "all": options.all, + "full": options.full, + "jobs": options.jobs, + "project_identity": vibelog.ProjectIdentity(projectRoot), + }) + + return logRun +} + +// failed writes the complete entry of a run that stopped before it had a response to print. +func (logRun compileCheckVibeLog) failed(err error) { + if !logRun.enabled { + return + } + + context := logRun.completeContext("failed") + context["error"] = clicore.ErrorMessage(err) + context["error_code"] = clierrors.ClassifyError(err, clierrors.ErrorContext{ + Command: clicore.CompileCheckCommandName, ProjectRoot: logRun.projectRoot, + }).ErrorCode + logRun.write("ERROR", compileCheckVibeCompleteOperation, "compile-check failed.", context) +} + +// completed writes the complete entry of a run that produced a response, with the response's own +// lists so the log and the printed JSON can be read side by side. +func (logRun compileCheckVibeLog) completed(response compileCheckResponse, result compilecheck.Result) { + if !logRun.enabled { + return + } + + level, outcome := "INFO", "succeeded" + if response.ErrorCount > 0 { + level, outcome = "WARNING", "errors" + } + context := logRun.completeContext(outcome) + context["error_count"] = response.ErrorCount + context["warning_count"] = response.WarningCount + context["compiled"] = response.CompiledAssemblies + context["reused"] = response.ReusedAssemblies + context["fast_checked"] = response.FastCheckedAssemblies + context["blocked"] = response.BlockedAssemblies + context["skipped_count"] = response.SkippedAssemblies + context["fast_checks"] = compileCheckVibeFastChecks(response.FastChecks) + context["fallbacks"] = compileCheckVibeReasons(response.FastPathFallbacks) + context["baseline_notes"] = compileCheckVibeReasons(response.FastPathBaselineNotes) + context["units"] = compileCheckVibeUnits(result.Units) + logRun.write(level, compileCheckVibeCompleteOperation, "compile-check finished.", context) +} + +// completeContext holds the fields every complete entry carries. +func (logRun compileCheckVibeLog) completeContext(outcome string) map[string]any { + return map[string]any{ + "command": clicore.CompileCheckCommandName, + "outcome": outcome, + "duration_ms": time.Since(logRun.startedAt).Milliseconds(), + } +} + +// write appends one entry of this run. +// Why a failed write is ignored: the log is a diagnostic aid, and a run must not fail for it. +func (logRun compileCheckVibeLog) write(level string, operation string, message string, context map[string]any) { + _ = vibelog.WriteCLIVibeLog(logRun.projectRoot, vibelog.CLIVibeLogEntry{ + Level: level, + Operation: operation, + Message: message, + Context: context, + CorrelationID: logRun.correlationID, + }) +} + +// compileCheckVibeFastChecks lists each fast check by assembly, whether it was replayed, and how +// many files it covered. +func compileCheckVibeFastChecks(checks []compileCheckFastCheck) []map[string]any { + entries := make([]map[string]any, 0, len(checks)) + for _, check := range checks { + entries = append(entries, map[string]any{ + "assembly": check.Assembly, + "reused": check.Reused, + "changed_file_count": len(check.ChangedFiles), + }) + } + + return entries +} + +// compileCheckVibeReasons lists one reason per assembly. +func compileCheckVibeReasons(reasons []compileCheckFastPathFallback) []map[string]any { + entries := make([]map[string]any, 0, len(reasons)) + for _, reason := range reasons { + entries = append(entries, map[string]any{"assembly": reason.Assembly, "reason": reason.Reason}) + } + + return entries +} + +// compileCheckVibeUnits lists how long each unit this run compiled or checked took. +// Why replayed units are left out: they took no work, and their durations would only hide which +// units the run spent its time on. +func compileCheckVibeUnits(units []compilecheck.UnitResult) []map[string]any { + entries := make([]map[string]any, 0, len(units)) + for _, unit := range units { + if unit.Reused || unit.FastCheckReused { + continue + } + entries = append(entries, map[string]any{ + "assembly": unit.Assembly, + "duration_ms": unit.Duration.Milliseconds(), + "succeeded": unit.Succeeded, + }) + } + + return entries +} diff --git a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go new file mode 100644 index 0000000000..7c96caeb8e --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go @@ -0,0 +1,196 @@ +package dispatcher + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/vibelog" + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck" +) + +// readCompileCheckVibeLog reads every entry today's CLI VibeLog of the project holds, in order. +func readCompileCheckVibeLog(t *testing.T, projectRoot string) []vibelog.CLIVibeLogEntry { + t.Helper() + path := filepath.Join(projectRoot, vibelog.CLIVibeLogDirectory, + vibelog.CLIVibeLogPrefix+"_"+time.Now().UTC().Format("20060102")+".json") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read the CLI VibeLog: %v", err) + } + + entries := []vibelog.CLIVibeLogEntry{} + scanner := bufio.NewScanner(bytes.NewReader(content)) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + var entry vibelog.CLIVibeLogEntry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + t.Fatalf("failed to parse a CLI VibeLog line %q: %v", scanner.Text(), err) + } + entries = append(entries, entry) + } + + return entries +} + +// assertCompileCheckVibeRun checks the log holds exactly one start and one complete of the same run, +// and returns the complete entry. +func assertCompileCheckVibeRun(t *testing.T, entries []vibelog.CLIVibeLogEntry) vibelog.CLIVibeLogEntry { + t.Helper() + if len(entries) != 2 { + t.Fatalf("expected a start and a complete entry, got %+v", entries) + } + start, complete := entries[0], entries[1] + if start.Operation != "cli_compile_check_start" || complete.Operation != "cli_compile_check_complete" { + t.Fatalf("operations = %q, %q", start.Operation, complete.Operation) + } + if start.CorrelationID == "" || start.CorrelationID != complete.CorrelationID { + t.Errorf("correlation ids = %q, %q, want one shared id", start.CorrelationID, complete.CorrelationID) + } + + return complete +} + +// assertVibeContextJSON checks one context field serializes to the given JSON. +func assertVibeContextJSON(t *testing.T, entry vibelog.CLIVibeLogEntry, key string, want string) { + t.Helper() + encoded, err := json.Marshal(entry.Context[key]) + if err != nil { + t.Fatalf("failed to encode %s: %v", key, err) + } + if string(encoded) != want { + t.Errorf("%s = %s, want %s", key, encoded, want) + } +} + +// loggedCompileCheckResult is a run that compiled, fast-checked, replayed a fast check of, reused, +// and blocked one assembly each, so a field the log drops or mixes up shows. +func loggedCompileCheckResult() compilecheck.Result { + return compilecheck.Result{ + DagDir: "Library/Bee/artifacts/1300b0aE.dag", + Skipped: 3, + Blocked: []string{"Delta"}, + Units: []compilecheck.UnitResult{ + { + Assembly: "Alpha", Succeeded: true, Duration: 1500 * time.Millisecond, + FastPathFallbackReason: "no baseline: no full compile of this assembly is on record", + FastPathNote: "baseline not recorded: source changed during the compile: Assets/A.cs", + }, + { + Assembly: "Beta", Succeeded: true, Duration: 250 * time.Millisecond, FastChecked: true, + FastCheckChangedFiles: []string{"Assets/B1.cs", "Assets/B2.cs"}, + }, + { + Assembly: "Epsilon", Succeeded: true, FastChecked: true, FastCheckReused: true, + FastCheckChangedFiles: []string{"Assets/E.cs"}, + }, + {Assembly: "Gamma", Succeeded: true, Reused: true}, + }, + } +} + +// Verifies a run writes a start and a complete entry with one correlation id, and the complete +// entry carries what the response reported and how long each compiled or checked unit took. +func TestCompileCheckVibeLogRecordsASucceededRun(t *testing.T) { + t.Setenv(vibelog.CLIVibeLogEnvName, "1") + projectRoot := t.TempDir() + result := loggedCompileCheckResult() + + logRun := startCompileCheckVibeLog(projectRoot, compileCheckOptions{all: true, jobs: 4}) + logRun.completed(buildCompileCheckResponse(result, projectRoot), result) + + entries := readCompileCheckVibeLog(t, projectRoot) + complete := assertCompileCheckVibeRun(t, entries) + assertVibeContextJSON(t, entries[0], "all", "true") + assertVibeContextJSON(t, entries[0], "full", "false") + assertVibeContextJSON(t, entries[0], "jobs", "4") + assertVibeContextJSON(t, entries[0], "project_identity", `"`+vibelog.ProjectIdentity(projectRoot)+`"`) + if complete.Level != "INFO" { + t.Errorf("level = %q, want INFO", complete.Level) + } + assertVibeContextJSON(t, complete, "outcome", `"succeeded"`) + assertVibeContextJSON(t, complete, "error_count", "0") + assertVibeContextJSON(t, complete, "compiled", `["Alpha"]`) + assertVibeContextJSON(t, complete, "fast_checked", `["Beta","Epsilon"]`) + assertVibeContextJSON(t, complete, "reused", `["Gamma"]`) + assertVibeContextJSON(t, complete, "blocked", `["Delta"]`) + assertVibeContextJSON(t, complete, "skipped_count", "3") + assertVibeContextJSON(t, complete, "fast_checks", `[{"assembly":"Beta","changed_file_count":2,"reused":false},`+ + `{"assembly":"Epsilon","changed_file_count":1,"reused":true}]`) + assertVibeContextJSON(t, complete, "fallbacks", + `[{"assembly":"Alpha","reason":"no baseline: no full compile of this assembly is on record"}]`) + assertVibeContextJSON(t, complete, "baseline_notes", + `[{"assembly":"Alpha","reason":"baseline not recorded: source changed during the compile: Assets/A.cs"}]`) + assertVibeContextJSON(t, complete, "units", + `[{"assembly":"Alpha","duration_ms":1500,"succeeded":true},{"assembly":"Beta","duration_ms":250,"succeeded":true}]`) + if _, found := complete.Context["duration_ms"]; !found { + t.Error("expected the run's duration to be logged") + } +} + +// Verifies a run that found compile errors is logged as a warning with the errors counted. +func TestCompileCheckVibeLogRecordsARunWithErrors(t *testing.T) { + t.Setenv(vibelog.CLIVibeLogEnvName, "1") + projectRoot := t.TempDir() + result := compilecheck.Result{Units: []compilecheck.UnitResult{{ + Assembly: "Alpha", Diagnostics: []compilecheck.Diagnostic{ + {Severity: "error", Code: "CS0103", Message: "missing", File: "Assets/A.cs", Line: 1, Column: 1}, + {Severity: "error", Code: "CS0029", Message: "cannot convert", File: "Assets/A.cs", Line: 2, Column: 1}, + }, + }}} + + logRun := startCompileCheckVibeLog(projectRoot, compileCheckOptions{}) + logRun.completed(buildCompileCheckResponse(result, projectRoot), result) + + complete := assertCompileCheckVibeRun(t, readCompileCheckVibeLog(t, projectRoot)) + if complete.Level != "WARNING" { + t.Errorf("level = %q, want WARNING", complete.Level) + } + assertVibeContextJSON(t, complete, "outcome", `"errors"`) + assertVibeContextJSON(t, complete, "error_count", "2") +} + +// Verifies a run that could not finish is logged as an error with what stopped it. +func TestCompileCheckVibeLogRecordsAFailedRun(t *testing.T) { + t.Setenv(vibelog.CLIVibeLogEnvName, "1") + projectRoot := t.TempDir() + + logRun := startCompileCheckVibeLog(projectRoot, compileCheckOptions{}) + logRun.failed(errors.New("no Bee build found")) + + complete := assertCompileCheckVibeRun(t, readCompileCheckVibeLog(t, projectRoot)) + if complete.Level != "ERROR" { + t.Errorf("level = %q, want ERROR", complete.Level) + } + assertVibeContextJSON(t, complete, "outcome", `"failed"`) + assertVibeContextJSON(t, complete, "error", `"no Bee build found"`) + wantCode := clierrors.ClassifyError(errors.New("no Bee build found"), clierrors.ErrorContext{}).ErrorCode + assertVibeContextJSON(t, complete, "error_code", `"`+wantCode+`"`) +} + +// Verifies runCompileCheck logs the run that stopped because the project's Editor is not +// installed, as a start and a failed complete of the same run. +func TestCompileCheckVibeLogRecordsAnEditorResolutionFailure(t *testing.T) { + t.Setenv(vibelog.CLIVibeLogEnvName, "1") + isolateCompileCheckEditorLookup(t) + projectRoot := createDispatcherUnityProject(t) + writeDispatcherTestFile(t, filepath.Join(projectRoot, projectVersionFilePath), + "m_EditorVersion: "+bogusCompileCheckEditorVersion+"\n") + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runCompileCheck(context.Background(), compileCheckOptions{projectPath: projectRoot}, projectRoot, &stdout, &stderr) + + if code != 1 { + t.Fatalf("code = %d, want 1", code) + } + complete := assertCompileCheckVibeRun(t, readCompileCheckVibeLog(t, projectRoot)) + assertVibeContextJSON(t, complete, "outcome", `"failed"`) +} diff --git a/docs/compile-check.md b/docs/compile-check.md index 31e77675e8..49bcc733d9 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -260,6 +260,11 @@ gives the `Assembly` and `Reason` of each successful full compile that left no b process exits 1 when `ErrorCount` is greater than zero. +With the `ULOOP_DEBUG` environment variable set, each run also writes two entries to the CLI +VibeLog of the project, `cli_compile_check_start` and `cli_compile_check_complete`, with which +assemblies it compiled, reused, checked on their changed files, or blocked, why each fallback +happened, and how long each assembly took. `docs/vibe-logs.md` lists their fields. + ## Limitations - **It replays the last Unity build, and only some staleness is detectable.** Four `.asmdef` diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 2cd8135638..6f34ec1775 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -222,8 +222,9 @@ command ran against: - A missing line is evidence only when the define was set and the code path logs at all. Coverage is per call site, not per command: for example, `execute-dynamic-code`, `simulate-keyboard`, `screenshot`, compile, domain reload, server binding, and hot reload log - start and completion, while the CLI side logs compile requests, window focus, and the tool - requests described under "CLI entries for tool commands" below. + start and completion, while the CLI side logs compile requests, window focus, the tool + requests described under "CLI entries for tool commands" below, and `compile-check` runs + ("CLI entries for compile-check"). Check that the operation you expect is logged somewhere (`git grep` the operation name) before reading its absence as "it never ran". @@ -270,6 +271,21 @@ or `control-play-mode` that waits for a domain reload or a Play Mode change, and - The `cli_tool_*` entries of one command share one `correlation_id`, and the two `cli_hot_reload_*` entries share another. Pair the two groups by time. +## CLI entries for compile-check + +`compile-check` never talks to the Editor, so these two entries are the only record of what a run +did. Both carry the same `correlation_id`. A run that cannot find its project writes neither, +because there is no project to write into. + +| `operation` | Written | `context` | +|---|---|---| +| `cli_compile_check_start` | once the project is found | `command`, `all`, `full`, `jobs`, `project_identity` | +| `cli_compile_check_complete` (`INFO`; `WARNING` when the run found compile errors; `ERROR` when it could not finish) | on every way out of a run that wrote the start entry | `command`, `outcome` (`succeeded`, `errors`, or `failed`), `duration_ms`; for `failed`, `error` and `error_code`; otherwise `error_count`, `warning_count`, `compiled`, `reused`, `fast_checked`, `blocked` (assembly names, as in the response), `skipped_count`, `fast_checks` (`assembly`, `reused`, `changed_file_count`), `fallbacks` and `baseline_notes` (`assembly`, `reason`), and `units` (`assembly`, `duration_ms`, `succeeded` of each assembly the run compiled or checked; replayed ones are left out) | + +- Unlike the tool-command entries, the complete entry holds the run's error message and fallback + reasons: they can name assemblies and files of the project, which the command's own output prints + as well. + ## How to use them in an investigation 1. Find the time window from the report or the command output, then open the matching From 02fd830058c91ced23b7b8e686c8cc734353bc5b Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 16:04:15 +0900 Subject: [PATCH 19/33] fix: compile-check's summary says 1 assembly and 1 error (#3334) --- .../CliOnlyTools~/CompileCheck/Skill/SKILL.md | 11 +- .../internal/dispatcher/compile_check.go | 63 ----------- .../compile_check_fast_path_test.go | 6 +- .../dispatcher/compile_check_message.go | 84 ++++++++++++++ .../dispatcher/compile_check_message_test.go | 107 ++++++++++++++++++ .../internal/dispatcher/compile_check_test.go | 8 +- docs/compile-check.md | 8 +- 7 files changed, 210 insertions(+), 77 deletions(-) create mode 100644 cli/dispatcher/internal/dispatcher/compile_check_message.go create mode 100644 cli/dispatcher/internal/dispatcher/compile_check_message_test.go diff --git a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md index 5652dd0742..8f3df62b04 100644 --- a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md @@ -58,9 +58,10 @@ declaration is checked on the changed files only, in a few seconds. Adding or re directive counts as a declaration change. Such a check skips a few assembly-wide rules, so run once with `--full` before finishing. When the project treats warnings as errors, a rule listed in `UncheckedCodes`, such as CS0414, can fail that `--full` run only. The first body edit after a Unity -build, after a `--full` that had errors, after a run that skipped the assembly, or after -`--all` in an assembly that references another of the project is compiled in full and records what -the next check compares against; `FastPathFallbacks` says why. +build, after a full compile that had errors (a `--full` run or a run that fell back to one), after +a run that compiled it only because an assembly it references changed, after a run that skipped the +assembly, or after `--all` in an assembly that references another of the project is compiled in +full and records what the next check compares against; `FastPathFallbacks` says why. ## Parameters @@ -92,7 +93,9 @@ A JSON payload: - `ProjectRoot`: resolved project root - `Message`: one-line summary -The process exits 1 when `ErrorCount` is greater than zero. +The process exits 1 when `ErrorCount` is greater than zero. A failure of the command itself, such +as `COMPILE_CHECK_UNITY_BUILD_REQUIRED`, also exits 1 but prints no payload: the error, with its +`ErrorCode`, goes to stderr. Only one compile-check runs per project at a time. A second one waits for the first, up to five minutes, then fails with `COMPILE_CHECK_PROJECT_BUSY`: wait for the other run to finish and run diff --git a/cli/dispatcher/internal/dispatcher/compile_check.go b/cli/dispatcher/internal/dispatcher/compile_check.go index ee88cd659b..42cf724abb 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check.go +++ b/cli/dispatcher/internal/dispatcher/compile_check.go @@ -3,7 +3,6 @@ package dispatcher import ( "context" "encoding/json" - "fmt" "io" "path/filepath" "strconv" @@ -306,68 +305,6 @@ func newCompileCheckIssue(assembly string, diagnostic compilecheck.Diagnostic) c } } -// compileCheckMessage states what the run did in one line. -// Why the blocked assemblies are added rather than folded in: without them a run that reports one -// error and hundreds of assemblies it never got to reads as a run that lost them. -// Why the fast-checked ones get a reminder: the check skipped rules only a full compile runs, and -// the caller has to know to run --full before finishing. -func compileCheckMessage(response compileCheckResponse) string { - message := compileCheckRunSummary(response) - if len(response.BlockedAssemblies) > 0 { - message += fmt.Sprintf( - " %d assemblies were not compiled because an assembly they reference has errors.", - len(response.BlockedAssemblies)) - } - if len(response.FastCheckedAssemblies) > 0 { - message += fmt.Sprintf( - " %d assemblies were checked on their changed files only; run with --full before finishing to cover assembly-wide rules.", - len(response.FastCheckedAssemblies)) - } - - return message -} - -// compileCheckRunSummary states what the run compiled, reused, and fast-checked. -// Why only the non-zero counts are named: a run that reused nothing reads exactly as it did before -// reuse existed, and a run that compiled nothing should not open by saying so. -func compileCheckRunSummary(response compileCheckResponse) string { - counts := []struct { - verb string - count int - }{ - {"compiled", len(response.CompiledAssemblies)}, - {"reused", len(response.ReusedAssemblies)}, - {"fast-checked", len(response.FastCheckedAssemblies)}, - } - parts := []string{} - for _, entry := range counts { - if entry.count == 0 { - continue - } - if len(parts) == 0 { - parts = append(parts, fmt.Sprintf("%s%s %d assemblies", - strings.ToUpper(entry.verb[:1]), entry.verb[1:], entry.count)) - continue - } - parts = append(parts, fmt.Sprintf("%s %d", entry.verb, entry.count)) - } - if len(parts) == 0 { - return compileCheckNoChangeMessage - } - - return fmt.Sprintf("%s with %d errors and %d warnings.", - joinCompileCheckSummaryParts(parts), response.ErrorCount, response.WarningCount) -} - -// joinCompileCheckSummaryParts joins the summary parts as "A and b" or "A, b and c". -func joinCompileCheckSummaryParts(parts []string) string { - if len(parts) == 1 { - return parts[0] - } - - return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1] -} - // printCompileCheckHelp documents the command and the build it replays. func printCompileCheckHelp(stdout io.Writer) { clicore.WriteLine(stdout, "Usage:") diff --git a/cli/dispatcher/internal/dispatcher/compile_check_fast_path_test.go b/cli/dispatcher/internal/dispatcher/compile_check_fast_path_test.go index c68e0f09c2..9250c051b3 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_fast_path_test.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_fast_path_test.go @@ -84,8 +84,8 @@ func TestResponseListsFallbackReasons(t *testing.T) { func TestMessageMentionsFastCheckedAssemblies(t *testing.T) { response := buildCompileCheckResponse(fastCheckedResult(), "/projects/sample") - want := "Compiled 1 assemblies, reused 1 and fast-checked 1 with 0 errors and 0 warnings." + - " 1 assemblies were checked on their changed files only; run with --full before finishing to cover assembly-wide rules." + want := "Compiled 1 assembly, reused 1 and fast-checked 1 with 0 errors and 0 warnings." + + " 1 assembly was checked on its changed files only; run with --full before finishing to cover assembly-wide rules." if response.Message != want { t.Errorf("message = %q, want %q", response.Message, want) } @@ -102,7 +102,7 @@ func TestFastCheckOnlyRunWithErrorDoesNotSayNoAssembliesChanged(t *testing.T) { }}, }, "/projects/sample") - if strings.Contains(response.Message, compileCheckNoChangeMessage) || !strings.HasPrefix(response.Message, "Fast-checked 1 assemblies with 1 errors and 0 warnings.") { + if strings.Contains(response.Message, compileCheckNoChangeMessage) || !strings.HasPrefix(response.Message, "Fast-checked 1 assembly with 1 error and 0 warnings.") { t.Errorf("unexpected message %q", response.Message) } if response.ErrorCount != 1 || response.Success { diff --git a/cli/dispatcher/internal/dispatcher/compile_check_message.go b/cli/dispatcher/internal/dispatcher/compile_check_message.go new file mode 100644 index 0000000000..29906468f8 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/compile_check_message.go @@ -0,0 +1,84 @@ +package dispatcher + +import ( + "fmt" + "strings" +) + +// compileCheckMessage states what the run did in one line. +// Why the blocked assemblies are added rather than folded in: without them a run that reports one +// error and hundreds of assemblies it never got to reads as a run that lost them. +// Why the fast-checked ones get a reminder: the check skipped rules only a full compile runs, and +// the caller has to know to run --full before finishing. +func compileCheckMessage(response compileCheckResponse) string { + message := compileCheckRunSummary(response) + if len(response.BlockedAssemblies) > 0 { + count := len(response.BlockedAssemblies) + message += fmt.Sprintf(compileCheckPlural(count, + " %d assembly was not compiled because an assembly it references has errors.", + " %d assemblies were not compiled because an assembly they reference has errors."), count) + } + if len(response.FastCheckedAssemblies) > 0 { + count := len(response.FastCheckedAssemblies) + message += fmt.Sprintf(compileCheckPlural(count, + " %d assembly was checked on its changed files only;", + " %d assemblies were checked on their changed files only;"), count) + + " run with --full before finishing to cover assembly-wide rules." + } + + return message +} + +// compileCheckRunSummary states what the run compiled, reused, and fast-checked. +// Why only the non-zero counts are named: a run that reused nothing reads exactly as it did before +// reuse existed, and a run that compiled nothing should not open by saying so. +func compileCheckRunSummary(response compileCheckResponse) string { + counts := []struct { + verb string + count int + }{ + {"compiled", len(response.CompiledAssemblies)}, + {"reused", len(response.ReusedAssemblies)}, + {"fast-checked", len(response.FastCheckedAssemblies)}, + } + parts := []string{} + for _, entry := range counts { + if entry.count == 0 { + continue + } + if len(parts) == 0 { + parts = append(parts, fmt.Sprintf("%s%s %d %s", + strings.ToUpper(entry.verb[:1]), entry.verb[1:], entry.count, + compileCheckPlural(entry.count, "assembly", "assemblies"))) + continue + } + parts = append(parts, fmt.Sprintf("%s %d", entry.verb, entry.count)) + } + if len(parts) == 0 { + return compileCheckNoChangeMessage + } + + return fmt.Sprintf("%s with %d %s and %d %s.", + joinCompileCheckSummaryParts(parts), + response.ErrorCount, compileCheckPlural(response.ErrorCount, "error", "errors"), + response.WarningCount, compileCheckPlural(response.WarningCount, "warning", "warnings")) +} + +// compileCheckPlural picks the wording for count, which is singular only for exactly one; zero +// takes the plural ("0 errors"). +func compileCheckPlural(count int, singular string, plural string) string { + if count == 1 { + return singular + } + + return plural +} + +// joinCompileCheckSummaryParts joins the summary parts as "A and b" or "A, b and c". +func joinCompileCheckSummaryParts(parts []string) string { + if len(parts) == 1 { + return parts[0] + } + + return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1] +} diff --git a/cli/dispatcher/internal/dispatcher/compile_check_message_test.go b/cli/dispatcher/internal/dispatcher/compile_check_message_test.go new file mode 100644 index 0000000000..3569cfca0b --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/compile_check_message_test.go @@ -0,0 +1,107 @@ +package dispatcher + +import ( + "strconv" + "testing" +) + +const compileCheckFastCheckedReminder = "run with --full before finishing to cover assembly-wide rules." + +// compileCheckMessageNames returns count distinct assembly names, since the summary counts names. +func compileCheckMessageNames(count int) []string { + names := []string{} + for index := range count { + names = append(names, "Assembly"+strconv.Itoa(index)) + } + + return names +} + +// Verifies that every count the summary puts before a noun uses the singular for exactly one and +// the plural for zero and for more than one. +func TestCompileCheckMessageUsesTheSingularOnlyForACountOfOne(t *testing.T) { + cases := []struct { + name string + response compileCheckResponse + want string + }{ + { + name: "no compiled assembly leaves the first noun to the next count", + response: compileCheckResponse{ReusedAssemblies: compileCheckMessageNames(2)}, + want: "Reused 2 assemblies with 0 errors and 0 warnings.", + }, + { + name: "one compiled assembly", + response: compileCheckResponse{CompiledAssemblies: compileCheckMessageNames(1)}, + want: "Compiled 1 assembly with 0 errors and 0 warnings.", + }, + { + name: "two compiled assemblies", + response: compileCheckResponse{CompiledAssemblies: compileCheckMessageNames(2)}, + want: "Compiled 2 assemblies with 0 errors and 0 warnings.", + }, + { + name: "one error", + response: compileCheckResponse{CompiledAssemblies: compileCheckMessageNames(2), ErrorCount: 1}, + want: "Compiled 2 assemblies with 1 error and 0 warnings.", + }, + { + name: "two errors", + response: compileCheckResponse{CompiledAssemblies: compileCheckMessageNames(2), ErrorCount: 2}, + want: "Compiled 2 assemblies with 2 errors and 0 warnings.", + }, + { + name: "one warning", + response: compileCheckResponse{CompiledAssemblies: compileCheckMessageNames(2), WarningCount: 1}, + want: "Compiled 2 assemblies with 0 errors and 1 warning.", + }, + { + name: "two warnings", + response: compileCheckResponse{CompiledAssemblies: compileCheckMessageNames(2), WarningCount: 2}, + want: "Compiled 2 assemblies with 0 errors and 2 warnings.", + }, + { + name: "one blocked assembly", + response: compileCheckResponse{ + CompiledAssemblies: compileCheckMessageNames(2), + BlockedAssemblies: compileCheckMessageNames(1), + }, + want: "Compiled 2 assemblies with 0 errors and 0 warnings." + + " 1 assembly was not compiled because an assembly it references has errors.", + }, + { + name: "two blocked assemblies", + response: compileCheckResponse{ + CompiledAssemblies: compileCheckMessageNames(2), + BlockedAssemblies: compileCheckMessageNames(2), + }, + want: "Compiled 2 assemblies with 0 errors and 0 warnings." + + " 2 assemblies were not compiled because an assembly they reference has errors.", + }, + { + name: "one fast-checked assembly", + response: compileCheckResponse{ + CompiledAssemblies: compileCheckMessageNames(2), + FastCheckedAssemblies: compileCheckMessageNames(1), + }, + want: "Compiled 2 assemblies and fast-checked 1 with 0 errors and 0 warnings." + + " 1 assembly was checked on its changed files only; " + compileCheckFastCheckedReminder, + }, + { + name: "two fast-checked assemblies", + response: compileCheckResponse{ + CompiledAssemblies: compileCheckMessageNames(2), + FastCheckedAssemblies: compileCheckMessageNames(2), + }, + want: "Compiled 2 assemblies and fast-checked 2 with 0 errors and 0 warnings." + + " 2 assemblies were checked on their changed files only; " + compileCheckFastCheckedReminder, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := compileCheckMessage(testCase.response); got != testCase.want { + t.Fatalf("unexpected summary\n got: %q\nwant: %q", got, testCase.want) + } + }) + } +} diff --git a/cli/dispatcher/internal/dispatcher/compile_check_test.go b/cli/dispatcher/internal/dispatcher/compile_check_test.go index 118cfb275c..c844f39b82 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_test.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_test.go @@ -225,7 +225,7 @@ func TestBuildCompileCheckResponseCountsWarningsOnASuccessfulRun(t *testing.T) { if !response.Success || response.WarningCount != 1 { t.Fatalf("expected a clean run with one warning, got %+v", response) } - if response.Message != "Compiled 1 assemblies with 0 errors and 1 warnings." { + if response.Message != "Compiled 1 assembly with 0 errors and 1 warning." { t.Fatalf("the summary should report the warning count, got %q", response.Message) } } @@ -285,7 +285,7 @@ func TestBuildCompileCheckResponseSummarizesCompiledAndReusedTogether(t *testing }, }, "/projects/sample") - if response.Message != "Compiled 1 assemblies and reused 2 with 0 errors and 0 warnings." { + if response.Message != "Compiled 1 assembly and reused 2 with 0 errors and 0 warnings." { t.Fatalf("unexpected summary, got %q", response.Message) } } @@ -312,7 +312,7 @@ func TestBuildCompileCheckResponseKeepsTheCompiledOnlySummary(t *testing.T) { Units: []compilecheck.UnitResult{{Assembly: "A", Succeeded: true}}, }, "/projects/sample") - if response.Message != "Compiled 1 assemblies with 0 errors and 0 warnings." { + if response.Message != "Compiled 1 assembly with 0 errors and 0 warnings." { t.Fatalf("unexpected summary, got %q", response.Message) } } @@ -378,7 +378,7 @@ func TestBuildCompileCheckResponseSaysWhyBlockedAssembliesWereNotCompiled(t *tes Blocked: []string{"B", "C"}, }, "/projects/sample") - want := "Compiled 1 assemblies with 1 errors and 0 warnings." + + want := "Compiled 1 assembly with 1 error and 0 warnings." + " 2 assemblies were not compiled because an assembly they reference has errors." if response.Message != want { t.Fatalf("unexpected summary, got %q", response.Message) diff --git a/docs/compile-check.md b/docs/compile-check.md index 49bcc733d9..ac9d11d999 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -199,8 +199,9 @@ So the first body edit is compiled in full more often than it looks: references has errors keeps them: the next run checks them against the inputs and output hashes they were recorded with before using them, so a reference that comes back the same leaves the assembly reused or checked on its changed files, and one that comes back changed compiles it. -- `--full` while the assembly still has errors drops its baseline, so the run after the fix is - compiled in full. Running `--full` once the errors are fixed keeps it. +- A full compile while the assembly still has errors, whether a `--full` run or a run that fell + back to one, drops its baseline, so the run after the fix is compiled in full. Running `--full` + once the errors are fixed keeps it. What the check does not guarantee, so run `--full` before finishing: @@ -258,7 +259,8 @@ check of the same changed files instead of checking them again), and `FastPathFa `Reason` of each assembly the check was tried on and compiled in full. `FastPathBaselineNotes` gives the `Assembly` and `Reason` of each successful full compile that left no baseline. The process exits 1 when -`ErrorCount` is greater than zero. +`ErrorCount` is greater than zero. A failure of the command itself also exits 1 but writes nothing to +stdout: stderr gets `{"Success": false, "Error": {...}}`, whose `ErrorCode` names the failure. With the `ULOOP_DEBUG` environment variable set, each run also writes two entries to the CLI VibeLog of the project, `cli_compile_check_start` and `cli_compile_check_complete`, with which From 9e491a5f05d947bf1a7b8f4b03d9916eef50eeea Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 19:12:32 +0900 Subject: [PATCH 20/33] fix: compile-check no longer recompiles dependents when a rebuilt assembly kept Unity's reference assembly (#3339) --- .../CliOnlyTools~/CompileCheck/Skill/SKILL.md | 4 +- .../internal/compilecheck/compiler.go | 27 +- .../internal/compilecheck/input_key.go | 17 +- .../internal/compilecheck/input_key_test.go | 2 +- cli/dispatcher/internal/compilecheck/plan.go | 5 + .../internal/compilecheck/scheduler.go | 64 +++- .../compilecheck/scheduler_surface_test.go | 306 ++++++++++++++++++ docs/compile-check.md | 27 +- 8 files changed, 410 insertions(+), 42 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/scheduler_surface_test.go diff --git a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md index 8f3df62b04..887d109e5d 100644 --- a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md @@ -60,8 +60,8 @@ with `--full` before finishing. When the project treats warnings as errors, a ru `UncheckedCodes`, such as CS0414, can fail that `--full` run only. The first body edit after a Unity build, after a full compile that had errors (a `--full` run or a run that fell back to one), after a run that compiled it only because an assembly it references changed, after a run that skipped the -assembly, or after `--all` in an assembly that references another of the project is compiled in -full and records what the next check compares against; `FastPathFallbacks` says why. +assembly, or after `--all` in an assembly that references one whose public surface differs from the last +Unity build is compiled in full and records what the next check compares against; `FastPathFallbacks` says why. ## Parameters diff --git a/cli/dispatcher/internal/compilecheck/compiler.go b/cli/dispatcher/internal/compilecheck/compiler.go index 0ff09958e6..a519ded479 100644 --- a/cli/dispatcher/internal/compilecheck/compiler.go +++ b/cli/dispatcher/internal/compilecheck/compiler.go @@ -90,6 +90,20 @@ type Compiler struct { Run CommandRunner // FastPath checks only the changed files when it can; nil compiles every unit in full. FastPath *fastPathRunner + // compareSurface stands in for the comparison of a unit's reference assembly with Unity's; nil + // compares the two files. Why it can be replaced: the tests have to see which units the run + // compares at all, and reading two files leaves nothing behind to count. + compareSurface func(projectRoot string, plan BuildPlan, unit CompileUnit) bool +} + +// keptSurface reports whether compiling this unit left its reference assembly describing the same +// public surface as the one the last Unity build produced. +func (c Compiler) keptSurface(plan BuildPlan, unit CompileUnit) bool { + if c.compareSurface != nil { + return c.compareSurface(c.ProjectRoot, plan, unit) + } + + return referenceSurfaceUnchanged(c.ProjectRoot, plan, unit) } // CompileUnit compiles one assembly and reports the diagnostics csc produced for it. @@ -284,7 +298,7 @@ func writeRewrittenResponseFile( } for _, reference := range rsp.References { lines = append(lines, - quoteFlag(referenceFlagPrefix, rewriteReference(projectRoot, plan, reference))) + quoteFlag(referenceFlagPrefix, rewriteReference(projectRoot, plan, unit, reference))) } for _, analyzer := range rsp.Analyzers { lines = append(lines, quoteFlag(analyzerFlagPrefix, analyzer)) @@ -318,13 +332,18 @@ func writeRewrittenResponseFile( return path, nil } -// rewriteReference points a reference at this run's own output when this run rebuilds that assembly. +// rewriteReference points a reference of the unit at this run's own output when this run rebuilt +// that assembly into a different public surface. // Why the others stay: an assembly this run skipped is unchanged, so Unity's own reference assembly // still describes it exactly. An assembly this run failed to compile wrote no reference assembly at // all, so it falls back to Unity's too rather than failing the dependent on a missing file. -func rewriteReference(projectRoot string, plan BuildPlan, reference string) string { +// Why a rebuilt one that kept its surface stays as well: the dependent then reads the same API from +// either file, and Unity's is the one an earlier run read whenever that assembly was not rebuilt, so +// staying on it keeps the dependent's key and its changed-files baseline where those runs left them. +// Pointing it at the output instead would recompile the dependent in full for nothing. +func rewriteReference(projectRoot string, plan BuildPlan, unit CompileUnit, reference string) string { name, ok := projectAssemblyReferenceName(reference, plan.DagDir) - if !ok || !planCompiles(plan, name) { + if !ok || !planCompiles(plan, name) || unit.KeptSurfaceReferences[name] { return reference } diff --git a/cli/dispatcher/internal/compilecheck/input_key.go b/cli/dispatcher/internal/compilecheck/input_key.go index 8bd8500a29..0ab2617a36 100644 --- a/cli/dispatcher/internal/compilecheck/input_key.go +++ b/cli/dispatcher/internal/compilecheck/input_key.go @@ -132,7 +132,7 @@ func keyParts( {keyPartNames[1], func(digest hash.Hash) error { return writeResponseFileIdentity(digest, unit.Assembly) }}, {keyPartNames[2], sourceWriter}, {keyPartNames[3], func(digest hash.Hash) error { - return writeReferenceIdentity(digest, projectRoot, plan, unit.Assembly.References) + return writeReferenceIdentity(digest, projectRoot, plan, unit) }}, {keyPartNames[4], func(digest hash.Hash) error { return writeAnalyzerIdentity(digest, projectRoot, unit.Assembly.Analyzers) @@ -234,15 +234,16 @@ func writeSourceNames(digest hash.Hash, sources []string) { // writeReferenceIdentity records the assemblies this one compiles against, resolved the way the // compile itself resolves them. -// Why the two rules: a reference this run rebuilds is read by content, because recompiling an -// assembly writes the same public surface with a new timestamp whenever the edit stayed inside a -// method body - the very case this cache exists for. Everything else is read by timestamp, which -// moves whenever Unity rebuilt it, and a compile that did not need it costs less than a missed error. +// Why the two rules: a reference this run rebuilt into a different surface is read by content, +// because a later rebuild can write the same bytes again with a new timestamp. Everything else - +// including a rebuilt reference that kept Unity's surface, which the compile reads from Unity's file - +// is read by timestamp, which moves whenever Unity rebuilt it, and a compile that did not need it +// costs less than a missed error. func writeReferenceIdentity( - digest hash.Hash, projectRoot string, plan BuildPlan, references []string, + digest hash.Hash, projectRoot string, plan BuildPlan, unit CompileUnit, ) error { - for _, reference := range references { - resolved := rewriteReference(projectRoot, plan, reference) + for _, reference := range unit.Assembly.References { + resolved := rewriteReference(projectRoot, plan, unit, reference) path := projectPath(projectRoot, resolved) if isUnderDirectory(resolved, plan.OutputDir) { if err := writeFileContentEntry(digest, "produced-reference", resolved, path); err != nil { diff --git a/cli/dispatcher/internal/compilecheck/input_key_test.go b/cli/dispatcher/internal/compilecheck/input_key_test.go index c564ba783d..6c1d1b2184 100644 --- a/cli/dispatcher/internal/compilecheck/input_key_test.go +++ b/cli/dispatcher/internal/compilecheck/input_key_test.go @@ -439,7 +439,7 @@ func TestInputKeyDigestsEveryPartInOneStream(t *testing.T) { if err := writeSourceIdentity(digest, project.root, unit.Sources, map[string]string{}); err != nil { t.Fatalf("sources: %v", err) } - if err := writeReferenceIdentity(digest, project.root, project.plan, unit.Assembly.References); err != nil { + if err := writeReferenceIdentity(digest, project.root, project.plan, unit); err != nil { t.Fatalf("references: %v", err) } if err := writeAnalyzerIdentity(digest, project.root, unit.Assembly.Analyzers); err != nil { diff --git a/cli/dispatcher/internal/compilecheck/plan.go b/cli/dispatcher/internal/compilecheck/plan.go index dfaad0858d..6fcc941331 100644 --- a/cli/dispatcher/internal/compilecheck/plan.go +++ b/cli/dispatcher/internal/compilecheck/plan.go @@ -26,6 +26,11 @@ type CompileUnit struct { // what the scheduler waits for: a reference left out of the plan was not rebuilt, so its // reference assembly from the last Unity build is already on disk and nothing has to wait. PlanReferences []string + // KeptSurfaceReferences names the assemblies among PlanReferences whose rebuilt reference + // assembly the run found to describe the same public surface as Unity's, so this unit reads + // Unity's instead. The scheduler fills it when it starts the unit, once every reference is done, + // and nothing writes to it afterwards. Empty everywhere else. + KeptSurfaceReferences map[string]bool } // BuildPlan is the ordered set of assemblies to compile for one run. diff --git a/cli/dispatcher/internal/compilecheck/scheduler.go b/cli/dispatcher/internal/compilecheck/scheduler.go index 0bc5124dcd..c45899e175 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler.go +++ b/cli/dispatcher/internal/compilecheck/scheduler.go @@ -14,10 +14,11 @@ import ( type unitCompletion struct { index int result UnitResult - // changed says whether this unit's reference assembly came out different from the one the last - // Unity build produced, which is what decides whether its dependents still have to compile. - changed bool - err error + // keptSurface says whether this unit's reference assembly came out describing the same public + // surface as the one the last Unity build produced, which decides whether its dependents still + // have to compile and which of the two reference assemblies they read when they do. + keptSurface bool + err error } // schedule is the readiness bookkeeping of one run: how many of a unit's references are still @@ -25,6 +26,8 @@ type unitCompletion struct { type schedule struct { pending []int dependents [][]int + // references lists, per unit, the units of the plan it references. + references [][]int ready []int // changedInputs counts, per unit, how many of the references it waited for came out with a // different public surface than the last Unity build recorded. @@ -32,6 +35,8 @@ type schedule struct { // blocked marks, per unit, that an assembly it references has errors, so this run must not // compile it. blocked []bool + // keptSurface marks, per unit, that it finished with the public surface of the last Unity build. + keptSurface []bool } // compileOutcome is everything one run of the scheduler produced: the results it has, and what it @@ -151,7 +156,7 @@ func (run *schedulerRun) startReadyUnits(jobs int) error { continue } run.running++ - go run.compileOneUnit(index) + go run.compileOneUnit(index, run.startingUnit(index), len(run.state.dependents[index]) > 0) } return nil @@ -163,9 +168,10 @@ func (run *schedulerRun) startReadyUnits(jobs int) error { // find rather than anything about itself. // Why its earlier outputs and records are kept: nothing in this run reads them, since every unit // that references this one is blocked too, and a later run uses them only when the records still -// match: a replayed compile needs its input key, which reads every reference this run rebuilds by -// content and the others by timestamp, and outputs that still hash as recorded; a changed-files -// check needs the baseline's compile result on disk and the same non-source key. +// match: a replayed compile needs its input key, which reads every reference this run rebuilt into +// a different surface by content and the others by timestamp, and outputs that still hash as +// recorded; a changed-files check needs the baseline's compile result on disk and the same +// non-source key. // Removing them made the first successful run after an error compile every dependent again even // when the assembly with errors came back with the same reference assembly. func (run *schedulerRun) skipBlockedUnit(index int) bool { @@ -198,20 +204,42 @@ func (run *schedulerRun) skipUnchangedDependent(index int) (bool, error) { return true, nil } +// startingUnit is the unit at index as it is about to start, carrying which of the references it +// waited for kept the surface of the last Unity build. +// Why a copy made here rather than a lookup from the unit's goroutine: the loop writes those answers +// as units finish, while the units it already started are still running, and every reference of this +// unit is done by now, so the copy is final. Why on the unit rather than passed alongside: the +// response file, the changed-files check and every key read it from the unit, so none of them can +// end up pointing at a different reference assembly than the compile does. +func (run *schedulerRun) startingUnit(index int) CompileUnit { + unit := run.plan.Units[index] + kept := map[string]bool{} + for _, reference := range run.state.references[index] { + if run.state.keptSurface[reference] { + kept[run.plan.Units[reference].Assembly.AssemblyName] = true + } + } + unit.KeptSurfaceReferences = kept + + return unit +} + // compileOneUnit produces one unit's result - by compiling it, or by replaying the previous run's -// answer when nothing it reads has moved - and reports whether its public surface changed. +// answer when nothing it reads has moved - and reports whether its public surface stayed the same. // Why the comparison happens here rather than in the loop: it reads two files, and the loop has to // stay free to start the next unit. Why a replayed unit is compared just the same: it kept the // outputs of the previous run, so the reference assembly on disk is still the one to hold against // Unity's. -func (run *schedulerRun) compileOneUnit(index int) { - unit := run.plan.Units[index] +// Why only a successful unit that something in the plan references is compared: nothing reads the +// answer of any other unit - the dependents of a failed one are blocked whatever it wrote - and +// comparing costs a read of two reference assemblies for every edit of a leaf assembly. +func (run *schedulerRun) compileOneUnit(index int, unit CompileUnit, readByDependents bool) { result, err := run.compiler.reuseOrCompile(run.runContext, run.plan, unit) - changed := true - if err == nil { - changed = !referenceSurfaceUnchanged(run.compiler.ProjectRoot, run.plan, unit) + keptSurface := false + if err == nil && readByDependents && result.Succeeded { + keptSurface = run.compiler.keptSurface(run.plan, unit) } - run.completions <- unitCompletion{index: index, result: result, changed: changed, err: err} + run.completions <- unitCompletion{index: index, result: result, keptSurface: keptSurface, err: err} } // collectOneCompletion waits for one unit to finish and hands what it produced to the schedule. @@ -232,7 +260,8 @@ func (run *schedulerRun) collectOneCompletion() error { if !completion.result.Succeeded { run.state.blockDependents(completion.index) } - run.state.release(completion.index, completion.changed) + run.state.keptSurface[completion.index] = completion.keptSurface + run.state.release(completion.index, !completion.keptSurface) return nil } @@ -272,9 +301,11 @@ func newSchedule(plan BuildPlan) *schedule { state := &schedule{ pending: make([]int, len(plan.Units)), dependents: make([][]int, len(plan.Units)), + references: make([][]int, len(plan.Units)), ready: []int{}, changedInputs: make([]int, len(plan.Units)), blocked: make([]bool, len(plan.Units)), + keptSurface: make([]bool, len(plan.Units)), } for index, unit := range plan.Units { for _, reference := range unit.PlanReferences { @@ -284,6 +315,7 @@ func newSchedule(plan BuildPlan) *schedule { } state.pending[index]++ state.dependents[referenceIndex] = append(state.dependents[referenceIndex], index) + state.references[index] = append(state.references[index], referenceIndex) } } for index := range plan.Units { diff --git a/cli/dispatcher/internal/compilecheck/scheduler_surface_test.go b/cli/dispatcher/internal/compilecheck/scheduler_surface_test.go new file mode 100644 index 0000000000..da98a97665 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/scheduler_surface_test.go @@ -0,0 +1,306 @@ +package compilecheck + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// keptSurfaceOf is what the fake compiler writes for a unit whose reference assembly comes out +// byte-identical to the one the last Unity build left, which newUnit writes as "unity ". +func keptSurfaceOf(name string) string { + return "unity " + name +} + +// withOnlyUnits narrows the project's plan to the named units, keeping their order and dropping +// every plan reference to a unit left out, the way a run that does not rebuild those assemblies +// plans them. +func (project *cachedSchedulerProject) withOnlyUnits(names ...string) BuildPlan { + included := map[string]bool{} + for _, name := range names { + included[name] = true + } + narrowed := project.plan + narrowed.Units = []CompileUnit{} + for _, unit := range project.plan.Units { + if !included[unit.Assembly.AssemblyName] { + continue + } + references := []string{} + for _, reference := range unit.PlanReferences { + if included[reference] { + references = append(references, reference) + } + } + unit.PlanReferences = references + narrowed.Units = append(narrowed.Units, unit) + } + + return narrowed +} + +// compileOnly runs the named units alone through the scheduler, leaving the project's full plan as +// it was, and fails the test when the run could not finish. +func (project *cachedSchedulerProject) compileOnly( + t *testing.T, recorder *schedulerRecorder, names ...string, +) []UnitResult { + t.Helper() + full := project.plan + project.plan = project.withOnlyUnits(names...) + defer func() { project.plan = full }() + + return project.compileOrFail(t, recorder) +} + +// writtenResponseFile reads a response file the check wrote into its output directory. +func (project *cachedSchedulerProject) writtenResponseFile(t *testing.T, fileName string) string { + t.Helper() + content, err := os.ReadFile(filepath.Join(project.outputDirectory(), fileName)) + if err != nil { + t.Fatalf("failed to read %s: %v", fileName, err) + } + + return string(content) +} + +// unityReferenceFlag is the reference flag that points a dependent at Unity's own reference +// assembly of the named unit. +func unityReferenceFlag(name string) string { + return quoteFlag(referenceFlagPrefix, filepath.Join(planDagDirectory, name+referenceAssemblyExtension)) +} + +// producedReferenceFlag is the reference flag that points a dependent at the reference assembly +// this check produced for the named unit. +func producedReferenceFlag(plan BuildPlan, name string) string { + return quoteFlag(referenceFlagPrefix, + filepath.ToSlash(filepath.Join(plan.OutputDir, name+referenceAssemblyExtension))) +} + +// assertReferenceFlags checks that a written response file points at the given references and not +// at the other spelling of the same assemblies. +func assertReferenceFlags(t *testing.T, written string, want []string, notWant []string) { + t.Helper() + lines := strings.Split(written, "\n") + for _, flag := range want { + if !containsLine(lines, flag) { + t.Errorf("the response file is missing %q\ngot:\n%s", flag, written) + } + } + for _, flag := range notWant { + if containsLine(lines, flag) { + t.Errorf("the response file should not contain %q\ngot:\n%s", flag, written) + } + } +} + +// containsLine reports whether one of the lines is exactly line. +func containsLine(lines []string, line string) bool { + for _, candidate := range lines { + if candidate == line { + return true + } + } + + return false +} + +// Verifies a unit the plan holds for its own sources reuses the result it recorded while the +// assembly it references was not rebuilt, once a later run rebuilds that assembly into a reference +// assembly byte-identical to Unity's: the dependent compiles against exactly what it read before. +func TestCompileUnitsReusesADependentInThePlanWhenItsReferenceKeptTheSurface(t *testing.T) { + project := newCachedSchedulerProject(t, map[string][]string{"Beta": {"Alpha"}}, "Alpha", "Beta") + project.compileOnly(t, newCachedSchedulerRecorder("Beta"), "Beta") + + project.editSource(t, "Alpha", "class Alpha { void Body() { } }") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + recorder.surfaces["Alpha"] = keptSurfaceOf("Alpha") + results := project.compileOrFail(t, recorder) + + if recorder.runs["Alpha"] != 1 { + t.Errorf("the edited assembly should have compiled once, got %d", recorder.runs["Alpha"]) + } + if recorder.runs["Beta"] != 0 || !resultOf(t, results, "Beta").Reused { + t.Errorf("expected Beta to be reused without a compile, got %+v after %d compiles", + resultOf(t, results, "Beta"), recorder.runs["Beta"]) + } +} + +// Verifies the same reuse holds when the assembly the dependent references was itself replayed +// from an earlier run: its reference assembly on disk is still byte-identical to Unity's, so the +// dependent still reads Unity's. +func TestCompileUnitsReusesADependentWhenItsReplayedReferenceKeptTheSurface(t *testing.T) { + project := newCachedSchedulerProject(t, map[string][]string{"Beta": {"Alpha"}}, "Alpha", "Beta") + project.compileOnly(t, newCachedSchedulerRecorder("Beta"), "Beta") + project.editSource(t, "Alpha", "class Alpha { void Body() { } }") + alphaAlone := newCachedSchedulerRecorder("Alpha") + alphaAlone.surfaces["Alpha"] = keptSurfaceOf("Alpha") + project.compileOnly(t, alphaAlone, "Alpha") + + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + recorder.surfaces["Alpha"] = keptSurfaceOf("Alpha") + results := project.compileOrFail(t, recorder) + + if !resultOf(t, results, "Alpha").Reused { + t.Fatalf("expected Alpha to be replayed, got %+v", resultOf(t, results, "Alpha")) + } + if recorder.runs["Beta"] != 0 || !resultOf(t, results, "Beta").Reused { + t.Errorf("expected Beta to be reused without a compile, got %+v after %d compiles", + resultOf(t, results, "Beta"), recorder.runs["Beta"]) + } +} + +// Verifies a dependent that has to compile reads Unity's reference assembly for the reference that +// kept its surface and this run's output for the one that changed, each decided on its own. +func TestCompileUnitsRewritesOnlyTheReferencesWhoseSurfaceChanged(t *testing.T) { + project := newCachedSchedulerProject(t, + map[string][]string{"Beta": {"Alpha", "Gamma"}}, "Alpha", "Gamma", "Beta") + project.plan.Units[2].SelectedAsDependent = true + recorder := newCachedSchedulerRecorder("Alpha", "Gamma", "Beta") + recorder.surfaces["Alpha"] = keptSurfaceOf("Alpha") + recorder.surfaces["Gamma"] = "changed Gamma" + + project.compileOrFail(t, recorder) + + if recorder.runs["Beta"] != 1 { + t.Fatalf("expected Beta to compile once, got %d", recorder.runs["Beta"]) + } + assertReferenceFlags(t, project.writtenResponseFile(t, "Beta"+responseFileExtension), + []string{unityReferenceFlag("Alpha"), producedReferenceFlag(project.plan, "Gamma")}, + []string{producedReferenceFlag(project.plan, "Alpha"), unityReferenceFlag("Gamma")}) +} + +// Verifies --all keys a reference that kept its surface the way an ordinary run keys it, so the +// ordinary run after --all reuses what --all compiled instead of compiling it once more. +func TestCompileUnitsKeysAKeptReferenceTheSameWayWithAll(t *testing.T) { + project := newCachedSchedulerProject(t, map[string][]string{"Beta": {"Alpha"}}, "Alpha", "Beta") + project.plan.All = true + all := newCachedSchedulerRecorder("Alpha", "Beta") + all.surfaces["Alpha"] = keptSurfaceOf("Alpha") + project.compileOrFail(t, all) + project.plan.All = false + + recorder := newCachedSchedulerRecorder("Beta") + results := project.compileOnly(t, recorder, "Beta") + + if recorder.runs["Beta"] != 0 || !resultOf(t, results, "Beta").Reused { + t.Errorf("expected Beta to be reused after --all, got %+v after %d compiles", + resultOf(t, results, "Beta"), recorder.runs["Beta"]) + } +} + +// newLargeDependentProject lays out Alpha and a Beta that references it with enough sources for the +// changed-files check, each source split into a declaration and a body the fake worker tells apart. +func newLargeDependentProject(t *testing.T) (*cachedSchedulerProject, *fakeFastCheckWorker) { + t.Helper() + project := newCachedSchedulerProject(t, map[string][]string{"Beta": {"Alpha"}}, "Alpha", "Beta") + sources := make([]string, 0, fastPathMinimumSources) + for index := 0; index < fastPathMinimumSources; index++ { + source := fmt.Sprintf("Assets/Beta/File%03d.cs", index) + project.writeFile(t, source, bodyContent(index, "body")) + sources = append(sources, source) + } + project.plan.Units[1].Sources = sources + + return project, newFakeFastCheckWorker() +} + +// compileWithFastPath runs the project's plan, narrowed to the named units, with the changed-files +// check in place. +func compileWithFastPath( + t *testing.T, project *cachedSchedulerProject, worker *fakeFastCheckWorker, recorder *schedulerRecorder, + names ...string, +) compileOutcome { + t.Helper() + compiler := Compiler{ + Paths: project.paths, + ProjectRoot: project.root, + Timeout: time.Minute, + Run: recorder.runner(), + FastPath: &fastPathRunner{Worker: worker}, + } + outcome, err := compileUnits(context.Background(), compiler, project.withOnlyUnits(names...), 4) + if err != nil { + t.Fatalf("expected the run to succeed, got error: %v", err) + } + + return outcome +} + +// Verifies a dependent the plan holds for its own edit can still be checked on its changed files +// when the assembly it references was rebuilt into a reference assembly byte-identical to Unity's, +// and that the check reads Unity's reference assembly, as the baseline did. +func TestCompileUnitsLetsADependentInThePlanFastCheckWhenItsReferenceKeptTheSurface(t *testing.T) { + project, worker := newLargeDependentProject(t) + compileWithFastPath(t, project, worker, newCachedSchedulerRecorder("Beta"), "Beta") + if !project.hasBaselineOf("Beta") { + t.Fatal("expected the first full compile of Beta to record a baseline") + } + + project.editSource(t, "Alpha", "class Alpha { void Body() { } }") + project.writeFile(t, "Assets/Beta/File007.cs", bodyContent(7, "edited body")) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + recorder.surfaces["Alpha"] = keptSurfaceOf("Alpha") + outcome := compileWithFastPath(t, project, worker, recorder, "Alpha", "Beta") + + beta := resultOf(t, outcome.Units, "Beta") + if !beta.FastChecked || beta.FastPathFallbackReason != "" || recorder.runs["Beta"] != 0 { + t.Fatalf("expected Beta to be checked on its changed files, got %+v after %d compiles", + beta, recorder.runs["Beta"]) + } + assertReferenceFlags(t, project.writtenResponseFile(t, "Beta"+fastCheckResponseFileSuffix), + []string{unityReferenceFlag("Alpha")}, []string{producedReferenceFlag(project.plan, "Alpha")}) +} + +// surfaceComparisonCounter stands in for the surface comparison and counts which units a run +// asked it about, answering that every surface changed. +type surfaceComparisonCounter struct { + mutex sync.Mutex + compared map[string]int +} + +// compare records one comparison of the unit's surface. +func (counter *surfaceComparisonCounter) compare(_ string, _ BuildPlan, unit CompileUnit) bool { + counter.mutex.Lock() + defer counter.mutex.Unlock() + counter.compared[unit.Assembly.AssemblyName]++ + + return false +} + +// Verifies a run compares the surface only of the units whose answer something in the plan reads: +// a unit nothing in the plan references, and a unit that failed, whose dependents are blocked +// whatever it produced, are not compared at all. +func TestCompileUnitsDoesNotCompareASurfaceNobodyInThePlanReads(t *testing.T) { + project := newCachedSchedulerProject(t, + map[string][]string{"Beta": {"Alpha"}, "Delta": {"Gamma"}}, "Alpha", "Gamma", "Beta", "Delta") + recorder := newCachedSchedulerRecorder("Alpha", "Gamma", "Beta", "Delta") + recorder.output["Gamma"] = fakeCompilerOutput{ + stdout: "Assets/Gamma/Gamma.cs(1,1): error CS1002: ; expected", exitCode: 1, + } + counter := &surfaceComparisonCounter{compared: map[string]int{}} + compiler := Compiler{ + Paths: project.paths, + ProjectRoot: project.root, + Timeout: time.Minute, + Run: recorder.runner(), + compareSurface: counter.compare, + } + + if _, err := compileUnits(context.Background(), compiler, project.plan, 4); err != nil { + t.Fatalf("expected the run to succeed, got error: %v", err) + } + + if counter.compared["Alpha"] != 1 { + t.Errorf("expected Alpha, which Beta reads, to be compared once, got %d", counter.compared["Alpha"]) + } + for _, name := range []string{"Gamma", "Beta", "Delta"} { + if counter.compared[name] != 0 { + t.Errorf("expected %s not to be compared, got %d", name, counter.compared[name]) + } + } +} diff --git a/docs/compile-check.md b/docs/compile-check.md index ac9d11d999..1e641f3bc4 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -98,15 +98,19 @@ references, scripting defines and analyzers. `compile-check` replays those respo reported errors reports them again until one of its inputs moves. Because the outputs stay in place, the assemblies below it resolve the same reference assembly and are reused in turn, which is what makes editing a single method body cost one compile rather than one per dependent. - References this check produces are compared by content and everything else by timestamp, so a + A reference to an assembly this run rebuilt is read from Unity's artifact when the rebuilt + reference assembly came out byte-identical to it, and from this check's own output otherwise. The + first is digested by timestamp, like every reference the run did not rebuild, so rebuilding an + assembly without moving its public surface leaves the digests of the assemblies that reference it + where the runs that did not rebuild it left them. The second is digested by content, so a reference assembly rewritten with identical bytes still counts as unmoved. A response file naming a file the digest does not follow — `keyfile`, `resource`, `link`, `addmodule` — gets no digest, and such an assembly always compiles. `--all` looks nothing up, though it still records what it - compiled. What `--all` records is keyed slightly differently, and the difference is visible: - every assembly is in its plan, so every reference to an assembly Bee builds resolves to this - check's own output, while an ordinary run resolves the references of an assembly it does not - compile to Unity's artifacts instead. The first ordinary run after an `--all` may therefore - compile such an assembly once more before it starts reusing it. This is a separate mechanism from the dependent skip in step 3: that one compares this + compiled, under the same rule: a reference whose rebuilt reference assembly matches Unity's stays + on Unity's artifact, so an ordinary run after it reuses what it compiled. Only a reference whose + rebuilt surface differs from Unity's resolves to this check's output under `--all`; an ordinary + run that leaves that assembly out reads Unity's artifact instead, and may compile the assemblies + that reference it once more before it starts reusing them. This is a separate mechanism from the dependent skip in step 3: that one compares this run's reference assembly against Unity's artifact to decide whether a *dependent* needs compiling at all, while this one compares an assembly's own inputs against what the previous `compile-check` read. @@ -189,11 +193,12 @@ So the first body edit is compiled in full more often than it looks: - Right after a Unity build, compile-check finds nothing to compile and records nothing. The first body edit after that is compiled in full and records the baseline; the edits after it are checked. -- A run with `--all` records baselines too, but it rebuilds the assemblies the next ordinary run - takes from Unity's build instead, so references to them are read differently. In an assembly - that references another assembly of the project, the first body edit after `--all` falls back - with `inputs other than source contents changed: references`; an assembly that references none - is checked as usual. +- A run with `--all` records baselines too. It rebuilds the assemblies the next ordinary run takes + from Unity's build, but a reference whose rebuilt reference assembly matches Unity's is read from + Unity's build in both runs. Only in an assembly that references one whose rebuilt reference + assembly differs from Unity's does the first body edit after `--all` fall back with + `inputs other than source contents changed: references`; every other assembly is checked as + usual. - A run that skips an assembly because nothing it compiles from changed removes its result and baseline, so its next body edit is compiled in full. A run that blocks it because an assembly it references has errors keeps them: the next run checks them against the inputs and output hashes From 65ec4406986364d70871342b1d2bea9480938105 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 19:59:18 +0900 Subject: [PATCH 21/33] feat: compile-check's worker can tell whether two reference assemblies show dependents the same API (#3340) --- .../fastcheck/worker/CompareSurfaceCommand.cs | 68 +++ .../compilecheck/fastcheck/worker/Program.cs | 36 +- .../fastcheck/worker/SurfaceManifestWriter.cs | 194 +++++++ .../fastcheck/worker/SurfaceMetadata.cs | 200 ++++++++ .../worker/SurfaceNullabilityWriter.cs | 274 ++++++++++ .../fastcheck/worker/SurfaceReachability.cs | 192 +++++++ .../worker/SurfaceSignatureProvider.cs | 142 ++++++ .../worker/SurfaceStructureWriter.cs | 221 ++++++++ .../fastcheck/worker/SurfaceTypeNames.cs | 155 ++++++ .../fastcheck/worker/WorkerRequest.cs | 11 + .../fastcheck/worker/WorkerResponse.cs | 27 + .../CompareSurfaceCommandTests.cs | 482 ++++++++++++++++++ .../CompileCheckWorker.UnitTests.csproj | 8 + .../SurfaceFixture.cs | 179 +++++++ 14 files changed, 2177 insertions(+), 12 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/CompareSurfaceCommand.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceManifestWriter.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceMetadata.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceNullabilityWriter.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceReachability.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceSignatureProvider.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceStructureWriter.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceTypeNames.cs create mode 100644 tests/CompileCheckWorker.UnitTests/CompareSurfaceCommandTests.cs create mode 100644 tests/CompileCheckWorker.UnitTests/SurfaceFixture.cs diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/CompareSurfaceCommand.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/CompareSurfaceCommand.cs new file mode 100644 index 0000000000..0574bab3e2 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/CompareSurfaceCommand.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Reflection.PortableExecutable; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Answers whether two reference assemblies of one assembly describe the same surface to every + /// assembly that compiles against them. Each is written out as text - its structure with what no + /// dependent can see left out, then the effective nullable annotation of every row - and only + /// identical texts are the same surface. Reads metadata alone; no compiler is loaded. + /// + internal static class CompareSurfaceCommand + { + // Raise whenever what the description writes changes, so a recorded answer is not reused for + // a comparison that would now come out differently. + private const string DescriptionVersion = "sv1"; + private const string NullabilitySection = "== nullability"; + private const string EndOfDescription = "(end)"; + + internal static CompareSurfaceResponse Run(CompareSurfaceRequest request) + { + List produced; + List unityBuild; + // Why every failure answers "different": compiling a dependent that did not need it costs + // time, while calling a surface the same without having read it costs a missed error. + try + { + produced = Describe(request.ProducedPath); + unityBuild = Describe(request.UnityBuildPath); + } + catch (Exception exception) + { + return CompareSurfaceResponse.Different("could not read: " + exception.GetType().Name); + } + + int length = Math.Max(produced.Count, unityBuild.Count); + for (int index = 0; index < length; index++) + { + string producedLine = index < produced.Count ? produced[index] : EndOfDescription; + string unityBuildLine = index < unityBuild.Count ? unityBuild[index] : EndOfDescription; + if (producedLine != unityBuildLine) + { + return CompareSurfaceResponse.Different("produced: " + producedLine + " / Unity build: " + unityBuildLine); + } + } + + return CompareSurfaceResponse.Same(); + } + + /// + /// Writes the surface description of one reference assembly. + /// + internal static List Describe(string path) + { + using PEReader peReader = new PEReader(ImmutableArray.Create(File.ReadAllBytes(path))); + SurfaceMetadata metadata = new SurfaceMetadata(peReader); + List lines = new List { DescriptionVersion }; + SurfaceStructureWriter.Write(metadata, lines); + lines.Add(NullabilitySection); + SurfaceNullabilityWriter.Write(metadata, lines); + + return lines; + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/Program.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/Program.cs index bb47fe7f32..e84ca64f22 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/worker/Program.cs +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/Program.cs @@ -16,15 +16,16 @@ internal static class Program private const int CompletedExitCode = 0; private const int UsageExitCode = 2; private const int UnexpectedFailureExitCode = 3; - private const string FingerprintCommandName = "fingerprint"; - private const string CheckCommandName = "check"; + internal const string FingerprintCommandName = "fingerprint"; + internal const string CheckCommandName = "check"; + internal const string CompareSurfaceCommandName = "compare-surface"; private const string RoslynDirectorySidecarFileName = "roslyn-directory.txt"; private static int Main(string[] args) { - if (args.Length != 3 || (args[0] != FingerprintCommandName && args[0] != CheckCommandName)) + if (args.Length != 3 || !IsCommand(args[0])) { - Console.Error.WriteLine("usage: worker (fingerprint|check) "); + Console.Error.WriteLine("usage: worker (fingerprint|check|compare-surface) "); return UsageExitCode; } @@ -41,19 +42,30 @@ private static int Main(string[] args) } } + private static bool IsCommand(string command) + { + return command == FingerprintCommandName || command == CheckCommandName || command == CompareSurfaceCommandName; + } + + // Runs one command on the request in inputPath and writes its response to outputPath. // Kept out of Main so no Roslyn type is loaded before the resolver is registered. [MethodImpl(MethodImplOptions.NoInlining)] - private static void RunCommand(string command, string inputPath, string outputPath) + internal static void RunCommand(string command, string inputPath, string outputPath) { - if (command == FingerprintCommandName) + switch (command) { - FingerprintRequest fingerprintRequest = ReadJson(inputPath); - WriteJson(outputPath, FingerprintCommand.Run(fingerprintRequest)); - return; + case FingerprintCommandName: + WriteJson(outputPath, FingerprintCommand.Run(ReadJson(inputPath))); + return; + case CheckCommandName: + WriteJson(outputPath, ChangedFileChecker.Check(ReadJson(inputPath))); + return; + case CompareSurfaceCommandName: + WriteJson(outputPath, CompareSurfaceCommand.Run(ReadJson(inputPath))); + return; + default: + throw new ArgumentException("unknown worker command: " + command, nameof(command)); } - - CheckRequest checkRequest = ReadJson(inputPath); - WriteJson(outputPath, ChangedFileChecker.Check(checkRequest)); } private static T ReadJson(string path) diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceManifestWriter.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceManifestWriter.cs new file mode 100644 index 0000000000..6004144728 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceManifestWriter.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Writes what a reference assembly says about itself rather than about its types: its identity, + /// the assemblies and modules it points at, the types it forwards and the resources it carries. + /// The module version id is left out: a deterministic build derives it from the content, so it + /// differs whenever anything does, including what a dependent never reads. + /// + internal static class SurfaceManifestWriter + { + internal static void Write(SurfaceMetadata metadata, List lines) + { + MetadataReader reader = metadata.Reader; + WriteAssembly(metadata, lines); + ModuleDefinition module = reader.GetModuleDefinition(); + lines.Add("module " + reader.GetString(module.Name)); + AddPrefixed(lines, "module ", metadata.AttributeLines(module.GetCustomAttributes())); + lines.AddRange(AssemblyReferenceLines(reader)); + lines.AddRange(ModuleReferenceLines(reader)); + lines.AddRange(FileLines(reader)); + lines.AddRange(ExportedTypeLines(metadata)); + lines.AddRange(ResourceLines(metadata)); + } + + internal static void AddPrefixed(List lines, string prefix, List entries) + { + foreach (string entry in entries) + { + lines.Add(prefix + entry); + } + } + + internal static List SecurityLines(MetadataReader reader, DeclarativeSecurityAttributeHandleCollection attributes) + { + List entries = new List(); + foreach (DeclarativeSecurityAttributeHandle handle in attributes) + { + DeclarativeSecurityAttribute attribute = reader.GetDeclarativeSecurityAttribute(handle); + entries.Add("security " + attribute.Action + " " + SurfaceTypeNames.BlobHex(reader, attribute.PermissionSet)); + } + entries.Sort(StringComparer.Ordinal); + + return entries; + } + + private static void WriteAssembly(SurfaceMetadata metadata, List lines) + { + MetadataReader reader = metadata.Reader; + if (!reader.IsAssembly) + { + lines.Add("no assembly manifest"); + return; + } + + AssemblyDefinition assembly = reader.GetAssemblyDefinition(); + lines.Add("assembly " + reader.GetString(assembly.Name) + " " + assembly.Version + + " culture=" + reader.GetString(assembly.Culture) + " flags=" + assembly.Flags + + " key=" + SurfaceTypeNames.BlobHex(reader, assembly.PublicKey) + " hash=" + assembly.HashAlgorithm); + AddPrefixed(lines, "assembly ", metadata.AttributeLines(assembly.GetCustomAttributes())); + AddPrefixed(lines, "assembly ", SecurityLines(reader, assembly.GetDeclarativeSecurityAttributes())); + } + + private static List AssemblyReferenceLines(MetadataReader reader) + { + List entries = new List(); + foreach (AssemblyReferenceHandle handle in reader.AssemblyReferences) + { + AssemblyReference reference = reader.GetAssemblyReference(handle); + entries.Add("assemblyref " + reader.GetString(reference.Name) + " " + reference.Version + + " culture=" + reader.GetString(reference.Culture) + " flags=" + reference.Flags + + " key=" + SurfaceTypeNames.BlobHex(reader, reference.PublicKeyOrToken)); + } + entries.Sort(StringComparer.Ordinal); + + return entries; + } + + private static List ModuleReferenceLines(MetadataReader reader) + { + List entries = new List(); + int count = reader.GetTableRowCount(TableIndex.ModuleRef); + for (int row = 1; row <= count; row++) + { + ModuleReference reference = reader.GetModuleReference(MetadataTokens.ModuleReferenceHandle(row)); + entries.Add("moduleref " + reader.GetString(reference.Name)); + } + entries.Sort(StringComparer.Ordinal); + + return entries; + } + + private static List FileLines(MetadataReader reader) + { + List entries = new List(); + foreach (AssemblyFileHandle handle in reader.AssemblyFiles) + { + AssemblyFile file = reader.GetAssemblyFile(handle); + entries.Add("file " + reader.GetString(file.Name) + " metadata=" + file.ContainsMetadata + + " hash=" + SurfaceTypeNames.BlobHex(reader, file.HashValue)); + } + entries.Sort(StringComparer.Ordinal); + + return entries; + } + + // Every forwarded or exported type with its attributes, as one entry each so the set compares + // whatever order the table holds them in. + private static List ExportedTypeLines(SurfaceMetadata metadata) + { + MetadataReader reader = metadata.Reader; + List entries = new List(); + foreach (ExportedTypeHandle handle in reader.ExportedTypes) + { + ExportedType type = reader.GetExportedType(handle); + string entry = "exported " + ExportedTypeName(reader, type) + " flags=" + type.Attributes + + " in=" + ImplementationName(reader, type.Implementation); + entries.Add(entry + " [" + string.Join("; ", metadata.AttributeLines(type.GetCustomAttributes())) + "]"); + } + entries.Sort(StringComparer.Ordinal); + + return entries; + } + + private static List ResourceLines(SurfaceMetadata metadata) + { + MetadataReader reader = metadata.Reader; + List entries = new List(); + foreach (ManifestResourceHandle handle in reader.ManifestResources) + { + ManifestResource resource = reader.GetManifestResource(handle); + string place = resource.Implementation.IsNil + ? "embedded sha256=" + EmbeddedResourceHash(metadata.PEReader, resource.Offset) + : "in=" + ImplementationName(reader, resource.Implementation) + " offset=" + resource.Offset.ToString(CultureInfo.InvariantCulture); + entries.Add("resource " + reader.GetString(resource.Name) + " flags=" + resource.Attributes + " " + place); + } + entries.Sort(StringComparer.Ordinal); + + return entries; + } + + private static string ExportedTypeName(MetadataReader reader, ExportedType type) + { + string space = reader.GetString(type.Namespace); + string name = space.Length == 0 ? reader.GetString(type.Name) : space + "." + reader.GetString(type.Name); + if (type.Implementation.Kind == HandleKind.ExportedType) + { + return ExportedTypeName(reader, reader.GetExportedType((ExportedTypeHandle)type.Implementation)) + "/" + name; + } + + return name; + } + + private static string ImplementationName(MetadataReader reader, EntityHandle implementation) + { + switch (implementation.Kind) + { + case HandleKind.AssemblyReference: + return "[" + reader.GetString(reader.GetAssemblyReference((AssemblyReferenceHandle)implementation).Name) + "]"; + case HandleKind.AssemblyFile: + return "file " + reader.GetString(reader.GetAssemblyFile((AssemblyFileHandle)implementation).Name); + case HandleKind.ExportedType: + return "exported " + ExportedTypeName(reader, reader.GetExportedType((ExportedTypeHandle)implementation)); + default: + throw new BadImageFormatException("an implementation is named by a " + implementation.Kind + " row"); + } + } + + // An embedded resource sits in the resources directory as a 4-byte length and its bytes. + private static string EmbeddedResourceHash(PEReader peReader, long offset) + { + CorHeader header = peReader.PEHeaders.CorHeader; + if (header == null || header.ResourcesDirectory.Size == 0) + { + throw new BadImageFormatException("an embedded resource has no resources directory"); + } + + BlobReader blob = peReader.GetSectionData(header.ResourcesDirectory.RelativeVirtualAddress).GetReader(); + blob.Offset = checked((int)offset); + int length = blob.ReadInt32(); + byte[] content = blob.ReadBytes(length); + using SHA256 sha256 = SHA256.Create(); + + return SurfaceTypeNames.BytesHex(sha256.ComputeHash(content)); + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceMetadata.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceMetadata.cs new file mode 100644 index 0000000000..3462aee6df --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceMetadata.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Which nullable attribute an attribute row is, when it is one the compiler embedded. + /// + internal enum NullableAttributeKind + { + None, + Nullable, + Context, + } + + /// + /// One reference assembly as a surface description reads it: which types it leaves out, and how it + /// writes names and attributes. + /// + internal sealed class SurfaceMetadata + { + private const string EmbeddedAttributeName = "Microsoft.CodeAnalysis.EmbeddedAttribute"; + private const string CompilerServicesNamespace = "System.Runtime.CompilerServices"; + private const string NullableAttributeName = CompilerServicesNamespace + ".NullableAttribute"; + private const string NullableContextAttributeName = CompilerServicesNamespace + ".NullableContextAttribute"; + private const ushort AttributeBlobProlog = 1; + + private static readonly string[] StateMachineAttributeNames = + { + "AsyncStateMachineAttribute", + "IteratorStateMachineAttribute", + "AsyncIteratorStateMachineAttribute", + }; + + private readonly HashSet embeddedTypes; + private readonly HashSet hiddenNestedTypes; + private readonly HashSet hiddenNestedReflectionNames = new HashSet(StringComparer.Ordinal); + + public SurfaceMetadata(PEReader peReader) + { + PEReader = peReader; + Reader = peReader.GetMetadataReader(); + Provider = new SurfaceSignatureProvider(); + embeddedTypes = FindEmbeddedTypes(Reader); + hiddenNestedTypes = SurfaceReachability.UnreferencedPrivateNestedTypes(Reader, embeddedTypes); + foreach (TypeDefinitionHandle handle in hiddenNestedTypes) + { + hiddenNestedReflectionNames.Add(SurfaceTypeNames.ReflectionName(Reader, handle)); + } + } + + public PEReader PEReader { get; } + + public MetadataReader Reader { get; } + + public SurfaceSignatureProvider Provider { get; } + + // A type no dependent can see: one the compiler embedded, or a private nested type nothing kept + // refers to. Its members, parameters and attributes go with it. + public bool IsLeftOut(TypeDefinitionHandle handle) + { + return embeddedTypes.Contains(handle) || hiddenNestedTypes.Contains(handle); + } + + public string TypeName(EntityHandle handle) + { + return SurfaceTypeNames.Entity(Reader, Provider, handle); + } + + public string MethodName(MethodDefinitionHandle handle) + { + return SurfaceTypeNames.Method(Reader, Provider, handle); + } + + /// + /// Says whether an attribute is the compiler's embedded NullableAttribute or + /// NullableContextAttribute. One of the same name defined in another assembly is not. + /// + public NullableAttributeKind NullableKindOf(CustomAttribute attribute) + { + if (attribute.Constructor.Kind != HandleKind.MethodDefinition) + { + return NullableAttributeKind.None; + } + + TypeDefinitionHandle declaring = Reader.GetMethodDefinition((MethodDefinitionHandle)attribute.Constructor).GetDeclaringType(); + if (!embeddedTypes.Contains(declaring)) + { + return NullableAttributeKind.None; + } + + string name = SurfaceTypeNames.Definition(Reader, declaring); + if (name == NullableAttributeName) + { + return NullableAttributeKind.Nullable; + } + + return name == NullableContextAttributeName ? NullableAttributeKind.Context : NullableAttributeKind.None; + } + + /// + /// Writes the attributes of one row, each as its constructor and its value, sorted: the order of + /// the table follows row numbers, which an embedded type added elsewhere can shift. The + /// compiler's nullable attributes are left to the nullability description, and a state machine + /// attribute naming a left-out type is left out with it. + /// + public List AttributeLines(CustomAttributeHandleCollection attributes) + { + List lines = new List(); + foreach (CustomAttributeHandle handle in attributes) + { + CustomAttribute attribute = Reader.GetCustomAttribute(handle); + if (NullableKindOf(attribute) != NullableAttributeKind.None || NamesHiddenStateMachine(attribute)) + { + continue; + } + lines.Add("@" + SurfaceTypeNames.Member(Reader, Provider, attribute.Constructor) + + " = " + SurfaceTypeNames.BlobHex(Reader, attribute.Value)); + } + lines.Sort(StringComparer.Ordinal); + + return lines; + } + + private static HashSet FindEmbeddedTypes(MetadataReader reader) + { + HashSet embedded = new HashSet(); + foreach (TypeDefinitionHandle handle in reader.TypeDefinitions) + { + foreach (CustomAttributeHandle attributeHandle in reader.GetTypeDefinition(handle).GetCustomAttributes()) + { + if (IsEmbeddedAttribute(reader, reader.GetCustomAttribute(attributeHandle))) + { + embedded.Add(handle); + } + } + } + + return embedded; + } + + // [Microsoft.CodeAnalysis.Embedded] as the compiler writes it: a type defined in this very module. + private static bool IsEmbeddedAttribute(MetadataReader reader, CustomAttribute attribute) + { + if (attribute.Constructor.Kind != HandleKind.MethodDefinition) + { + return false; + } + + TypeDefinitionHandle declaring = reader.GetMethodDefinition((MethodDefinitionHandle)attribute.Constructor).GetDeclaringType(); + + return SurfaceTypeNames.Definition(reader, declaring) == EmbeddedAttributeName; + } + + // An async or iterator attribute whose typeof names a state machine type left out of the + // surface: its name carries the method's position in the type, so it moves whenever a member + // is added before the method, while a dependent only ever sees the method itself. + private bool NamesHiddenStateMachine(CustomAttribute attribute) + { + if (!IsStateMachineAttribute(attribute)) + { + return false; + } + + BlobReader blob = Reader.GetBlobReader(attribute.Value); + if (blob.Length < 2 || blob.ReadUInt16() != AttributeBlobProlog) + { + return false; + } + string typeName = blob.ReadSerializedString(); + + return typeName != null && hiddenNestedReflectionNames.Contains(typeName); + } + + private bool IsStateMachineAttribute(CustomAttribute attribute) + { + if (attribute.Constructor.Kind != HandleKind.MemberReference) + { + return false; + } + + EntityHandle parent = Reader.GetMemberReference((MemberReferenceHandle)attribute.Constructor).Parent; + if (parent.Kind != HandleKind.TypeReference) + { + return false; + } + + TypeReference type = Reader.GetTypeReference((TypeReferenceHandle)parent); + if (type.ResolutionScope.Kind != HandleKind.AssemblyReference + || !Reader.StringComparer.Equals(type.Namespace, CompilerServicesNamespace)) + { + return false; + } + + return Array.Exists(StateMachineAttributeNames, name => Reader.StringComparer.Equals(type.Name, name)); + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceNullabilityWriter.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceNullabilityWriter.cs new file mode 100644 index 0000000000..ee0404832e --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceNullabilityWriter.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection.Metadata; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Writes the nullable annotation of every row that can hold one, as one effective value per row. + /// The compiler writes an annotation either on the row itself or, when it matches, only once as the + /// context of the method or type around it, and that context follows every member of the type - + /// private ones included. Resolving each row to the value that applies to it makes two assemblies + /// whose contexts moved compare the same exactly when every annotation a dependent reads is the same. + /// What the bytes of an annotation mean position by position is not decoded: equal bytes on the same + /// row describe the same type. + /// + internal static class SurfaceNullabilityWriter + { + private const byte NoContext = 0; + private const ushort AttributeBlobProlog = 1; + private const int ReturnParameterSequence = 0; + + private static readonly HandleKind[] AnnotatedRowKinds = + { + HandleKind.TypeDefinition, HandleKind.InterfaceImplementation, HandleKind.GenericParameter, + HandleKind.GenericParameterConstraint, HandleKind.FieldDefinition, HandleKind.PropertyDefinition, + HandleKind.EventDefinition, HandleKind.Parameter, + }; + + private static readonly HandleKind[] ContextRowKinds = { HandleKind.TypeDefinition, HandleKind.MethodDefinition }; + + internal static void Write(SurfaceMetadata metadata, List lines) + { + RejectUnexpectedRows(metadata); + foreach (TypeDefinitionHandle handle in metadata.Reader.TypeDefinitions) + { + if (!metadata.IsLeftOut(handle)) + { + WriteType(metadata, handle, lines); + } + } + } + + // Why an attribute on any other kind of row stops the comparison: this description would not + // read it, and an annotation it does not read must not pass for an unchanged one. + private static void RejectUnexpectedRows(SurfaceMetadata metadata) + { + foreach (CustomAttributeHandle handle in metadata.Reader.CustomAttributes) + { + CustomAttribute attribute = metadata.Reader.GetCustomAttribute(handle); + NullableAttributeKind kind = metadata.NullableKindOf(attribute); + bool unexpected = (kind == NullableAttributeKind.Nullable && !AnnotatedRowKinds.Contains(attribute.Parent.Kind)) + || (kind == NullableAttributeKind.Context && !ContextRowKinds.Contains(attribute.Parent.Kind)); + if (unexpected) + { + throw new InvalidOperationException("a nullable attribute sits on a " + attribute.Parent.Kind + " row"); + } + } + } + + private static void WriteType(SurfaceMetadata metadata, TypeDefinitionHandle handle, List lines) + { + MetadataReader reader = metadata.Reader; + TypeDefinition type = reader.GetTypeDefinition(handle); + string name = SurfaceTypeNames.Definition(reader, handle); + byte typeContext = TypeContext(metadata, handle); + // The annotation of the base type sits on the type's own row. + if (!type.BaseType.IsNil) + { + lines.Add("base " + name + " " + Effective(metadata, type.GetCustomAttributes(), typeContext)); + } + List interfaces = new List(); + foreach (InterfaceImplementationHandle interfaceHandle in type.GetInterfaceImplementations()) + { + InterfaceImplementation implementation = reader.GetInterfaceImplementation(interfaceHandle); + interfaces.Add("iface " + name + " " + metadata.TypeName(implementation.Interface) + + " " + Effective(metadata, implementation.GetCustomAttributes(), typeContext)); + } + interfaces.Sort(StringComparer.Ordinal); + lines.AddRange(interfaces); + WriteGenericParameters(metadata, type.GetGenericParameters(), "gp " + name, typeContext, lines); + WriteMembers(metadata, type, name, typeContext, lines); + } + + private static void WriteMembers(SurfaceMetadata metadata, TypeDefinition type, string name, byte typeContext, List lines) + { + MetadataReader reader = metadata.Reader; + foreach (FieldDefinitionHandle fieldHandle in type.GetFields()) + { + FieldDefinition field = reader.GetFieldDefinition(fieldHandle); + if (field.DecodeSignature(metadata.Provider, null).HasNullablePosition) + { + lines.Add("field " + name + "::" + reader.GetString(field.Name) + " " + Effective(metadata, field.GetCustomAttributes(), typeContext)); + } + } + foreach (PropertyDefinitionHandle propertyHandle in type.GetProperties()) + { + PropertyDefinition property = reader.GetPropertyDefinition(propertyHandle); + if (property.DecodeSignature(metadata.Provider, null).ReturnType.HasNullablePosition) + { + lines.Add("property " + name + "::" + reader.GetString(property.Name) + " " + Effective(metadata, property.GetCustomAttributes(), typeContext)); + } + } + foreach (EventDefinitionHandle eventHandle in type.GetEvents()) + { + EventDefinition eventDefinition = reader.GetEventDefinition(eventHandle); + lines.Add("event " + name + "::" + reader.GetString(eventDefinition.Name) + " " + Effective(metadata, eventDefinition.GetCustomAttributes(), typeContext)); + } + foreach (MethodDefinitionHandle methodHandle in type.GetMethods()) + { + WriteMethod(metadata, methodHandle, typeContext, lines); + } + } + + private static void WriteMethod(SurfaceMetadata metadata, MethodDefinitionHandle handle, byte typeContext, List lines) + { + MetadataReader reader = metadata.Reader; + MethodDefinition method = reader.GetMethodDefinition(handle); + string name = metadata.MethodName(handle); + byte? ownContext = OwnContext(metadata, method.GetCustomAttributes()); + byte context = ownContext ?? typeContext; + WriteGenericParameters(metadata, method.GetGenericParameters(), "gp " + name, context, lines); + + Dictionary rows = new Dictionary(); + foreach (ParameterHandle parameterHandle in method.GetParameters()) + { + Parameter parameter = reader.GetParameter(parameterHandle); + rows[parameter.SequenceNumber] = parameter; + } + MethodSignature signature = method.DecodeSignature(metadata.Provider, null); + if (signature.ReturnType.HasNullablePosition) + { + lines.Add("ret " + name + " " + Effective(metadata, RowAttributes(rows, ReturnParameterSequence), context)); + } + for (int index = 0; index < signature.ParameterTypes.Length; index++) + { + if (signature.ParameterTypes[index].HasNullablePosition) + { + int sequence = index + 1; + lines.Add("param " + name + "#" + sequence.ToString(CultureInfo.InvariantCulture) + + " " + Effective(metadata, RowAttributes(rows, sequence), context)); + } + } + } + + // A parameter without a row has no attribute of its own, the same as a row without one. + private static IEnumerable RowAttributes(Dictionary rows, int sequence) + { + return rows.TryGetValue(sequence, out Parameter parameter) + ? parameter.GetCustomAttributes() + : Enumerable.Empty(); + } + + // Type parameters and their constraints always carry a position, whatever their constraints are. + private static void WriteGenericParameters( + SurfaceMetadata metadata, GenericParameterHandleCollection parameters, string owner, byte context, List lines) + { + MetadataReader reader = metadata.Reader; + foreach (GenericParameterHandle handle in parameters) + { + GenericParameter parameter = reader.GetGenericParameter(handle); + string name = owner + "!" + parameter.Index.ToString(CultureInfo.InvariantCulture); + lines.Add(name + " " + Effective(metadata, parameter.GetCustomAttributes(), context)); + List constraints = new List(); + foreach (GenericParameterConstraintHandle constraintHandle in parameter.GetConstraints()) + { + GenericParameterConstraint constraint = reader.GetGenericParameterConstraint(constraintHandle); + constraints.Add(name + " constraint " + metadata.TypeName(constraint.Type) + + " " + Effective(metadata, constraint.GetCustomAttributes(), context)); + } + constraints.Sort(StringComparer.Ordinal); + lines.AddRange(constraints); + } + } + + // The context a type's members fall back to: its own, else that of the type around it. + private static byte TypeContext(SurfaceMetadata metadata, TypeDefinitionHandle handle) + { + for (TypeDefinitionHandle current = handle; !current.IsNil; current = metadata.Reader.GetTypeDefinition(current).GetDeclaringType()) + { + byte? own = OwnContext(metadata, metadata.Reader.GetTypeDefinition(current).GetCustomAttributes()); + if (own.HasValue) + { + return own.Value; + } + } + + return NoContext; + } + + private static byte? OwnContext(SurfaceMetadata metadata, CustomAttributeHandleCollection attributes) + { + foreach (CustomAttributeHandle handle in attributes) + { + CustomAttribute attribute = metadata.Reader.GetCustomAttribute(handle); + if (metadata.NullableKindOf(attribute) == NullableAttributeKind.Context) + { + byte[] values = AttributeValues(metadata, attribute); + if (values.Length != 1) + { + throw new InvalidOperationException("a nullable context holds " + values.Length + " values"); + } + + return values[0]; + } + } + + return null; + } + + // The row's own annotation, else the context that applies to it. A uniform array reads as its + // single value, since the compiler writes it either way. + private static string Effective(SurfaceMetadata metadata, IEnumerable attributes, byte context) + { + byte[] own = null; + foreach (CustomAttributeHandle handle in attributes) + { + CustomAttribute attribute = metadata.Reader.GetCustomAttribute(handle); + if (metadata.NullableKindOf(attribute) != NullableAttributeKind.Nullable) + { + continue; + } + if (own != null) + { + throw new InvalidOperationException("a row carries two nullable attributes"); + } + own = AttributeValues(metadata, attribute); + } + + if (own == null) + { + return "u:" + context.ToString(CultureInfo.InvariantCulture); + } + if (own.Length > 0 && own.All(value => value == own[0])) + { + return "u:" + own[0].ToString(CultureInfo.InvariantCulture); + } + + return "a:" + SurfaceTypeNames.BytesHex(own); + } + + // Reads the single byte or byte array a nullable attribute was constructed with, and refuses any + // other shape. + private static byte[] AttributeValues(SurfaceMetadata metadata, CustomAttribute attribute) + { + MethodDefinition constructor = metadata.Reader.GetMethodDefinition((MethodDefinitionHandle)attribute.Constructor); + MethodSignature signature = constructor.DecodeSignature(metadata.Provider, null); + BlobReader blob = metadata.Reader.GetBlobReader(attribute.Value); + if (signature.ParameterTypes.Length != 1 || blob.ReadUInt16() != AttributeBlobProlog) + { + throw new InvalidOperationException("a nullable attribute has an unexpected constructor"); + } + + string parameterType = signature.ParameterTypes[0].Name; + if (parameterType == PrimitiveTypeCode.Byte.ToString()) + { + return new[] { blob.ReadByte() }; + } + if (parameterType != PrimitiveTypeCode.Byte + "[]") + { + throw new InvalidOperationException("a nullable attribute is constructed from " + parameterType); + } + + int count = blob.ReadInt32(); + if (count < 0) + { + throw new InvalidOperationException("a nullable attribute is constructed from a null array"); + } + + return blob.ReadBytes(count); + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceReachability.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceReachability.cs new file mode 100644 index 0000000000..1bca0e11bb --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceReachability.cs @@ -0,0 +1,192 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Reflection; +using System.Reflection.Metadata; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Finds the private nested types of a reference assembly that nothing a dependent can see refers + /// to: the compiler's closure, async and iterator types, and private helpers. A dependent cannot + /// use such a type, so whether it exists never decides whether a dependent compiles; it only + /// decides which error a dependent that already names it reports (CS0122 rather than CS0246 or + /// CS0426). One a kept row names - an interface a public type implements over it, the type of a + /// private field a struct keeps - does reach it, and stays. + /// + internal sealed class SurfaceReachability : ISignatureTypeProvider + { + private readonly HashSet referenced = new HashSet(); + + private SurfaceReachability() + { + } + + /// + /// Lists every type left out of the surface for being private-nested, itself or through a type + /// around it, without any kept row referring to it or to a type around it. + /// + internal static HashSet UnreferencedPrivateNestedTypes( + MetadataReader reader, HashSet embeddedTypes) + { + SurfaceReachability reachability = new SurfaceReachability(); + // Why to a fixpoint: a private nested type a kept row names is kept whole, and what its own + // members name is then kept in turn. + int before = -1; + while (reachability.referenced.Count != before) + { + before = reachability.referenced.Count; + foreach (TypeDefinitionHandle handle in reader.TypeDefinitions) + { + if (!embeddedTypes.Contains(handle) && reachability.IsKept(reader, handle)) + { + reachability.VisitType(reader, handle); + } + } + } + + HashSet unreferenced = new HashSet(); + foreach (TypeDefinitionHandle handle in reader.TypeDefinitions) + { + if (!reachability.IsKept(reader, handle)) + { + unreferenced.Add(handle); + } + } + + return unreferenced; + } + + public object GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) + { + for (TypeDefinitionHandle current = handle; !current.IsNil; current = reader.GetTypeDefinition(current).GetDeclaringType()) + { + if (IsPrivateNested(reader, current)) + { + referenced.Add(current); + } + } + + return null; + } + + public object GetTypeFromSpecification(MetadataReader reader, object genericContext, TypeSpecificationHandle handle, byte rawTypeKind) + { + return reader.GetTypeSpecification(handle).DecodeSignature(this, genericContext); + } + + public object GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) => null; + + public object GetPrimitiveType(PrimitiveTypeCode typeCode) => null; + + public object GetGenericInstantiation(object genericType, ImmutableArray typeArguments) => null; + + public object GetSZArrayType(object elementType) => null; + + public object GetArrayType(object elementType, ArrayShape shape) => null; + + public object GetByReferenceType(object elementType) => null; + + public object GetPointerType(object elementType) => null; + + public object GetPinnedType(object elementType) => null; + + public object GetModifiedType(object modifier, object unmodifiedType, bool isRequired) => null; + + public object GetFunctionPointerType(MethodSignature signature) => null; + + public object GetGenericTypeParameter(object genericContext, int index) => null; + + public object GetGenericMethodParameter(object genericContext, int index) => null; + + private static bool IsPrivateNested(MetadataReader reader, TypeDefinitionHandle handle) + { + return (reader.GetTypeDefinition(handle).Attributes & TypeAttributes.VisibilityMask) == TypeAttributes.NestedPrivate; + } + + // Kept when the type or a type around it is referenced, which keeps everything inside it, and + // otherwise unless the type or a type around it is private-nested. + private bool IsKept(MetadataReader reader, TypeDefinitionHandle handle) + { + bool underPrivateNested = false; + for (TypeDefinitionHandle current = handle; !current.IsNil; current = reader.GetTypeDefinition(current).GetDeclaringType()) + { + if (referenced.Contains(current)) + { + return true; + } + underPrivateNested |= IsPrivateNested(reader, current); + } + + return !underPrivateNested; + } + + private void VisitType(MetadataReader reader, TypeDefinitionHandle handle) + { + TypeDefinition type = reader.GetTypeDefinition(handle); + VisitEntity(reader, type.BaseType); + foreach (InterfaceImplementationHandle implementation in type.GetInterfaceImplementations()) + { + VisitEntity(reader, reader.GetInterfaceImplementation(implementation).Interface); + } + VisitConstraints(reader, type.GetGenericParameters()); + foreach (MethodImplementationHandle implementation in type.GetMethodImplementations()) + { + VisitMember(reader, reader.GetMethodImplementation(implementation).MethodDeclaration); + } + foreach (FieldDefinitionHandle field in type.GetFields()) + { + reader.GetFieldDefinition(field).DecodeSignature(this, null); + } + foreach (MethodDefinitionHandle methodHandle in type.GetMethods()) + { + MethodDefinition method = reader.GetMethodDefinition(methodHandle); + method.DecodeSignature(this, null); + VisitConstraints(reader, method.GetGenericParameters()); + } + foreach (PropertyDefinitionHandle property in type.GetProperties()) + { + reader.GetPropertyDefinition(property).DecodeSignature(this, null); + } + foreach (EventDefinitionHandle eventHandle in type.GetEvents()) + { + VisitEntity(reader, reader.GetEventDefinition(eventHandle).Type); + } + } + + private void VisitConstraints(MetadataReader reader, GenericParameterHandleCollection parameters) + { + foreach (GenericParameterHandle parameter in parameters) + { + foreach (GenericParameterConstraintHandle constraint in reader.GetGenericParameter(parameter).GetConstraints()) + { + VisitEntity(reader, reader.GetGenericParameterConstraint(constraint).Type); + } + } + } + + // The interface method an implementation declares, which can sit on a private nested interface. + private void VisitMember(MetadataReader reader, EntityHandle member) + { + if (member.Kind == HandleKind.MemberReference) + { + VisitEntity(reader, reader.GetMemberReference((MemberReferenceHandle)member).Parent); + } + if (member.Kind == HandleKind.MethodDefinition) + { + GetTypeFromDefinition(reader, reader.GetMethodDefinition((MethodDefinitionHandle)member).GetDeclaringType(), 0); + } + } + + private void VisitEntity(MetadataReader reader, EntityHandle handle) + { + if (handle.Kind == HandleKind.TypeDefinition) + { + GetTypeFromDefinition(reader, (TypeDefinitionHandle)handle, 0); + } + if (handle.Kind == HandleKind.TypeSpecification) + { + reader.GetTypeSpecification((TypeSpecificationHandle)handle).DecodeSignature(this, null); + } + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceSignatureProvider.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceSignatureProvider.cs new file mode 100644 index 0000000000..68d6d78587 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceSignatureProvider.cs @@ -0,0 +1,142 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Reflection.Metadata; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// A type read out of a signature: how it is written in a surface description, and whether it + /// holds a position a nullable annotation can describe. + /// + internal sealed class SurfaceType + { + public SurfaceType(string name, bool hasNullablePosition, bool isNullableValueType) + { + Name = name; + HasNullablePosition = hasNullablePosition; + IsNullableValueType = isNullableValueType; + } + + public string Name { get; } + + public bool HasNullablePosition { get; } + + // Set for System.Nullable`1 itself, so an instantiation over a type without a position has none. + public bool IsNullableValueType { get; } + } + + /// + /// Decodes signatures into the text a surface description writes, naming every type rather than + /// pointing at a metadata row, so two assemblies whose tables are numbered differently compare. + /// + internal sealed class SurfaceSignatureProvider : ISignatureTypeProvider + { + private const string NullableNamespace = "System"; + private const string NullableName = "Nullable`1"; + + public SurfaceType GetPrimitiveType(PrimitiveTypeCode typeCode) + { + // Only these two primitives are reference types; every other one, and void, has no position. + bool hasPosition = typeCode == PrimitiveTypeCode.String || typeCode == PrimitiveTypeCode.Object; + + return new SurfaceType(typeCode.ToString(), hasPosition, false); + } + + public SurfaceType GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) + { + TypeDefinition type = reader.GetTypeDefinition(handle); + bool isNullable = type.GetDeclaringType().IsNil + && reader.StringComparer.Equals(type.Namespace, NullableNamespace) + && reader.StringComparer.Equals(type.Name, NullableName); + + return Named(SurfaceTypeNames.Definition(reader, handle), rawTypeKind, isNullable); + } + + public SurfaceType GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) + { + TypeReference type = reader.GetTypeReference(handle); + bool isNullable = type.ResolutionScope.Kind != HandleKind.TypeReference + && reader.StringComparer.Equals(type.Namespace, NullableNamespace) + && reader.StringComparer.Equals(type.Name, NullableName); + + return Named(SurfaceTypeNames.Reference(reader, handle), rawTypeKind, isNullable); + } + + public SurfaceType GetTypeFromSpecification( + MetadataReader reader, object genericContext, TypeSpecificationHandle handle, byte rawTypeKind) + { + return reader.GetTypeSpecification(handle).DecodeSignature(this, genericContext); + } + + public SurfaceType GetGenericInstantiation(SurfaceType genericType, ImmutableArray typeArguments) + { + string name = genericType.Name + "<" + string.Join(",", typeArguments.Select(argument => argument.Name)) + ">"; + // A Nullable over a type without a position has none either; every other instantiation, + // a generic value type included, carries the positions of its type arguments. + bool positionFree = genericType.IsNullableValueType + && typeArguments.Length == 1 + && !typeArguments[0].HasNullablePosition; + + return new SurfaceType(name, !positionFree, false); + } + + public SurfaceType GetSZArrayType(SurfaceType elementType) + { + return new SurfaceType(elementType.Name + "[]", true, false); + } + + public SurfaceType GetArrayType(SurfaceType elementType, ArrayShape shape) + { + string name = elementType.Name + "[" + shape.Rank.ToString(CultureInfo.InvariantCulture) + + ";" + string.Join(",", shape.Sizes) + ";" + string.Join(",", shape.LowerBounds) + "]"; + + return new SurfaceType(name, true, false); + } + + public SurfaceType GetByReferenceType(SurfaceType elementType) + { + return new SurfaceType(elementType.Name + "&", elementType.HasNullablePosition, false); + } + + public SurfaceType GetPointerType(SurfaceType elementType) + { + return new SurfaceType(elementType.Name + "*", true, false); + } + + public SurfaceType GetPinnedType(SurfaceType elementType) + { + return new SurfaceType(elementType.Name + " pinned", elementType.HasNullablePosition, false); + } + + public SurfaceType GetModifiedType(SurfaceType modifier, SurfaceType unmodifiedType, bool isRequired) + { + string name = unmodifiedType.Name + (isRequired ? " modreq(" : " modopt(") + modifier.Name + ")"; + + return new SurfaceType(name, unmodifiedType.HasNullablePosition, false); + } + + public SurfaceType GetFunctionPointerType(MethodSignature signature) + { + return new SurfaceType("method " + SurfaceTypeNames.Signature(signature), true, false); + } + + public SurfaceType GetGenericTypeParameter(object genericContext, int index) + { + return new SurfaceType("!" + index.ToString(CultureInfo.InvariantCulture), true, false); + } + + public SurfaceType GetGenericMethodParameter(object genericContext, int index) + { + return new SurfaceType("!!" + index.ToString(CultureInfo.InvariantCulture), true, false); + } + + private static SurfaceType Named(string name, byte rawTypeKind, bool isNullable) + { + // A non-generic value type, an enum included, has no position of its own. + bool isValueType = rawTypeKind == (byte)SignatureTypeKind.ValueType; + + return new SurfaceType((isValueType ? "valuetype " : "class ") + name, !isValueType, isValueType && isNullable); + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceStructureWriter.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceStructureWriter.cs new file mode 100644 index 0000000000..26456b5066 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceStructureWriter.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection.Metadata; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Writes every type and member of a reference assembly a dependent can see, line by line, naming + /// what each row points at instead of writing its row number. Types and members keep the order of + /// their tables, so a reordering reads as a change; what hangs off one row and has no order in C# - + /// attributes, interfaces, constraints, explicit implementations - is sorted. The compiler's + /// nullable attributes are left to SurfaceNullabilityWriter. + /// + internal static class SurfaceStructureWriter + { + internal static void Write(SurfaceMetadata metadata, List lines) + { + SurfaceManifestWriter.Write(metadata, lines); + foreach (TypeDefinitionHandle handle in metadata.Reader.TypeDefinitions) + { + if (!metadata.IsLeftOut(handle)) + { + WriteType(metadata, handle, lines); + } + } + } + + private static void WriteType(SurfaceMetadata metadata, TypeDefinitionHandle handle, List lines) + { + MetadataReader reader = metadata.Reader; + TypeDefinition type = reader.GetTypeDefinition(handle); + string name = SurfaceTypeNames.Definition(reader, handle); + TypeLayout layout = type.GetLayout(); + lines.Add("type " + name + " flags=" + type.Attributes + " base=" + metadata.TypeName(type.BaseType) + + " layout=" + layout.PackingSize.ToString(CultureInfo.InvariantCulture) + "," + layout.Size.ToString(CultureInfo.InvariantCulture)); + WriteGenericParameters(metadata, type.GetGenericParameters(), name, lines); + SurfaceManifestWriter.AddPrefixed(lines, name + " ", InterfaceEntries(metadata, type)); + SurfaceManifestWriter.AddPrefixed(lines, name + " ", SurfaceManifestWriter.SecurityLines(reader, type.GetDeclarativeSecurityAttributes())); + SurfaceManifestWriter.AddPrefixed(lines, name + " ", metadata.AttributeLines(type.GetCustomAttributes())); + SurfaceManifestWriter.AddPrefixed(lines, name + " ", ImplementationEntries(metadata, type)); + foreach (FieldDefinitionHandle field in type.GetFields()) + { + WriteField(metadata, name, field, lines); + } + foreach (MethodDefinitionHandle method in type.GetMethods()) + { + WriteMethod(metadata, method, lines); + } + foreach (PropertyDefinitionHandle property in type.GetProperties()) + { + WriteProperty(metadata, name, property, lines); + } + foreach (EventDefinitionHandle eventHandle in type.GetEvents()) + { + WriteEvent(metadata, name, eventHandle, lines); + } + } + + private static List InterfaceEntries(SurfaceMetadata metadata, TypeDefinition type) + { + List entries = new List(); + foreach (InterfaceImplementationHandle handle in type.GetInterfaceImplementations()) + { + InterfaceImplementation implementation = metadata.Reader.GetInterfaceImplementation(handle); + entries.Add("implements " + metadata.TypeName(implementation.Interface) + + " [" + string.Join("; ", metadata.AttributeLines(implementation.GetCustomAttributes())) + "]"); + } + entries.Sort(StringComparer.Ordinal); + + return entries; + } + + private static List ImplementationEntries(SurfaceMetadata metadata, TypeDefinition type) + { + List entries = new List(); + foreach (MethodImplementationHandle handle in type.GetMethodImplementations()) + { + MethodImplementation implementation = metadata.Reader.GetMethodImplementation(handle); + entries.Add("methodimpl " + SurfaceTypeNames.Member(metadata.Reader, metadata.Provider, implementation.MethodBody) + + " overrides " + SurfaceTypeNames.Member(metadata.Reader, metadata.Provider, implementation.MethodDeclaration)); + } + entries.Sort(StringComparer.Ordinal); + + return entries; + } + + private static void WriteGenericParameters( + SurfaceMetadata metadata, GenericParameterHandleCollection parameters, string owner, List lines) + { + MetadataReader reader = metadata.Reader; + foreach (GenericParameterHandle handle in parameters) + { + GenericParameter parameter = reader.GetGenericParameter(handle); + string name = owner + " !" + parameter.Index.ToString(CultureInfo.InvariantCulture); + lines.Add(name + " " + reader.GetString(parameter.Name) + " flags=" + parameter.Attributes); + SurfaceManifestWriter.AddPrefixed(lines, name + " ", metadata.AttributeLines(parameter.GetCustomAttributes())); + List constraints = new List(); + foreach (GenericParameterConstraintHandle constraintHandle in parameter.GetConstraints()) + { + GenericParameterConstraint constraint = reader.GetGenericParameterConstraint(constraintHandle); + constraints.Add("constraint " + metadata.TypeName(constraint.Type) + + " [" + string.Join("; ", metadata.AttributeLines(constraint.GetCustomAttributes())) + "]"); + } + constraints.Sort(StringComparer.Ordinal); + SurfaceManifestWriter.AddPrefixed(lines, name + " ", constraints); + } + } + + private static void WriteField(SurfaceMetadata metadata, string typeName, FieldDefinitionHandle handle, List lines) + { + MetadataReader reader = metadata.Reader; + FieldDefinition field = reader.GetFieldDefinition(handle); + string name = "field " + typeName + "::" + reader.GetString(field.Name); + lines.Add(name + " flags=" + field.Attributes + " type=" + field.DecodeSignature(metadata.Provider, null).Name + + " const=" + ConstantText(reader, field.GetDefaultValue()) + + " marshal=" + SurfaceTypeNames.BlobHex(reader, field.GetMarshallingDescriptor()) + + " offset=" + field.GetOffset().ToString(CultureInfo.InvariantCulture) + + " rva=" + (field.GetRelativeVirtualAddress() != 0)); + SurfaceManifestWriter.AddPrefixed(lines, name + " ", metadata.AttributeLines(field.GetCustomAttributes())); + } + + private static void WriteMethod(SurfaceMetadata metadata, MethodDefinitionHandle handle, List lines) + { + MetadataReader reader = metadata.Reader; + MethodDefinition method = reader.GetMethodDefinition(handle); + string name = "method " + metadata.MethodName(handle); + lines.Add(name + " flags=" + method.Attributes + " impl=" + method.ImplAttributes + ImportText(reader, method.GetImport())); + WriteGenericParameters(metadata, method.GetGenericParameters(), name, lines); + foreach (ParameterHandle parameterHandle in method.GetParameters()) + { + lines.Add(name + " " + ParameterLine(metadata, reader.GetParameter(parameterHandle))); + } + SurfaceManifestWriter.AddPrefixed(lines, name + " ", SurfaceManifestWriter.SecurityLines(reader, method.GetDeclarativeSecurityAttributes())); + SurfaceManifestWriter.AddPrefixed(lines, name + " ", metadata.AttributeLines(method.GetCustomAttributes())); + } + + // One parameter row, the return value's included. Why a return-value row that only carries a + // nullable attribute is kept: a method's nullable context follows its own signature alone, so + // whether that row exists never moves with members a dependent cannot see. + private static string ParameterLine(SurfaceMetadata metadata, Parameter parameter) + { + MetadataReader reader = metadata.Reader; + List attributes = metadata.AttributeLines(parameter.GetCustomAttributes()); + + return "param #" + parameter.SequenceNumber.ToString(CultureInfo.InvariantCulture) + + " " + (parameter.Name.IsNil ? "" : reader.GetString(parameter.Name)) + + " flags=" + parameter.Attributes + + " const=" + ConstantText(reader, parameter.GetDefaultValue()) + + " marshal=" + SurfaceTypeNames.BlobHex(reader, parameter.GetMarshallingDescriptor()) + + " [" + string.Join("; ", attributes) + "]"; + } + + private static void WriteProperty(SurfaceMetadata metadata, string typeName, PropertyDefinitionHandle handle, List lines) + { + MetadataReader reader = metadata.Reader; + PropertyDefinition property = reader.GetPropertyDefinition(handle); + PropertyAccessors accessors = property.GetAccessors(); + string name = "property " + typeName + "::" + reader.GetString(property.Name); + lines.Add(name + " flags=" + property.Attributes + + " sig=" + SurfaceTypeNames.Signature(property.DecodeSignature(metadata.Provider, null)) + + " const=" + ConstantText(reader, property.GetDefaultValue()) + + " get=" + AccessorName(metadata, accessors.Getter) + " set=" + AccessorName(metadata, accessors.Setter) + + " other=[" + string.Join("; ", OtherAccessorNames(metadata, accessors.Others)) + "]"); + SurfaceManifestWriter.AddPrefixed(lines, name + " ", metadata.AttributeLines(property.GetCustomAttributes())); + } + + private static void WriteEvent(SurfaceMetadata metadata, string typeName, EventDefinitionHandle handle, List lines) + { + MetadataReader reader = metadata.Reader; + EventDefinition eventDefinition = reader.GetEventDefinition(handle); + EventAccessors accessors = eventDefinition.GetAccessors(); + string name = "event " + typeName + "::" + reader.GetString(eventDefinition.Name); + lines.Add(name + " flags=" + eventDefinition.Attributes + " type=" + metadata.TypeName(eventDefinition.Type) + + " add=" + AccessorName(metadata, accessors.Adder) + " remove=" + AccessorName(metadata, accessors.Remover) + + " raise=" + AccessorName(metadata, accessors.Raiser) + + " other=[" + string.Join("; ", OtherAccessorNames(metadata, accessors.Others)) + "]"); + SurfaceManifestWriter.AddPrefixed(lines, name + " ", metadata.AttributeLines(eventDefinition.GetCustomAttributes())); + } + + private static string AccessorName(SurfaceMetadata metadata, MethodDefinitionHandle handle) + { + return handle.IsNil ? "-" : metadata.MethodName(handle); + } + + private static List OtherAccessorNames(SurfaceMetadata metadata, System.Collections.Immutable.ImmutableArray others) + { + List names = new List(); + foreach (MethodDefinitionHandle other in others) + { + names.Add(metadata.MethodName(other)); + } + names.Sort(StringComparer.Ordinal); + + return names; + } + + private static string ImportText(MetadataReader reader, MethodImport import) + { + if (import.Module.IsNil) + { + return ""; + } + + return " import=" + reader.GetString(reader.GetModuleReference(import.Module).Name) + + "!" + reader.GetString(import.Name) + " " + import.Attributes; + } + + private static string ConstantText(MetadataReader reader, ConstantHandle handle) + { + if (handle.IsNil) + { + return "-"; + } + + Constant constant = reader.GetConstant(handle); + + return constant.TypeCode + ":" + SurfaceTypeNames.BlobHex(reader, constant.Value); + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceTypeNames.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceTypeNames.cs new file mode 100644 index 0000000000..bafe4c47cd --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceTypeNames.cs @@ -0,0 +1,155 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Reflection.Metadata; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Writes the names a surface description uses for types, members and signatures. Nested types + /// are joined with '/', the metadata form, everywhere except in ReflectionName. + /// + internal static class SurfaceTypeNames + { + private const string MetadataNestingSeparator = "/"; + private const string ReflectionNestingSeparator = "+"; + + internal static string Definition(MetadataReader reader, TypeDefinitionHandle handle) + { + return DefinitionName(reader, handle, MetadataNestingSeparator); + } + + // The name typeof writes into an attribute value for a type of the same assembly. + internal static string ReflectionName(MetadataReader reader, TypeDefinitionHandle handle) + { + return DefinitionName(reader, handle, ReflectionNestingSeparator); + } + + internal static string Reference(MetadataReader reader, TypeReferenceHandle handle) + { + TypeReference type = reader.GetTypeReference(handle); + string name = QualifiedName(reader, type.Namespace, type.Name); + EntityHandle scope = type.ResolutionScope; + switch (scope.Kind) + { + case HandleKind.TypeReference: + return Reference(reader, (TypeReferenceHandle)scope) + MetadataNestingSeparator + reader.GetString(type.Name); + case HandleKind.AssemblyReference: + return "[" + reader.GetString(reader.GetAssemblyReference((AssemblyReferenceHandle)scope).Name) + "]" + name; + case HandleKind.ModuleReference: + return "[.module " + reader.GetString(reader.GetModuleReference((ModuleReferenceHandle)scope).Name) + "]" + name; + case HandleKind.ModuleDefinition: + return "[.this]" + name; + default: + return "[*]" + name; + } + } + + // A type a row points at by handle: a base type, an interface, a constraint, an event type. + internal static string Entity(MetadataReader reader, SurfaceSignatureProvider provider, EntityHandle handle) + { + if (handle.IsNil) + { + return "(none)"; + } + + switch (handle.Kind) + { + case HandleKind.TypeDefinition: + return Definition(reader, (TypeDefinitionHandle)handle); + case HandleKind.TypeReference: + return Reference(reader, (TypeReferenceHandle)handle); + case HandleKind.TypeSpecification: + return reader.GetTypeSpecification((TypeSpecificationHandle)handle).DecodeSignature(provider, null).Name; + default: + throw new BadImageFormatException("a type is named by a " + handle.Kind + " row"); + } + } + + internal static string Method(MetadataReader reader, SurfaceSignatureProvider provider, MethodDefinitionHandle handle) + { + MethodDefinition method = reader.GetMethodDefinition(handle); + + return Definition(reader, method.GetDeclaringType()) + "::" + reader.GetString(method.Name) + + Signature(method.DecodeSignature(provider, null)); + } + + // A method or field another row points at, by definition or by reference. + internal static string Member(MetadataReader reader, SurfaceSignatureProvider provider, EntityHandle handle) + { + if (handle.Kind == HandleKind.MethodDefinition) + { + return Method(reader, provider, (MethodDefinitionHandle)handle); + } + if (handle.Kind != HandleKind.MemberReference) + { + throw new BadImageFormatException("a member is named by a " + handle.Kind + " row"); + } + + MemberReference member = reader.GetMemberReference((MemberReferenceHandle)handle); + string name = MemberParent(reader, provider, member.Parent) + "::" + reader.GetString(member.Name); + if (member.GetKind() == MemberReferenceKind.Field) + { + return name + " field " + member.DecodeFieldSignature(provider, null).Name; + } + + return name + Signature(member.DecodeMethodSignature(provider, null)); + } + + internal static string Signature(MethodSignature signature) + { + string text = "`" + signature.GenericParameterCount.ToString(CultureInfo.InvariantCulture) + + " " + signature.Header.RawValue.ToString("X2", CultureInfo.InvariantCulture) + + "(" + string.Join(",", signature.ParameterTypes.Select(type => type.Name)) + ")"; + // A vararg signature says where its fixed parameters end. + if (signature.RequiredParameterCount != signature.ParameterTypes.Length) + { + text += " required=" + signature.RequiredParameterCount.ToString(CultureInfo.InvariantCulture); + } + + return text + "->" + signature.ReturnType.Name; + } + + internal static string BytesHex(byte[] bytes) + { + return BitConverter.ToString(bytes); + } + + internal static string BlobHex(MetadataReader reader, BlobHandle handle) + { + return handle.IsNil ? "" : BitConverter.ToString(reader.GetBlobBytes(handle)); + } + + private static string MemberParent(MetadataReader reader, SurfaceSignatureProvider provider, EntityHandle parent) + { + switch (parent.Kind) + { + case HandleKind.MethodDefinition: + return Method(reader, provider, (MethodDefinitionHandle)parent); + case HandleKind.ModuleReference: + return "[.module " + reader.GetString(reader.GetModuleReference((ModuleReferenceHandle)parent).Name) + "]"; + default: + return Entity(reader, provider, parent); + } + } + + private static string DefinitionName(MetadataReader reader, TypeDefinitionHandle handle, string separator) + { + TypeDefinition type = reader.GetTypeDefinition(handle); + TypeDefinitionHandle declaring = type.GetDeclaringType(); + if (!declaring.IsNil) + { + return DefinitionName(reader, declaring, separator) + separator + reader.GetString(type.Name); + } + + return QualifiedName(reader, type.Namespace, type.Name); + } + + private static string QualifiedName(MetadataReader reader, StringHandle space, StringHandle name) + { + string spaceText = reader.GetString(space); + + return spaceText.Length == 0 ? reader.GetString(name) : spaceText + "." + reader.GetString(name); + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerRequest.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerRequest.cs index 7ac9b65b73..59691bf515 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerRequest.cs +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerRequest.cs @@ -32,4 +32,15 @@ internal sealed class CheckRequest public string GeneratedDirectory { get; set; } } + + /// + /// Asks whether the reference assembly compile-check produced for an assembly describes the same + /// surface, as a dependent compiles against it, as the one the last Unity build produced. + /// + internal sealed class CompareSurfaceRequest + { + public string ProducedPath { get; set; } + + public string UnityBuildPath { get; set; } + } } diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerResponse.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerResponse.cs index 691e82b603..137eb4cdd4 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerResponse.cs +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerResponse.cs @@ -44,6 +44,33 @@ public static CheckResponse Unsupported(string reason) } } + /// + /// Result of a surface comparison. "same" only when every dependent would compile against either + /// reference assembly with the same outcome and the same warnings; anything the comparison cannot + /// vouch for is "different". A dependent that already fails because it names a private nested type + /// may report that error under another ID (CS0246, CS0426 or CS0122). + /// + internal sealed class CompareSurfaceResponse + { + public const string SameOutcome = "same"; + public const string DifferentOutcome = "different"; + + public string Outcome { get; set; } + + // The first line the two descriptions disagree on, or why they could not be read; empty for "same". + public string Detail { get; set; } = ""; + + public static CompareSurfaceResponse Same() + { + return new CompareSurfaceResponse { Outcome = SameOutcome }; + } + + public static CompareSurfaceResponse Different(string detail) + { + return new CompareSurfaceResponse { Outcome = DifferentOutcome, Detail = detail }; + } + } + /// /// One error or warning, located the way csc reports it (1-based line and column). /// diff --git a/tests/CompileCheckWorker.UnitTests/CompareSurfaceCommandTests.cs b/tests/CompileCheckWorker.UnitTests/CompareSurfaceCommandTests.cs new file mode 100644 index 0000000000..89f7a6b269 --- /dev/null +++ b/tests/CompileCheckWorker.UnitTests/CompareSurfaceCommandTests.cs @@ -0,0 +1,482 @@ +using System; +using System.IO; +using System.Text.Json; +using NUnit.Framework; + +namespace UnityCliLoop.CompileCheckWorker.UnitTests +{ + [TestFixture] + public sealed class CompareSurfaceCommandTests + { + private const string TwoPrivateStrings = " private string pa = \"\"; private string pb = \"\"; "; + private const string ThreePrivateNullableObjects = " private object? pa; private object? pb; private object? pc; "; + private const string InternalsVisibleToDependent = + "[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(\"" + SurfaceFixture.DependentAssemblyName + "\")]\n"; + + /// + /// Verifies that adding private reference-type fields, which only moves the nullable attributes + /// the compiler writes, keeps the surface. + /// + [Test] + public void PrivateReferenceFieldsAddedKeepTheSurface() + { + SurfaceFixture.AssertSame( + "public class Bag { public Bag(int seed) { } public int Count() => 0; }", + "public class Bag { public Bag(int seed) { } public int Count() => 0;" + TwoPrivateStrings + "}", + "class D { int F() => new Bag(1).Count(); }"); + } + + /// + /// Verifies that adding a private generic method with an unmanaged constraint, which only adds an + /// embedded attribute type, keeps the surface. + /// + [Test] + public void PrivateUnmanagedGenericMethodAddedKeepsTheSurface() + { + SurfaceFixture.AssertSame( + "public class C { public int X; }", + "public class C { public int X; private static void G() where T : unmanaged { } }", + "class D { int F() => new C().X; }"); + } + + /// + /// Verifies that annotating a public property as nullable changes the surface. + /// + [Test] + public void PublicPropertyAnnotationChangeChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public class C { public string P { get; set; } = \"\"; }", + "public class C { public string? P { get; set; } }", + "class D { int F() => new C().P.Length; }"); + } + + /// + /// Verifies that turning an oblivious public member into a not-annotated one changes the surface. + /// + [Test] + public void ObliviousToNotAnnotatedChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public class C {\n#nullable disable\npublic string P { get; set; }\n#nullable restore\n}", + "public class C { public string P { get; set; } = \"\"; }", + "class D { void F() { new C().P = null; } }"); + } + + /// + /// Verifies that relaxing a class constraint to a nullable one changes the surface. + /// + [Test] + public void ConstraintAnnotationChangeChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public class G where T : class { }", + "public class G where T : class? { }", + "class D { G? F() => null; }"); + } + + /// + /// Verifies that annotating a type argument of the base type changes the surface. + /// + [Test] + public void BaseTypeArgumentAnnotationChangeChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public class C : System.Collections.Generic.List { }", + "public class C : System.Collections.Generic.List { }", + "class D { int F() => new C()[0].Length; }"); + } + + /// + /// Verifies that marking a public method obsolete as an error changes the surface. + /// + [Test] + public void ObsoleteErrorAddedChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public class C { public void M() { } }", + "public class C { [System.Obsolete(\"x\", true)] public void M() { } }", + "class D { void F() { new C().M(); } }"); + } + + /// + /// Verifies that changing the value of a public constant changes the surface. + /// + [Test] + public void PublicConstantValueChangeChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public class C { public const int K = 1; }", + "public class C { public const int K = 300; }", + "class D { const byte B = C.K; }"); + } + + /// + /// Verifies that adding a public method changes the surface. + /// + [Test] + public void PublicMethodAddedChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public class C { public int X; }", + "public class C { public int X; public void M() { } }", + "class D { void F() { new C().M(); } }"); + } + + /// + /// Verifies that adding a private reference-type field to a struct, which the reference assembly + /// keeps and which makes the struct no longer unmanaged, changes the surface. + /// + [Test] + public void StructPrivateReferenceFieldAddedChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public struct S { public int X; }", + "public struct S { public int X; private string? p; }", + "class D { unsafe void F() { S* p = null; } }"); + } + + /// + /// Verifies that annotating an internal member a dependent sees through InternalsVisibleTo + /// changes the surface. + /// + [Test] + public void InternalAnnotationChangeChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + InternalsVisibleToDependent + "public class C { internal static string M() => \"\"; public int P; }", + InternalsVisibleToDependent + "public class C { internal static string? M() => null; public int P; }", + "class D { int F() => C.M().Length; }"); + } + + /// + /// Verifies that turning IntPtr into nint, which differs only in an attribute of an embedded + /// type, changes the surface. + /// + [Test] + public void NativeIntegerChangeChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public class C { public static System.IntPtr M(System.IntPtr x) => x; }", + "public class C { public static nint M(nint x) => x; }", + "class D { long F() { return (long)(C.M(default) * 2); } }"); + } + + /// + /// Verifies that annotating a type argument of the type containing a nested return type changes + /// the surface. + /// + [Test] + public void ContainingTypeArgumentAnnotationChangeChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "using System.Collections.Generic;\npublic class C { public static Dictionary.ValueCollection M() => null!; }", + "using System.Collections.Generic;\npublic class C { public static Dictionary.ValueCollection M() => null!; }", + "class D { int F() { int n = 0; foreach (string v in C.M()) n += v.Length; return n; } }"); + } + + /// + /// Verifies that enabling nullable annotations on an interface with an unconstrained method type + /// parameter changes the surface. + /// + [Test] + public void ObliviousUnconstrainedTypeParameterToEnabledChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "#nullable disable\npublic interface I { void M(); }", + "public interface I { void M(); }", + "class D : I { public void M() where T : notnull { } }"); + } + + /// + /// Verifies that adding an attribute to the return value of a public method changes the surface. + /// + [Test] + public void ReturnAttributeAddedChangesTheSurface() + { + // .NET Standard 2.0 has no MaybeNullAttribute, so the library declares its own, the way + // libraries targeting it do; the compiler reads the attribute by name. + const string MaybeNull = "namespace System.Diagnostics.CodeAnalysis { [System.AttributeUsage(System.AttributeTargets.ReturnValue)] internal sealed class MaybeNullAttribute : System.Attribute { } }\n"; + SurfaceFixture.AssertDifferent( + MaybeNull + "public class C { public static string M() => \"\"; }", + MaybeNull + "public class C { [return: System.Diagnostics.CodeAnalysis.MaybeNull] public static string M() => \"\"; }", + "class D { int F() => C.M().Length; }"); + } + + /// + /// Verifies that members without a nullable position keep the surface when private fields move + /// the context of their type. + /// + [Test] + public void PositionFreeMembersKeepTheSurfaceWhenTheContextMoves() + { + SurfaceFixture.AssertSame( + "public enum Kind { A, B }\npublic class C { public int? N { get; set; } public Kind K { get; set; } }", + "public enum Kind { A, B }\npublic class C { public int? N { get; set; } public Kind K { get; set; }" + TwoPrivateStrings + "}", + "class D { int F() { C c = new C(); return (c.N ?? 0) + (int)c.K; } }"); + } + + /// + /// Verifies that annotating the element type of a returned array changes the surface. + /// + [Test] + public void ArrayElementAnnotationChangeChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public class C { public static string[] M() => new string[0]; }", + "public class C { public static string?[] M() => new string?[0]; }", + "class D { int F() => C.M()[0].Length; }"); + } + + /// + /// Verifies that a base type keeps its annotation when private fields move the context of the + /// type deriving from it. + /// + [Test] + public void BaseTypeKeepsItsAnnotationWhenTheTypeContextMoves() + { + SurfaceFixture.AssertSame( + "public class C : System.Collections.Generic.List { public int X; }", + "public class C : System.Collections.Generic.List { public int X;" + ThreePrivateNullableObjects + "}", + "class D { int F() { C c = new C(); c.Add(1); return c.Count + c.X; } }"); + } + + /// + /// Verifies that a file that is not an assembly is never called the same surface. + /// + [Test] + public void UnreadableAssemblyIsNotTheSameSurface() + { + byte[] assembly = SurfaceFixture.EmitReferenceAssembly("public class C { }"); + + CompareSurfaceResponse response = SurfaceFixture.Compare(assembly, new byte[] { 1, 2, 3 }); + + Assert.That(response.Outcome, Is.EqualTo(CompareSurfaceResponse.DifferentOutcome)); + Assert.That(response.Detail, Does.StartWith("could not read: ")); + } + + /// + /// Verifies that two assemblies built from the same source keep the surface. + /// + [Test] + public void IdenticalAssembliesKeepTheSurface() + { + byte[] assembly = SurfaceFixture.EmitReferenceAssembly("public class C { public string? P { get; set; } }"); + + CompareSurfaceResponse response = SurfaceFixture.Compare(assembly, (byte[])assembly.Clone()); + + Assert.That(response.Outcome, Is.EqualTo(CompareSurfaceResponse.SameOutcome), response.Detail); + Assert.That(response.Detail, Is.Empty); + } + + /// + /// Verifies that the worker's command dispatch runs a compare-surface request and writes its + /// outcome to the response file. + /// + [Test] + public void CompareSurfaceCommandIsDispatched() + { + string directory = Path.Combine(Path.GetTempPath(), "compile-check-dispatch-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string assemblyPath = Path.Combine(directory, "lib.ref.dll"); + File.WriteAllBytes(assemblyPath, SurfaceFixture.EmitReferenceAssembly("public class C { }")); + string requestPath = Path.Combine(directory, "in.json"); + string responsePath = Path.Combine(directory, "out.json"); + File.WriteAllBytes(requestPath, JsonSerializer.SerializeToUtf8Bytes(new CompareSurfaceRequest + { + ProducedPath = assemblyPath, + UnityBuildPath = assemblyPath, + })); + + Program.RunCommand(Program.CompareSurfaceCommandName, requestPath, responsePath); + + CompareSurfaceResponse response = JsonSerializer.Deserialize(File.ReadAllBytes(responsePath)); + Assert.That(response.Outcome, Is.EqualTo(CompareSurfaceResponse.SameOutcome)); + } + finally + { + Directory.Delete(directory, true); + } + } + + /// + /// Verifies that annotating a type argument of a generic value type changes the surface. + /// + [Test] + public void GenericValueTypeArgumentAnnotationChangeChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public class C { public System.Collections.Generic.KeyValuePair P; }", + "public class C { public System.Collections.Generic.KeyValuePair P; }", + "class D { int F() => new C().P.Value.Length; }"); + } + + /// + /// Verifies that adding a private method before a lambda, which only renumbers the cached + /// delegate fields of the compiler's private closure class, keeps the surface. + /// + [Test] + public void PrivateMemberAddedBeforeALambdaKeepsTheSurface() + { + string oldSource = "#nullable disable\npublic class C { public int Sum(int[] xs) => System.Linq.Enumerable.Sum(System.Linq.Enumerable.Select(xs, x => x * 2)); }"; + string newSource = "#nullable disable\npublic class C { private void Before() { } public int Sum(int[] xs) => System.Linq.Enumerable.Sum(System.Linq.Enumerable.Select(xs, x => x * 2)); }"; + + AssertOnlyPrivateNestedTypesDiffer(oldSource, newSource); + SurfaceFixture.AssertSame(oldSource, newSource, "class D { int F() => new C().Sum(new int[0]); }"); + } + + /// + /// Verifies that making a public nested type private changes the surface. + /// + [Test] + public void NestedTypeMadePrivateChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public class Outer { public class N { public int V; } }", + "public class Outer { private class N { public int V; } }", + "class D { int F() => new Outer.N().V; }"); + } + + /// + /// Verifies that adding a private nested class nothing outside its type refers to keeps the + /// surface. + /// + [Test] + public void PrivateNestedTypeChangeKeepsTheSurface() + { + string oldSource = "public class Outer { public int V; }"; + string newSource = "public class Outer { public int V; private class P { public string S = \"\"; public int M() => 1; } }"; + + AssertOnlyPrivateNestedTypesDiffer(oldSource, newSource); + SurfaceFixture.AssertSame(oldSource, newSource, "class D { int F() => new Outer().V; }"); + } + + /// + /// Verifies that a private nested type a public interface implementation names reaches the + /// dependent, so dropping one of its interfaces changes the surface. + /// + [Test] + public void PrivateNestedTypeInAnInterfaceChangesTheSurface() + { + const string Usings = "using System; using System.Collections; using System.Collections.Generic;\n"; + SurfaceFixture.AssertDifferent( + Usings + "public class A : IEnumerable { private class X : ICloneable { public object Clone() => this; } IEnumerator IEnumerable.GetEnumerator() => null!; IEnumerator IEnumerable.GetEnumerator() => null!; }", + Usings + "public class A : IEnumerable { private class X { public object Clone() => this; } IEnumerator IEnumerable.GetEnumerator() => null!; IEnumerator IEnumerable.GetEnumerator() => null!; }", + "using System; using System.Collections.Generic;\nclass D { IEnumerable F() => new A(); }"); + } + + /// + /// Verifies that a private nested struct behind a private field of a public struct reaches the + /// dependent, so giving it a reference-type field changes the surface. + /// + [Test] + public void PrivateNestedStructBehindAFieldChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public struct S { private Inner _x; private struct Inner { private int _a; } }", + "public struct S { private Inner _x; private struct Inner { private object? _a; } }", + "unsafe class D { static void G() where T : unmanaged { } void F() { S* p = null; G(); } }"); + } + + /// + /// Verifies that giving an empty private nested struct behind a private field a field changes + /// the surface, since the dependent's definite assignment depends on it. + /// + [Test] + public void PrivateNestedStructGainingAFieldChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public struct S { private Inner _x; private struct Inner { } }", + "public struct S { private Inner _x; private struct Inner { private int _a; } }", + "class D { object F() { S s; return s; } }"); + } + + /// + /// Verifies that adding a private method before a coroutine, which only renumbers the compiler's + /// state machine type the iterator attribute names, keeps the surface. + /// + [Test] + public void PrivateMemberAddedBeforeAnIteratorKeepsTheSurface() + { + SurfaceFixture.AssertSame( + "public class C { public System.Collections.IEnumerator Run() { yield return null; } }", + "public class C { private void Before() { } public System.Collections.IEnumerator Run() { yield return null; } }", + "class D { object F() => new C().Run(); }"); + } + + /// + /// Verifies that adding a private method before an async method, which only renumbers the + /// compiler's state machine type the async attribute names, keeps the surface. + /// + [Test] + public void PrivateMemberAddedBeforeAnAsyncMethodKeepsTheSurface() + { + SurfaceFixture.AssertSame( + "public class C { public async System.Threading.Tasks.Task M() { await System.Threading.Tasks.Task.Yield(); } }", + "public class C { private void Before() { } public async System.Threading.Tasks.Task M() { await System.Threading.Tasks.Task.Yield(); } }", + "class D { System.Threading.Tasks.Task F() => new C().M(); }"); + } + + /// + /// Verifies that a private protected nested type reaches a derived dependent through + /// InternalsVisibleTo, so removing its member changes the surface. + /// + [Test] + public void PrivateProtectedNestedTypeChangeChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + InternalsVisibleToDependent + "public class Outer { private protected class PP { public int V; } }", + InternalsVisibleToDependent + "public class Outer { private protected class PP { } }", + "class D : Outer { int F() => new PP().V; }"); + } + + /// + /// Verifies that a nested type keeps the surface when private members move the context of the + /// type around it: the nested type's members then carry a context of their own instead of + /// inheriting the outer one, with the same effective annotations. + /// + [Test] + public void NestedTypeKeepsTheSurfaceWhenTheOuterTypeContextMoves() + { + SurfaceFixture.AssertSame( + "public class Outer { public string P() => \"\"; public class Inner { public string M() => \"\"; public string N() => \"\"; } }", + "public class Outer { public string P() => \"\";" + ThreePrivateNullableObjects + "public class Inner { public string M() => \"\"; public string N() => \"\"; } }", + "class D { int F() => new Outer.Inner().M().Length; }"); + } + + /// + /// Verifies that sending the internals to another assembly changes the surface: what the + /// assembly says about itself decides what a dependent may use. + /// + [Test] + public void InternalsVisibleToTargetChangeChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + InternalsVisibleToDependent + "public class C { internal static int M() => 0; }", + "[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(\"Other\")]\npublic class C { internal static int M() => 0; }", + "class D { int F() => C.M(); }"); + } + + /// + /// Verifies that renaming a parameter changes the surface, since a dependent can pass it by name. + /// + [Test] + public void ParameterRenameChangesTheSurface() + { + SurfaceFixture.AssertDifferent( + "public class C { public static void M(int a) { } }", + "public class C { public static void M(int b) { } }", + "class D { void F() => C.M(a: 1); }"); + } + + private static void AssertOnlyPrivateNestedTypesDiffer(string oldSource, string newSource) + { + Assert.That( + SurfaceFixture.PrivateNestedMemberNames(SurfaceFixture.EmitReferenceAssembly(newSource)), + Is.Not.EqualTo(SurfaceFixture.PrivateNestedMemberNames(SurfaceFixture.EmitReferenceAssembly(oldSource))), + "the private nested types of the two versions are the same, so they are not what this case changes"); + } + } +} diff --git a/tests/CompileCheckWorker.UnitTests/CompileCheckWorker.UnitTests.csproj b/tests/CompileCheckWorker.UnitTests/CompileCheckWorker.UnitTests.csproj index 9279afe66a..dd39f95fd1 100644 --- a/tests/CompileCheckWorker.UnitTests/CompileCheckWorker.UnitTests.csproj +++ b/tests/CompileCheckWorker.UnitTests/CompileCheckWorker.UnitTests.csproj @@ -18,6 +18,14 @@ + + + + + + diff --git a/tests/CompileCheckWorker.UnitTests/SurfaceFixture.cs b/tests/CompileCheckWorker.UnitTests/SurfaceFixture.cs new file mode 100644 index 0000000000..48d533b093 --- /dev/null +++ b/tests/CompileCheckWorker.UnitTests/SurfaceFixture.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using NUnit.Framework; + +namespace UnityCliLoop.CompileCheckWorker.UnitTests +{ + /// + /// Builds the reference assemblies of two versions of a library the way Unity does, compiles a + /// dependent against each, and asks the worker whether the two describe the same surface. + /// + internal static class SurfaceFixture + { + private const string LibraryAssemblyName = "Lib"; + + // The name a library's InternalsVisibleTo has to give for the dependent to see its internals. + internal const string DependentAssemblyName = "Dep"; + + /// + /// Asserts that the two versions build different bytes that a dependent reads the same way, + /// and that the worker calls them the same surface. + /// + internal static void AssertSame(string oldSource, string newSource, string dependentSource) + { + byte[] oldAssembly = EmitReferenceAssembly(oldSource); + byte[] newAssembly = EmitReferenceAssembly(newSource); + Assert.That(newAssembly, Is.Not.EqualTo(oldAssembly), + "the two versions built identical bytes, so the comparison was never exercised"); + Assert.That(DependentDiagnostics(newAssembly, dependentSource), + Is.EqualTo(DependentDiagnostics(oldAssembly, dependentSource)), + "the dependent compiles differently, so the case is not a same-surface case"); + CompareSurfaceResponse response = Compare(oldAssembly, newAssembly); + Assert.That(response.Outcome, Is.EqualTo(CompareSurfaceResponse.SameOutcome), response.Detail); + } + + /// + /// Asserts that a dependent reads the two versions differently, and that the worker calls them + /// different surfaces. + /// + internal static void AssertDifferent(string oldSource, string newSource, string dependentSource) + { + byte[] oldAssembly = EmitReferenceAssembly(oldSource); + byte[] newAssembly = EmitReferenceAssembly(newSource); + Assert.That(DependentDiagnostics(newAssembly, dependentSource), + Is.Not.EqualTo(DependentDiagnostics(oldAssembly, dependentSource)), + "the dependent compiles the same way, so the case does not show a real difference"); + CompareSurfaceResponse response = Compare(oldAssembly, newAssembly); + Assert.That(response.Outcome, Is.EqualTo(CompareSurfaceResponse.DifferentOutcome)); + } + + /// + /// Builds the reference assembly of a library with nullable annotations enabled, the way Unity + /// builds it with -refout and /deterministic. + /// + internal static byte[] EmitReferenceAssembly(string source) + { + CSharpCompilation compilation = CSharpCompilation.Create( + LibraryAssemblyName, + new[] { Parse(source) }, + new[] { StandardLibrary() }, + CompilationOptions()); + using MemoryStream assembly = new MemoryStream(); + using MemoryStream referenceAssembly = new MemoryStream(); + EmitResult result = compilation.Emit( + assembly, + metadataPEStream: referenceAssembly, + options: new EmitOptions().WithIncludePrivateMembers(false)); + Assert.That(result.Success, Is.True, string.Join("\n", result.Diagnostics)); + + return referenceAssembly.ToArray(); + } + + /// + /// Compiles a dependent against a reference assembly and lists its warnings and errors as + /// "ID@line:column", in a fixed order. + /// + internal static string DependentDiagnostics(byte[] referenceAssembly, string source) + { + CSharpCompilation compilation = CSharpCompilation.Create( + DependentAssemblyName, + new[] { Parse(source) }, + new[] { StandardLibrary(), MetadataReference.CreateFromImage(referenceAssembly) }, + CompilationOptions()); + using MemoryStream output = new MemoryStream(); + string[] diagnostics = compilation.Emit(output).Diagnostics + .Where(diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning) + .Select(Describe) + .OrderBy(text => text, StringComparer.Ordinal) + .ToArray(); + + return diagnostics.Length == 0 ? "(none)" : string.Join(" ", diagnostics); + } + + /// + /// Writes the two assemblies to files and runs the worker's comparison on them. + /// + internal static CompareSurfaceResponse Compare(byte[] unityBuild, byte[] produced) + { + string directory = Path.Combine(Path.GetTempPath(), "compile-check-surface-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string unityBuildPath = Path.Combine(directory, "unity.ref.dll"); + string producedPath = Path.Combine(directory, "produced.ref.dll"); + File.WriteAllBytes(unityBuildPath, unityBuild); + File.WriteAllBytes(producedPath, produced); + + return CompareSurfaceCommand.Run(new CompareSurfaceRequest + { + ProducedPath = producedPath, + UnityBuildPath = unityBuildPath, + }); + } + finally + { + Directory.Delete(directory, true); + } + } + + /// + /// Lists every field and method name inside the private nested types of a reference assembly, + /// qualified by the type, so a test can show that those types are what differs. + /// + internal static List PrivateNestedMemberNames(byte[] referenceAssembly) + { + using PEReader peReader = new PEReader(ImmutableArray.Create(referenceAssembly)); + MetadataReader reader = peReader.GetMetadataReader(); + List names = new List(); + foreach (TypeDefinitionHandle handle in reader.TypeDefinitions) + { + TypeDefinition type = reader.GetTypeDefinition(handle); + if ((type.Attributes & TypeAttributes.VisibilityMask) != TypeAttributes.NestedPrivate) + { + continue; + } + string typeName = reader.GetString(type.Name); + names.Add(typeName); + names.AddRange(type.GetFields().Select(field => typeName + "::" + reader.GetString(reader.GetFieldDefinition(field).Name))); + names.AddRange(type.GetMethods().Select(method => typeName + "::" + reader.GetString(reader.GetMethodDefinition(method).Name))); + } + + return names; + } + + private static SyntaxTree Parse(string source) + { + return CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.CSharp9)); + } + + private static CSharpCompilationOptions CompilationOptions() + { + return new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable, + deterministic: true, + allowUnsafe: true); + } + + private static MetadataReference StandardLibrary() + { + return MetadataReference.CreateFromFile(Path.Combine(AppContext.BaseDirectory, "SurfaceReferences", "netstandard.dll")); + } + + private static string Describe(Diagnostic diagnostic) + { + FileLinePositionSpan span = diagnostic.Location.GetLineSpan(); + + return diagnostic.Id + "@" + (span.StartLinePosition.Line + 1) + ":" + (span.StartLinePosition.Character + 1); + } + } +} From 32b4a6dc36fad05a8acbf279deaf0f5d8b8291fc Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 20:07:28 +0900 Subject: [PATCH 22/33] feat: compile-check skips dependents when a rebuilt assembly keeps the same API in different bytes (#3341) --- .../CliOnlyTools~/CompileCheck/Skill/SKILL.md | 5 +- .../compilecheck/baseline_record_test.go | 9 +- .../internal/compilecheck/compiler.go | 17 +- .../internal/compilecheck/fast_path.go | 11 +- .../compilecheck/fast_path_fixture_test.go | 51 +++ .../internal/compilecheck/fast_path_test.go | 10 +- .../internal/compilecheck/fastcheck/client.go | 37 ++ .../compilecheck/fastcheck/client_test.go | 54 +++ .../compilecheck/reference_surface.go | 35 -- .../compilecheck/resultstore/surface.go | 87 +++++ .../compilecheck/resultstore/surface_test.go | 86 ++++ .../internal/compilecheck/scheduler.go | 16 +- .../internal/compilecheck/surface_verdict.go | 217 +++++++++++ ..._test.go => surface_verdict_bytes_test.go} | 45 ++- .../compilecheck/surface_verdict_test.go | 367 ++++++++++++++++++ .../dispatcher/compile_check_vibe_log.go | 23 ++ .../dispatcher/compile_check_vibe_log_test.go | 21 +- docs/compile-check.md | 39 +- docs/vibe-logs.md | 2 +- 19 files changed, 1049 insertions(+), 83 deletions(-) delete mode 100644 cli/dispatcher/internal/compilecheck/reference_surface.go create mode 100644 cli/dispatcher/internal/compilecheck/resultstore/surface.go create mode 100644 cli/dispatcher/internal/compilecheck/resultstore/surface_test.go create mode 100644 cli/dispatcher/internal/compilecheck/surface_verdict.go rename cli/dispatcher/internal/compilecheck/{reference_surface_test.go => surface_verdict_bytes_test.go} (62%) create mode 100644 cli/dispatcher/internal/compilecheck/surface_verdict_test.go diff --git a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md index 887d109e5d..3dbcaca77b 100644 --- a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md @@ -60,8 +60,9 @@ with `--full` before finishing. When the project treats warnings as errors, a ru `UncheckedCodes`, such as CS0414, can fail that `--full` run only. The first body edit after a Unity build, after a full compile that had errors (a `--full` run or a run that fell back to one), after a run that compiled it only because an assembly it references changed, after a run that skipped the -assembly, or after `--all` in an assembly that references one whose public surface differs from the last -Unity build is compiled in full and records what the next check compares against; `FastPathFallbacks` says why. +assembly, or after `--all` in an assembly that references one whose rebuilt API differed from the +last Unity build and that the next run no longer rebuilds is compiled in full and records what the +next check compares against; `FastPathFallbacks` says why. ## Parameters diff --git a/cli/dispatcher/internal/compilecheck/baseline_record_test.go b/cli/dispatcher/internal/compilecheck/baseline_record_test.go index b84ce7279f..3a8e04d9b8 100644 --- a/cli/dispatcher/internal/compilecheck/baseline_record_test.go +++ b/cli/dispatcher/internal/compilecheck/baseline_record_test.go @@ -65,8 +65,12 @@ func TestBaselineNotRecordedForDependentUnit(t *testing.T) { // Verifies that an assembly with fewer sources than the minimum records no baseline. func TestBaselineNotRecordedForSmallAssembly(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources-1) + // Why Alpha keeps Unity's reference assembly: a differing one is compared by the worker, which + // would build it for a reason other than the changed-files check. + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + recorder.surfaces["Alpha"] = keptSurfaceOf("Alpha") - project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + project.run(t, recorder, false) if project.hasBaseline() || project.worker.ensures != 0 { t.Errorf("expected no baseline and no worker for a small assembly, got %d builds", project.worker.ensures) @@ -187,6 +191,9 @@ func TestBootstrappedWorkerBuildsAtMostOncePerRun(t *testing.T) { if _, err := worker.Check(context.Background(), paths, fastcheck.CheckRequest{}); err == nil { t.Error("expected a failing check call to be an error") } + if _, err := worker.CompareSurface(context.Background(), paths, fastcheck.CompareSurfaceRequest{}); err == nil { + t.Error("expected a failing surface comparison to be an error") + } } // Verifies that a worker diagnostic in a file that is not a source of the assembly, such as saved diff --git a/cli/dispatcher/internal/compilecheck/compiler.go b/cli/dispatcher/internal/compilecheck/compiler.go index a519ded479..983dd4f9cf 100644 --- a/cli/dispatcher/internal/compilecheck/compiler.go +++ b/cli/dispatcher/internal/compilecheck/compiler.go @@ -77,6 +77,9 @@ type UnitResult struct { FastPathFallbackReason string // FastPathNote says why a successful full compile left no baseline for the next fast check. FastPathNote string + // Surface says whether the unit's reference assembly kept the API of the last Unity build, for a + // successful unit something in the plan references; zero for any other unit. + Surface SurfaceVerdict } // CommandRunner runs one compiler invocation, so tests can stand in for the real process. @@ -91,19 +94,19 @@ type Compiler struct { // FastPath checks only the changed files when it can; nil compiles every unit in full. FastPath *fastPathRunner // compareSurface stands in for the comparison of a unit's reference assembly with Unity's; nil - // compares the two files. Why it can be replaced: the tests have to see which units the run - // compares at all, and reading two files leaves nothing behind to count. + // compares the two. Why it can be replaced: the tests have to see which units the run compares at + // all, and a byte comparison leaves nothing behind to count. compareSurface func(projectRoot string, plan BuildPlan, unit CompileUnit) bool } -// keptSurface reports whether compiling this unit left its reference assembly describing the same -// public surface as the one the last Unity build produced. -func (c Compiler) keptSurface(plan BuildPlan, unit CompileUnit) bool { +// surfaceVerdict says whether compiling this unit left its reference assembly showing its dependents +// the public surface the last Unity build showed them. +func (c Compiler) surfaceVerdict(ctx context.Context, plan BuildPlan, unit CompileUnit) SurfaceVerdict { if c.compareSurface != nil { - return c.compareSurface(c.ProjectRoot, plan, unit) + return SurfaceVerdict{Kept: c.compareSurface(c.ProjectRoot, plan, unit)} } - return referenceSurfaceUnchanged(c.ProjectRoot, plan, unit) + return c.referenceSurfaceVerdict(ctx, plan, unit) } // CompileUnit compiles one assembly and reports the diagnostics csc produced for it. diff --git a/cli/dispatcher/internal/compilecheck/fast_path.go b/cli/dispatcher/internal/compilecheck/fast_path.go index 571e719309..0401dab087 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path.go +++ b/cli/dispatcher/internal/compilecheck/fast_path.go @@ -25,12 +25,13 @@ const ( fastPathMaximumChangedFiles = 30 ) -// fastCheckWorker is what the changed-files check needs from its worker, so tests can stand in -// for the real build and process. +// fastCheckWorker is what the changed-files check and the surface comparison need from their +// worker, so tests can stand in for the real build and process. type fastCheckWorker interface { Ensure(ctx context.Context) (fastcheck.WorkerPaths, error) Fingerprint(ctx context.Context, worker fastcheck.WorkerPaths, request fastcheck.FingerprintRequest) (fastcheck.FingerprintResponse, error) Check(ctx context.Context, worker fastcheck.WorkerPaths, request fastcheck.CheckRequest) (fastcheck.CheckResponse, error) + CompareSurface(ctx context.Context, worker fastcheck.WorkerPaths, request fastcheck.CompareSurfaceRequest) (fastcheck.CompareSurfaceResponse, error) } // fastPathRunner checks only the changed files of a unit against the last successful full compile, @@ -361,6 +362,12 @@ func (w *bootstrappedWorker) Check( return w.client(worker).Check(ctx, request) } +func (w *bootstrappedWorker) CompareSurface( + ctx context.Context, worker fastcheck.WorkerPaths, request fastcheck.CompareSurfaceRequest, +) (fastcheck.CompareSurfaceResponse, error) { + return w.client(worker).CompareSurface(ctx, request) +} + func (w *bootstrappedWorker) client(worker fastcheck.WorkerPaths) fastcheck.Client { return fastcheck.Client{Worker: worker, TempDirectory: w.tempDirectory, Run: w.run} } diff --git a/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go index 2a23fec558..09ce05083c 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io/fs" "os" @@ -38,6 +39,23 @@ type fakeFastCheckWorker struct { fingerprints int checks int lastCheck fastcheck.CheckRequest + // surfaceOutcome is what CompareSurface answers; empty answers different, so a test that does + // not care about the comparison sees every differing reference assembly as a changed surface. + surfaceOutcome string + surfaceErr error + // surfaceByContent answers same exactly when the two files handed over hold the same bytes, + // instead of answering surfaceOutcome. + surfaceByContent bool + // onCompare runs as a comparison starts, before the worker reads what it was handed. + onCompare func() + // surfaceComparisons lists what every comparison was handed, in call order. + surfaceComparisons []fakeSurfaceComparison +} + +// fakeSurfaceComparison is what one comparison read from the two paths it was handed. +type fakeSurfaceComparison struct { + Produced string + UnityBuild string } func newFakeFastCheckWorker() *fakeFastCheckWorker { @@ -107,6 +125,39 @@ func (worker *fakeFastCheckWorker) Check( }, nil } +func (worker *fakeFastCheckWorker) CompareSurface( + _ context.Context, _ fastcheck.WorkerPaths, request fastcheck.CompareSurfaceRequest, +) (fastcheck.CompareSurfaceResponse, error) { + worker.mutex.Lock() + defer worker.mutex.Unlock() + if worker.onCompare != nil { + worker.onCompare() + } + produced, producedErr := os.ReadFile(request.ProducedPath) + unityBuild, unityBuildErr := os.ReadFile(request.UnityBuildPath) + if producedErr != nil || unityBuildErr != nil { + return fastcheck.CompareSurfaceResponse{}, errors.Join(producedErr, unityBuildErr) + } + worker.surfaceComparisons = append(worker.surfaceComparisons, + fakeSurfaceComparison{Produced: string(produced), UnityBuild: string(unityBuild)}) + if worker.surfaceErr != nil { + return fastcheck.CompareSurfaceResponse{}, worker.surfaceErr + } + if worker.surfaceByContent { + outcome := fastcheck.DifferentSurfaceOutcome + if string(produced) == string(unityBuild) { + outcome = fastcheck.SameSurfaceOutcome + } + + return fastcheck.CompareSurfaceResponse{Outcome: outcome}, nil + } + if worker.surfaceOutcome == "" { + return fastcheck.CompareSurfaceResponse{Outcome: fastcheck.DifferentSurfaceOutcome, Detail: "fake difference"}, nil + } + + return fastcheck.CompareSurfaceResponse{Outcome: worker.surfaceOutcome}, nil +} + // fakeDeclarationOf is the fake fingerprint: everything before the separator. func fakeDeclarationOf(content string) string { declaration, _, _ := strings.Cut(content, fakeDeclarationSeparator) diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go index 60a2b6845b..32c069a9d2 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -171,10 +171,16 @@ func TestNonSourceKeyMismatchFallsBack(t *testing.T) { // full without a fallback reason on every run. func TestSmallAssemblyIsNotTried(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources-1) - project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + // Why Alpha keeps Unity's reference assembly: a differing one is compared by the worker, which + // would build it for a reason other than the changed-files check. + first := newCachedSchedulerRecorder("Alpha", "Beta") + first.surfaces["Alpha"] = keptSurfaceOf("Alpha") + project.run(t, first, false) project.editBody(t, 0, "edited") - outcome := project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + recorder.surfaces["Alpha"] = keptSurfaceOf("Alpha") + outcome := project.run(t, recorder, false) assertNotTried(t, outcome, project.worker) if project.worker.ensures != 0 || project.hasBaseline() { diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/client.go b/cli/dispatcher/internal/compilecheck/fastcheck/client.go index 67f2ebe488..14de2c5368 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/client.go +++ b/cli/dispatcher/internal/compilecheck/fastcheck/client.go @@ -14,6 +14,7 @@ import ( const ( fingerprintCommandName = "fingerprint" checkCommandName = "check" + compareSurfaceCommand = "compare-surface" defaultWorkerTimeout = 60 * time.Second requestFileSuffix = ".in.json" responseFileSuffix = ".out.json" @@ -25,6 +26,13 @@ const ( DeclarationChangedOutcome = "DeclarationChanged" // UnsupportedOutcome means the assembly uses something the changed-files check cannot handle. UnsupportedOutcome = "Unsupported" + + // SameSurfaceOutcome means every assembly compiling against either reference assembly sees the + // same API. + SameSurfaceOutcome = "same" + // DifferentSurfaceOutcome means the two reference assemblies may show a dependent a different API, + // including when the worker could not read one of them. + DifferentSurfaceOutcome = "different" ) // The request and response types mirror the worker's JSON contract field for field. Field names @@ -62,6 +70,19 @@ type CheckResponse struct { GeneratorsNotRerun bool } +// CompareSurfaceRequest asks whether two reference assemblies of one assembly show its dependents +// the same API. +type CompareSurfaceRequest struct { + ProducedPath string // absolute + UnityBuildPath string // absolute +} + +// CompareSurfaceResponse is the answer of one surface comparison. +type CompareSurfaceResponse struct { + Outcome string + Detail string // the first line of the two descriptions that differs, or why one could not be read +} + // WorkerDiagnostic is one error or warning in a changed file, with a 1-based line and column. type WorkerDiagnostic struct { File string // absolute @@ -108,6 +129,22 @@ func (c Client) Check(ctx context.Context, request CheckRequest) (CheckResponse, } } +// CompareSurface asks whether two reference assemblies show their dependents the same API. +func (c Client) CompareSurface(ctx context.Context, request CompareSurfaceRequest) (CompareSurfaceResponse, error) { + response := CompareSurfaceResponse{} + if err := c.invoke(ctx, compareSurfaceCommand, request, &response); err != nil { + return CompareSurfaceResponse{}, err + } + + // Rejected here so an answer the worker does not define is never read as either. + switch response.Outcome { + case SameSurfaceOutcome, DifferentSurfaceOutcome: + return response, nil + default: + return CompareSurfaceResponse{}, fmt.Errorf("changed-files check worker returned an unknown outcome %q", response.Outcome) + } +} + // invoke writes the request, runs the worker command, and reads its response into response. func (c Client) invoke(ctx context.Context, command string, request any, response any) error { if c.Worker.DllPath == "" { diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/client_test.go b/cli/dispatcher/internal/compilecheck/fastcheck/client_test.go index 0d6dfeaee2..cfdce19940 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/client_test.go +++ b/cli/dispatcher/internal/compilecheck/fastcheck/client_test.go @@ -223,3 +223,57 @@ func TestUnknownOutcomeIsError(t *testing.T) { } } } + +// Verifies that CompareSurface runs the compare-surface command with both reference assembly paths +// and parses the worker's answer. +func TestCompareSurfaceParsesResponse(t *testing.T) { + request := CompareSurfaceRequest{ProducedPath: "/project/out/Sample.ref.dll", UnityBuildPath: "/project/Library/Sample.ref.dll"} + var sentArguments []string + var sentRequest map[string]any + client := newTestClient(t, func(_ context.Context, cmd *exec.Cmd) (string, string, int, error) { + sentArguments = cmd.Args + content, err := os.ReadFile(cmd.Args[len(cmd.Args)-2]) + if err != nil { + return "", err.Error(), 1, err + } + if err := json.Unmarshal(content, &sentRequest); err != nil { + return "", err.Error(), 1, err + } + + response := `{"Outcome":"different","Detail":"produced: a / Unity build: b"}` + return "", "", 0, os.WriteFile(cmd.Args[len(cmd.Args)-1], []byte(response), cacheFilePermissions) + }) + + got, err := client.CompareSurface(context.Background(), request) + if err != nil { + t.Fatalf("expected the comparison to succeed, got error: %v", err) + } + + want := CompareSurfaceResponse{Outcome: DifferentSurfaceOutcome, Detail: "produced: a / Unity build: b"} + if got != want { + t.Errorf("expected %+v, got %+v", want, got) + } + if sentArguments[len(sentArguments)-3] != "compare-surface" { + t.Errorf("expected the compare-surface command, got arguments %v", sentArguments) + } + wantRequest := map[string]any{"ProducedPath": request.ProducedPath, "UnityBuildPath": request.UnityBuildPath} + if !reflect.DeepEqual(sentRequest, wantRequest) { + t.Errorf("expected the request %v, got %v", wantRequest, sentRequest) + } + assertNoRequestFilesLeft(t, client.TempDirectory) +} + +// Verifies that a comparison answer other than same or different is an error, so a caller never +// reads an unexpected answer as either. +func TestCompareSurfaceUnknownOutcomeIsError(t *testing.T) { + for _, response := range []string{`{"Outcome":""}`, `{}`, `{"Outcome":"Same"}`} { + client := newTestClient(t, func(_ context.Context, cmd *exec.Cmd) (string, string, int, error) { + return "", "", 0, os.WriteFile(cmd.Args[len(cmd.Args)-1], []byte(response), cacheFilePermissions) + }) + + _, err := client.CompareSurface(context.Background(), CompareSurfaceRequest{}) + if err == nil || !strings.Contains(err.Error(), "unknown outcome") { + t.Fatalf("expected an unknown outcome error for %s, got %v", response, err) + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/reference_surface.go b/cli/dispatcher/internal/compilecheck/reference_surface.go deleted file mode 100644 index ca8a9884ed..0000000000 --- a/cli/dispatcher/internal/compilecheck/reference_surface.go +++ /dev/null @@ -1,35 +0,0 @@ -package compilecheck - -import ( - "bytes" - "os" - "path/filepath" -) - -// referenceSurfaceUnchanged reports whether compiling this unit produced the very reference assembly -// the last Unity build produced for it, which means the change it carries does not reach its public -// surface: every assembly that references it would compile against exactly the same API. -// Why the comparison is against Unity's own artifact rather than this check's previous output: the -// dependents were last compiled against Unity's, so only that one answers whether they would see -// anything new. Comparing against the previous run would only say the surface has not moved since -// that run, and would hide an error a dependent has been carrying all along. -// Why anything unreadable counts as changed: an assembly that failed to compile writes no reference -// assembly at all, and compiling a dependent that did not need it costs time, while skipping one -// that did costs a missed error. -func referenceSurfaceUnchanged(projectRoot string, plan BuildPlan, unit CompileUnit) bool { - if unit.Assembly.RefOutputPath == "" { - return false - } - - produced, err := os.ReadFile( - filepath.Join(projectRoot, plan.OutputDir, filepath.Base(unit.Assembly.RefOutputPath))) - if err != nil { - return false - } - fromUnityBuild, err := os.ReadFile(filepath.Join(projectRoot, unit.Assembly.RefOutputPath)) - if err != nil { - return false - } - - return bytes.Equal(produced, fromUnityBuild) -} diff --git a/cli/dispatcher/internal/compilecheck/resultstore/surface.go b/cli/dispatcher/internal/compilecheck/resultstore/surface.go new file mode 100644 index 0000000000..948eb92ac4 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/resultstore/surface.go @@ -0,0 +1,87 @@ +package resultstore + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const ( + // surfaceRecordFormatVersion guards against reading a verdict an older or newer build recorded. + surfaceRecordFormatVersion = 1 + + surfaceRecordExtension = ".surface.json" +) + +// SurfaceInputs names the comparison a surface verdict answered: the two reference assemblies, by +// content, and the worker build that compared them. +type SurfaceInputs struct { + ProducedSha256 string + UnityBuildSha256 string + WorkerKey string +} + +// SurfaceVerdict is what the worker answered about whether two reference assemblies show their +// dependents the same API. +type SurfaceVerdict struct { + Same bool + Detail string +} + +// surfaceRecord is one recorded surface verdict with the comparison it answered. +type surfaceRecord struct { + FormatVersion int + ProducedSha256 string + UnityBuildSha256 string + WorkerKey string + Same bool + Detail string +} + +func surfaceRecordPath(directoryPath string, assembly string) string { + return filepath.Join(directoryPath, assembly+surfaceRecordExtension) +} + +// ReusableSurfaceVerdict reports the recorded verdict of the assembly when it answered this very +// comparison. Anything unexpected means no. +// Why the record is not removed with the assembly's outputs: it is keyed by the content of both +// reference assemblies, so a record left behind can only ever answer the comparison it was made for. +func (s Store[D]) ReusableSurfaceVerdict(assembly string, inputs SurfaceInputs) (SurfaceVerdict, bool) { + if inputs.ProducedSha256 == "" || inputs.UnityBuildSha256 == "" || inputs.WorkerKey == "" { + return SurfaceVerdict{}, false + } + content, err := os.ReadFile(surfaceRecordPath(s.directoryPath(), assembly)) + if err != nil { + return SurfaceVerdict{}, false + } + + record := surfaceRecord{} + if err := json.Unmarshal(content, &record); err != nil { + return SurfaceVerdict{}, false + } + if record.FormatVersion != surfaceRecordFormatVersion || record.ProducedSha256 != inputs.ProducedSha256 || + record.UnityBuildSha256 != inputs.UnityBuildSha256 || record.WorkerKey != inputs.WorkerKey { + return SurfaceVerdict{}, false + } + + return SurfaceVerdict{Same: record.Same, Detail: record.Detail}, true +} + +// RecordSurfaceVerdict records what the worker answered for one comparison of the assembly's +// reference assemblies, replacing the verdict of any earlier comparison. +func (s Store[D]) RecordSurfaceVerdict(assembly string, inputs SurfaceInputs, verdict SurfaceVerdict) error { + content, err := json.Marshal(surfaceRecord{ + FormatVersion: surfaceRecordFormatVersion, + ProducedSha256: inputs.ProducedSha256, + UnityBuildSha256: inputs.UnityBuildSha256, + WorkerKey: inputs.WorkerKey, + Same: verdict.Same, + Detail: verdict.Detail, + }) + if err != nil { + return fmt.Errorf("failed to encode the surface verdict of %s: %w", assembly, err) + } + + return writeFileAtomically(s.directoryPath(), surfaceRecordPath(s.directoryPath(), assembly), content) +} diff --git a/cli/dispatcher/internal/compilecheck/resultstore/surface_test.go b/cli/dispatcher/internal/compilecheck/resultstore/surface_test.go new file mode 100644 index 0000000000..f2f34d425a --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/resultstore/surface_test.go @@ -0,0 +1,86 @@ +package resultstore + +import ( + "os" + "path/filepath" + "testing" +) + +// comparedInputs names the comparison the tests record a verdict for. +func comparedInputs() SurfaceInputs { + return SurfaceInputs{ProducedSha256: "produced", UnityBuildSha256: "unity", WorkerKey: "worker"} +} + +// recordSurface records a "same" verdict with a detail for Alpha under comparedInputs. +func recordSurface(t *testing.T, directoryPath string) { + t.Helper() + err := directoryStore(directoryPath).RecordSurfaceVerdict("Alpha", comparedInputs(), SurfaceVerdict{Same: true, Detail: "detail"}) + if err != nil { + t.Fatalf("failed to record the surface verdict: %v", err) + } +} + +// Verifies a recorded verdict is read back, with its detail, for the very comparison it answered. +func TestReusableSurfaceVerdictReadsBackTheRecordedVerdict(t *testing.T) { + directoryPath := t.TempDir() + recordSurface(t, directoryPath) + + got, found := directoryStore(directoryPath).ReusableSurfaceVerdict("Alpha", comparedInputs()) + + if !found || got != (SurfaceVerdict{Same: true, Detail: "detail"}) { + t.Errorf("expected the recorded verdict, got %+v (found %v)", got, found) + } +} + +// Verifies a recorded verdict answers no other comparison: each input on its own keeps it unused. +func TestReusableSurfaceVerdictRejectsAnotherComparison(t *testing.T) { + directoryPath := t.TempDir() + recordSurface(t, directoryPath) + cases := map[string]func(inputs *SurfaceInputs){ + "produced": func(inputs *SurfaceInputs) { inputs.ProducedSha256 = "other" }, + "unity": func(inputs *SurfaceInputs) { inputs.UnityBuildSha256 = "other" }, + "worker": func(inputs *SurfaceInputs) { inputs.WorkerKey = "other" }, + "no produced": func(inputs *SurfaceInputs) { inputs.ProducedSha256 = "" }, + } + for name, change := range cases { + inputs := comparedInputs() + change(&inputs) + if _, found := directoryStore(directoryPath).ReusableSurfaceVerdict("Alpha", inputs); found { + t.Errorf("%s: expected the record not to be used", name) + } + } +} + +// Verifies a record that is not one this build writes is not used. +func TestReusableSurfaceVerdictRejectsAnUnreadableRecord(t *testing.T) { + directoryPath := t.TempDir() + for name, content := range map[string]string{ + "not json": "not json", + "other version": `{"FormatVersion":2,"ProducedSha256":"produced","UnityBuildSha256":"unity","WorkerKey":"worker","Same":true}`, + } { + if err := os.WriteFile(filepath.Join(directoryPath, "Alpha"+surfaceRecordExtension), []byte(content), 0o600); err != nil { + t.Fatalf("failed to write the record: %v", err) + } + if _, found := directoryStore(directoryPath).ReusableSurfaceVerdict("Alpha", comparedInputs()); found { + t.Errorf("%s: expected the record not to be used", name) + } + } + if _, found := directoryStore(t.TempDir()).ReusableSurfaceVerdict("Alpha", comparedInputs()); found { + t.Error("expected no verdict without a record") + } +} + +// Verifies the surface verdict is kept apart from the records Forget removes with an assembly's +// outputs: it is keyed by content, so it can only answer the comparison it was made for. +func TestForgetKeepsTheSurfaceVerdict(t *testing.T) { + directoryPath := t.TempDir() + recordSurface(t, directoryPath) + + if err := directoryStore(directoryPath).Forget("Alpha"); err != nil { + t.Fatalf("failed to forget Alpha: %v", err) + } + + if _, found := directoryStore(directoryPath).ReusableSurfaceVerdict("Alpha", comparedInputs()); !found { + t.Error("expected the surface verdict to survive Forget") + } +} diff --git a/cli/dispatcher/internal/compilecheck/scheduler.go b/cli/dispatcher/internal/compilecheck/scheduler.go index c45899e175..42e144a793 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler.go +++ b/cli/dispatcher/internal/compilecheck/scheduler.go @@ -226,20 +226,20 @@ func (run *schedulerRun) startingUnit(index int) CompileUnit { // compileOneUnit produces one unit's result - by compiling it, or by replaying the previous run's // answer when nothing it reads has moved - and reports whether its public surface stayed the same. -// Why the comparison happens here rather than in the loop: it reads two files, and the loop has to -// stay free to start the next unit. Why a replayed unit is compared just the same: it kept the -// outputs of the previous run, so the reference assembly on disk is still the one to hold against -// Unity's. +// Why the comparison happens here rather than in the loop: it reads two files and may ask the +// worker, and the loop has to stay free to start the next unit. Why a replayed unit is compared just +// the same: it kept the outputs of the previous run, so the reference assembly on disk is still the +// one to hold against Unity's. // Why only a successful unit that something in the plan references is compared: nothing reads the // answer of any other unit - the dependents of a failed one are blocked whatever it wrote - and -// comparing costs a read of two reference assemblies for every edit of a leaf assembly. +// comparing costs a read of two reference assemblies, and possibly a worker call, for every edit of +// a leaf assembly. func (run *schedulerRun) compileOneUnit(index int, unit CompileUnit, readByDependents bool) { result, err := run.compiler.reuseOrCompile(run.runContext, run.plan, unit) - keptSurface := false if err == nil && readByDependents && result.Succeeded { - keptSurface = run.compiler.keptSurface(run.plan, unit) + result.Surface = run.compiler.surfaceVerdict(run.runContext, run.plan, unit) } - run.completions <- unitCompletion{index: index, result: result, keptSurface: keptSurface, err: err} + run.completions <- unitCompletion{index: index, result: result, keptSurface: result.Surface.Kept, err: err} } // collectOneCompletion waits for one unit to finish and hands what it produced to the schedule. diff --git a/cli/dispatcher/internal/compilecheck/surface_verdict.go b/cli/dispatcher/internal/compilecheck/surface_verdict.go new file mode 100644 index 0000000000..b0ccd81eba --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/surface_verdict.go @@ -0,0 +1,217 @@ +package compilecheck + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/fastcheck" + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/resultstore" +) + +const ( + // SurfaceSameBytes means the rebuilt reference assembly is byte-identical to Unity's. + SurfaceSameBytes = "same bytes" + // SurfaceSameAPI means the bytes differ but the worker found the same API for every dependent. + SurfaceSameAPI = "same API" + // SurfaceChanged means the worker found an API a dependent may read differently. + SurfaceChanged = "changed" + // SurfaceBytesDiffer means the bytes differ and the run had no worker to compare the API with. + SurfaceBytesDiffer = "bytes differ" + // SurfaceCouldNotComparePrefix starts the reason of a verdict the worker could not give. + SurfaceCouldNotComparePrefix = "could not compare: " + // SurfaceCouldNotReadPrefix starts the reason of a verdict whose reference assemblies could not + // both be read. + SurfaceCouldNotReadPrefix = "could not read: " +) + +// SurfaceVerdict says whether a unit's rebuilt reference assembly shows its dependents the API of +// the one the last Unity build produced, and why. +type SurfaceVerdict struct { + // Kept means every dependent compiles against the same API either way. + Kept bool + // Reason is one of the Surface* values, or starts with one of the prefixes. + Reason string + // Detail is what the worker said differs, when it answered. + Detail string + // Asked marks a verdict the worker gave, now or in a record of the same comparison. + Asked bool + // FromRecord marks a verdict taken from the record of the same comparison. + FromRecord bool + // Duration is how long asking took, record lookup included. + Duration time.Duration +} + +// surfaceComparer is what the surface comparison needs from the worker. +type surfaceComparer interface { + Ensure(ctx context.Context) (fastcheck.WorkerPaths, error) + CompareSurface( + ctx context.Context, worker fastcheck.WorkerPaths, request fastcheck.CompareSurfaceRequest, + ) (fastcheck.CompareSurfaceResponse, error) +} + +// referenceSurfaceVerdict says whether compiling this unit left its reference assembly showing every +// dependent the API the last Unity build showed it, so the dependents need not be compiled again. +// Why the comparison is against Unity's own artifact rather than this check's previous output: the +// dependents were last compiled against Unity's, so only that one answers whether they would see +// anything new. Comparing against the previous run would only say the surface has not moved since +// that run, and would hide an error a dependent has been carrying all along. +// Why differing bytes go to the worker: the compiler writes private bookkeeping into a reference +// assembly - nullable context attributes that follow private members, attribute types it embeds, +// renumbered closure and state machine types - so an edit no dependent can see still moves bytes. +// Why anything unreadable or unanswered counts as changed: compiling a dependent that did not need +// it costs time, while skipping one that did costs a missed error. +func (c Compiler) referenceSurfaceVerdict(ctx context.Context, plan BuildPlan, unit CompileUnit) SurfaceVerdict { + if unit.Assembly.RefOutputPath == "" { + return SurfaceVerdict{Reason: SurfaceCouldNotReadPrefix + "the assembly has no reference assembly"} + } + + producedPath := filepath.Join(c.ProjectRoot, plan.OutputDir, filepath.Base(unit.Assembly.RefOutputPath)) + produced, err := os.ReadFile(producedPath) + if err != nil { + return SurfaceVerdict{Reason: SurfaceCouldNotReadPrefix + "this run produced no reference assembly"} + } + unityBuildPath := filepath.Join(c.ProjectRoot, unit.Assembly.RefOutputPath) + unityBuild, err := os.ReadFile(unityBuildPath) + if err != nil { + return SurfaceVerdict{Reason: SurfaceCouldNotReadPrefix + "the last Unity build left no reference assembly"} + } + if bytes.Equal(produced, unityBuild) { + return SurfaceVerdict{Kept: true, Reason: SurfaceSameBytes} + } + + comparer := c.surfaceComparer() + if comparer == nil { + return SurfaceVerdict{Reason: SurfaceBytesDiffer} + } + + startedAt := time.Now() + verdict := c.askSurface(ctx, plan, unit, comparer, comparedPair{produced: produced, unityBuild: unityBuild}) + verdict.Asked = true + verdict.Duration = time.Since(startedAt) + + return verdict +} + +// surfaceComparer is the worker the run shares with the changed-files check, or nil without one. +// Why it is used under --full too: --full stops trying the changed-files check, while whether a +// dependent needs compiling is a separate question. +func (c Compiler) surfaceComparer() surfaceComparer { + if c.FastPath == nil || c.FastPath.Worker == nil { + return nil + } + + return c.FastPath.Worker +} + +// comparedPair is the two reference assemblies of one comparison, as read once. +type comparedPair struct { + produced []byte + unityBuild []byte +} + +// askSurface answers from the record of the same comparison when there is one, and otherwise asks +// the worker and records what it said. +// Why the worker reads copies rather than the two paths: Unity can rewrite its reference assembly +// while the worker is built or runs, and an answer about bytes other than the ones the record is +// keyed by would later answer for a pair nobody compared. +// Why only an answer is recorded: a failure says nothing about the two assemblies, and the next +// run, which meets the same pair until Unity builds again, should ask again rather than repeat it. +// Why a record that cannot be written leaves the verdict standing: it only saves the next run a +// comparison. +func (c Compiler) askSurface( + ctx context.Context, plan BuildPlan, unit CompileUnit, comparer surfaceComparer, pair comparedPair, +) SurfaceVerdict { + worker, err := comparer.Ensure(ctx) + if err != nil { + return SurfaceVerdict{Reason: SurfaceCouldNotComparePrefix + err.Error()} + } + + inputs := resultstore.SurfaceInputs{ + ProducedSha256: sha256Hex(pair.produced), UnityBuildSha256: sha256Hex(pair.unityBuild), WorkerKey: worker.Key, + } + records := c.records(plan) + assembly := unit.Assembly.AssemblyName + if recorded, found := records.ReusableSurfaceVerdict(assembly, inputs); found { + verdict := verdictFromWorker(recorded) + verdict.FromRecord = true + + return verdict + } + + request, cleanup, err := writeComparedPair(filepath.Join(c.ProjectRoot, plan.OutputDir), assembly, pair) + if err != nil { + return SurfaceVerdict{Reason: SurfaceCouldNotComparePrefix + err.Error()} + } + defer cleanup() + + response, err := comparer.CompareSurface(ctx, worker, request) + if err != nil { + return SurfaceVerdict{Reason: SurfaceCouldNotComparePrefix + err.Error()} + } + + answer := resultstore.SurfaceVerdict{Same: response.Outcome == fastcheck.SameSurfaceOutcome, Detail: response.Detail} + _ = records.RecordSurfaceVerdict(assembly, inputs, answer) + + return verdictFromWorker(answer) +} + +// writeComparedPair writes the two reference assemblies of a comparison to files of their own in +// directory, and returns the request naming them with what removes them. +func writeComparedPair( + directory string, assembly string, pair comparedPair, +) (fastcheck.CompareSurfaceRequest, func(), error) { + paths := []string{} + cleanup := func() { + for _, path := range paths { + _ = os.Remove(path) + } + } + for _, content := range [][]byte{pair.produced, pair.unityBuild} { + path, err := writeComparedCopy(directory, assembly, content) + if err != nil { + cleanup() + + return fastcheck.CompareSurfaceRequest{}, nil, err + } + paths = append(paths, path) + } + + return fastcheck.CompareSurfaceRequest{ProducedPath: paths[0], UnityBuildPath: paths[1]}, cleanup, nil +} + +func writeComparedCopy(directory string, assembly string, content []byte) (string, error) { + file, err := os.CreateTemp(directory, assembly+".surface-compare-*.tmp") + if err != nil { + return "", fmt.Errorf("failed to write a reference assembly to compare: %w", err) + } + _, writeErr := file.Write(content) + closeErr := file.Close() + if err := errors.Join(writeErr, closeErr); err != nil { + _ = os.Remove(file.Name()) + + return "", fmt.Errorf("failed to write a reference assembly to compare: %w", err) + } + + return file.Name(), nil +} + +func verdictFromWorker(answer resultstore.SurfaceVerdict) SurfaceVerdict { + if answer.Same { + return SurfaceVerdict{Kept: true, Reason: SurfaceSameAPI, Detail: answer.Detail} + } + + return SurfaceVerdict{Reason: SurfaceChanged, Detail: answer.Detail} +} + +func sha256Hex(content []byte) string { + sum := sha256.Sum256(content) + + return hex.EncodeToString(sum[:]) +} diff --git a/cli/dispatcher/internal/compilecheck/reference_surface_test.go b/cli/dispatcher/internal/compilecheck/surface_verdict_bytes_test.go similarity index 62% rename from cli/dispatcher/internal/compilecheck/reference_surface_test.go rename to cli/dispatcher/internal/compilecheck/surface_verdict_bytes_test.go index f29ad793dd..80ec797a96 100644 --- a/cli/dispatcher/internal/compilecheck/reference_surface_test.go +++ b/cli/dispatcher/internal/compilecheck/surface_verdict_bytes_test.go @@ -1,6 +1,7 @@ package compilecheck import ( + "context" "os" "path/filepath" "testing" @@ -34,58 +35,80 @@ func writeReferenceAssembly(t *testing.T, path string, content string) { } } +// keptWithAWorkerThatSaysSame asks for the unit's verdict with a worker that would call any two +// reference assemblies the same surface, and fails the test when the worker was asked: every case +// here is settled before a comparison. +func keptWithAWorkerThatSaysSame(t *testing.T, projectRoot string, plan BuildPlan, unit CompileUnit) bool { + t.Helper() + worker := newFakeFastCheckWorker() + worker.surfaceOutcome = "same" + compiler := Compiler{ProjectRoot: projectRoot, FastPath: &fastPathRunner{Worker: worker}} + verdict := compiler.referenceSurfaceVerdict(context.Background(), plan, unit) + if len(worker.surfaceComparisons) != 0 { + t.Errorf("expected the verdict to be settled without the worker, got %d comparisons", len(worker.surfaceComparisons)) + } + + return verdict.Kept +} + +// keptWithoutAWorker asks for the unit's verdict in a run that has no worker to compare surfaces. +func keptWithoutAWorker(projectRoot string, plan BuildPlan, unit CompileUnit) bool { + return Compiler{ProjectRoot: projectRoot}.referenceSurfaceVerdict(context.Background(), plan, unit).Kept +} + // Verifies a reference assembly identical to the one the last Unity build produced counts as an // unchanged public surface, which is what lets a dependent be left out of the run. -func TestReferenceSurfaceUnchangedAcceptsIdenticalReferenceAssemblies(t *testing.T) { +func TestReferenceSurfaceVerdictAcceptsIdenticalReferenceAssemblies(t *testing.T) { unityPath := filepath.Join(planDagDirectory, "A.ref.dll") projectRoot, plan, unit := newReferenceSurfaceProject(t, unityPath) writeReferenceAssembly(t, filepath.Join(projectRoot, unityPath), "surface") writeReferenceAssembly(t, filepath.Join(projectRoot, plan.OutputDir, "A.ref.dll"), "surface") - if !referenceSurfaceUnchanged(projectRoot, plan, unit) { + if !keptWithAWorkerThatSaysSame(t, projectRoot, plan, unit) { t.Error("identical reference assemblies should count as an unchanged surface") } } -// Verifies a single differing byte counts as a changed surface, so the dependents are compiled. -func TestReferenceSurfaceUnchangedRejectsDifferingReferenceAssemblies(t *testing.T) { +// Verifies a single differing byte counts as a changed surface in a run with no worker to compare +// the API, so the dependents are compiled. +func TestReferenceSurfaceVerdictRejectsDifferingReferenceAssemblies(t *testing.T) { unityPath := filepath.Join(planDagDirectory, "A.ref.dll") projectRoot, plan, unit := newReferenceSurfaceProject(t, unityPath) writeReferenceAssembly(t, filepath.Join(projectRoot, unityPath), "surface") writeReferenceAssembly(t, filepath.Join(projectRoot, plan.OutputDir, "A.ref.dll"), "surfacf") - if referenceSurfaceUnchanged(projectRoot, plan, unit) { + if keptWithoutAWorker(projectRoot, plan, unit) { t.Error("reference assemblies differing by one byte should count as a changed surface") } } // Verifies a missing output - which is what a failed compile leaves behind - counts as changed. -func TestReferenceSurfaceUnchangedRejectsAMissingOutput(t *testing.T) { +func TestReferenceSurfaceVerdictRejectsAMissingOutput(t *testing.T) { unityPath := filepath.Join(planDagDirectory, "A.ref.dll") projectRoot, plan, unit := newReferenceSurfaceProject(t, unityPath) writeReferenceAssembly(t, filepath.Join(projectRoot, unityPath), "surface") - if referenceSurfaceUnchanged(projectRoot, plan, unit) { + if keptWithAWorkerThatSaysSame(t, projectRoot, plan, unit) { t.Error("a reference assembly this run did not produce should count as a changed surface") } } // Verifies a missing Unity-side reference assembly counts as changed rather than as a match. -func TestReferenceSurfaceUnchangedRejectsAMissingUnityOutput(t *testing.T) { +func TestReferenceSurfaceVerdictRejectsAMissingUnityOutput(t *testing.T) { unityPath := filepath.Join(planDagDirectory, "A.ref.dll") projectRoot, plan, unit := newReferenceSurfaceProject(t, unityPath) writeReferenceAssembly(t, filepath.Join(projectRoot, plan.OutputDir, "A.ref.dll"), "surface") - if referenceSurfaceUnchanged(projectRoot, plan, unit) { + if keptWithAWorkerThatSaysSame(t, projectRoot, plan, unit) { t.Error("a missing Unity reference assembly should count as a changed surface") } } // Verifies an assembly built without a reference assembly has nothing to compare and counts as changed. -func TestReferenceSurfaceUnchangedRejectsAnAssemblyWithoutAReferenceAssembly(t *testing.T) { +func TestReferenceSurfaceVerdictRejectsAnAssemblyWithoutAReferenceAssembly(t *testing.T) { projectRoot, plan, unit := newReferenceSurfaceProject(t, "") - if referenceSurfaceUnchanged(projectRoot, plan, unit) { + if keptWithAWorkerThatSaysSame(t, projectRoot, plan, unit) { t.Error("an assembly with no reference assembly should count as a changed surface") } } diff --git a/cli/dispatcher/internal/compilecheck/surface_verdict_test.go b/cli/dispatcher/internal/compilecheck/surface_verdict_test.go new file mode 100644 index 0000000000..7d37bb545b --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/surface_verdict_test.go @@ -0,0 +1,367 @@ +package compilecheck + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// surfaceVerdictProject is one assembly whose rebuilt reference assembly differs in bytes from the +// one the last Unity build left, with a fake worker to ask about the difference. +type surfaceVerdictProject struct { + root string + plan BuildPlan + unit CompileUnit + worker *fakeFastCheckWorker +} + +func newSurfaceVerdictProject(t *testing.T) *surfaceVerdictProject { + t.Helper() + unityPath := filepath.Join(planDagDirectory, "A.ref.dll") + projectRoot, plan, unit := newReferenceSurfaceProject(t, unityPath) + project := &surfaceVerdictProject{root: projectRoot, plan: plan, unit: unit, worker: newFakeFastCheckWorker()} + project.writeUnityBuild(t, "unity surface") + project.writeProduced(t, "rebuilt surface") + + return project +} + +func (project *surfaceVerdictProject) writeUnityBuild(t *testing.T, content string) { + t.Helper() + writeReferenceAssembly(t, filepath.Join(project.root, project.unit.Assembly.RefOutputPath), content) +} + +func (project *surfaceVerdictProject) writeProduced(t *testing.T, content string) { + t.Helper() + writeReferenceAssembly(t, filepath.Join(project.root, project.plan.OutputDir, "A.ref.dll"), content) +} + +// verdict asks for the unit's verdict with the changed-files check worker in place. +func (project *surfaceVerdictProject) verdict() SurfaceVerdict { + compiler := Compiler{ProjectRoot: project.root, FastPath: &fastPathRunner{Worker: project.worker}} + + return compiler.referenceSurfaceVerdict(context.Background(), project.plan, project.unit) +} + +func (project *surfaceVerdictProject) recordPath() string { + return filepath.Join(project.root, project.plan.OutputDir, "A.surface.json") +} + +// rewriteRecord replaces one field of the recorded verdict, the way a record left by another build +// or for other assemblies would hold it. +func (project *surfaceVerdictProject) rewriteRecord(t *testing.T, field string, value any) { + t.Helper() + content, err := os.ReadFile(project.recordPath()) + if err != nil { + t.Fatalf("expected a recorded verdict, got %v", err) + } + record := map[string]any{} + if err := json.Unmarshal(content, &record); err != nil { + t.Fatalf("failed to parse the recorded verdict: %v", err) + } + if _, found := record[field]; !found { + t.Fatalf("expected the recorded verdict to hold %s, got %v", field, record) + } + record[field] = value + rewritten, err := json.Marshal(record) + if err != nil { + t.Fatalf("failed to encode the recorded verdict: %v", err) + } + if err := os.WriteFile(project.recordPath(), rewritten, 0o600); err != nil { + t.Fatalf("failed to rewrite the recorded verdict: %v", err) + } +} + +func assertVerdict(t *testing.T, got SurfaceVerdict, kept bool, reason string) { + t.Helper() + if got.Kept != kept || got.Reason != reason { + t.Fatalf("expected kept=%v with reason %q, got %+v", kept, reason, got) + } +} + +func assertComparisons(t *testing.T, worker *fakeFastCheckWorker, want int) { + t.Helper() + if len(worker.surfaceComparisons) != want { + t.Fatalf("expected %d comparisons by the worker, got %d", want, len(worker.surfaceComparisons)) + } +} + +// Verifies identical bytes keep the surface without asking the worker. +func TestReferenceSurfaceVerdictKeepsIdenticalBytesWithoutTheWorker(t *testing.T) { + project := newSurfaceVerdictProject(t) + project.writeProduced(t, "unity surface") + + got := project.verdict() + + assertVerdict(t, got, true, SurfaceSameBytes) + assertComparisons(t, project.worker, 0) + if got.Asked { + t.Errorf("expected a byte match not to count as asking the worker, got %+v", got) + } +} + +// Verifies the worker is asked about any two reference assemblies whose bytes differ, whatever they +// contain - a private member renumbering the compiler's private nested types changes the bytes of an +// assembly that embeds no attribute type at all - is handed the rebuilt one and Unity's in that +// order, and leaves no copy behind. +func TestReferenceSurfaceVerdictAsksTheWorkerWhateverTheAssembliesContain(t *testing.T) { + project := newSurfaceVerdictProject(t) + project.worker.surfaceOutcome = "same" + + got := project.verdict() + + assertVerdict(t, got, true, SurfaceSameAPI) + assertComparisons(t, project.worker, 1) + want := fakeSurfaceComparison{Produced: "rebuilt surface", UnityBuild: "unity surface"} + if project.worker.surfaceComparisons[0] != want { + t.Errorf("expected the worker to compare %+v, got %+v", want, project.worker.surfaceComparisons[0]) + } + if !got.Asked || got.FromRecord { + t.Errorf("expected a verdict the worker gave, got %+v", got) + } + leftovers, err := filepath.Glob(filepath.Join(project.root, project.plan.OutputDir, "*.surface-compare-*")) + if err != nil || len(leftovers) != 0 { + t.Errorf("expected the copies handed to the worker to be removed, got %v (%v)", leftovers, err) + } +} + +// Verifies the worker's "same" keeps the surface and is recorded for the next run. +func TestReferenceSurfaceVerdictKeepsTheSurfaceTheWorkerCallsTheSame(t *testing.T) { + project := newSurfaceVerdictProject(t) + project.worker.surfaceOutcome = "same" + + assertVerdict(t, project.verdict(), true, SurfaceSameAPI) + + if _, err := os.Stat(project.recordPath()); err != nil { + t.Errorf("expected the verdict to be recorded, got %v", err) + } +} + +// Verifies the worker's "different" changes the surface, with its detail, and is recorded too. +func TestReferenceSurfaceVerdictRecordsADifferenceTheWorkerFound(t *testing.T) { + project := newSurfaceVerdictProject(t) + + got := project.verdict() + + assertVerdict(t, got, false, SurfaceChanged) + if got.Detail != "fake difference" { + t.Errorf("expected the worker's detail, got %q", got.Detail) + } + if _, err := os.Stat(project.recordPath()); err != nil { + t.Errorf("expected the verdict to be recorded, got %v", err) + } +} + +// Verifies the worker compares the very bytes the record is keyed by: when Unity rewrites its +// reference assembly while the comparison runs, the answer and its record still describe the pair +// read before it, so a later run meeting that pair again is not answered for another one. +func TestReferenceSurfaceVerdictComparesTheBytesItKeysTheRecordBy(t *testing.T) { + project := newSurfaceVerdictProject(t) + project.worker.surfaceByContent = true + project.worker.onCompare = func() { project.writeUnityBuild(t, "rebuilt surface") } + + assertVerdict(t, project.verdict(), false, SurfaceChanged) + + project.worker.onCompare = nil + project.writeUnityBuild(t, "unity surface") + project.worker.surfaceByContent = false + + assertVerdict(t, project.verdict(), false, SurfaceChanged) +} + +// Verifies a matching record answers "same" without asking the worker again. +func TestReferenceSurfaceVerdictUsesAMatchingRecordThatSaysSame(t *testing.T) { + project := newSurfaceVerdictProject(t) + project.worker.surfaceOutcome = "same" + project.verdict() + project.worker.surfaceOutcome = "" + + got := project.verdict() + + assertVerdict(t, got, true, SurfaceSameAPI) + assertComparisons(t, project.worker, 1) + if !got.Asked || !got.FromRecord { + t.Errorf("expected a verdict taken from the record, got %+v", got) + } +} + +// Verifies a matching record answers "different" without asking the worker again. +func TestReferenceSurfaceVerdictUsesAMatchingRecordThatSaysDifferent(t *testing.T) { + project := newSurfaceVerdictProject(t) + project.verdict() + project.worker.surfaceOutcome = "same" + + got := project.verdict() + + assertVerdict(t, got, false, SurfaceChanged) + assertComparisons(t, project.worker, 1) + if !got.FromRecord || got.Detail != "fake difference" { + t.Errorf("expected the recorded verdict with its detail, got %+v", got) + } +} + +// recordSameThenAnswerDifferent records a "same" verdict and makes the worker answer "different" +// from then on, so a test can tell whether the next verdict came from the record or the worker. +func recordSameThenAnswerDifferent(t *testing.T) *surfaceVerdictProject { + t.Helper() + project := newSurfaceVerdictProject(t) + project.worker.surfaceOutcome = "same" + assertVerdict(t, project.verdict(), true, SurfaceSameAPI) + project.worker.surfaceOutcome = "" + + return project +} + +// Verifies a record another version of the record format wrote is not used. +func TestReferenceSurfaceVerdictIgnoresARecordOfAnotherVersion(t *testing.T) { + project := recordSameThenAnswerDifferent(t) + project.rewriteRecord(t, "FormatVersion", -1) + + assertVerdict(t, project.verdict(), false, SurfaceChanged) + assertComparisons(t, project.worker, 2) +} + +// Verifies a record made for another rebuilt reference assembly is not used. +func TestReferenceSurfaceVerdictIgnoresARecordOfAnotherProducedAssembly(t *testing.T) { + project := recordSameThenAnswerDifferent(t) + project.writeProduced(t, "rebuilt surface after another edit") + + assertVerdict(t, project.verdict(), false, SurfaceChanged) + assertComparisons(t, project.worker, 2) +} + +// Verifies a record made against another Unity build is not used. +func TestReferenceSurfaceVerdictIgnoresARecordOfAnotherUnityBuild(t *testing.T) { + project := recordSameThenAnswerDifferent(t) + project.writeUnityBuild(t, "unity surface after another build") + + assertVerdict(t, project.verdict(), false, SurfaceChanged) + assertComparisons(t, project.worker, 2) +} + +// Verifies a record another worker build gave is not used: what the worker writes out is part of +// its code. +func TestReferenceSurfaceVerdictIgnoresARecordOfAnotherWorker(t *testing.T) { + project := recordSameThenAnswerDifferent(t) + project.worker.key = "worker-b" + + assertVerdict(t, project.verdict(), false, SurfaceChanged) + assertComparisons(t, project.worker, 2) +} + +// Verifies a worker that cannot be built counts as a changed surface, without a comparison. +func TestReferenceSurfaceVerdictTreatsAnUnavailableWorkerAsChanged(t *testing.T) { + project := newSurfaceVerdictProject(t) + project.worker.surfaceOutcome = "same" + project.worker.ensureErr = errors.New("worker build failed") + + got := project.verdict() + + if got.Kept || !strings.HasPrefix(got.Reason, SurfaceCouldNotComparePrefix) { + t.Fatalf("expected a changed surface that could not be compared, got %+v", got) + } + assertComparisons(t, project.worker, 0) +} + +// Verifies a comparison that failed counts as a changed surface and leaves no record behind, so the +// next run asks again. +func TestReferenceSurfaceVerdictTreatsAFailedComparisonAsChangedAndRecordsNothing(t *testing.T) { + project := newSurfaceVerdictProject(t) + project.worker.surfaceErr = context.DeadlineExceeded + + got := project.verdict() + + if got.Kept || !strings.HasPrefix(got.Reason, SurfaceCouldNotComparePrefix) { + t.Fatalf("expected a changed surface that could not be compared, got %+v", got) + } + if _, err := os.Stat(project.recordPath()); !os.IsNotExist(err) { + t.Errorf("expected no recorded verdict, got %v", err) + } +} + +// Verifies a verdict that cannot be recorded still keeps the surface. +func TestReferenceSurfaceVerdictKeepsTheSurfaceWhenTheRecordCannotBeWritten(t *testing.T) { + project := newSurfaceVerdictProject(t) + project.worker.surfaceOutcome = "same" + if err := os.MkdirAll(filepath.Join(project.recordPath(), "blocked"), outputDirPermissions); err != nil { + t.Fatalf("failed to block the record path: %v", err) + } + + assertVerdict(t, project.verdict(), true, SurfaceSameAPI) +} + +// Verifies a run without the worker compares bytes only, as a run always did before the worker +// could compare surfaces. +func TestReferenceSurfaceVerdictWithoutAWorkerComparesBytesOnly(t *testing.T) { + project := newSurfaceVerdictProject(t) + compiler := Compiler{ProjectRoot: project.root} + + assertVerdict(t, compiler.referenceSurfaceVerdict(context.Background(), project.plan, project.unit), false, SurfaceBytesDiffer) +} + +// Verifies a dependent picked only for an assembly whose rebuilt reference assembly the worker calls +// the same surface is skipped and counted as skipped, and that the surface check is reported on the +// referenced unit's result. +func TestCompileUnitsSkipsDependentsWhenTheWorkerCallsTheSurfaceTheSame(t *testing.T) { + project := newCachedSchedulerProject(t, map[string][]string{"Beta": {"Alpha"}}, "Alpha", "Beta") + markSelectedAsDependent(t, project.plan, "Beta") + worker := newFakeFastCheckWorker() + worker.surfaceOutcome = "same" + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + recorder.surfaces["Alpha"] = "rebuilt Alpha" + + outcome := compileWithFastPath(t, project, worker, recorder, "Alpha", "Beta") + + if recorder.runs["Beta"] != 0 || outcome.Skipped != 1 { + t.Errorf("expected Beta to be skipped, got %d runs and %d skipped", recorder.runs["Beta"], outcome.Skipped) + } + alpha := resultOf(t, outcome.Units, "Alpha") + if !alpha.Surface.Asked || alpha.Surface.Reason != SurfaceSameAPI { + t.Errorf("expected Alpha to report the worker's same-API verdict, got %+v", alpha.Surface) + } +} + +// Verifies a unit that failed is never compared, even when the previous run's outputs it leaves +// behind would compare. +func TestCompileUnitsDoesNotCompareTheSurfaceOfAFailedUnit(t *testing.T) { + project := newCachedSchedulerProject(t, map[string][]string{"Beta": {"Alpha"}}, "Alpha", "Beta") + worker := newFakeFastCheckWorker() + worker.surfaceOutcome = "same" + first := newCachedSchedulerRecorder("Alpha", "Beta") + first.surfaces["Alpha"] = "rebuilt Alpha" + compileWithFastPath(t, project, worker, first, "Alpha", "Beta") + worker.surfaceComparisons = nil + + project.editSource(t, "Alpha", "class Alpha { broken }") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + recorder.output["Alpha"] = fakeCompilerOutput{stdout: "Assets/Alpha/Alpha.cs(1,1): error CS1002: ; expected", exitCode: 1} + compileWithFastPath(t, project, worker, recorder, "Alpha", "Beta") + + assertComparisons(t, worker, 0) +} + +// Verifies a comparison whose copies cannot be written counts as changed, without asking the worker. +func TestReferenceSurfaceVerdictTreatsUnwritableCopiesAsChanged(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("directory permissions do not make a directory read-only on Windows") + } + project := newSurfaceVerdictProject(t) + project.worker.surfaceOutcome = "same" + outputDirectory := filepath.Join(project.root, project.plan.OutputDir) + if err := os.Chmod(outputDirectory, 0o500); err != nil { + t.Fatalf("failed to make the output directory read-only: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(outputDirectory, outputDirPermissions) }) + + got := project.verdict() + + if got.Kept || !strings.HasPrefix(got.Reason, SurfaceCouldNotComparePrefix) { + t.Fatalf("expected a changed surface that could not be compared, got %+v", got) + } + assertComparisons(t, project.worker, 0) +} diff --git a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go index df2eb49ee9..fbc7df274e 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go @@ -86,6 +86,7 @@ func (logRun compileCheckVibeLog) completed(response compileCheckResponse, resul context["fallbacks"] = compileCheckVibeReasons(response.FastPathFallbacks) context["baseline_notes"] = compileCheckVibeReasons(response.FastPathBaselineNotes) context["units"] = compileCheckVibeUnits(result.Units) + context["surface_checks"] = compileCheckVibeSurfaceChecks(result.Units) logRun.write(level, compileCheckVibeCompleteOperation, "compile-check finished.", context) } @@ -153,3 +154,25 @@ func compileCheckVibeUnits(units []compilecheck.UnitResult) []map[string]any { return entries } + +// compileCheckVibeSurfaceChecks lists every unit whose surface the worker, or its record of the same +// comparison, settled: what it answered and how long asking took. +// Why replayed units are kept here, unlike in units: a replayed unit is compared just the same, and +// its verdict decides whether its dependents compile. +func compileCheckVibeSurfaceChecks(units []compilecheck.UnitResult) []map[string]any { + entries := make([]map[string]any, 0, len(units)) + for _, unit := range units { + if !unit.Surface.Asked { + continue + } + entries = append(entries, map[string]any{ + "assembly": unit.Assembly, + "verdict": unit.Surface.Reason, + "detail": unit.Surface.Detail, + "duration_ms": unit.Surface.Duration.Milliseconds(), + "from_record": unit.Surface.FromRecord, + }) + } + + return entries +} diff --git a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go index 7c96caeb8e..e3dab053d5 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go @@ -71,7 +71,8 @@ func assertVibeContextJSON(t *testing.T, entry vibelog.CLIVibeLogEntry, key stri } // loggedCompileCheckResult is a run that compiled, fast-checked, replayed a fast check of, reused, -// and blocked one assembly each, so a field the log drops or mixes up shows. +// and blocked one assembly each, and settled the surface of three of them three ways, so a field the +// log drops or mixes up shows. func loggedCompileCheckResult() compilecheck.Result { return compilecheck.Result{ DagDir: "Library/Bee/artifacts/1300b0aE.dag", @@ -82,22 +83,33 @@ func loggedCompileCheckResult() compilecheck.Result { Assembly: "Alpha", Succeeded: true, Duration: 1500 * time.Millisecond, FastPathFallbackReason: "no baseline: no full compile of this assembly is on record", FastPathNote: "baseline not recorded: source changed during the compile: Assets/A.cs", + Surface: compilecheck.SurfaceVerdict{ + Kept: true, Reason: compilecheck.SurfaceSameAPI, Asked: true, Duration: 400 * time.Millisecond, + }, }, { Assembly: "Beta", Succeeded: true, Duration: 250 * time.Millisecond, FastChecked: true, FastCheckChangedFiles: []string{"Assets/B1.cs", "Assets/B2.cs"}, + Surface: compilecheck.SurfaceVerdict{Kept: true, Reason: compilecheck.SurfaceSameBytes}, }, { Assembly: "Epsilon", Succeeded: true, FastChecked: true, FastCheckReused: true, FastCheckChangedFiles: []string{"Assets/E.cs"}, }, - {Assembly: "Gamma", Succeeded: true, Reused: true}, + { + Assembly: "Gamma", Succeeded: true, Reused: true, + Surface: compilecheck.SurfaceVerdict{ + Reason: compilecheck.SurfaceChanged, Detail: "produced: a / Unity build: b", Asked: true, FromRecord: true, + Duration: 2 * time.Millisecond, + }, + }, }, } } // Verifies a run writes a start and a complete entry with one correlation id, and the complete -// entry carries what the response reported and how long each compiled or checked unit took. +// entry carries what the response reported, how long each compiled or checked unit took, and every +// surface the worker or its record settled, including that of a replayed unit. func TestCompileCheckVibeLogRecordsASucceededRun(t *testing.T) { t.Setenv(vibelog.CLIVibeLogEnvName, "1") projectRoot := t.TempDir() @@ -130,6 +142,9 @@ func TestCompileCheckVibeLogRecordsASucceededRun(t *testing.T) { `[{"assembly":"Alpha","reason":"baseline not recorded: source changed during the compile: Assets/A.cs"}]`) assertVibeContextJSON(t, complete, "units", `[{"assembly":"Alpha","duration_ms":1500,"succeeded":true},{"assembly":"Beta","duration_ms":250,"succeeded":true}]`) + assertVibeContextJSON(t, complete, "surface_checks", + `[{"assembly":"Alpha","detail":"","duration_ms":400,"from_record":false,"verdict":"same API"},`+ + `{"assembly":"Gamma","detail":"produced: a / Unity build: b","duration_ms":2,"from_record":true,"verdict":"changed"}]`) if _, found := complete.Context["duration_ms"]; !found { t.Error("expected the run's duration to be logged") } diff --git a/docs/compile-check.md b/docs/compile-check.md index 1e641f3bc4..963cd83e6e 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -38,10 +38,25 @@ references, scripting defines and analyzers. `compile-check` replays those respo 3. **Decide what to compile.** By default the run compiles the assemblies whose sources changed since the last Unity build, plus every assembly that references one of them. An assembly pulled in only through a reference is dropped again once everything it references has been compiled and - every one of those reference assemblies (`.ref.dll`, which holds the public surface alone) came - out byte-identical to the last Unity build's. That is the decision Unity itself makes through - Bee: a change that does not reach the public surface — a method body, say — cannot change what a - dependent compiles to. The comparison is always against Unity's own artifact, never against the + every one of those reference assemblies (`.ref.dll`, which holds the public surface alone) shows + a dependent the same API as the last Unity build's: a change that does not reach the public + surface — a method body, say — cannot change what a dependent compiles to. A reference assembly + that came out byte-identical counts as the same, as it does for Unity. One whose bytes differ is + handed to the changed-files check worker, which writes both out as text and counts them the same + only when the texts match. The text leaves out what no dependent can read: the attribute types + the compiler embeds, private nested types nothing visible refers to (closures and async or + iterator state machines) along with the attributes naming them, and where the compiler happens to + put nullable annotations — each annotation is written as the value that applies to its member. + So a private reference-type field in a nullable-enabled assembly, a private generic method with + an `unmanaged` constraint, or a private member added before a lambda no longer compiles every + dependent again, which Unity, going by bytes, does. This is safe because a dependent cannot name + an embedded type or an unreferenced private nested type, and reads every annotation by the value + that applies to it. One difference remains: a dependent that already fails because it names a + private nested type may report the error under another ID (CS0246, CS0426 or CS0117 in place of + CS0122, or the other way round) until it compiles again; whether it compiles does not change. When the worker cannot be built or cannot answer, the dependents are compiled. + Its answer is kept in `.surface.json`, keyed by the content of both reference + assemblies and the worker build, so the runs until Unity builds again do not compare the same + pair twice. The comparison is always against Unity's own artifact, never against the previous `compile-check` output, which would only say that nothing moved since that run and would hide an error a dependent has been carrying all along. An assembly whose compiler step failed in the last Unity build is selected whatever its timestamps say: Unity runs Bee with deferred dag @@ -99,14 +114,15 @@ references, scripting defines and analyzers. `compile-check` replays those respo place, the assemblies below it resolve the same reference assembly and are reused in turn, which is what makes editing a single method body cost one compile rather than one per dependent. A reference to an assembly this run rebuilt is read from Unity's artifact when the rebuilt - reference assembly came out byte-identical to it, and from this check's own output otherwise. The + reference assembly shows dependents the same API (step 3), and from this check's own output + otherwise. The first is digested by timestamp, like every reference the run did not rebuild, so rebuilding an assembly without moving its public surface leaves the digests of the assemblies that reference it where the runs that did not rebuild it left them. The second is digested by content, so a reference assembly rewritten with identical bytes still counts as unmoved. A response file naming a file the digest does not follow — `keyfile`, `resource`, `link`, `addmodule` — gets no digest, and such an assembly always compiles. `--all` looks nothing up, though it still records what it - compiled, under the same rule: a reference whose rebuilt reference assembly matches Unity's stays + compiled, under the same rule: a reference whose rebuilt reference assembly keeps Unity's API stays on Unity's artifact, so an ordinary run after it reuses what it compiled. Only a reference whose rebuilt surface differs from Unity's resolves to this check's output under `--all`; an ordinary run that leaves that assembly out reads Unity's artifact instead, and may compile the assemblies @@ -194,11 +210,12 @@ So the first body edit is compiled in full more often than it looks: - Right after a Unity build, compile-check finds nothing to compile and records nothing. The first body edit after that is compiled in full and records the baseline; the edits after it are checked. - A run with `--all` records baselines too. It rebuilds the assemblies the next ordinary run takes - from Unity's build, but a reference whose rebuilt reference assembly matches Unity's is read from - Unity's build in both runs. Only in an assembly that references one whose rebuilt reference - assembly differs from Unity's does the first body edit after `--all` fall back with - `inputs other than source contents changed: references`; every other assembly is checked as - usual. + from Unity's build, but a reference whose rebuilt reference assembly keeps Unity's API is read + from Unity's build in both runs. A reference whose rebuilt API differs is read from this check's + output under `--all`; while the next ordinary run rebuilds it too, the reference reads the same + and the first body edit is checked as usual. Only when that run leaves the reference out, so it + reads Unity's build instead, does the first body edit of an assembly referencing it fall back + with `inputs other than source contents changed: references`. - A run that skips an assembly because nothing it compiles from changed removes its result and baseline, so its next body edit is compiled in full. A run that blocks it because an assembly it references has errors keeps them: the next run checks them against the inputs and output hashes diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 6f34ec1775..98a1794d6b 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -280,7 +280,7 @@ because there is no project to write into. | `operation` | Written | `context` | |---|---|---| | `cli_compile_check_start` | once the project is found | `command`, `all`, `full`, `jobs`, `project_identity` | -| `cli_compile_check_complete` (`INFO`; `WARNING` when the run found compile errors; `ERROR` when it could not finish) | on every way out of a run that wrote the start entry | `command`, `outcome` (`succeeded`, `errors`, or `failed`), `duration_ms`; for `failed`, `error` and `error_code`; otherwise `error_count`, `warning_count`, `compiled`, `reused`, `fast_checked`, `blocked` (assembly names, as in the response), `skipped_count`, `fast_checks` (`assembly`, `reused`, `changed_file_count`), `fallbacks` and `baseline_notes` (`assembly`, `reason`), and `units` (`assembly`, `duration_ms`, `succeeded` of each assembly the run compiled or checked; replayed ones are left out) | +| `cli_compile_check_complete` (`INFO`; `WARNING` when the run found compile errors; `ERROR` when it could not finish) | on every way out of a run that wrote the start entry | `command`, `outcome` (`succeeded`, `errors`, or `failed`), `duration_ms`; for `failed`, `error` and `error_code`; otherwise `error_count`, `warning_count`, `compiled`, `reused`, `fast_checked`, `blocked` (assembly names, as in the response), `skipped_count`, `fast_checks` (`assembly`, `reused`, `changed_file_count`), `fallbacks` and `baseline_notes` (`assembly`, `reason`), `units` (`assembly`, `duration_ms`, `succeeded` of each assembly the run compiled or checked; replayed ones are left out), and `surface_checks` (`assembly`, `verdict`, `detail`, `duration_ms`, `from_record` of each assembly whose rebuilt reference assembly differed in bytes from Unity's and was compared by the worker or taken from its record, replayed ones included) | - Unlike the tool-command entries, the complete entry holds the run's error message and fallback reasons: they can name assemblies and files of the project, which the command's own output prints From fa02f447fbff057bf621f2f351cfda5a320bd7e0 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 21:02:54 +0900 Subject: [PATCH 23/33] fix: compile-check no longer recompiles dependents of an assembly whose body alone changed when Unity's source order is not ordinal (#3342) --- .../internal/compilecheck/fast_path_test.go | 30 +++ .../compilecheck/resultstore/baseline.go | 20 +- .../resultstore/baseline_contract_test.go | 16 ++ .../resultstore/compile_result.go | 6 +- .../resultstore/compile_result_test.go | 30 ++- .../resultstore/record_format_test.go | 2 +- .../compilecheck/source_order_test.go | 238 ++++++++++++++++++ .../internal/compilecheck/source_set.go | 40 ++- .../internal/compilecheck/source_set_test.go | 4 +- docs/compile-check.md | 9 +- 10 files changed, 366 insertions(+), 29 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/source_order_test.go diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go index 32c069a9d2..87b524a3e4 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -2,6 +2,7 @@ package compilecheck import ( "context" + "encoding/json" "errors" "os" "path/filepath" @@ -134,6 +135,35 @@ func TestHandCorruptedFailedManifestFallsBack(t *testing.T) { assertFallback(t, outcome, recorder, "baseline compile is no longer on disk") } +// Verifies that a baseline whose full compile was recorded in format version 1, when sources were +// compiled in ordinal order, is not used: the body edit compiles in full instead of handing the +// dependents the reference assembly that compile wrote. +func TestBaselineOfAFormatVersion1CompileFallsBack(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + manifestPath := filepath.Join(project.outputDirectory(), "Alpha.result.json") + content, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("failed to read the compile result: %v", err) + } + manifest := map[string]any{} + if err := json.Unmarshal(content, &manifest); err != nil { + t.Fatalf("failed to parse the compile result: %v", err) + } + manifest["FormatVersion"] = 1 + rewritten, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("failed to write the compile result back: %v", err) + } + writeOutputFile(t, project.outputDirectory(), "Alpha.result.json", string(rewritten)) + project.editBody(t, 0, "edited") + recorder := newCachedSchedulerRecorder("Alpha", "Beta") + + outcome := project.run(t, recorder, false) + + assertFallback(t, outcome, recorder, "no baseline: it was recorded by another version of compile-check") +} + // Verifies that a failed full compile leaves no baseline, so the next body edit compiles in full. func TestFailedFullCompileLeavesNoBaseline(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) diff --git a/cli/dispatcher/internal/compilecheck/resultstore/baseline.go b/cli/dispatcher/internal/compilecheck/resultstore/baseline.go index 629cd78852..964266dcfc 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/baseline.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/baseline.go @@ -73,6 +73,9 @@ func (s Store[D]) GeneratedDirectory(assembly string) string { return filepath.Join(s.outputDir, assembly+generatedDirectoryExtension) } +// anotherVersionReason says a baseline, or the compile it describes, was recorded by another version. +const anotherVersionReason = "no baseline: it was recorded by another version of compile-check" + // LoadBaseline reads the baseline of an assembly together with what the full compile it describes // reported, or says why there is none to use. func (s Store[D]) LoadBaseline(assembly string) (Baseline[D], string) { @@ -83,11 +86,12 @@ func (s Store[D]) LoadBaseline(assembly string) (Baseline[D], string) { var record baselineRecord if err := json.Unmarshal(content, &record); err != nil || record.FormatVersion != baselineRecordFormatVersion { - return Baseline[D]{}, "no baseline: it was recorded by another version of compile-check" + return Baseline[D]{}, anotherVersionReason } // In the normal flow the record and the manifest are written together under one key, and a full - // compile removes both, so the checks below only catch a state someone changed by hand. + // compile removes both, so the checks below other than the format version only catch a state + // someone changed by hand. const goneFromDisk = "baseline compile is no longer on disk" manifestContent, err := os.ReadFile(manifestPath(s.directoryPath(), assembly)) if err != nil { @@ -95,8 +99,16 @@ func (s Store[D]) LoadBaseline(assembly string) (Baseline[D], string) { } var manifest unitResultManifest[D] - if err := json.Unmarshal(manifestContent, &manifest); err != nil || - manifest.InputKey != record.InputKey || !manifest.Succeeded || + if err := json.Unmarshal(manifestContent, &manifest); err != nil { + return Baseline[D]{}, goneFromDisk + } + // Why a manifest of another format version ends the baseline too: a changed-files check leaves the + // outputs of the full compile in place, so a baseline would hand dependents what an older version + // compiled long after the manifest itself stopped being reused. + if manifest.FormatVersion != resultManifestFormatVersion { + return Baseline[D]{}, anotherVersionReason + } + if manifest.InputKey != record.InputKey || !manifest.Succeeded || !outputsStillMatch(s.directoryPath(), manifest.Outputs) { return Baseline[D]{}, goneFromDisk } diff --git a/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go b/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go index 41cb9687ad..1d925134fd 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go @@ -128,6 +128,22 @@ func TestGeneratorOutputUnchangedNoticesEveryChange(t *testing.T) { } } +// Verifies a baseline tied to a compile result of another format version is not handed out, even +// when its key, success and outputs all still match: the outputs of a format version 1 compile came +// from sources in ordinal order, and the changed-files check would hand them to dependents as is. +func TestLoadBaselineRefusesABaselineWhoseCompileHasAnotherFormatVersion(t *testing.T) { + project := newProjectStore(t) + project.recordCompile(t, "input", true) + project.recordBaseline(t, "input") + setManifestFormatVersion(t, project.outputPath(), "Alpha", 1) + + _, reason := project.store.LoadBaseline("Alpha") + + if reason != "no baseline: it was recorded by another version of compile-check" { + t.Errorf("reason = %q", reason) + } +} + // Verifies a baseline is not handed out once the compile it describes is no longer what is on disk. func TestLoadBaselineRefusesABaselineWhoseCompileIsGone(t *testing.T) { for name, damage := range map[string]func(t *testing.T, project projectStore){ diff --git a/cli/dispatcher/internal/compilecheck/resultstore/compile_result.go b/cli/dispatcher/internal/compilecheck/resultstore/compile_result.go index 6387507efd..cd240cff18 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/compile_result.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/compile_result.go @@ -10,8 +10,10 @@ import ( const ( // resultManifestFormatVersion guards against reading a manifest an older or newer build wrote: // what belongs in the key is part of this check's code, so a manifest from another version says - // nothing about whether this one would compile the same thing. - resultManifestFormatVersion = 1 + // nothing about whether this one would compile the same thing. So is the order the sources are + // handed to csc in, which decides the bytes of what it writes without being in the key: version 1 + // compiled them in ordinal order rather than Unity's. + resultManifestFormatVersion = 2 resultManifestExtension = ".result.json" ) diff --git a/cli/dispatcher/internal/compilecheck/resultstore/compile_result_test.go b/cli/dispatcher/internal/compilecheck/resultstore/compile_result_test.go index 659f959b4c..97c95ed20b 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/compile_result_test.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/compile_result_test.go @@ -129,7 +129,29 @@ func TestReusableResultRejectsAnUnreadableManifest(t *testing.T) { func TestReusableResultRejectsAnotherFormatVersion(t *testing.T) { directoryPath := newRecordedOutputs(t) recordResult(t, directoryPath, "key") - path := manifestPath(directoryPath, "Alpha") + setManifestFormatVersion(t, directoryPath, "Alpha", resultManifestFormatVersion+1) + + if _, reusable := directoryStore(directoryPath).Reusable("Alpha", "key"); reusable { + t.Error("a manifest of another format version should not be reused") + } +} + +// Verifies a manifest of format version 1, whose outputs were compiled from sources in ordinal +// rather than response file order, is not reused under the same key. +func TestReusableResultRejectsAManifestCompiledInOrdinalSourceOrder(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordResult(t, directoryPath, "key") + setManifestFormatVersion(t, directoryPath, "Alpha", 1) + + if _, reusable := directoryStore(directoryPath).Reusable("Alpha", "key"); reusable { + t.Error("a manifest of format version 1 should not be reused") + } +} + +// setManifestFormatVersion rewrites the format version of an assembly's manifest, leaving the rest. +func setManifestFormatVersion(t *testing.T, directoryPath string, assembly string, version int) { + t.Helper() + path := manifestPath(directoryPath, assembly) content, err := os.ReadFile(path) if err != nil { t.Fatalf("failed to read the manifest: %v", err) @@ -138,7 +160,7 @@ func TestReusableResultRejectsAnotherFormatVersion(t *testing.T) { if err := json.Unmarshal(content, &manifest); err != nil { t.Fatalf("failed to parse the manifest: %v", err) } - manifest.FormatVersion = resultManifestFormatVersion + 1 + manifest.FormatVersion = version rewritten, err := json.Marshal(manifest) if err != nil { t.Fatalf("failed to write the manifest back: %v", err) @@ -146,10 +168,6 @@ func TestReusableResultRejectsAnotherFormatVersion(t *testing.T) { if err := os.WriteFile(path, rewritten, 0o600); err != nil { t.Fatalf("failed to write the manifest back: %v", err) } - - if _, reusable := directoryStore(directoryPath).Reusable("Alpha", "key"); reusable { - t.Error("a manifest of another format version should not be reused") - } } // Verifies a compile that wrote no outputs still records what it reported, so the next run can tell diff --git a/cli/dispatcher/internal/compilecheck/resultstore/record_format_test.go b/cli/dispatcher/internal/compilecheck/resultstore/record_format_test.go index 5bd69cb30d..e4e61f11d2 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/record_format_test.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/record_format_test.go @@ -42,7 +42,7 @@ func TestWriteUnitResultManifestWritesTheRecordedFormat(t *testing.T) { t.Fatalf("failed to write the manifest: %v", err) } - assertFileContent(t, manifestPath(directoryPath, "Alpha"), `{"FormatVersion":1,"InputKey":"key","Succeeded":false,`+ + assertFileContent(t, manifestPath(directoryPath, "Alpha"), `{"FormatVersion":2,"InputKey":"key","Succeeded":false,`+ `"Diagnostics":[{"Severity":"error","Code":"CS0103","Message":"The name 'Missing' does not exist in the current context",`+ `"File":"Assets/Alpha/Alpha.cs","Line":3,"Column":9}],`+ `"Outputs":{"Alpha.dll":"e020bdf568793dee747a220086c5e53c8b3af5abd959383a31297f303577efe6",`+ diff --git a/cli/dispatcher/internal/compilecheck/source_order_test.go b/cli/dispatcher/internal/compilecheck/source_order_test.go new file mode 100644 index 0000000000..55bded5380 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/source_order_test.go @@ -0,0 +1,238 @@ +package compilecheck + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// fooAssemblyDefinition is the Foo assembly newAssemblyProject lays out. +func fooAssemblyDefinition(projectRoot string) AssemblyDefinition { + return AssemblyDefinition{ + Name: "Foo", + Path: filepath.Join(projectRoot, "Assets", "Foo", "Foo.asmdef"), + Directory: filepath.Join(projectRoot, "Assets", "Foo"), + } +} + +// rebuildFooSources rebuilds Foo's sources from rsp, failing the test when the rebuild fails. +func rebuildFooSources(t *testing.T, projectRoot string, rsp ResponseFile) []string { + t.Helper() + asmdef := fooAssemblyDefinition(projectRoot) + sources, err := RebuildSources(projectRoot, rsp, &asmdef, newOwnerIndexForProject(t, projectRoot)) + if err != nil { + t.Fatalf("expected the rebuild to succeed, got error: %v", err) + } + + return sources +} + +// Verifies the recorded sources keep the order of the response file, which is neither ordinal nor +// case-insensitive here, and a source deleted from the middle of it drops out without moving the rest. +func TestRebuildSourcesKeepsTheResponseFileOrder(t *testing.T) { + projectRoot := newAssemblyProject(t) + rsp := ResponseFile{Sources: []string{ + "Assets/Foo/Sub/B.cs", + "Assets/Foo/Deleted.cs", + "Assets/Foo/A.cs", + }} + + sources := rebuildFooSources(t, projectRoot, rsp) + + assertStrings(t, "sources", sources, []string{ + "Assets/Foo/Sub/B.cs", + "Assets/Foo/A.cs", + }) +} + +// Verifies sources added since the last build come after the recorded ones in ordinal order, whatever +// order the walk found them in: N/M.cs is walked before N.cs, and C.cs sorts before b.cs only when +// case counts. +func TestRebuildSourcesAppendsAddedSourcesInOrdinalOrderAfterTheRecordedOnes(t *testing.T) { + projectRoot := newAssemblyProject(t) + for _, added := range []string{"N.cs", filepath.Join("N", "M.cs"), "C.cs", "b.cs"} { + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Foo", added), "") + } + rsp := ResponseFile{Sources: []string{ + "Assets/Foo/Sub/B.cs", + "Assets/Foo/A.cs", + }} + + sources := rebuildFooSources(t, projectRoot, rsp) + + assertStrings(t, "sources", sources, []string{ + "Assets/Foo/Sub/B.cs", + "Assets/Foo/A.cs", + "Assets/Foo/C.cs", + "Assets/Foo/N.cs", + "Assets/Foo/N/M.cs", + "Assets/Foo/b.cs", + }) +} + +// Verifies a source of a folder an .asmref attaches keeps its place at the head of the response +// file, although the walk reaches that folder after the assembly's own and its name sorts after it. +func TestRebuildSourcesKeepsAnAttachedFolderSourceAtItsResponseFilePosition(t *testing.T) { + projectRoot := newAssemblyProject(t) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Shared", "E.cs"), "") + writeFileAt(t, + filepath.Join(projectRoot, "Assets", "Shared", "Foo.Ref.asmref"), `{"reference":"Foo"}`) + rsp := ResponseFile{Sources: []string{ + "Assets/Shared/E.cs", + "Assets/Foo/A.cs", + }} + + sources := rebuildFooSources(t, projectRoot, rsp) + + assertStrings(t, "sources", sources, []string{ + "Assets/Shared/E.cs", + "Assets/Foo/A.cs", + "Assets/Foo/Sub/B.cs", + }) +} + +// Verifies a source only the rescue of recorded sources keeps - it sits under a stale duplicate +// .asmdef that stops the walk, inside a folder an .asmref attaches - keeps its place at the head of +// the response file. The same rebuild without it in the response file shows the walk never finds it. +func TestRebuildSourcesKeepsARescuedSourceAtItsResponseFilePosition(t *testing.T) { + projectRoot := newAssemblyProject(t) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Bar", "Bar.asmdef"), `{"name":"Bar"}`) + writeFileAt(t, + filepath.Join(projectRoot, "Assets", "Shared", "Foo.Ref.asmref"), `{"reference":"Foo"}`) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Shared", "Dup", "Bar.asmdef"), `{"name":"Bar"}`) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "Shared", "Dup", "R.cs"), "") + + walkedOnly := rebuildFooSources(t, projectRoot, ResponseFile{Sources: []string{"Assets/Foo/A.cs"}}) + assertStrings(t, "sources the walk finds", walkedOnly, []string{ + "Assets/Foo/A.cs", + "Assets/Foo/Sub/B.cs", + }) + + sources := rebuildFooSources(t, projectRoot, ResponseFile{Sources: []string{ + "Assets/Shared/Dup/R.cs", + "Assets/Foo/A.cs", + }}) + + assertStrings(t, "sources", sources, []string{ + "Assets/Shared/Dup/R.cs", + "Assets/Foo/A.cs", + "Assets/Foo/Sub/B.cs", + }) +} + +// Verifies a package-cache assembly keeps the order of its response file. +func TestRebuildSourcesKeepsTheResponseFileOrderOfAPackageCacheAssembly(t *testing.T) { + projectRoot := t.TempDir() + packageDirectory := filepath.Join(projectRoot, "Library", "PackageCache", "com.example.pkg") + writeFileAt(t, filepath.Join(packageDirectory, "Pkg.asmdef"), `{"name":"Pkg"}`) + writeFileAt(t, filepath.Join(packageDirectory, "A.cs"), "") + writeFileAt(t, filepath.Join(packageDirectory, "Sub", "B.cs"), "") + rsp := ResponseFile{Sources: []string{ + "Library/PackageCache/com.example.pkg/Sub/B.cs", + "Library/PackageCache/com.example.pkg/Deleted.cs", + "Library/PackageCache/com.example.pkg/A.cs", + }} + asmdef := AssemblyDefinition{ + Name: "Pkg", + Path: filepath.Join(packageDirectory, "Pkg.asmdef"), + Directory: packageDirectory, + } + + sources, err := RebuildSources(projectRoot, rsp, &asmdef, newOwnerIndexForProject(t, projectRoot)) + if err != nil { + t.Fatalf("expected the rebuild to succeed, got error: %v", err) + } + + assertStrings(t, "sources", sources, []string{ + "Library/PackageCache/com.example.pkg/Sub/B.cs", + "Library/PackageCache/com.example.pkg/A.cs", + }) +} + +// Verifies an assembly with no .asmdef keeps the order of its response file. +func TestRebuildSourcesKeepsTheResponseFileOrderWithoutAnAssemblyDefinition(t *testing.T) { + projectRoot := newAssemblyProject(t) + rsp := ResponseFile{Sources: []string{ + "Assets/Foo/Sub/B.cs", + "Assets/Foo/Deleted.cs", + "Assets/Foo/A.cs", + }} + + sources, err := RebuildSources(projectRoot, rsp, nil, newOwnerIndexForProject(t, projectRoot)) + if err != nil { + t.Fatalf("expected the rebuild to succeed, got error: %v", err) + } + + assertStrings(t, "sources", sources, []string{ + "Assets/Foo/Sub/B.cs", + "Assets/Foo/A.cs", + }) +} + +// Verifies the response file a compile writes lists the sources in the order of Unity's response +// file, from the plan through to the file csc reads, so nothing on the way sorts them again. +func TestCompileWritesSourcesInTheResponseFileOrder(t *testing.T) { + projectRoot := t.TempDir() + writePlanAssembly(t, projectRoot, "A", nil) + writeFileAt(t, filepath.Join(projectRoot, "Assets", "A", "Sub", "Z.cs"), "") + writeFileAt(t, filepath.Join(projectRoot, planDagDirectory, "A.rsp"), joinLines([]string{ + `-out:"` + planDagDirectory + `/A.dll"`, + `-refout:"` + planDagDirectory + `/A.ref.dll"`, + `/additionalfile:"` + planDagDirectory + `/A.UnityAdditionalFile.txt"`, + `"Assets/A/Sub/Z.cs"`, + `"Assets/A/A.cs"`, + })) + settlePlanProject(t, projectRoot, "A") + plan, err := BuildCompilePlan(projectRoot, planDagDirectory, true) + if err != nil { + t.Fatalf("expected the plan to build, got error: %v", err) + } + makeOutputDirectory(t, projectRoot, plan) + captured := exec.Cmd{} + compiler := Compiler{ + Paths: EditorCompilerPaths{DotnetHostPath: "/dotnet", CompilerDllPath: "/csc.dll"}, + ProjectRoot: projectRoot, + Timeout: time.Minute, + Run: stubRunner("", 0, &captured), + } + + if _, err := compiler.CompileUnit(context.Background(), plan, plan.Units[0]); err != nil { + t.Fatalf("expected the unit to compile, got error: %v", err) + } + + written, err := os.ReadFile(filepath.Join(projectRoot, plan.OutputDir, "A.rsp")) + if err != nil { + t.Fatalf("failed to read the written response file: %v", err) + } + sourceLines := []string{} + for _, line := range strings.Split(string(written), "\n") { + if strings.HasPrefix(line, `"Assets/`) { + sourceLines = append(sourceLines, line) + } + } + assertStrings(t, "source lines", sourceLines, []string{`"Assets/A/Sub/Z.cs"`, `"Assets/A/A.cs"`}) +} + +// Verifies a source the response file lists twice is compiled once, at its first position, in the +// re-globbed branch and in the one without an .asmdef alike. +func TestRebuildSourcesKeepsADuplicateResponseFileSourceOnce(t *testing.T) { + projectRoot := newAssemblyProject(t) + rsp := ResponseFile{Sources: []string{ + "Assets/Foo/Sub/B.cs", + "Assets/Foo/A.cs", + "Assets/Foo/Sub/B.cs", + }} + want := []string{"Assets/Foo/Sub/B.cs", "Assets/Foo/A.cs"} + + assertStrings(t, "re-globbed sources", rebuildFooSources(t, projectRoot, rsp), want) + + sources, err := RebuildSources(projectRoot, rsp, nil, newOwnerIndexForProject(t, projectRoot)) + if err != nil { + t.Fatalf("expected the rebuild to succeed, got error: %v", err) + } + assertStrings(t, "sources without an .asmdef", sources, want) +} diff --git a/cli/dispatcher/internal/compilecheck/source_set.go b/cli/dispatcher/internal/compilecheck/source_set.go index f4a634dee7..2e737a507d 100644 --- a/cli/dispatcher/internal/compilecheck/source_set.go +++ b/cli/dispatcher/internal/compilecheck/source_set.go @@ -7,6 +7,7 @@ import ( "io/fs" "os" "path/filepath" + "slices" "sort" "strings" ) @@ -165,21 +166,28 @@ func readAssemblyDefinitionGUID(assemblyDefinitionPath string) string { } // RebuildSources lists the sources to compile now, reflecting .cs files added or deleted since Bee ran. +// The recorded sources keep the order of the response file, and the ones added since follow them in +// ordinal order. +// Why the order is kept: csc emits types in the order of its sources, so another order gives an +// assembly nobody edited a reference assembly in other bytes than Unity's, and its dependents compile +// again. Why the added ones are not slotted in where Unity would put them: Unity versions order the +// response file differently, and appending leaves the relative order of every recorded source as is. func RebuildSources( projectRoot string, rsp ResponseFile, asmdef *AssemblyDefinition, owners assemblyOwnerIndex, ) ([]string, error) { existing := make([]string, 0, len(rsp.Sources)) + seen := map[string]bool{} for _, source := range rsp.Sources { - if owners.tree.fileExists(sourcePath(projectRoot, source)) { - existing = append(existing, source) + if seen[source] || !owners.tree.fileExists(sourcePath(projectRoot, source)) { + continue } + seen[source] = true + existing = append(existing, source) } // Why package-cache assemblies are not re-globbed: their contents are restored from the package // manager and never edited in place, so the walk would only cost time. if asmdef == nil || isUnderPackageCache(projectRoot, asmdef.Directory) { - sort.Strings(existing) - return existing, nil } @@ -225,10 +233,11 @@ func detectMovedAssemblyDefinition( "so it moved after the last Unity build", asmdef.Name) } -// rescueRecordedSources adds back the recorded sources the glob did not produce, which are the files -// an .asmref attaches to this assembly from a folder the glob never reaches - the folder is not -// always outside the assembly directory, since an .asmref nested inside a child assembly's directory -// sits under it yet stops the glob at the child's boundary. +// rescueRecordedSources keeps the recorded sources the glob did not produce, which are the files an +// .asmref attaches to this assembly from a folder the glob never reaches - the folder is not always +// outside the assembly directory, since an .asmref nested inside a child assembly's directory sits +// under it yet stops the glob at the child's boundary. It returns every recorded source in response +// file order, then the sources the glob found that the response file did not record, sorted. // Why each one is checked against the .asmref that owns its folder rather than simply kept: removing // or retargeting an .asmref leaves the .cs file where it is, so the response file still records it // while the project has handed it to another assembly, and keeping it would compile the file into @@ -237,12 +246,13 @@ func rescueRecordedSources( projectRoot string, asmdef AssemblyDefinition, globbed []string, existing []string, owners assemblyOwnerIndex, ) ([]string, error) { - result := globbed globbedSet := map[string]bool{} for _, source := range globbed { globbedSet[filepath.ToSlash(source)] = true } + recordedSet := map[string]bool{} for _, source := range existing { + recordedSet[filepath.ToSlash(source)] = true if globbedSet[filepath.ToSlash(source)] { continue } @@ -251,11 +261,17 @@ func rescueRecordedSources( "the last build recorded %s in %s, which no assembly reference attaches to it any more", filepath.Base(source), asmdef.Name) } - result = append(result, source) } - sort.Strings(result) - return result, nil + added := []string{} + for _, source := range globbed { + if !recordedSet[filepath.ToSlash(source)] { + added = append(added, source) + } + } + sort.Strings(added) + + return append(slices.Clone(existing), added...), nil } // globAssemblySources lists the .cs files an assembly owns, stopping at nested assembly boundaries. diff --git a/cli/dispatcher/internal/compilecheck/source_set_test.go b/cli/dispatcher/internal/compilecheck/source_set_test.go index 87678d2b87..49ed10bd0d 100644 --- a/cli/dispatcher/internal/compilecheck/source_set_test.go +++ b/cli/dispatcher/internal/compilecheck/source_set_test.go @@ -91,8 +91,8 @@ func TestRebuildSourcesKeepsSourcesOutsideTheAssemblyDirectory(t *testing.T) { assertStrings(t, "sources", sources, []string{ "Assets/Foo/A.cs", - "Assets/Foo/Sub/B.cs", "Assets/Shared/E.cs", + "Assets/Foo/Sub/B.cs", }) } @@ -481,8 +481,8 @@ func TestRebuildSourcesGlobsANewSourceInsideAnOutsideAsmrefFolder(t *testing.T) } assertStrings(t, "sources", sources, []string{ - "Assets/Ext/Ext.cs", "Assets/P/A.cs", "Assets/P/Core/Dialog/Dialog.cs", + "Assets/Ext/Ext.cs", }) } diff --git a/docs/compile-check.md b/docs/compile-check.md index 963cd83e6e..55192b3dda 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -53,7 +53,8 @@ references, scripting defines and analyzers. `compile-check` replays those respo an embedded type or an unreferenced private nested type, and reads every annotation by the value that applies to it. One difference remains: a dependent that already fails because it names a private nested type may report the error under another ID (CS0246, CS0426 or CS0117 in place of - CS0122, or the other way round) until it compiles again; whether it compiles does not change. When the worker cannot be built or cannot answer, the dependents are compiled. + CS0122, or the other way round) until it compiles again; whether it compiles does not change. + When the worker cannot be built or cannot answer, the dependents are compiled. Its answer is kept in `.surface.json`, keyed by the content of both reference assemblies and the worker build, so the runs until Unity builds again do not compare the same pair twice. The comparison is always against Unity's own artifact, never against the @@ -86,7 +87,11 @@ references, scripting defines and analyzers. `compile-check` replays those respo it as. A package shipping a sample for a render pipeline the project does not install is the ordinary case. Membership is compared without looking at any timestamp, because moving a file carries its modification time along, and either an `.asmdef` or an `.asmref` can - hand a folder's sources to a different assembly without editing a single `.cs` file. + hand a folder's sources to a different assembly without editing a single `.cs` file. The + recorded sources keep the order of the response file, and the ones added since the last Unity + build follow them in ordinal order: csc emits types in the order of its sources, so any other + order gives an assembly nobody edited a reference assembly in other bytes than Unity's, and + every assembly that references it compiles again. 4. **Check that the response files still describe the project.** An assembly definition that was added, removed, or that stopped building for the Editor, and a reference or a precompiled reference that was added to one, invalidate the recorded build. The run then stops with From cbddc275949140ac2750dca02ae81749622a5256 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 15 Oct 2026 23:53:50 +0900 Subject: [PATCH 24/33] feat: compile-check records whether a compile may still apply while its references are compiling (#3343) --- .../internal/compilecheck/fast_path_test.go | 4 +- .../internal/compilecheck/input_key.go | 138 +++++++++++- .../internal/compilecheck/input_key_test.go | 139 +++++++++++++ .../internal/compilecheck/result_cache.go | 29 ++- .../compilecheck/result_cache_applies_test.go | 133 ++++++++++++ .../resultstore/baseline_contract_test.go | 2 +- .../compilecheck/resultstore/baseline_test.go | 2 +- .../resultstore/compile_result.go | 143 +++++++++++-- .../resultstore/compile_result_test.go | 196 +++++++++++++++++- .../resultstore/record_format_test.go | 6 +- .../compilecheck/resultstore/store.go | 3 +- 11 files changed, 751 insertions(+), 44 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/result_cache_applies_test.go diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go index 87b524a3e4..b4bdd277d0 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -124,7 +124,7 @@ func TestHandCorruptedFailedManifestFallsBack(t *testing.T) { project.recordBaseline(t) project.editBody(t, 0, "edited") baseline := project.baseline(t) - if err := project.records().RecordCompile("Alpha", baseline.InputKey, + if err := project.records().RecordCompile("Alpha", resultstore.KeyIdentity{Key: baseline.InputKey}, resultstore.CompileResult[Diagnostic]{Succeeded: false, Diagnostics: baseline.Diagnostics}, unitOutputNames(project.plan.Units[0])); err != nil { t.Fatalf("failed to record the failed compile: %v", err) } @@ -477,7 +477,7 @@ func TestZeroChangedFilesDropsBaselineAndFallsBack(t *testing.T) { project.recordBaseline(t) baseline := project.baseline(t) records := project.records() - if err := records.RecordCompile("Alpha", "tampered", + if err := records.RecordCompile("Alpha", resultstore.KeyIdentity{Key: "tampered"}, resultstore.CompileResult[Diagnostic]{Succeeded: true, Diagnostics: baseline.Diagnostics}, unitOutputNames(project.plan.Units[0])); err != nil { t.Fatalf("failed to record the tampered compile: %v", err) } diff --git a/cli/dispatcher/internal/compilecheck/input_key.go b/cli/dispatcher/internal/compilecheck/input_key.go index 0ab2617a36..f01c39a886 100644 --- a/cli/dispatcher/internal/compilecheck/input_key.go +++ b/cli/dispatcher/internal/compilecheck/input_key.go @@ -1,6 +1,7 @@ package compilecheck import ( + "bytes" "crypto/sha256" "encoding/hex" "fmt" @@ -10,6 +11,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/resultstore" ) // pathValuedFlagNames are the flags whose value names a file the project edits and csc reads: they @@ -32,6 +35,22 @@ type inputKeyResult struct { // SourceHashes maps each source, spelled as unit.Sources spells it, to the sha256 of the bytes // the key read from it. SourceHashes map[string]string + // Shape digests the same inputs as Key, in the same order, except the references to project + // assemblies, which ProjectReferences carries one by one instead. + // Why they are split out: a reference still compiling is read from Unity's build when a unit + // starts before it finishes, and from this run's output when it changed. Comparing the rest + // apart from it tells whether a recorded compile can still be replayed once it finishes. + Shape string + // ProjectReferences maps each project assembly the unit references, in the plan or not, to the + // sha256 of the line Key holds for it and whether that line reads this run's output. + // Why the plan does not decide which references go here: a reference entering or leaving the + // plan leaves the bytes of Key as they were, so it must leave these as they were too. + ProjectReferences map[string]resultstore.ReferenceIdentity +} + +// identity is what a record of a compile under this key is kept with. +func (result inputKeyResult) identity() resultstore.KeyIdentity { + return resultstore.KeyIdentity{Key: result.Key, Shape: result.Shape, ProjectReferences: result.ProjectReferences} } // computeInputKey digests everything that decides what csc reports for one assembly, so that two @@ -52,8 +71,54 @@ func computeInputKey( if err != nil { return inputKeyResult{}, err } + shape, projectReferences, err := computeShape(projectRoot, paths, plan, unit, sourceHashes) + if err != nil { + return inputKeyResult{}, err + } + + return inputKeyResult{ + Key: key, SourceHashes: sourceHashes, Shape: shape, ProjectReferences: projectReferences, + }, nil +} + +// computeShape digests the parts of the input key in the same order, writing the sources from the +// hashes the key read and leaving each project assembly reference out of the digest, and reports +// the identity of each of those references apart. +// Why the sources are not read again: the shape must describe the very bytes the key does. +func computeShape( + projectRoot string, paths EditorCompilerPaths, plan BuildPlan, unit CompileUnit, + sourceHashes map[string]string, +) (string, map[string]resultstore.ReferenceIdentity, error) { + projectReferences := map[string]resultstore.ReferenceIdentity{} + parts := keyParts(projectRoot, paths, plan, unit, func(digest hash.Hash) error { + writeHashedSources(digest, sourceHashes) + + return nil + }) + parts[referencesPartIndex].write = func(digest hash.Hash) error { + return writeShapeReferenceIdentity(digest, projectRoot, plan, unit, projectReferences) + } + digest := sha256.New() + for _, part := range parts { + if err := part.write(digest); err != nil { + return "", nil, err + } + } + + return hex.EncodeToString(digest.Sum(nil)), projectReferences, nil +} - return inputKeyResult{Key: key, SourceHashes: sourceHashes}, nil +// computeProjectReferences reports the identity of each project assembly the unit references, +// reading those references alone. +// Why apart from the key: it answers whether a record can still apply without reading the sources, +// and differs in most cases where the answer is no. +func computeProjectReferences( + projectRoot string, plan BuildPlan, unit CompileUnit, +) (map[string]resultstore.ReferenceIdentity, error) { + projectReferences := map[string]resultstore.ReferenceIdentity{} + err := writeShapeReferenceIdentity(io.Discard, projectRoot, plan, unit, projectReferences) + + return projectReferences, err } // keyStillHolds reports whether the unit's inputs still give the key the run started from. @@ -122,6 +187,9 @@ var keyPartNames = []string{ "compiler", "response file", "source list", "references", "analyzers", "analyzer inputs", "output name", } +// referencesPartIndex is where keyParts digests the references. +const referencesPartIndex = 3 + // keyParts lists every kind of input of one unit, letting the caller decide how the sources go in. func keyParts( projectRoot string, paths EditorCompilerPaths, plan BuildPlan, unit CompileUnit, @@ -131,7 +199,7 @@ func keyParts( {keyPartNames[0], func(digest hash.Hash) error { return writeCompilerIdentity(digest, paths) }}, {keyPartNames[1], func(digest hash.Hash) error { return writeResponseFileIdentity(digest, unit.Assembly) }}, {keyPartNames[2], sourceWriter}, - {keyPartNames[3], func(digest hash.Hash) error { + {keyPartNames[referencesPartIndex], func(digest hash.Hash) error { return writeReferenceIdentity(digest, projectRoot, plan, unit) }}, {keyPartNames[4], func(digest hash.Hash) error { @@ -223,6 +291,18 @@ func writeSourceIdentity( return nil } +// writeHashedSources writes the same lines writeSourceIdentity writes, from hashes it already read. +func writeHashedSources(digest io.Writer, hashes map[string]string) { + ordered := make([]string, 0, len(hashes)) + for source := range hashes { + ordered = append(ordered, source) + } + sort.Strings(ordered) + for _, source := range ordered { + _, _ = fmt.Fprintf(digest, "source\x00%s\x00%s\n", filepath.ToSlash(source), hashes[source]) + } +} + // writeSourceNames records which sources the assembly compiles, without what they hold. func writeSourceNames(digest hash.Hash, sources []string) { ordered := append([]string{}, sources...) @@ -240,26 +320,64 @@ func writeSourceNames(digest hash.Hash, sources []string) { // is read by timestamp, which moves whenever Unity rebuilt it, and a compile that did not need it // costs less than a missed error. func writeReferenceIdentity( - digest hash.Hash, projectRoot string, plan BuildPlan, unit CompileUnit, + digest io.Writer, projectRoot string, plan BuildPlan, unit CompileUnit, +) error { + for _, reference := range unit.Assembly.References { + if _, err := writeReferenceLine(digest, projectRoot, plan, unit, reference); err != nil { + return err + } + } + + return nil +} + +// writeShapeReferenceIdentity writes the lines writeReferenceIdentity writes for every reference +// that is not a project assembly, and puts the sha256 of the line of each one that is, with which +// file that line reads, into projectReferences under its name. +// Why a name seen twice is simply overwritten: the same reference resolves to the same line. +func writeShapeReferenceIdentity( + digest io.Writer, projectRoot string, plan BuildPlan, unit CompileUnit, + projectReferences map[string]resultstore.ReferenceIdentity, ) error { for _, reference := range unit.Assembly.References { - resolved := rewriteReference(projectRoot, plan, unit, reference) - path := projectPath(projectRoot, resolved) - if isUnderDirectory(resolved, plan.OutputDir) { - if err := writeFileContentEntry(digest, "produced-reference", resolved, path); err != nil { + name, isProjectAssembly := projectAssemblyReferenceName(reference, plan.DagDir) + if !isProjectAssembly { + if _, err := writeReferenceLine(digest, projectRoot, plan, unit, reference); err != nil { return err } continue } - if err := writeStatEntry(digest, "reference", resolved, path); err != nil { + var line bytes.Buffer + produced, err := writeReferenceLine(&line, projectRoot, plan, unit, reference) + if err != nil { return err } + sum := sha256.Sum256(line.Bytes()) + kind := resultstore.UnityReference + if produced { + kind = resultstore.ProducedReference + } + projectReferences[name] = resultstore.ReferenceIdentity{Kind: kind, Hash: hex.EncodeToString(sum[:])} } return nil } +// writeReferenceLine writes the line one reference contributes to the key, and reports whether that +// line reads this run's output rather than Unity's build. +func writeReferenceLine( + digest io.Writer, projectRoot string, plan BuildPlan, unit CompileUnit, reference string, +) (bool, error) { + resolved := rewriteReference(projectRoot, plan, unit, reference) + path := projectPath(projectRoot, resolved) + if isUnderDirectory(resolved, plan.OutputDir) { + return true, writeFileContentEntry(digest, "produced-reference", resolved, path) + } + + return false, writeStatEntry(digest, "reference", resolved, path) +} + // writeAnalyzerIdentity records the analyzer assemblies csc loads, which are shipped rather than // edited, so their timestamps answer whether they moved. func writeAnalyzerIdentity(digest hash.Hash, projectRoot string, analyzers []string) error { @@ -360,7 +478,7 @@ func splitFlag(line string) (string, string, bool) { } // writeStatEntry records a file by where it is, how big it is and when it last changed. -func writeStatEntry(digest hash.Hash, kind string, name string, path string) error { +func writeStatEntry(digest io.Writer, kind string, name string, path string) error { info, err := os.Stat(path) if err != nil { return fmt.Errorf("failed to read %s: %w", path, err) @@ -372,7 +490,7 @@ func writeStatEntry(digest hash.Hash, kind string, name string, path string) err } // writeFileContentEntry records a file by where it is and what it holds. -func writeFileContentEntry(digest hash.Hash, kind string, name string, path string) error { +func writeFileContentEntry(digest io.Writer, kind string, name string, path string) error { file, err := os.Open(path) if err != nil { return fmt.Errorf("failed to read %s: %w", path, err) diff --git a/cli/dispatcher/internal/compilecheck/input_key_test.go b/cli/dispatcher/internal/compilecheck/input_key_test.go index 6c1d1b2184..d9f874b496 100644 --- a/cli/dispatcher/internal/compilecheck/input_key_test.go +++ b/cli/dispatcher/internal/compilecheck/input_key_test.go @@ -6,8 +6,11 @@ import ( "os" "path/filepath" "reflect" + "sort" "testing" "time" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/resultstore" ) // inputKeyProject is a project tree holding every kind of input one unit's key is built from, so a @@ -471,3 +474,139 @@ func TestInputKeyReturnsHashesOfContentItRead(t *testing.T) { t.Errorf("expected source hashes %v, got %v", expected, result.SourceHashes) } } + +// identity computes the whole input key result of the unit under test. +func (project *inputKeyProject) identity(t *testing.T) inputKeyResult { + t.Helper() + keyResult, err := computeInputKey(project.root, project.paths, project.plan, project.plan.Units[0]) + if err != nil { + t.Fatalf("expected a key, got error: %v", err) + } + + return keyResult +} + +// assertShapeKept fails when a change that should only move one project reference moved the shape, +// or did not move the key and that reference's identity. +func assertShapeKept(t *testing.T, before inputKeyResult, after inputKeyResult, reference string) { + t.Helper() + if after.Key == before.Key { + t.Error("the change should move the key") + } + if after.ProjectReferences[reference] == before.ProjectReferences[reference] { + t.Errorf("the change should move the identity of %s", reference) + } + if after.Shape != before.Shape { + t.Error("the change should leave the shape alone") + } +} + +// Verifies the shape leaves out the content of a reference this run produced, which the identity of +// that reference carries instead: a record must stay comparable while that reference is unknown. +func TestComputeInputKeyShapeIgnoresTheIdentityOfAProducedReference(t *testing.T) { + project := newInputKeyProject(t) + before := project.identity(t) + + writeProjectFile(t, project.root, + filepath.Join(project.plan.OutputDir, "Beta"+referenceAssemblyExtension), "beta surface with more") + + assertShapeKept(t, before, project.identity(t), "Beta") +} + +// Verifies the shape leaves out the timestamp of Unity's file for a reference this run compiles but +// reads from Unity's build, which the identity of that reference carries instead. +func TestComputeInputKeyShapeIgnoresTheIdentityOfAKeptReference(t *testing.T) { + project := newInputKeyProject(t) + unityReference := filepath.Join(planDagDirectory, "Beta"+referenceAssemblyExtension) + writeProjectFile(t, project.root, unityReference, "unity beta") + project.plan.Units[0].KeptSurfaceReferences = map[string]bool{"Beta": true} + before := project.identity(t) + + touchProjectFile(t, project.root, unityReference) + + assertShapeKept(t, before, project.identity(t), "Beta") +} + +// Verifies the key, the shape and the project reference identities do not depend on whether a +// referenced assembly is in the plan: when it is and kept Unity's surface, the compile reads the +// same file as when it is not, so a record must stay comparable as the reference enters or leaves. +func TestComputeInputKeyShapeDoesNotDependOnThePlan(t *testing.T) { + project := newInputKeyProject(t) + outsidePlan := project.identity(t) + + project.plan.Units = append(project.plan.Units, CompileUnit{Assembly: ResponseFile{AssemblyName: "Gamma"}}) + project.plan.Units[0].KeptSurfaceReferences = map[string]bool{"Gamma": true} + insidePlan := project.identity(t) + + if insidePlan.Key != outsidePlan.Key { + t.Error("the key should not depend on whether a kept reference is in the plan") + } + if insidePlan.Shape != outsidePlan.Shape { + t.Error("the shape should not depend on whether a kept reference is in the plan") + } + if !reflect.DeepEqual(insidePlan.ProjectReferences, outsidePlan.ProjectReferences) { + t.Errorf("project references = %v, want %v", insidePlan.ProjectReferences, outsidePlan.ProjectReferences) + } +} + +// Verifies a reference that is not a project assembly - a package or system dll - stays in the +// shape: it is never unknown while the run compiles, so the shape is what follows it. +func TestComputeInputKeyShapeFollowsAReferenceOutsideTheProject(t *testing.T) { + project := newInputKeyProject(t) + library := filepath.Join("Packages", "Library.dll") + writeProjectFile(t, project.root, library, "library") + project.plan.Units[0].Assembly.References = append(project.plan.Units[0].Assembly.References, library) + before := project.identity(t) + + touchProjectFile(t, project.root, library) + after := project.identity(t) + + if after.Shape == before.Shape { + t.Error("a reference outside the project should move the shape") + } + if !reflect.DeepEqual(after.ProjectReferences, before.ProjectReferences) { + t.Error("a reference outside the project should leave the project reference identities alone") + } +} + +// Verifies the shape follows the content of the sources and the project reference identities do not. +func TestComputeInputKeyShapeFollowsTheSources(t *testing.T) { + project := newInputKeyProject(t) + before := project.identity(t) + + writeProjectFile(t, project.root, filepath.Join("Assets", "Alpha", "Alpha.cs"), "class Alpha { int Added; }") + after := project.identity(t) + + if after.Shape == before.Shape { + t.Error("an edited source should move the shape") + } + if !reflect.DeepEqual(after.ProjectReferences, before.ProjectReferences) { + t.Error("an edited source should leave the project reference identities alone") + } +} + +// Verifies the project reference identities name every project assembly the unit references, in the +// plan or not, and nothing else, each with the kind of file its line reads. +func TestComputeInputKeyNamesEveryProjectReference(t *testing.T) { + project := newInputKeyProject(t) + library := filepath.Join("Packages", "Library.dll") + writeProjectFile(t, project.root, library, "library") + project.plan.Units[0].Assembly.References = append(project.plan.Units[0].Assembly.References, library) + + identity := project.identity(t) + + names := []string{} + for name := range identity.ProjectReferences { + names = append(names, name) + } + sort.Strings(names) + if !reflect.DeepEqual(names, []string{"Beta", "Gamma"}) { + t.Errorf("project references = %v, want [Beta Gamma]", names) + } + if kind := identity.ProjectReferences["Beta"].Kind; kind != resultstore.ProducedReference { + t.Errorf("Beta, read from this run's output, is of kind %q", kind) + } + if kind := identity.ProjectReferences["Gamma"].Kind; kind != resultstore.UnityReference { + t.Errorf("Gamma, read from Unity's build, is of kind %q", kind) + } +} diff --git a/cli/dispatcher/internal/compilecheck/result_cache.go b/cli/dispatcher/internal/compilecheck/result_cache.go index 4592285d0d..7453b858ef 100644 --- a/cli/dispatcher/internal/compilecheck/result_cache.go +++ b/cli/dispatcher/internal/compilecheck/result_cache.go @@ -63,7 +63,7 @@ func (c Compiler) reuseOrCompile( // putting the file back would replay what the edited file reported. CompileUnit has already // removed the previous record, so leaving this one out makes the next run compile again. if c.keyStillHolds(plan, unit, key) { - if writeErr := records.RecordCompile(unit.Assembly.AssemblyName, key, + if writeErr := records.RecordCompile(unit.Assembly.AssemblyName, keyResult.identity(), resultstore.CompileResult[Diagnostic]{Succeeded: result.Succeeded, Diagnostics: result.Diagnostics}, unitOutputNames(unit)); writeErr != nil { return UnitResult{}, writeErr } @@ -82,6 +82,33 @@ func (c Compiler) reuseOrCompile( return result, nil } +// recordMayStillApply reports whether the unit's recorded compile may be replayed once the +// references named in unknown finish compiling. unit is shaped the way it would start now, reading +// Unity's reference assembly for each of them; what it reads for them is not compared. +// Why the project references are compared before the shape: the shape reads every source of the +// unit, and in the common case where a reference that already finished changed again, the +// references alone answer no. +// Why --all and an unreadable input answer no: --all never replays a record, and a unit whose key +// cannot be computed cannot replay one either, so waiting for either would gain nothing. +func (c Compiler) recordMayStillApply(plan BuildPlan, unit CompileUnit, unknown map[string]bool) bool { + if plan.All { + return false + } + projectReferences, err := computeProjectReferences(c.ProjectRoot, plan, unit) + if err != nil { + return false + } + + // Why the sources are hashed inside the function only: the store asks for the shape last, and + // most answers of no come from the references before it. + return c.records(plan).MayStillApply(unit.Assembly.AssemblyName, projectReferences, unknown, + func() (string, error) { + keyResult, keyErr := computeInputKey(c.ProjectRoot, c.Paths, plan, unit) + + return keyResult.Shape, keyErr + }) +} + // records opens the store of what earlier runs of the check recorded in the plan's output directory. func (c Compiler) records(plan BuildPlan) resultstore.Store[Diagnostic] { return resultstore.Open[Diagnostic](c.ProjectRoot, plan.OutputDir) diff --git a/cli/dispatcher/internal/compilecheck/result_cache_applies_test.go b/cli/dispatcher/internal/compilecheck/result_cache_applies_test.go new file mode 100644 index 0000000000..db3351f394 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/result_cache_applies_test.go @@ -0,0 +1,133 @@ +package compilecheck + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// recordMayStillApply asks whether the named unit's record may still apply under plan, with the +// named references still compiling: the unit is shaped the way it would start then, reading Unity's +// reference assembly for each of them. +func (project *cachedSchedulerProject) recordMayStillApply( + t *testing.T, plan BuildPlan, name string, unknownNames ...string, +) bool { + t.Helper() + unknown := map[string]bool{} + for _, unknownName := range unknownNames { + unknown[unknownName] = true + } + for _, unit := range plan.Units { + if unit.Assembly.AssemblyName != name { + continue + } + unit.KeptSurfaceReferences = unknown + compiler := Compiler{Paths: project.paths, ProjectRoot: project.root, Timeout: time.Minute} + + return compiler.recordMayStillApply(plan, unit, unknown) + } + t.Fatalf("the plan has no unit %s", name) + + return false +} + +// newChangedReferenceProject compiles [X, D], D referencing X, where X changed its surface, so D's +// record holds the identity of the reference assembly this run produced for X. +func newChangedReferenceProject(t *testing.T) *cachedSchedulerProject { + t.Helper() + project := newCachedSchedulerProject(t, map[string][]string{"D": {"X"}}, "X", "D") + project.compileOrFail(t, newCachedSchedulerRecorder("X", "D")) + + return project +} + +// Verifies a record holding the produced identity of a reference still may apply while that +// reference is unknown - the next start reads Unity's file for it - even after the produced file +// changed, and stops applying once the unit's own sources change. +func TestCompileUnitsRecordsAKeyAUnitCanStillApplyLater(t *testing.T) { + project := newChangedReferenceProject(t) + + if !project.recordMayStillApply(t, project.plan, "D", "X") { + t.Fatal("the record should still apply while X is unknown") + } + + writeOutputFile(t, project.outputDirectory(), "X"+referenceAssemblyExtension, "another surface X") + if !project.recordMayStillApply(t, project.plan, "D", "X") { + t.Error("the record should still apply while X is unknown, whatever X's output holds") + } + + project.editSource(t, "D", "class D { int Added; }") + if project.recordMayStillApply(t, project.plan, "D", "X") { + t.Error("the record should not apply once D's own sources changed") + } +} + +// Verifies a record that read an unknown reference from Unity's build stops applying once Unity +// rebuilt that file: whatever the reference finishes as, the key can never read the recorded line +// again. +func TestRecordMayStillApplyRefusesAnUnknownReferenceUnityRebuilt(t *testing.T) { + project := newCachedSchedulerProject(t, map[string][]string{"D": {"X"}}, "X", "D") + recorder := newCachedSchedulerRecorder("X", "D") + recorder.surfaces["X"] = keptSurfaceOf("X") + project.compileOrFail(t, recorder) + if !project.recordMayStillApply(t, project.plan, "D", "X") { + t.Fatal("the record should still apply while Unity's file for X is as it read it") + } + + rebuilt := time.Now().Add(time.Hour) + unityReference := filepath.Join(project.root, planDagDirectory, "X"+referenceAssemblyExtension) + if err := os.Chtimes(unityReference, rebuilt, rebuilt); err != nil { + t.Fatalf("failed to move Unity's reference assembly of X: %v", err) + } + + if project.recordMayStillApply(t, project.plan, "D", "X") { + t.Error("the record should not apply once Unity rebuilt the reference assembly it read for X") + } +} + +// Verifies a record still may apply after a reference that kept Unity's surface left the plan: the +// compile reads the same file for it either way, so neither the key nor its parts move. +func TestMayStillApplyWhenAReferenceLeftThePlan(t *testing.T) { + project := newCachedSchedulerProject(t, map[string][]string{ + "D1": {"X"}, "D2": {"X", "D1", "W"}, + }, "X", "D1", "D2", "W") + recorder := newCachedSchedulerRecorder("X", "D1", "D2", "W") + recorder.surfaces["W"] = keptSurfaceOf("W") + project.compileOrFail(t, recorder) + + if !project.recordMayStillApply(t, project.withOnlyUnits("X", "D1", "D2"), "D2", "D1") { + t.Error("the record should still apply after the kept reference W left the plan") + } +} + +// Verifies --all never waits on a record: it does not replay one. +func TestRecordMayStillApplyIsFalseForAll(t *testing.T) { + project := newChangedReferenceProject(t) + if !project.recordMayStillApply(t, project.plan, "D", "X") { + t.Fatal("the record should still apply in an ordinary run") + } + + all := project.plan + all.All = true + + if project.recordMayStillApply(t, all, "D", "X") { + t.Error("a run with --all should not wait on a record") + } +} + +// Verifies a unit whose key cannot be computed never waits on a record: it could not replay one. +func TestRecordMayStillApplyIsFalseWhenTheKeyCannotBeComputed(t *testing.T) { + project := newChangedReferenceProject(t) + if !project.recordMayStillApply(t, project.plan, "D", "X") { + t.Fatal("the record should still apply while every input can be read") + } + + if err := os.Remove(filepath.Join(project.root, "Assets", "D", "D.cs")); err != nil { + t.Fatalf("failed to remove D's source: %v", err) + } + + if project.recordMayStillApply(t, project.plan, "D", "X") { + t.Error("a unit whose source is gone should not wait on a record") + } +} diff --git a/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go b/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go index 1d925134fd..b170c55727 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/baseline_contract_test.go @@ -45,7 +45,7 @@ func (project projectStore) recordCompile(t *testing.T, key string, succeeded bo Succeeded: succeeded, Diagnostics: []testDiagnostic{{Severity: "warning", Code: "CS0168", File: "Assets/A.cs", Line: 1, Column: 1}}, } - if err := project.store.RecordCompile("Alpha", key, result, alphaOutputs()); err != nil { + if err := project.store.RecordCompile("Alpha", KeyIdentity{Key: key}, result, alphaOutputs()); err != nil { t.Fatalf("failed to record the compile: %v", err) } } diff --git a/cli/dispatcher/internal/compilecheck/resultstore/baseline_test.go b/cli/dispatcher/internal/compilecheck/resultstore/baseline_test.go index 8557a2d412..acd1d2200c 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/baseline_test.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/baseline_test.go @@ -30,7 +30,7 @@ func TestHashGeneratedFilesRecordsOnlyCSharpFiles(t *testing.T) { func TestLoadBaselineReportsABaselineOfAnotherFormat(t *testing.T) { directoryPath := newRecordedOutputs(t) store := directoryStore(directoryPath) - if err := store.RecordCompile("Alpha", "input", CompileResult[testDiagnostic]{Succeeded: true}, alphaOutputs()); err != nil { + if err := store.RecordCompile("Alpha", KeyIdentity{Key: "input"}, CompileResult[testDiagnostic]{Succeeded: true}, alphaOutputs()); err != nil { t.Fatalf("failed to write the manifest: %v", err) } if err := writeBaselineRecord(directoryPath, "Alpha", baselineRecord{ diff --git a/cli/dispatcher/internal/compilecheck/resultstore/compile_result.go b/cli/dispatcher/internal/compilecheck/resultstore/compile_result.go index cd240cff18..8d39cfa3ae 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/compile_result.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/compile_result.go @@ -12,8 +12,9 @@ const ( // what belongs in the key is part of this check's code, so a manifest from another version says // nothing about whether this one would compile the same thing. So is the order the sources are // handed to csc in, which decides the bytes of what it writes without being in the key: version 1 - // compiled them in ordinal order rather than Unity's. - resultManifestFormatVersion = 2 + // compiled them in ordinal order rather than Unity's. Version 2 had no shape, so it could not say + // whether it may still apply while some of its references are compiling. + resultManifestFormatVersion = 3 resultManifestExtension = ".result.json" ) @@ -24,13 +25,42 @@ type CompileResult[D any] struct { Diagnostics []D } +// KeyIdentity is the input key a compile ran on, together with the parts of it that tell whether the +// compile may still apply while some of the project assemblies it references are compiling. +type KeyIdentity struct { + Key string + // Shape digests every input of the key except the references to project assemblies. + Shape string + // ProjectReferences maps each project assembly the unit references to the identity the key holds + // for it. + ProjectReferences map[string]ReferenceIdentity +} + +const ( + // ProducedReference is the kind of a reference the compile read from this run's own output. + ProducedReference = "produced" + // UnityReference is the kind of a reference the compile read from Unity's build. + UnityReference = "unity" +) + +// ReferenceIdentity is what an input key holds for one project assembly reference: which file the +// compile read it from, and a hash of the line the key holds for it. +// Why the kind is kept with the hash: a reference still compiling is read from Unity's build, and +// whether a record can still apply once it finishes depends on which file the record read. +type ReferenceIdentity struct { + Kind string + Hash string +} + // unitResultManifest is what one finished compile left behind for the next run: the inputs it ran // on, what it reported, and the outputs it wrote. type unitResultManifest[D any] struct { - FormatVersion int - InputKey string - Succeeded bool - Diagnostics []D + FormatVersion int + InputKey string + Shape string + ProjectReferences map[string]ReferenceIdentity + Succeeded bool + Diagnostics []D // Outputs maps the name of each file the compile wrote in the check's output directory to its // content hash, so a run that finds them changed underneath compiles again. Outputs map[string]string @@ -46,16 +76,8 @@ func manifestPath(directoryPath string, assembly string) string { // Why anything unexpected simply means no: the caller compiles the unit, which is what it would // have done anyway. Nothing here is worth failing a run over. func (s Store[D]) Reusable(assembly string, key string) (CompileResult[D], bool) { - content, err := os.ReadFile(manifestPath(s.directoryPath(), assembly)) - if err != nil { - return CompileResult[D]{}, false - } - - var manifest unitResultManifest[D] - if err := json.Unmarshal(content, &manifest); err != nil { - return CompileResult[D]{}, false - } - if manifest.FormatVersion != resultManifestFormatVersion || manifest.InputKey != key || key == "" { + manifest, found := s.loadManifest(assembly) + if !found || manifest.InputKey != key || key == "" { return CompileResult[D]{}, false } if !outputsStillMatch(s.directoryPath(), manifest.Outputs) { @@ -68,6 +90,77 @@ func (s Store[D]) Reusable(assembly string, key string) (CompileResult[D], bool) return CompileResult[D]{Succeeded: manifest.Succeeded, Diagnostics: manifest.Diagnostics}, true } +// MayStillApply reports whether the assembly's recorded compile may become reusable once the +// references named in unknown finish compiling. projectReferences is what the key holds for each +// project assembly reference now, with the unknown ones read from Unity's build. +// It checks, in this order, and stops at the first that fails: a record of the current format, the +// same project assemblies referenced, every reference outside unknown as recorded, every unknown one +// the record read from Unity's build still as recorded, the outputs still holding what that compile +// wrote, and shape giving the recorded shape. +// Why an unknown reference the record read from Unity's build must still match: it finishes either +// keeping Unity's surface, read from that same file, or changed, read from this run's output; when +// Unity's file moved, neither gives the recorded line back. One the record read from this run's +// output is not compared, because what it finishes as decides that. +// Why shape is a function asked last: it reads every source of the assembly, and the cheaper checks +// before it answer most of the cases where the answer is no. +// Why anything unexpected means no: the caller then compiles without waiting, which is what it did +// before this check existed. +func (s Store[D]) MayStillApply( + assembly string, projectReferences map[string]ReferenceIdentity, unknown map[string]bool, + shape func() (string, error), +) bool { + manifest, found := s.loadManifest(assembly) + if !found || !referencesMayStillMatch(manifest.ProjectReferences, projectReferences, unknown) { + return false + } + if !outputsStillMatch(s.directoryPath(), manifest.Outputs) { + return false + } + current, err := shape() + + return err == nil && current == manifest.Shape +} + +// referencesMayStillMatch reports whether two sets of project reference identities name the same +// assemblies, agree on every one outside unknown, and agree on every unknown one the record read from +// Unity's build. +func referencesMayStillMatch( + recorded map[string]ReferenceIdentity, current map[string]ReferenceIdentity, unknown map[string]bool, +) bool { + if len(recorded) != len(current) { + return false + } + for name, identity := range current { + recordedIdentity, found := recorded[name] + if !found { + return false + } + if unknown[name] && recordedIdentity.Kind == ProducedReference { + continue + } + if recordedIdentity != identity { + return false + } + } + + return true +} + +// loadManifest reads the assembly's manifest when one of the current format is on disk. +func (s Store[D]) loadManifest(assembly string) (unitResultManifest[D], bool) { + content, err := os.ReadFile(manifestPath(s.directoryPath(), assembly)) + if err != nil { + return unitResultManifest[D]{}, false + } + + var manifest unitResultManifest[D] + if err := json.Unmarshal(content, &manifest); err != nil { + return unitResultManifest[D]{}, false + } + + return manifest, manifest.FormatVersion == resultManifestFormatVersion +} + // outputsStillMatch reports whether every file a compile wrote is still there and still holds what // it wrote. func outputsStillMatch(directoryPath string, outputs map[string]string) bool { @@ -81,12 +174,14 @@ func outputsStillMatch(directoryPath string, outputs map[string]string) bool { return true } -// RecordCompile records what a compile of the assembly ran on (key) and what it reported, together +// RecordCompile records what a compile of the assembly ran on (identity) and what it reported, together // with the outputs it wrote, named in the output directory. It records nothing when that compile // claimed success without producing every one of those outputs. // Why it is written through a temporary file: a run interrupted while writing would otherwise leave // half a manifest behind, and the next run would read it as a description of outputs it never saw. -func (s Store[D]) RecordCompile(assembly string, key string, result CompileResult[D], outputs []string) error { +func (s Store[D]) RecordCompile( + assembly string, identity KeyIdentity, result CompileResult[D], outputs []string, +) error { hashes, complete, err := hashOutputs(s.directoryPath(), outputs, result.Succeeded) if err != nil { return err @@ -96,11 +191,13 @@ func (s Store[D]) RecordCompile(assembly string, key string, result CompileResul } content, err := json.Marshal(unitResultManifest[D]{ - FormatVersion: resultManifestFormatVersion, - InputKey: key, - Succeeded: result.Succeeded, - Diagnostics: result.Diagnostics, - Outputs: hashes, + FormatVersion: resultManifestFormatVersion, + InputKey: identity.Key, + Shape: identity.Shape, + ProjectReferences: identity.ProjectReferences, + Succeeded: result.Succeeded, + Diagnostics: result.Diagnostics, + Outputs: hashes, }) if err != nil { return fmt.Errorf("failed to record the result of %s: %w", assembly, err) diff --git a/cli/dispatcher/internal/compilecheck/resultstore/compile_result_test.go b/cli/dispatcher/internal/compilecheck/resultstore/compile_result_test.go index 97c95ed20b..8e9aed7b8e 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/compile_result_test.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/compile_result_test.go @@ -46,7 +46,7 @@ func storedResult() CompileResult[testDiagnostic] { // recordResult stores one compile of Alpha's outcome under a key. func recordResult(t *testing.T, directoryPath string, key string) { t.Helper() - if err := directoryStore(directoryPath).RecordCompile("Alpha", key, storedResult(), alphaOutputs()); err != nil { + if err := directoryStore(directoryPath).RecordCompile("Alpha", KeyIdentity{Key: key}, storedResult(), alphaOutputs()); err != nil { t.Fatalf("failed to write the manifest: %v", err) } } @@ -197,7 +197,7 @@ func TestRecordCompileReportsAFailedRename(t *testing.T) { directoryPath := newRecordedOutputs(t) blockManifestPath(t, manifestPath(directoryPath, "Alpha")) - err := directoryStore(directoryPath).RecordCompile("Alpha", "key", storedResult(), alphaOutputs()) + err := directoryStore(directoryPath).RecordCompile("Alpha", KeyIdentity{Key: "key"}, storedResult(), alphaOutputs()) if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(directoryPath, "Alpha")+":") { t.Fatalf("err = %v", err) @@ -213,7 +213,7 @@ func TestRecordCompileReportsAFailedRename(t *testing.T) { func TestRecordCompileReportsAMissingOutputDirectory(t *testing.T) { directoryPath := filepath.Join(t.TempDir(), "missing") - err := directoryStore(directoryPath).RecordCompile("Alpha", "key", storedResult(), alphaOutputs()) + err := directoryStore(directoryPath).RecordCompile("Alpha", KeyIdentity{Key: "key"}, storedResult(), alphaOutputs()) if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(directoryPath, "Alpha")+":") { t.Fatalf("err = %v", err) @@ -271,3 +271,193 @@ func TestForgetRemovesTheManifestTheBaselineAndTheGeneratorOutput(t *testing.T) } } } + +// recordIdentity stores one compile of Alpha's outcome under the shape and project reference +// identities of the key it ran on. +func recordIdentity( + t *testing.T, directoryPath string, shape string, projectReferences map[string]ReferenceIdentity, +) { + t.Helper() + identity := KeyIdentity{Key: "key", Shape: shape, ProjectReferences: projectReferences} + if err := directoryStore(directoryPath).RecordCompile("Alpha", identity, storedResult(), alphaOutputs()); err != nil { + t.Fatalf("failed to write the manifest: %v", err) + } +} + +// unityLine is the identity of a reference read from Unity's build. +func unityLine(hash string) ReferenceIdentity { + return ReferenceIdentity{Kind: UnityReference, Hash: hash} +} + +// producedLine is the identity of a reference read from this run's output. +func producedLine(hash string) ReferenceIdentity { + return ReferenceIdentity{Kind: ProducedReference, Hash: hash} +} + +// shapeOf hands MayStillApply a shape, counting how many times it was asked for one. +func shapeOf(shape string, asked *int) func() (string, error) { + return func() (string, error) { + *asked++ + + return shape, nil + } +} + +// Verifies a record still may apply when the only difference is an unknown reference it read from +// this run's output: what that reference finishes as decides the key, and it has not finished. +func TestMayStillApplyWhenOnlyUnknownReferencesDiffer(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordIdentity(t, directoryPath, "shape", + map[string]ReferenceIdentity{"Beta": producedLine("produced beta"), "Gamma": unityLine("gamma")}) + asked := 0 + + mayApply := directoryStore(directoryPath).MayStillApply("Alpha", + map[string]ReferenceIdentity{"Beta": unityLine("unity beta"), "Gamma": unityLine("gamma")}, + map[string]bool{"Beta": true}, shapeOf("shape", &asked)) + + if !mayApply { + t.Error("a record differing only in an unknown reference read from this run's output should still apply") + } +} + +// Verifies an unknown reference the record read from Unity's build must still match Unity's file: +// whatever it finishes as, a moved Unity file can never give the recorded line back. The same +// record with Unity's file as recorded still applies. +func TestMayStillApplyRefusesAnUnknownReferenceWhoseUnityFileMoved(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordIdentity(t, directoryPath, "shape", map[string]ReferenceIdentity{"Beta": unityLine("unity beta")}) + asked := 0 + store := directoryStore(directoryPath) + unknown := map[string]bool{"Beta": true} + + if !store.MayStillApply("Alpha", + map[string]ReferenceIdentity{"Beta": unityLine("unity beta")}, unknown, shapeOf("shape", &asked)) { + t.Fatal("a record whose unknown reference is still Unity's file as recorded should still apply") + } + if store.MayStillApply("Alpha", + map[string]ReferenceIdentity{"Beta": unityLine("rebuilt unity beta")}, unknown, shapeOf("shape", &asked)) { + t.Error("a record whose unknown reference read a Unity file that has moved should not apply") + } +} + +// Verifies a record no longer applies when a reference that is not unknown has another identity, +// and that the shape, which reads every source, is not asked for then. +func TestMayStillApplyRefusesAChangedKnownReference(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordIdentity(t, directoryPath, "shape", + map[string]ReferenceIdentity{"Beta": producedLine("beta"), "Gamma": unityLine("gamma")}) + asked := 0 + + mayApply := directoryStore(directoryPath).MayStillApply("Alpha", + map[string]ReferenceIdentity{"Beta": producedLine("beta"), "Gamma": unityLine("another gamma")}, + map[string]bool{"Beta": true}, shapeOf("shape", &asked)) + + if mayApply { + t.Error("a record whose known reference changed should not apply") + } + if asked != 0 { + t.Errorf("the shape was asked for %d times, want none once a known reference differs", asked) + } +} + +// Verifies a known reference must match in kind as well as in hash: a line read from Unity's build +// never matches one read from this run's output. +func TestMayStillApplyRefusesAKnownReferenceOfAnotherKind(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordIdentity(t, directoryPath, "shape", map[string]ReferenceIdentity{"Beta": producedLine("beta")}) + asked := 0 + + if directoryStore(directoryPath).MayStillApply("Alpha", + map[string]ReferenceIdentity{"Beta": unityLine("beta")}, map[string]bool{}, shapeOf("shape", &asked)) { + t.Error("a known reference read from another file should not apply") + } +} + +// Verifies the shape is asked for exactly once when everything before it matches, and decides the +// answer then. +func TestMayStillApplyAsksForTheShapeOnceEverythingElseMatches(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordIdentity(t, directoryPath, "shape", map[string]ReferenceIdentity{"Beta": producedLine("beta")}) + store := directoryStore(directoryPath) + current := map[string]ReferenceIdentity{"Beta": producedLine("beta")} + + sameAsked := 0 + if !store.MayStillApply("Alpha", current, map[string]bool{}, shapeOf("shape", &sameAsked)) { + t.Error("a record matching in everything should still apply") + } + otherAsked := 0 + if store.MayStillApply("Alpha", current, map[string]bool{}, shapeOf("another shape", &otherAsked)) { + t.Error("a record of another shape should not apply") + } + if sameAsked != 1 || otherAsked != 1 { + t.Errorf("the shape was asked for %d and %d times, want once each", sameAsked, otherAsked) + } +} + +// Verifies a shape that cannot be computed means the record does not apply. +func TestMayStillApplyRefusesAShapeThatCannotBeComputed(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordIdentity(t, directoryPath, "shape", map[string]ReferenceIdentity{"Beta": producedLine("beta")}) + + if directoryStore(directoryPath).MayStillApply("Alpha", + map[string]ReferenceIdentity{"Beta": producedLine("beta")}, map[string]bool{}, + func() (string, error) { return "shape", os.ErrNotExist }) { + t.Error("a record whose shape cannot be computed should not apply") + } +} + +// Verifies a record no longer applies when the unit references another set of project assemblies, +// whether one was added or one was dropped, even when the difference is an unknown reference. +func TestMayStillApplyRefusesAnotherSetOfReferences(t *testing.T) { + cases := map[string]map[string]ReferenceIdentity{ + "a reference the record lacks": { + "Beta": producedLine("beta"), "Gamma": producedLine("gamma"), "Delta": producedLine("delta"), + }, + "a reference no longer there": {"Beta": producedLine("beta")}, + } + for name, current := range cases { + t.Run(name, func(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordIdentity(t, directoryPath, "shape", + map[string]ReferenceIdentity{"Beta": producedLine("beta"), "Gamma": producedLine("gamma")}) + asked := 0 + unknown := map[string]bool{"Gamma": true, "Delta": true} + + if directoryStore(directoryPath).MayStillApply("Alpha", current, unknown, shapeOf("shape", &asked)) { + t.Error("a record over another set of project references should not apply") + } + }) + } +} + +// Verifies a record no longer applies once an output it hashed holds other bytes. +func TestMayStillApplyRefusesChangedOutputs(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordIdentity(t, directoryPath, "shape", map[string]ReferenceIdentity{"Beta": producedLine("beta")}) + writeOutputFile(t, directoryPath, "Alpha"+referenceAssemblyExtension, "another surface") + asked := 0 + + if directoryStore(directoryPath).MayStillApply("Alpha", + map[string]ReferenceIdentity{"Beta": producedLine("beta")}, map[string]bool{}, shapeOf("shape", &asked)) { + t.Error("a record whose outputs changed should not apply") + } + if asked != 0 { + t.Errorf("the shape was asked for %d times, want none once an output differs", asked) + } +} + +// Verifies a record of format version 2, which kept no shape, neither may apply nor is reused. +func TestMayStillApplyRefusesAnotherFormatVersion(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordIdentity(t, directoryPath, "shape", map[string]ReferenceIdentity{"Beta": producedLine("beta")}) + setManifestFormatVersion(t, directoryPath, "Alpha", 2) + asked := 0 + + if directoryStore(directoryPath).MayStillApply("Alpha", + map[string]ReferenceIdentity{"Beta": producedLine("beta")}, map[string]bool{}, shapeOf("shape", &asked)) { + t.Error("a record of format version 2 should not apply") + } + if _, reusable := directoryStore(directoryPath).Reusable("Alpha", "key"); reusable { + t.Error("a record of format version 2 should not be reused") + } +} diff --git a/cli/dispatcher/internal/compilecheck/resultstore/record_format_test.go b/cli/dispatcher/internal/compilecheck/resultstore/record_format_test.go index e4e61f11d2..892075a00d 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/record_format_test.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/record_format_test.go @@ -38,11 +38,13 @@ func TestWriteUnitResultManifestWritesTheRecordedFormat(t *testing.T) { }}, } - if err := directoryStore(directoryPath).RecordCompile("Alpha", "key", result, alphaOutputs()); err != nil { + if err := directoryStore(directoryPath).RecordCompile("Alpha", KeyIdentity{ + Key: "key", Shape: "shape", ProjectReferences: map[string]ReferenceIdentity{"Beta": {Kind: ProducedReference, Hash: "beta"}}, + }, result, alphaOutputs()); err != nil { t.Fatalf("failed to write the manifest: %v", err) } - assertFileContent(t, manifestPath(directoryPath, "Alpha"), `{"FormatVersion":2,"InputKey":"key","Succeeded":false,`+ + assertFileContent(t, manifestPath(directoryPath, "Alpha"), `{"FormatVersion":3,"InputKey":"key","Shape":"shape","ProjectReferences":{"Beta":{"Kind":"produced","Hash":"beta"}},"Succeeded":false,`+ `"Diagnostics":[{"Severity":"error","Code":"CS0103","Message":"The name 'Missing' does not exist in the current context",`+ `"File":"Assets/Alpha/Alpha.cs","Line":3,"Column":9}],`+ `"Outputs":{"Alpha.dll":"e020bdf568793dee747a220086c5e53c8b3af5abd959383a31297f303577efe6",`+ diff --git a/cli/dispatcher/internal/compilecheck/resultstore/store.go b/cli/dispatcher/internal/compilecheck/resultstore/store.go index 2de3bd0301..ddef833052 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/store.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/store.go @@ -14,7 +14,8 @@ // - Records are written through a temporary file and renamed into place, so a run stopped while // writing never leaves half a record behind. // - A compile result is reusable only under the key it was recorded with, and only while every -// output it hashed still holds those bytes. +// output it hashed still holds those bytes. It may still apply while some project references are +// unknown only when every other part of that key, and those outputs, are as recorded. // - A baseline is handed out only when the compile result recorded with it is still on disk: the // same key, a successful compile, and outputs that still match. Otherwise LoadBaseline says why // there is none, in one of these reasons: From 68aaa81cdb5ed869ac534b6aba506f2ba40e63db Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 16 Oct 2026 01:17:50 +0900 Subject: [PATCH 25/33] feat: compile-check no longer makes dependents wait for each other when an assembly's public API changes (#3344) --- .../internal/compilecheck/compiler.go | 13 + cli/dispatcher/internal/compilecheck/plan.go | 13 +- .../internal/compilecheck/scheduler.go | 176 ++++-- .../compilecheck/scheduler_optimistic_test.go | 564 ++++++++++++++++++ .../internal/compilecheck/scheduler_settle.go | 277 +++++++++ .../internal/compilecheck/scheduler_test.go | 109 +++- .../dispatcher/compile_check_vibe_log.go | 29 +- .../dispatcher/compile_check_vibe_log_test.go | 29 + ...re-the-dependents-they-reference-finish.md | 80 +++ docs/compile-check.md | 21 +- docs/vibe-logs.md | 2 +- 11 files changed, 1229 insertions(+), 84 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/scheduler_optimistic_test.go create mode 100644 cli/dispatcher/internal/compilecheck/scheduler_settle.go create mode 100644 docs/adr/0016-compile-check-starts-dependents-before-the-dependents-they-reference-finish.md diff --git a/cli/dispatcher/internal/compilecheck/compiler.go b/cli/dispatcher/internal/compilecheck/compiler.go index 983dd4f9cf..748ed69022 100644 --- a/cli/dispatcher/internal/compilecheck/compiler.go +++ b/cli/dispatcher/internal/compilecheck/compiler.go @@ -80,6 +80,19 @@ type UnitResult struct { // Surface says whether the unit's reference assembly kept the API of the last Unity build, for a // successful unit something in the plan references; zero for any other unit. Surface SurfaceVerdict + // AssumedReferences names the referenced assemblies that were still compiling when the compile + // this result came from started, and that it read from Unity's build on the assumption that they + // keep Unity's public surface. + AssumedReferences []string + // CompiledAgainAfter names the referenced assembly whose changed surface proved an earlier + // compile of this unit wrong, so this result comes from compiling it again; empty when one + // compile was enough. + CompiledAgainAfter string + // DiscardedDuration is how long the compile this run threw away took. + DiscardedDuration time.Duration + // WaitedForRecord marks a unit that waited for the references still compiling rather than + // starting before them, because its recorded result might be replayed once they finished. + WaitedForRecord bool } // CommandRunner runs one compiler invocation, so tests can stand in for the real process. diff --git a/cli/dispatcher/internal/compilecheck/plan.go b/cli/dispatcher/internal/compilecheck/plan.go index 6fcc941331..a20a86b375 100644 --- a/cli/dispatcher/internal/compilecheck/plan.go +++ b/cli/dispatcher/internal/compilecheck/plan.go @@ -23,13 +23,14 @@ type CompileUnit struct { // every unit of an --all run. SelectedAsDependent bool // PlanReferences names the assemblies this one references that this run compiles too. It is - // what the scheduler waits for: a reference left out of the plan was not rebuilt, so its - // reference assembly from the last Unity build is already on disk and nothing has to wait. + // what the scheduler waits for, or starts ahead of: a reference left out of the plan was not + // rebuilt, so its reference assembly from the last Unity build is already on disk. PlanReferences []string - // KeptSurfaceReferences names the assemblies among PlanReferences whose rebuilt reference - // assembly the run found to describe the same public surface as Unity's, so this unit reads - // Unity's instead. The scheduler fills it when it starts the unit, once every reference is done, - // and nothing writes to it afterwards. Empty everywhere else. + // KeptSurfaceReferences names the assemblies among PlanReferences this unit reads Unity's + // reference assembly for: the ones whose rebuilt reference assembly the run found to describe + // the same public surface as Unity's, or assumed would while they were still compiling. The + // scheduler fills it when it starts the unit, and nothing writes to it afterwards. Empty + // everywhere else. KeptSurfaceReferences map[string]bool } diff --git a/cli/dispatcher/internal/compilecheck/scheduler.go b/cli/dispatcher/internal/compilecheck/scheduler.go index 42e144a793..48181ebb75 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler.go +++ b/cli/dispatcher/internal/compilecheck/scheduler.go @@ -23,8 +23,16 @@ type unitCompletion struct { // schedule is the readiness bookkeeping of one run: how many of a unit's references are still // missing, and which units are waiting on each one. +// A unit's final state - compiled and kept, compiled and changed, failed, skipped or blocked - is +// decided once, after every unit of the plan it references has a final state of its own. A unit may +// start before that, but what it found counts only from then on. type schedule struct { - pending []int + pending []int + // pendingOwn counts, per unit, the references in pending that were selected for their own + // sources rather than through a reference of their own. + pendingOwn []int + // ownSources marks, per unit, that it was selected for its own sources. + ownSources []bool dependents [][]int // references lists, per unit, the units of the plan it references. references [][]int @@ -37,6 +45,7 @@ type schedule struct { blocked []bool // keptSurface marks, per unit, that it finished with the public surface of the last Unity build. keptSurface []bool + unitProgress } // compileOutcome is everything one run of the scheduler produced: the results it has, and what it @@ -77,12 +86,14 @@ type schedulerRun struct { referenceSkips int } -// compileUnits compiles a plan's assemblies, starting each one as soon as the assemblies it -// references have been compiled and keeping at most jobs of them running at a time. It reports the -// results of the units it compiled, in the plan's order, and how many it left out. +// compileUnits compiles a plan's assemblies, keeping at most jobs of them running at a time. It +// reports the results of the units it compiled, in the plan's order, and how many it left out. +// A unit starts once every assembly it references has a final state, or - when it has to compile +// anyway - once every one of them selected for its own sources has, assuming the dependents still +// compiling keep Unity's public surface. A unit whose assumption turns out wrong compiles again. // Why the units cannot simply run in the plan's order all at once: a dependent reads the reference -// assembly its reference writes, so starting it early would compile it against the previous run's -// output or against nothing at all. +// assembly its reference writes when that one changed its surface, so starting it before anything is +// known would mostly compile it against the wrong file. func compileUnits( ctx context.Context, compiler Compiler, plan BuildPlan, jobs int, ) (compileOutcome, error) { @@ -108,16 +119,23 @@ func compileUnits( compiled: make([]bool, len(plan.Units)), completions: make(chan unitCompletion, len(plan.Units)), } + for index := range plan.Units { + run.offer(index) + } var failure error for { if failure == nil { - if err := run.startReadyUnits(jobs); err != nil { + if err := run.startAndSettle(jobs); err != nil { failure = err cancel() } } if run.running == 0 { + if failure == nil { + failure = run.unfinishedError() + } + break } if err := run.collectOneCompletion(); err != nil && failure == nil { @@ -141,22 +159,32 @@ func compileUnits( // the ones that have nothing left to learn from compiling. func (run *schedulerRun) startReadyUnits(jobs int) error { for run.running < jobs && len(run.state.ready) > 0 { - index := run.state.ready[0] - run.state.ready = run.state.ready[1:] + index := run.state.takeNext() // Why the block is read before the skip: a blocked unit has no result to compare against // anything, so asking whether its references kept their surface answers nothing. - if run.skipBlockedUnit(index) { - continue - } - skipped, err := run.skipUnchangedDependent(index) + blocked, err := run.skipBlockedUnit(index) if err != nil { return err } - if skipped { + if blocked { continue } + // Why the skip is asked only once every reference is final: until then a reference may + // still change its surface, and a unit queued early was queued because it compiles anyway. + if run.state.pending[index] == 0 { + skipped, err := run.skipUnchangedDependent(index) + if err != nil { + return err + } + if skipped { + continue + } + } + unit, assumed := run.startingUnit(index) + run.state.assumed[index] = assumed + run.state.started[index] = true run.running++ - go run.compileOneUnit(index, run.startingUnit(index), len(run.state.dependents[index]) > 0) + go run.compileOneUnit(index, unit, len(run.state.dependents[index]) > 0) } return nil @@ -174,19 +202,29 @@ func (run *schedulerRun) startReadyUnits(jobs int) error { // non-source key. // Removing them made the first successful run after an error compile every dependent again even // when the assembly with errors came back with the same reference assembly. -func (run *schedulerRun) skipBlockedUnit(index int) bool { +func (run *schedulerRun) skipBlockedUnit(index int) (bool, error) { if !run.state.blocked[index] { - return false + return false, nil + } + // Why a unit queued early is only taken out of the queue here: it is decided once every + // reference it has is, like any other unit, and the last of them to finish offers it again. + if run.state.pending[index] != 0 { + return true, nil + } + if err := run.markFinal(index); err != nil { + return false, err } run.state.blockDependents(index) - run.state.release(index, false) + run.releaseAndOffer(index, false) - return true + return true, nil } // skipUnchangedDependent leaves out a unit this run picked up only because something it references // was recompiled, when every one of those came out with the public surface the last Unity build -// recorded: compiling it would read exactly the API it was last compiled against. +// recorded: compiling it would read exactly the API it was last compiled against. It counts as +// having kept Unity's surface, so the units that started before it finished read Unity's reference +// assembly for it, as they assumed. // Why its earlier outputs are removed rather than left alone: a reference assembly an earlier run // wrote would otherwise be picked up as this run's own output, and the units below it would be // compiled against a result this run never produced. @@ -198,30 +236,45 @@ func (run *schedulerRun) skipUnchangedDependent(index int) (bool, error) { if err := run.compiler.removeUnitOutputs(run.plan, unit); err != nil { return false, err } + if err := run.markFinal(index); err != nil { + return false, err + } run.referenceSkips++ - run.state.release(index, false) + run.state.keptSurface[index] = true + run.releaseAndOffer(index, false) return true, nil } -// startingUnit is the unit at index as it is about to start, carrying which of the references it -// waited for kept the surface of the last Unity build. +// startingUnit is the unit at index as it would start now, carrying which of its references it +// reads from Unity's build: the final ones that kept the surface of the last Unity build, and the +// ones without a final state yet, which it assumes will. It also reports the latter. // Why a copy made here rather than a lookup from the unit's goroutine: the loop writes those answers -// as units finish, while the units it already started are still running, and every reference of this -// unit is done by now, so the copy is final. Why on the unit rather than passed alongside: the -// response file, the changed-files check and every key read it from the unit, so none of them can -// end up pointing at a different reference assembly than the compile does. -func (run *schedulerRun) startingUnit(index int) CompileUnit { +// as units finish, while the units it already started are still running. Why on the unit rather than +// passed alongside: the response file, the changed-files check and every key read it from the unit, +// so none of them can end up pointing at a different reference assembly than the compile does. +// Why an assumed reference is named rather than left to the missing-output fallback: an earlier run +// may have left its output on disk, and reading that would compile against a result this run never +// produced. +func (run *schedulerRun) startingUnit(index int) (CompileUnit, []int) { unit := run.plan.Units[index] kept := map[string]bool{} + assumed := []int{} for _, reference := range run.state.references[index] { + name := run.plan.Units[reference].Assembly.AssemblyName + if !run.state.final[reference] { + assumed = append(assumed, reference) + kept[name] = true + + continue + } if run.state.keptSurface[reference] { - kept[run.plan.Units[reference].Assembly.AssemblyName] = true + kept[name] = true } } unit.KeptSurfaceReferences = kept - return unit + return unit, assumed } // compileOneUnit produces one unit's result - by compiling it, or by replaying the previous run's @@ -242,10 +295,14 @@ func (run *schedulerRun) compileOneUnit(index int, unit CompileUnit, readByDepen run.completions <- unitCompletion{index: index, result: result, keptSurface: result.Surface.Kept, err: err} } -// collectOneCompletion waits for one unit to finish and hands what it produced to the schedule. +// collectOneCompletion waits for one unit to finish and holds what it produced until the unit's +// final state can be decided. // Why a failure stops the run: it means the compiler itself could not be started, which every // remaining unit would hit as well. Compile errors are not failures here - they arrive as // diagnostics and the run continues so that one pass reports all of them. +// Why nothing is released or blocked here: a unit that started before its references finished may +// still have its result thrown away, and the errors or the surface of a result nobody keeps must not +// decide anything for the units below it. func (run *schedulerRun) collectOneCompletion() error { completion := <-run.completions run.running-- @@ -253,15 +310,8 @@ func (run *schedulerRun) collectOneCompletion() error { return completion.err } run.results[completion.index] = completion.result - run.compiled[completion.index] = true - // Why a replayed result blocks the same way a compiled one does: it says the assembly has - // errors, and whether this run learned that from csc or from what the last run recorded changes - // nothing about the reference assembly the units below it would have to read. - if !completion.result.Succeeded { - run.state.blockDependents(completion.index) - } - run.state.keptSurface[completion.index] = completion.keptSurface - run.state.release(completion.index, !completion.keptSurface) + run.state.pendingKept[completion.index] = completion.keptSurface + run.state.finished[completion.index] = true return nil } @@ -291,7 +341,8 @@ func (run *schedulerRun) blockedNames() []string { return names } -// newSchedule counts what every unit is waiting for and queues the units waiting for nothing. +// newSchedule counts what every unit is waiting for. The caller offers every unit once, which queues +// the ones waiting for nothing. func newSchedule(plan BuildPlan) *schedule { indexByName := make(map[string]int, len(plan.Units)) for index, unit := range plan.Units { @@ -300,12 +351,18 @@ func newSchedule(plan BuildPlan) *schedule { state := &schedule{ pending: make([]int, len(plan.Units)), + pendingOwn: make([]int, len(plan.Units)), + ownSources: make([]bool, len(plan.Units)), dependents: make([][]int, len(plan.Units)), references: make([][]int, len(plan.Units)), ready: []int{}, changedInputs: make([]int, len(plan.Units)), blocked: make([]bool, len(plan.Units)), keptSurface: make([]bool, len(plan.Units)), + unitProgress: newUnitProgress(len(plan.Units)), + } + for index, unit := range plan.Units { + state.ownSources[index] = !unit.SelectedAsDependent } for index, unit := range plan.Units { for _, reference := range unit.PlanReferences { @@ -314,38 +371,47 @@ func newSchedule(plan BuildPlan) *schedule { continue } state.pending[index]++ + if state.ownSources[referenceIndex] { + state.pendingOwn[index]++ + } state.dependents[referenceIndex] = append(state.dependents[referenceIndex], index) state.references[index] = append(state.references[index], referenceIndex) } } - for index := range plan.Units { - if state.pending[index] == 0 { - state.ready = append(state.ready, index) - } - } return state } // blockDependents marks every unit waiting on this one as one this run must not compile. -// Why marking rather than removing from the queue: a unit is only queued once everything it waits -// for is done, so the ones marked here have not been queued yet and are read as they come up. +// Why marking rather than removing from the queue: a queued unit is read as it comes up, and one that +// already started is read when its final state is decided. func (state *schedule) blockDependents(index int) { for _, dependent := range state.dependents[index] { state.blocked[dependent] = true } } -// release hands the units that were waiting on a finished one to the ready queue, recording whether -// what it produced still describes the same public surface as the last Unity build. -func (state *schedule) release(index int, changed bool) { +// release tells the units waiting on one that just got its final state that it has, recording +// whether what it produced still describes the same public surface as the last Unity build, and +// reports those units so the caller can decide which of them to queue. +// Why a unit that assumed this one is marked stale only when it changed: it read Unity's reference +// assembly for it, which is exactly right when the surface was kept. A failed or blocked one blocks +// it through blockDependents instead. +func (state *schedule) release(index int, changed bool) []int { for _, dependent := range state.dependents[index] { state.pending[dependent]-- - if changed { - state.changedInputs[dependent]++ + if state.ownSources[index] { + state.pendingOwn[dependent]-- } - if state.pending[dependent] == 0 { - state.ready = append(state.ready, dependent) + if !changed { + continue + } + state.changedInputs[dependent]++ + if !state.stale[dependent] && containsIndex(state.assumed[dependent], index) { + state.stale[dependent] = true + state.staleCause[dependent] = index } } + + return state.dependents[index] } diff --git a/cli/dispatcher/internal/compilecheck/scheduler_optimistic_test.go b/cli/dispatcher/internal/compilecheck/scheduler_optimistic_test.go new file mode 100644 index 0000000000..db0725c069 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/scheduler_optimistic_test.go @@ -0,0 +1,564 @@ +package compilecheck + +import ( + "context" + "fmt" + "sort" + "strings" + "testing" + "time" +) + +// weightHold is how long the fake compile of a reference takes in a test that checks a unit does not +// start while it runs. Why a hold rather than a gate there: nothing is meant to start, so there is +// nothing to wait for, and a slow machine can only let such a test pass, never fail it. +const weightHold = 60 * time.Millisecond + +// optimisticPlanRun is a plan of named units with Unity's reference assembly of each one on disk and +// a fake compiler that, unless told otherwise, writes a reference assembly that differs from it. +// Why Unity's files are written for every unit: the surface verdict compares the two files, and a +// unit without Unity's file never counts as having kept its surface. +type optimisticPlanRun struct { + plan BuildPlan + recorder *schedulerRecorder + compiler Compiler + // jobs caps how many units compile at once. + jobs int +} + +// newOptimisticPlanRun lays out the plan, marking the named dependents as units this run picks up +// only through a reference. +func newOptimisticPlanRun( + t *testing.T, references map[string][]string, dependents []string, names ...string, +) *optimisticPlanRun { + t.Helper() + plan := newSchedulerPlan(references, names...) + markSelectedAsDependent(t, plan, dependents...) + recorder := newSchedulerRecorder(0) + compiler := newSchedulerCompiler(t, recorder) + for _, name := range names { + writeUnityReferenceAssembly(t, compiler.ProjectRoot, name, keptSurfaceOf(name)) + recorder.surfaces[name] = "changed " + name + } + + return &optimisticPlanRun{plan: plan, recorder: recorder, compiler: compiler, jobs: 8} +} + +// keep makes the named units come out with Unity's public surface. +func (run *optimisticPlanRun) keep(names ...string) { + for _, name := range names { + run.recorder.surfaces[name] = keptSurfaceOf(name) + } +} + +// fail makes the named unit report a compile error and write no reference assembly. +func (run *optimisticPlanRun) fail(name string) { + run.recorder.output[name] = failingCompilerOutput(name) + delete(run.recorder.surfaces, name) +} + +// compile runs the plan and fails the test when the run could not finish. +func (run *optimisticPlanRun) compile(t *testing.T) compileOutcome { + t.Helper() + outcome, err := compileUnits(context.Background(), run.compiler, run.plan, run.jobs) + if err != nil { + t.Fatalf("expected the run to succeed, got error: %v", err) + } + + return outcome +} + +// newOptimisticCachedProject lays out a project the input key can read, marking the named dependents +// as units this run picks up only through a reference, and a fake compiler that writes a reference +// assembly differing from Unity's for every unit. +func newOptimisticCachedProject( + t *testing.T, references map[string][]string, dependents []string, names ...string, +) (*cachedSchedulerProject, *schedulerRecorder) { + t.Helper() + project := newCachedSchedulerProject(t, references, names...) + markSelectedAsDependent(t, project.plan, dependents...) + + return project, newCachedSchedulerRecorder(names...) +} + +// chainReferences is the plan most tests here use: X and K are selected for their own sources, D1 +// references both, and D2 references X and D1. The tests hold K until D2 begins, so D1 has not +// started when D2 does. +var chainReferences = map[string][]string{"D1": {"X", "K"}, "D2": {"X", "D1"}} + +// sortedNames copies names into ordinal order, for comparing sets of names. +func sortedNames(names []string) []string { + sorted := append([]string{}, names...) + sort.Strings(sorted) + + return sorted +} + +// warningOutput is what csc prints for an assembly that compiles with one warning. +func warningOutput(name string, message string) fakeCompilerOutput { + return fakeCompilerOutput{ + stdout: "Assets/" + name + "/" + name + ".cs(1,7): warning CS0168: " + message, + } +} + +// Verifies a dependent that must compile starts as soon as every assembly it references that was +// selected for its own sources finished, reading Unity's reference assembly for the dependent it +// references that has not even started - not the output an earlier run left for it - and keeps that +// compile when the dependent turns out to keep Unity's surface. +func TestCompileUnitsStartsADependentBeforeTheDependentsItReferencesFinish(t *testing.T) { + project, recorder := newOptimisticCachedProject(t, chainReferences, []string{"D1", "D2"}, "X", "K", "D1", "D2") + recorder.holdUntilBegun(t, "K", "D2") + recorder.surfaces["K"] = keptSurfaceOf("K") + recorder.surfaces["D1"] = keptSurfaceOf("D1") + writeStaleUnitOutput(t, project.root, project.plan, "D1") + + results := project.compileOrFail(t, recorder) + + assertStrings(t, "units finished when D2 started", sortedNames(recorder.finishedWhen["D2"]), []string{"X"}) + if recorder.runs["D2"] != 1 { + t.Errorf("D2 compiled %d times, want 1", recorder.runs["D2"]) + } + assertReferenceFlags(t, project.writtenResponseFile(t, "D2"+responseFileExtension), + []string{unityReferenceFlag("D1"), producedReferenceFlag(project.plan, "X")}, + []string{producedReferenceFlag(project.plan, "D1")}) + assertStrings(t, "results", compiledNames(results), []string{"X", "K", "D1", "D2"}) + assertStrings(t, "D2 assumed", resultOf(t, results, "D2").AssumedReferences, []string{"D1"}) + if again := resultOf(t, results, "D2").CompiledAgainAfter; again != "" { + t.Errorf("D2 compiled again after %q, want once", again) + } +} + +// Verifies an assembly selected for its own sources starts before a dependent it references +// finishes, too. +func TestCompileUnitsStartsAnEditedAssemblyBeforeTheDependentsItReferencesFinish(t *testing.T) { + run := newOptimisticPlanRun(t, map[string][]string{"D1": {"X", "K"}, "Y": {"X", "D1"}}, + []string{"D1"}, "X", "K", "D1", "Y") + run.recorder.holdUntilBegun(t, "K", "Y") + run.keep("K", "D1") + + outcome := run.compile(t) + + assertStrings(t, "units finished when Y started", sortedNames(run.recorder.finishedWhen["Y"]), []string{"X"}) + assertStrings(t, "results", compiledNames(outcome.Units), []string{"X", "K", "D1", "Y"}) + assertStrings(t, "Y assumed", resultOf(t, outcome.Units, "Y").AssumedReferences, []string{"D1"}) +} + +// Verifies a dependent still waits for a reference selected for its own sources: such a reference +// is likely to change its surface, so starting before it would mostly be wasted. +func TestCompileUnitsWaitsForAReferenceWhoseOwnSourcesChanged(t *testing.T) { + run := newOptimisticPlanRun(t, map[string][]string{"D": {"X", "Y"}}, []string{"D"}, "X", "Y", "D") + run.recorder.holdByUnit["Y"] = weightHold + run.keep("Y") + + run.compile(t) + + if !containsLine(run.recorder.finishedWhen["D"], "Y") { + t.Errorf("D started before Y finished; finished then: %v", run.recorder.finishedWhen["D"]) + } +} + +// Verifies a dependent none of whose finished references changed its surface does not start early: +// it may still be skipped once the rest finish, and here it is. +func TestCompileUnitsDoesNotStartADependentBeforeAReferenceIsKnownToHaveChanged(t *testing.T) { + run := newOptimisticPlanRun(t, map[string][]string{"D1": {"X"}, "D2": {"K", "D1"}}, + []string{"D1", "D2"}, "X", "K", "D1", "D2") + run.recorder.holdByUnit["D1"] = weightHold + run.keep("K", "D1") + + outcome := run.compile(t) + + if run.recorder.started["D2"] { + t.Error("D2 should not have started before any reference was known to have changed") + } + assertStrings(t, "results", compiledNames(outcome.Units), []string{"X", "K", "D1"}) + if outcome.Skipped != 1 { + t.Errorf("skipped = %d, want 1", outcome.Skipped) + } +} + +// Verifies a dependent blocked by a reference with errors does not start early, although another +// reference it waits for is a dependent still compiling. +func TestCompileUnitsDoesNotStartABlockedDependentOptimistically(t *testing.T) { + run := newOptimisticPlanRun(t, map[string][]string{"D1": {"X"}, "D2": {"A", "D1"}}, + []string{"D1", "D2"}, "X", "A", "D1", "D2") + run.recorder.holdByUnit["D1"] = weightHold + run.fail("A") + run.keep("D1") + + outcome := run.compile(t) + + if run.recorder.started["D2"] { + t.Error("a blocked dependent should not have started") + } + assertStrings(t, "blocked", outcome.Blocked, []string{"D2"}) + assertStrings(t, "results", compiledNames(outcome.Units), []string{"X", "A", "D1"}) +} + +// Verifies a dependent that started before a dependent it references finished is compiled again, +// against this run's output, when that reference changed its surface, and that only the second +// compile's diagnostics are reported. +func TestCompileUnitsCompilesADependentAgainWhenAnAssumedReferenceChangedItsSurface(t *testing.T) { + project, recorder := newOptimisticCachedProject(t, chainReferences, []string{"D1", "D2"}, "X", "K", "D1", "D2") + recorder.holdUntilBegun(t, "K", "D2") + recorder.holdByUnit["D2"] = 5 * time.Millisecond + recorder.surfaces["K"] = keptSurfaceOf("K") + recorder.outputByRun["D2"] = []fakeCompilerOutput{ + warningOutput("D2", "first compile"), warningOutput("D2", "second compile"), + } + + results := project.compileOrFail(t, recorder) + + if recorder.runs["D2"] != 2 { + t.Fatalf("D2 compiled %d times, want 2", recorder.runs["D2"]) + } + if !containsLine(recorder.finishedWhen["D2"], "D1") { + t.Errorf("the second compile of D2 started before D1 finished; finished then: %v", recorder.finishedWhen["D2"]) + } + assertReferenceFlags(t, project.writtenResponseFile(t, "D2"+responseFileExtension), + []string{producedReferenceFlag(project.plan, "D1")}, []string{unityReferenceFlag("D1")}) + d2 := resultOf(t, results, "D2") + if len(d2.Diagnostics) != 1 || d2.Diagnostics[0].Message != "second compile" { + t.Errorf("D2 diagnostics = %+v, want only the second compile's warning", d2.Diagnostics) + } + if d2.CompiledAgainAfter != "D1" || d2.DiscardedDuration <= 0 || len(d2.AssumedReferences) != 0 { + t.Errorf("D2 = %+v, want compiled again after D1, with a discarded duration and nothing assumed", d2) + } +} + +// Verifies a dependent whose two assumed references both changed their surface, at different +// times, is compiled twice and no more. +func TestCompileUnitsCompilesAStaleDependentOnlyOnceMore(t *testing.T) { + run := newOptimisticPlanRun(t, map[string][]string{ + "D1": {"X", "K1"}, "D1b": {"X", "K2"}, "D2": {"X", "D1", "D1b"}, + }, []string{"D1", "D1b", "D2"}, "X", "K1", "K2", "D1", "D1b", "D2") + run.recorder.holdUntilBegun(t, "K1", "D2") + run.recorder.holdUntilBegun(t, "K2", "D2") + run.recorder.holdByUnit["K2"] = 40 * time.Millisecond + run.keep("K1", "K2") + + run.compile(t) + + if run.recorder.runs["D2"] != 2 { + t.Fatalf("D2 compiled %d times, want 2", run.recorder.runs["D2"]) + } + for _, reference := range []string{"D1", "D1b"} { + if !containsLine(run.recorder.finishedWhen["D2"], reference) { + t.Errorf("the last compile of D2 started before %s finished; finished then: %v", + reference, run.recorder.finishedWhen["D2"]) + } + } +} + +// Verifies the surface verdict of a compile that was thrown away decides nothing: the dependent +// below it compiles again against the output of the compile that was kept. +func TestCompileUnitsIgnoresTheVerdictOfADiscardedCompile(t *testing.T) { + references := map[string][]string{"D1": {"X", "K"}, "D2": {"X", "D1"}, "D3": {"X", "D2"}} + project, recorder := newOptimisticCachedProject(t, references, []string{"D1", "D2", "D3"}, + "X", "K", "D1", "D2", "D3") + recorder.holdUntilBegun(t, "K", "D2", "D3") + recorder.surfaces["K"] = keptSurfaceOf("K") + recorder.surfacesByRun["D2"] = []string{keptSurfaceOf("D2"), "changed D2"} + + project.compileOrFail(t, recorder) + + if recorder.runs["D3"] != 2 { + t.Fatalf("D3 compiled %d times, want 2", recorder.runs["D3"]) + } + assertReferenceFlags(t, project.writtenResponseFile(t, "D3"+responseFileExtension), + []string{producedReferenceFlag(project.plan, "D2")}, []string{unityReferenceFlag("D2")}) +} + +// Verifies the errors of a compile that was thrown away block nothing: the dependent below it is +// compiled, and the unit is reported with the outcome of the compile that was kept. +func TestCompileUnitsIgnoresTheErrorsOfADiscardedCompile(t *testing.T) { + run := newOptimisticPlanRun(t, map[string][]string{"D1": {"X", "K"}, "D2": {"X", "D1"}, "D3": {"X", "D2"}}, + []string{"D1", "D2", "D3"}, "X", "K", "D1", "D2", "D3") + run.recorder.holdUntilBegun(t, "K", "D2") + run.keep("K") + run.recorder.outputByRun["D2"] = []fakeCompilerOutput{failingCompilerOutput("D2"), {}} + run.recorder.surfacesByRun["D2"] = []string{"", keptSurfaceOf("D2")} + + outcome := run.compile(t) + + if len(outcome.Blocked) != 0 { + t.Errorf("blocked = %v, want none", outcome.Blocked) + } + if !resultOf(t, outcome.Units, "D2").Succeeded { + t.Errorf("D2 = %+v, want the successful second compile", resultOf(t, outcome.Units, "D2")) + } + if !containsLine(compiledNames(outcome.Units), "D3") || run.recorder.runs["D3"] != 1 { + t.Errorf("D3 compiled %d times and results are %v, want one compile reported", + run.recorder.runs["D3"], compiledNames(outcome.Units)) + } +} + +// Verifies a dependent that started before a dependent it references failed is blocked, its result +// thrown away, and the block carried on to the units below it. +func TestCompileUnitsBlocksADependentThatStartedBeforeItsReferenceFailed(t *testing.T) { + run := newOptimisticPlanRun(t, map[string][]string{"D1": {"X", "K"}, "D2": {"X", "D1"}, "D3": {"X", "D2"}}, + []string{"D1", "D2", "D3"}, "X", "K", "D1", "D2", "D3") + run.recorder.holdUntilBegun(t, "K", "D2", "D3") + run.keep("K") + run.fail("D1") + + outcome := run.compile(t) + + assertStrings(t, "results", compiledNames(outcome.Units), []string{"X", "K", "D1"}) + if len(resultOf(t, outcome.Units, "D1").Diagnostics) == 0 { + t.Error("expected D1's errors to be reported") + } + assertStrings(t, "blocked", outcome.Blocked, []string{"D2", "D3"}) + if outcome.Skipped != 0 { + t.Errorf("skipped = %d, want 0", outcome.Skipped) + } + if run.recorder.runs["D2"] != 1 || run.recorder.runs["D3"] != 1 { + t.Errorf("D2 and D3 compiled %d and %d times, want once each before the failure was known", + run.recorder.runs["D2"], run.recorder.runs["D3"]) + } +} + +// Verifies a dependent that assumed a reference later skipped - because everything that reference +// reads kept its surface - is settled with the compile it started early. +func TestCompileUnitsSettlesADependentWhoseAssumedReferenceWasSkipped(t *testing.T) { + run := newOptimisticPlanRun(t, map[string][]string{"R": {"K"}, "U": {"X", "R"}}, + []string{"R", "U"}, "X", "K", "R", "U") + run.recorder.holdUntilBegun(t, "K", "U") + run.keep("K") + + outcome := run.compile(t) + + assertStrings(t, "results", compiledNames(outcome.Units), []string{"X", "K", "U"}) + if outcome.Skipped != 1 || len(outcome.Blocked) != 0 { + t.Errorf("skipped = %d and blocked = %v, want 1 and none", outcome.Skipped, outcome.Blocked) + } + if run.recorder.runs["U"] != 1 { + t.Errorf("U compiled %d times, want 1", run.recorder.runs["U"]) + } + assertStrings(t, "units finished when U started", sortedNames(run.recorder.finishedWhen["U"]), []string{"X"}) + assertStrings(t, "U assumed", resultOf(t, outcome.Units, "U").AssumedReferences, []string{"R"}) +} + +// Verifies a dependent that assumed a reference later blocked by an error further up is blocked +// too, with its result thrown away and the error that caused it still reported. +func TestCompileUnitsBlocksADependentWhoseAssumedReferenceWasBlocked(t *testing.T) { + run := newOptimisticPlanRun(t, map[string][]string{"R": {"A"}, "U": {"X", "R"}}, + []string{"R", "U"}, "X", "A", "R", "U") + run.recorder.holdUntilBegun(t, "A", "U") + run.fail("A") + + outcome := run.compile(t) + + assertStrings(t, "results", compiledNames(outcome.Units), []string{"X", "A"}) + if len(resultOf(t, outcome.Units, "A").Diagnostics) == 0 { + t.Error("expected A's errors to be reported") + } + assertStrings(t, "blocked", outcome.Blocked, []string{"R", "U"}) + if run.recorder.runs["U"] != 1 { + t.Errorf("U compiled %d times, want 1", run.recorder.runs["U"]) + } +} + +// Verifies an assembly selected for its own sources that started before a dependent it references +// finished is compiled again, against this run's output, when that dependent changed its surface. +func TestCompileUnitsCompilesAnEditedAssemblyAgainWhenAnAssumedReferenceChangedItsSurface(t *testing.T) { + project, recorder := newOptimisticCachedProject(t, map[string][]string{"D1": {"X", "K"}, "Y": {"X", "D1"}}, + []string{"D1"}, "X", "K", "D1", "Y") + recorder.holdUntilBegun(t, "K", "Y") + recorder.surfaces["K"] = keptSurfaceOf("K") + + results := project.compileOrFail(t, recorder) + + if recorder.runs["Y"] != 2 { + t.Fatalf("Y compiled %d times, want 2", recorder.runs["Y"]) + } + assertReferenceFlags(t, project.writtenResponseFile(t, "Y"+responseFileExtension), + []string{producedReferenceFlag(project.plan, "D1")}, []string{unityReferenceFlag("D1")}) + if again := resultOf(t, results, "Y").CompiledAgainAfter; again != "D1" { + t.Errorf("Y compiled again after %q, want D1", again) + } +} + +// Verifies an assembly selected for its own sources that started before a dependent it references +// failed is blocked, and its result thrown away. +func TestCompileUnitsBlocksAnEditedAssemblyWhoseAssumedReferenceFailed(t *testing.T) { + run := newOptimisticPlanRun(t, map[string][]string{"D1": {"X", "K"}, "Y": {"X", "D1"}}, + []string{"D1"}, "X", "K", "D1", "Y") + run.recorder.holdUntilBegun(t, "K", "Y") + run.keep("K") + run.fail("D1") + + outcome := run.compile(t) + + assertStrings(t, "blocked", outcome.Blocked, []string{"Y"}) + assertStrings(t, "results", compiledNames(outcome.Units), []string{"X", "K", "D1"}) + if run.recorder.runs["Y"] != 1 { + t.Errorf("Y compiled %d times, want 1", run.recorder.runs["Y"]) + } +} + +// newChainRecorder prepares the fake compiler for the chain plan of the cached project, with K +// keeping its surface and still compiling when D2 can start. +func newChainRecorder(t *testing.T) *schedulerRecorder { + recorder := newCachedSchedulerRecorder("X", "K", "D1", "D2") + recorder.holdUntilBegun(t, "K", "D2") + recorder.surfaces["K"] = keptSurfaceOf("K") + + return recorder +} + +// Verifies a dependent whose record may still be replayed once the dependents it references finish +// waits for them instead of starting early, so a second run over the same inputs replays it rather +// than compiling it twice again. +func TestCompileUnitsWaitsForAnAssumedReferenceWhenItsRecordMayStillApply(t *testing.T) { + project, _ := newOptimisticCachedProject(t, chainReferences, []string{"D1", "D2"}, "X", "K", "D1", "D2") + first := newChainRecorder(t) + project.compileOrFail(t, first) + if first.runs["D2"] != 2 { + t.Fatalf("the first run compiled D2 %d times, want 2", first.runs["D2"]) + } + + second := newChainRecorder(t) + results := project.compileOrFail(t, second) + + d2 := resultOf(t, results, "D2") + if !d2.Reused || second.runs["D2"] != 0 || !d2.WaitedForRecord { + t.Errorf("D2 = %+v after %d compiles, want replayed after waiting for its record", d2, second.runs["D2"]) + } + if _, started := second.finishedWhen["D2"]; started { + t.Error("D2 should not have started a compile in the second run") + } +} + +// Verifies a dependent whose record can no longer be replayed - a reference that already finished +// came out different - starts early again rather than waiting. +func TestCompileUnitsStartsADependentWhenItsRecordCannotApply(t *testing.T) { + project, _ := newOptimisticCachedProject(t, chainReferences, []string{"D1", "D2"}, "X", "K", "D1", "D2") + project.compileOrFail(t, newChainRecorder(t)) + + project.editSource(t, "X", "class X { int Added; }") + second := newChainRecorder(t) + second.surfaces["X"] = "surface X again" + results := project.compileOrFail(t, second) + + d2 := resultOf(t, results, "D2") + if second.runs["D2"] != 2 || d2.WaitedForRecord || d2.Reused { + t.Errorf("D2 = %+v after %d compiles, want compiled twice without waiting", d2, second.runs["D2"]) + } +} + +// Verifies the record of a compile that started early, against a reference that then kept its +// surface, is replayed by the next run, whose key reads that reference the same way. +func TestCompileUnitsReplaysADependentStartedBeforeItsReferencesFinished(t *testing.T) { + project, _ := newOptimisticCachedProject(t, chainReferences, []string{"D1", "D2"}, "X", "K", "D1", "D2") + first := newChainRecorder(t) + first.surfaces["D1"] = keptSurfaceOf("D1") + firstResults := project.compileOrFail(t, first) + if first.runs["D2"] != 1 { + t.Fatalf("the first run compiled D2 %d times, want 1", first.runs["D2"]) + } + assertStrings(t, "D2 assumed", resultOf(t, firstResults, "D2").AssumedReferences, []string{"D1"}) + + second := newChainRecorder(t) + second.surfaces["D1"] = keptSurfaceOf("D1") + results := project.compileOrFail(t, second) + + if d2 := resultOf(t, results, "D2"); !d2.Reused || second.runs["D2"] != 0 { + t.Errorf("D2 = %+v after %d compiles, want replayed", d2, second.runs["D2"]) + } +} + +// Verifies an edited assembly large enough for the changed-files check is checked on its changed +// files when it starts before a dependent it references finishes, assuming that dependent kept +// Unity's surface the way its baseline read it. +func TestCompileUnitsFastChecksAnEditedAssemblyStartedBeforeItsReferencesFinish(t *testing.T) { + project := newCachedSchedulerProject(t, map[string][]string{ + "Gamma": {"Alpha", "K"}, "Beta": {"Alpha", "Gamma"}, + }, "Alpha", "K", "Gamma", "Beta") + markSelectedAsDependent(t, project.plan, "Gamma") + sources := make([]string, 0, fastPathMinimumSources) + for index := 0; index < fastPathMinimumSources; index++ { + source := fmt.Sprintf("Assets/Beta/File%03d.cs", index) + project.writeFile(t, source, bodyContent(index, "body")) + sources = append(sources, source) + } + project.plan.Units[indexOfUnit(t, project.plan, "Beta")].Sources = sources + worker := newFakeFastCheckWorker() + var current *schedulerRecorder + worker.onCheck = func() { current.markBegun("Beta") } + keptRecorder := func() *schedulerRecorder { + recorder := newCachedSchedulerRecorder("Alpha", "K", "Gamma", "Beta") + recorder.holdUntilBegun(t, "K", "Beta") + recorder.surfaces["Alpha"] = keptSurfaceOf("Alpha") + recorder.surfaces["K"] = keptSurfaceOf("K") + recorder.surfaces["Gamma"] = keptSurfaceOf("Gamma") + current = recorder + + return recorder + } + compileWithFastPath(t, project, worker, keptRecorder(), "Alpha", "K", "Gamma", "Beta") + if !project.hasBaselineOf("Beta") { + t.Fatal("expected the full compile of Beta to record a baseline") + } + + // Why K is edited as well: it then compiles again, slowly, so Gamma is still waiting on it when + // Beta can start. A replayed K would let Gamma finish first. + project.editSource(t, "K", "class K { void Body() { } }") + project.writeFile(t, "Assets/Beta/File007.cs", bodyContent(7, "edited body")) + checksBefore := worker.checks + outcome := compileWithFastPath(t, project, worker, keptRecorder(), "Alpha", "K", "Gamma", "Beta") + + beta := resultOf(t, outcome.Units, "Beta") + if !beta.FastChecked || beta.FastPathFallbackReason != "" { + t.Fatalf("expected Beta to be checked on its changed files, got %+v", beta) + } + assertStrings(t, "Beta assumed", beta.AssumedReferences, []string{"Gamma"}) + if checks := worker.checks - checksBefore; checks != 1 { + t.Errorf("the worker checked %d times, want 1", checks) + } +} + +// Verifies a plan whose references never let a unit become ready - a cycle - ends the run with an +// error naming the units left over, instead of an empty success. +func TestCompileUnitsReportsAPlanItCannotFinish(t *testing.T) { + plan := newSchedulerPlan(map[string][]string{"A": {"B"}, "B": {"A"}}, "A", "B") + compiler := newSchedulerCompiler(t, newSchedulerRecorder(0)) + + _, err := compileUnits(context.Background(), compiler, plan, 2) + + if err == nil { + t.Fatal("expected the run to fail") + } + if !strings.Contains(err.Error(), "A") || !strings.Contains(err.Error(), "B") { + t.Errorf("error = %v, want it to name A and B", err) + } +} + +// Verifies a unit queued early that a reference with errors then blocks keeps waiting for the other +// references it has until they finish, rather than being decided while one of them still compiles: +// U is queued behind B with S still compiling when R's failure blocks it. +func TestCompileUnitsDecidesABlockedUnitQueuedEarlyOnlyOnceItsReferencesFinish(t *testing.T) { + run := newOptimisticPlanRun(t, map[string][]string{"R": {"X"}, "B": {"X"}, "S": {"X"}, "U": {"X", "R", "S"}}, + []string{"R", "B", "S", "U"}, "X", "R", "B", "S", "U") + run.jobs = 2 + run.fail("R") + run.recorder.holdByUnit["B"] = 20 * time.Millisecond + run.recorder.holdByUnit["S"] = 80 * time.Millisecond + + outcome := run.compile(t) + + assertStrings(t, "blocked", outcome.Blocked, []string{"U"}) +} + +// Verifies a unit queued early that becomes ready while it still waits for a free job is queued +// once and compiled once: D is queued early behind B, and A finishes before a job frees up. +func TestCompileUnitsQueuesAUnitOnceWhenItsLastReferenceFinishesWhileItWaitsForAJob(t *testing.T) { + run := newOptimisticPlanRun(t, map[string][]string{"A": {"X"}, "B": {"X"}, "D": {"X", "A"}}, + []string{"A", "B", "D"}, "X", "A", "B", "D") + run.jobs = 1 + + run.compile(t) + + if run.recorder.runs["D"] != 1 { + t.Errorf("D compiled %d times, want 1", run.recorder.runs["D"]) + } +} diff --git a/cli/dispatcher/internal/compilecheck/scheduler_settle.go b/cli/dispatcher/internal/compilecheck/scheduler_settle.go new file mode 100644 index 0000000000..9bffbb5578 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/scheduler_settle.go @@ -0,0 +1,277 @@ +package compilecheck + +import ( + "fmt" + "strings" + "time" +) + +// unitProgress is where each unit of a run stands on its way to a final state. +type unitProgress struct { + // queued marks a unit waiting in ready, started one whose compile has been launched at least + // once, finished one whose latest compile is back but not yet decided on, and final one whose + // final state is decided. + queued, started, finished, final []bool + // assumed lists, per unit, the references that had no final state when its latest compile + // started, and that it read from Unity's build on the assumption that they keep its surface. + assumed [][]int + // stale marks a unit whose latest compile assumed a reference that then changed its surface, + // and staleCause names the first such reference, or -1. + stale []bool + staleCause []int + // deferred marks a unit that did not start early because its record might still be replayed. + deferred []bool + // pendingKept holds the surface verdict of a unit's latest compile until its final state is + // decided, and discarded how long the compile it threw away took. + pendingKept []bool + discarded []time.Duration +} + +// newUnitProgress prepares the progress of count units, none of which has started. +func newUnitProgress(count int) unitProgress { + progress := unitProgress{ + queued: make([]bool, count), + started: make([]bool, count), + finished: make([]bool, count), + final: make([]bool, count), + assumed: make([][]int, count), + stale: make([]bool, count), + staleCause: make([]int, count), + deferred: make([]bool, count), + pendingKept: make([]bool, count), + discarded: make([]time.Duration, count), + } + for index := range progress.staleCause { + progress.staleCause[index] = -1 + } + + return progress +} + +// enqueue puts a unit at the end of the ready queue. +func (state *schedule) enqueue(index int) { + state.queued[index] = true + state.ready = append(state.ready, index) +} + +// takeNext removes the next unit to start from the ready queue. +func (state *schedule) takeNext() int { + index := state.ready[0] + state.ready = state.ready[1:] + state.queued[index] = false + + return index +} + +// mayStartEarly reports whether a unit with references still lacking a final state may start before +// them: it has to compile whatever they turn out to be, every reference still missing is a dependent +// rather than one selected for its own sources, and nothing has ruled it out. +// Why a reference selected for its own sources is waited for: its sources changed, so it is likely +// to change its surface, and starting before it would mostly compile against the wrong file. +// Why a dependent nothing changed under waits too: it may still be skipped once the rest finish. +func (state *schedule) mayStartEarly(index int) bool { + mustCompile := state.ownSources[index] || state.changedInputs[index] > 0 + + return mustCompile && state.pendingOwn[index] == 0 && !state.blocked[index] && !state.deferred[index] +} + +// offer queues a unit that has not started when it may start now: when every reference has a final +// state, or when it may start early and its record could not be replayed by waiting. +// Why the record is asked about before starting early: a compile overwrites the unit's record, so +// starting early would throw away a record the same inputs replay once the references finish, and +// every later run over those inputs would compile the unit twice again. Once the record says no it +// says no for good - fewer unknown references only make it stricter - so the answer is not asked again. +func (run *schedulerRun) offer(index int) { + state := run.state + if state.started[index] || state.queued[index] || state.final[index] { + return + } + if state.pending[index] == 0 { + state.enqueue(index) + + return + } + if !state.mayStartEarly(index) { + return + } + unit, unknown := run.startingUnit(index) + if run.compiler.recordMayStillApply(run.plan, unit, run.namedUnits(unknown)) { + state.deferred[index] = true + + return + } + state.enqueue(index) +} + +// releaseAndOffer tells the units waiting on one that just got its final state, and queues those that +// may start now. +func (run *schedulerRun) releaseAndOffer(index int, changed bool) { + for _, dependent := range run.state.release(index, changed) { + run.offer(dependent) + } +} + +// startAndSettle starts what is ready and decides the final state of what finished, until neither +// moves anything. +// Why the two alternate: a skip decided while starting units gives a reference its final state, +// which can let a unit that already finished be decided, and deciding one can make others ready. +func (run *schedulerRun) startAndSettle(jobs int) error { + for { + if err := run.startReadyUnits(jobs); err != nil { + return err + } + settled, err := run.settleFinished() + if err != nil { + return err + } + if !settled { + return nil + } + } +} + +// settleFinished decides the final state of every finished unit whose references all have one, until +// none is left, and reports whether it decided anything. +func (run *schedulerRun) settleFinished() (bool, error) { + state := run.state + progressed := false + for settled := true; settled; { + settled = false + for index := range run.plan.Units { + if !state.finished[index] || state.final[index] || state.pending[index] != 0 { + continue + } + settled = true + progressed = true + if err := run.settle(index); err != nil { + return progressed, err + } + } + } + + return progressed, nil +} + +// settle decides what a finished unit's latest compile counts for, now that every reference it has +// in the plan has a final state: nothing when one it assumed failed or was blocked, a compile again +// when one it assumed changed its surface, and its result otherwise. +// Why a compile again rather than a correction: the reference assembly it read is the wrong one, and +// what csc reports against the right one cannot be derived from what it reported against Unity's. +func (run *schedulerRun) settle(index int) error { + state := run.state + switch { + case state.blocked[index]: + state.finished[index] = false + if err := run.markFinal(index); err != nil { + return err + } + state.blockDependents(index) + run.releaseAndOffer(index, false) + case state.stale[index]: + state.discarded[index] = run.results[index].Duration + run.results[index] = UnitResult{} + state.finished[index] = false + state.stale[index] = false + state.assumed[index] = nil + state.enqueue(index) + default: + return run.adopt(index) + } + + return nil +} + +// adopt makes a finished unit's latest compile its final state and tells the units below it. +// Why a replayed result blocks the same way a compiled one does: it says the assembly has errors, +// and whether this run learned that from csc or from what the last run recorded changes nothing about +// the reference assembly the units below it would have to read. +func (run *schedulerRun) adopt(index int) error { + state := run.state + result := run.results[index] + result.AssumedReferences = run.namesOf(state.assumed[index]) + result.WaitedForRecord = state.deferred[index] + if cause := state.staleCause[index]; cause >= 0 { + result.CompiledAgainAfter = run.plan.Units[cause].Assembly.AssemblyName + result.DiscardedDuration = state.discarded[index] + } + run.results[index] = result + if err := run.markFinal(index); err != nil { + return err + } + run.compiled[index] = true + state.keptSurface[index] = state.pendingKept[index] + if !result.Succeeded { + state.blockDependents(index) + } + run.releaseAndOffer(index, !state.keptSurface[index]) + + return nil +} + +// markFinal gives a unit its final state, which every unit may get only once every reference it has +// in the plan has one. +// Why a breach is an error rather than ignored: a unit decided early tells the units below it what +// it turned out as before that is settled, and the run then reports results nobody can trust. As an +// error it stops the run the way a plan it cannot finish does, after the running compiles return. +func (run *schedulerRun) markFinal(index int) error { + if run.state.pending[index] != 0 { + return fmt.Errorf("compile-check finished scheduling %s before every assembly it references finished", + run.plan.Units[index].Assembly.AssemblyName) + } + run.state.final[index] = true + + return nil +} + +// unfinishedError reports the units a run that ran out of work never decided on, or nil when there +// are none. +// Why it is an error: only references that wait on each other leave units behind, and returning +// without them would report a clean run that never compiled them. +func (run *schedulerRun) unfinishedError() error { + left := []string{} + for index, final := range run.state.final { + if !final { + left = append(left, run.plan.Units[index].Assembly.AssemblyName) + } + } + if len(left) == 0 { + return nil + } + + return fmt.Errorf("compile-check could not finish scheduling %d assemblies: %s", + len(left), strings.Join(left, ", ")) +} + +// namesOf names the given units of the plan, in the order given; nil for none. +func (run *schedulerRun) namesOf(indices []int) []string { + if len(indices) == 0 { + return nil + } + names := make([]string, 0, len(indices)) + for _, index := range indices { + names = append(names, run.plan.Units[index].Assembly.AssemblyName) + } + + return names +} + +// namedUnits is the set of the names of the given units of the plan. +func (run *schedulerRun) namedUnits(indices []int) map[string]bool { + names := make(map[string]bool, len(indices)) + for _, index := range indices { + names[run.plan.Units[index].Assembly.AssemblyName] = true + } + + return names +} + +// containsIndex reports whether indices holds index. +func containsIndex(indices []int, index int) bool { + for _, candidate := range indices { + if candidate == index { + return true + } + } + + return false +} diff --git a/cli/dispatcher/internal/compilecheck/scheduler_test.go b/cli/dispatcher/internal/compilecheck/scheduler_test.go index a562f96599..c73faf78a1 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_test.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_test.go @@ -62,6 +62,74 @@ type schedulerRecorder struct { // run kills it mid-compile: whatever it had already printed, plus an exit error. Without this a // test can only produce the tidy failure of a process that never ran. killedUnits map[string]bool + // outputByRun holds, per unit, what the n-th invocation of it prints and exits with. An + // invocation past its end falls back to output. + outputByRun map[string][]fakeCompilerOutput + // surfacesByRun holds, per unit, the reference assembly the n-th invocation of it writes; an + // empty one writes none. An invocation past its end falls back to surfaces. + surfacesByRun map[string][]string + // begun names the units whose compile or changed-files check has begun. + begun map[string]bool + // heldUntilBegun holds, per unit, the units its fake invocation waits to see begun before it + // finishes, so a test can rely on those starting while it still runs instead of on a hold long + // enough under every load. gatesReleased lets every waiting invocation finish. + heldUntilBegun map[string][]string + gatesReleased bool +} + +// gateLimit is how long a held invocation waits for the units it waits on before it finishes anyway. +// Why it finishes rather than fails: the test's own assertions then say what started in which +// order, which says more than a timeout would. +const gateLimit = 10 * time.Second + +// holdUntilBegun makes the fake invocation of unit finish only once every named unit has begun, or +// gateLimit has passed. Every invocation still waiting is let go when the test ends. +func (recorder *schedulerRecorder) holdUntilBegun(t *testing.T, unit string, names ...string) { + t.Helper() + recorder.mutex.Lock() + recorder.heldUntilBegun[unit] = names + recorder.mutex.Unlock() + t.Cleanup(func() { + recorder.mutex.Lock() + recorder.gatesReleased = true + recorder.mutex.Unlock() + }) +} + +// markBegun records that the named unit has begun, for a unit checked on its changed files, which +// never reaches the fake compiler. +func (recorder *schedulerRecorder) markBegun(name string) { + recorder.mutex.Lock() + recorder.begun[name] = true + recorder.mutex.Unlock() +} + +// waitForGate returns once every unit the named one waits on has begun, the gates were released, or +// gateLimit has passed. +func (recorder *schedulerRecorder) waitForGate(name string) { + deadline := time.Now().Add(gateLimit) + for time.Now().Before(deadline) { + if recorder.gateOpen(name) { + return + } + time.Sleep(time.Millisecond) + } +} + +// gateOpen reports whether the named unit no longer has to wait. +func (recorder *schedulerRecorder) gateOpen(name string) bool { + recorder.mutex.Lock() + defer recorder.mutex.Unlock() + if recorder.gatesReleased { + return true + } + for _, waited := range recorder.heldUntilBegun[name] { + if !recorder.begun[waited] { + return false + } + } + + return true } // fakeCompilerOutput is what one fake invocation prints and exits with. @@ -73,16 +141,20 @@ type fakeCompilerOutput struct { // newSchedulerRecorder prepares a recorder whose units each take the same time to compile. func newSchedulerRecorder(hold time.Duration) *schedulerRecorder { return &schedulerRecorder{ - hold: hold, - holdByUnit: map[string]time.Duration{}, - started: map[string]bool{}, - surfaces: map[string]string{}, - assemblies: map[string]string{}, - output: map[string]fakeCompilerOutput{}, - killedUnits: map[string]bool{}, - runs: map[string]int{}, - finished: map[string]bool{}, - finishedWhen: map[string][]string{}, + hold: hold, + holdByUnit: map[string]time.Duration{}, + started: map[string]bool{}, + surfaces: map[string]string{}, + assemblies: map[string]string{}, + output: map[string]fakeCompilerOutput{}, + killedUnits: map[string]bool{}, + runs: map[string]int{}, + finished: map[string]bool{}, + finishedWhen: map[string][]string{}, + outputByRun: map[string][]fakeCompilerOutput{}, + surfacesByRun: map[string][]string{}, + begun: map[string]bool{}, + heldUntilBegun: map[string][]string{}, } } @@ -93,6 +165,8 @@ func (recorder *schedulerRecorder) runner() CommandRunner { recorder.mutex.Lock() recorder.started[name] = true recorder.runs[name]++ + run := recorder.runs[name] + recorder.begun[name] = true recorder.running++ if recorder.running > recorder.maxRunning { recorder.maxRunning = recorder.running @@ -109,6 +183,9 @@ func (recorder *schedulerRecorder) runner() CommandRunner { failing := recorder.failingUnit == name during := recorder.during output := recorder.output[name] + if byRun := recorder.outputByRun[name]; run <= len(byRun) { + output = byRun[run-1] + } killed := recorder.killedUnits[name] recorder.mutex.Unlock() @@ -116,8 +193,9 @@ func (recorder *schedulerRecorder) runner() CommandRunner { during(name) } time.Sleep(hold) + recorder.waitForGate(name) - recorder.writeReferenceAssembly(cmd, name) + recorder.writeReferenceAssembly(cmd, name, run) recorder.writeAssembly(cmd, name) recorder.mutex.Lock() @@ -159,11 +237,14 @@ func (recorder *schedulerRecorder) writeAssembly(cmd *exec.Cmd, name string) { writeBesideResponseFile(cmd, name+assemblyExtension, content) } -// writeReferenceAssembly writes the reference assembly this fake invocation is set up to produce, -// beside the response file it was given, which is where csc writes it too. -func (recorder *schedulerRecorder) writeReferenceAssembly(cmd *exec.Cmd, name string) { +// writeReferenceAssembly writes the reference assembly the run-th invocation of the unit is set up to +// produce, beside the response file it was given, which is where csc writes it too. +func (recorder *schedulerRecorder) writeReferenceAssembly(cmd *exec.Cmd, name string, run int) { recorder.mutex.Lock() surface, produces := recorder.surfaces[name] + if byRun := recorder.surfacesByRun[name]; run <= len(byRun) { + surface, produces = byRun[run-1], byRun[run-1] != "" + } recorder.mutex.Unlock() if !produces { return diff --git a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go index fbc7df274e..3d699299b5 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go @@ -136,25 +136,48 @@ func compileCheckVibeReasons(reasons []compileCheckFastPathFallback) []map[strin return entries } -// compileCheckVibeUnits lists how long each unit this run compiled or checked took. +// compileCheckVibeUnits lists how long each unit this run compiled or checked took, and how it was +// scheduled when that was out of the ordinary: the references it assumed kept Unity's surface, the +// one that made it compile again and the time the compile it threw away took, and that it waited +// for a record that then did not apply - a unit whose record applied is replayed and left out. // Why replayed units are left out: they took no work, and their durations would only hide which // units the run spent its time on. +// Why the scheduling keys appear only when they say something: most units carry none of them, and a +// line of empty values would hide the few that do. func compileCheckVibeUnits(units []compilecheck.UnitResult) []map[string]any { entries := make([]map[string]any, 0, len(units)) for _, unit := range units { if unit.Reused || unit.FastCheckReused { continue } - entries = append(entries, map[string]any{ + entry := map[string]any{ "assembly": unit.Assembly, "duration_ms": unit.Duration.Milliseconds(), "succeeded": unit.Succeeded, - }) + } + addCompileCheckVibeScheduling(entry, unit) + entries = append(entries, entry) } return entries } +// addCompileCheckVibeScheduling adds to a units entry the scheduling keys that apply to the unit. +func addCompileCheckVibeScheduling(entry map[string]any, unit compilecheck.UnitResult) { + if len(unit.AssumedReferences) > 0 { + entry["assumed_kept"] = unit.AssumedReferences + } + if unit.CompiledAgainAfter != "" { + entry["compiled_again_after"] = unit.CompiledAgainAfter + } + if unit.DiscardedDuration > 0 { + entry["discarded_ms"] = unit.DiscardedDuration.Milliseconds() + } + if unit.WaitedForRecord { + entry["waited_for_record"] = true + } +} + // compileCheckVibeSurfaceChecks lists every unit whose surface the worker, or its record of the same // comparison, settled: what it answered and how long asking took. // Why replayed units are kept here, unlike in units: a replayed unit is compared just the same, and diff --git a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go index e3dab053d5..df02dd33cb 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go @@ -209,3 +209,32 @@ func TestCompileCheckVibeLogRecordsAnEditorResolutionFailure(t *testing.T) { complete := assertCompileCheckVibeRun(t, readCompileCheckVibeLog(t, projectRoot)) assertVibeContextJSON(t, complete, "outcome", `"failed"`) } + +// Verifies the units entries say which references a unit assumed kept Unity's surface, which one +// made it compile again and how long the discarded compile took, and that it waited for a record, +// each only on the units it applies to. +func TestCompileCheckVibeLogRecordsUnitsCompiledAgain(t *testing.T) { + t.Setenv(vibelog.CLIVibeLogEnvName, "1") + projectRoot := t.TempDir() + result := compilecheck.Result{Units: []compilecheck.UnitResult{ + {Assembly: "Alpha", Succeeded: true, Duration: 100 * time.Millisecond}, + { + Assembly: "Beta", Succeeded: true, Duration: 200 * time.Millisecond, + AssumedReferences: []string{"Gamma", "Delta"}, + }, + { + Assembly: "Epsilon", Succeeded: true, Duration: 300 * time.Millisecond, + CompiledAgainAfter: "Gamma", DiscardedDuration: 50 * time.Millisecond, WaitedForRecord: true, + }, + }} + + logRun := startCompileCheckVibeLog(projectRoot, compileCheckOptions{}) + logRun.completed(buildCompileCheckResponse(result, projectRoot), result) + + complete := assertCompileCheckVibeRun(t, readCompileCheckVibeLog(t, projectRoot)) + assertVibeContextJSON(t, complete, "units", + `[{"assembly":"Alpha","duration_ms":100,"succeeded":true},`+ + `{"assembly":"Beta","assumed_kept":["Gamma","Delta"],"duration_ms":200,"succeeded":true},`+ + `{"assembly":"Epsilon","compiled_again_after":"Gamma","discarded_ms":50,"duration_ms":300,`+ + `"succeeded":true,"waited_for_record":true}]`) +} diff --git a/docs/adr/0016-compile-check-starts-dependents-before-the-dependents-they-reference-finish.md b/docs/adr/0016-compile-check-starts-dependents-before-the-dependents-they-reference-finish.md new file mode 100644 index 0000000000..4cce6f3878 --- /dev/null +++ b/docs/adr/0016-compile-check-starts-dependents-before-the-dependents-they-reference-finish.md @@ -0,0 +1,80 @@ +# Compile Check Starts Dependents Before the Dependents They Reference Finish + +Date: 2026-10-15 + +## Decision + +`uloop compile-check` starts an assembly that has to compile - one selected for its own sources, +or a dependent at least one of whose finished references changed its public surface - as soon as +every assembly it references that was selected for its own sources has finished. It does not wait +for the dependents it references that are still compiling. For those it reads Unity's reference +assembly, assuming they keep Unity's public surface. + +- An assembly's final state is decided only once every assembly it references in the plan has + one. When every assumption held, the compile it started early is its result. When an assumed + reference changed its surface, it is compiled again, against this run's output for that + reference. When an assumed reference failed or was blocked, the assembly is blocked and its + result is dropped. The errors and the surface verdict of a dropped result decide nothing for the + assemblies below it. +- An assembly is compiled at most twice per run: the second compile starts only once every + reference has its final state, so it assumes nothing. +- An assembly whose recorded result may still be replayed once the dependents it references + finish does not start early; it waits for them. A compile overwrites the record, so starting + early would throw away a record the same inputs replay, and every run over those inputs would + compile it twice. The record keeps, beside its input key, a digest of every input except the + references to project assemblies and the identity of each of those references, so the check + compares everything but the references still compiling, without reading the sources when a + finished reference already differs. +- An assembly still waits for references selected for their own sources (they are likely to + change their surface), and a dependent none of whose finished references changed still waits + (it may still be skipped). `--all` selects every assembly for its own sources, so nothing starts + early there. + +## Context + +On a project with 561 assemblies, adding a public member to an assembly that 67 others reference +compiled all 68 and took 35 to 43 seconds. The edited assembly took about 10.5 seconds; the 67 +dependents took 57 to 71 seconds of CPU between them but 25 to 32 seconds of wall time with eight +jobs, because they reference each other through 354 references up to ten levels deep. Every +dependent waited for its references to finish, so the critical path through the dependents - 31 +to 37 seconds - was the wall time. More jobs did not help: 12 and 16 jobs saved 2 to 5 seconds, +and 24 jobs took almost twice as long. + +In all nine of those runs, the only reference assembly that differed from Unity's build was the +edited assembly's: the run's surface checks named one assembly each time. Every dependent waited +for the dependents it references only to read Unity's reference assembly for them anyway. + +Simulating the schedule with the measured unit times puts the new critical path at 20 to 25 +seconds. The floor is the edited assembly plus the largest dependent, which can only start after +it. + +## Considered Options + +- **Start a dependent when the dependents it references have handed their sources to csc, rather + than when they finish.** It would remove the wasted early compile of a run that replays the + references, but that waste happens only when a replay and a changed surface coincide, and only + then on repeated runs. Not now. +- **Stop a running compile as soon as an assumption fails.** It needs a per-assembly cancel, and + the compile it saves is at most one per assembly per run. Not now. +- **Write the early compile's outputs and record somewhere else and move them into place when they + are kept.** It would protect the record without the waiting rule, but where the outputs live is + assumed by the compile, the surface verdict and the changed-files baseline alike, so it moves + many parts, and the early compile would still run on every repeated run. + +## Limits + +- An assembly selected for its own sources has no record that can still apply (its sources + changed), so it starts early. If its changed-files check falls back to a full compile and an + assumed reference then changes its surface, the full compile removes its changed-files baseline + and the compile again records a new one. The loss is only for running the same state again with + a changed dependent below an edited assembly: a changed-files check becomes two full compiles. + It did not occur in the measured runs. + +## Revisit When + +- On a real project, more than one in ten of the assemblies that started early carry + `compiled_again_after` in the VibeLog. Then narrow what is assumed, or stop starting early. +- More than one in ten of the assemblies that started early, or waited instead, carry + `waited_for_record` - a wait for a record that then did not apply. Then revisit the record check. +- A report shows the changed-files baseline of an edited assembly lost this way. Then extend the + record check to the baseline. diff --git a/docs/compile-check.md b/docs/compile-check.md index 55192b3dda..b215b4cbde 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -97,11 +97,22 @@ references, scripting defines and analyzers. `compile-check` replays those respo reference that was added to one, invalidate the recorded build. The run then stops with `COMPILE_CHECK_UNITY_BUILD_REQUIRED` rather than reporting diagnostics for a configuration the project no longer has. -5. **Compile in dependency order** and parse the compiler's diagnostics into JSON. An assembly starts - as soon as every assembly it references that this run also compiles has finished, so independent - assemblies compile at the same time. `--jobs ` caps how many run at once; the default is half - the machine's CPUs and never less than one, because one `csc` process already keeps two to three - cores busy on its own. `--jobs 1` compiles the assemblies one after another. +5. **Compile in dependency order** and parse the compiler's diagnostics into JSON. An assembly + starts once every assembly it references that this run also compiles has finished, so + independent assemblies compile at the same time. An assembly selected for its own sources, or a + dependent that is already known to need compiling, starts sooner: as soon as every assembly it + references that was selected for its own sources has finished. The dependents it references + that are still compiling are assumed to keep Unity's public surface, so it reads Unity's + reference assembly for them. When one of them turns out to have changed its surface, the + assembly is compiled again after all of them finished; when one of them fails, the assembly is + blocked. An assembly is compiled at most twice per run. An assembly whose recorded result may + still apply once those dependents finish waits for them instead, so a run over the same inputs + still replays it. `assumed_kept` and `compiled_again_after` in the VibeLog's `units` say which + happened. A unit whose record applied after the wait is counted under `reused` and does not + appear in `units`; `waited_for_record` marks a unit that waited for a record that then did not + apply. `--jobs ` caps how many run at once; the default is half the machine's CPUs and never + less than one, because one `csc` process already keeps two to three cores busy on its own. + `--jobs 1` compiles the assemblies one after another. The reported order does not depend on the job count: results are collected in dependency order whatever finishes first. An assembly that fails to compile stops the run at that point of the tree: it wrote no reference assembly, so everything below it has nothing to compile against and diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 98a1794d6b..cdcc4e7285 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -280,7 +280,7 @@ because there is no project to write into. | `operation` | Written | `context` | |---|---|---| | `cli_compile_check_start` | once the project is found | `command`, `all`, `full`, `jobs`, `project_identity` | -| `cli_compile_check_complete` (`INFO`; `WARNING` when the run found compile errors; `ERROR` when it could not finish) | on every way out of a run that wrote the start entry | `command`, `outcome` (`succeeded`, `errors`, or `failed`), `duration_ms`; for `failed`, `error` and `error_code`; otherwise `error_count`, `warning_count`, `compiled`, `reused`, `fast_checked`, `blocked` (assembly names, as in the response), `skipped_count`, `fast_checks` (`assembly`, `reused`, `changed_file_count`), `fallbacks` and `baseline_notes` (`assembly`, `reason`), `units` (`assembly`, `duration_ms`, `succeeded` of each assembly the run compiled or checked; replayed ones are left out), and `surface_checks` (`assembly`, `verdict`, `detail`, `duration_ms`, `from_record` of each assembly whose rebuilt reference assembly differed in bytes from Unity's and was compared by the worker or taken from its record, replayed ones included) | +| `cli_compile_check_complete` (`INFO`; `WARNING` when the run found compile errors; `ERROR` when it could not finish) | on every way out of a run that wrote the start entry | `command`, `outcome` (`succeeded`, `errors`, or `failed`), `duration_ms`; for `failed`, `error` and `error_code`; otherwise `error_count`, `warning_count`, `compiled`, `reused`, `fast_checked`, `blocked` (assembly names, as in the response), `skipped_count`, `fast_checks` (`assembly`, `reused`, `changed_file_count`), `fallbacks` and `baseline_notes` (`assembly`, `reason`), `units` (`assembly`, `duration_ms`, `succeeded` of each assembly the run compiled or checked; replayed ones are left out; `assumed_kept` names the references it started ahead of, assuming they kept Unity's surface, `compiled_again_after` the one whose changed surface made it compile again and `discarded_ms` how long the compile it threw away took, and `waited_for_record` says it waited for its references for a record that then did not apply - each only when it applies), and `surface_checks` (`assembly`, `verdict`, `detail`, `duration_ms`, `from_record` of each assembly whose rebuilt reference assembly differed in bytes from Unity's and was compared by the worker or taken from its record, replayed ones included) | - Unlike the tool-command entries, the complete entry holds the run's error message and fallback reasons: they can name assemblies and files of the project, which the command's own output prints From 8b218a30feb9a4b76cae5d92a6a298701e1ce874 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 16 Oct 2026 08:32:50 +0900 Subject: [PATCH 26/33] feat: compile-check starts the largest ready assembly first so long compiles do not finish last (#3345) --- .../internal/compilecheck/scheduler.go | 6 +- .../compilecheck/scheduler_optimistic_test.go | 62 +++++++++++++++++++ .../internal/compilecheck/scheduler_settle.go | 40 ++++++++++-- .../internal/compilecheck/scheduler_test.go | 3 + docs/compile-check.md | 3 + 5 files changed, 109 insertions(+), 5 deletions(-) diff --git a/cli/dispatcher/internal/compilecheck/scheduler.go b/cli/dispatcher/internal/compilecheck/scheduler.go index 48181ebb75..77c69dce5f 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler.go +++ b/cli/dispatcher/internal/compilecheck/scheduler.go @@ -33,7 +33,9 @@ type schedule struct { pendingOwn []int // ownSources marks, per unit, that it was selected for its own sources. ownSources []bool - dependents [][]int + // sourceCounts holds, per unit, how many sources it compiles. + sourceCounts []int + dependents [][]int // references lists, per unit, the units of the plan it references. references [][]int ready []int @@ -353,6 +355,7 @@ func newSchedule(plan BuildPlan) *schedule { pending: make([]int, len(plan.Units)), pendingOwn: make([]int, len(plan.Units)), ownSources: make([]bool, len(plan.Units)), + sourceCounts: make([]int, len(plan.Units)), dependents: make([][]int, len(plan.Units)), references: make([][]int, len(plan.Units)), ready: []int{}, @@ -363,6 +366,7 @@ func newSchedule(plan BuildPlan) *schedule { } for index, unit := range plan.Units { state.ownSources[index] = !unit.SelectedAsDependent + state.sourceCounts[index] = len(unit.Sources) } for index, unit := range plan.Units { for _, reference := range unit.PlanReferences { diff --git a/cli/dispatcher/internal/compilecheck/scheduler_optimistic_test.go b/cli/dispatcher/internal/compilecheck/scheduler_optimistic_test.go index db0725c069..8b22a9fd38 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_optimistic_test.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_optimistic_test.go @@ -562,3 +562,65 @@ func TestCompileUnitsQueuesAUnitOnceWhenItsLastReferenceFinishesWhileItWaitsForA t.Errorf("D compiled %d times, want 1", run.recorder.runs["D"]) } } + +// withSourceCounts gives each named unit of a plan that many sources. +func withSourceCounts(plan BuildPlan, counts map[string]int) BuildPlan { + for index, unit := range plan.Units { + sources := []string{} + for source := 0; source < counts[unit.Assembly.AssemblyName]; source++ { + sources = append(sources, fmt.Sprintf("Assets/%s/File%d.cs", unit.Assembly.AssemblyName, source)) + } + plan.Units[index].Sources = sources + } + + return plan +} + +// Verifies the unit with the most sources starts first among those ready at the same time, since it +// is likely to take longest, while the results stay in the plan's order. +func TestCompileUnitsStartsTheReadyUnitWithTheMostSourcesFirst(t *testing.T) { + plan := withSourceCounts(newSchedulerPlan(nil, "A", "B", "C"), map[string]int{"A": 1, "B": 3, "C": 2}) + recorder := newSchedulerRecorder(0) + + outcome, err := compileUnits(context.Background(), newSchedulerCompiler(t, recorder), plan, 1) + if err != nil { + t.Fatalf("expected the run to succeed, got error: %v", err) + } + + assertStrings(t, "start order", recorder.startOrder, []string{"B", "C", "A"}) + assertStrings(t, "results", compiledNames(outcome.Units), []string{"A", "B", "C"}) +} + +// Verifies units ready at the same time with as many sources start in the plan's order, not in the +// order they became ready: B is ready from the start and A only once Z finished, yet A goes first. +func TestCompileUnitsBreaksATieInSourceCountByPlanOrder(t *testing.T) { + plan := withSourceCounts(newSchedulerPlan(map[string][]string{"A": {"Z"}}, "A", "B", "Z"), + map[string]int{"A": 1, "B": 1, "Z": 2}) + recorder := newSchedulerRecorder(0) + + if _, err := compileUnits(context.Background(), newSchedulerCompiler(t, recorder), plan, 1); err != nil { + t.Fatalf("expected the run to succeed, got error: %v", err) + } + + assertStrings(t, "start order", recorder.startOrder, []string{"Z", "A", "B"}) +} + +// Verifies a unit whose references are all final starts before a larger unit queued early that +// references it: starting the larger one first would compile it against a reference not yet +// compiled, and again once that reference changed its surface. +func TestCompileUnitsStartsAUnitWhoseReferencesAreFinalBeforeALargerUnitQueuedEarly(t *testing.T) { + run := newOptimisticPlanRun(t, map[string][]string{"A": {"X"}, "B": {"X"}, "D": {"X", "A"}}, + []string{"A", "B", "D"}, "X", "A", "B", "D") + run.plan = withSourceCounts(run.plan, map[string]int{"X": 1, "A": 1, "B": 1, "D": 3}) + run.jobs = 1 + + outcome := run.compile(t) + + if run.recorder.runs["D"] != 1 { + t.Errorf("D compiled %d times, want 1", run.recorder.runs["D"]) + } + assertStrings(t, "start order", run.recorder.startOrder, []string{"X", "A", "D", "B"}) + if again := resultOf(t, outcome.Units, "D").CompiledAgainAfter; again != "" { + t.Errorf("D compiled again after %q, want once", again) + } +} diff --git a/cli/dispatcher/internal/compilecheck/scheduler_settle.go b/cli/dispatcher/internal/compilecheck/scheduler_settle.go index 9bffbb5578..3ede0081d7 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_settle.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_settle.go @@ -54,15 +54,42 @@ func (state *schedule) enqueue(index int) { state.ready = append(state.ready, index) } -// takeNext removes the next unit to start from the ready queue. +// takeNext removes the next unit to start from the ready queue: a unit whose references are all final +// before one queued early, then the one with the most sources, and among those the one first in the +// plan. +// Why a unit with final references goes first: a unit queued early may reference one still waiting +// in the queue, and starting it ahead of that reference would compile it against Unity's file, only +// to compile it again once the reference changed its surface. +// Why the most sources first: how long a compile takes follows its source count closely, and +// starting the longest compiles first keeps them from finishing last while the other jobs sit idle. +// Why plan order breaks a tie rather than the order units became ready: it keeps the start order the +// same from run to run. func (state *schedule) takeNext() int { - index := state.ready[0] - state.ready = state.ready[1:] + best := 0 + for position := 1; position < len(state.ready); position++ { + if state.startsBefore(state.ready[position], state.ready[best]) { + best = position + } + } + index := state.ready[best] + state.ready = append(state.ready[:best], state.ready[best+1:]...) state.queued[index] = false return index } +// startsBefore reports whether unit a should start before unit b when both are ready. +func (state *schedule) startsBefore(a int, b int) bool { + if (state.pending[a] == 0) != (state.pending[b] == 0) { + return state.pending[a] == 0 + } + if state.sourceCounts[a] != state.sourceCounts[b] { + return state.sourceCounts[a] > state.sourceCounts[b] + } + + return a < b +} + // mayStartEarly reports whether a unit with references still lacking a final state may start before // them: it has to compile whatever they turn out to be, every reference still missing is a dependent // rather than one selected for its own sources, and nothing has ruled it out. @@ -111,11 +138,16 @@ func (run *schedulerRun) releaseAndOffer(index int, changed bool) { } } -// startAndSettle starts what is ready and decides the final state of what finished, until neither +// startAndSettle decides the final state of what finished and starts what is ready, until neither // moves anything. // Why the two alternate: a skip decided while starting units gives a reference its final state, // which can let a unit that already finished be decided, and deciding one can make others ready. +// Why it decides before it starts: the units a finished one releases then compete for the free jobs +// with the ones already waiting, instead of queuing behind whatever started first. func (run *schedulerRun) startAndSettle(jobs int) error { + if _, err := run.settleFinished(); err != nil { + return err + } for { if err := run.startReadyUnits(jobs); err != nil { return err diff --git a/cli/dispatcher/internal/compilecheck/scheduler_test.go b/cli/dispatcher/internal/compilecheck/scheduler_test.go index c73faf78a1..bf31ecaff0 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_test.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_test.go @@ -68,6 +68,8 @@ type schedulerRecorder struct { // surfacesByRun holds, per unit, the reference assembly the n-th invocation of it writes; an // empty one writes none. An invocation past its end falls back to surfaces. surfacesByRun map[string][]string + // startOrder lists the units in the order their invocations started. + startOrder []string // begun names the units whose compile or changed-files check has begun. begun map[string]bool // heldUntilBegun holds, per unit, the units its fake invocation waits to see begun before it @@ -166,6 +168,7 @@ func (recorder *schedulerRecorder) runner() CommandRunner { recorder.started[name] = true recorder.runs[name]++ run := recorder.runs[name] + recorder.startOrder = append(recorder.startOrder, name) recorder.begun[name] = true recorder.running++ if recorder.running > recorder.maxRunning { diff --git a/docs/compile-check.md b/docs/compile-check.md index b215b4cbde..569de3b02b 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -113,6 +113,9 @@ references, scripting defines and analyzers. `compile-check` replays those respo apply. `--jobs ` caps how many run at once; the default is half the machine's CPUs and never less than one, because one `csc` process already keeps two to three cores busy on its own. `--jobs 1` compiles the assemblies one after another. + Among the assemblies ready at the same time, one whose references have all finished starts + before one started early, then the one with the most sources, and a tie goes to the one first in + dependency order. The reported order does not depend on the job count: results are collected in dependency order whatever finishes first. An assembly that fails to compile stops the run at that point of the tree: it wrote no reference assembly, so everything below it has nothing to compile against and From 869c812e8add217afd4500a2143b63e71e8dc4a7 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 16 Oct 2026 10:28:32 +0900 Subject: [PATCH 27/33] refactor: split compile-check input key writers into their own file (#3346) --- .../internal/compilecheck/input_key.go | 293 ----------------- .../compilecheck/input_key_identity.go | 304 ++++++++++++++++++ 2 files changed, 304 insertions(+), 293 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/input_key_identity.go diff --git a/cli/dispatcher/internal/compilecheck/input_key.go b/cli/dispatcher/internal/compilecheck/input_key.go index f01c39a886..86b9896f7f 100644 --- a/cli/dispatcher/internal/compilecheck/input_key.go +++ b/cli/dispatcher/internal/compilecheck/input_key.go @@ -1,16 +1,11 @@ package compilecheck import ( - "bytes" "crypto/sha256" "encoding/hex" "fmt" "hash" "io" - "os" - "path/filepath" - "sort" - "strings" "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/resultstore" ) @@ -233,291 +228,3 @@ func computeKeyWith( return hex.EncodeToString(digest.Sum(nil)), nil } - -// writeCompilerIdentity records which compiler would run: another one reports other diagnostics. -func writeCompilerIdentity(digest hash.Hash, paths EditorCompilerPaths) error { - for _, path := range []string{paths.CompilerDllPath, paths.DotnetHostPath} { - if err := writeStatEntry(digest, "compiler", path, path); err != nil { - return err - } - } - - return nil -} - -// writeResponseFileIdentity records the response files Bee wrote for this assembly, which carry its -// defines, its analyzer list and the path mapping its metadata is built with. -func writeResponseFileIdentity(digest hash.Hash, rsp ResponseFile) error { - companionPath := strings.TrimSuffix(rsp.Path, responseFileExtension) + companionFileExtension - // Why only the companion file may be absent: Bee writes one for some assemblies and not others, - // so the ones it left out are keyed on that absence. It writes the primary response file for - // every assembly it builds, so a run that cannot find one is looking at a state this key cannot - // describe - and keying on its absence would let two different states hash the same. - content, err := os.ReadFile(rsp.Path) - if err != nil { - return fmt.Errorf("failed to read %s: %w", rsp.Path, err) - } - writeContentEntry(digest, "response", filepath.Base(rsp.Path), content) - - companionContent, err := os.ReadFile(companionPath) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to read %s: %w", companionPath, err) - } - writeContentEntry(digest, "response", filepath.Base(companionPath), companionContent) - - return nil -} - -// writeSourceIdentity records what the assembly compiles, by content and in a fixed order, and -// fills hashes with the content hash of each source it read. -// Why the content rather than the modification time: a checkout that puts a file back the way it -// was leaves it newer than the build, and recompiling it would report the diagnostics it already -// reported. The whole source set of the largest assembly hashes in tens of milliseconds. -func writeSourceIdentity( - digest hash.Hash, projectRoot string, sources []string, hashes map[string]string, -) error { - ordered := append([]string{}, sources...) - sort.Strings(ordered) - for _, source := range ordered { - path := projectPath(projectRoot, source) - contentHash, err := hashFile(path) - if err != nil { - return fmt.Errorf("failed to read %s: %w", path, err) - } - hashes[source] = contentHash - _, _ = fmt.Fprintf(digest, "source\x00%s\x00%s\n", filepath.ToSlash(source), contentHash) - } - - return nil -} - -// writeHashedSources writes the same lines writeSourceIdentity writes, from hashes it already read. -func writeHashedSources(digest io.Writer, hashes map[string]string) { - ordered := make([]string, 0, len(hashes)) - for source := range hashes { - ordered = append(ordered, source) - } - sort.Strings(ordered) - for _, source := range ordered { - _, _ = fmt.Fprintf(digest, "source\x00%s\x00%s\n", filepath.ToSlash(source), hashes[source]) - } -} - -// writeSourceNames records which sources the assembly compiles, without what they hold. -func writeSourceNames(digest hash.Hash, sources []string) { - ordered := append([]string{}, sources...) - sort.Strings(ordered) - for _, source := range ordered { - _, _ = fmt.Fprintf(digest, "source-name\x00%s\n", filepath.ToSlash(source)) - } -} - -// writeReferenceIdentity records the assemblies this one compiles against, resolved the way the -// compile itself resolves them. -// Why the two rules: a reference this run rebuilt into a different surface is read by content, -// because a later rebuild can write the same bytes again with a new timestamp. Everything else - -// including a rebuilt reference that kept Unity's surface, which the compile reads from Unity's file - -// is read by timestamp, which moves whenever Unity rebuilt it, and a compile that did not need it -// costs less than a missed error. -func writeReferenceIdentity( - digest io.Writer, projectRoot string, plan BuildPlan, unit CompileUnit, -) error { - for _, reference := range unit.Assembly.References { - if _, err := writeReferenceLine(digest, projectRoot, plan, unit, reference); err != nil { - return err - } - } - - return nil -} - -// writeShapeReferenceIdentity writes the lines writeReferenceIdentity writes for every reference -// that is not a project assembly, and puts the sha256 of the line of each one that is, with which -// file that line reads, into projectReferences under its name. -// Why a name seen twice is simply overwritten: the same reference resolves to the same line. -func writeShapeReferenceIdentity( - digest io.Writer, projectRoot string, plan BuildPlan, unit CompileUnit, - projectReferences map[string]resultstore.ReferenceIdentity, -) error { - for _, reference := range unit.Assembly.References { - name, isProjectAssembly := projectAssemblyReferenceName(reference, plan.DagDir) - if !isProjectAssembly { - if _, err := writeReferenceLine(digest, projectRoot, plan, unit, reference); err != nil { - return err - } - - continue - } - var line bytes.Buffer - produced, err := writeReferenceLine(&line, projectRoot, plan, unit, reference) - if err != nil { - return err - } - sum := sha256.Sum256(line.Bytes()) - kind := resultstore.UnityReference - if produced { - kind = resultstore.ProducedReference - } - projectReferences[name] = resultstore.ReferenceIdentity{Kind: kind, Hash: hex.EncodeToString(sum[:])} - } - - return nil -} - -// writeReferenceLine writes the line one reference contributes to the key, and reports whether that -// line reads this run's output rather than Unity's build. -func writeReferenceLine( - digest io.Writer, projectRoot string, plan BuildPlan, unit CompileUnit, reference string, -) (bool, error) { - resolved := rewriteReference(projectRoot, plan, unit, reference) - path := projectPath(projectRoot, resolved) - if isUnderDirectory(resolved, plan.OutputDir) { - return true, writeFileContentEntry(digest, "produced-reference", resolved, path) - } - - return false, writeStatEntry(digest, "reference", resolved, path) -} - -// writeAnalyzerIdentity records the analyzer assemblies csc loads, which are shipped rather than -// edited, so their timestamps answer whether they moved. -func writeAnalyzerIdentity(digest hash.Hash, projectRoot string, analyzers []string) error { - for _, analyzer := range analyzers { - if err := writeStatEntry(digest, "analyzer", analyzer, projectPath(projectRoot, analyzer)); err != nil { - return err - } - } - - return nil -} - -// writeAnalyzerInputIdentity records the files the analyzers read - a ruleset, a banned-symbol list, -// an editorconfig handed over as /analyzerconfig - and refuses a key for a flag naming a file this -// digest does not follow. -func writeAnalyzerInputIdentity(digest hash.Hash, projectRoot string, rsp ResponseFile) error { - inputs := []string{} - if rsp.AdditionalFile != "" { - inputs = append(inputs, rsp.AdditionalFile) - } - // Why both flag lists: the companion lines are Bee's second response file, and compiler.go - // appends them to the invocation verbatim, so a flag naming a file csc reads decides the - // diagnostics no matter which of the two files Bee put it in. - for _, flags := range [][]string{rsp.OtherFlags, rsp.CompanionFlags} { - for _, flag := range flags { - value, isPathValued := pathValuedFlag(flag) - if isPathValued { - inputs = append(inputs, value) - - continue - } - if untrackedFileInputFlag(flag) { - return fmt.Errorf("response file flag %q names a file the input key does not follow", flag) - } - } - } - - for _, input := range inputs { - if err := writeFileContentEntry( - digest, "analyzer-input", input, projectPath(projectRoot, input)); err != nil { - return err - } - } - - return nil -} - -// writeOutputIdentity records what the compile is named, which is part of what it produces. -func writeOutputIdentity(digest hash.Hash, rsp ResponseFile) { - for _, path := range []string{rsp.OutputPath, rsp.RefOutputPath} { - _, _ = fmt.Fprintf(digest, "output\x00%s\n", filepath.Base(path)) - } -} - -// pathValuedFlag reads the file path out of a flag whose content belongs in the key. -func pathValuedFlag(line string) (string, bool) { - name, value, ok := splitFlag(line) - if !ok { - return "", false - } - for _, candidate := range pathValuedFlagNames { - if name == candidate { - return unquotePath(value), true - } - } - - return "", false -} - -// untrackedFileInputFlag reports whether a flag names a file csc reads that the key does not follow. -func untrackedFileInputFlag(line string) bool { - name, _, ok := splitFlag(line) - if !ok { - return false - } - for _, candidate := range untrackedFileInputFlagNames { - if name == candidate { - return true - } - } - - return false -} - -// splitFlag reads one response file line as a flag name and its value. -// Why the name is lowered and both flag markers are accepted: csc treats -ruleset:, /ruleset: and -// /RuleSet: as the same flag, and a comparison that did not would silently follow none of them. -func splitFlag(line string) (string, string, bool) { - if line == "" || (line[0] != '-' && line[0] != '/') { - return "", "", false - } - name, value, found := strings.Cut(line[1:], ":") - if !found { - return "", "", false - } - - return strings.ToLower(name), value, true -} - -// writeStatEntry records a file by where it is, how big it is and when it last changed. -func writeStatEntry(digest io.Writer, kind string, name string, path string) error { - info, err := os.Stat(path) - if err != nil { - return fmt.Errorf("failed to read %s: %w", path, err) - } - _, _ = fmt.Fprintf(digest, "%s\x00%s\x00%d\x00%d\n", - kind, filepath.ToSlash(name), info.Size(), info.ModTime().UnixNano()) - - return nil -} - -// writeFileContentEntry records a file by where it is and what it holds. -func writeFileContentEntry(digest io.Writer, kind string, name string, path string) error { - file, err := os.Open(path) - if err != nil { - return fmt.Errorf("failed to read %s: %w", path, err) - } - defer func() { _ = file.Close() }() - - content := sha256.New() - if _, err := io.Copy(content, file); err != nil { - return fmt.Errorf("failed to read %s: %w", path, err) - } - _, _ = fmt.Fprintf(digest, "%s\x00%s\x00%s\n", - kind, filepath.ToSlash(name), hex.EncodeToString(content.Sum(nil))) - - return nil -} - -// writeContentEntry records bytes already in hand under a name. -func writeContentEntry(digest hash.Hash, kind string, name string, content []byte) { - sum := sha256.Sum256(content) - _, _ = fmt.Fprintf(digest, "%s\x00%s\x00%s\n", kind, filepath.ToSlash(name), hex.EncodeToString(sum[:])) -} - -// projectPath resolves a path a response file spells relative to the project root. -func projectPath(projectRoot string, path string) string { - if filepath.IsAbs(path) { - return path - } - - return filepath.Join(projectRoot, path) -} diff --git a/cli/dispatcher/internal/compilecheck/input_key_identity.go b/cli/dispatcher/internal/compilecheck/input_key_identity.go new file mode 100644 index 0000000000..51ee190943 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/input_key_identity.go @@ -0,0 +1,304 @@ +package compilecheck + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "hash" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/resultstore" +) + +// writeCompilerIdentity records which compiler would run: another one reports other diagnostics. +func writeCompilerIdentity(digest hash.Hash, paths EditorCompilerPaths) error { + for _, path := range []string{paths.CompilerDllPath, paths.DotnetHostPath} { + if err := writeStatEntry(digest, "compiler", path, path); err != nil { + return err + } + } + + return nil +} + +// writeResponseFileIdentity records the response files Bee wrote for this assembly, which carry its +// defines, its analyzer list and the path mapping its metadata is built with. +func writeResponseFileIdentity(digest hash.Hash, rsp ResponseFile) error { + companionPath := strings.TrimSuffix(rsp.Path, responseFileExtension) + companionFileExtension + // Why only the companion file may be absent: Bee writes one for some assemblies and not others, + // so the ones it left out are keyed on that absence. It writes the primary response file for + // every assembly it builds, so a run that cannot find one is looking at a state this key cannot + // describe - and keying on its absence would let two different states hash the same. + content, err := os.ReadFile(rsp.Path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", rsp.Path, err) + } + writeContentEntry(digest, "response", filepath.Base(rsp.Path), content) + + companionContent, err := os.ReadFile(companionPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read %s: %w", companionPath, err) + } + writeContentEntry(digest, "response", filepath.Base(companionPath), companionContent) + + return nil +} + +// writeSourceIdentity records what the assembly compiles, by content and in a fixed order, and +// fills hashes with the content hash of each source it read. +// Why the content rather than the modification time: a checkout that puts a file back the way it +// was leaves it newer than the build, and recompiling it would report the diagnostics it already +// reported. The whole source set of the largest assembly hashes in tens of milliseconds. +func writeSourceIdentity( + digest hash.Hash, projectRoot string, sources []string, hashes map[string]string, +) error { + ordered := append([]string{}, sources...) + sort.Strings(ordered) + for _, source := range ordered { + path := projectPath(projectRoot, source) + contentHash, err := hashFile(path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) + } + hashes[source] = contentHash + _, _ = fmt.Fprintf(digest, "source\x00%s\x00%s\n", filepath.ToSlash(source), contentHash) + } + + return nil +} + +// writeHashedSources writes the same lines writeSourceIdentity writes, from hashes it already read. +func writeHashedSources(digest io.Writer, hashes map[string]string) { + ordered := make([]string, 0, len(hashes)) + for source := range hashes { + ordered = append(ordered, source) + } + sort.Strings(ordered) + for _, source := range ordered { + _, _ = fmt.Fprintf(digest, "source\x00%s\x00%s\n", filepath.ToSlash(source), hashes[source]) + } +} + +// writeSourceNames records which sources the assembly compiles, without what they hold. +func writeSourceNames(digest hash.Hash, sources []string) { + ordered := append([]string{}, sources...) + sort.Strings(ordered) + for _, source := range ordered { + _, _ = fmt.Fprintf(digest, "source-name\x00%s\n", filepath.ToSlash(source)) + } +} + +// writeReferenceIdentity records the assemblies this one compiles against, resolved the way the +// compile itself resolves them. +// Why the two rules: a reference this run rebuilt into a different surface is read by content, +// because a later rebuild can write the same bytes again with a new timestamp. Everything else - +// including a rebuilt reference that kept Unity's surface, which the compile reads from Unity's file - +// is read by timestamp, which moves whenever Unity rebuilt it, and a compile that did not need it +// costs less than a missed error. +func writeReferenceIdentity( + digest io.Writer, projectRoot string, plan BuildPlan, unit CompileUnit, +) error { + for _, reference := range unit.Assembly.References { + if _, err := writeReferenceLine(digest, projectRoot, plan, unit, reference); err != nil { + return err + } + } + + return nil +} + +// writeShapeReferenceIdentity writes the lines writeReferenceIdentity writes for every reference +// that is not a project assembly, and puts the sha256 of the line of each one that is, with which +// file that line reads, into projectReferences under its name. +// Why a name seen twice is simply overwritten: the same reference resolves to the same line. +func writeShapeReferenceIdentity( + digest io.Writer, projectRoot string, plan BuildPlan, unit CompileUnit, + projectReferences map[string]resultstore.ReferenceIdentity, +) error { + for _, reference := range unit.Assembly.References { + name, isProjectAssembly := projectAssemblyReferenceName(reference, plan.DagDir) + if !isProjectAssembly { + if _, err := writeReferenceLine(digest, projectRoot, plan, unit, reference); err != nil { + return err + } + + continue + } + var line bytes.Buffer + produced, err := writeReferenceLine(&line, projectRoot, plan, unit, reference) + if err != nil { + return err + } + sum := sha256.Sum256(line.Bytes()) + kind := resultstore.UnityReference + if produced { + kind = resultstore.ProducedReference + } + projectReferences[name] = resultstore.ReferenceIdentity{Kind: kind, Hash: hex.EncodeToString(sum[:])} + } + + return nil +} + +// writeReferenceLine writes the line one reference contributes to the key, and reports whether that +// line reads this run's output rather than Unity's build. +func writeReferenceLine( + digest io.Writer, projectRoot string, plan BuildPlan, unit CompileUnit, reference string, +) (bool, error) { + resolved := rewriteReference(projectRoot, plan, unit, reference) + path := projectPath(projectRoot, resolved) + if isUnderDirectory(resolved, plan.OutputDir) { + return true, writeFileContentEntry(digest, "produced-reference", resolved, path) + } + + return false, writeStatEntry(digest, "reference", resolved, path) +} + +// writeAnalyzerIdentity records the analyzer assemblies csc loads, which are shipped rather than +// edited, so their timestamps answer whether they moved. +func writeAnalyzerIdentity(digest hash.Hash, projectRoot string, analyzers []string) error { + for _, analyzer := range analyzers { + if err := writeStatEntry(digest, "analyzer", analyzer, projectPath(projectRoot, analyzer)); err != nil { + return err + } + } + + return nil +} + +// writeAnalyzerInputIdentity records the files the analyzers read - a ruleset, a banned-symbol list, +// an editorconfig handed over as /analyzerconfig - and refuses a key for a flag naming a file this +// digest does not follow. +func writeAnalyzerInputIdentity(digest hash.Hash, projectRoot string, rsp ResponseFile) error { + inputs := []string{} + if rsp.AdditionalFile != "" { + inputs = append(inputs, rsp.AdditionalFile) + } + // Why both flag lists: the companion lines are Bee's second response file, and compiler.go + // appends them to the invocation verbatim, so a flag naming a file csc reads decides the + // diagnostics no matter which of the two files Bee put it in. + for _, flags := range [][]string{rsp.OtherFlags, rsp.CompanionFlags} { + for _, flag := range flags { + value, isPathValued := pathValuedFlag(flag) + if isPathValued { + inputs = append(inputs, value) + + continue + } + if untrackedFileInputFlag(flag) { + return fmt.Errorf("response file flag %q names a file the input key does not follow", flag) + } + } + } + + for _, input := range inputs { + if err := writeFileContentEntry( + digest, "analyzer-input", input, projectPath(projectRoot, input)); err != nil { + return err + } + } + + return nil +} + +// writeOutputIdentity records what the compile is named, which is part of what it produces. +func writeOutputIdentity(digest hash.Hash, rsp ResponseFile) { + for _, path := range []string{rsp.OutputPath, rsp.RefOutputPath} { + _, _ = fmt.Fprintf(digest, "output\x00%s\n", filepath.Base(path)) + } +} + +// pathValuedFlag reads the file path out of a flag whose content belongs in the key. +func pathValuedFlag(line string) (string, bool) { + name, value, ok := splitFlag(line) + if !ok { + return "", false + } + for _, candidate := range pathValuedFlagNames { + if name == candidate { + return unquotePath(value), true + } + } + + return "", false +} + +// untrackedFileInputFlag reports whether a flag names a file csc reads that the key does not follow. +func untrackedFileInputFlag(line string) bool { + name, _, ok := splitFlag(line) + if !ok { + return false + } + for _, candidate := range untrackedFileInputFlagNames { + if name == candidate { + return true + } + } + + return false +} + +// splitFlag reads one response file line as a flag name and its value. +// Why the name is lowered and both flag markers are accepted: csc treats -ruleset:, /ruleset: and +// /RuleSet: as the same flag, and a comparison that did not would silently follow none of them. +func splitFlag(line string) (string, string, bool) { + if line == "" || (line[0] != '-' && line[0] != '/') { + return "", "", false + } + name, value, found := strings.Cut(line[1:], ":") + if !found { + return "", "", false + } + + return strings.ToLower(name), value, true +} + +// writeStatEntry records a file by where it is, how big it is and when it last changed. +func writeStatEntry(digest io.Writer, kind string, name string, path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) + } + _, _ = fmt.Fprintf(digest, "%s\x00%s\x00%d\x00%d\n", + kind, filepath.ToSlash(name), info.Size(), info.ModTime().UnixNano()) + + return nil +} + +// writeFileContentEntry records a file by where it is and what it holds. +func writeFileContentEntry(digest io.Writer, kind string, name string, path string) error { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) + } + defer func() { _ = file.Close() }() + + content := sha256.New() + if _, err := io.Copy(content, file); err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) + } + _, _ = fmt.Fprintf(digest, "%s\x00%s\x00%s\n", + kind, filepath.ToSlash(name), hex.EncodeToString(content.Sum(nil))) + + return nil +} + +// writeContentEntry records bytes already in hand under a name. +func writeContentEntry(digest hash.Hash, kind string, name string, content []byte) { + sum := sha256.Sum256(content) + _, _ = fmt.Fprintf(digest, "%s\x00%s\x00%s\n", kind, filepath.ToSlash(name), hex.EncodeToString(sum[:])) +} + +// projectPath resolves a path a response file spells relative to the project root. +func projectPath(projectRoot string, path string) string { + if filepath.IsAbs(path) { + return path + } + + return filepath.Join(projectRoot, path) +} From 5b69d3c3d44617e2ab114b53fb3225c50004bfe1 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 16 Oct 2026 12:22:22 +0900 Subject: [PATCH 28/33] feat: compile-check learns which names a public API change touches (#3347) --- .../compilecheck/fast_path_fixture_test.go | 9 +- .../internal/compilecheck/fastcheck/client.go | 20 +- .../compilecheck/fastcheck/client_test.go | 60 +- .../fastcheck/worker/ChangedSurfaceNames.cs | 353 +++++++++++ .../fastcheck/worker/CompareSurfaceCommand.cs | 35 +- .../fastcheck/worker/SurfaceDescription.cs | 49 ++ .../fastcheck/worker/SurfaceManifestWriter.cs | 39 +- .../fastcheck/worker/SurfaceMetadata.cs | 78 +++ .../worker/SurfaceNullabilityWriter.cs | 51 +- .../fastcheck/worker/SurfaceOwner.cs | 87 +++ .../worker/SurfaceStructureWriter.cs | 86 +-- .../fastcheck/worker/SurfaceTypeOwners.cs | 149 +++++ .../fastcheck/worker/WorkerResponse.cs | 19 + .../compilecheck/resultstore/surface.go | 14 +- .../compilecheck/resultstore/surface_test.go | 39 +- .../internal/compilecheck/surface_verdict.go | 51 +- .../compilecheck/surface_verdict_test.go | 88 +++ .../dispatcher/compile_check_vibe_log.go | 34 +- .../dispatcher/compile_check_vibe_log_test.go | 19 +- docs/vibe-logs.md | 2 +- .../ChangedSurfaceNamesTests.cs | 565 ++++++++++++++++++ .../SurfaceFixture.cs | 55 ++ 22 files changed, 1773 insertions(+), 129 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/ChangedSurfaceNames.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceDescription.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceOwner.cs create mode 100644 cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceTypeOwners.cs create mode 100644 tests/CompileCheckWorker.UnitTests/ChangedSurfaceNamesTests.cs diff --git a/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go index 09ce05083c..51affff0bb 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go @@ -42,6 +42,10 @@ type fakeFastCheckWorker struct { // surfaceOutcome is what CompareSurface answers; empty answers different, so a test that does // not care about the comparison sees every differing reference assembly as a changed surface. surfaceOutcome string + // surfaceNames and surfaceUnnamed go with a different answer, as the worker's changed names and + // the reason no name tells the change. + surfaceNames []string + surfaceUnnamed string surfaceErr error // surfaceByContent answers same exactly when the two files handed over hold the same bytes, // instead of answering surfaceOutcome. @@ -152,7 +156,10 @@ func (worker *fakeFastCheckWorker) CompareSurface( return fastcheck.CompareSurfaceResponse{Outcome: outcome}, nil } if worker.surfaceOutcome == "" { - return fastcheck.CompareSurfaceResponse{Outcome: fastcheck.DifferentSurfaceOutcome, Detail: "fake difference"}, nil + return fastcheck.CompareSurfaceResponse{ + Outcome: fastcheck.DifferentSurfaceOutcome, Detail: "fake difference", + ChangedNames: worker.surfaceNames, UnnamedReason: worker.surfaceUnnamed, + }, nil } return fastcheck.CompareSurfaceResponse{Outcome: worker.surfaceOutcome}, nil diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/client.go b/cli/dispatcher/internal/compilecheck/fastcheck/client.go index 14de2c5368..9f7d49d796 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/client.go +++ b/cli/dispatcher/internal/compilecheck/fastcheck/client.go @@ -81,8 +81,17 @@ type CompareSurfaceRequest struct { type CompareSurfaceResponse struct { Outcome string Detail string // the first line of the two descriptions that differs, or why one could not be read + // ChangedNames are the identifiers a dependent has to write to be affected by a different answer, + // sorted; empty for same and for an unnamed change. + ChangedNames []string + // UnnamedReason says why a different answer cannot be told by names; empty when it can. + UnnamedReason string } +// WorkerNamedNoChange is the unnamed reason of a different answer that came with neither names nor +// a reason of its own. +const WorkerNamedNoChange = "the worker named no change" + // WorkerDiagnostic is one error or warning in a changed file, with a 1-based line and column. type WorkerDiagnostic struct { File string // absolute @@ -138,7 +147,16 @@ func (c Client) CompareSurface(ctx context.Context, request CompareSurfaceReques // Rejected here so an answer the worker does not define is never read as either. switch response.Outcome { - case SameSurfaceOutcome, DifferentSurfaceOutcome: + case SameSurfaceOutcome: + return response, nil + case DifferentSurfaceOutcome: + // Why an answer naming nothing is unnamed rather than an error: a worker older than the names, + // or one whose answer lost them, still found a difference, and reading it as touching no + // dependent would skip them all. + if len(response.ChangedNames) == 0 && response.UnnamedReason == "" { + response.UnnamedReason = WorkerNamedNoChange + } + return response, nil default: return CompareSurfaceResponse{}, fmt.Errorf("changed-files check worker returned an unknown outcome %q", response.Outcome) diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/client_test.go b/cli/dispatcher/internal/compilecheck/fastcheck/client_test.go index cfdce19940..adecb8995e 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/client_test.go +++ b/cli/dispatcher/internal/compilecheck/fastcheck/client_test.go @@ -240,7 +240,7 @@ func TestCompareSurfaceParsesResponse(t *testing.T) { return "", err.Error(), 1, err } - response := `{"Outcome":"different","Detail":"produced: a / Unity build: b"}` + response := `{"Outcome":"different","Detail":"produced: a / Unity build: b","ChangedNames":["C","M"],"UnnamedReason":""}` return "", "", 0, os.WriteFile(cmd.Args[len(cmd.Args)-1], []byte(response), cacheFilePermissions) }) @@ -249,8 +249,10 @@ func TestCompareSurfaceParsesResponse(t *testing.T) { t.Fatalf("expected the comparison to succeed, got error: %v", err) } - want := CompareSurfaceResponse{Outcome: DifferentSurfaceOutcome, Detail: "produced: a / Unity build: b"} - if got != want { + want := CompareSurfaceResponse{ + Outcome: DifferentSurfaceOutcome, Detail: "produced: a / Unity build: b", ChangedNames: []string{"C", "M"}, + } + if !reflect.DeepEqual(got, want) { t.Errorf("expected %+v, got %+v", want, got) } if sentArguments[len(sentArguments)-3] != "compare-surface" { @@ -263,6 +265,58 @@ func TestCompareSurfaceParsesResponse(t *testing.T) { assertNoRequestFilesLeft(t, client.TempDirectory) } +// Verifies that CompareSurface reads the reason a different answer cannot be told by names. +func TestCompareSurfaceParsesChangedNames(t *testing.T) { + client := newTestClient(t, func(_ context.Context, cmd *exec.Cmd) (string, string, int, error) { + response := `{"Outcome":"different","Detail":"d","ChangedNames":[],"UnnamedReason":"operator op_Addition"}` + return "", "", 0, os.WriteFile(cmd.Args[len(cmd.Args)-1], []byte(response), cacheFilePermissions) + }) + + got, err := client.CompareSurface(context.Background(), CompareSurfaceRequest{}) + if err != nil { + t.Fatalf("expected the comparison to succeed, got error: %v", err) + } + + if got.UnnamedReason != "operator op_Addition" || len(got.ChangedNames) != 0 { + t.Errorf("expected the unnamed reason and no names, got %+v", got) + } +} + +// Verifies that a different answer carrying neither names nor a reason, as an older worker or a +// broken response gives, reads as unnamed rather than as an error or as a change no name touches. +func TestCompareSurfaceDifferentWithoutNamesIsUnnamed(t *testing.T) { + for _, response := range []string{`{"Outcome":"different","Detail":"d"}`, `{"Outcome":"different","ChangedNames":[],"UnnamedReason":""}`} { + client := newTestClient(t, func(_ context.Context, cmd *exec.Cmd) (string, string, int, error) { + return "", "", 0, os.WriteFile(cmd.Args[len(cmd.Args)-1], []byte(response), cacheFilePermissions) + }) + + got, err := client.CompareSurface(context.Background(), CompareSurfaceRequest{}) + if err != nil { + t.Fatalf("expected %s to be read, got error: %v", response, err) + } + + if got.UnnamedReason == "" || len(got.ChangedNames) != 0 { + t.Errorf("expected %s to read as unnamed, got %+v", response, got) + } + } +} + +// Verifies that a same answer stays without names or a reason. +func TestCompareSurfaceSameCarriesNoUnnamedReason(t *testing.T) { + client := newTestClient(t, func(_ context.Context, cmd *exec.Cmd) (string, string, int, error) { + return "", "", 0, os.WriteFile(cmd.Args[len(cmd.Args)-1], []byte(`{"Outcome":"same"}`), cacheFilePermissions) + }) + + got, err := client.CompareSurface(context.Background(), CompareSurfaceRequest{}) + if err != nil { + t.Fatalf("expected the comparison to succeed, got error: %v", err) + } + + if got.UnnamedReason != "" || len(got.ChangedNames) != 0 { + t.Errorf("expected a same answer without names or a reason, got %+v", got) + } +} + // Verifies that a comparison answer other than same or different is an error, so a caller never // reads an unexpected answer as either. func TestCompareSurfaceUnknownOutcomeIsError(t *testing.T) { diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/ChangedSurfaceNames.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/ChangedSurfaceNames.cs new file mode 100644 index 0000000000..29d204050b --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/ChangedSurfaceNames.cs @@ -0,0 +1,353 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Tells what two different surfaces of one assembly changed, as the identifiers a dependent has to + /// write in its source to be affected: type names, member names, and "new(" for a constructor a + /// target-typed new can reach. A change a dependent can feel without writing any name - an operator, + /// a base type, an abstract member added, a member C# binds by pattern - is unnamed instead, and an + /// unnamed change carries no names at all, so a reader cannot narrow by them by mistake. + /// + internal sealed class ChangedSurfaceNames + { + // Beyond this many names, matching them against every dependent costs more than it saves. + internal const int MaximumNames = 2000; + private const int ReportedReasons = 5; + private const int ManifestTextLength = 60; + private const string ConstructorName = ".ctor"; + private const string TargetTypedNew = "new("; + private const string AttributeSuffix = "Attribute"; + private const string OperatorPrefix = "op_"; + private static readonly string[] AccessorPrefixes = { "get_", "set_", "add_", "remove_", "raise_" }; + + // Namespaces whose types the compiler looks up by name: a dependent writing init, ^1, a tuple or + // an interpolated string uses them without writing any of their names. Only polyfills declare + // types there in a project, so leaving every change of theirs unnamed costs little. + private static readonly string[] WellKnownNamespaces = { "System", "Microsoft.CodeAnalysis" }; + + // Members C# binds without the dependent writing their names: foreach, await, using, collection + // initializers, deconstruction, indexers, fixed, ranges and list patterns, LINQ query syntax, + // delegate invocation, async method builders, interpolated string handlers, collection builders. + private static readonly HashSet BoundByPattern = new HashSet(StringComparer.Ordinal) + { + "Add", "Dispose", "DisposeAsync", "GetEnumerator", "GetAsyncEnumerator", "MoveNext", "MoveNextAsync", + "Current", "GetAwaiter", "GetResult", "IsCompleted", "OnCompleted", "UnsafeOnCompleted", + "GetPinnableReference", "Slice", "Length", "Count", "Deconstruct", "Invoke", "Item", "Select", + "SelectMany", "Where", "Join", "GroupJoin", "OrderBy", "OrderByDescending", "ThenBy", + "ThenByDescending", "GroupBy", "Cast", "Create", "Start", "SetResult", "SetException", + "SetStateMachine", "AwaitOnCompleted", "AwaitUnsafeOnCompleted", "Task", "AppendLiteral", + "AppendFormatted", + }; + + private readonly SortedSet names = new SortedSet(StringComparer.Ordinal); + // In the order first met, each once: one member's change shows on several rows. + private readonly List reasons = new List(); + private readonly HashSet reasonSet = new HashSet(StringComparer.Ordinal); + private readonly HashSet producedTypes; + private readonly HashSet unityBuildTypes; + private readonly HashSet producedNamespaces; + private readonly HashSet unityBuildNamespaces; + + private ChangedSurfaceNames(SurfaceDescription produced, SurfaceDescription unityBuild) + { + producedTypes = TypeNames(produced); + unityBuildTypes = TypeNames(unityBuild); + producedNamespaces = NamespacePrefixes(producedTypes); + unityBuildNamespaces = NamespacePrefixes(unityBuildTypes); + } + + // Sorted ordinal, without duplicates; empty when the change is unnamed. + public string[] Names { get; private set; } = Array.Empty(); + + // Why the change cannot be told by names; empty when it can. + public string UnnamedReason { get; private set; } = ""; + + /// + /// Tells two descriptions apart by the rows whose text one holds more times than the other. Call + /// it only for descriptions that differ. + /// + public static ChangedSurfaceNames Collect(SurfaceDescription produced, SurfaceDescription unityBuild) + { + ChangedSurfaceNames result = new ChangedSurfaceNames(produced, unityBuild); + Dictionary producedCounts = Counts(produced); + Dictionary unityBuildCounts = Counts(unityBuild); + bool any = false; + // Why rows are counted rather than walked in order: one member added shifts every row after it. + foreach (SurfaceEntry entry in produced.Entries) + { + if (Count(producedCounts, entry.Text) != Count(unityBuildCounts, entry.Text)) + { + any = true; + result.Read(entry, true); + } + } + foreach (SurfaceEntry entry in unityBuild.Entries) + { + if (Count(producedCounts, entry.Text) != Count(unityBuildCounts, entry.Text)) + { + any = true; + result.Read(entry, false); + } + } + + return result.Finish(any); + } + + private ChangedSurfaceNames Finish(bool anyRowDiffers) + { + if (!anyRowDiffers) + { + UnnamedReason = "the descriptions differ only in order"; + } + else if (names.Count > MaximumNames) + { + UnnamedReason = "too many changed names (" + names.Count + ")"; + } + else if (reasons.Count > 0) + { + UnnamedReason = string.Join("; ", reasons.Take(ReportedReasons)); + } + else + { + Names = names.ToArray(); + } + + return this; + } + + private void Read(SurfaceEntry entry, bool producedSide) + { + SurfaceOwner owner = entry.Owner; + if (owner.Kind == SurfaceOwnerKind.Manifest) + { + AddReason("manifest: " + (entry.Text.Length > ManifestTextLength ? entry.Text.Substring(0, ManifestTextLength) : entry.Text)); + return; + } + if (IsInWellKnownNamespace(owner.TypeName)) + { + AddReason("well-known type " + owner.TypeName); + return; + } + if (producedTypes.Contains(owner.TypeName) != unityBuildTypes.Contains(owner.TypeName)) + { + ReadOneSidedType(owner); + return; + } + + switch (owner.Kind) + { + case SurfaceOwnerKind.AbstractMember when producedSide: + AddReason("abstract member added: " + owner.TypeName + "::" + owner.MemberName); + return; + case SurfaceOwnerKind.Member: + case SurfaceOwnerKind.AbstractMember: + case SurfaceOwnerKind.ExtensionMethod: + AddTypeNames(owner.TypeName); + AddMemberName(owner.MemberName); + return; + default: + // A row of a type both sides have that a dependent can feel without writing a name. + AddReason(owner.Kind + " of " + owner.TypeName); + return; + } + } + + // A type added, removed or renamed: a dependent reaches it only by writing its name or through + // another member of the assembly, whose own row then changes too. An extension method is the + // exception: it binds by pattern on a type it does not belong to. + private void ReadOneSidedType(SurfaceOwner owner) + { + AddTypeNames(owner.TypeName); + if (owner.Kind == SurfaceOwnerKind.ExtensionMethod) + { + AddMemberName(owner.MemberName); + return; + } + if (owner.MemberName.Length == 0 || owner.MemberName.StartsWith(".", StringComparison.Ordinal)) + { + return; + } + + string unqualified = Unqualified(owner.MemberName); + string member = StripAccessorPrefix(unqualified); + if (IsCSharpIdentifier(member) && !member.StartsWith(OperatorPrefix, StringComparison.Ordinal)) + { + names.Add(member); + AddUnstripped(unqualified, member); + } + } + + private void AddReason(string reason) + { + if (reasonSet.Add(reason)) + { + reasons.Add(reason); + } + } + + private void AddMemberName(string member) + { + if (member == ConstructorName) + { + names.Add(TargetTypedNew); + return; + } + if (member == ".cctor" || member == "Finalize") + { + return; + } + + string unqualified = Unqualified(member); + member = StripAccessorPrefix(unqualified); + if (member.StartsWith(OperatorPrefix, StringComparison.Ordinal)) + { + AddReason("operator " + member); + } + else if (!IsCSharpIdentifier(member)) + { + AddReason("compiler-generated member " + member); + } + else if (BoundByPattern.Contains(member)) + { + AddReason("pattern-bound member " + member); + } + else + { + names.Add(member); + AddUnstripped(unqualified, member); + } + } + + // Why the name before the prefix is kept as well: only a member's metadata tells an accessor from + // a method declared as get_Value, which a dependent writes with its prefix. + private void AddUnstripped(string unqualified, string member) + { + if (unqualified != member && IsCSharpIdentifier(unqualified)) + { + names.Add(unqualified); + } + } + + // "A.B.Outer`1/Inner" names Outer and Inner, and A or B only where one side lacks that namespace: + // nearly every dependent writes a using for a namespace both sides have. + private void AddTypeNames(string metadataName) + { + foreach (string nested in metadataName.Split('/')) + { + int arity = nested.IndexOf('`'); + string[] segments = (arity < 0 ? nested : nested.Substring(0, arity)).Split('.'); + string simple = segments[segments.Length - 1]; + names.Add(simple); + if (simple.EndsWith(AttributeSuffix, StringComparison.Ordinal) && simple.Length > AttributeSuffix.Length) + { + names.Add(simple.Substring(0, simple.Length - AttributeSuffix.Length)); + } + for (int index = 0; index < segments.Length - 1; index++) + { + string prefix = string.Join(".", segments, 0, index + 1); + if (producedNamespaces.Contains(prefix) != unityBuildNamespaces.Contains(prefix)) + { + names.Add(segments[index]); + } + } + } + } + + // An explicit implementation "Ns.IFoo.get_P" is written as get_P or P. + private static string Unqualified(string member) + { + int dot = member.LastIndexOf('.'); + + return dot >= 0 ? member.Substring(dot + 1) : member; + } + + private static string StripAccessorPrefix(string member) + { + foreach (string prefix in AccessorPrefixes) + { + if (member.StartsWith(prefix, StringComparison.Ordinal) && member.Length > prefix.Length) + { + return member.Substring(prefix.Length); + } + } + + return member; + } + + // "System.Runtime.CompilerServices.IsExternalInit" and "System.Index/Inner" are in System. + private static bool IsInWellKnownNamespace(string metadataName) + { + string outermost = metadataName.Split('/')[0]; + int dot = outermost.LastIndexOf('.'); + if (dot < 0) + { + return false; + } + + string typeNamespace = outermost.Substring(0, dot); + + return WellKnownNamespaces.Any(root => typeNamespace == root || typeNamespace.StartsWith(root + ".", StringComparison.Ordinal)); + } + + internal static bool IsCSharpIdentifier(string text) + { + if (text.Length == 0 || !(char.IsLetter(text[0]) || text[0] == '_')) + { + return false; + } + + return text.All(character => char.IsLetterOrDigit(character) || character == '_'); + } + + private static HashSet TypeNames(SurfaceDescription description) + { + HashSet types = new HashSet(StringComparer.Ordinal); + foreach (SurfaceEntry entry in description.Entries) + { + if (!string.IsNullOrEmpty(entry.Owner.TypeName)) + { + types.Add(entry.Owner.TypeName); + } + } + + return types; + } + + // The namespaces "A.B.C" and "A.B.C/D" sit in: "A" and "A.B". + private static HashSet NamespacePrefixes(HashSet types) + { + HashSet prefixes = new HashSet(StringComparer.Ordinal); + foreach (string type in types) + { + string outer = type.Split('/')[0]; + int arity = outer.IndexOf('`'); + string[] segments = (arity < 0 ? outer : outer.Substring(0, arity)).Split('.'); + for (int index = 0; index < segments.Length - 1; index++) + { + prefixes.Add(string.Join(".", segments, 0, index + 1)); + } + } + + return prefixes; + } + + private static Dictionary Counts(SurfaceDescription description) + { + Dictionary counts = new Dictionary(StringComparer.Ordinal); + foreach (string text in description.Texts) + { + counts[text] = Count(counts, text) + 1; + } + + return counts; + } + + private static int Count(Dictionary counts, string text) + { + return counts.TryGetValue(text, out int count) ? count : 0; + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/CompareSurfaceCommand.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/CompareSurfaceCommand.cs index 0574bab3e2..d7b04bac5b 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/worker/CompareSurfaceCommand.cs +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/CompareSurfaceCommand.cs @@ -10,20 +10,21 @@ namespace UnityCliLoop.CompileCheckWorker /// Answers whether two reference assemblies of one assembly describe the same surface to every /// assembly that compiles against them. Each is written out as text - its structure with what no /// dependent can see left out, then the effective nullable annotation of every row - and only - /// identical texts are the same surface. Reads metadata alone; no compiler is loaded. + /// identical texts are the same surface. Reads metadata alone; no compiler is loaded. A different + /// surface also says which names its change touches, or why no names can tell it. /// internal static class CompareSurfaceCommand { // Raise whenever what the description writes changes, so a recorded answer is not reused for // a comparison that would now come out differently. - private const string DescriptionVersion = "sv1"; + private const string DescriptionVersion = "sv2"; private const string NullabilitySection = "== nullability"; private const string EndOfDescription = "(end)"; internal static CompareSurfaceResponse Run(CompareSurfaceRequest request) { - List produced; - List unityBuild; + SurfaceDescription produced; + SurfaceDescription unityBuild; // Why every failure answers "different": compiling a dependent that did not need it costs // time, while calling a surface the same without having read it costs a missed error. try @@ -36,14 +37,19 @@ internal static CompareSurfaceResponse Run(CompareSurfaceRequest request) return CompareSurfaceResponse.Different("could not read: " + exception.GetType().Name); } - int length = Math.Max(produced.Count, unityBuild.Count); + IReadOnlyList producedTexts = produced.Texts; + IReadOnlyList unityBuildTexts = unityBuild.Texts; + int length = Math.Max(producedTexts.Count, unityBuildTexts.Count); for (int index = 0; index < length; index++) { - string producedLine = index < produced.Count ? produced[index] : EndOfDescription; - string unityBuildLine = index < unityBuild.Count ? unityBuild[index] : EndOfDescription; + string producedLine = index < producedTexts.Count ? producedTexts[index] : EndOfDescription; + string unityBuildLine = index < unityBuildTexts.Count ? unityBuildTexts[index] : EndOfDescription; if (producedLine != unityBuildLine) { - return CompareSurfaceResponse.Different("produced: " + producedLine + " / Unity build: " + unityBuildLine); + ChangedSurfaceNames changed = ChangedSurfaceNames.Collect(produced, unityBuild); + + return CompareSurfaceResponse.DifferentWithNames( + "produced: " + producedLine + " / Unity build: " + unityBuildLine, changed.Names, changed.UnnamedReason); } } @@ -53,16 +59,17 @@ internal static CompareSurfaceResponse Run(CompareSurfaceRequest request) /// /// Writes the surface description of one reference assembly. /// - internal static List Describe(string path) + internal static SurfaceDescription Describe(string path) { using PEReader peReader = new PEReader(ImmutableArray.Create(File.ReadAllBytes(path))); SurfaceMetadata metadata = new SurfaceMetadata(peReader); - List lines = new List { DescriptionVersion }; - SurfaceStructureWriter.Write(metadata, lines); - lines.Add(NullabilitySection); - SurfaceNullabilityWriter.Write(metadata, lines); + SurfaceDescription description = new SurfaceDescription(); + description.Add(DescriptionVersion, SurfaceOwner.Manifest); + SurfaceStructureWriter.Write(metadata, description); + description.Add(NullabilitySection, SurfaceOwner.Manifest); + SurfaceNullabilityWriter.Write(metadata, description); - return lines; + return description; } } } diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceDescription.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceDescription.cs new file mode 100644 index 0000000000..109d8743dd --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceDescription.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// One line of a surface description and the row it belongs to. + /// + internal readonly struct SurfaceEntry + { + public SurfaceEntry(string text, SurfaceOwner owner) + { + Text = text; + Owner = owner; + } + + public string Text { get; } + + public SurfaceOwner Owner { get; } + } + + /// + /// The surface of one reference assembly written out as lines, in a fixed order, each with the + /// type and member it belongs to. Two assemblies are the same surface exactly when their texts are + /// equal line by line; the owners only say what a difference names. + /// + internal sealed class SurfaceDescription + { + private readonly List texts = new List(); + private readonly List entries = new List(); + + public IReadOnlyList Texts => texts; + + public IReadOnlyList Entries => entries; + + public void Add(string text, SurfaceOwner owner) + { + texts.Add(text); + entries.Add(new SurfaceEntry(text, owner)); + } + + public void AddPrefixed(string prefix, List lines, SurfaceOwner owner) + { + foreach (string line in lines) + { + Add(prefix + line, owner); + } + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceManifestWriter.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceManifestWriter.cs index 6004144728..b5cd15a786 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceManifestWriter.cs +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceManifestWriter.cs @@ -16,26 +16,19 @@ namespace UnityCliLoop.CompileCheckWorker /// internal static class SurfaceManifestWriter { - internal static void Write(SurfaceMetadata metadata, List lines) + // Every row here belongs to the assembly rather than to a type, so no name tells a change of it. + internal static void Write(SurfaceMetadata metadata, SurfaceDescription description) { MetadataReader reader = metadata.Reader; - WriteAssembly(metadata, lines); + WriteAssembly(metadata, description); ModuleDefinition module = reader.GetModuleDefinition(); - lines.Add("module " + reader.GetString(module.Name)); - AddPrefixed(lines, "module ", metadata.AttributeLines(module.GetCustomAttributes())); - lines.AddRange(AssemblyReferenceLines(reader)); - lines.AddRange(ModuleReferenceLines(reader)); - lines.AddRange(FileLines(reader)); - lines.AddRange(ExportedTypeLines(metadata)); - lines.AddRange(ResourceLines(metadata)); - } - - internal static void AddPrefixed(List lines, string prefix, List entries) - { - foreach (string entry in entries) - { - lines.Add(prefix + entry); - } + description.Add("module " + reader.GetString(module.Name), SurfaceOwner.Manifest); + description.AddPrefixed("module ", metadata.AttributeLines(module.GetCustomAttributes()), SurfaceOwner.Manifest); + description.AddPrefixed("", AssemblyReferenceLines(reader), SurfaceOwner.Manifest); + description.AddPrefixed("", ModuleReferenceLines(reader), SurfaceOwner.Manifest); + description.AddPrefixed("", FileLines(reader), SurfaceOwner.Manifest); + description.AddPrefixed("", ExportedTypeLines(metadata), SurfaceOwner.Manifest); + description.AddPrefixed("", ResourceLines(metadata), SurfaceOwner.Manifest); } internal static List SecurityLines(MetadataReader reader, DeclarativeSecurityAttributeHandleCollection attributes) @@ -51,21 +44,21 @@ internal static List SecurityLines(MetadataReader reader, DeclarativeSec return entries; } - private static void WriteAssembly(SurfaceMetadata metadata, List lines) + private static void WriteAssembly(SurfaceMetadata metadata, SurfaceDescription description) { MetadataReader reader = metadata.Reader; if (!reader.IsAssembly) { - lines.Add("no assembly manifest"); + description.Add("no assembly manifest", SurfaceOwner.Manifest); return; } AssemblyDefinition assembly = reader.GetAssemblyDefinition(); - lines.Add("assembly " + reader.GetString(assembly.Name) + " " + assembly.Version + description.Add("assembly " + reader.GetString(assembly.Name) + " " + assembly.Version + " culture=" + reader.GetString(assembly.Culture) + " flags=" + assembly.Flags - + " key=" + SurfaceTypeNames.BlobHex(reader, assembly.PublicKey) + " hash=" + assembly.HashAlgorithm); - AddPrefixed(lines, "assembly ", metadata.AttributeLines(assembly.GetCustomAttributes())); - AddPrefixed(lines, "assembly ", SecurityLines(reader, assembly.GetDeclarativeSecurityAttributes())); + + " key=" + SurfaceTypeNames.BlobHex(reader, assembly.PublicKey) + " hash=" + assembly.HashAlgorithm, SurfaceOwner.Manifest); + description.AddPrefixed("assembly ", metadata.AttributeLines(assembly.GetCustomAttributes()), SurfaceOwner.Manifest); + description.AddPrefixed("assembly ", SecurityLines(reader, assembly.GetDeclarativeSecurityAttributes()), SurfaceOwner.Manifest); } private static List AssemblyReferenceLines(MetadataReader reader) diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceMetadata.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceMetadata.cs index 3462aee6df..1b5df49a26 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceMetadata.cs +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceMetadata.cs @@ -25,6 +25,8 @@ internal sealed class SurfaceMetadata private const string CompilerServicesNamespace = "System.Runtime.CompilerServices"; private const string NullableAttributeName = CompilerServicesNamespace + ".NullableAttribute"; private const string NullableContextAttributeName = CompilerServicesNamespace + ".NullableContextAttribute"; + private const string InterpolatedStringHandlerAttributeName = CompilerServicesNamespace + ".InterpolatedStringHandlerAttribute"; + private const string CollectionBuilderAttributeName = CompilerServicesNamespace + ".CollectionBuilderAttribute"; private const ushort AttributeBlobProlog = 1; private static readonly string[] StateMachineAttributeNames = @@ -124,6 +126,82 @@ public List AttributeLines(CustomAttributeHandleCollection attributes) return lines; } + /// + /// Says whether a row carries an attribute of the given namespace-qualified type, whether the type + /// comes from another assembly or is defined in this one (as a polyfill would be). + /// + public bool HasAttribute(CustomAttributeHandleCollection attributes, string namespaceAndName) + { + foreach (CustomAttributeHandle handle in attributes) + { + if (AttributeTypeName(Reader.GetCustomAttribute(handle)) == namespaceAndName) + { + return true; + } + } + + return false; + } + + /// + /// Says whether the compiler binds the members of a type by pattern without a dependent writing + /// their names: an interpolated string handler or a collection builder target. + /// + public bool IsPatternBoundType(TypeDefinitionHandle handle) + { + CustomAttributeHandleCollection attributes = Reader.GetTypeDefinition(handle).GetCustomAttributes(); + + return HasAttribute(attributes, InterpolatedStringHandlerAttributeName) + || HasAttribute(attributes, CollectionBuilderAttributeName); + } + + /// + /// The namespace-qualified name of a type defined in or referenced by this assembly, without the + /// assembly it comes from; "" for no type and for any other row, such as a constructed generic type. + /// + public string NamespaceAndName(EntityHandle type) + { + if (type.IsNil) + { + return ""; + } + + switch (type.Kind) + { + case HandleKind.TypeDefinition: + return SurfaceTypeNames.Definition(Reader, (TypeDefinitionHandle)type); + case HandleKind.TypeReference: + TypeReference reference = Reader.GetTypeReference((TypeReferenceHandle)type); + string space = Reader.GetString(reference.Namespace); + string name = Reader.GetString(reference.Name); + + return space.Length == 0 ? name : space + "." + name; + default: + return ""; + } + } + + // The namespace-qualified name of an attribute's type, or "" when its constructor is not a method + // of a type this reader can name. + private string AttributeTypeName(CustomAttribute attribute) + { + EntityHandle type; + if (attribute.Constructor.Kind == HandleKind.MethodDefinition) + { + type = Reader.GetMethodDefinition((MethodDefinitionHandle)attribute.Constructor).GetDeclaringType(); + } + else if (attribute.Constructor.Kind == HandleKind.MemberReference) + { + type = Reader.GetMemberReference((MemberReferenceHandle)attribute.Constructor).Parent; + } + else + { + return ""; + } + + return NamespaceAndName(type); + } + private static HashSet FindEmbeddedTypes(MetadataReader reader) { HashSet embedded = new HashSet(); diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceNullabilityWriter.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceNullabilityWriter.cs index ee0404832e..cf3175e5fe 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceNullabilityWriter.cs +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceNullabilityWriter.cs @@ -30,14 +30,14 @@ internal static class SurfaceNullabilityWriter private static readonly HandleKind[] ContextRowKinds = { HandleKind.TypeDefinition, HandleKind.MethodDefinition }; - internal static void Write(SurfaceMetadata metadata, List lines) + internal static void Write(SurfaceMetadata metadata, SurfaceDescription description) { RejectUnexpectedRows(metadata); foreach (TypeDefinitionHandle handle in metadata.Reader.TypeDefinitions) { if (!metadata.IsLeftOut(handle)) { - WriteType(metadata, handle, lines); + WriteType(metadata, handle, description); } } } @@ -59,16 +59,17 @@ private static void RejectUnexpectedRows(SurfaceMetadata metadata) } } - private static void WriteType(SurfaceMetadata metadata, TypeDefinitionHandle handle, List lines) + private static void WriteType(SurfaceMetadata metadata, TypeDefinitionHandle handle, SurfaceDescription description) { MetadataReader reader = metadata.Reader; TypeDefinition type = reader.GetTypeDefinition(handle); - string name = SurfaceTypeNames.Definition(reader, handle); + SurfaceTypeOwners owners = new SurfaceTypeOwners(metadata, handle); + string name = owners.TypeName; byte typeContext = TypeContext(metadata, handle); // The annotation of the base type sits on the type's own row. if (!type.BaseType.IsNil) { - lines.Add("base " + name + " " + Effective(metadata, type.GetCustomAttributes(), typeContext)); + description.Add("base " + name + " " + Effective(metadata, type.GetCustomAttributes(), typeContext), owners.Shape()); } List interfaces = new List(); foreach (InterfaceImplementationHandle interfaceHandle in type.GetInterfaceImplementations()) @@ -78,20 +79,23 @@ private static void WriteType(SurfaceMetadata metadata, TypeDefinitionHandle han + " " + Effective(metadata, implementation.GetCustomAttributes(), typeContext)); } interfaces.Sort(StringComparer.Ordinal); - lines.AddRange(interfaces); - WriteGenericParameters(metadata, type.GetGenericParameters(), "gp " + name, typeContext, lines); - WriteMembers(metadata, type, name, typeContext, lines); + description.AddPrefixed("", interfaces, owners.Shape()); + WriteGenericParameters(metadata, type.GetGenericParameters(), "gp " + name, typeContext, owners.Shape(), description); + WriteMembers(metadata, type, owners, typeContext, description); } - private static void WriteMembers(SurfaceMetadata metadata, TypeDefinition type, string name, byte typeContext, List lines) + private static void WriteMembers( + SurfaceMetadata metadata, TypeDefinition type, SurfaceTypeOwners owners, byte typeContext, SurfaceDescription description) { MetadataReader reader = metadata.Reader; + string name = owners.TypeName; foreach (FieldDefinitionHandle fieldHandle in type.GetFields()) { FieldDefinition field = reader.GetFieldDefinition(fieldHandle); if (field.DecodeSignature(metadata.Provider, null).HasNullablePosition) { - lines.Add("field " + name + "::" + reader.GetString(field.Name) + " " + Effective(metadata, field.GetCustomAttributes(), typeContext)); + description.Add("field " + name + "::" + reader.GetString(field.Name) + " " + Effective(metadata, field.GetCustomAttributes(), typeContext), + owners.Field(fieldHandle)); } } foreach (PropertyDefinitionHandle propertyHandle in type.GetProperties()) @@ -99,28 +103,31 @@ private static void WriteMembers(SurfaceMetadata metadata, TypeDefinition type, PropertyDefinition property = reader.GetPropertyDefinition(propertyHandle); if (property.DecodeSignature(metadata.Provider, null).ReturnType.HasNullablePosition) { - lines.Add("property " + name + "::" + reader.GetString(property.Name) + " " + Effective(metadata, property.GetCustomAttributes(), typeContext)); + description.Add("property " + name + "::" + reader.GetString(property.Name) + " " + Effective(metadata, property.GetCustomAttributes(), typeContext), + owners.Property(propertyHandle)); } } foreach (EventDefinitionHandle eventHandle in type.GetEvents()) { EventDefinition eventDefinition = reader.GetEventDefinition(eventHandle); - lines.Add("event " + name + "::" + reader.GetString(eventDefinition.Name) + " " + Effective(metadata, eventDefinition.GetCustomAttributes(), typeContext)); + description.Add("event " + name + "::" + reader.GetString(eventDefinition.Name) + " " + Effective(metadata, eventDefinition.GetCustomAttributes(), typeContext), + owners.Event(eventHandle)); } foreach (MethodDefinitionHandle methodHandle in type.GetMethods()) { - WriteMethod(metadata, methodHandle, typeContext, lines); + WriteMethod(metadata, methodHandle, typeContext, owners.Method(methodHandle), description); } } - private static void WriteMethod(SurfaceMetadata metadata, MethodDefinitionHandle handle, byte typeContext, List lines) + private static void WriteMethod( + SurfaceMetadata metadata, MethodDefinitionHandle handle, byte typeContext, SurfaceOwner owner, SurfaceDescription description) { MetadataReader reader = metadata.Reader; MethodDefinition method = reader.GetMethodDefinition(handle); string name = metadata.MethodName(handle); byte? ownContext = OwnContext(metadata, method.GetCustomAttributes()); byte context = ownContext ?? typeContext; - WriteGenericParameters(metadata, method.GetGenericParameters(), "gp " + name, context, lines); + WriteGenericParameters(metadata, method.GetGenericParameters(), "gp " + name, context, owner, description); Dictionary rows = new Dictionary(); foreach (ParameterHandle parameterHandle in method.GetParameters()) @@ -131,15 +138,15 @@ private static void WriteMethod(SurfaceMetadata metadata, MethodDefinitionHandle MethodSignature signature = method.DecodeSignature(metadata.Provider, null); if (signature.ReturnType.HasNullablePosition) { - lines.Add("ret " + name + " " + Effective(metadata, RowAttributes(rows, ReturnParameterSequence), context)); + description.Add("ret " + name + " " + Effective(metadata, RowAttributes(rows, ReturnParameterSequence), context), owner); } for (int index = 0; index < signature.ParameterTypes.Length; index++) { if (signature.ParameterTypes[index].HasNullablePosition) { int sequence = index + 1; - lines.Add("param " + name + "#" + sequence.ToString(CultureInfo.InvariantCulture) - + " " + Effective(metadata, RowAttributes(rows, sequence), context)); + description.Add("param " + name + "#" + sequence.ToString(CultureInfo.InvariantCulture) + + " " + Effective(metadata, RowAttributes(rows, sequence), context), owner); } } } @@ -153,15 +160,15 @@ private static IEnumerable RowAttributes(Dictionary lines) + private static void WriteGenericParameters(SurfaceMetadata metadata, GenericParameterHandleCollection parameters, + string owner, byte context, SurfaceOwner rowOwner, SurfaceDescription description) { MetadataReader reader = metadata.Reader; foreach (GenericParameterHandle handle in parameters) { GenericParameter parameter = reader.GetGenericParameter(handle); string name = owner + "!" + parameter.Index.ToString(CultureInfo.InvariantCulture); - lines.Add(name + " " + Effective(metadata, parameter.GetCustomAttributes(), context)); + description.Add(name + " " + Effective(metadata, parameter.GetCustomAttributes(), context), rowOwner); List constraints = new List(); foreach (GenericParameterConstraintHandle constraintHandle in parameter.GetConstraints()) { @@ -170,7 +177,7 @@ private static void WriteGenericParameters( + " " + Effective(metadata, constraint.GetCustomAttributes(), context)); } constraints.Sort(StringComparer.Ordinal); - lines.AddRange(constraints); + description.AddPrefixed("", constraints, rowOwner); } } diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceOwner.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceOwner.cs new file mode 100644 index 0000000000..8907ccfc62 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceOwner.cs @@ -0,0 +1,87 @@ +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// What kind of row of a surface description a line is, as far as telling a change by names goes. + /// + internal enum SurfaceOwnerKind + { + // First, so a row added without an owner reads as one no name tells. + Manifest, + Type, + TypeShape, + PatternBoundType, + ValueTypeInstanceField, + ParameterlessConstructor, + Indexer, + AbstractMember, + ExtensionMethod, + Member, + } + + /// + /// The type and member a line of a surface description belongs to, and what kind of row it is. + /// An empty type name is a row of the assembly itself; an empty member name is a row of the type. + /// + internal readonly struct SurfaceOwner + { + private SurfaceOwner(string typeName, string memberName, SurfaceOwnerKind kind) + { + TypeName = typeName; + MemberName = memberName; + Kind = kind; + } + + public string TypeName { get; } + + public string MemberName { get; } + + public SurfaceOwnerKind Kind { get; } + + public static SurfaceOwner Manifest => new SurfaceOwner("", "", SurfaceOwnerKind.Manifest); + + public static SurfaceOwner ForType(string typeName) + { + return new SurfaceOwner(typeName, "", SurfaceOwnerKind.Type); + } + + public static SurfaceOwner ForTypeShape(string typeName) + { + return new SurfaceOwner(typeName, "", SurfaceOwnerKind.TypeShape); + } + + public static SurfaceOwner ForPatternBoundType(string typeName) + { + return new SurfaceOwner(typeName, "", SurfaceOwnerKind.PatternBoundType); + } + + public static SurfaceOwner ForMember(string typeName, string memberName) + { + return new SurfaceOwner(typeName, memberName, SurfaceOwnerKind.Member); + } + + public static SurfaceOwner ForAbstractMember(string typeName, string memberName) + { + return new SurfaceOwner(typeName, memberName, SurfaceOwnerKind.AbstractMember); + } + + public static SurfaceOwner ForExtensionMethod(string typeName, string memberName) + { + return new SurfaceOwner(typeName, memberName, SurfaceOwnerKind.ExtensionMethod); + } + + public static SurfaceOwner ForValueTypeInstanceField(string typeName, string memberName) + { + return new SurfaceOwner(typeName, memberName, SurfaceOwnerKind.ValueTypeInstanceField); + } + + public static SurfaceOwner ForParameterlessConstructor(string typeName) + { + return new SurfaceOwner(typeName, ".ctor", SurfaceOwnerKind.ParameterlessConstructor); + } + + public static SurfaceOwner ForIndexer(string typeName, string memberName) + { + return new SurfaceOwner(typeName, memberName, SurfaceOwnerKind.Indexer); + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceStructureWriter.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceStructureWriter.cs index 26456b5066..2a47ec9b05 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceStructureWriter.cs +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceStructureWriter.cs @@ -14,46 +14,51 @@ namespace UnityCliLoop.CompileCheckWorker /// internal static class SurfaceStructureWriter { - internal static void Write(SurfaceMetadata metadata, List lines) + internal static void Write(SurfaceMetadata metadata, SurfaceDescription description) { - SurfaceManifestWriter.Write(metadata, lines); + SurfaceManifestWriter.Write(metadata, description); foreach (TypeDefinitionHandle handle in metadata.Reader.TypeDefinitions) { if (!metadata.IsLeftOut(handle)) { - WriteType(metadata, handle, lines); + WriteType(metadata, handle, description); } } } - private static void WriteType(SurfaceMetadata metadata, TypeDefinitionHandle handle, List lines) + // Why the base type has a row of its own: a change of it alone then reads as a change of the + // type's shape, apart from its flags and layout. + private static void WriteType(SurfaceMetadata metadata, TypeDefinitionHandle handle, SurfaceDescription description) { MetadataReader reader = metadata.Reader; TypeDefinition type = reader.GetTypeDefinition(handle); - string name = SurfaceTypeNames.Definition(reader, handle); + SurfaceTypeOwners owners = new SurfaceTypeOwners(metadata, handle); + string name = owners.TypeName; TypeLayout layout = type.GetLayout(); - lines.Add("type " + name + " flags=" + type.Attributes + " base=" + metadata.TypeName(type.BaseType) - + " layout=" + layout.PackingSize.ToString(CultureInfo.InvariantCulture) + "," + layout.Size.ToString(CultureInfo.InvariantCulture)); - WriteGenericParameters(metadata, type.GetGenericParameters(), name, lines); - SurfaceManifestWriter.AddPrefixed(lines, name + " ", InterfaceEntries(metadata, type)); - SurfaceManifestWriter.AddPrefixed(lines, name + " ", SurfaceManifestWriter.SecurityLines(reader, type.GetDeclarativeSecurityAttributes())); - SurfaceManifestWriter.AddPrefixed(lines, name + " ", metadata.AttributeLines(type.GetCustomAttributes())); - SurfaceManifestWriter.AddPrefixed(lines, name + " ", ImplementationEntries(metadata, type)); + description.Add("type " + name + " flags=" + type.Attributes + + " layout=" + layout.PackingSize.ToString(CultureInfo.InvariantCulture) + "," + layout.Size.ToString(CultureInfo.InvariantCulture), + owners.Type()); + description.Add(name + " base " + metadata.TypeName(type.BaseType), owners.Shape()); + WriteGenericParameters(metadata, type.GetGenericParameters(), name, owners.Shape(), description); + description.AddPrefixed(name + " ", InterfaceEntries(metadata, type), owners.Shape()); + description.AddPrefixed(name + " ", SurfaceManifestWriter.SecurityLines(reader, type.GetDeclarativeSecurityAttributes()), owners.Type()); + description.AddPrefixed(name + " ", metadata.AttributeLines(type.GetCustomAttributes()), owners.Type()); + description.AddPrefixed(name + " ", ImplementationEntries(metadata, type), owners.Type()); foreach (FieldDefinitionHandle field in type.GetFields()) { - WriteField(metadata, name, field, lines); + WriteField(metadata, name, field, owners.Field(field), description); } foreach (MethodDefinitionHandle method in type.GetMethods()) { - WriteMethod(metadata, method, lines); + WriteMethod(metadata, method, owners.Method(method), description); } foreach (PropertyDefinitionHandle property in type.GetProperties()) { - WriteProperty(metadata, name, property, lines); + WriteProperty(metadata, name, property, owners.Property(property), description); } foreach (EventDefinitionHandle eventHandle in type.GetEvents()) { - WriteEvent(metadata, name, eventHandle, lines); + WriteEvent(metadata, name, eventHandle, owners.Event(eventHandle), description); } } @@ -85,16 +90,16 @@ private static List ImplementationEntries(SurfaceMetadata metadata, Type return entries; } - private static void WriteGenericParameters( - SurfaceMetadata metadata, GenericParameterHandleCollection parameters, string owner, List lines) + private static void WriteGenericParameters(SurfaceMetadata metadata, GenericParameterHandleCollection parameters, + string owner, SurfaceOwner rowOwner, SurfaceDescription description) { MetadataReader reader = metadata.Reader; foreach (GenericParameterHandle handle in parameters) { GenericParameter parameter = reader.GetGenericParameter(handle); string name = owner + " !" + parameter.Index.ToString(CultureInfo.InvariantCulture); - lines.Add(name + " " + reader.GetString(parameter.Name) + " flags=" + parameter.Attributes); - SurfaceManifestWriter.AddPrefixed(lines, name + " ", metadata.AttributeLines(parameter.GetCustomAttributes())); + description.Add(name + " " + reader.GetString(parameter.Name) + " flags=" + parameter.Attributes, rowOwner); + description.AddPrefixed(name + " ", metadata.AttributeLines(parameter.GetCustomAttributes()), rowOwner); List constraints = new List(); foreach (GenericParameterConstraintHandle constraintHandle in parameter.GetConstraints()) { @@ -103,36 +108,37 @@ private static void WriteGenericParameters( + " [" + string.Join("; ", metadata.AttributeLines(constraint.GetCustomAttributes())) + "]"); } constraints.Sort(StringComparer.Ordinal); - SurfaceManifestWriter.AddPrefixed(lines, name + " ", constraints); + description.AddPrefixed(name + " ", constraints, rowOwner); } } - private static void WriteField(SurfaceMetadata metadata, string typeName, FieldDefinitionHandle handle, List lines) + private static void WriteField( + SurfaceMetadata metadata, string typeName, FieldDefinitionHandle handle, SurfaceOwner owner, SurfaceDescription description) { MetadataReader reader = metadata.Reader; FieldDefinition field = reader.GetFieldDefinition(handle); string name = "field " + typeName + "::" + reader.GetString(field.Name); - lines.Add(name + " flags=" + field.Attributes + " type=" + field.DecodeSignature(metadata.Provider, null).Name + description.Add(name + " flags=" + field.Attributes + " type=" + field.DecodeSignature(metadata.Provider, null).Name + " const=" + ConstantText(reader, field.GetDefaultValue()) + " marshal=" + SurfaceTypeNames.BlobHex(reader, field.GetMarshallingDescriptor()) + " offset=" + field.GetOffset().ToString(CultureInfo.InvariantCulture) - + " rva=" + (field.GetRelativeVirtualAddress() != 0)); - SurfaceManifestWriter.AddPrefixed(lines, name + " ", metadata.AttributeLines(field.GetCustomAttributes())); + + " rva=" + (field.GetRelativeVirtualAddress() != 0), owner); + description.AddPrefixed(name + " ", metadata.AttributeLines(field.GetCustomAttributes()), owner); } - private static void WriteMethod(SurfaceMetadata metadata, MethodDefinitionHandle handle, List lines) + private static void WriteMethod(SurfaceMetadata metadata, MethodDefinitionHandle handle, SurfaceOwner owner, SurfaceDescription description) { MetadataReader reader = metadata.Reader; MethodDefinition method = reader.GetMethodDefinition(handle); string name = "method " + metadata.MethodName(handle); - lines.Add(name + " flags=" + method.Attributes + " impl=" + method.ImplAttributes + ImportText(reader, method.GetImport())); - WriteGenericParameters(metadata, method.GetGenericParameters(), name, lines); + description.Add(name + " flags=" + method.Attributes + " impl=" + method.ImplAttributes + ImportText(reader, method.GetImport()), owner); + WriteGenericParameters(metadata, method.GetGenericParameters(), name, owner, description); foreach (ParameterHandle parameterHandle in method.GetParameters()) { - lines.Add(name + " " + ParameterLine(metadata, reader.GetParameter(parameterHandle))); + description.Add(name + " " + ParameterLine(metadata, reader.GetParameter(parameterHandle)), owner); } - SurfaceManifestWriter.AddPrefixed(lines, name + " ", SurfaceManifestWriter.SecurityLines(reader, method.GetDeclarativeSecurityAttributes())); - SurfaceManifestWriter.AddPrefixed(lines, name + " ", metadata.AttributeLines(method.GetCustomAttributes())); + description.AddPrefixed(name + " ", SurfaceManifestWriter.SecurityLines(reader, method.GetDeclarativeSecurityAttributes()), owner); + description.AddPrefixed(name + " ", metadata.AttributeLines(method.GetCustomAttributes()), owner); } // One parameter row, the return value's included. Why a return-value row that only carries a @@ -151,31 +157,33 @@ private static string ParameterLine(SurfaceMetadata metadata, Parameter paramete + " [" + string.Join("; ", attributes) + "]"; } - private static void WriteProperty(SurfaceMetadata metadata, string typeName, PropertyDefinitionHandle handle, List lines) + private static void WriteProperty( + SurfaceMetadata metadata, string typeName, PropertyDefinitionHandle handle, SurfaceOwner owner, SurfaceDescription description) { MetadataReader reader = metadata.Reader; PropertyDefinition property = reader.GetPropertyDefinition(handle); PropertyAccessors accessors = property.GetAccessors(); string name = "property " + typeName + "::" + reader.GetString(property.Name); - lines.Add(name + " flags=" + property.Attributes + description.Add(name + " flags=" + property.Attributes + " sig=" + SurfaceTypeNames.Signature(property.DecodeSignature(metadata.Provider, null)) + " const=" + ConstantText(reader, property.GetDefaultValue()) + " get=" + AccessorName(metadata, accessors.Getter) + " set=" + AccessorName(metadata, accessors.Setter) - + " other=[" + string.Join("; ", OtherAccessorNames(metadata, accessors.Others)) + "]"); - SurfaceManifestWriter.AddPrefixed(lines, name + " ", metadata.AttributeLines(property.GetCustomAttributes())); + + " other=[" + string.Join("; ", OtherAccessorNames(metadata, accessors.Others)) + "]", owner); + description.AddPrefixed(name + " ", metadata.AttributeLines(property.GetCustomAttributes()), owner); } - private static void WriteEvent(SurfaceMetadata metadata, string typeName, EventDefinitionHandle handle, List lines) + private static void WriteEvent( + SurfaceMetadata metadata, string typeName, EventDefinitionHandle handle, SurfaceOwner owner, SurfaceDescription description) { MetadataReader reader = metadata.Reader; EventDefinition eventDefinition = reader.GetEventDefinition(handle); EventAccessors accessors = eventDefinition.GetAccessors(); string name = "event " + typeName + "::" + reader.GetString(eventDefinition.Name); - lines.Add(name + " flags=" + eventDefinition.Attributes + " type=" + metadata.TypeName(eventDefinition.Type) + description.Add(name + " flags=" + eventDefinition.Attributes + " type=" + metadata.TypeName(eventDefinition.Type) + " add=" + AccessorName(metadata, accessors.Adder) + " remove=" + AccessorName(metadata, accessors.Remover) + " raise=" + AccessorName(metadata, accessors.Raiser) - + " other=[" + string.Join("; ", OtherAccessorNames(metadata, accessors.Others)) + "]"); - SurfaceManifestWriter.AddPrefixed(lines, name + " ", metadata.AttributeLines(eventDefinition.GetCustomAttributes())); + + " other=[" + string.Join("; ", OtherAccessorNames(metadata, accessors.Others)) + "]", owner); + description.AddPrefixed(name + " ", metadata.AttributeLines(eventDefinition.GetCustomAttributes()), owner); } private static string AccessorName(SurfaceMetadata metadata, MethodDefinitionHandle handle) diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceTypeOwners.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceTypeOwners.cs new file mode 100644 index 0000000000..2ecbfe3a78 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceTypeOwners.cs @@ -0,0 +1,149 @@ +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Metadata; + +namespace UnityCliLoop.CompileCheckWorker +{ + /// + /// Decides which owner each row of one type gets, so the structure and the nullability descriptions + /// file the same member under the same kind. A row whose change a dependent can feel without writing + /// the member's name gets a kind of its own; every row of a type the compiler binds by pattern gets + /// PatternBoundType. + /// + internal sealed class SurfaceTypeOwners + { + private const string ConstructorName = ".ctor"; + private const string ExtensionAttributeName = "System.Runtime.CompilerServices.ExtensionAttribute"; + private const string ValueTypeName = "System.ValueType"; + private const string EnumTypeName = "System.Enum"; + + private readonly SurfaceMetadata metadata; + private readonly string typeName; + private readonly bool patternBound; + private readonly bool valueType; + private readonly HashSet indexerAccessors = new HashSet(); + + public SurfaceTypeOwners(SurfaceMetadata metadata, TypeDefinitionHandle handle) + { + this.metadata = metadata; + MetadataReader reader = metadata.Reader; + TypeDefinition type = reader.GetTypeDefinition(handle); + typeName = SurfaceTypeNames.Definition(reader, handle); + patternBound = metadata.IsPatternBoundType(handle); + string baseType = metadata.NamespaceAndName(type.BaseType); + valueType = baseType == ValueTypeName || baseType == EnumTypeName; + foreach (PropertyDefinitionHandle propertyHandle in type.GetProperties()) + { + if (IsIndexer(propertyHandle)) + { + AddAccessors(reader.GetPropertyDefinition(propertyHandle).GetAccessors()); + } + } + } + + public string TypeName => typeName; + + // The type's own row and what hangs off it without shaping it: attributes, security, explicit + // implementations. + public SurfaceOwner Type() + { + return patternBound ? SurfaceOwner.ForPatternBoundType(typeName) : SurfaceOwner.ForType(typeName); + } + + // The base type, interfaces, and type parameters with their constraints. + public SurfaceOwner Shape() + { + return patternBound ? SurfaceOwner.ForPatternBoundType(typeName) : SurfaceOwner.ForTypeShape(typeName); + } + + public SurfaceOwner Method(MethodDefinitionHandle handle) + { + if (patternBound) + { + return SurfaceOwner.ForPatternBoundType(typeName); + } + + MethodDefinition method = metadata.Reader.GetMethodDefinition(handle); + string name = metadata.Reader.GetString(method.Name); + if (indexerAccessors.Contains(handle)) + { + return SurfaceOwner.ForIndexer(typeName, name); + } + if (name == ConstructorName && method.DecodeSignature(metadata.Provider, null).ParameterTypes.Length == 0) + { + return SurfaceOwner.ForParameterlessConstructor(typeName); + } + if (metadata.HasAttribute(method.GetCustomAttributes(), ExtensionAttributeName)) + { + return SurfaceOwner.ForExtensionMethod(typeName, name); + } + if ((method.Attributes & MethodAttributes.Abstract) != 0) + { + return SurfaceOwner.ForAbstractMember(typeName, name); + } + + return SurfaceOwner.ForMember(typeName, name); + } + + public SurfaceOwner Field(FieldDefinitionHandle handle) + { + if (patternBound) + { + return SurfaceOwner.ForPatternBoundType(typeName); + } + + FieldDefinition field = metadata.Reader.GetFieldDefinition(handle); + string name = metadata.Reader.GetString(field.Name); + if (valueType && (field.Attributes & FieldAttributes.Static) == 0) + { + return SurfaceOwner.ForValueTypeInstanceField(typeName, name); + } + + return SurfaceOwner.ForMember(typeName, name); + } + + public SurfaceOwner Property(PropertyDefinitionHandle handle) + { + if (patternBound) + { + return SurfaceOwner.ForPatternBoundType(typeName); + } + + string name = metadata.Reader.GetString(metadata.Reader.GetPropertyDefinition(handle).Name); + + return IsIndexer(handle) ? SurfaceOwner.ForIndexer(typeName, name) : SurfaceOwner.ForMember(typeName, name); + } + + public SurfaceOwner Event(EventDefinitionHandle handle) + { + if (patternBound) + { + return SurfaceOwner.ForPatternBoundType(typeName); + } + + return SurfaceOwner.ForMember(typeName, metadata.Reader.GetString(metadata.Reader.GetEventDefinition(handle).Name)); + } + + // A property with parameters, whatever IndexerName called it. + private bool IsIndexer(PropertyDefinitionHandle handle) + { + return metadata.Reader.GetPropertyDefinition(handle).DecodeSignature(metadata.Provider, null).ParameterTypes.Length > 0; + } + + private void AddAccessors(PropertyAccessors accessors) + { + if (!accessors.Getter.IsNil) + { + indexerAccessors.Add(accessors.Getter); + } + if (!accessors.Setter.IsNil) + { + indexerAccessors.Add(accessors.Setter); + } + foreach (MethodDefinitionHandle other in accessors.Others) + { + indexerAccessors.Add(other); + } + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerResponse.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerResponse.cs index 137eb4cdd4..be10a3a5c8 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerResponse.cs +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/WorkerResponse.cs @@ -60,6 +60,13 @@ internal sealed class CompareSurfaceResponse // The first line the two descriptions disagree on, or why they could not be read; empty for "same". public string Detail { get; set; } = ""; + // The identifiers a dependent has to write to be affected, sorted ordinal; empty for "same", for an + // unnamed change, and when the descriptions could not be read. + public string[] ChangedNames { get; set; } = System.Array.Empty(); + + // Why the change cannot be told by names; empty for "same" and for a named change. + public string UnnamedReason { get; set; } = ""; + public static CompareSurfaceResponse Same() { return new CompareSurfaceResponse { Outcome = SameOutcome }; @@ -69,6 +76,18 @@ public static CompareSurfaceResponse Different(string detail) { return new CompareSurfaceResponse { Outcome = DifferentOutcome, Detail = detail }; } + + // A difference whose descriptions were read: names, or the reason no names tell it. + public static CompareSurfaceResponse DifferentWithNames(string detail, string[] changedNames, string unnamedReason) + { + return new CompareSurfaceResponse + { + Outcome = DifferentOutcome, + Detail = detail, + ChangedNames = changedNames, + UnnamedReason = unnamedReason, + }; + } } /// diff --git a/cli/dispatcher/internal/compilecheck/resultstore/surface.go b/cli/dispatcher/internal/compilecheck/resultstore/surface.go index 948eb92ac4..7d40df5687 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/surface.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/surface.go @@ -9,7 +9,8 @@ import ( const ( // surfaceRecordFormatVersion guards against reading a verdict an older or newer build recorded. - surfaceRecordFormatVersion = 1 + // Version 1 had no changed names. + surfaceRecordFormatVersion = 2 surfaceRecordExtension = ".surface.json" ) @@ -27,6 +28,9 @@ type SurfaceInputs struct { type SurfaceVerdict struct { Same bool Detail string + // ChangedNames and UnnamedReason are what a different answer said its change touches. + ChangedNames []string + UnnamedReason string } // surfaceRecord is one recorded surface verdict with the comparison it answered. @@ -37,6 +41,8 @@ type surfaceRecord struct { WorkerKey string Same bool Detail string + ChangedNames []string + UnnamedReason string } func surfaceRecordPath(directoryPath string, assembly string) string { @@ -65,7 +71,9 @@ func (s Store[D]) ReusableSurfaceVerdict(assembly string, inputs SurfaceInputs) return SurfaceVerdict{}, false } - return SurfaceVerdict{Same: record.Same, Detail: record.Detail}, true + return SurfaceVerdict{ + Same: record.Same, Detail: record.Detail, ChangedNames: record.ChangedNames, UnnamedReason: record.UnnamedReason, + }, true } // RecordSurfaceVerdict records what the worker answered for one comparison of the assembly's @@ -78,6 +86,8 @@ func (s Store[D]) RecordSurfaceVerdict(assembly string, inputs SurfaceInputs, ve WorkerKey: inputs.WorkerKey, Same: verdict.Same, Detail: verdict.Detail, + ChangedNames: verdict.ChangedNames, + UnnamedReason: verdict.UnnamedReason, }) if err != nil { return fmt.Errorf("failed to encode the surface verdict of %s: %w", assembly, err) diff --git a/cli/dispatcher/internal/compilecheck/resultstore/surface_test.go b/cli/dispatcher/internal/compilecheck/resultstore/surface_test.go index f2f34d425a..b6a2ce1b40 100644 --- a/cli/dispatcher/internal/compilecheck/resultstore/surface_test.go +++ b/cli/dispatcher/internal/compilecheck/resultstore/surface_test.go @@ -3,6 +3,7 @@ package resultstore import ( "os" "path/filepath" + "reflect" "testing" ) @@ -27,7 +28,7 @@ func TestReusableSurfaceVerdictReadsBackTheRecordedVerdict(t *testing.T) { got, found := directoryStore(directoryPath).ReusableSurfaceVerdict("Alpha", comparedInputs()) - if !found || got != (SurfaceVerdict{Same: true, Detail: "detail"}) { + if !found || !reflect.DeepEqual(got, SurfaceVerdict{Same: true, Detail: "detail"}) { t.Errorf("expected the recorded verdict, got %+v (found %v)", got, found) } } @@ -51,12 +52,46 @@ func TestReusableSurfaceVerdictRejectsAnotherComparison(t *testing.T) { } } +// Verifies a changed verdict is read back with its changed names and its unnamed reason. +func TestReusableSurfaceVerdictReadsBackChangedNames(t *testing.T) { + directoryPath := t.TempDir() + recorded := []SurfaceVerdict{ + {Detail: "named", ChangedNames: []string{"C", "M"}}, + {Detail: "unnamed", UnnamedReason: "operator op_Addition"}, + } + for _, verdict := range recorded { + if err := directoryStore(directoryPath).RecordSurfaceVerdict("Alpha", comparedInputs(), verdict); err != nil { + t.Fatalf("failed to record the surface verdict: %v", err) + } + + got, found := directoryStore(directoryPath).ReusableSurfaceVerdict("Alpha", comparedInputs()) + + if !found || !reflect.DeepEqual(got, verdict) { + t.Errorf("expected %+v back, got %+v (found %v)", verdict, got, found) + } + } +} + +// Verifies a record written before verdicts carried changed names is not used, so a change it +// recorded is asked again rather than read as naming nothing. +func TestReusableSurfaceVerdictRejectsAVersionOneRecord(t *testing.T) { + directoryPath := t.TempDir() + content := `{"FormatVersion":1,"ProducedSha256":"produced","UnityBuildSha256":"unity","WorkerKey":"worker","Same":false,"Detail":"d"}` + if err := os.WriteFile(filepath.Join(directoryPath, "Alpha"+surfaceRecordExtension), []byte(content), 0o600); err != nil { + t.Fatalf("failed to write the record: %v", err) + } + + if _, found := directoryStore(directoryPath).ReusableSurfaceVerdict("Alpha", comparedInputs()); found { + t.Error("expected a version 1 record not to be used") + } +} + // Verifies a record that is not one this build writes is not used. func TestReusableSurfaceVerdictRejectsAnUnreadableRecord(t *testing.T) { directoryPath := t.TempDir() for name, content := range map[string]string{ "not json": "not json", - "other version": `{"FormatVersion":2,"ProducedSha256":"produced","UnityBuildSha256":"unity","WorkerKey":"worker","Same":true}`, + "other version": `{"FormatVersion":3,"ProducedSha256":"produced","UnityBuildSha256":"unity","WorkerKey":"worker","Same":true}`, } { if err := os.WriteFile(filepath.Join(directoryPath, "Alpha"+surfaceRecordExtension), []byte(content), 0o600); err != nil { t.Fatalf("failed to write the record: %v", err) diff --git a/cli/dispatcher/internal/compilecheck/surface_verdict.go b/cli/dispatcher/internal/compilecheck/surface_verdict.go index b0ccd81eba..514b5035b7 100644 --- a/cli/dispatcher/internal/compilecheck/surface_verdict.go +++ b/cli/dispatcher/internal/compilecheck/surface_verdict.go @@ -46,6 +46,18 @@ type SurfaceVerdict struct { FromRecord bool // Duration is how long asking took, record lookup included. Duration time.Duration + // ChangedNames are the identifiers a dependent has to write to be affected by the change, sorted; + // empty when kept and when unnamed. + ChangedNames []string + // Unnamed says why the change cannot be told by names; empty when kept and when named. + Unnamed string +} + +// namedChange reports whether a dependent that writes none of the verdict's names can treat the +// reference as kept. Every other verdict that is not kept - unnamed, not compared, the zero value +// of a unit that failed or has no dependents - affects every dependent. +func (v SurfaceVerdict) namedChange() bool { + return !v.Kept && v.Unnamed == "" && len(v.ChangedNames) > 0 } // surfaceComparer is what the surface comparison needs from the worker. @@ -69,18 +81,18 @@ type surfaceComparer interface { // it costs time, while skipping one that did costs a missed error. func (c Compiler) referenceSurfaceVerdict(ctx context.Context, plan BuildPlan, unit CompileUnit) SurfaceVerdict { if unit.Assembly.RefOutputPath == "" { - return SurfaceVerdict{Reason: SurfaceCouldNotReadPrefix + "the assembly has no reference assembly"} + return unnamedVerdict(SurfaceCouldNotReadPrefix + "the assembly has no reference assembly") } producedPath := filepath.Join(c.ProjectRoot, plan.OutputDir, filepath.Base(unit.Assembly.RefOutputPath)) produced, err := os.ReadFile(producedPath) if err != nil { - return SurfaceVerdict{Reason: SurfaceCouldNotReadPrefix + "this run produced no reference assembly"} + return unnamedVerdict(SurfaceCouldNotReadPrefix + "this run produced no reference assembly") } unityBuildPath := filepath.Join(c.ProjectRoot, unit.Assembly.RefOutputPath) unityBuild, err := os.ReadFile(unityBuildPath) if err != nil { - return SurfaceVerdict{Reason: SurfaceCouldNotReadPrefix + "the last Unity build left no reference assembly"} + return unnamedVerdict(SurfaceCouldNotReadPrefix + "the last Unity build left no reference assembly") } if bytes.Equal(produced, unityBuild) { return SurfaceVerdict{Kept: true, Reason: SurfaceSameBytes} @@ -88,7 +100,7 @@ func (c Compiler) referenceSurfaceVerdict(ctx context.Context, plan BuildPlan, u comparer := c.surfaceComparer() if comparer == nil { - return SurfaceVerdict{Reason: SurfaceBytesDiffer} + return unnamedVerdict(SurfaceBytesDiffer) } startedAt := time.Now() @@ -130,7 +142,7 @@ func (c Compiler) askSurface( ) SurfaceVerdict { worker, err := comparer.Ensure(ctx) if err != nil { - return SurfaceVerdict{Reason: SurfaceCouldNotComparePrefix + err.Error()} + return unnamedVerdict(SurfaceCouldNotComparePrefix + err.Error()) } inputs := resultstore.SurfaceInputs{ @@ -147,16 +159,19 @@ func (c Compiler) askSurface( request, cleanup, err := writeComparedPair(filepath.Join(c.ProjectRoot, plan.OutputDir), assembly, pair) if err != nil { - return SurfaceVerdict{Reason: SurfaceCouldNotComparePrefix + err.Error()} + return unnamedVerdict(SurfaceCouldNotComparePrefix + err.Error()) } defer cleanup() response, err := comparer.CompareSurface(ctx, worker, request) if err != nil { - return SurfaceVerdict{Reason: SurfaceCouldNotComparePrefix + err.Error()} + return unnamedVerdict(SurfaceCouldNotComparePrefix + err.Error()) } - answer := resultstore.SurfaceVerdict{Same: response.Outcome == fastcheck.SameSurfaceOutcome, Detail: response.Detail} + answer := resultstore.SurfaceVerdict{ + Same: response.Outcome == fastcheck.SameSurfaceOutcome, Detail: response.Detail, + ChangedNames: response.ChangedNames, UnnamedReason: response.UnnamedReason, + } _ = records.RecordSurfaceVerdict(assembly, inputs, answer) return verdictFromWorker(answer) @@ -202,12 +217,30 @@ func writeComparedCopy(directory string, assembly string, content []byte) (strin return file.Name(), nil } +// unnamedVerdict is a verdict reached without the worker's answer: it affects every dependent. +func unnamedVerdict(reason string) SurfaceVerdict { + return SurfaceVerdict{Reason: reason, Unnamed: reason} +} + +// verdictFromWorker turns an answer into a verdict. Why a difference naming nothing is unnamed here +// and not only in the client: an answer reaches this point from a record or from any comparer, and +// a change that names nothing must never read as one no dependent writes. func verdictFromWorker(answer resultstore.SurfaceVerdict) SurfaceVerdict { if answer.Same { return SurfaceVerdict{Kept: true, Reason: SurfaceSameAPI, Detail: answer.Detail} } - return SurfaceVerdict{Reason: SurfaceChanged, Detail: answer.Detail} + verdict := SurfaceVerdict{Reason: SurfaceChanged, Detail: answer.Detail} + switch { + case answer.UnnamedReason != "": + verdict.Unnamed = answer.UnnamedReason + case len(answer.ChangedNames) == 0: + verdict.Unnamed = fastcheck.WorkerNamedNoChange + default: + verdict.ChangedNames = answer.ChangedNames + } + + return verdict } func sha256Hex(content []byte) string { diff --git a/cli/dispatcher/internal/compilecheck/surface_verdict_test.go b/cli/dispatcher/internal/compilecheck/surface_verdict_test.go index 7d37bb545b..a349c47e1c 100644 --- a/cli/dispatcher/internal/compilecheck/surface_verdict_test.go +++ b/cli/dispatcher/internal/compilecheck/surface_verdict_test.go @@ -6,9 +6,12 @@ import ( "errors" "os" "path/filepath" + "reflect" "runtime" "strings" "testing" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck/resultstore" ) // surfaceVerdictProject is one assembly whose rebuilt reference assembly differs in bytes from the @@ -174,6 +177,91 @@ func TestReferenceSurfaceVerdictComparesTheBytesItKeysTheRecordBy(t *testing.T) assertVerdict(t, project.verdict(), false, SurfaceChanged) } +// Verifies a difference the worker tells by names carries them and no unnamed reason. +func TestReferenceSurfaceVerdictCarriesTheChangedNames(t *testing.T) { + project := newSurfaceVerdictProject(t) + project.worker.surfaceNames = []string{"C", "M"} + + got := project.verdict() + + assertVerdict(t, got, false, SurfaceChanged) + if !reflect.DeepEqual(got.ChangedNames, []string{"C", "M"}) || got.Unnamed != "" || !got.namedChange() { + t.Errorf("expected a named change of C and M, got %+v", got) + } +} + +// Verifies a difference the worker cannot tell by names is unnamed and carries no names. +func TestReferenceSurfaceVerdictTreatsAnUnnamedAnswerAsUnnamed(t *testing.T) { + project := newSurfaceVerdictProject(t) + project.worker.surfaceUnnamed = "operator op_Addition" + + got := project.verdict() + + if got.Unnamed != "operator op_Addition" || len(got.ChangedNames) != 0 || got.namedChange() { + t.Errorf("expected an unnamed change, got %+v", got) + } +} + +// Verifies a verdict given without the worker is unnamed, with its reason as the reason. +func TestReferenceSurfaceVerdictWithoutAWorkerIsUnnamed(t *testing.T) { + project := newSurfaceVerdictProject(t) + compiler := Compiler{ProjectRoot: project.root} + + got := compiler.referenceSurfaceVerdict(context.Background(), project.plan, project.unit) + + assertVerdict(t, got, false, SurfaceBytesDiffer) + if got.Unnamed != SurfaceBytesDiffer || got.namedChange() { + t.Errorf("expected an unnamed verdict, got %+v", got) + } +} + +// Verifies the record of a named difference answers again with the same names. +func TestReferenceSurfaceVerdictFromRecordCarriesTheChangedNames(t *testing.T) { + project := newSurfaceVerdictProject(t) + project.worker.surfaceNames = []string{"C", "M"} + project.verdict() + project.worker.surfaceNames = nil + project.worker.surfaceUnnamed = "asked again" + + got := project.verdict() + + assertComparisons(t, project.worker, 1) + if !got.FromRecord || !reflect.DeepEqual(got.ChangedNames, []string{"C", "M"}) || got.Unnamed != "" { + t.Errorf("expected the recorded names, got %+v", got) + } +} + +// Verifies a different answer carrying neither names nor a reason is unnamed: the fake worker in +// the scheduler tests hands its answer over without the client's own check, so this is the one +// place such an answer is kept from reading as a change no dependent writes. +func TestVerdictFromWorkerTreatsADifferenceWithoutNamesAsUnnamed(t *testing.T) { + got := verdictFromWorker(resultstore.SurfaceVerdict{Same: false}) + + if got.Unnamed == "" || got.namedChange() { + t.Errorf("expected an unnamed verdict, got %+v", got) + } +} + +// Verifies the verdicts nobody named a change in - kept, the zero value of a unit that failed or +// has no dependents, and every reason the worker was not asked - never read as a named change. +func TestOnlyANamedVerdictIsANamedChange(t *testing.T) { + verdicts := []SurfaceVerdict{ + {Kept: true, Reason: SurfaceSameAPI}, + {}, + {Reason: SurfaceBytesDiffer, Unnamed: SurfaceBytesDiffer}, + {Reason: SurfaceChanged, ChangedNames: []string{"C"}, Unnamed: "operator op_Addition"}, + {Reason: SurfaceChanged}, + } + for _, verdict := range verdicts { + if verdict.namedChange() { + t.Errorf("expected %+v not to be a named change", verdict) + } + } + if !(SurfaceVerdict{Reason: SurfaceChanged, ChangedNames: []string{"C"}}).namedChange() { + t.Error("expected a verdict with names and no unnamed reason to be a named change") + } +} + // Verifies a matching record answers "same" without asking the worker again. func TestReferenceSurfaceVerdictUsesAMatchingRecordThatSaysSame(t *testing.T) { project := newSurfaceVerdictProject(t) diff --git a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go index 3d699299b5..cf7923ec5b 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go @@ -12,6 +12,8 @@ import ( const ( compileCheckVibeStartOperation = "cli_compile_check_start" compileCheckVibeCompleteOperation = "cli_compile_check_complete" + // loggedChangedNames is how many of a surface change's names a log entry keeps. + loggedChangedNames = 10 ) // compileCheckVibeLog writes what one compile-check run did to the CLI VibeLog: a start entry, and a @@ -188,14 +190,32 @@ func compileCheckVibeSurfaceChecks(units []compilecheck.UnitResult) []map[string if !unit.Surface.Asked { continue } - entries = append(entries, map[string]any{ - "assembly": unit.Assembly, - "verdict": unit.Surface.Reason, - "detail": unit.Surface.Detail, - "duration_ms": unit.Surface.Duration.Milliseconds(), - "from_record": unit.Surface.FromRecord, - }) + entry := map[string]any{ + "assembly": unit.Assembly, + "verdict": unit.Surface.Reason, + "detail": unit.Surface.Detail, + "duration_ms": unit.Surface.Duration.Milliseconds(), + "from_record": unit.Surface.FromRecord, + "changed_name_count": len(unit.Surface.ChangedNames), + "changed_names": firstChangedNames(unit.Surface.ChangedNames), + } + if unit.Surface.Unnamed != "" { + entry["unnamed"] = unit.Surface.Unnamed + } + entries = append(entries, entry) } return entries } + +// firstChangedNames keeps the log line short: a change can name thousands of identifiers. +func firstChangedNames(names []string) []string { + if len(names) > loggedChangedNames { + return names[:loggedChangedNames] + } + if names == nil { + return []string{} + } + + return names +} diff --git a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go index df02dd33cb..71a41a9e10 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go @@ -71,8 +71,8 @@ func assertVibeContextJSON(t *testing.T, entry vibelog.CLIVibeLogEntry, key stri } // loggedCompileCheckResult is a run that compiled, fast-checked, replayed a fast check of, reused, -// and blocked one assembly each, and settled the surface of three of them three ways, so a field the -// log drops or mixes up shows. +// and blocked one assembly each, and settled the surface of four of them four ways - one change with +// more names than the log keeps, one no name tells - so a field the log drops or mixes up shows. func loggedCompileCheckResult() compilecheck.Result { return compilecheck.Result{ DagDir: "Library/Bee/artifacts/1300b0aE.dag", @@ -95,12 +95,16 @@ func loggedCompileCheckResult() compilecheck.Result { { Assembly: "Epsilon", Succeeded: true, FastChecked: true, FastCheckReused: true, FastCheckChangedFiles: []string{"Assets/E.cs"}, + Surface: compilecheck.SurfaceVerdict{ + Reason: compilecheck.SurfaceChanged, Unnamed: "operator op_Addition", Asked: true, Duration: 3 * time.Millisecond, + }, }, { Assembly: "Gamma", Succeeded: true, Reused: true, Surface: compilecheck.SurfaceVerdict{ Reason: compilecheck.SurfaceChanged, Detail: "produced: a / Unity build: b", Asked: true, FromRecord: true, - Duration: 2 * time.Millisecond, + Duration: 2 * time.Millisecond, + ChangedNames: []string{"N01", "N02", "N03", "N04", "N05", "N06", "N07", "N08", "N09", "N10", "N11", "N12"}, }, }, }, @@ -143,8 +147,13 @@ func TestCompileCheckVibeLogRecordsASucceededRun(t *testing.T) { assertVibeContextJSON(t, complete, "units", `[{"assembly":"Alpha","duration_ms":1500,"succeeded":true},{"assembly":"Beta","duration_ms":250,"succeeded":true}]`) assertVibeContextJSON(t, complete, "surface_checks", - `[{"assembly":"Alpha","detail":"","duration_ms":400,"from_record":false,"verdict":"same API"},`+ - `{"assembly":"Gamma","detail":"produced: a / Unity build: b","duration_ms":2,"from_record":true,"verdict":"changed"}]`) + `[{"assembly":"Alpha","changed_name_count":0,"changed_names":[],"detail":"","duration_ms":400,"from_record":false,`+ + `"verdict":"same API"},`+ + `{"assembly":"Epsilon","changed_name_count":0,"changed_names":[],"detail":"","duration_ms":3,"from_record":false,`+ + `"unnamed":"operator op_Addition","verdict":"changed"},`+ + `{"assembly":"Gamma","changed_name_count":12,`+ + `"changed_names":["N01","N02","N03","N04","N05","N06","N07","N08","N09","N10"],`+ + `"detail":"produced: a / Unity build: b","duration_ms":2,"from_record":true,"verdict":"changed"}]`) if _, found := complete.Context["duration_ms"]; !found { t.Error("expected the run's duration to be logged") } diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index cdcc4e7285..82260ad373 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -280,7 +280,7 @@ because there is no project to write into. | `operation` | Written | `context` | |---|---|---| | `cli_compile_check_start` | once the project is found | `command`, `all`, `full`, `jobs`, `project_identity` | -| `cli_compile_check_complete` (`INFO`; `WARNING` when the run found compile errors; `ERROR` when it could not finish) | on every way out of a run that wrote the start entry | `command`, `outcome` (`succeeded`, `errors`, or `failed`), `duration_ms`; for `failed`, `error` and `error_code`; otherwise `error_count`, `warning_count`, `compiled`, `reused`, `fast_checked`, `blocked` (assembly names, as in the response), `skipped_count`, `fast_checks` (`assembly`, `reused`, `changed_file_count`), `fallbacks` and `baseline_notes` (`assembly`, `reason`), `units` (`assembly`, `duration_ms`, `succeeded` of each assembly the run compiled or checked; replayed ones are left out; `assumed_kept` names the references it started ahead of, assuming they kept Unity's surface, `compiled_again_after` the one whose changed surface made it compile again and `discarded_ms` how long the compile it threw away took, and `waited_for_record` says it waited for its references for a record that then did not apply - each only when it applies), and `surface_checks` (`assembly`, `verdict`, `detail`, `duration_ms`, `from_record` of each assembly whose rebuilt reference assembly differed in bytes from Unity's and was compared by the worker or taken from its record, replayed ones included) | +| `cli_compile_check_complete` (`INFO`; `WARNING` when the run found compile errors; `ERROR` when it could not finish) | on every way out of a run that wrote the start entry | `command`, `outcome` (`succeeded`, `errors`, or `failed`), `duration_ms`; for `failed`, `error` and `error_code`; otherwise `error_count`, `warning_count`, `compiled`, `reused`, `fast_checked`, `blocked` (assembly names, as in the response), `skipped_count`, `fast_checks` (`assembly`, `reused`, `changed_file_count`), `fallbacks` and `baseline_notes` (`assembly`, `reason`), `units` (`assembly`, `duration_ms`, `succeeded` of each assembly the run compiled or checked; replayed ones are left out; `assumed_kept` names the references it started ahead of, assuming they kept Unity's surface, `compiled_again_after` the one whose changed surface made it compile again and `discarded_ms` how long the compile it threw away took, and `waited_for_record` says it waited for its references for a record that then did not apply - each only when it applies), and `surface_checks` (`assembly`, `verdict`, `detail`, `duration_ms`, `from_record` of each assembly whose rebuilt reference assembly differed in bytes from Unity's and was compared by the worker or taken from its record, replayed ones included; `changed_name_count` and `changed_names`, the first 10 of the identifiers a dependent has to write to be affected by the change, and `unnamed`, why no name tells the change, only when it applies) | - Unlike the tool-command entries, the complete entry holds the run's error message and fallback reasons: they can name assemblies and files of the project, which the command's own output prints diff --git a/tests/CompileCheckWorker.UnitTests/ChangedSurfaceNamesTests.cs b/tests/CompileCheckWorker.UnitTests/ChangedSurfaceNamesTests.cs new file mode 100644 index 0000000000..cc6a64d6e8 --- /dev/null +++ b/tests/CompileCheckWorker.UnitTests/ChangedSurfaceNamesTests.cs @@ -0,0 +1,565 @@ +using System.Linq; +using System.Text; +using NUnit.Framework; + +namespace UnityCliLoop.CompileCheckWorker.UnitTests +{ + [TestFixture] + public sealed class ChangedSurfaceNamesTests + { + private const string Getter = "public static class F { public static T Get() => new T(); }\n"; + private const string StructGetter = "public static class F { public static S Get() => default; }\n"; + private const string Awaiter = + "public sealed class Aw : System.Runtime.CompilerServices.INotifyCompletion { public bool IsCompleted => true;" + + " public int GetResult() => 0; public void OnCompleted(System.Action a) { } }\n"; + private const string InterpolatedStringHandlerAttribute = + "namespace System.Runtime.CompilerServices { [System.AttributeUsage(System.AttributeTargets.Struct)]" + + " public sealed class InterpolatedStringHandlerAttribute : System.Attribute { } }\n"; + + /// + /// Verifies that adding a public method names its type and the method. + /// + [Test] + public void MethodAddedNamesTheTypeAndTheMethod() + { + SurfaceFixture.AssertChangedNames( + "public class C { }", + "public class C { public void M() { } }", + new[] { "C", "M" }); + } + + /// + /// Verifies that a member change in a nested generic type names every level of the nesting and the + /// member, and leaves out namespaces both versions have. + /// + [Test] + public void NestedGenericMemberChangeNamesEachLevelButNotSharedNamespaces() + { + SurfaceFixture.AssertChangedNames( + "namespace A.B { public class Outer { public class Inner { public int P { get; set; } } } }", + "namespace A.B { public class Outer { public class Inner { public long P { get; set; } } } }", + new[] { "Outer", "Inner", "P", "get_P", "set_P" }); + } + + /// + /// Verifies that a type added in a new namespace names the type and the new namespace level, but + /// not the level both versions have. + /// + [Test] + public void TypeAddedInANewNamespaceNamesTheNewLevel() + { + SurfaceFixture.AssertChangedNames( + "namespace A { public class Keep { } }", + "namespace A { public class Keep { } } namespace A.New { public class T { } }", + new[] { "New", "T" }); + } + + /// + /// Verifies that removing a type names the type and its members. + /// + [Test] + public void TypeRemovedNamesTheTypeAndItsMembers() + { + SurfaceFixture.AssertChangedNames( + "public class Keep { } public class C { public int X; }", + "public class Keep { }", + new[] { "C", "X" }); + } + + /// + /// Verifies that a change in an attribute type names it with and without the Attribute suffix. + /// + [Test] + public void AttributeTypeChangeNamesItWithAndWithoutTheSuffix() + { + SurfaceFixture.AssertChangedNames( + "public class FooAttribute : System.Attribute { }", + "public class FooAttribute : System.Attribute { public int Level { get; set; } }", + new[] { "Foo", "FooAttribute", "Level", "get_Level", "set_Level" }); + } + + /// + /// Verifies that changing the parameters of a constructor names the type and target-typed new. + /// + [Test] + public void ConstructorChangeNamesTheTypeAndTargetTypedNew() + { + SurfaceFixture.AssertChangedNames( + "public class C { public C(int a) { } }", + "public class C { public C(int a, int b) { } }", + new[] { "C", "new(" }); + } + + /// + /// Verifies that a change on an accessor names both the property and the accessor, since only the + /// member's metadata tells an accessor from a method declared under the same name. + /// + [Test] + public void AccessorChangeNamesThePropertyAndTheAccessor() + { + SurfaceFixture.AssertChangedNames( + "public class C { public int P { get; } }", + "public class C { public int P { get; [System.Obsolete] set; } }", + new[] { "C", "P", "set_P" }); + } + + /// + /// Verifies that a dependent calling a method declared as get_Value is compiled when that method + /// changes: the name it writes keeps the prefix. + /// + [Test] + public void MethodNamedLikeAnAccessorIsCompiled() + { + const string OldSource = "public class T { public int get_Value() => 0; }\n" + Getter; + const string NewSource = "public class T { public long get_Value() => 0; }\n" + Getter; + SurfaceFixture.AssertDifferent(OldSource, NewSource, "class D { int M() => F.Get().get_Value(); }"); + SurfaceFixture.AssertChangedNames(OldSource, NewSource, new[] { "T", "Value", "get_Value" }); + } + + /// + /// Verifies that a change on an explicit implementation of an accessor names the accessor and the + /// property without their interface qualifier. + /// + [Test] + public void ExplicitAccessorImplementationChangeNamesTheBareAccessorAndProperty() + { + const string Interface = "namespace N { public interface IFoo { int P { get; } } }\n"; + SurfaceFixture.AssertChangedNames( + Interface + "public class C : N.IFoo { int N.IFoo.P => 0; }", + Interface + "public class C : N.IFoo { int N.IFoo.P { [System.Obsolete] get => 0; } }", + new[] { "C", "P", "get_P" }); + } + + /// + /// Verifies that a dependent writing only init is compiled when the well-known type the compiler + /// looks up for it is removed, though the dependent never writes its name. + /// + [Test] + public void WellKnownTypeRemovedIsCompiled() + { + const string Kept = "namespace System.Runtime.CompilerServices { public sealed class KeepPolyfill { } }\n"; + const string OldSource = Kept + "namespace System.Runtime.CompilerServices { public static class IsExternalInit { } }"; + SurfaceFixture.AssertDifferent(OldSource, Kept, "public class D { public int P { get; init; } }"); + SurfaceFixture.AssertUnnamed(OldSource, Kept, "well-known type System.Runtime.CompilerServices.IsExternalInit"); + } + + /// + /// Verifies that a dependent writing only ^1 is compiled when a member of the well-known type the + /// compiler binds it to changes, though the dependent writes neither the type nor the member. + /// + [Test] + public void WellKnownTypeMemberChangeIsCompiled() + { + const string OldSource = "namespace System { public readonly struct Index { public Index(int value, bool fromEnd = false) { }" + + " public int GetOffset(int length) => 0; } }"; + const string NewSource = "namespace System { public readonly struct Index { public Index(int value) { }" + + " public int GetOffset(int length) => 0; } }"; + SurfaceFixture.AssertDifferent(OldSource, NewSource, "class D { object M() { var i = ^1; return i; } }"); + SurfaceFixture.AssertUnnamed(OldSource, NewSource, "well-known type System.Index"); + } + + /// + /// Verifies that a change on an explicit interface implementation names the member without its + /// interface qualifier. + /// + [Test] + public void ExplicitImplementationChangeNamesTheBareMember() + { + SurfaceFixture.AssertChangedNames( + "namespace N { public interface IFoo { void Bar(); } } public class C : N.IFoo { void N.IFoo.Bar() { } }", + "namespace N { public interface IFoo { void Bar(); } } public class C : N.IFoo { [System.Obsolete] void N.IFoo.Bar() { } }", + new[] { "C", "Bar" }); + } + + /// + /// Verifies that adding an operator cannot be told by names. + /// + [Test] + public void OperatorAddedIsUnnamed() + { + SurfaceFixture.AssertUnnamed( + "public class C { }", + "public class C { public static C operator +(C a, C b) => a; }", + "operator op_Addition"); + } + + /// + /// Verifies that changing a base type cannot be told by names. + /// + [Test] + public void BaseTypeChangeIsUnnamed() + { + SurfaceFixture.AssertUnnamed( + "public class B { } public class C { }", + "public class B { } public class C : B { }", + "TypeShape of C"); + } + + /// + /// Verifies that adding an interface to a class cannot be told by names. + /// + [Test] + public void InterfaceAddedIsUnnamed() + { + SurfaceFixture.AssertUnnamed( + "public interface I { } public class C { }", + "public interface I { } public class C : I { }", + "TypeShape of C"); + } + + /// + /// Verifies that adding an abstract member to an abstract class cannot be told by names. + /// + [Test] + public void AbstractMemberAddedIsUnnamed() + { + SurfaceFixture.AssertUnnamed( + "public abstract class C { }", + "public abstract class C { public abstract void M(); }", + "abstract member added: C::M"); + } + + /// + /// Verifies that adding a member without a body to an interface cannot be told by names. + /// + [Test] + public void InterfaceMemberAddedIsUnnamed() + { + SurfaceFixture.AssertUnnamed( + "public interface I { }", + "public interface I { void M(); }", + "abstract member added: I::M"); + } + + /// + /// Verifies that adding a member that is not abstract to an interface, as one with a default body + /// is, names the interface and the member: no implementer has to change for it. Built from rows + /// because the .NET Standard 2.0 the fixture compiles against cannot hold a default body. + /// + [Test] + public void InterfaceMemberWithABodyAddedNamesTheInterfaceAndTheMember() + { + SurfaceDescription unityBuild = new SurfaceDescription(); + unityBuild.Add("type I flags=Interface, Abstract", SurfaceOwner.ForType("I")); + SurfaceDescription produced = new SurfaceDescription(); + produced.Add("type I flags=Interface, Abstract", SurfaceOwner.ForType("I")); + produced.Add("method I::M`0 20()->Void flags=Public, Virtual", SurfaceOwner.ForMember("I", "M")); + + ChangedSurfaceNames names = ChangedSurfaceNames.Collect(produced, unityBuild); + + Assert.That(names.UnnamedReason, Is.Empty); + Assert.That(names.Names, Is.EqualTo(new[] { "I", "M" })); + } + + /// + /// Verifies that removing an abstract member names the type and the member: no implementer has to + /// change for it. + /// + [Test] + public void AbstractMemberRemovedNamesTheTypeAndTheMember() + { + SurfaceFixture.AssertChangedNames( + "public abstract class C { public abstract void M(); }", + "public abstract class C { }", + new[] { "C", "M" }); + } + + /// + /// Verifies that adding an indexer cannot be told by names, through the default member attribute + /// it puts on the type and through its own rows. + /// + [Test] + public void IndexerAddedIsUnnamed() + { + SurfaceFixture.AssertUnnamed( + "public class C { }", + "public class C { public int this[int i] => i; }", + "Type of C; Indexer of C"); + } + + /// + /// Verifies that adding GetEnumerator, which foreach binds without writing it, cannot be told by + /// names. + /// + [Test] + public void GetEnumeratorAddedIsUnnamed() + { + SurfaceFixture.AssertUnnamed( + "public class C { }", + "public class C { public System.Collections.IEnumerator GetEnumerator() => null!; }", + "pattern-bound member GetEnumerator"); + } + + /// + /// Verifies that changing SetResult, which an async method builder binds without its name being + /// written, cannot be told by names. + /// + [Test] + public void SetResultChangeIsUnnamed() + { + SurfaceFixture.AssertUnnamed( + "public class C { public void SetResult(int value) { } }", + "public class C { public void SetResult(long value) { } }", + "pattern-bound member SetResult"); + } + + /// + /// Verifies that a change of a compiler-generated member, whose name no source writes, cannot be + /// told by names. + /// + [Test] + public void CompilerGeneratedMemberChangeIsUnnamed() + { + SurfaceDescription unityBuild = new SurfaceDescription(); + unityBuild.Add("method R::$`0 20()->R flags=Public", SurfaceOwner.ForMember("R", "$")); + SurfaceDescription produced = new SurfaceDescription(); + produced.Add("method R::$`0 20()->R flags=Public, Virtual", SurfaceOwner.ForMember("R", "$")); + + ChangedSurfaceNames names = ChangedSurfaceNames.Collect(produced, unityBuild); + + Assert.That(names.UnnamedReason, Does.StartWith("compiler-generated member $")); + Assert.That(names.Names, Is.Empty); + } + + /// + /// Verifies that a record whose generated members change is never named, whatever reason comes + /// first. + /// + [Test] + public void RecordChangeIsUnnamed() + { + const string IsExternalInit = "namespace System.Runtime.CompilerServices { internal static class IsExternalInit { } }\n"; + SurfaceFixture.AssertUnnamed( + IsExternalInit + "public record R { }", + IsExternalInit + "public sealed record R { }", + ""); + } + + /// + /// Verifies that two descriptions holding the same rows in another order cannot be told by names. + /// + [Test] + public void ReorderedMembersAreUnnamed() + { + SurfaceFixture.AssertUnnamed( + "public class C { public void A() { } public void B() { } }", + "public class C { public void B() { } public void A() { } }", + "the descriptions differ only in order"); + } + + /// + /// Verifies that a change naming more than the limit cannot be told by names. + /// + [Test] + public void TooManyChangedNamesAreUnnamed() + { + StringBuilder fields = new StringBuilder(); + for (int index = 0; index < 2001; index++) + { + fields.Append("public int F").Append(index).Append("; "); + } + + SurfaceFixture.AssertUnnamed( + "public class C { }", + "public class C { " + fields + "}", + "too many changed names"); + } + + /// + /// Verifies that a member change of an interpolated string handler type, which the compiler binds + /// by pattern, cannot be told by names. + /// + [Test] + public void InterpolatedStringHandlerMemberChangeIsUnnamed() + { + SurfaceFixture.AssertUnnamed( + InterpolatedStringHandlerAttribute + + "[System.Runtime.CompilerServices.InterpolatedStringHandler] public struct H { public void Mark() { } }", + InterpolatedStringHandlerAttribute + + "[System.Runtime.CompilerServices.InterpolatedStringHandler] public struct H { public void Mark(int level) { } }", + "PatternBoundType of H"); + } + + /// + /// Verifies that a dependent iterating a value it never names the type of is compiled when the + /// enumerator that foreach binds changes. + /// + [Test] + public void ForeachOverAnUnnamedTypeIsCompiled() + { + const string Enumerators = + "public sealed class E { public object Current => null!; public bool MoveNext() => false; }" + + " public sealed class E2 { public object Current => null!; }\n"; + SurfaceFixture.AssertDifferent( + Enumerators + "public class T { public E GetEnumerator() => new E(); }\n" + Getter, + Enumerators + "public class T { public E2 GetEnumerator() => new E2(); }\n" + Getter, + "class D { void M() { foreach (object x in F.Get()) { } } }"); + } + + /// + /// Verifies that a dependent awaiting a value it never names the type of is compiled when the + /// awaiter that await binds changes. + /// + [Test] + public void AwaitOnAnUnnamedTypeIsCompiled() + { + const string Awaiters = Awaiter + + "public sealed class Aw2 : System.Runtime.CompilerServices.INotifyCompletion { public bool IsCompleted => true;" + + " public string GetResult() => \"\"; public void OnCompleted(System.Action a) { } }\n"; + SurfaceFixture.AssertDifferent( + Awaiters + "public class T { public Aw GetAwaiter() => new Aw(); }\n" + Getter, + Awaiters + "public class T { public Aw2 GetAwaiter() => new Aw2(); }\n" + Getter, + "class D { async System.Threading.Tasks.Task M() { return await F.Get(); } }"); + } + + /// + /// Verifies that a dependent adding two values it never names the type of is compiled when the + /// operator it binds changes. + /// + [Test] + public void OperatorOnAnUnnamedTypeIsCompiled() + { + SurfaceFixture.AssertDifferent( + "public class T { public static T operator +(T a, T b) => a; }\n" + Getter, + "public class T { public static T operator +(T a, int b) => a; }\n" + Getter, + "class D { object M() => F.Get() + F.Get(); }"); + } + + /// + /// Verifies that making a type abstract, which breaks a new() constraint a dependent satisfies + /// without naming the type, cannot be told by names. + /// + [Test] + public void TypeMadeAbstractIsUnnamed() + { + const string OldSource = "public class T { public T() { } }\n" + Getter; + const string NewSource = "public abstract class T { public T() { } }\n" + + "public static class F { public static T Get() => null!; }\n"; + SurfaceFixture.AssertDifferent(OldSource, NewSource, + "class D { static void Use(U u) where U : new() { } void M() { Use(F.Get()); } }"); + SurfaceFixture.AssertUnnamed(OldSource, NewSource, "Type of T"); + } + + /// + /// Verifies that sealing a type, which breaks a cast a dependent writes without naming the type, + /// cannot be told by names. + /// + [Test] + public void TypeSealedIsUnnamed() + { + const string OldSource = "public class T { }\n" + Getter; + const string NewSource = "public sealed class T { }\n" + Getter; + SurfaceFixture.AssertDifferent(OldSource, NewSource, + "class D { object M() => (System.IDisposable)F.Get(); }"); + SurfaceFixture.AssertUnnamed(OldSource, NewSource, "Type of T"); + } + + /// + /// Verifies that turning a struct into a ref struct, which breaks boxing a dependent writes + /// without naming the type, cannot be told by names. + /// + [Test] + public void StructMadeRefStructIsUnnamed() + { + const string OldSource = "public struct S { }\n" + StructGetter; + const string NewSource = "public ref struct S { }\n" + StructGetter; + SurfaceFixture.AssertDifferent(OldSource, NewSource, "class D { object M() { object o = F.Get(); return o; } }"); + SurfaceFixture.AssertUnnamed(OldSource, NewSource, "Type of S"); + } + + /// + /// Verifies that adding an instance field to a struct, which can break an unmanaged constraint a + /// dependent satisfies without naming the type, cannot be told by names. + /// + [Test] + public void StructInstanceFieldAddedIsUnnamed() + { + const string OldSource = "public struct S { public int A; }\n" + StructGetter; + const string NewSource = "public struct S { public int A; public string? B; }\n" + StructGetter; + SurfaceFixture.AssertDifferent(OldSource, NewSource, + "class D { static void U(V v) where V : unmanaged { } void M() { U(F.Get()); } }"); + SurfaceFixture.AssertUnnamed(OldSource, NewSource, "ValueTypeInstanceField of S"); + } + + /// + /// Verifies that removing a parameterless constructor, which breaks a new() constraint a dependent + /// satisfies without naming the type, cannot be told by names. + /// + [Test] + public void ParameterlessConstructorRemovedIsUnnamed() + { + const string Maker = "public static class G { public static void Make(U u) where U : new() { } }\n"; + const string OldSource = "public class T { public T() { } }\n" + Getter + Maker; + const string NewSource = "public class T { public T(int x) { } }\n" + + "public static class F { public static T Get() => new T(0); }\n" + Maker; + SurfaceFixture.AssertDifferent(OldSource, NewSource, "class D { void M() { G.Make(F.Get()); } }"); + SurfaceFixture.AssertUnnamed(OldSource, NewSource, "ParameterlessConstructor of T"); + } + + /// + /// Verifies that changing a renamed indexer, which a dependent uses without writing its name, + /// cannot be told by names. + /// + [Test] + public void RenamedIndexerChangeIsUnnamed() + { + const string OldSource = + "public class T { [System.Runtime.CompilerServices.IndexerName(\"At\")] public int this[int i] => i; }\n" + Getter; + const string NewSource = + "public class T { [System.Runtime.CompilerServices.IndexerName(\"At\")] public string this[int i] => \"\"; }\n" + Getter; + SurfaceFixture.AssertDifferent(OldSource, NewSource, "class D { int M() { int v = F.Get()[0]; return v; } }"); + SurfaceFixture.AssertUnnamed(OldSource, NewSource, "Indexer of T"); + } + + /// + /// Verifies that an extension GetAwaiter added in a new static class, which makes a dependent's + /// await bind without writing anything new, cannot be told by names. + /// + [Test] + public void ExtensionGetAwaiterAddedIsUnnamed() + { + // An extension method both versions have, so the attribute it puts on the assembly is no change. + const string OldSource = "public class T { }\n" + Getter + Awaiter + + "public static class Old { public static int Twice(this int value) => value * 2; }\n"; + const string NewSource = OldSource + "public static class Ext { public static Aw GetAwaiter(this T t) => new Aw(); }\n"; + SurfaceFixture.AssertDifferent(OldSource, NewSource, + "class D { async System.Threading.Tasks.Task M() { return await F.Get(); } }"); + SurfaceFixture.AssertUnnamed(OldSource, NewSource, "pattern-bound member GetAwaiter"); + } + + /// + /// Verifies that adding a property a dependent reaches through a value it never names the type of + /// is named, and that the dependent writes one of the names. + /// + [Test] + public void PropertyAddedIsNamedForADependentThatWritesIt() + { + const string OldSource = "public class T { }\n" + Getter; + const string NewSource = "public class T { public int P { get; set; } }\n" + Getter; + SurfaceFixture.AssertDifferent(OldSource, NewSource, "class D { int M() => F.Get().P; }"); + SurfaceFixture.AssertChangedNames(OldSource, NewSource, new[] { "T", "P", "get_P", "set_P" }); + } + + /// + /// Verifies that the description carries the version that keeps the base type on its own row. + /// + [Test] + public void DescriptionKeepsTheBaseTypeOnItsOwnRow() + { + string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "compile-check-describe-" + System.Guid.NewGuid().ToString("N") + ".dll"); + System.IO.File.WriteAllBytes(path, SurfaceFixture.EmitReferenceAssembly("public class B { } public class C : B { }")); + try + { + string[] texts = CompareSurfaceCommand.Describe(path).Texts.ToArray(); + + Assert.That(texts[0], Is.EqualTo("sv2")); + Assert.That(texts.Where(text => text.StartsWith("type C ")).Single(), Does.Not.Contain("base=")); + Assert.That(texts, Does.Contain("C base B")); + } + finally + { + System.IO.File.Delete(path); + } + } + } +} diff --git a/tests/CompileCheckWorker.UnitTests/SurfaceFixture.cs b/tests/CompileCheckWorker.UnitTests/SurfaceFixture.cs index 48d533b093..17f6197d5c 100644 --- a/tests/CompileCheckWorker.UnitTests/SurfaceFixture.cs +++ b/tests/CompileCheckWorker.UnitTests/SurfaceFixture.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Reflection.Metadata; using System.Reflection.PortableExecutable; +using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Emit; @@ -24,6 +25,12 @@ internal static class SurfaceFixture // The name a library's InternalsVisibleTo has to give for the dependent to see its internals. internal const string DependentAssemblyName = "Dep"; + // The identifiers of a dependent's source, cut the way the scheduler cuts them: a letter or '_' + // and then letters, digits and '_', with '@' left off, and "new" followed by '(' as "new(". + private static readonly Regex Identifier = new Regex(@"[\p{L}_][\p{L}\p{Nd}_]*", RegexOptions.CultureInvariant); + private static readonly Regex TargetTypedNew = new Regex(@"(? /// Asserts that the two versions build different bytes that a dependent reads the same way, /// and that the worker calls them the same surface. @@ -54,6 +61,54 @@ internal static void AssertDifferent(string oldSource, string newSource, string "the dependent compiles the same way, so the case does not show a real difference"); CompareSurfaceResponse response = Compare(oldAssembly, newAssembly); Assert.That(response.Outcome, Is.EqualTo(CompareSurfaceResponse.DifferentOutcome)); + // A dependent that compiles differently must be compiled: either the change cannot be told + // by names, or one of its names is written in the dependent. + HashSet identifiers = SourceIdentifiers(dependentSource); + Assert.That(response.UnnamedReason != "" || response.ChangedNames.Any(identifiers.Contains), Is.True, + "the dependent compiles differently but writes none of the changed names [" + + string.Join(", ", response.ChangedNames) + "] and the change was not called unnamed"); + } + + /// + /// Asserts that the two versions are different surfaces whose change is told by exactly the + /// expected names. + /// + internal static void AssertChangedNames(string oldSource, string newSource, string[] expectedNames) + { + CompareSurfaceResponse response = Compare(EmitReferenceAssembly(oldSource), EmitReferenceAssembly(newSource)); + Assert.That(response.Outcome, Is.EqualTo(CompareSurfaceResponse.DifferentOutcome)); + Assert.That(response.UnnamedReason, Is.Empty); + Assert.That(response.ChangedNames, Is.EquivalentTo(expectedNames)); + } + + /// + /// Asserts that the two versions are different surfaces whose change cannot be told by names, + /// for the expected reason, and that no names come with it. + /// + internal static void AssertUnnamed(string oldSource, string newSource, string reasonPrefix) + { + CompareSurfaceResponse response = Compare(EmitReferenceAssembly(oldSource), EmitReferenceAssembly(newSource)); + Assert.That(response.Outcome, Is.EqualTo(CompareSurfaceResponse.DifferentOutcome)); + Assert.That(response.UnnamedReason, Does.StartWith(reasonPrefix)); + Assert.That(response.ChangedNames, Is.Empty); + } + + /// + /// Lists the identifiers a dependent's source writes, comments and strings included. + /// + internal static HashSet SourceIdentifiers(string source) + { + HashSet identifiers = new HashSet(StringComparer.Ordinal); + foreach (Match match in Identifier.Matches(source)) + { + identifiers.Add(match.Value); + } + if (TargetTypedNew.IsMatch(source)) + { + identifiers.Add(TargetTypedNewToken); + } + + return identifiers; } /// From c253245f3c85d9b2e897daa7f78e21ce6220b0a1 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 16 Oct 2026 13:17:06 +0900 Subject: [PATCH 29/33] feat: compile-check compiles only the dependents that use what a public API change touched (#3348) --- .../internal/compilecheck/compiler.go | 7 +- .../internal/compilecheck/name_index.go | 376 ++++++++++++++++++ .../compilecheck/name_index_fifo_test.go | 70 ++++ .../internal/compilecheck/name_index_test.go | 182 +++++++++ .../internal/compilecheck/scheduler.go | 56 ++- .../compilecheck/scheduler_names_test.go | 281 +++++++++++++ .../internal/compilecheck/scheduler_settle.go | 16 +- .../compilecheck/scheduler_surface_test.go | 4 +- .../internal/compilecheck/session.go | 10 +- .../dispatcher/compile_check_vibe_log.go | 28 ++ .../dispatcher/compile_check_vibe_log_test.go | 48 +++ docs/compile-check.md | 31 ++ docs/vibe-logs.md | 2 +- 13 files changed, 1083 insertions(+), 28 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/name_index.go create mode 100644 cli/dispatcher/internal/compilecheck/name_index_fifo_test.go create mode 100644 cli/dispatcher/internal/compilecheck/name_index_test.go create mode 100644 cli/dispatcher/internal/compilecheck/scheduler_names_test.go diff --git a/cli/dispatcher/internal/compilecheck/compiler.go b/cli/dispatcher/internal/compilecheck/compiler.go index 748ed69022..28182bba0c 100644 --- a/cli/dispatcher/internal/compilecheck/compiler.go +++ b/cli/dispatcher/internal/compilecheck/compiler.go @@ -93,6 +93,9 @@ type UnitResult struct { // WaitedForRecord marks a unit that waited for the references still compiling rather than // starting before them, because its recorded result might be replayed once they finished. WaitedForRecord bool + // ChangedReferences names the referenced assemblies whose changed surface counted for this unit, + // in the plan's order, each with the first changed name the unit writes. + ChangedReferences []ChangedReference } // CommandRunner runs one compiler invocation, so tests can stand in for the real process. @@ -109,14 +112,14 @@ type Compiler struct { // compareSurface stands in for the comparison of a unit's reference assembly with Unity's; nil // compares the two. Why it can be replaced: the tests have to see which units the run compares at // all, and a byte comparison leaves nothing behind to count. - compareSurface func(projectRoot string, plan BuildPlan, unit CompileUnit) bool + compareSurface func(projectRoot string, plan BuildPlan, unit CompileUnit) SurfaceVerdict } // surfaceVerdict says whether compiling this unit left its reference assembly showing its dependents // the public surface the last Unity build showed them. func (c Compiler) surfaceVerdict(ctx context.Context, plan BuildPlan, unit CompileUnit) SurfaceVerdict { if c.compareSurface != nil { - return SurfaceVerdict{Kept: c.compareSurface(c.ProjectRoot, plan, unit)} + return c.compareSurface(c.ProjectRoot, plan, unit) } return c.referenceSurfaceVerdict(ctx, plan, unit) diff --git a/cli/dispatcher/internal/compilecheck/name_index.go b/cli/dispatcher/internal/compilecheck/name_index.go new file mode 100644 index 0000000000..0595f9ab99 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/name_index.go @@ -0,0 +1,376 @@ +package compilecheck + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +// targetTypedNewToken stands for a target-typed new in a dependent's identifiers, the way the worker +// names a changed constructor. +const targetTypedNewToken = "new(" + +// ChangedReference names a referenced assembly whose changed surface made a unit compile, and the +// first of its changed names the unit writes; empty when the change could not be told by names. +type ChangedReference struct { + Assembly string + Name string +} + +// NameSkip names a unit left out although a reference it reads changed its surface, because the unit +// writes none of the names the change touches, and those references. +type NameSkip struct { + Assembly string + ChangedReferences []string +} + +// NameIndexReport says what reading the identifiers of the units with references in the plan took. +type NameIndexReport struct { + Duration time.Duration + // Units counts the units whose identifiers were read, WithGenerated those whose saved generator + // output was read too, and Unreadable those that could not be read and so compile on any change. + Units int + WithGenerated int + Unreadable int +} + +// identifierSet is the identifiers one unit writes. +type identifierSet map[string]struct{} + +// scanIdentifiers adds every identifier content holds to into, cut the way C# cuts one: a letter, a +// letter number or '_', followed by those, decimal digits, combining marks, connectors and formatting +// characters, the last left out of the name as the compiler leaves them out. Comments, strings and code a #if leaves out are read too, which can only make a +// unit compile more often. A "new" followed by '(' also adds the target-typed new token. +// Why \u escapes are not decoded: almost nobody writes one in an identifier, and the miss is named +// among the limits of the check. +func scanIdentifiers(content []byte, into identifierSet) { + position := 0 + for position < len(content) { + character, size := utf8.DecodeRune(content[position:]) + if !isIdentifierStart(character) { + position += size + + continue + } + start := position + position += size + for position < len(content) { + next, nextSize := utf8.DecodeRune(content[position:]) + if !isIdentifierPart(next) { + break + } + position += nextSize + } + identifier := identifierText(content[start:position]) + into[identifier] = struct{}{} + if identifier == "new" && opensParenthesis(content[position:]) { + into[targetTypedNewToken] = struct{}{} + } + } +} + +func isIdentifierStart(character rune) bool { + return unicode.IsLetter(character) || unicode.Is(unicode.Nl, character) || character == '_' +} + +func isIdentifierPart(character rune) bool { + return isIdentifierStart(character) || unicode.In(character, unicode.Nd, unicode.Mn, unicode.Mc, unicode.Pc, unicode.Cf) +} + +// identifierText is the name an identifier's text spells, without its formatting characters. +func identifierText(text []byte) string { + if !bytes.ContainsFunc(text, isFormatting) { + return string(text) + } + + return string(bytes.Map(func(character rune) rune { + if isFormatting(character) { + return -1 + } + + return character + }, text)) +} + +func isFormatting(character rune) bool { + return unicode.Is(unicode.Cf, character) +} + +// opensParenthesis reports whether rest starts with '(' after any white space. +// Why it walks the bytes in place: copying the rest of the file at every "new" makes a file full of +// them take time in the square of its length. +func opensParenthesis(rest []byte) bool { + trimmed := bytes.TrimLeftFunc(rest, unicode.IsSpace) + + return len(trimmed) > 0 && trimmed[0] == '(' +} + +// readSourceIdentifiers adds the identifiers of every source of a unit to into. +// Why a unit without sources is an error rather than an empty set: an empty set would read as a unit +// that writes no changed name, and skip it on every change. +func readSourceIdentifiers(projectRoot string, unit CompileUnit, into identifierSet) error { + if len(unit.Sources) == 0 { + return fmt.Errorf("%s has no sources to read names from", unit.Assembly.AssemblyName) + } + for _, source := range unit.Sources { + content, err := os.ReadFile(sourcePath(projectRoot, filepath.FromSlash(source))) + if err != nil { + return fmt.Errorf("failed to read the names of %s: %w", source, err) + } + scanIdentifiers(content, into) + } + + return nil +} + +// readGeneratedIdentifiers reads the identifiers of the generator output a unit's last full compile +// saved, and reports whether there was any. +// Why generator output is read: a generator can write a referenced name the unit's own sources never +// do, and that code compiles with the unit. +func readGeneratedIdentifiers(generatedDirectory string) (identifierSet, bool, error) { + identifiers := identifierSet{} + if _, err := os.Stat(generatedDirectory); errors.Is(err, fs.ErrNotExist) { + return identifiers, false, nil + } + err := filepath.WalkDir(generatedDirectory, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !strings.EqualFold(filepath.Ext(path), ".cs") { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + scanIdentifiers(content, identifiers) + + return nil + }) + if err != nil { + return nil, false, fmt.Errorf("failed to read the generated names in %s: %w", generatedDirectory, err) + } + + return identifiers, true, nil +} + +// nameIndex reads, in the background, the identifiers of every unit that references another unit of +// the plan, so a reference that changed only names a unit never writes need not make it compile. +// Why every such unit and not only the dependents: a unit selected for its own sources compiles +// anyway, but one that started on the assumption that a reference keeps its surface need not compile +// again when that reference changed only names it never writes. +type nameIndex struct { + sets []identifierSet + errs []error + withGenerated []bool + indexed []bool + done chan struct{} + duration time.Duration + started bool +} + +// start reads the identifiers of the units marked in wanted, at most once per run. generated holds +// the generator output directory of each unit. The generator output is read before start returns and +// the sources in the background. +// Why the generator output is read first: a unit's compile removes and rewrites that directory, and a +// read racing it would lose the names only the generator wrote. Sources are never removed, and few +// units have saved generator output, so only the sources are worth reading while compiles run. +func (index *nameIndex) start(projectRoot string, units []CompileUnit, generated []string, wanted []bool) { + if index.started { + return + } + index.started = true + index.done = make(chan struct{}) + index.indexed = wanted + startedAt := time.Now() + type unitNames struct { + set identifierSet + withGenerated bool + err error + } + generatedNames := computeInParallel(len(units), func(position int) unitNames { + if !wanted[position] { + return unitNames{} + } + set, withGenerated, err := readGeneratedIdentifiers(generated[position]) + + return unitNames{set: set, withGenerated: withGenerated, err: err} + }) + go func() { + defer close(index.done) + results := computeInParallel(len(units), func(position int) unitNames { + names := generatedNames[position] + if !wanted[position] || names.err != nil { + return names + } + if err := readSourceIdentifiers(projectRoot, units[position], names.set); err != nil { + return unitNames{err: err} + } + + return names + }) + index.sets = make([]identifierSet, len(units)) + index.errs = make([]error, len(units)) + index.withGenerated = make([]bool, len(units)) + for position, result := range results { + index.sets[position] = result.set + index.errs[position] = result.err + index.withGenerated[position] = result.withGenerated + } + index.duration = time.Since(startedAt) + }() +} + +// lookup waits for the index and returns the identifiers of one unit, or an error when it has none. +func (index *nameIndex) lookup(position int) (identifierSet, error) { + if !index.started { + return nil, errors.New("the names of the plan were never read") + } + <-index.done + if !index.indexed[position] { + return nil, errors.New("the names of this unit were not read") + } + if index.errs[position] != nil { + return nil, index.errs[position] + } + + return index.sets[position], nil +} + +// wait returns once the background read is over, so nothing outlives the run that started it. +func (index *nameIndex) wait() { + if index.started { + <-index.done + } +} + +// report says what the read took; zero when nothing was read. +func (index *nameIndex) report() NameIndexReport { + if !index.started { + return NameIndexReport{} + } + <-index.done + report := NameIndexReport{Duration: index.duration} + for position, indexed := range index.indexed { + if !indexed { + continue + } + report.Units++ + if index.errs[position] != nil { + report.Unreadable++ + } else if index.withGenerated[position] { + report.WithGenerated++ + } + } + + return report +} + +// startNameIndex starts reading the identifiers of the units that reference another unit of the plan, +// when there is any. +func (run *schedulerRun) startNameIndex() { + wanted := make([]bool, len(run.plan.Units)) + any := false + for position := range run.plan.Units { + wanted[position] = len(run.state.references[position]) > 0 + any = any || wanted[position] + } + if !any { + return + } + records := run.compiler.records(run.plan) + generated := make([]string, len(run.plan.Units)) + for position, unit := range run.plan.Units { + generated[position] = filepath.Join(run.compiler.ProjectRoot, records.GeneratedDirectory(unit.Assembly.AssemblyName)) + } + run.names.start(run.compiler.ProjectRoot, run.plan.Units, generated, wanted) +} + +// referenceEffect is what one reference's final state means for one unit that reads it: whether it +// counts as a changed input, and the first of its changed names the unit writes. +type referenceEffect struct { + dependent int + touches bool + // named marks a reference whose change was told by names, so one that does not touch was + // changed rather than kept. + named bool + name string +} + +// changedReference is a reference that touched a unit, by its position in the plan, with the first +// changed name the unit writes; empty when no name told the change. +type changedReference struct { + reference int + name string +} + +// effectsOf decides, for every unit reading the unit at index, whether its verdict touches that unit: +// never when kept; always when the change cannot be told by names, when nothing compared it, or when +// the unit's names could not be read; and otherwise when the unit writes one of the changed names. +func (run *schedulerRun) effectsOf(index int, verdict SurfaceVerdict) []referenceEffect { + dependents := run.state.dependents[index] + effects := make([]referenceEffect, 0, len(dependents)) + for _, dependent := range dependents { + switch { + case verdict.Kept: + effects = append(effects, referenceEffect{dependent: dependent}) + case !verdict.namedChange(): + effects = append(effects, referenceEffect{dependent: dependent, touches: true}) + default: + effects = append(effects, run.namedEffect(dependent, verdict.ChangedNames)) + } + } + + return effects +} + +func (run *schedulerRun) namedEffect(dependent int, changedNames []string) referenceEffect { + identifiers, err := run.names.lookup(dependent) + if err != nil { + return referenceEffect{dependent: dependent, touches: true} + } + for _, name := range changedNames { + if _, found := identifiers[name]; found { + return referenceEffect{dependent: dependent, touches: true, named: true, name: name} + } + } + + return referenceEffect{dependent: dependent, named: true} +} + +// changedReferencesOf names the references that touched the unit at index, in the plan's order. +func (run *schedulerRun) changedReferencesOf(index int) []ChangedReference { + touched := slices.Clone(run.state.changedBy[index]) + if len(touched) == 0 { + return nil + } + slices.SortFunc(touched, func(a changedReference, b changedReference) int { return a.reference - b.reference }) + references := make([]ChangedReference, 0, len(touched)) + for _, reference := range touched { + references = append(references, ChangedReference{ + Assembly: run.plan.Units[reference.reference].Assembly.AssemblyName, Name: reference.name, + }) + } + + return references +} + +// recordNameSkip notes a unit left out although a reference changed, because it writes none of the +// names that reference's change touches. +func (run *schedulerRun) recordNameSkip(index int) { + unaffected := run.state.unaffectedBy[index] + if len(unaffected) == 0 { + return + } + run.nameSkips = append(run.nameSkips, NameSkip{ + Assembly: run.plan.Units[index].Assembly.AssemblyName, ChangedReferences: run.namesOf(slices.Sorted(slices.Values(unaffected))), + }) +} diff --git a/cli/dispatcher/internal/compilecheck/name_index_fifo_test.go b/cli/dispatcher/internal/compilecheck/name_index_fifo_test.go new file mode 100644 index 0000000000..293704ccba --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/name_index_fifo_test.go @@ -0,0 +1,70 @@ +//go:build !windows + +package compilecheck + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" +) + +// Verifies the generator output is read before start returns: with the background read held on a +// source until the generated directory is gone, as a compile that started meanwhile removes it, the +// names only the generator wrote are still in the index. +func TestNameIndexReadsGeneratedCodeBeforeStartReturns(t *testing.T) { + root := t.TempDir() + writeUnitSources(t, root, map[string]string{"Generated/Gen.g.cs": "partial class First { Foo f; }"}) + source := filepath.Join(root, "Assets", "A.cs") + if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil { + t.Fatalf("failed to create the source directory: %v", err) + } + // Why a FIFO: reading it blocks until the test writes it, which holds the background read at a + // point the test chooses. + if err := syscall.Mkfifo(source, 0o644); err != nil { + t.Fatalf("failed to create the source FIFO: %v", err) + } + unit := CompileUnit{Assembly: ResponseFile{AssemblyName: "Unit"}, Sources: []string{"Assets/A.cs"}} + index := nameIndex{} + + index.start(root, []CompileUnit{unit}, []string{filepath.Join(root, "Generated")}, []bool{true}) + if err := os.RemoveAll(filepath.Join(root, "Generated")); err != nil { + t.Fatalf("failed to remove the generated code: %v", err) + } + releaseFIFO(t, source, "class First { }") + + set, err := index.lookup(0) + if err != nil { + t.Fatalf("expected the identifiers of the unit, got %v", err) + } + assertHasIdentifiers(t, set, []string{"First", "Foo"}, nil) +} + +// releaseFIFO writes content to the FIFO at path once its reader opens it, and fails the test when +// no reader comes. +func releaseFIFO(t *testing.T, path string, content string) { + t.Helper() + written := make(chan error, 1) + go func() { + writer, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + written <- err + + return + } + _, err = writer.WriteString(content) + if closeErr := writer.Close(); err == nil { + err = closeErr + } + written <- err + }() + select { + case err := <-written: + if err != nil { + t.Fatalf("failed to write the source FIFO: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("nothing read the source FIFO") + } +} diff --git a/cli/dispatcher/internal/compilecheck/name_index_test.go b/cli/dispatcher/internal/compilecheck/name_index_test.go new file mode 100644 index 0000000000..bad746696a --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/name_index_test.go @@ -0,0 +1,182 @@ +package compilecheck + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func identifiersOf(content string) identifierSet { + set := identifierSet{} + scanIdentifiers([]byte(content), set) + + return set +} + +func assertHasIdentifiers(t *testing.T, set identifierSet, present []string, absent []string) { + t.Helper() + for _, name := range present { + if _, found := set[name]; !found { + t.Errorf("expected %q among the identifiers, got %v", name, set) + } + } + for _, name := range absent { + if _, found := set[name]; found { + t.Errorf("expected %q not to be among the identifiers, got %v", name, set) + } + } +} + +// Verifies identifiers are cut by the characters C# allows in them: a spacing mark, a combining mark +// and a connector stay inside one identifier, a letter number starts one, and a formatting character +// is left out of the name, as the compiler leaves it out. +func TestScanIdentifiersCutsIdentifiersByTheCharactersCSharpAllows(t *testing.T) { + set := identifiersOf("नाम cafe\u0301 a\u203fb \u216bth Fo\u200do") + + assertHasIdentifiers(t, set, + []string{"नाम", "cafe\u0301", "a\u203fb", "\u216bth", "Foo"}, + []string{"न", "म", "cafe", "a", "b", "th", "Fo", "o"}) +} + +// Verifies a target-typed new is found with any white space before its parenthesis, and only then. +func TestScanIdentifiersFindsATargetTypedNewOnlyBeforeAParenthesis(t *testing.T) { + cases := map[string]bool{ + "new(1)": true, "new (1)": true, "new\n(1)": true, "new x(1)": false, "x = new": false, "new ": false, + } + for content, want := range cases { + if _, found := identifiersOf(content)[targetTypedNewToken]; found != want { + t.Errorf("%q: target-typed new found = %v, want %v", content, found, want) + } + } +} + +// BenchmarkScanIdentifiers reads a file holding many "new" expressions. +func BenchmarkScanIdentifiers(b *testing.B) { + content := []byte(strings.Repeat("var x = new Thing(); ", 40_000)) + for b.Loop() { + scanIdentifiers(content, identifierSet{}) + } +} + +// Verifies identifiers are cut the way C# spells them: a verbatim '@' left off, Unicode letters +// kept, words in strings and comments read too, a target-typed new found with or without a space, +// and an identifier that only starts with "new" not taken for one. +func TestScanIdentifiersCutsIdentifiersTheWayCSharpSpellsThem(t *testing.T) { + set := identifiersOf("var @class = new (1); Point p = new(2); newFoo(3); // Commented\n" + + "string s = \"Quoted\"; int 名前 = _under9; char c = '\\u0041';") + + assertHasIdentifiers(t, set, + []string{"class", "new(", "newFoo", "Commented", "Quoted", "名前", "_under9", "Point", "u0041"}, + []string{"@class", "A", "9"}) +} + +// Verifies "newFoo(" alone is not read as a target-typed new. +func TestScanIdentifiersDoesNotTakeAnIdentifierStartingWithNewForANew(t *testing.T) { + assertHasIdentifiers(t, identifiersOf("newFoo(1); renew (2);"), []string{"newFoo", "renew"}, []string{"new("}) +} + +// writeUnitSources writes the given project-relative files and returns a unit compiling them. +func writeUnitSources(t *testing.T, root string, files map[string]string) CompileUnit { + t.Helper() + unit := CompileUnit{Assembly: ResponseFile{AssemblyName: "Unit"}} + for path, content := range files { + absolute := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil { + t.Fatalf("failed to create %s: %v", path, err) + } + if err := os.WriteFile(absolute, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write %s: %v", path, err) + } + unit.Sources = append(unit.Sources, path) + } + + return unit +} + +// Verifies a unit's identifiers come from every source, and its generator output's from every C# file +// under the directory, nested directories included, with reading the latter reported. +func TestReadIdentifiersReadsSourcesAndGeneratedCode(t *testing.T) { + root := t.TempDir() + unit := writeUnitSources(t, root, map[string]string{"Assets/A.cs": "class First { }", "Assets/B.cs": "class Second { }"}) + writeUnitSources(t, root, map[string]string{"Generated/sub/a.CS": "class Third { }", "Generated/notes.txt": "Fourth"}) + + set, withGenerated, err := readGeneratedIdentifiers(filepath.Join(root, "Generated")) + if err != nil { + t.Fatalf("expected the generated identifiers to be read, got %v", err) + } + if err := readSourceIdentifiers(root, unit, set); err != nil { + t.Fatalf("expected the source identifiers to be read, got %v", err) + } + + assertHasIdentifiers(t, set, []string{"First", "Second", "Third"}, []string{"Fourth"}) + if !withGenerated { + t.Error("expected the generated code to be reported as read") + } +} + +// Verifies a missing generator output directory reads as no generated code rather than an error. +func TestReadGeneratedIdentifiersWithoutGeneratedCode(t *testing.T) { + set, withGenerated, err := readGeneratedIdentifiers(filepath.Join(t.TempDir(), "Missing")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if withGenerated || len(set) != 0 { + t.Errorf("expected no generated code, got %v (reported read: %v)", set, withGenerated) + } +} + +// Verifies a source given by absolute path, as a local package's is, is read from that path. +func TestReadSourceIdentifiersReadsAnAbsoluteSource(t *testing.T) { + elsewhere := t.TempDir() + writeUnitSources(t, elsewhere, map[string]string{"Package/P.cs": "class Packaged { }"}) + unit := CompileUnit{ + Assembly: ResponseFile{AssemblyName: "Unit"}, + Sources: []string{filepath.ToSlash(filepath.Join(elsewhere, "Package", "P.cs"))}, + } + set := identifierSet{} + + if err := readSourceIdentifiers(t.TempDir(), unit, set); err != nil { + t.Fatalf("expected the identifiers to be read, got %v", err) + } + + assertHasIdentifiers(t, set, []string{"Packaged"}, nil) +} + +// Verifies a unit without sources, or with one that cannot be read, is refused rather than read as +// an empty set, so it is never read as writing no changed name. +func TestReadSourceIdentifiersRefusesAUnitItCannotRead(t *testing.T) { + root := t.TempDir() + missing := CompileUnit{Assembly: ResponseFile{AssemblyName: "Unit"}, Sources: []string{"Assets/Missing.cs"}} + for label, unit := range map[string]CompileUnit{"no sources": {Assembly: ResponseFile{AssemblyName: "Unit"}}, "missing": missing} { + if err := readSourceIdentifiers(root, unit, identifierSet{}); err == nil { + t.Errorf("%s: expected an error", label) + } + } +} + +// Verifies the background read is over once wait returns, and that lookup answers only for the +// units it was asked to read. +func TestNameIndexIsOverOnceWaitReturns(t *testing.T) { + root := t.TempDir() + unit := writeUnitSources(t, root, map[string]string{"Assets/A.cs": "class First { }"}) + index := nameIndex{} + index.start(root, []CompileUnit{unit, unit}, []string{filepath.Join(root, "Missing"), ""}, []bool{true, false}) + + index.wait() + + select { + case <-index.done: + default: + t.Fatal("expected the read to be over once wait returned") + } + if _, err := index.lookup(0); err != nil { + t.Errorf("expected the identifiers of the read unit, got %v", err) + } + if _, err := index.lookup(1); err == nil { + t.Error("expected no identifiers for a unit that was not read") + } + if _, err := (&nameIndex{}).lookup(0); err == nil { + t.Error("expected no identifiers from an index that never started") + } +} diff --git a/cli/dispatcher/internal/compilecheck/scheduler.go b/cli/dispatcher/internal/compilecheck/scheduler.go index 77c69dce5f..e6fe233014 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler.go +++ b/cli/dispatcher/internal/compilecheck/scheduler.go @@ -47,6 +47,10 @@ type schedule struct { blocked []bool // keptSurface marks, per unit, that it finished with the public surface of the last Unity build. keptSurface []bool + // unaffectedBy lists, per unit, the references that changed their surface only in names the unit + // never writes, and changedBy the ones whose change counted for it. + unaffectedBy [][]int + changedBy [][]changedReference unitProgress } @@ -60,6 +64,10 @@ type compileOutcome struct { // Blocked names the units left out because an assembly they reference has errors, in the plan's // order. They are kept apart from Skipped: nothing they compile from was read at all. Blocked []string + // NameSkips names the skipped units a reference did change under, in names they never write. + NameSkips []NameSkip + // NameIndex says what reading the units' names took. + NameIndex NameIndexReport } // DefaultJobs is how many assemblies compile at once when the caller names no number. @@ -86,6 +94,8 @@ type schedulerRun struct { completions chan unitCompletion running int referenceSkips int + names nameIndex + nameSkips []NameSkip } // compileUnits compiles a plan's assemblies, keeping at most jobs of them running at a time. It @@ -121,6 +131,10 @@ func compileUnits( compiled: make([]bool, len(plan.Units)), completions: make(chan unitCompletion, len(plan.Units)), } + run.startNameIndex() + // Why waited for on every way out: the read runs on files a caller may remove once the run + // returns. + defer run.names.wait() for index := range plan.Units { run.offer(index) } @@ -151,9 +165,11 @@ func compileUnits( } return compileOutcome{ - Units: run.compiledResults(), - Skipped: run.referenceSkips, - Blocked: run.blockedNames(), + Units: run.compiledResults(), + Skipped: run.referenceSkips, + Blocked: run.blockedNames(), + NameSkips: run.nameSkips, + NameIndex: run.names.report(), }, nil } @@ -217,14 +233,15 @@ func (run *schedulerRun) skipBlockedUnit(index int) (bool, error) { return false, err } run.state.blockDependents(index) - run.releaseAndOffer(index, false) + run.releaseAndOffer(index, SurfaceVerdict{Kept: true}) return true, nil } // skipUnchangedDependent leaves out a unit this run picked up only because something it references // was recompiled, when every one of those came out with the public surface the last Unity build -// recorded: compiling it would read exactly the API it was last compiled against. It counts as +// recorded, or changed only names the unit never writes: compiling it would read exactly the API it +// was last compiled against, as far as anything it writes can tell. It counts as // having kept Unity's surface, so the units that started before it finished read Unity's reference // assembly for it, as they assumed. // Why its earlier outputs are removed rather than left alone: a reference assembly an earlier run @@ -242,8 +259,9 @@ func (run *schedulerRun) skipUnchangedDependent(index int) (bool, error) { return false, err } run.referenceSkips++ + run.recordNameSkip(index) run.state.keptSurface[index] = true - run.releaseAndOffer(index, false) + run.releaseAndOffer(index, SurfaceVerdict{Kept: true}) return true, nil } @@ -362,6 +380,8 @@ func newSchedule(plan BuildPlan) *schedule { changedInputs: make([]int, len(plan.Units)), blocked: make([]bool, len(plan.Units)), keptSurface: make([]bool, len(plan.Units)), + unaffectedBy: make([][]int, len(plan.Units)), + changedBy: make([][]changedReference, len(plan.Units)), unitProgress: newUnitProgress(len(plan.Units)), } for index, unit := range plan.Units { @@ -395,22 +415,28 @@ func (state *schedule) blockDependents(index int) { } } -// release tells the units waiting on one that just got its final state that it has, recording -// whether what it produced still describes the same public surface as the last Unity build, and -// reports those units so the caller can decide which of them to queue. -// Why a unit that assumed this one is marked stale only when it changed: it read Unity's reference -// assembly for it, which is exactly right when the surface was kept. A failed or blocked one blocks -// it through blockDependents instead. -func (state *schedule) release(index int, changed bool) []int { - for _, dependent := range state.dependents[index] { +// release tells the units waiting on one that just got its final state that it has, recording for +// each whether what it produced touches it, and reports those units so the caller can decide which +// of them to queue. effects holds one entry per unit waiting on it, in the order of dependents. +// Why a unit that assumed this one is marked stale only when touched: it read Unity's reference +// assembly for it, which is exactly right when the surface was kept, and compiles the same when the +// change touches no name it writes. A failed or blocked one blocks it through blockDependents instead. +func (state *schedule) release(index int, effects []referenceEffect) []int { + for _, effect := range effects { + dependent := effect.dependent state.pending[dependent]-- if state.ownSources[index] { state.pendingOwn[dependent]-- } - if !changed { + if !effect.touches { + if effect.named { + state.unaffectedBy[dependent] = append(state.unaffectedBy[dependent], index) + } + continue } state.changedInputs[dependent]++ + state.changedBy[dependent] = append(state.changedBy[dependent], changedReference{reference: index, name: effect.name}) if !state.stale[dependent] && containsIndex(state.assumed[dependent], index) { state.stale[dependent] = true state.staleCause[dependent] = index diff --git a/cli/dispatcher/internal/compilecheck/scheduler_names_test.go b/cli/dispatcher/internal/compilecheck/scheduler_names_test.go new file mode 100644 index 0000000000..c8689c85ce --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/scheduler_names_test.go @@ -0,0 +1,281 @@ +package compilecheck + +import ( + "context" + "path/filepath" + "reflect" + "testing" + "time" +) + +// namedVerdicts stands in for the surface comparison: the units it names get the verdict it holds, +// and every other unit is compared by bytes, as a run without the worker does. +type namedVerdicts map[string]SurfaceVerdict + +func (verdicts namedVerdicts) compare(projectRoot string, plan BuildPlan, unit CompileUnit) SurfaceVerdict { + if verdict, found := verdicts[unit.Assembly.AssemblyName]; found { + return verdict + } + + return Compiler{ProjectRoot: projectRoot}.referenceSurfaceVerdict(context.Background(), plan, unit) +} + +// namedChangeOf is the verdict of a reference whose change the worker told by the given names. +func namedChangeOf(names ...string) SurfaceVerdict { + return SurfaceVerdict{Reason: SurfaceChanged, ChangedNames: names} +} + +// compileWithVerdicts runs the whole plan with the given surface verdicts and fails the test when the +// run could not finish. +func (project *cachedSchedulerProject) compileWithVerdicts( + t *testing.T, recorder *schedulerRecorder, verdicts namedVerdicts, +) compileOutcome { + t.Helper() + compiler := Compiler{ + Paths: project.paths, + ProjectRoot: project.root, + Timeout: time.Minute, + Run: recorder.runner(), + compareSurface: verdicts.compare, + } + outcome, err := compileUnits(context.Background(), compiler, project.plan, 4) + if err != nil { + t.Fatalf("expected the run to succeed, got error: %v", err) + } + + return outcome +} + +// newNamesProject is A, selected for its own sources, and B, a dependent of A, whose source is given. +func newNamesProject(t *testing.T, sourceOfB string) (*cachedSchedulerProject, *schedulerRecorder) { + t.Helper() + project, recorder := newOptimisticCachedProject(t, map[string][]string{"B": {"A"}}, []string{"B"}, "A", "B") + project.editSource(t, "B", sourceOfB) + + return project, recorder +} + +func assertChangedReferences(t *testing.T, result UnitResult, want []ChangedReference) { + t.Helper() + if !reflect.DeepEqual(result.ChangedReferences, want) { + t.Errorf("%s changed references = %+v, want %+v", result.Assembly, result.ChangedReferences, want) + } +} + +// Verifies a dependent that writes none of the names its reference's change touches is left out the +// way one whose reference kept its surface is, and is reported as left out by names. +func TestCompileUnitsSkipsADependentThatWritesNoChangedName(t *testing.T) { + project, recorder := newNamesProject(t, "class B { int Bar; }") + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{"A": namedChangeOf("A", "Foo")}) + + assertStrings(t, "results", compiledNames(outcome.Units), []string{"A"}) + if recorder.started["B"] || outcome.Skipped != 1 { + t.Errorf("B started = %v, skipped = %d; want B left out", recorder.started["B"], outcome.Skipped) + } + want := []NameSkip{{Assembly: "B", ChangedReferences: []string{"A"}}} + if !reflect.DeepEqual(outcome.NameSkips, want) { + t.Errorf("name skips = %+v, want %+v", outcome.NameSkips, want) + } + if outcome.NameIndex.Units != 1 || outcome.NameIndex.Unreadable != 0 { + t.Errorf("name index = %+v, want one readable unit", outcome.NameIndex) + } +} + +// Verifies a dependent that writes one of the changed names compiles and says which name made it, +// whether it writes the member's name or only the type's. +func TestCompileUnitsCompilesADependentThatWritesAChangedName(t *testing.T) { + cases := map[string]struct { + source string + names []string + want string + }{ + "member": {source: "class B { int x = Make().Foo; }", names: []string{"A", "Foo"}, want: "Foo"}, + "type": {source: "class B { C c; }", names: []string{"C", "Foo"}, want: "C"}, + } + for label, testCase := range cases { + t.Run(label, func(t *testing.T) { + project, recorder := newNamesProject(t, testCase.source) + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{"A": namedChangeOf(testCase.names...)}) + + assertStrings(t, "results", compiledNames(outcome.Units), []string{"A", "B"}) + assertChangedReferences(t, resultOf(t, outcome.Units, "B"), []ChangedReference{{Assembly: "A", Name: testCase.want}}) + if len(outcome.NameSkips) != 0 { + t.Errorf("name skips = %+v, want none", outcome.NameSkips) + } + }) + } +} + +// Verifies a dependent whose names cannot be read compiles on a named change, and is counted as +// unreadable. +func TestCompileUnitsCompilesADependentWhoseNamesCannotBeRead(t *testing.T) { + project, recorder := newNamesProject(t, "class B { }") + index := planIndexOf(t, project.plan, "B") + project.plan.Units[index].Sources = append(project.plan.Units[index].Sources, "Assets/B/Missing.cs") + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{"A": namedChangeOf("Foo")}) + + assertStrings(t, "results", compiledNames(outcome.Units), []string{"A", "B"}) + if outcome.NameIndex.Unreadable != 1 { + t.Errorf("name index = %+v, want one unreadable unit", outcome.NameIndex) + } +} + +// Verifies a change no name tells makes every dependent compile, even one that carries names as +// well, and the change is reported without a name. +func TestCompileUnitsCompilesEveryDependentOnAnUnnamedChange(t *testing.T) { + verdicts := []SurfaceVerdict{ + {Reason: SurfaceChanged, Unnamed: "operator op_Addition"}, + {Reason: SurfaceChanged, Unnamed: "operator op_Addition", ChangedNames: []string{"Foo"}}, + } + for _, verdict := range verdicts { + project, recorder := newNamesProject(t, "class B { }") + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{"A": verdict}) + + assertStrings(t, "results", compiledNames(outcome.Units), []string{"A", "B"}) + assertChangedReferences(t, resultOf(t, outcome.Units, "B"), []ChangedReference{{Assembly: "A"}}) + } +} + +// Verifies a dependent that started on the assumption that a reference keeps its surface keeps that +// compile when the reference changed only names the dependent never writes, whether it was selected +// as a dependent or for its own sources. +func TestCompileUnitsKeepsAnEarlyCompileWhenTheAssumedReferenceChangedNoNameItWrites(t *testing.T) { + for label, dependents := range map[string][]string{"dependent": {"D1", "D2"}, "own sources": {"D1"}} { + t.Run(label, func(t *testing.T) { + project, recorder := newOptimisticCachedProject(t, chainReferences, dependents, "X", "K", "D1", "D2") + recorder.holdUntilBegun(t, "K", "D2") + recorder.surfaces["K"] = keptSurfaceOf("K") + project.editSource(t, "D2", "class D2 { int Bar; }") + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{"D1": namedChangeOf("D1", "Foo")}) + + if recorder.runs["D2"] != 1 { + t.Fatalf("D2 compiled %d times, want 1", recorder.runs["D2"]) + } + d2 := resultOf(t, outcome.Units, "D2") + if d2.CompiledAgainAfter != "" || !reflect.DeepEqual(d2.AssumedReferences, []string{"D1"}) { + t.Errorf("D2 = %+v, want one compile that assumed D1", d2) + } + assertChangedReferences(t, d2, []ChangedReference{{Assembly: "X"}}) + }) + } +} + +// Verifies a dependent that started on the assumption that a reference keeps its surface compiles +// again when the reference changed a name it writes. +func TestCompileUnitsCompilesAgainWhenTheAssumedReferenceChangedANameItWrites(t *testing.T) { + project, recorder := newOptimisticCachedProject(t, chainReferences, []string{"D1", "D2"}, "X", "K", "D1", "D2") + recorder.holdUntilBegun(t, "K", "D2") + recorder.surfaces["K"] = keptSurfaceOf("K") + project.editSource(t, "D2", "class D2 { int x = new D1().Foo; }") + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{"D1": namedChangeOf("D1", "Foo")}) + + if recorder.runs["D2"] != 2 { + t.Fatalf("D2 compiled %d times, want 2", recorder.runs["D2"]) + } + d2 := resultOf(t, outcome.Units, "D2") + if d2.CompiledAgainAfter != "D1" { + t.Errorf("D2 compiled again after %q, want D1", d2.CompiledAgainAfter) + } + assertChangedReferences(t, d2, []ChangedReference{{Assembly: "X"}, {Assembly: "D1", Name: "D1"}}) +} + +// Verifies a unit selected for its own sources still compiles against the reference assembly this +// run produced for a reference that changed only names it never writes: whether a reference touches +// a unit never decides which file the unit reads. +func TestCompileUnitsReadsTheProducedReferenceOfANameChangeItDoesNotWrite(t *testing.T) { + project, recorder := newOptimisticCachedProject(t, map[string][]string{"B": {"A"}}, nil, "A", "B") + project.editSource(t, "B", "class B { }") + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{"A": namedChangeOf("Foo")}) + + assertStrings(t, "results", compiledNames(outcome.Units), []string{"A", "B"}) + assertReferenceFlags(t, project.writtenResponseFile(t, "B"+responseFileExtension), + []string{producedReferenceFlag(project.plan, "A")}, []string{unityReferenceFlag("A")}) + assertChangedReferences(t, resultOf(t, outcome.Units, "B"), nil) +} + +// Verifies a dependent another reference touches compiles, and names only that reference. +func TestCompileUnitsNamesOnlyTheReferencesThatTouchedADependent(t *testing.T) { + project, recorder := newOptimisticCachedProject(t, map[string][]string{"D": {"X", "P"}}, []string{"D"}, "X", "P", "D") + project.editSource(t, "D", "class D { }") + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{ + "X": namedChangeOf("Foo"), + "P": {Reason: SurfaceChanged, Unnamed: "operator op_Addition"}, + }) + + assertStrings(t, "results", compiledNames(outcome.Units), []string{"X", "P", "D"}) + assertChangedReferences(t, resultOf(t, outcome.Units, "D"), []ChangedReference{{Assembly: "P"}}) +} + +// Verifies a dependent left out by names counts as keeping its surface, so the units below it are +// left out too. +func TestCompileUnitsSkipsTheChainBelowADependentLeftOutByNames(t *testing.T) { + project, recorder := newOptimisticCachedProject(t, + map[string][]string{"D": {"X"}, "E": {"D"}}, []string{"D", "E"}, "X", "D", "E") + project.editSource(t, "D", "class D { }") + project.editSource(t, "E", "class E { Foo f; }") + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{"X": namedChangeOf("Foo")}) + + assertStrings(t, "results", compiledNames(outcome.Units), []string{"X"}) + if outcome.Skipped != 2 || recorder.started["E"] { + t.Errorf("skipped = %d, E started = %v; want D and E left out", outcome.Skipped, recorder.started["E"]) + } +} + +// Verifies a changed name only the dependent's saved generator output writes makes it compile. +func TestCompileUnitsCompilesADependentWhoseGeneratedCodeWritesAChangedName(t *testing.T) { + project, recorder := newNamesProject(t, "class B { }") + project.writeFile(t, filepath.Join(project.records().GeneratedDirectory("B"), "sub", "Gen.g.cs"), "partial class B { Foo f; }") + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{"A": namedChangeOf("Foo")}) + + assertStrings(t, "results", compiledNames(outcome.Units), []string{"A", "B"}) + if outcome.NameIndex.WithGenerated != 1 { + t.Errorf("name index = %+v, want one unit read with its generated code", outcome.NameIndex) + } +} + +// planIndexOf finds a unit's position in the plan. +func planIndexOf(t *testing.T, plan BuildPlan, name string) int { + t.Helper() + for index, unit := range plan.Units { + if unit.Assembly.AssemblyName == name { + return index + } + } + t.Fatalf("the plan has no unit %s", name) + + return -1 +} + +// Verifies a unit that started on the assumption that a reference keeps its surface compiles again +// when the reference changed a name only the unit's saved generator output writes, although the unit's +// own compile removes that output as soon as it starts. +func TestCompileUnitsCompilesAgainOnANameOnlyGeneratedCodeWritesWhenTheEarlyCompileRemovesIt(t *testing.T) { + project, recorder := newOptimisticCachedProject(t, + map[string][]string{"D1": {"X"}, "D2": {"D1"}}, []string{"D1"}, "X", "D1", "D2") + project.editSource(t, "D2", "class D2 { int Bar; }") + project.writeFile(t, filepath.Join(project.records().GeneratedDirectory("D2"), "sub", "Gen.g.cs"), "partial class D2 { Foo f; }") + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{ + "X": {Reason: SurfaceChanged, Unnamed: "operator op_Addition"}, + "D1": namedChangeOf("Foo"), + }) + + if recorder.runs["D2"] != 2 { + t.Fatalf("D2 compiled %d times, want 2", recorder.runs["D2"]) + } + d2 := resultOf(t, outcome.Units, "D2") + if d2.CompiledAgainAfter != "D1" { + t.Errorf("D2 compiled again after %q, want D1", d2.CompiledAgainAfter) + } + assertChangedReferences(t, d2, []ChangedReference{{Assembly: "D1", Name: "Foo"}}) +} diff --git a/cli/dispatcher/internal/compilecheck/scheduler_settle.go b/cli/dispatcher/internal/compilecheck/scheduler_settle.go index 3ede0081d7..574a44ee12 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_settle.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_settle.go @@ -130,10 +130,10 @@ func (run *schedulerRun) offer(index int) { state.enqueue(index) } -// releaseAndOffer tells the units waiting on one that just got its final state, and queues those that -// may start now. -func (run *schedulerRun) releaseAndOffer(index int, changed bool) { - for _, dependent := range run.state.release(index, changed) { +// releaseAndOffer tells the units waiting on one that just got its final state what its verdict means +// for each of them, and queues those that may start now. +func (run *schedulerRun) releaseAndOffer(index int, verdict SurfaceVerdict) { + for _, dependent := range run.state.release(index, run.effectsOf(index, verdict)) { run.offer(dependent) } } @@ -198,7 +198,7 @@ func (run *schedulerRun) settle(index int) error { return err } state.blockDependents(index) - run.releaseAndOffer(index, false) + run.releaseAndOffer(index, SurfaceVerdict{Kept: true}) case state.stale[index]: state.discarded[index] = run.results[index].Duration run.results[index] = UnitResult{} @@ -226,6 +226,7 @@ func (run *schedulerRun) adopt(index int) error { result.CompiledAgainAfter = run.plan.Units[cause].Assembly.AssemblyName result.DiscardedDuration = state.discarded[index] } + result.ChangedReferences = run.changedReferencesOf(index) run.results[index] = result if err := run.markFinal(index); err != nil { return err @@ -235,7 +236,10 @@ func (run *schedulerRun) adopt(index int) error { if !result.Succeeded { state.blockDependents(index) } - run.releaseAndOffer(index, !state.keptSurface[index]) + // Why the result's verdict rather than keptSurface alone: a unit compared and found changed + // carries the names its change touches, and a unit not compared - failed, or read by nothing - + // carries the zero verdict, which touches every unit waiting on it. + run.releaseAndOffer(index, result.Surface) return nil } diff --git a/cli/dispatcher/internal/compilecheck/scheduler_surface_test.go b/cli/dispatcher/internal/compilecheck/scheduler_surface_test.go index da98a97665..35ada12072 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_surface_test.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_surface_test.go @@ -264,12 +264,12 @@ type surfaceComparisonCounter struct { } // compare records one comparison of the unit's surface. -func (counter *surfaceComparisonCounter) compare(_ string, _ BuildPlan, unit CompileUnit) bool { +func (counter *surfaceComparisonCounter) compare(_ string, _ BuildPlan, unit CompileUnit) SurfaceVerdict { counter.mutex.Lock() defer counter.mutex.Unlock() counter.compared[unit.Assembly.AssemblyName]++ - return false + return SurfaceVerdict{Reason: SurfaceBytesDiffer, Unnamed: SurfaceBytesDiffer} } // Verifies a run compares the surface only of the units whose answer something in the plan reads: diff --git a/cli/dispatcher/internal/compilecheck/session.go b/cli/dispatcher/internal/compilecheck/session.go index 829d882d04..b01acbda27 100644 --- a/cli/dispatcher/internal/compilecheck/session.go +++ b/cli/dispatcher/internal/compilecheck/session.go @@ -46,6 +46,10 @@ type Result struct { // They are kept apart from Skipped: a skipped assembly was read and found to have nothing left // to say, and nothing about these was read at all. Blocked []string + // NameSkips names the skipped assemblies a reference changed under, in names they never write. + NameSkips []NameSkip + // NameIndex says what reading the names of the assemblies with references in the plan took. + NameIndex NameIndexReport } // Run compiles the assemblies that need checking and collects their diagnostics. @@ -102,8 +106,10 @@ func Run(ctx context.Context, options Options) (Result, error) { // Why the two counts are added: both name an assembly the run did not compile - one because // nothing it compiles from changed, the other because everything it references kept the // public surface the last Unity build recorded. - Skipped: plan.Skipped + outcome.Skipped, - Blocked: outcome.Blocked, + Skipped: plan.Skipped + outcome.Skipped, + Blocked: outcome.Blocked, + NameSkips: outcome.NameSkips, + NameIndex: outcome.NameIndex, }, nil } diff --git a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go index cf7923ec5b..74331aa1cb 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go @@ -89,6 +89,7 @@ func (logRun compileCheckVibeLog) completed(response compileCheckResponse, resul context["baseline_notes"] = compileCheckVibeReasons(response.FastPathBaselineNotes) context["units"] = compileCheckVibeUnits(result.Units) context["surface_checks"] = compileCheckVibeSurfaceChecks(result.Units) + addCompileCheckVibeNameNarrowing(context, result) logRun.write(level, compileCheckVibeCompleteOperation, "compile-check finished.", context) } @@ -178,6 +179,33 @@ func addCompileCheckVibeScheduling(entry map[string]any, unit compilecheck.UnitR if unit.WaitedForRecord { entry["waited_for_record"] = true } + if len(unit.ChangedReferences) > 0 { + changedBy := make([]map[string]any, 0, len(unit.ChangedReferences)) + for _, reference := range unit.ChangedReferences { + changedBy = append(changedBy, map[string]any{"reference": reference.Assembly, "name": reference.Name}) + } + entry["changed_by"] = changedBy + } +} + +// addCompileCheckVibeNameNarrowing adds the units left out because they write none of the names a +// reference's change touches, and what reading the names took, each only when there is any. +func addCompileCheckVibeNameNarrowing(context map[string]any, result compilecheck.Result) { + if len(result.NameSkips) > 0 { + skips := make([]map[string]any, 0, len(result.NameSkips)) + for _, skip := range result.NameSkips { + skips = append(skips, map[string]any{"assembly": skip.Assembly, "unaffected_by": skip.ChangedReferences}) + } + context["name_skips"] = skips + } + if result.NameIndex.Units > 0 { + context["name_index"] = map[string]any{ + "duration_ms": result.NameIndex.Duration.Milliseconds(), + "units": result.NameIndex.Units, + "with_generated": result.NameIndex.WithGenerated, + "unreadable": result.NameIndex.Unreadable, + } + } } // compileCheckVibeSurfaceChecks lists every unit whose surface the worker, or its record of the same diff --git a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go index 71a41a9e10..1b12cffef5 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go @@ -247,3 +247,51 @@ func TestCompileCheckVibeLogRecordsUnitsCompiledAgain(t *testing.T) { `{"assembly":"Epsilon","compiled_again_after":"Gamma","discarded_ms":50,"duration_ms":300,`+ `"succeeded":true,"waited_for_record":true}]`) } + +// Verifies a run that narrowed dependents by names logs which references touched each unit and by +// which name, which units it left out by names, and what reading the names took. +func TestCompileCheckVibeLogRecordsNameNarrowing(t *testing.T) { + t.Setenv(vibelog.CLIVibeLogEnvName, "1") + projectRoot := t.TempDir() + result := compilecheck.Result{ + Units: []compilecheck.UnitResult{ + {Assembly: "Alpha", Succeeded: true, Duration: 100 * time.Millisecond}, + { + Assembly: "Beta", Succeeded: true, Duration: 200 * time.Millisecond, + ChangedReferences: []compilecheck.ChangedReference{{Assembly: "Alpha", Name: "Foo"}, {Assembly: "Gamma"}}, + }, + }, + Skipped: 1, + NameSkips: []compilecheck.NameSkip{{Assembly: "Delta", ChangedReferences: []string{"Alpha"}}}, + NameIndex: compilecheck.NameIndexReport{Duration: 30 * time.Millisecond, Units: 3, WithGenerated: 1, Unreadable: 1}, + } + + logRun := startCompileCheckVibeLog(projectRoot, compileCheckOptions{}) + logRun.completed(buildCompileCheckResponse(result, projectRoot), result) + + complete := assertCompileCheckVibeRun(t, readCompileCheckVibeLog(t, projectRoot)) + assertVibeContextJSON(t, complete, "units", + `[{"assembly":"Alpha","duration_ms":100,"succeeded":true},`+ + `{"assembly":"Beta","changed_by":[{"name":"Foo","reference":"Alpha"},{"name":"","reference":"Gamma"}],`+ + `"duration_ms":200,"succeeded":true}]`) + assertVibeContextJSON(t, complete, "name_skips", `[{"assembly":"Delta","unaffected_by":["Alpha"]}]`) + assertVibeContextJSON(t, complete, "name_index", + `{"duration_ms":30,"units":3,"unreadable":1,"with_generated":1}`) +} + +// Verifies a run that read no names and left nothing out by names logs neither. +func TestCompileCheckVibeLogLeavesOutNameNarrowingWhenNothingWasRead(t *testing.T) { + t.Setenv(vibelog.CLIVibeLogEnvName, "1") + projectRoot := t.TempDir() + result := compilecheck.Result{Units: []compilecheck.UnitResult{{Assembly: "Alpha", Succeeded: true}}} + + logRun := startCompileCheckVibeLog(projectRoot, compileCheckOptions{}) + logRun.completed(buildCompileCheckResponse(result, projectRoot), result) + + complete := assertCompileCheckVibeRun(t, readCompileCheckVibeLog(t, projectRoot)) + for _, key := range []string{"name_skips", "name_index"} { + if _, found := complete.Context[key]; found { + t.Errorf("expected no %s in the log, got %v", key, complete.Context[key]) + } + } +} diff --git a/docs/compile-check.md b/docs/compile-check.md index 569de3b02b..2b6afddb2e 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -55,6 +55,23 @@ references, scripting defines and analyzers. `compile-check` replays those respo private nested type may report the error under another ID (CS0246, CS0426 or CS0117 in place of CS0122, or the other way round) until it compiles again; whether it compiles does not change. When the worker cannot be built or cannot answer, the dependents are compiled. + When the API did change, the worker also says which names the change touches: the simple name of + the type at each level of nesting (with and without an `Attribute` suffix), a namespace segment + only when one side lacks that namespace, the member's name (a property or event rather than its + accessor, the member alone for an explicit implementation), and `new(` for a constructor that a + target-typed `new` can reach. A dependent that writes none of those names - in its own sources + or in the generator output its last full compile saved, comments and strings included - is + dropped the same way, and counts as keeping its surface for the assemblies below it. Whether + a reference touches a dependent never decides which reference assembly the dependent reads when + it compiles for another reason. A change a dependent can feel without writing any name is + unnamed and compiles every dependent: an assembly-level row; for a type both sides have, its + flags or attributes, base type, interfaces, type parameters, an instance field of a value type, + a parameterless constructor or an indexer; an abstract member added; an operator; a + compiler-generated member; a member C# binds by pattern (`GetEnumerator`, `GetAwaiter`, `Add`, + `Deconstruct`, `Dispose`, `SetResult`, the LINQ query methods and the like); every member of an + interpolated string handler or collection builder type; every member of a type in `System`, + `Microsoft.CodeAnalysis` or a namespace under them, which the compiler looks up by name for + `init`, `^1` and the like; members that only moved; and a change naming more than 2000 names. A dependent whose sources cannot be read compiles too. Its answer is kept in `.surface.json`, keyed by the content of both reference assemblies and the worker build, so the runs until Unity builds again do not compare the same pair twice. The comparison is always against Unity's own artifact, never against the @@ -336,6 +353,20 @@ happened, and how long each assembly took. `docs/vibe-logs.md` lists their field invalidate a reused result**, because it is not part of the input digest — the same exposure the dependent skip in step 3 already has. One handed to the compiler as `/analyzerconfig:` is read by content and does invalidate it. `--all` compiles everything again. +- **A dependent left out by names can miss what a generator would write.** The names are read from + the dependent's sources and from the generator output its last full compile saved. Leaving a + dependent out - because its references kept their surface or changed only names it never writes - + removes that saved output, so in steady use the names come from the hand-written sources alone and + the VibeLog's `name_index.with_generated` stays near 0. A generator that emits code using a member + of a referenced type whose name the dependent's own sources never write is then not seen. Closing + that gap needs a left-out dependent to keep its outputs and record, which this check does not do + yet. Neither is a change to the type of an async method builder seen, which a dependent never + names; a change to its members is unnamed and compiles every dependent. +- **Every change to a type in `System`, `Microsoft.CodeAnalysis` or a namespace under them compiles + every dependent.** The compiler looks these types up by name, so only polyfills normally live + there; a project that puts its own extension classes in `System.*` gets no narrowing for them. +- **Identifiers written with `\u` escapes are not decoded** when the dependent's names are read, so + a dependent that writes a changed name only that way is left out. `--all` compiles everything. - **Linux (x86_64) is supported.** The Editor is looked up under `~/Unity/Hub/Editor//Editor/Unity`. ## Troubleshooting diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 82260ad373..70510648b5 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -280,7 +280,7 @@ because there is no project to write into. | `operation` | Written | `context` | |---|---|---| | `cli_compile_check_start` | once the project is found | `command`, `all`, `full`, `jobs`, `project_identity` | -| `cli_compile_check_complete` (`INFO`; `WARNING` when the run found compile errors; `ERROR` when it could not finish) | on every way out of a run that wrote the start entry | `command`, `outcome` (`succeeded`, `errors`, or `failed`), `duration_ms`; for `failed`, `error` and `error_code`; otherwise `error_count`, `warning_count`, `compiled`, `reused`, `fast_checked`, `blocked` (assembly names, as in the response), `skipped_count`, `fast_checks` (`assembly`, `reused`, `changed_file_count`), `fallbacks` and `baseline_notes` (`assembly`, `reason`), `units` (`assembly`, `duration_ms`, `succeeded` of each assembly the run compiled or checked; replayed ones are left out; `assumed_kept` names the references it started ahead of, assuming they kept Unity's surface, `compiled_again_after` the one whose changed surface made it compile again and `discarded_ms` how long the compile it threw away took, and `waited_for_record` says it waited for its references for a record that then did not apply - each only when it applies), and `surface_checks` (`assembly`, `verdict`, `detail`, `duration_ms`, `from_record` of each assembly whose rebuilt reference assembly differed in bytes from Unity's and was compared by the worker or taken from its record, replayed ones included; `changed_name_count` and `changed_names`, the first 10 of the identifiers a dependent has to write to be affected by the change, and `unnamed`, why no name tells the change, only when it applies) | +| `cli_compile_check_complete` (`INFO`; `WARNING` when the run found compile errors; `ERROR` when it could not finish) | on every way out of a run that wrote the start entry | `command`, `outcome` (`succeeded`, `errors`, or `failed`), `duration_ms`; for `failed`, `error` and `error_code`; otherwise `error_count`, `warning_count`, `compiled`, `reused`, `fast_checked`, `blocked` (assembly names, as in the response), `skipped_count`, `fast_checks` (`assembly`, `reused`, `changed_file_count`), `fallbacks` and `baseline_notes` (`assembly`, `reason`), `units` (`assembly`, `duration_ms`, `succeeded` of each assembly the run compiled or checked; replayed ones are left out; `assumed_kept` names the references it started ahead of, assuming they kept Unity's surface, `compiled_again_after` the one whose changed surface made it compile again and `discarded_ms` how long the compile it threw away took, `waited_for_record` says it waited for its references for a record that then did not apply, and `changed_by` lists the references whose changed surface counted for it (`reference`, and `name`, the first changed name it writes, empty when no name tells the change) - each only when it applies), and `surface_checks` (`assembly`, `verdict`, `detail`, `duration_ms`, `from_record` of each assembly whose rebuilt reference assembly differed in bytes from Unity's and was compared by the worker or taken from its record, replayed ones included; `changed_name_count` and `changed_names`, the first 10 of the identifiers a dependent has to write to be affected by the change, and `unnamed`, why no name tells the change, only when it applies), `name_skips` (`assembly`, `unaffected_by`: the assemblies left out although a reference changed, because they write none of the names the change touches; only when there is any), and `name_index` (`duration_ms`, `units`, `with_generated`, `unreadable`: what reading the names of the assemblies with references in the plan took; only when any was read) | - Unlike the tool-command entries, the complete entry holds the run's error message and fallback reasons: they can name assemblies and files of the project, which the command's own output prints From 9452a07dc1b0518d35c348306cc48d3f699a0d44 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 16 Oct 2026 13:20:02 +0900 Subject: [PATCH 30/33] chore: document why compile-check skips dependents that use none of the changed names (#3349) --- .../CliOnlyTools~/CompileCheck/Skill/SKILL.md | 2 +- ...s-that-name-nothing-a-reference-changed.md | 87 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0017-compile-check-skips-dependents-that-name-nothing-a-reference-changed.md diff --git a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md index 3dbcaca77b..aac2892259 100644 --- a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md @@ -89,7 +89,7 @@ A JSON payload: - `FastChecks`: per fast-checked assembly, `ChangedFiles`, `UncheckedCodes` (rules only `--full` runs), `GeneratorsNotRerun`, and `Reused` (the previous run's check of the same edit, reported again) - `FastPathFallbacks`: `Assembly` and `Reason` for each assembly that was compiled in full instead - `FastPathBaselineNotes`: `Assembly` and `Reason` for each successful full compile that left no baseline for the next changed-files check -- `SkippedAssemblies`: how many assemblies were left out: nothing they compile from changed, or every assembly they reference kept the same public surface +- `SkippedAssemblies`: how many assemblies were left out: nothing they compile from changed, or every assembly they reference kept the same public surface or changed only names they never write - `ResponseFileSet`: the Bee build the run replayed - `ProjectRoot`: resolved project root - `Message`: one-line summary diff --git a/docs/adr/0017-compile-check-skips-dependents-that-name-nothing-a-reference-changed.md b/docs/adr/0017-compile-check-skips-dependents-that-name-nothing-a-reference-changed.md new file mode 100644 index 0000000000..65f1ad200c --- /dev/null +++ b/docs/adr/0017-compile-check-skips-dependents-that-name-nothing-a-reference-changed.md @@ -0,0 +1,87 @@ +# Compile Check Skips Dependents That Name Nothing a Reference Changed + +Date: 2026-10-16 + +## Decision + +When an assembly's reference assembly shows its dependents a different API than Unity's last +build, the changed-files check worker also names what the change touches: the simple names of the +types whose rows differ, their members' names, the namespace segments one side lacks, and `new(` +for a constructor a target-typed `new` can reach. `uloop compile-check` then leaves out a dependent +that writes none of those names, in its own sources or in the generator output its last full +compile saved, the same way it leaves out a dependent whose references all kept their surface. The +dependent counts as keeping its surface for the assemblies below it, and an assembly that started +early on the assumption that the reference keeps its surface keeps that compile. + +- A change a dependent can feel without writing any name is **unnamed**, and an unnamed change + compiles every dependent, as before: + - an assembly-level row; + - for a type both sides have: its flags or attributes, base type, interfaces, type parameters, + an instance field of a value type, a parameterless constructor, an indexer; + - an abstract member added; + - an operator, a compiler-generated member, or a member C# binds by pattern; + - every row of an interpolated string handler or collection builder type; + - every row of a type in `System`, `Microsoft.CodeAnalysis` or a namespace under them, on one + side or both: the compiler looks these up by name (`IsExternalInit` for `init`, `Index` for + `^1`), so a dependent uses them without writing their names; + - rows that only moved, and a change with more than 2000 names. + + An unnamed answer carries no names at all, so nothing downstream can narrow by them. +- A type added or removed is named by its type name: a dependent can only reach it by writing that + name, or through another member whose own row then changes too. An extension method is the + exception and is checked like a member. +- Anything the check cannot read compiles: a reference it could not compare, a worker answer that + names nothing, a dependent without sources or with a source it cannot read. +- Whether a reference touches a dependent never decides which reference assembly the dependent + reads when it compiles for another reason, so the input keys and the compiles are what they were. +- A dependent's identifiers are cut mechanically from its text. Comments, strings and code a `#if` + leaves out count as well, which can only make it compile more often. + +## Context + +On a project with 561 assemblies, adding a public property to an assembly that 67 others reference +took about 28.5 seconds after dependents stopped waiting for each other (ADR 0016): about 10 for +the edited assembly and about 18 for the 67 dependents. Measured on that project: + +- a specific type or member name of the edited assembly was written by 0 to 2 of the 67 dependents; +- a short common member name was written by 48 to 52; +- a target-typed `new(` was written by 59. + +Every dependent ran 29 analyzers, about 16 of them source generators. The generator output the +check had saved existed for only 4 dependents, because a skipped dependent's outputs are removed. +In those 4, no generated code used a name of the edited assembly, and every generator in use +generates from its own assembly's code. + +So the dependents that write a changed name are few for a specific name. The others compile to the +same result, and only spend the time. + +## Consequences + +- Editing a specific type or member of a widely referenced assembly is expected to take 12 to 15 + seconds on that project instead of 28.5. Edits to short common names, constructors and type + shapes compile about as many dependents as before. +- A dependent left out by names is not compiled, so an error only compiling it would show is + missed when the names miss it. These are the known gaps: + - a source generator that writes code using a member of a referenced type whose name the + dependent's sources never write, in a dependent with no saved generator output. Leaving a + dependent out - because its references kept their surface or changed only names it never + writes - removes its saved generator output, so in steady use the names come from the + hand-written sources alone and the VibeLog's `name_index.with_generated` stays near 0. Closing + this gap needs a left-out dependent to keep its outputs and record, which is a separate change; + - a change to the type of an async method builder, which a dependent never names (a change to + its members is unnamed); + - an identifier a dependent writes only with `\u` escapes. +- A project that puts its own extension classes in `System.*` gets every dependent compiled when one + of their members changes, because the whole namespace is unnamed. +- The members C# binds by pattern include common method names such as `Create`, `Add`, `Count` and + `Start`, so a signature change to any member of that name compiles every dependent. This costs + time, never a missed error. +- The VibeLog shows the changed names of each surface check, the references that touched each + compiled unit, the units left out by names, and how long reading the names took. + +## Revisit When + +- A dependent compiled by Unity fails on a change this check left it out of. Then add that kind of + change to the unnamed list, or stop leaving dependents out by names. +- The `name_index` duration in the VibeLog is more than a tenth of a run in which no reference + changed by names. Then start reading the names only when the first named change arrives. From 8de6940a40f4f049d90382c1d3e60b9c905c02b8 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 16 Oct 2026 16:09:41 +0900 Subject: [PATCH 31/33] perf: compile-check reads the names of the plan only when a run compiles or needs them (#3350) --- .../internal/compilecheck/compiler.go | 11 ++ .../internal/compilecheck/fast_path.go | 1 + .../internal/compilecheck/name_index.go | 145 ++++++++++++------ .../compilecheck/name_index_fifo_test.go | 39 ++++- .../internal/compilecheck/name_index_test.go | 26 +++- .../internal/compilecheck/result_cache.go | 1 + .../result_cache_compiler_work_test.go | 86 +++++++++++ .../compilecheck/scheduler_names_test.go | 62 ++++++++ .../dispatcher/compile_check_vibe_log.go | 1 + .../dispatcher/compile_check_vibe_log_test.go | 23 ++- docs/compile-check.md | 4 + docs/vibe-logs.md | 2 +- 12 files changed, 346 insertions(+), 55 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/result_cache_compiler_work_test.go diff --git a/cli/dispatcher/internal/compilecheck/compiler.go b/cli/dispatcher/internal/compilecheck/compiler.go index 28182bba0c..7628232404 100644 --- a/cli/dispatcher/internal/compilecheck/compiler.go +++ b/cli/dispatcher/internal/compilecheck/compiler.go @@ -113,6 +113,17 @@ type Compiler struct { // compares the two. Why it can be replaced: the tests have to see which units the run compares at // all, and a byte comparison leaves nothing behind to count. compareSurface func(projectRoot string, plan BuildPlan, unit CompileUnit) SurfaceVerdict + // beforeCompilerWork is called right before a unit compiles in full or the worker checks its + // changed files; nil calls nothing. Why: a run that does either reads what it may need later while + // that work runs, and one that only replays never does. + beforeCompilerWork func() +} + +// startCompilerWork calls the hook set for the moment a unit's compile or check begins, if any. +func (c Compiler) startCompilerWork() { + if c.beforeCompilerWork != nil { + c.beforeCompilerWork() + } } // surfaceVerdict says whether compiling this unit left its reference assembly showing its dependents diff --git a/cli/dispatcher/internal/compilecheck/fast_path.go b/cli/dispatcher/internal/compilecheck/fast_path.go index 0401dab087..00339d3658 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path.go +++ b/cli/dispatcher/internal/compilecheck/fast_path.go @@ -140,6 +140,7 @@ func (runner *fastPathRunner) check( return fastPathAttempt{Used: true, Result: fastCheckedUnitResult(unit, recorded, time.Since(startedAt), true)} } + c.startCompilerWork() responseFilePath, err := writeRewrittenResponseFile( c.ProjectRoot, plan, unit, unit.Assembly.AssemblyName+fastCheckResponseFileSuffix, nil) if err != nil { diff --git a/cli/dispatcher/internal/compilecheck/name_index.go b/cli/dispatcher/internal/compilecheck/name_index.go index 0595f9ab99..c1111b0cea 100644 --- a/cli/dispatcher/internal/compilecheck/name_index.go +++ b/cli/dispatcher/internal/compilecheck/name_index.go @@ -9,6 +9,8 @@ import ( "path/filepath" "slices" "strings" + "sync" + "sync/atomic" "time" "unicode" "unicode/utf8" @@ -34,12 +36,16 @@ type NameSkip struct { // NameIndexReport says what reading the identifiers of the units with references in the plan took. type NameIndexReport struct { + // Duration is the time spent reading the generator output and, when they were read, the sources. Duration time.Duration // Units counts the units whose identifiers were read, WithGenerated those whose saved generator // output was read too, and Unreadable those that could not be read and so compile on any change. Units int WithGenerated int Unreadable int + // SourcesRead says whether the run needed the sources, by compiling a unit, by having the worker + // check its changed files, or by asking a verdict for names. + SourcesRead bool } // identifierSet is the identifiers one unit writes. @@ -162,78 +168,112 @@ func readGeneratedIdentifiers(generatedDirectory string) (identifierSet, bool, e return identifiers, true, nil } -// nameIndex reads, in the background, the identifiers of every unit that references another unit of -// the plan, so a reference that changed only names a unit never writes need not make it compile. +// nameIndex reads the identifiers of every unit that references another unit of the plan, so a +// reference that changed only names a unit never writes need not make it compile. // Why every such unit and not only the dependents: a unit selected for its own sources compiles // anyway, but one that started on the assumption that a reference keeps its surface need not compile // again when that reference changed only names it never writes. type nameIndex struct { + projectRoot string + units []CompileUnit sets []identifierSet errs []error withGenerated []bool indexed []bool done chan struct{} - duration time.Duration - started bool + // generatedDuration and sourcesDuration are how long each read took, measured from its own start + // so the time the run spent before asking for the sources is not counted. + generatedDuration time.Duration + sourcesDuration time.Duration + started bool + sourcesOnce sync.Once + sourcesRead atomic.Bool } -// start reads the identifiers of the units marked in wanted, at most once per run. generated holds -// the generator output directory of each unit. The generator output is read before start returns and -// the sources in the background. -// Why the generator output is read first: a unit's compile removes and rewrites that directory, and a -// read racing it would lose the names only the generator wrote. Sources are never removed, and few -// units have saved generator output, so only the sources are worth reading while compiles run. +// unitIdentifiers is what was read of one unit's identifiers. +type unitIdentifiers struct { + set identifierSet + withGenerated bool + err error +} + +// start reads the generator output of the units marked in wanted, at most once per run, and leaves +// their sources for readSources. generated holds the generator output directory of each unit. +// Why the generator output is read here, before anything compiles: a unit's compile removes and +// rewrites that directory, and a read racing it would lose the names only the generator wrote. +// Sources are never removed, so they can wait until a run needs them. func (index *nameIndex) start(projectRoot string, units []CompileUnit, generated []string, wanted []bool) { if index.started { return } index.started = true + index.projectRoot = projectRoot + index.units = units index.done = make(chan struct{}) index.indexed = wanted startedAt := time.Now() - type unitNames struct { - set identifierSet - withGenerated bool - err error - } - generatedNames := computeInParallel(len(units), func(position int) unitNames { + generatedNames := computeInParallel(len(units), func(position int) unitIdentifiers { if !wanted[position] { - return unitNames{} + return unitIdentifiers{} } set, withGenerated, err := readGeneratedIdentifiers(generated[position]) - return unitNames{set: set, withGenerated: withGenerated, err: err} + return unitIdentifiers{set: set, withGenerated: withGenerated, err: err} }) - go func() { - defer close(index.done) - results := computeInParallel(len(units), func(position int) unitNames { - names := generatedNames[position] - if !wanted[position] || names.err != nil { + index.keep(generatedNames) + index.generatedDuration = time.Since(startedAt) +} + +// readSources starts reading, in the background, the sources of the units start read the generator +// output of, at most once per run; nothing when start never ran. +// Why the read waits for this call: a run that only replays and asks no verdict for names would +// otherwise spend most of its time reading names it never uses. A run that compiles or checks reads +// them while that runs, and a verdict that needs them waits for them. +// Safe to call from any goroutine. +func (index *nameIndex) readSources() { + if !index.started { + return + } + index.sourcesOnce.Do(func() { + index.sourcesRead.Store(true) + go func() { + defer close(index.done) + startedAt := time.Now() + defer func() { index.sourcesDuration = time.Since(startedAt) }() + index.keep(computeInParallel(len(index.units), func(position int) unitIdentifiers { + names := unitIdentifiers{set: index.sets[position], withGenerated: index.withGenerated[position], err: index.errs[position]} + if !index.indexed[position] || names.err != nil { + return names + } + if err := readSourceIdentifiers(index.projectRoot, index.units[position], names.set); err != nil { + return unitIdentifiers{err: err} + } + return names - } - if err := readSourceIdentifiers(projectRoot, units[position], names.set); err != nil { - return unitNames{err: err} - } + })) + }() + }) +} - return names - }) - index.sets = make([]identifierSet, len(units)) - index.errs = make([]error, len(units)) - index.withGenerated = make([]bool, len(units)) - for position, result := range results { - index.sets[position] = result.set - index.errs[position] = result.err - index.withGenerated[position] = result.withGenerated - } - index.duration = time.Since(startedAt) - }() +// keep holds what was read so far. +func (index *nameIndex) keep(results []unitIdentifiers) { + index.sets = make([]identifierSet, len(results)) + index.errs = make([]error, len(results)) + index.withGenerated = make([]bool, len(results)) + for position, result := range results { + index.sets[position] = result.set + index.errs[position] = result.err + index.withGenerated[position] = result.withGenerated + } } -// lookup waits for the index and returns the identifiers of one unit, or an error when it has none. +// lookup reads the sources when nothing has yet, waits for them and returns the identifiers of one +// unit, or an error when it has none. func (index *nameIndex) lookup(position int) (identifierSet, error) { if !index.started { return nil, errors.New("the names of the plan were never read") } + index.readSources() <-index.done if !index.indexed[position] { return nil, errors.New("the names of this unit were not read") @@ -245,20 +285,27 @@ func (index *nameIndex) lookup(position int) (identifierSet, error) { return index.sets[position], nil } -// wait returns once the background read is over, so nothing outlives the run that started it. +// wait returns once the background read of the sources is over, so nothing outlives the run that +// started it; at once when the sources were never asked for. func (index *nameIndex) wait() { - if index.started { + if index.started && index.sourcesRead.Load() { <-index.done } } -// report says what the read took; zero when nothing was read. +// report says what the read took; zero when nothing was read. It waits for the sources when they are +// being read. +// Why it waits itself rather than rely on wait: the run builds its outcome, and with it this report, +// before its deferred wait runs. func (index *nameIndex) report() NameIndexReport { if !index.started { return NameIndexReport{} } - <-index.done - report := NameIndexReport{Duration: index.duration} + sourcesRead := index.sourcesRead.Load() + if sourcesRead { + <-index.done + } + report := NameIndexReport{Duration: index.generatedDuration + index.sourcesDuration, SourcesRead: sourcesRead} for position, indexed := range index.indexed { if !indexed { continue @@ -274,8 +321,9 @@ func (index *nameIndex) report() NameIndexReport { return report } -// startNameIndex starts reading the identifiers of the units that reference another unit of the plan, -// when there is any. +// startNameIndex reads the generator output of the units that reference another unit of the plan, +// when there is any, and has the run read their sources once it first compiles a unit in full or has +// the worker check its changed files. func (run *schedulerRun) startNameIndex() { wanted := make([]bool, len(run.plan.Units)) any := false @@ -292,6 +340,9 @@ func (run *schedulerRun) startNameIndex() { generated[position] = filepath.Join(run.compiler.ProjectRoot, records.GeneratedDirectory(unit.Assembly.AssemblyName)) } run.names.start(run.compiler.ProjectRoot, run.plan.Units, generated, wanted) + // Why a compile or a check of changed files starts the read of the sources: either runs long + // enough to hide the read, while a run that only replays would wait for it. + run.compiler.beforeCompilerWork = run.names.readSources } // referenceEffect is what one reference's final state means for one unit that reads it: whether it diff --git a/cli/dispatcher/internal/compilecheck/name_index_fifo_test.go b/cli/dispatcher/internal/compilecheck/name_index_fifo_test.go index 293704ccba..aac8f5c7e2 100644 --- a/cli/dispatcher/internal/compilecheck/name_index_fifo_test.go +++ b/cli/dispatcher/internal/compilecheck/name_index_fifo_test.go @@ -10,9 +10,9 @@ import ( "time" ) -// Verifies the generator output is read before start returns: with the background read held on a -// source until the generated directory is gone, as a compile that started meanwhile removes it, the -// names only the generator wrote are still in the index. +// Verifies the generator output is read before start returns: with the sources read only after the +// generated directory is gone, as a compile that started meanwhile removes it, and that read held on a +// source until the test releases it, the names only the generator wrote are still in the index. func TestNameIndexReadsGeneratedCodeBeforeStartReturns(t *testing.T) { root := t.TempDir() writeUnitSources(t, root, map[string]string{"Generated/Gen.g.cs": "partial class First { Foo f; }"}) @@ -32,6 +32,7 @@ func TestNameIndexReadsGeneratedCodeBeforeStartReturns(t *testing.T) { if err := os.RemoveAll(filepath.Join(root, "Generated")); err != nil { t.Fatalf("failed to remove the generated code: %v", err) } + index.readSources() releaseFIFO(t, source, "class First { }") set, err := index.lookup(0) @@ -41,6 +42,38 @@ func TestNameIndexReadsGeneratedCodeBeforeStartReturns(t *testing.T) { assertHasIdentifiers(t, set, []string{"First", "Foo"}, nil) } +// Verifies a run that never asks for the sources does not read them: with a source no writer ever +// opens, wait and report still return at once after start. +func TestNameIndexWaitReturnsAtOnceWhenTheSourcesWereNeverAsked(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "Assets", "A.cs") + if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil { + t.Fatalf("failed to create the source directory: %v", err) + } + // Why a FIFO no one writes: opening it blocks forever, so any read of the sources holds wait. + if err := syscall.Mkfifo(source, 0o644); err != nil { + t.Fatalf("failed to create the source FIFO: %v", err) + } + unit := CompileUnit{Assembly: ResponseFile{AssemblyName: "Unit"}, Sources: []string{"Assets/A.cs"}} + index := nameIndex{} + index.start(root, []CompileUnit{unit}, []string{filepath.Join(root, "Generated")}, []bool{true}) + + reported := make(chan NameIndexReport, 1) + go func() { + index.wait() + reported <- index.report() + }() + + select { + case report := <-reported: + if report.SourcesRead || report.Units != 1 { + t.Errorf("report = %+v, want one unit whose sources were not read", report) + } + case <-time.After(5 * time.Second): + t.Fatal("wait or report held on sources nothing asked for") + } +} + // releaseFIFO writes content to the FIFO at path once its reader opens it, and fails the test when // no reader comes. func releaseFIFO(t *testing.T, path string, content string) { diff --git a/cli/dispatcher/internal/compilecheck/name_index_test.go b/cli/dispatcher/internal/compilecheck/name_index_test.go index bad746696a..1ddfb846f7 100644 --- a/cli/dispatcher/internal/compilecheck/name_index_test.go +++ b/cli/dispatcher/internal/compilecheck/name_index_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func identifiersOf(content string) identifierSet { @@ -155,13 +156,14 @@ func TestReadSourceIdentifiersRefusesAUnitItCannotRead(t *testing.T) { } } -// Verifies the background read is over once wait returns, and that lookup answers only for the -// units it was asked to read. +// Verifies the background read of the sources is over once wait returns after it was asked for, and +// that lookup answers only for the units it was asked to read. func TestNameIndexIsOverOnceWaitReturns(t *testing.T) { root := t.TempDir() unit := writeUnitSources(t, root, map[string]string{"Assets/A.cs": "class First { }"}) index := nameIndex{} index.start(root, []CompileUnit{unit, unit}, []string{filepath.Join(root, "Missing"), ""}, []bool{true, false}) + index.readSources() index.wait() @@ -180,3 +182,23 @@ func TestNameIndexIsOverOnceWaitReturns(t *testing.T) { t.Error("expected no identifiers from an index that never started") } } + +// Verifies the reported duration counts only the time spent reading, not the time the run spent +// before it asked for the sources. +// Why the bounds hold without flaking: the test waits 300 ms between start and readSources, so a +// duration measured from start is at least 300 ms, while reading one small file takes well under a +// millisecond; 150 ms leaves room on both sides for a slow machine. +func TestNameIndexReportCountsOnlyTheTimeSpentReading(t *testing.T) { + root := t.TempDir() + unit := writeUnitSources(t, root, map[string]string{"Assets/A.cs": "class First { }"}) + index := nameIndex{} + index.start(root, []CompileUnit{unit}, []string{filepath.Join(root, "Missing")}, []bool{true}) + time.Sleep(300 * time.Millisecond) + + index.readSources() + index.wait() + + if report := index.report(); !report.SourcesRead || report.Duration >= 150*time.Millisecond { + t.Errorf("report = %+v, want the sources read in well under 150 ms", report) + } +} diff --git a/cli/dispatcher/internal/compilecheck/result_cache.go b/cli/dispatcher/internal/compilecheck/result_cache.go index 7453b858ef..2af126b32c 100644 --- a/cli/dispatcher/internal/compilecheck/result_cache.go +++ b/cli/dispatcher/internal/compilecheck/result_cache.go @@ -53,6 +53,7 @@ func (c Compiler) reuseOrCompile( // Why nothing is recorded unless csc ran to completion: a compile that was cancelled or never // started says nothing about the inputs, and CompileUnit has already removed the manifest of the // compile this one replaces, so an interrupted run leaves no record at all rather than a stale one. + c.startCompilerWork() result, err := c.CompileUnit(ctx, plan, unit) result.FastPathFallbackReason = fallbackReason if err != nil || key == "" || result.RawOutput != "" { diff --git a/cli/dispatcher/internal/compilecheck/result_cache_compiler_work_test.go b/cli/dispatcher/internal/compilecheck/result_cache_compiler_work_test.go new file mode 100644 index 0000000000..035a39067a --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/result_cache_compiler_work_test.go @@ -0,0 +1,86 @@ +package compilecheck + +import ( + "context" + "os/exec" + "slices" + "testing" + "time" +) + +// compilerWorkMarks runs one unit through reuseOrCompile with a hook, a compiler and, when given, a +// worker check that all note when they are called, in the order they are. +func compilerWorkMarks( + t *testing.T, project *cachedSchedulerProject, fastPath *fastPathRunner, name string, +) (UnitResult, []string) { + t.Helper() + makeOutputDirectory(t, project.root, project.plan) + marks := []string{} + if fastPath != nil { + fastPath.Worker.(*fakeFastCheckWorker).onCheck = func() { marks = append(marks, "check") } + } + compile := newCachedSchedulerRecorder(name).runner() + compiler := Compiler{ + Paths: project.paths, + ProjectRoot: project.root, + Timeout: time.Minute, + Run: func(ctx context.Context, cmd *exec.Cmd) (string, string, int, error) { + marks = append(marks, "compile") + + return compile(ctx, cmd) + }, + FastPath: fastPath, + beforeCompilerWork: func() { marks = append(marks, "hook") }, + } + result, err := compiler.reuseOrCompile(context.Background(), project.plan, project.plan.Units[planIndexOf(t, project.plan, name)]) + if err != nil { + t.Fatalf("expected %s to produce a result, got error: %v", name, err) + } + + return result, marks +} + +// Verifies the compiler-work hook runs once, right before csc starts or the worker checks the changed +// files, and never for a unit whose result is replayed from its record or from the last check of the +// same edit. +func TestReuseOrCompileCallsTheCompilerWorkHookOnlyBeforeACompileOrACheck(t *testing.T) { + t.Run("full compile", func(t *testing.T) { + project := newCachedSchedulerProject(t, nil, "Alpha") + + _, marks := compilerWorkMarks(t, project, nil, "Alpha") + + if !slices.Equal(marks, []string{"hook", "compile"}) { + t.Errorf("marks = %v, want the hook once before the compile", marks) + } + }) + t.Run("replayed", func(t *testing.T) { + project := newCachedSchedulerProject(t, nil, "Alpha") + compilerWorkMarks(t, project, nil, "Alpha") + + result, marks := compilerWorkMarks(t, project, nil, "Alpha") + + if !result.Reused || len(marks) != 0 { + t.Errorf("reused = %v, marks = %v; want a replay with no hook", result.Reused, marks) + } + }) + t.Run("fast check", func(t *testing.T) { + project := newEditedFastPathProject(t) + + result, marks := compilerWorkMarks(t, project.cachedSchedulerProject, project.runner(false), "Alpha") + + if !result.FastChecked || result.FastCheckReused || !slices.Equal(marks, []string{"hook", "check"}) { + t.Errorf("fast checked = %v, reused = %v, marks = %v; want the hook once before the check", + result.FastChecked, result.FastCheckReused, marks) + } + }) + t.Run("fast check replayed", func(t *testing.T) { + project := newEditedFastPathProject(t) + compilerWorkMarks(t, project.cachedSchedulerProject, project.runner(false), "Alpha") + + result, marks := compilerWorkMarks(t, project.cachedSchedulerProject, project.runner(false), "Alpha") + + if !result.FastCheckReused || len(marks) != 0 { + t.Errorf("fast check reused = %v, marks = %v; want a replayed check with no hook", result.FastCheckReused, marks) + } + }) +} diff --git a/cli/dispatcher/internal/compilecheck/scheduler_names_test.go b/cli/dispatcher/internal/compilecheck/scheduler_names_test.go index c8689c85ce..149cbca1e4 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_names_test.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_names_test.go @@ -279,3 +279,65 @@ func TestCompileUnitsCompilesAgainOnANameOnlyGeneratedCodeWritesWhenTheEarlyComp } assertChangedReferences(t, d2, []ChangedReference{{Assembly: "D1", Name: "Foo"}}) } + +// replayedNamesProject is newNamesProject after a first run that compiled A with a kept surface, so +// the next run replays A. B's sources are moved to a file that does not exist, so any read of them +// is counted as unreadable. +func replayedNamesProject(t *testing.T) (*cachedSchedulerProject, *schedulerRecorder) { + t.Helper() + project, recorder := newNamesProject(t, "class B { int Bar; }") + project.compileWithVerdicts(t, recorder, namedVerdicts{"A": {Kept: true, Reason: SurfaceSameAPI}}) + index := planIndexOf(t, project.plan, "B") + project.plan.Units[index].Sources = []string{"Assets/B/Missing.cs"} + + return project, recorder +} + +// Verifies a run that compiles nothing in full and whose every verdict is kept never reads the +// sources of the plan. +func TestCompileUnitsReadsNoSourcesWhenNothingCompilesAndEveryVerdictIsKept(t *testing.T) { + project, recorder := replayedNamesProject(t) + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{"A": {Kept: true, Reason: SurfaceSameAPI}}) + + if !resultOf(t, outcome.Units, "A").Reused || outcome.Skipped != 1 { + t.Fatalf("A reused = %v, skipped = %d; want A replayed and B left out", + resultOf(t, outcome.Units, "A").Reused, outcome.Skipped) + } + if outcome.NameIndex.SourcesRead || outcome.NameIndex.Unreadable != 0 { + t.Errorf("name index = %+v, want the sources never read", outcome.NameIndex) + } +} + +// Verifies a replayed verdict that names its change reads the sources of the dependents it asks +// about, and a dependent writing none of the names is left out by names. +func TestCompileUnitsReadsTheSourcesWhenAReplayedVerdictNamesAChange(t *testing.T) { + project, recorder := replayedNamesProject(t) + project.plan.Units[planIndexOf(t, project.plan, "B")].Sources = []string{"Assets/B/B.cs"} + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{"A": namedChangeOf("Foo")}) + + if !resultOf(t, outcome.Units, "A").Reused { + t.Fatal("expected A to be replayed") + } + want := []NameSkip{{Assembly: "B", ChangedReferences: []string{"A"}}} + if !reflect.DeepEqual(outcome.NameSkips, want) { + t.Errorf("name skips = %+v, want %+v", outcome.NameSkips, want) + } + if !outcome.NameIndex.SourcesRead || outcome.NameIndex.Unreadable != 0 { + t.Errorf("name index = %+v, want the sources read", outcome.NameIndex) + } +} + +// Verifies a run that compiles a unit in full reads the sources while it does, even when no verdict +// needs the names. +func TestCompileUnitsReadsTheSourcesOnceAUnitCompilesInFull(t *testing.T) { + project, recorder := newNamesProject(t, "class B { int Bar; }") + + outcome := project.compileWithVerdicts(t, recorder, namedVerdicts{"A": {Kept: true, Reason: SurfaceSameAPI}}) + + assertStrings(t, "results", compiledNames(outcome.Units), []string{"A"}) + if !outcome.NameIndex.SourcesRead { + t.Errorf("name index = %+v, want the sources read", outcome.NameIndex) + } +} diff --git a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go index 74331aa1cb..2af7335114 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log.go @@ -204,6 +204,7 @@ func addCompileCheckVibeNameNarrowing(context map[string]any, result compilechec "units": result.NameIndex.Units, "with_generated": result.NameIndex.WithGenerated, "unreadable": result.NameIndex.Unreadable, + "sources_read": result.NameIndex.SourcesRead, } } } diff --git a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go index 1b12cffef5..d1bf704884 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_vibe_log_test.go @@ -263,7 +263,7 @@ func TestCompileCheckVibeLogRecordsNameNarrowing(t *testing.T) { }, Skipped: 1, NameSkips: []compilecheck.NameSkip{{Assembly: "Delta", ChangedReferences: []string{"Alpha"}}}, - NameIndex: compilecheck.NameIndexReport{Duration: 30 * time.Millisecond, Units: 3, WithGenerated: 1, Unreadable: 1}, + NameIndex: compilecheck.NameIndexReport{Duration: 30 * time.Millisecond, Units: 3, WithGenerated: 1, Unreadable: 1, SourcesRead: true}, } logRun := startCompileCheckVibeLog(projectRoot, compileCheckOptions{}) @@ -276,7 +276,26 @@ func TestCompileCheckVibeLogRecordsNameNarrowing(t *testing.T) { `"duration_ms":200,"succeeded":true}]`) assertVibeContextJSON(t, complete, "name_skips", `[{"assembly":"Delta","unaffected_by":["Alpha"]}]`) assertVibeContextJSON(t, complete, "name_index", - `{"duration_ms":30,"units":3,"unreadable":1,"with_generated":1}`) + `{"duration_ms":30,"sources_read":true,"units":3,"unreadable":1,"with_generated":1}`) +} + +// Verifies a run that read only the saved generator output of the units with references logs the +// name index and says it never read their sources. +func TestCompileCheckVibeLogRecordsANameIndexWhoseSourcesWereNotRead(t *testing.T) { + t.Setenv(vibelog.CLIVibeLogEnvName, "1") + projectRoot := t.TempDir() + result := compilecheck.Result{ + Units: []compilecheck.UnitResult{{Assembly: "Alpha", Succeeded: true, Reused: true}}, + Skipped: 2, + NameIndex: compilecheck.NameIndexReport{Duration: 2 * time.Millisecond, Units: 2}, + } + + logRun := startCompileCheckVibeLog(projectRoot, compileCheckOptions{}) + logRun.completed(buildCompileCheckResponse(result, projectRoot), result) + + complete := assertCompileCheckVibeRun(t, readCompileCheckVibeLog(t, projectRoot)) + assertVibeContextJSON(t, complete, "name_index", + `{"duration_ms":2,"sources_read":false,"units":2,"unreadable":0,"with_generated":0}`) } // Verifies a run that read no names and left nothing out by names logs neither. diff --git a/docs/compile-check.md b/docs/compile-check.md index 2b6afddb2e..d97acce0f4 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -72,6 +72,10 @@ references, scripting defines and analyzers. `compile-check` replays those respo interpolated string handler or collection builder type; every member of a type in `System`, `Microsoft.CodeAnalysis` or a namespace under them, which the compiler looks up by name for `init`, `^1` and the like; members that only moved; and a change naming more than 2000 names. A dependent whose sources cannot be read compiles too. + The saved generator output is read before any assembly compiles; the sources are read from the + moment the run first compiles an assembly or has the worker check its changed files, or first + needs the names for a verdict, whichever comes first; a run that only replays earlier results and + has no verdict that needs names never reads them. Its answer is kept in `.surface.json`, keyed by the content of both reference assemblies and the worker build, so the runs until Unity builds again do not compare the same pair twice. The comparison is always against Unity's own artifact, never against the diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 70510648b5..e97b7ca495 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -280,7 +280,7 @@ because there is no project to write into. | `operation` | Written | `context` | |---|---|---| | `cli_compile_check_start` | once the project is found | `command`, `all`, `full`, `jobs`, `project_identity` | -| `cli_compile_check_complete` (`INFO`; `WARNING` when the run found compile errors; `ERROR` when it could not finish) | on every way out of a run that wrote the start entry | `command`, `outcome` (`succeeded`, `errors`, or `failed`), `duration_ms`; for `failed`, `error` and `error_code`; otherwise `error_count`, `warning_count`, `compiled`, `reused`, `fast_checked`, `blocked` (assembly names, as in the response), `skipped_count`, `fast_checks` (`assembly`, `reused`, `changed_file_count`), `fallbacks` and `baseline_notes` (`assembly`, `reason`), `units` (`assembly`, `duration_ms`, `succeeded` of each assembly the run compiled or checked; replayed ones are left out; `assumed_kept` names the references it started ahead of, assuming they kept Unity's surface, `compiled_again_after` the one whose changed surface made it compile again and `discarded_ms` how long the compile it threw away took, `waited_for_record` says it waited for its references for a record that then did not apply, and `changed_by` lists the references whose changed surface counted for it (`reference`, and `name`, the first changed name it writes, empty when no name tells the change) - each only when it applies), and `surface_checks` (`assembly`, `verdict`, `detail`, `duration_ms`, `from_record` of each assembly whose rebuilt reference assembly differed in bytes from Unity's and was compared by the worker or taken from its record, replayed ones included; `changed_name_count` and `changed_names`, the first 10 of the identifiers a dependent has to write to be affected by the change, and `unnamed`, why no name tells the change, only when it applies), `name_skips` (`assembly`, `unaffected_by`: the assemblies left out although a reference changed, because they write none of the names the change touches; only when there is any), and `name_index` (`duration_ms`, `units`, `with_generated`, `unreadable`: what reading the names of the assemblies with references in the plan took; only when any was read) | +| `cli_compile_check_complete` (`INFO`; `WARNING` when the run found compile errors; `ERROR` when it could not finish) | on every way out of a run that wrote the start entry | `command`, `outcome` (`succeeded`, `errors`, or `failed`), `duration_ms`; for `failed`, `error` and `error_code`; otherwise `error_count`, `warning_count`, `compiled`, `reused`, `fast_checked`, `blocked` (assembly names, as in the response), `skipped_count`, `fast_checks` (`assembly`, `reused`, `changed_file_count`), `fallbacks` and `baseline_notes` (`assembly`, `reason`), `units` (`assembly`, `duration_ms`, `succeeded` of each assembly the run compiled or checked; replayed ones are left out; `assumed_kept` names the references it started ahead of, assuming they kept Unity's surface, `compiled_again_after` the one whose changed surface made it compile again and `discarded_ms` how long the compile it threw away took, `waited_for_record` says it waited for its references for a record that then did not apply, and `changed_by` lists the references whose changed surface counted for it (`reference`, and `name`, the first changed name it writes, empty when no name tells the change) - each only when it applies), and `surface_checks` (`assembly`, `verdict`, `detail`, `duration_ms`, `from_record` of each assembly whose rebuilt reference assembly differed in bytes from Unity's and was compared by the worker or taken from its record, replayed ones included; `changed_name_count` and `changed_names`, the first 10 of the identifiers a dependent has to write to be affected by the change, and `unnamed`, why no name tells the change, only when it applies), `name_skips` (`assembly`, `unaffected_by`: the assemblies left out although a reference changed, because they write none of the names the change touches; only when there is any), and `name_index` (`duration_ms`, `units`, `with_generated`, `unreadable`, `sources_read`: what reading the names of the assemblies with references in the plan took, and whether the run read their sources - it does only once it compiles an assembly, has the worker check changed files, or a verdict needs the names; only when the plan has an assembly with references) | - Unlike the tool-command entries, the complete entry holds the run's error message and fallback reasons: they can name assemblies and files of the project, which the command's own output prints From 22518a42f8165cc0d9052defbf1d872f8192d2ac Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Fri, 16 Oct 2026 21:26:51 +0900 Subject: [PATCH 32/33] chore: compile-check docs say an assembly a run leaves out keeps its changed-files baseline (#3351) --- .../CliOnlyTools~/CompileCheck/Skill/SKILL.md | 8 +++---- .../internal/compilecheck/fast_path_test.go | 23 +++++++++++++++++++ docs/compile-check.md | 19 +++++++++------ 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md index aac2892259..6e73277ee6 100644 --- a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md @@ -59,10 +59,10 @@ directive counts as a declaration change. Such a check skips a few assembly-wide with `--full` before finishing. When the project treats warnings as errors, a rule listed in `UncheckedCodes`, such as CS0414, can fail that `--full` run only. The first body edit after a Unity build, after a full compile that had errors (a `--full` run or a run that fell back to one), after -a run that compiled it only because an assembly it references changed, after a run that skipped the -assembly, or after `--all` in an assembly that references one whose rebuilt API differed from the -last Unity build and that the next run no longer rebuilds is compiled in full and records what the -next check compares against; `FastPathFallbacks` says why. +a run that picked it up only because an assembly it references changed, whether it compiled or +skipped the assembly, or after `--all` in an assembly that references one whose rebuilt API +differed from the last Unity build and that the next run no longer rebuilds is compiled in full and +records what the next check compares against; `FastPathFallbacks` says why. ## Parameters diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go index b4bdd277d0..1f43e6a817 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -470,6 +470,29 @@ func TestRevertedContentIsReused(t *testing.T) { } } +// Verifies that a run leaving an assembly out of its plan, because nothing it compiles from changed, +// keeps that assembly's outputs, result, and baseline as they were, whether the run compiles nothing +// or only another assembly, so the next body edit of it is checked against that baseline. +func TestRunLeavingAnAssemblyOutKeepsItsBaseline(t *testing.T) { + for label, planned := range map[string][]string{"nothing": {}, "another assembly": {"Beta"}} { + t.Run(label, func(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + before := project.outputSnapshot(t) + full := project.plan + project.plan = project.withOnlyUnits(planned...) + project.run(t, newCachedSchedulerRecorder(planned...), false) + project.plan = full + + if after := project.outputSnapshot(t); !reflect.DeepEqual(after, before) { + t.Fatalf("expected the outputs, result, and baseline to stay, got %v then %v", before, after) + } + project.editBody(t, 0, "edited") + project.runFastChecked(t) + }) + } +} + // Verifies that a baseline agreeing with every source but disagreeing with the current key is // dropped, and the unit compiles in full. func TestZeroChangedFilesDropsBaselineAndFallsBack(t *testing.T) { diff --git a/docs/compile-check.md b/docs/compile-check.md index d97acce0f4..6f6f0f3c12 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -215,8 +215,9 @@ build - does not call the worker again: it reports what the previous check repor Otherwise the assembly is compiled in full as before, and `FastPathFallbacks` says why: - `no baseline: no full compile of this assembly is on record` (none ran yet, a later run - skipped the assembly, which removes its result and baseline, or an input changed while - the last full compile ran, so it left no record; `FastPathBaselineNotes` of that run says which) + picked the assembly up only because a reference changed and then skipped it, which removes its + result and baseline, or an input changed while the last full compile ran, so it left no record; + `FastPathBaselineNotes` of that run says which) - `no baseline: the last full compile had errors; the first full compile without errors records one` (a full compile that fails drops the baseline an earlier one left, and the assembly is compiled in full until a compile succeeds) @@ -256,11 +257,15 @@ So the first body edit is compiled in full more often than it looks: and the first body edit is checked as usual. Only when that run leaves the reference out, so it reads Unity's build instead, does the first body edit of an assembly referencing it fall back with `inputs other than source contents changed: references`. -- A run that skips an assembly because nothing it compiles from changed removes its result and - baseline, so its next body edit is compiled in full. A run that blocks it because an assembly it - references has errors keeps them: the next run checks them against the inputs and output hashes - they were recorded with before using them, so a reference that comes back the same leaves the - assembly reused or checked on its changed files, and one that comes back changed compiles it. +- An assembly a run leaves out because nothing it compiles from changed keeps its result and + baseline, so its next body edit is checked against them. An assembly a run picks up only because + a reference changed loses its result and baseline either way: compiled, it records no baseline, + and left out, because every such reference kept its public surface or changed only names the + assembly never writes, its result and baseline are removed. The next body edit of that assembly + is compiled in full. A run that blocks an assembly because one it references has errors keeps + its result and baseline: the next run checks them against the inputs and output hashes they were + recorded with before using them, so a reference that comes back the same leaves the assembly + reused or checked on its changed files, and one that comes back changed compiles it. - A full compile while the assembly still has errors, whether a `--full` run or a run that fell back to one, drops its baseline, so the run after the fix is compiled in full. Running `--full` once the errors are fixed keeps it. From c74c3f8055f11b7cc934a9c0317060455c948b34 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 17 Oct 2026 00:23:31 +0900 Subject: [PATCH 33/33] fix: compile-check surface comparison leaves out the types the compiler owns (#3352) --- .../fastcheck/worker/CompareSurfaceCommand.cs | 2 +- .../fastcheck/worker/SurfaceMetadata.cs | 45 +++++++++++-- .../fastcheck/worker/SurfaceReachability.cs | 7 +- docs/compile-check.md | 17 +++-- .../ChangedSurfaceNamesTests.cs | 2 +- .../CompareSurfaceCommandTests.cs | 65 +++++++++++++++++++ .../SurfaceFixture.cs | 37 +++++++++++ 7 files changed, 159 insertions(+), 16 deletions(-) diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/CompareSurfaceCommand.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/CompareSurfaceCommand.cs index d7b04bac5b..1ecdba284c 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/worker/CompareSurfaceCommand.cs +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/CompareSurfaceCommand.cs @@ -17,7 +17,7 @@ internal static class CompareSurfaceCommand { // Raise whenever what the description writes changes, so a recorded answer is not reused for // a comparison that would now come out differently. - private const string DescriptionVersion = "sv2"; + private const string DescriptionVersion = "sv3"; private const string NullabilitySection = "== nullability"; private const string EndOfDescription = "(end)"; diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceMetadata.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceMetadata.cs index 1b5df49a26..8e78d1054d 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceMetadata.cs +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceMetadata.cs @@ -37,6 +37,7 @@ internal sealed class SurfaceMetadata }; private readonly HashSet embeddedTypes; + private readonly HashSet compilerOwnedTypes; private readonly HashSet hiddenNestedTypes; private readonly HashSet hiddenNestedReflectionNames = new HashSet(StringComparer.Ordinal); @@ -46,7 +47,10 @@ public SurfaceMetadata(PEReader peReader) Reader = peReader.GetMetadataReader(); Provider = new SurfaceSignatureProvider(); embeddedTypes = FindEmbeddedTypes(Reader); - hiddenNestedTypes = SurfaceReachability.UnreferencedPrivateNestedTypes(Reader, embeddedTypes); + compilerOwnedTypes = FindCompilerOwnedTypes(Reader); + HashSet notVisited = new HashSet(embeddedTypes); + notVisited.UnionWith(compilerOwnedTypes); + hiddenNestedTypes = SurfaceReachability.UnreferencedPrivateNestedTypes(Reader, notVisited); foreach (TypeDefinitionHandle handle in hiddenNestedTypes) { hiddenNestedReflectionNames.Add(SurfaceTypeNames.ReflectionName(Reader, handle)); @@ -59,11 +63,12 @@ public SurfaceMetadata(PEReader peReader) public SurfaceSignatureProvider Provider { get; } - // A type no dependent can see: one the compiler embedded, or a private nested type nothing kept - // refers to. Its members, parameters and attributes go with it. + // A type no dependent can see: one the compiler embedded, a top-level type the compiler owns + // outright along with everything nested in it, or a private nested type nothing kept refers to. + // Its members, parameters and attributes go with it. public bool IsLeftOut(TypeDefinitionHandle handle) { - return embeddedTypes.Contains(handle) || hiddenNestedTypes.Contains(handle); + return embeddedTypes.Contains(handle) || compilerOwnedTypes.Contains(handle) || hiddenNestedTypes.Contains(handle); } public string TypeName(EntityHandle handle) @@ -219,6 +224,38 @@ private static HashSet FindEmbeddedTypes(MetadataReader re return embedded; } + // Why the outermost name decides: the compiler starts the names of the types it owns outright + // (, ) with '<', which no C# source can write, so neither + // they nor anything nested in them is a surface a dependent can read. Generated types nested in + // a hand-written type (closures, state machines) are left to the reachability pass. + private static HashSet FindCompilerOwnedTypes(MetadataReader reader) + { + HashSet owned = new HashSet(); + foreach (TypeDefinitionHandle handle in reader.TypeDefinitions) + { + if (IsUnderCompilerOwnedRoot(reader, handle)) + { + owned.Add(handle); + } + } + + return owned; + } + + private static bool IsUnderCompilerOwnedRoot(MetadataReader reader, TypeDefinitionHandle handle) + { + TypeDefinitionHandle root = handle; + for (TypeDefinitionHandle declaring = reader.GetTypeDefinition(root).GetDeclaringType(); + !declaring.IsNil; + declaring = reader.GetTypeDefinition(root).GetDeclaringType()) + { + root = declaring; + } + string name = reader.GetString(reader.GetTypeDefinition(root).Name); + + return name.Length > 0 && name[0] == '<'; + } + // [Microsoft.CodeAnalysis.Embedded] as the compiler writes it: a type defined in this very module. private static bool IsEmbeddedAttribute(MetadataReader reader, CustomAttribute attribute) { diff --git a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceReachability.cs b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceReachability.cs index 1bca0e11bb..513ac68f34 100644 --- a/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceReachability.cs +++ b/cli/dispatcher/internal/compilecheck/fastcheck/worker/SurfaceReachability.cs @@ -23,10 +23,11 @@ private SurfaceReachability() /// /// Lists every type left out of the surface for being private-nested, itself or through a type - /// around it, without any kept row referring to it or to a type around it. + /// around it, without any kept row referring to it or to a type around it. The rows of the types + /// in notVisited, which the surface leaves out anyway, keep nothing. /// internal static HashSet UnreferencedPrivateNestedTypes( - MetadataReader reader, HashSet embeddedTypes) + MetadataReader reader, HashSet notVisited) { SurfaceReachability reachability = new SurfaceReachability(); // Why to a fixpoint: a private nested type a kept row names is kept whole, and what its own @@ -37,7 +38,7 @@ internal static HashSet UnreferencedPrivateNestedTypes( before = reachability.referenced.Count; foreach (TypeDefinitionHandle handle in reader.TypeDefinitions) { - if (!embeddedTypes.Contains(handle) && reachability.IsKept(reader, handle)) + if (!notVisited.Contains(handle) && reachability.IsKept(reader, handle)) { reachability.VisitType(reader, handle); } diff --git a/docs/compile-check.md b/docs/compile-check.md index 6f6f0f3c12..6183fd8be3 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -44,13 +44,16 @@ references, scripting defines and analyzers. `compile-check` replays those respo that came out byte-identical counts as the same, as it does for Unity. One whose bytes differ is handed to the changed-files check worker, which writes both out as text and counts them the same only when the texts match. The text leaves out what no dependent can read: the attribute types - the compiler embeds, private nested types nothing visible refers to (closures and async or - iterator state machines) along with the attributes naming them, and where the compiler happens to - put nullable annotations — each annotation is written as the value that applies to its member. - So a private reference-type field in a nullable-enabled assembly, a private generic method with - an `unmanaged` constraint, or a private member added before a lambda no longer compiles every - dependent again, which Unity, going by bytes, does. This is safe because a dependent cannot name - an embedded type or an unreferenced private nested type, and reads every annotation by the value + the compiler embeds, the top-level types the compiler owns outright, whose names start with `<`, + together with everything nested in them (such as ``, which holds + array-initializer data and string-switch hashing), private nested types nothing visible refers to + (closures and async or iterator state machines) along with the attributes naming them, and where + the compiler happens to put nullable annotations — each annotation is written as the value that + applies to its member. So a private reference-type field in a nullable-enabled assembly, a + private generic method with an `unmanaged` constraint, or a private member added before a lambda + no longer compiles every dependent again, which Unity, going by bytes, does. This is safe because + a dependent cannot name an embedded type, a top-level type whose name starts with `<` or anything + nested in it, or an unreferenced private nested type, and reads every annotation by the value that applies to it. One difference remains: a dependent that already fails because it names a private nested type may report the error under another ID (CS0246, CS0426 or CS0117 in place of CS0122, or the other way round) until it compiles again; whether it compiles does not change. diff --git a/tests/CompileCheckWorker.UnitTests/ChangedSurfaceNamesTests.cs b/tests/CompileCheckWorker.UnitTests/ChangedSurfaceNamesTests.cs index cc6a64d6e8..11b7a93c59 100644 --- a/tests/CompileCheckWorker.UnitTests/ChangedSurfaceNamesTests.cs +++ b/tests/CompileCheckWorker.UnitTests/ChangedSurfaceNamesTests.cs @@ -552,7 +552,7 @@ public void DescriptionKeepsTheBaseTypeOnItsOwnRow() { string[] texts = CompareSurfaceCommand.Describe(path).Texts.ToArray(); - Assert.That(texts[0], Is.EqualTo("sv2")); + Assert.That(texts[0], Is.EqualTo("sv3")); Assert.That(texts.Where(text => text.StartsWith("type C ")).Single(), Does.Not.Contain("base=")); Assert.That(texts, Does.Contain("C base B")); } diff --git a/tests/CompileCheckWorker.UnitTests/CompareSurfaceCommandTests.cs b/tests/CompileCheckWorker.UnitTests/CompareSurfaceCommandTests.cs index 89f7a6b269..4014f7a8a4 100644 --- a/tests/CompileCheckWorker.UnitTests/CompareSurfaceCommandTests.cs +++ b/tests/CompileCheckWorker.UnitTests/CompareSurfaceCommandTests.cs @@ -10,6 +10,8 @@ public sealed class CompareSurfaceCommandTests { private const string TwoPrivateStrings = " private string pa = \"\"; private string pb = \"\"; "; private const string ThreePrivateNullableObjects = " private object? pa; private object? pb; private object? pc; "; + private const string PrivateImplementationDetails = ""; + private const string Dependent = "public class D { int x = new C().X; }"; private const string InternalsVisibleToDependent = "[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(\"" + SurfaceFixture.DependentAssemblyName + "\")]\n"; @@ -471,6 +473,69 @@ public void ParameterRenameChangesTheSurface() "class D { void F() => C.M(a: 1); }"); } + /// + /// Verifies that a private method whose body holds an array literal, which makes the compiler + /// write its own type for the array data, keeps the surface. + /// + [Test] + public void PrivateArrayInitializerAddedKeepsTheSurface() + { + const string OldSource = "public class C { public int X; }"; + const string NewSource = + "public class C { public int X; private static int A() { int[] table = { 11, 12, 13, 14, 15, 16, 17 }; return table[3]; } }"; + SurfaceFixture.AssertDefinesTypeStartingWith(SurfaceFixture.EmitReferenceAssembly(NewSource), PrivateImplementationDetails); + SurfaceFixture.AssertSame(OldSource, NewSource, Dependent); + } + + /// + /// Verifies that adding array data to the compiler's own type that both versions already have + /// keeps the surface, whatever the name the compiler gives the new data. + /// + [TestCase("{ 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27 }")] + [TestCase("{ 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 28 }")] + public void PrivateArrayInitializerDataAddedKeepsTheSurface(string arrayLiteral) + { + string newSource = WithPrivateArrayData(" private static int B() { int[] t = " + arrayLiteral + "; return t[0]; }"); + AssertPrivateImplementationDetailsChanged(WithPrivateArrayData(""), newSource); + SurfaceFixture.AssertSame(WithPrivateArrayData(""), newSource, Dependent); + } + + /// + /// Verifies that a private method switching on a string, which makes the compiler add a string + /// hash method to its own type, keeps the surface. + /// + [Test] + public void PrivateStringSwitchAddedKeepsTheSurface() + { + const string StringSwitch = + " private static int S(string s) { switch (s) { case \"a\": return 1; case \"b\": return 2; case \"c\": return 3;" + + " case \"d\": return 4; case \"e\": return 5; case \"f\": return 6; case \"g\": return 7; default: return 0; } }"; + string newSource = WithPrivateArrayData(StringSwitch); + AssertPrivateImplementationDetailsChanged(WithPrivateArrayData(""), newSource); + SurfaceFixture.AssertSame(WithPrivateArrayData(""), newSource, Dependent); + } + + // A library whose private array literal already makes the compiler write its own type, so a + // change to that type's members is compared on both sides. + private static string WithPrivateArrayData(string addedMembers) + { + return "public class C { public int X; private static int A() { int[] t = { 1, 2, 3, 4, 5, 6, 7 }; return t[0]; }" + addedMembers + " }"; + } + + // Both versions must have the compiler's own type and differ inside it, or the case would pass + // without the comparison ever seeing a change to that type. + private static void AssertPrivateImplementationDetailsChanged(string oldSource, string newSource) + { + byte[] oldAssembly = SurfaceFixture.EmitReferenceAssembly(oldSource); + byte[] newAssembly = SurfaceFixture.EmitReferenceAssembly(newSource); + SurfaceFixture.AssertDefinesTypeStartingWith(oldAssembly, PrivateImplementationDetails); + SurfaceFixture.AssertDefinesTypeStartingWith(newAssembly, PrivateImplementationDetails); + Assert.That( + SurfaceFixture.TopLevelTypeMemberNames(newAssembly, PrivateImplementationDetails), + Is.Not.EquivalentTo(SurfaceFixture.TopLevelTypeMemberNames(oldAssembly, PrivateImplementationDetails)), + "the compiler's own type has the same members in both versions, so the case does not change it"); + } + private static void AssertOnlyPrivateNestedTypesDiffer(string oldSource, string newSource) { Assert.That( diff --git a/tests/CompileCheckWorker.UnitTests/SurfaceFixture.cs b/tests/CompileCheckWorker.UnitTests/SurfaceFixture.cs index 17f6197d5c..e4aa489973 100644 --- a/tests/CompileCheckWorker.UnitTests/SurfaceFixture.cs +++ b/tests/CompileCheckWorker.UnitTests/SurfaceFixture.cs @@ -93,6 +93,43 @@ internal static void AssertUnnamed(string oldSource, string newSource, string re Assert.That(response.ChangedNames, Is.Empty); } + /// + /// Asserts that the reference assembly defines a type whose name starts with the prefix, so a + /// test about a compiler-owned type fails loudly when the compiler stops writing it. + /// + internal static void AssertDefinesTypeStartingWith(byte[] referenceAssembly, string prefix) + { + using PEReader peReader = new PEReader(ImmutableArray.Create(referenceAssembly)); + MetadataReader reader = peReader.GetMetadataReader(); + bool found = reader.TypeDefinitions + .Select(handle => reader.GetString(reader.GetTypeDefinition(handle).Name)) + .Any(name => name.StartsWith(prefix, StringComparison.Ordinal)); + Assert.That(found, Is.True, "no type named " + prefix + "* in the reference assembly"); + } + + /// + /// Lists the field and method names of a top-level type, and the names of the types nested in + /// it, so a test can show that a change reached a type the compiler owns. + /// + internal static List TopLevelTypeMemberNames(byte[] referenceAssembly, string typeName) + { + using PEReader peReader = new PEReader(ImmutableArray.Create(referenceAssembly)); + MetadataReader reader = peReader.GetMetadataReader(); + List names = new List(); + foreach (TypeDefinitionHandle handle in reader.TypeDefinitions) + { + TypeDefinition type = reader.GetTypeDefinition(handle); + if (type.GetDeclaringType().IsNil && reader.GetString(type.Name) == typeName) + { + names.AddRange(type.GetFields().Select(field => reader.GetString(reader.GetFieldDefinition(field).Name))); + names.AddRange(type.GetMethods().Select(method => reader.GetString(reader.GetMethodDefinition(method).Name))); + names.AddRange(type.GetNestedTypes().Select(nested => reader.GetString(reader.GetTypeDefinition(nested).Name))); + } + } + + return names; + } + /// /// Lists the identifiers a dependent's source writes, comments and strings included. ///