diff --git a/.agents/skills/uloop-compile-check/SKILL.md b/.agents/skills/uloop-compile-check/SKILL.md deleted file mode 100644 index d212709575..0000000000 --- a/.agents/skills/uloop-compile-check/SKILL.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -name: uloop-compile-check -description: "Compile C# with the Unity-bundled Roslyn compiler without launching or contacting the Unity Editor; reports errors and warnings only." ---- - -# uloop compile-check - -Compile the project's C# offline and report the diagnostics, without starting Unity or sending -anything to a running Editor. - -The command replays the response files Unity wrote during its last build, using the C# compiler -bundled with the Unity Editor install. Nothing it produces is loaded by Unity: the assemblies land -in `Library/uloop/compile-check/` and are never handed to the Editor. - -## When to use - -- The Editor is not running and you only want to know whether the code compiles. -- You want compiler errors quickly, without waiting for an import and a domain reload. -- You are iterating on code whose result does not need to be live in the Editor yet. - -## When not to use - -- You want the change reflected in the Editor (play mode, tests, a tool call): run `uloop compile`. -- You just added or removed an `.asmdef`, added a reference to one, or changed scripting defines: - run `uloop compile` once first. The response files describe the previous build only, so - compile-check refuses the run with `COMPILE_CHECK_UNITY_BUILD_REQUIRED` instead of reporting - diagnostics for a configuration the project no longer has. -- The project has no `Library` yet (a fresh clone, or an Editor that has never opened it): there is - no build to replay, so the run is refused with the same code. Let Unity build once; a headless run - is enough and needs no window: - - ```bash - -batchmode -nographics -quit -projectPath /path/to/project - ``` - - `uloop launch` does the same with the Editor window open. After that one build, compile-check works - without the Editor; it asks for another build when it detects an `.asmdef` change. A scripting define - change it cannot detect, so run `uloop compile` yourself after one. - -## Usage - -```bash -uloop compile-check -uloop compile-check --all --project-path /path/to/project -``` - -By default it compiles the assemblies whose sources changed since the last Unity build, plus every -assembly that references one of them. `--all` compiles every assembly in the build. Assemblies that do not depend on each other compile at -the same time; `--jobs` caps how many run at once, and `--jobs 1` compiles them one after another. - -An assembly whose inputs are all exactly as the previous `compile-check` read them is not compiled -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. - -## Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `--all` | flag | Compile every assembly instead of only the changed ones and their dependents | -| `--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) | -| `--project-path ` | string | Target another Unity project instead of the current directory | - -## Output - -A JSON payload: - -- `Success`: whether the compile produced no errors -- `ErrorCount`, `WarningCount`: totals across every compiled and reused assembly -- `Errors`, `Warnings`: each with `Message`, `Code`, `File`, `Line`, `Column`, `Assembly` -- `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 -- `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 -- `Message`: one-line summary - -The process exits 1 when `ErrorCount` is greater than zero. - -## Limitations - -- It replays the last Unity build's response files. A new or deleted `.asmdef`, a changed `.asmdef` - reference, an `.asmdef` that stopped building for the Editor, or a newly required precompiled - reference stops the run and asks for `uloop compile`. -- Changes to `defineConstraints` and `versionDefines` are **not** detected, because they depend on - scripting defines and package versions that cannot be evaluated outside the Editor. Run - `uloop compile` after editing them. -- A reference **removed** from an `.asmdef` is not detected either: Unity injects references of its - own that no `.asmdef` declares, so only added references can be told apart from those. Diagnostics - may therefore miss an error that the removal would cause. Run `uloop compile` after removing one. -- Changed scripting defines are not detected; the defines from the last build are reused. -- The predefined assemblies (`Assembly-CSharp` and friends) keep the source list of the last build, - so a brand-new `.cs` file outside an `.asmdef` is not compiled until Unity imports it. -- The produced DLLs are never loaded by Unity, and the command never contacts a running Editor. diff --git a/.agents/skills/uloop-compile/SKILL.md b/.agents/skills/uloop-compile/SKILL.md index 9c675ac79a..7263aadc28 100644 --- a/.agents/skills/uloop-compile/SKILL.md +++ b/.agents/skills/uloop-compile/SKILL.md @@ -1,13 +1,21 @@ --- name: uloop-compile toolName: compile -description: "Compile the Unity project and report errors/warnings. Use after C# edits." +description: "Compile the Unity project and report errors/warnings. Use after C# edits; to try edits in the running Editor without a domain reload, use hot-reload." --- # uloop compile Execute Unity project compilation. +## When to use + +- After C# edits, to check for compile errors and make the change live in the Editor (Play + Mode, tests, a tool call): to land edits you finished trying with hot reload, or to apply + structural changes hot reload cannot. +- To try an edit in the running Editor, especially during Play Mode, run `uloop hot-reload` + instead: a compile drops every hot-reload patch and discards the Play session. + ## Usage ```bash diff --git a/.claude/skills/uloop-compile-check/SKILL.md b/.claude/skills/uloop-compile-check/SKILL.md deleted file mode 100644 index d212709575..0000000000 --- a/.claude/skills/uloop-compile-check/SKILL.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -name: uloop-compile-check -description: "Compile C# with the Unity-bundled Roslyn compiler without launching or contacting the Unity Editor; reports errors and warnings only." ---- - -# uloop compile-check - -Compile the project's C# offline and report the diagnostics, without starting Unity or sending -anything to a running Editor. - -The command replays the response files Unity wrote during its last build, using the C# compiler -bundled with the Unity Editor install. Nothing it produces is loaded by Unity: the assemblies land -in `Library/uloop/compile-check/` and are never handed to the Editor. - -## When to use - -- The Editor is not running and you only want to know whether the code compiles. -- You want compiler errors quickly, without waiting for an import and a domain reload. -- You are iterating on code whose result does not need to be live in the Editor yet. - -## When not to use - -- You want the change reflected in the Editor (play mode, tests, a tool call): run `uloop compile`. -- You just added or removed an `.asmdef`, added a reference to one, or changed scripting defines: - run `uloop compile` once first. The response files describe the previous build only, so - compile-check refuses the run with `COMPILE_CHECK_UNITY_BUILD_REQUIRED` instead of reporting - diagnostics for a configuration the project no longer has. -- The project has no `Library` yet (a fresh clone, or an Editor that has never opened it): there is - no build to replay, so the run is refused with the same code. Let Unity build once; a headless run - is enough and needs no window: - - ```bash - -batchmode -nographics -quit -projectPath /path/to/project - ``` - - `uloop launch` does the same with the Editor window open. After that one build, compile-check works - without the Editor; it asks for another build when it detects an `.asmdef` change. A scripting define - change it cannot detect, so run `uloop compile` yourself after one. - -## Usage - -```bash -uloop compile-check -uloop compile-check --all --project-path /path/to/project -``` - -By default it compiles the assemblies whose sources changed since the last Unity build, plus every -assembly that references one of them. `--all` compiles every assembly in the build. Assemblies that do not depend on each other compile at -the same time; `--jobs` caps how many run at once, and `--jobs 1` compiles them one after another. - -An assembly whose inputs are all exactly as the previous `compile-check` read them is not compiled -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. - -## Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `--all` | flag | Compile every assembly instead of only the changed ones and their dependents | -| `--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) | -| `--project-path ` | string | Target another Unity project instead of the current directory | - -## Output - -A JSON payload: - -- `Success`: whether the compile produced no errors -- `ErrorCount`, `WarningCount`: totals across every compiled and reused assembly -- `Errors`, `Warnings`: each with `Message`, `Code`, `File`, `Line`, `Column`, `Assembly` -- `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 -- `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 -- `Message`: one-line summary - -The process exits 1 when `ErrorCount` is greater than zero. - -## Limitations - -- It replays the last Unity build's response files. A new or deleted `.asmdef`, a changed `.asmdef` - reference, an `.asmdef` that stopped building for the Editor, or a newly required precompiled - reference stops the run and asks for `uloop compile`. -- Changes to `defineConstraints` and `versionDefines` are **not** detected, because they depend on - scripting defines and package versions that cannot be evaluated outside the Editor. Run - `uloop compile` after editing them. -- A reference **removed** from an `.asmdef` is not detected either: Unity injects references of its - own that no `.asmdef` declares, so only added references can be told apart from those. Diagnostics - may therefore miss an error that the removal would cause. Run `uloop compile` after removing one. -- Changed scripting defines are not detected; the defines from the last build are reused. -- The predefined assemblies (`Assembly-CSharp` and friends) keep the source list of the last build, - so a brand-new `.cs` file outside an `.asmdef` is not compiled until Unity imports it. -- The produced DLLs are never loaded by Unity, and the command never contacts a running Editor. diff --git a/.claude/skills/uloop-compile/SKILL.md b/.claude/skills/uloop-compile/SKILL.md index 9c675ac79a..7263aadc28 100644 --- a/.claude/skills/uloop-compile/SKILL.md +++ b/.claude/skills/uloop-compile/SKILL.md @@ -1,13 +1,21 @@ --- name: uloop-compile toolName: compile -description: "Compile the Unity project and report errors/warnings. Use after C# edits." +description: "Compile the Unity project and report errors/warnings. Use after C# edits; to try edits in the running Editor without a domain reload, use hot-reload." --- # uloop compile Execute Unity project compilation. +## When to use + +- After C# edits, to check for compile errors and make the change live in the Editor (Play + Mode, tests, a tool call): to land edits you finished trying with hot reload, or to apply + structural changes hot reload cannot. +- To try an edit in the running Editor, especially during Play Mode, run `uloop hot-reload` + instead: a compile drops every hot-reload patch and discards the Play session. + ## Usage ```bash diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b87c870ae8..7bbcbd5827 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,5 @@ { "Packages/src": "3.6.3", - "cli/dispatcher": "3.5.1", + "cli/dispatcher": "3.6.0", "cli/project-runner": "3.4.0" } diff --git a/.uloop/project-runner-pin.json b/.uloop/project-runner-pin.json index b328501b04..edbf2339cd 100644 --- a/.uloop/project-runner-pin.json +++ b/.uloop/project-runner-pin.json @@ -1,6 +1,6 @@ { - "dispatcherArchiveManifest": "32f8231a15eb7cffa0f0af19074303d65d52edb1846b1b830f07e9dc41317db0 install.ps1\n8b8b9b0b2f8f0db066570d3061ac0aad97f9b46aa905cb84804950f02a23066e uloop-dispatcher-darwin-amd64.tar.gz\n9254f79ee74ed44195cb34614891339218eedf4c35eef49a5a87fc4e29e9080b uloop-dispatcher-windows-amd64.zip.sha256\n9a6aa1bdf7c9a0c6e7957abb0d037c315b2ef57eadc4c8006783fe500452e2b4 install.sh.sha256\n9d981d103c593a967ebfcd8f705d8ff30ef1e5bd20e416f96e0d32d0ff28f8c3 uloop-dispatcher-windows-amd64.zip\nacdbe117b6f469eb13c1f68617861584559af121f71685bbadb53ad9fb8054ee uloop-dispatcher-darwin-arm64.tar.gz\nc0671764ccd8e88ec1d9992f006f1e82f7891b4ba17462893750d0710c9a5a9e uloop-dispatcher-darwin-arm64.tar.gz.sha256\nd2e8e2e7c5f9d24facb80388836f55559cdf1dd41237e9d3df4d0784ae1b3fa8 uloop-dispatcher-darwin-amd64.tar.gz.sha256\ne97de8d921af3115bfa00544ec69da6136b9cb0e271966f1073777872cd69838 install.ps1.sha256\nf2a93560cae680c67a3fec28f74116eeef9f3be0804025fa616a630ab52477ae install.sh", - "dispatcherReleaseTag": "dispatcher-v3.5.1", + "dispatcherArchiveManifest": "011262cf25499b388950a0d96651e843728b40bbcced83b1be82ff0dfae257dc uloop-dispatcher-darwin-amd64.tar.gz.sha256\n1cb407b18c2038fd2d8617f5a7bc9a3e0d3b7fd13402b6072e12b0d81483bc38 uloop-dispatcher-darwin-amd64.tar.gz\n1d26e626a05c32c1cda0e0fd5b52d7597b52476e317580e4c6ad0d49afe3435d uloop-dispatcher-darwin-arm64.tar.gz.sha256\n26f40893169a6b54471919280998df39902dcd0e0099c5398ab403ecda88f102 uloop-dispatcher-linux-amd64.tar.gz.sha256\n32f8231a15eb7cffa0f0af19074303d65d52edb1846b1b830f07e9dc41317db0 install.ps1\n39b201f20f34c413313638621c501cd4ed49f33c240c64a8e7ff89bc54dcf8e3 uloop-dispatcher-darwin-arm64.tar.gz\n3f0e7ad97e94a36d4ff8ef1c4c7bf4e0ffade39bcd65e81aa118af855053264e install.sh\n4466b53c3728492e5ce789259f2b7274107a97a1e894426ce55ce35d1c3395f4 uloop-dispatcher-linux-amd64.tar.gz\n6eda2092bc4adcff771e0f7d1246d86767d2608710e5bddf85b19b3da4cb9d60 install.sh.sha256\n863b9d107d02b008dad9b95f6b6e0c81e8cc169380b717bf95b3be050585273c uloop-dispatcher-windows-amd64.zip\ncdd88c24dcbb11a484bbaf8a432e8349508927e420525244765918535826a527 uloop-dispatcher-windows-amd64.zip.sha256\ne97de8d921af3115bfa00544ec69da6136b9cb0e271966f1073777872cd69838 install.ps1.sha256", + "dispatcherReleaseTag": "dispatcher-v3.6.0", "minimumDispatcherVersion": "3.0.0-beta.31", "projectRunnerVersion": "3.4.0" } diff --git a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs index a5048e1f80..a7bd8a3faf 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs @@ -820,12 +820,135 @@ public void AppendRemovedMemberNotices_WhenARunReportsNothing_PrintsTheFullWarni "Alpha"))); } + /// + /// What: a run that lists the same file twice prints the full warning for both entries, + /// because the second entry is not a later run. Fails if the first entry's record is what + /// the second entry is compared against. + /// + [Test] + public void AppendRemovedMemberNotices_WhenOneRunListsTheSameFileTwice_PrintsTheFullWarningForBoth() + { + IReadOnlyList warnings = AppendDuplicateFileRemovedMembersRun( + new[] { "Alpha", "Beta" }, + new[] { "Alpha", "Beta" }); + + string fullWarning = string.Format(HotReloadConstants.RemovedMembersWarningFormat, "Alpha, Beta"); + Assert.That(warnings, Is.EqualTo(new[] { fullWarning, fullWarning })); + } + + /// + /// What: the set a run that listed the same file twice reported is still recorded when the + /// run ends, so the next run with the same set collapses to the continuation line. Fails if + /// the record is no longer written once it is deferred to the end of the run. + /// + [Test] + public void AppendRemovedMemberNotices_AfterARunListedTheSameFileTwice_CollapsesTheNextRunToTheContinuationLine() + { + AppendDuplicateFileRemovedMembersRun(new[] { "Alpha", "Beta" }, new[] { "Alpha", "Beta" }); + + string nextWarning = AppendRemovedMembersRun("Alpha", "Beta"); + + Assert.That( + nextWarning, + Is.EqualTo(string.Format( + CultureInfo.InvariantCulture, + HotReloadConstants.ContinuingRemovedMembersWarningFormat, + 2, + "Alpha, Beta"))); + } + + /// + /// What: when one run lists the same file twice with different removed sets, the set of + /// the later entry is what the run records, so the next run continues from it. Fails if + /// the first entry's set wins. + /// + [Test] + public void AppendRemovedMemberNotices_WhenOneRunListsTheSameFileTwiceWithDifferentSets_RecordsTheLaterSet() + { + AppendDuplicateFileRemovedMembersRun(new[] { "Alpha" }, new[] { "Alpha", "Beta" }); + + string nextWarning = AppendRemovedMembersRun("Alpha", "Beta"); + + Assert.That( + nextWarning, + Is.EqualTo(string.Format( + CultureInfo.InvariantCulture, + HotReloadConstants.ContinuingRemovedMembersWarningFormat, + 2, + "Alpha, Beta"))); + } + + /// + /// What: a run that staged nothing for the file leaves the record alone, so the run after + /// it still continues from the earlier report. Fails if ending such a run clears or + /// overwrites the record. + /// + [Test] + public void ApplyTo_RunThatStagedNothingForTheFile_KeepsTheRecord() + { + AppendRemovedMembersRun("Alpha"); + + new HotReloadRunDisplayedRemovedMembers().ApplyTo(HotReloadCompositionRoot.Services.Patcher); + string thirdWarning = AppendRemovedMembersRun("Alpha"); + + Assert.That( + thirdWarning, + Is.EqualTo(string.Format( + CultureInfo.InvariantCulture, + HotReloadConstants.ContinuingRemovedMembersWarningFormat, + 1, + "Alpha"))); + } + // Runs the removed-member notices for one file of a fresh run context against the domain - // installed right now, and returns the removed-members warning that run produced. + // installed right now, ends the run the way the orchestrator does, and returns the + // removed-members warning that run produced. private static string AppendRemovedMembersRun(params string[] removedMemberNames) { - HotReloadApplyContext context = CreateContext(); + HotReloadRunDisplayedRemovedMembers run = new HotReloadRunDisplayedRemovedMembers(); + HotReloadApplyContext context = CreateContext(displayedRemovedMembers: run); HotReloadGroupFile file = context.Files[0]; + file.FileOutput.removedMembers = CreateRemovedFields(removedMemberNames); + + HotReloadGroupNotices.AppendRemovedMemberNotices( + HotReloadCompositionRoot.Services.Patcher, + context, + CreateEmptyGateResult()); + run.ApplyTo(HotReloadCompositionRoot.Services.Patcher); + + return FindRemovedMembersWarning(file); + } + + // Runs one input that lists a file twice the way the orchestrator does: each copy in its + // own group of the same run, and the record written once the run ends. Returns the + // removed-members warning of each copy in input order. + private static IReadOnlyList AppendDuplicateFileRemovedMembersRun( + string[] firstRemovedMemberNames, + string[] secondRemovedMemberNames) + { + HotReloadRunDisplayedRemovedMembers run = new HotReloadRunDisplayedRemovedMembers(); + HotReloadApplyContext firstContext = CreateContext(displayedRemovedMembers: run); + HotReloadApplyContext secondContext = CreateContext(displayedRemovedMembers: run); + HotReloadGroupFile first = firstContext.Files[0]; + HotReloadGroupFile second = secondContext.Files[0]; + first.FileOutput.removedMembers = CreateRemovedFields(firstRemovedMemberNames); + second.FileOutput.removedMembers = CreateRemovedFields(secondRemovedMemberNames); + + HotReloadGroupNotices.AppendRemovedMemberNotices( + HotReloadCompositionRoot.Services.Patcher, + firstContext, + CreateEmptyGateResult()); + HotReloadGroupNotices.AppendRemovedMemberNotices( + HotReloadCompositionRoot.Services.Patcher, + secondContext, + CreateEmptyGateResult()); + run.ApplyTo(HotReloadCompositionRoot.Services.Patcher); + + return new[] { FindRemovedMembersWarning(first), FindRemovedMembersWarning(second) }; + } + + private static TransformWorkerRemovedMemberDto[] CreateRemovedFields(string[] removedMemberNames) + { List removedMembers = new List(); foreach (string removedMemberName in removedMemberNames) { @@ -836,13 +959,11 @@ private static string AppendRemovedMembersRun(params string[] removedMemberNames }); } - file.FileOutput.removedMembers = removedMembers.ToArray(); - - HotReloadGroupNotices.AppendRemovedMemberNotices( - HotReloadCompositionRoot.Services.Patcher, - context, - CreateEmptyGateResult()); + return removedMembers.ToArray(); + } + private static string FindRemovedMembersWarning(HotReloadGroupFile file) + { foreach (string warning in file.Sinks.Warnings) { if (warning.Contains("Alpha", StringComparison.Ordinal) @@ -1128,12 +1249,14 @@ private static HotReloadGroupCompileResult CreateCompile(params TransformWorkerE } private static HotReloadApplyContext CreateContext( - HotReloadNewSourceMembershipEvidence newSourceMembershipEvidence = null) + HotReloadNewSourceMembershipEvidence newSourceMembershipEvidence = null, + HotReloadRunDisplayedRemovedMembers displayedRemovedMembers = null) { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); Assembly compilationAssembly = FindCompilationAssembly(); HotReloadGroupFile callerFile = CreateFile( - "Assets/CoverageCaller.cs", projectRoot, compilationAssembly, newSourceMembershipEvidence); + "Assets/CoverageCaller.cs", projectRoot, compilationAssembly, newSourceMembershipEvidence, + displayedRemovedMembers); HotReloadGroupFile targetFile = CreateFile( "Assets/CoverageTarget.cs", projectRoot, compilationAssembly); TransformWorkerEntryDto caller = CreateCallerEntry(callerFile.ProjectRelativePath); @@ -1225,7 +1348,8 @@ private static HotReloadGroupFile CreateFile( string path, string projectRoot, Assembly compilationAssembly, - HotReloadNewSourceMembershipEvidence newSourceMembershipEvidence = null) + HotReloadNewSourceMembershipEvidence newSourceMembershipEvidence = null, + HotReloadRunDisplayedRemovedMembers displayedRemovedMembers = null) { // Why a real worker source with its hash: the commit boundary verifies each request // source still hashes to what the transform run reported, and refuses a file it @@ -1236,7 +1360,8 @@ private static HotReloadGroupFile CreateFile( HotReloadGroupFile file = new HotReloadGroupFile( path, workerSourcePath, path, AssemblyName, compilationAssembly, HotReloadTypeHome.ScriptAssembliesUnderProject(projectRoot, AssemblyName), - projectRoot, new HotReloadFileSinks(new List(), null), newSourceMembershipEvidence); + projectRoot, new HotReloadFileSinks(new List(), null, displayedRemovedMembers), + newSourceMembershipEvidence); file.FileOutput = new TransformWorkerFileOutputDto { projectRelativePath = path, diff --git a/Assets/Tests/Editor/SkillInstallLayoutTests.cs b/Assets/Tests/Editor/SkillInstallLayoutTests.cs index 2670569830..afb93d5c52 100644 --- a/Assets/Tests/Editor/SkillInstallLayoutTests.cs +++ b/Assets/Tests/Editor/SkillInstallLayoutTests.cs @@ -163,7 +163,7 @@ public void GetToolDescriptionsByToolName_WhenSkillHasDescription_MapsDescriptio { IReadOnlyDictionary descriptions = SkillInstallLayout.GetToolDescriptionsByToolName(_projectRoot); - Assert.That(descriptions["compile"], Is.EqualTo("Compile the Unity project and report errors/warnings. Use after C# edits.")); + Assert.That(descriptions["compile"], Does.StartWith("Compile the Unity project")); Assert.That(descriptions[UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT], Does.StartWith("Pauses Unity playback")); } diff --git a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md index d212709575..680bcb7b74 100644 --- a/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/CompileCheck/Skill/SKILL.md @@ -1,6 +1,7 @@ --- name: uloop-compile-check description: "Compile C# with the Unity-bundled Roslyn compiler without launching or contacting the Unity Editor; reports errors and warnings only." +internal: true --- # uloop compile-check diff --git a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md index 9c675ac79a..7263aadc28 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md @@ -1,13 +1,21 @@ --- name: uloop-compile toolName: compile -description: "Compile the Unity project and report errors/warnings. Use after C# edits." +description: "Compile the Unity project and report errors/warnings. Use after C# edits; to try edits in the running Editor without a domain reload, use hot-reload." --- # uloop compile Execute Unity project compilation. +## When to use + +- After C# edits, to check for compile errors and make the change live in the Editor (Play + Mode, tests, a tool call): to land edits you finished trying with hot reload, or to apply + structural changes hot reload cannot. +- To try an edit in the running Editor, especially during Play Mode, run `uloop hot-reload` + instead: a compile drops every hot-reload patch and discards the Play session. + ## Usage ```bash diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadFileSinks.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadFileSinks.cs index 7b85645d4b..b6e1f6a399 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadFileSinks.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadFileSinks.cs @@ -16,7 +16,8 @@ internal sealed class HotReloadFileSinks { internal HotReloadFileSinks( List siblingDerivedWarnings, - List oneShotCallerNoteCandidates) + List oneShotCallerNoteCandidates, + HotReloadRunDisplayedRemovedMembers displayedRemovedMembers = null) { Debug.Assert(siblingDerivedWarnings != null, "siblingDerivedWarnings must not be null."); @@ -28,6 +29,7 @@ internal HotReloadFileSinks( IntroducedTypes = new List(); SiblingDerivedWarnings = siblingDerivedWarnings; OneShotCallerNoteCandidates = oneShotCallerNoteCandidates; + DisplayedRemovedMembers = displayedRemovedMembers; } internal List Outcomes { get; } @@ -58,5 +60,9 @@ internal HotReloadFileSinks( // Shared across the whole run; null when the caller collects no one-shot caller notes. internal List OneShotCallerNoteCandidates { get; } + + // Shared across the whole run so the removed-member record is written once the run ends; + // null when the caller keeps no such record. + internal HotReloadRunDisplayedRemovedMembers DisplayedRemovedMembers { get; } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupNotices.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupNotices.cs index 8885e769d5..e74edf5e19 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupNotices.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupNotices.cs @@ -69,11 +69,10 @@ internal static void AppendRemovedMemberNotices( file.FileOutput.removedMembers, file.FileOutput.removedMethodSignatures, gateResult.GatedReplacementMethodKeys); - // Why the record is written even when nothing is reported: the set is the file's, - // not this run's, so a run that reports none has to end the continuation. - bool isSameAsLastDisplayed = patcher.RecordDisplayedRemovedMembers( + bool isSameAsLastDisplayed = patcher.IsSameAsLastDisplayedRemovedMembers( file.ProjectRelativePath, displayedRemovedMembers); + file.Sinks.DisplayedRemovedMembers?.Stage(file.ProjectRelativePath, displayedRemovedMembers); if (displayedRemovedMembers.Count > 0) { file.Sinks.Warnings.Add( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadInputFileResolver.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadInputFileResolver.cs index 6d3f3c5804..4fe52cd87b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadInputFileResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadInputFileResolver.cs @@ -48,7 +48,8 @@ internal void ResolveInputFile( contentPathOverrideByFile); HotReloadFileSinks sinks = new HotReloadFileSinks( run.SiblingDerivedWarnings, - run.OneShotCallerNoteCandidates); + run.OneShotCallerNoteCandidates, + run.DisplayedRemovedMembers); List alreadyActiveOutcomes = new List(); HotReloadPatchTargetResolution resolution = HotReloadPatchTargetSupport.ResolvePatchTarget( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs index 462e32d0d3..9042b73345 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs @@ -182,6 +182,7 @@ await ProcessPlannedGroupAsync( } run.RecordAppliedSourceHashes(); + run.DisplayedRemovedMembers.ApplyTo(_patcher); await MainThreadSwitcher.SwitchToMainThread(ct); string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs index eb4dd76c5f..300058ede6 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs @@ -78,6 +78,10 @@ public HotReloadRunAccumulator( /// Warning sink shared with the per-file stage for sibling-derived notices. public List SiblingDerivedWarnings => _siblingDerivedWarnings; + /// Where each file stages the removed members it reported, recorded once per run. + public HotReloadRunDisplayedRemovedMembers DisplayedRemovedMembers { get; } = + new HotReloadRunDisplayedRemovedMembers(); + /// Where re-applied siblings report a missing baseline, summarized once per run. public HotReloadSiblingBaselineNotices SiblingBaselineNotices => _siblingBaselineNotices; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunDisplayedRemovedMembers.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunDisplayedRemovedMembers.cs new file mode 100644 index 0000000000..27dccbfde3 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunDisplayedRemovedMembers.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Holds the removed-member names each file's warning listed during one run, and writes them + /// to the patcher's record once the run ends. + /// + /// + /// Why the run holds them instead of writing the record where the warning is chosen: an input + /// that lists one file twice runs its second copy in a later group of the same run. That copy + /// has to be compared against the record the run started with, not the one its first copy + /// would have just written, or it reports "Continuing from an earlier run" within one run. + /// + internal sealed class HotReloadRunDisplayedRemovedMembers + { + // Why keyed like the patcher's record and overwritten per stage: the set staged last for a + // path is what that file reported last in the run, which is its state when the run ends. + private readonly Dictionary> _displayedByPath = + new Dictionary>(StringComparer.Ordinal); + + internal void Stage(string projectRelativePath, IReadOnlyList displayedNames) + { + Debug.Assert(!string.IsNullOrEmpty(projectRelativePath), "projectRelativePath must not be empty."); + Debug.Assert(displayedNames != null, "displayedNames must not be null."); + + _displayedByPath[projectRelativePath] = displayedNames; + } + + /// + /// Writes every staged set to the patcher's record. Call once after every group of the + /// run finished, so a run that fails or is cancelled before then writes nothing. + /// + internal void ApplyTo(HotReloadPatcher patcher) + { + Debug.Assert(patcher != null, "patcher must not be null."); + + // Why an empty set is written too: the set is the file's, not this run's, so a run + // that reports none has to end the continuation. + foreach (KeyValuePair> pair in _displayedByPath) + { + // Why the comparison it returns is dropped: every entry of the run was already + // compared against the record as it stood when the run started. + patcher.RecordDisplayedRemovedMembers(pair.Key, pair.Value); + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunDisplayedRemovedMembers.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunDisplayedRemovedMembers.cs.meta new file mode 100644 index 0000000000..ed80189dc6 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunDisplayedRemovedMembers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9c9e44457bbd04d2c9e73b407f8bc195 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingRebindReporter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingRebindReporter.cs index b6474492cf..064a4cd510 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingRebindReporter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingRebindReporter.cs @@ -48,7 +48,10 @@ internal void AppendActiveSiblingsToGroup( firstFile, inclusion.ProjectRelativePath, inclusion.WorkerSourcePath, - new HotReloadFileSinks(run.SiblingDerivedWarnings, run.OneShotCallerNoteCandidates), + new HotReloadFileSinks( + run.SiblingDerivedWarnings, + run.OneShotCallerNoteCandidates, + run.DisplayedRemovedMembers), inclusion.Evidence, run.SiblingBaselineNotices)); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDisplayedRemovedMemberLedger.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDisplayedRemovedMemberLedger.cs new file mode 100644 index 0000000000..420b8d4c0a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDisplayedRemovedMemberLedger.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The removed-member names the last run that reported any showed for each file, so a later + /// run can tell whether it is repeating the same report. + /// + /// + /// Why the last displayed set is kept per file rather than derived from the worker output: + /// the removed members of a run are recomputed from scratch every time, so nothing in a + /// single run can tell a set the previous run already reported from one it never did. + /// + internal sealed class HotReloadDisplayedRemovedMemberLedger + { + private readonly Dictionary> _displayedByPath = + new Dictionary>(StringComparer.Ordinal); + + /// + /// Answers whether the last run that reported removed members for one file reported + /// exactly this set, without changing the record. + /// + internal bool IsSameAsLastDisplayed( + string projectRelativePath, + IReadOnlyList displayedNames) + { + Debug.Assert(!string.IsNullOrEmpty(projectRelativePath), "projectRelativePath must not be empty."); + Debug.Assert(displayedNames != null, "displayedNames must not be null."); + + return displayedNames.Count > 0 + && _displayedByPath.TryGetValue(projectRelativePath, out HashSet lastDisplayed) + && lastDisplayed.SetEquals(displayedNames); + } + + /// + /// Takes the removed-member names a run is about to report for one file and answers + /// whether the last run that reported any for it reported exactly the same set. An empty + /// set drops the record, so a run that reports nothing is not a gap inside a continuation. + /// + internal bool Record( + string projectRelativePath, + IReadOnlyList displayedNames) + { + Debug.Assert(!string.IsNullOrEmpty(projectRelativePath), "projectRelativePath must not be empty."); + Debug.Assert(displayedNames != null, "displayedNames must not be null."); + + if (displayedNames.Count == 0) + { + _displayedByPath.Remove(projectRelativePath); + return false; + } + + HashSet displayed = new HashSet(displayedNames, StringComparer.Ordinal); + bool isSameAsLastDisplayed = + _displayedByPath.TryGetValue(projectRelativePath, out HashSet lastDisplayed) + && lastDisplayed.SetEquals(displayed); + _displayedByPath[projectRelativePath] = displayed; + return isSameAsLastDisplayed; + } + + internal void Clear() + { + _displayedByPath.Clear(); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDisplayedRemovedMemberLedger.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDisplayedRemovedMemberLedger.cs.meta new file mode 100644 index 0000000000..dd78768657 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDisplayedRemovedMemberLedger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 265e7b80578f44df1b5ae83b591624f8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDomain.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDomain.cs index d23c908d3b..19ca117819 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDomain.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDomain.cs @@ -38,12 +38,6 @@ internal sealed class HotReloadDomain : IDisposable new Dictionary( HotReloadSourcePathNormalizer.ProjectRelativePathComparer()); - // Why the last displayed set is kept per file rather than derived from the worker output: - // the removed members of a run are recomputed from scratch every time, so nothing in a - // single run can tell a set the previous run already reported from one it never did. - private readonly Dictionary> _displayedRemovedMembersByPath = - new Dictionary>(StringComparer.Ordinal); - // The identity keys of the serialized added fields the last run left active. Why the whole // active set and not an append-only history: a field that stops being active (removed, // attribute dropped, file reverted) is new again when it comes back, and the reader @@ -84,6 +78,9 @@ internal HotReloadDomain( /// internal HotReloadCompanionSourceLedger CompanionSources { get; } = new HotReloadCompanionSourceLedger(); + internal HotReloadDisplayedRemovedMemberLedger DisplayedRemovedMembers { get; } = + new HotReloadDisplayedRemovedMemberLedger(); + /// /// Where the types of live for this domain: the artifact /// this domain retains when the name belongs to one, and the project's compiled assembly @@ -599,32 +596,6 @@ internal HotReloadNewSourceMembershipEvidence TryGetNewSourceMembershipEvidence( return evidence; } - /// - /// Takes the removed-member names a run is about to report for one file and answers - /// whether the last run that reported any for it reported exactly the same set. An empty - /// set drops the record, so a run that reports nothing is not a gap inside a continuation. - /// - internal bool RecordDisplayedRemovedMembers( - string projectRelativePath, - IReadOnlyList displayedNames) - { - Debug.Assert(!string.IsNullOrEmpty(projectRelativePath), "projectRelativePath must not be empty."); - Debug.Assert(displayedNames != null, "displayedNames must not be null."); - - if (displayedNames.Count == 0) - { - _displayedRemovedMembersByPath.Remove(projectRelativePath); - return false; - } - - HashSet displayed = new HashSet(displayedNames, StringComparer.Ordinal); - bool isSameAsLastDisplayed = - _displayedRemovedMembersByPath.TryGetValue(projectRelativePath, out HashSet lastDisplayed) - && lastDisplayed.SetEquals(displayed); - _displayedRemovedMembersByPath[projectRelativePath] = displayed; - return isSameAsLastDisplayed; - } - /// /// The display names of the active added fields declared with a serialization attribute /// that no earlier run has reported, sorted ordinal; the active set becomes the new record. @@ -698,7 +669,7 @@ internal IReadOnlyList RevertAll() _generationsByPath.Clear(); _appliedSourceByPath.Clear(); _newSourceMembershipEvidenceByPath.Clear(); - _displayedRemovedMembersByPath.Clear(); + DisplayedRemovedMembers.Clear(); _reportedSerializedAddedFields.Clear(); AddedFieldValues.Clear(); Invocations.Clear(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPatcher.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPatcher.cs index 368266c8e7..c1b21f2373 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPatcher.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPatcher.cs @@ -291,6 +291,17 @@ public IReadOnlyList DescribeActivePatches() return _domain.DescribeActivePatches(); } + /// + /// Returns true when the last run that reported removed members for one file reported + /// exactly this set, without changing the record. + /// + public bool IsSameAsLastDisplayedRemovedMembers( + string projectRelativePath, + IReadOnlyList displayedNames) + { + return _domain.DisplayedRemovedMembers.IsSameAsLastDisplayed(projectRelativePath, displayedNames); + } + /// /// Records the removed-member names this run reports for one file, and returns true when /// the last run that reported any for it reported exactly the same set. @@ -299,7 +310,7 @@ public bool RecordDisplayedRemovedMembers( string projectRelativePath, IReadOnlyList displayedNames) { - return _domain.RecordDisplayedRemovedMembers(projectRelativePath, displayedNames); + return _domain.DisplayedRemovedMembers.Record(projectRelativePath, displayedNames); } // Why projectRelativePath, not DescribeActivePatches FilePath filtering by callers: a diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputPressActionExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputPressActionExecutor.cs index 5c344f6c1a..8c6c7b3430 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputPressActionExecutor.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputPressActionExecutor.cs @@ -85,7 +85,9 @@ internal static async Task ExecuteClick( } finally { - waitOutcome = await FinishHeldButton(mouse, button, waitOutcome, pressWasApplied) + // The release must still run when the command itself was cancelled, or the button stays held. + waitOutcome = await FinishHeldButton( + mouse, button, waitOutcome, pressWasApplied, CancellationToken.None) .ConfigureAwait(false); } @@ -179,7 +181,9 @@ internal static async Task ExecuteLongPress( } finally { - waitOutcome = await FinishHeldButton(mouse, button, waitOutcome, pressWasApplied) + // The release must still run when the command itself was cancelled, or the button stays held. + waitOutcome = await FinishHeldButton( + mouse, button, waitOutcome, pressWasApplied, CancellationToken.None) .ConfigureAwait(false); } @@ -216,7 +220,8 @@ private static async Task FinishHeldButton( Mouse mouse, RuntimeMouseButton button, InputSimulationWaitOutcome waitOutcome, - bool pressWasApplied) + bool pressWasApplied, + CancellationToken ct) { if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { @@ -226,7 +231,7 @@ private static async Task FinishHeldButton( if (waitOutcome == InputSimulationWaitOutcome.Paused && pressWasApplied) { - return await ReleaseHeldButtonAfterPause(mouse, button).ConfigureAwait(false); + return await ReleaseHeldButtonAfterPause(mouse, button, ct).ConfigureAwait(false); } if (pressWasApplied) @@ -235,7 +240,7 @@ private static async Task FinishHeldButton( await MouseInputMainThreadCleanup.ReleaseButtonIfPossible( mouse, button, - CancellationToken.None).ConfigureAwait(false); + ct).ConfigureAwait(false); if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) { MouseInputMainThreadCleanup.ScheduleTimedOutButtonCleanup(mouse, button, false); @@ -245,11 +250,11 @@ await MouseInputMainThreadCleanup.ReleaseButtonIfPossible( // The pause can also arrive while this release is queued, which discards it the same way. if (releaseOutcome == InputSimulationWaitOutcome.Paused) { - return await ReleaseHeldButtonAfterPause(mouse, button).ConfigureAwait(false); + return await ReleaseHeldButtonAfterPause(mouse, button, ct).ConfigureAwait(false); } } - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); MouseInputState.SetButtonUp(button); if (waitOutcome == InputSimulationWaitOutcome.Paused) { @@ -269,13 +274,14 @@ await MouseInputMainThreadCleanup.ReleaseButtonIfPossible( /// private static async Task ReleaseHeldButtonAfterPause( Mouse mouse, - RuntimeMouseButton button) + RuntimeMouseButton button, + CancellationToken ct) { // Why: ApplyOnNextConfiguredUpdate already disposed the pending apply subscription when it // returned Paused, so a release routed through it again is discarded and the button stays // held until the next command. Writing after that dispose also keeps a queued edge from // re-applying the press once the Editor resumes. - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); MouseInputMainThreadCleanup.ReleaseButtonImmediatelyAfterPauseInterruption(mouse, button); MouseInputState.SetButtonUp(button); SimulateMouseInputOverlayState.Clear(); diff --git a/Packages/src/project-runner-pin.json b/Packages/src/project-runner-pin.json index b328501b04..edbf2339cd 100644 --- a/Packages/src/project-runner-pin.json +++ b/Packages/src/project-runner-pin.json @@ -1,6 +1,6 @@ { - "dispatcherArchiveManifest": "32f8231a15eb7cffa0f0af19074303d65d52edb1846b1b830f07e9dc41317db0 install.ps1\n8b8b9b0b2f8f0db066570d3061ac0aad97f9b46aa905cb84804950f02a23066e uloop-dispatcher-darwin-amd64.tar.gz\n9254f79ee74ed44195cb34614891339218eedf4c35eef49a5a87fc4e29e9080b uloop-dispatcher-windows-amd64.zip.sha256\n9a6aa1bdf7c9a0c6e7957abb0d037c315b2ef57eadc4c8006783fe500452e2b4 install.sh.sha256\n9d981d103c593a967ebfcd8f705d8ff30ef1e5bd20e416f96e0d32d0ff28f8c3 uloop-dispatcher-windows-amd64.zip\nacdbe117b6f469eb13c1f68617861584559af121f71685bbadb53ad9fb8054ee uloop-dispatcher-darwin-arm64.tar.gz\nc0671764ccd8e88ec1d9992f006f1e82f7891b4ba17462893750d0710c9a5a9e uloop-dispatcher-darwin-arm64.tar.gz.sha256\nd2e8e2e7c5f9d24facb80388836f55559cdf1dd41237e9d3df4d0784ae1b3fa8 uloop-dispatcher-darwin-amd64.tar.gz.sha256\ne97de8d921af3115bfa00544ec69da6136b9cb0e271966f1073777872cd69838 install.ps1.sha256\nf2a93560cae680c67a3fec28f74116eeef9f3be0804025fa616a630ab52477ae install.sh", - "dispatcherReleaseTag": "dispatcher-v3.5.1", + "dispatcherArchiveManifest": "011262cf25499b388950a0d96651e843728b40bbcced83b1be82ff0dfae257dc uloop-dispatcher-darwin-amd64.tar.gz.sha256\n1cb407b18c2038fd2d8617f5a7bc9a3e0d3b7fd13402b6072e12b0d81483bc38 uloop-dispatcher-darwin-amd64.tar.gz\n1d26e626a05c32c1cda0e0fd5b52d7597b52476e317580e4c6ad0d49afe3435d uloop-dispatcher-darwin-arm64.tar.gz.sha256\n26f40893169a6b54471919280998df39902dcd0e0099c5398ab403ecda88f102 uloop-dispatcher-linux-amd64.tar.gz.sha256\n32f8231a15eb7cffa0f0af19074303d65d52edb1846b1b830f07e9dc41317db0 install.ps1\n39b201f20f34c413313638621c501cd4ed49f33c240c64a8e7ff89bc54dcf8e3 uloop-dispatcher-darwin-arm64.tar.gz\n3f0e7ad97e94a36d4ff8ef1c4c7bf4e0ffade39bcd65e81aa118af855053264e install.sh\n4466b53c3728492e5ce789259f2b7274107a97a1e894426ce55ce35d1c3395f4 uloop-dispatcher-linux-amd64.tar.gz\n6eda2092bc4adcff771e0f7d1246d86767d2608710e5bddf85b19b3da4cb9d60 install.sh.sha256\n863b9d107d02b008dad9b95f6b6e0c81e8cc169380b717bf95b3be050585273c uloop-dispatcher-windows-amd64.zip\ncdd88c24dcbb11a484bbaf8a432e8349508927e420525244765918535826a527 uloop-dispatcher-windows-amd64.zip.sha256\ne97de8d921af3115bfa00544ec69da6136b9cb0e271966f1073777872cd69838 install.ps1.sha256", + "dispatcherReleaseTag": "dispatcher-v3.6.0", "minimumDispatcherVersion": "3.0.0-beta.31", "projectRunnerVersion": "3.4.0" } diff --git a/README.md b/README.md index 8638050281..601ef5dd10 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,6 @@ uloop launch Once Unity is up, the package is installed into the project. The next step reads the skills from the installed package. -Later, once the project has been built at least once, `uloop compile-check` reports compile errors without launching Unity at all. See [docs/compile-check.md](docs/compile-check.md). - ### Step 3: Install the skills Run the command for your LLM tool in the root of your Unity project: @@ -174,7 +172,6 @@ That's it! After installing Skills, LLM tools can automatically handle instructi | Your Instruction | Skill Used by LLM Tools | |---|---| | "Launch Unity for this project" | `/uloop-launch` | -| "Check whether it compiles without launching Unity" | `/uloop-compile-check` | | "Fix the compile errors" | `/uloop-compile` | | "Apply this fix right now without compiling" | `/uloop-hot-reload` | | "Run the tests and tell me why they failed" | `/uloop-run-tests` + `/uloop-get-logs` | @@ -187,11 +184,10 @@ That's it! After installing Skills, LLM tools can automatically handle instructi
-All 20 Bundled Skills +All 19 Bundled Skills - `/uloop-launch` - Launch Unity with correct version - `/uloop-compile` - Execute compilation -- `/uloop-compile-check` - Compile without launching Unity - `/uloop-get-logs` - Get console logs - `/uloop-run-tests` - Run tests - `/uloop-hot-reload` - Apply method-body changes to running code instantly, without recompiling @@ -230,9 +226,6 @@ uloop launch -p Android # Kill running Unity and restart uloop launch -r -# Compile without launching Unity -uloop compile-check - # Execute compilation uloop compile diff --git a/cli/common/clicore/command_errors_test.go b/cli/common/clicore/command_errors_test.go index c3ab6f9bb4..964bc672a7 100644 --- a/cli/common/clicore/command_errors_test.go +++ b/cli/common/clicore/command_errors_test.go @@ -3,9 +3,9 @@ package clicore import "testing" func TestAvailableCommandNamesIncludesBuiltIns(t *testing.T) { - // Verifies unknown-command suggestions include built-in CLI commands before cached tools. + // Verifies unknown-command suggestions include visible built-in CLI commands before cached tools. names := availableCommandNames(ToolsCache{}) - expectedBuiltIns := []string{"launch", "compile-check", "list", "sync", "focus-window", "await-pause-point", "pause-point-status", "set-code-optimization", "skills", "package", "install", "update", "uninstall"} + expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", "await-pause-point", "pause-point-status", "set-code-optimization", "skills", "package", "install", "update", "uninstall"} for index, expected := range expectedBuiltIns { if names[index] != expected { t.Fatalf("built-in command mismatch: %#v", names) diff --git a/cli/common/clicore/command_registry.go b/cli/common/clicore/command_registry.go index 7f922516b1..85925fe920 100644 --- a/cli/common/clicore/command_registry.go +++ b/cli/common/clicore/command_registry.go @@ -21,6 +21,8 @@ type NativeCommandEntry struct { Name string Description string Owner CommandOwner + // Hidden keeps a command routable while leaving it out of help, `list --names`, and completion. + Hidden bool } type CommandOwner string @@ -32,7 +34,9 @@ const ( var NativeCommands = []NativeCommandEntry{ {Name: LaunchCommandName, Description: "Open this Unity project with the matching Editor version", Owner: DispatcherOwned}, - {Name: CompileCheckCommandName, Description: "Compile changed assemblies with the Editor's bundled C# compiler without launching Unity", Owner: DispatcherOwned}, + // Why compile-check is hidden: it covers too few situations to recommend yet, so it stays + // runnable for development while nothing user-facing advertises it. + {Name: CompileCheckCommandName, Description: "Compile changed assemblies with the Editor's bundled C# compiler without launching Unity", Owner: DispatcherOwned, Hidden: true}, {Name: "list", Description: "Show Unity tools currently exposed by the Editor", Owner: RunnerOwned}, {Name: "sync", Description: "Refresh .uloop/tools.json from the running Editor", Owner: RunnerOwned}, {Name: "focus-window", Description: "Bring the Unity Editor window to the foreground", Owner: RunnerOwned}, @@ -78,9 +82,22 @@ func NativeCommand(command string) (NativeCommandEntry, bool) { return NativeCommandEntry{}, false } +// VisibleNativeCommands lists the native commands shown to users, in registry order. +func VisibleNativeCommands() []NativeCommandEntry { + entries := make([]NativeCommandEntry, 0, len(NativeCommands)) + for _, entry := range NativeCommands { + if entry.Hidden { + continue + } + entries = append(entries, entry) + } + return entries +} + func NativeCommandNamesForCompletion() []string { - names := make([]string, 0, len(NativeCommands)) - for _, command := range NativeCommands { + visible := VisibleNativeCommands() + names := make([]string, 0, len(visible)) + for _, command := range visible { names = append(names, command.Name) } return names diff --git a/cli/common/clicore/command_registry_test.go b/cli/common/clicore/command_registry_test.go index 240b102a11..b6746a4714 100644 --- a/cli/common/clicore/command_registry_test.go +++ b/cli/common/clicore/command_registry_test.go @@ -106,3 +106,23 @@ func TestPausePointStatusNativeCommandDescription(t *testing.T) { t.Fatalf("description = %q, want %q", entry.Description, want) } } + +// Verifies a hidden native command stays routable but is left out of every user-facing command list. +func TestHiddenNativeCommandIsRoutableButNotListed(t *testing.T) { + if !IsDispatcherOwnedCommandName(CompileCheckCommandName) { + t.Fatalf("%s must stay registered as a dispatcher-owned command", CompileCheckCommandName) + } + for _, entry := range VisibleNativeCommands() { + if entry.Name == CompileCheckCommandName { + t.Fatalf("%s must not be listed: %#v", CompileCheckCommandName, VisibleNativeCommands()) + } + } + for _, name := range NativeCommandNamesForCompletion() { + if name == CompileCheckCommandName { + t.Fatalf("%s must not be offered as a completion: %#v", CompileCheckCommandName, NativeCommandNamesForCompletion()) + } + } + if len(VisibleNativeCommands()) != len(NativeCommands)-1 { + t.Fatalf("only %s should be hidden: %#v", CompileCheckCommandName, VisibleNativeCommands()) + } +} diff --git a/cli/common/tooldocs/skill_guidance.go b/cli/common/tooldocs/skill_guidance.go index 84effa64a3..37d786bff7 100644 --- a/cli/common/tooldocs/skill_guidance.go +++ b/cli/common/tooldocs/skill_guidance.go @@ -8,7 +8,6 @@ package tooldocs var commandSkillNames = map[string]string{ "clear-console": "uloop-clear-console", "compile": "uloop-compile", - "compile-check": "uloop-compile-check", "control-play-mode": "uloop-control-play-mode", "execute-dynamic-code": "uloop-execute-dynamic-code", "find-game-objects": "uloop-find-game-objects", diff --git a/cli/common/tooldocs/skill_guidance_test.go b/cli/common/tooldocs/skill_guidance_test.go index f818aa06a6..770e12053a 100644 --- a/cli/common/tooldocs/skill_guidance_test.go +++ b/cli/common/tooldocs/skill_guidance_test.go @@ -49,14 +49,15 @@ func TestSkillGuidanceLineOmittedForUnmappedCommands(t *testing.T) { } } -// Verifies every skill named in the guidance map exists as a SKILL.md frontmatter name, so the -// instruction can never tell an agent to load a skill that was renamed or removed. +// Verifies every skill named in the guidance map exists as a SKILL.md frontmatter name and is not +// marked internal, so the instruction can never tell an agent to load a skill that was renamed, +// removed, or is never installed. func TestSkillGuidanceMapNamesExistingSkills(t *testing.T) { existing := installedSkillNames(t) for command, skillName := range commandSkillNames { if !existing[skillName] { - t.Errorf("command %s points at skill %q, which no SKILL.md declares", command, skillName) + t.Errorf("command %s points at skill %q, which no installable SKILL.md declares", command, skillName) } } } @@ -74,6 +75,9 @@ func installedSkillNames(t *testing.T) map[string]bool { t.Fatalf("failed to glob %s: %v", pattern, err) } for _, match := range matches { + if isInternalSkill(t, match) { + continue + } names[skillFrontmatterName(t, match)] = true } } @@ -84,6 +88,24 @@ func installedSkillNames(t *testing.T) map[string]bool { return names } +// isInternalSkill reports whether a SKILL.md is marked `internal: true`, which keeps it out of every +// skill install. +func isInternalSkill(t *testing.T, path string) bool { + t.Helper() + + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + + for _, line := range strings.Split(string(content), "\n") { + if strings.TrimSpace(line) == "internal: true" { + return true + } + } + return false +} + func skillFrontmatterName(t *testing.T, path string) string { t.Helper() diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index f213273091..ab4972a3fa 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -2,7 +2,7 @@ "tools": [ { "name": "compile", - "description": "Compile the Unity project and report errors/warnings. Use after C# edits.", + "description": "Compile the Unity project and report errors/warnings. Use after C# edits; to try edits in the running Editor without a domain reload, use hot-reload.", "inputSchema": { "type": "object", "properties": { diff --git a/cli/dispatcher/CHANGELOG.md b/cli/dispatcher/CHANGELOG.md index 21867ae7ee..b2482191bb 100644 --- a/cli/dispatcher/CHANGELOG.md +++ b/cli/dispatcher/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [3.6.0](https://github.com/hatayama/unity-cli-loop/compare/dispatcher-v3.5.1...dispatcher-v3.6.0) (2026-09-23) + + +### Features + +* hot-reload says whether the CLI should compile the edits it could not apply, chosen by --compile-on-skip ([#2819](https://github.com/hatayama/unity-cli-loop/issues/2819)) ([edf54a3](https://github.com/hatayama/unity-cli-loop/commit/edf54a34a1440c5a130477f406248033ea977d88)) +* uloop finds and launches the Unity Editor on Linux ([#2854](https://github.com/hatayama/unity-cli-loop/issues/2854)) ([ba61348](https://github.com/hatayama/unity-cli-loop/commit/ba613489bbe2f65b0807859562e8720a75f4300c)) +* uloop installs, updates, and downloads its project runner on Linux ([#2855](https://github.com/hatayama/unity-cli-loop/issues/2855)) ([828e141](https://github.com/hatayama/unity-cli-loop/commit/828e14144ef0b100a7c7e099370bd824c2a2d103)) + + +### Bug Fixes + +* A busy error while the Editor is paused says the running command cannot finish and how to recover ([#2860](https://github.com/hatayama/unity-cli-loop/issues/2860)) ([63e4ac5](https://github.com/hatayama/unity-cli-loop/commit/63e4ac58b8b18ce1f83ea00ba8c57e9a8159ec51)) +* **cli:** launch --restart tolerates Temp files still held after Unity exits ([#2766](https://github.com/hatayama/unity-cli-loop/issues/2766)) ([4e1fd86](https://github.com/hatayama/unity-cli-loop/commit/4e1fd86bbfa96986df104384c2e0e53a593a206b)) +* Hot reload reports what a run actually did and skips added methods it cannot verify ([#2830](https://github.com/hatayama/unity-cli-loop/issues/2830)) ([c90d226](https://github.com/hatayama/unity-cli-loop/commit/c90d2265a7f5f1bdafe958c24e4bf26aa44f69f4)) + ## [3.5.1](https://github.com/hatayama/unity-cli-loop/compare/dispatcher-v3.5.0...dispatcher-v3.5.1) (2026-09-10) diff --git a/cli/dispatcher/dispatchercontract/dispatcher-contract.json b/cli/dispatcher/dispatchercontract/dispatcher-contract.json index a2fb9b4326..4eb0b1db65 100644 --- a/cli/dispatcher/dispatchercontract/dispatcher-contract.json +++ b/cli/dispatcher/dispatchercontract/dispatcher-contract.json @@ -1,3 +1,3 @@ { - "dispatcherVersion": "3.5.1" + "dispatcherVersion": "3.6.0" } diff --git a/cli/dispatcher/internal/dispatcher/help_test.go b/cli/dispatcher/internal/dispatcher/help_test.go index 67b718d54c..16715eb4f5 100644 --- a/cli/dispatcher/internal/dispatcher/help_test.go +++ b/cli/dispatcher/internal/dispatcher/help_test.go @@ -24,7 +24,6 @@ func TestPrintDispatcherHelpListsNativeCommandsAndLiveToolGuidance(t *testing.T) "Dispatcher. Finds the Unity project, then dispatches live Unity tool commands.", "Native commands:", " launch", - " compile-check", " focus-window", " list", " skills", @@ -45,8 +44,8 @@ func TestPrintDispatcherHelpListsNativeCommandsAndLiveToolGuidance(t *testing.T) } } for _, unexpected := range []string{ - // Why the trailing space: " compile-check" is a native command and shares the prefix. - " compile ", + // Why compile-check is here: it is a hidden native command, still routable but never listed. + " compile", " get-logs", " run-tests", "uloop --list-commands", @@ -68,7 +67,6 @@ func TestPrintProjectLocalHelpListsNativeCommandsAndLiveToolGuidance(t *testing. for _, expected := range []string{ "Native commands:", " launch", - " compile-check", " focus-window", " list", " sync", @@ -86,8 +84,8 @@ func TestPrintProjectLocalHelpListsNativeCommandsAndLiveToolGuidance(t *testing. } } for _, unexpected := range []string{ - // Why the trailing space: " compile-check" is a native command and shares the prefix. - " compile ", + // Why compile-check is here: it is a hidden native command, still routable but never listed. + " compile", " get-logs", " run-tests", "uloop --list-commands", diff --git a/cli/dispatcher/internal/dispatcher/run_help.go b/cli/dispatcher/internal/dispatcher/run_help.go index e8dd8a9984..572595a146 100644 --- a/cli/dispatcher/internal/dispatcher/run_help.go +++ b/cli/dispatcher/internal/dispatcher/run_help.go @@ -78,7 +78,7 @@ func printMainHelp(stdout io.Writer, displayVersion string, description string, func printNativeCommandHelp(stdout io.Writer) { clicore.WriteLine(stdout, "Native commands:") - for _, entry := range clicore.NativeCommands { + for _, entry := range clicore.VisibleNativeCommands() { clicore.WriteFormat(stdout, " %-14s %s\n", entry.Name, entry.Description) } } diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 14a5ea17b7..f3f8ed4736 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "2f4294125a26d4c92bb6d29fedbf8ab04eca4731" + "sharedInputsHash": "b15125665692a67fdde75e95585bde76a35defc5" } diff --git a/cli/project-runner/internal/projectrunner/list_names_test.go b/cli/project-runner/internal/projectrunner/list_names_test.go index 22248ebc59..8ec9105145 100644 --- a/cli/project-runner/internal/projectrunner/list_names_test.go +++ b/cli/project-runner/internal/projectrunner/list_names_test.go @@ -44,7 +44,7 @@ func TestRunResolvedProjectCommandListNamesWritesNativeAndLiveToolNames(t *testi t.Fatalf("list --names failed: code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) } - const expected = "launch\ncompile-check\nlist\nsync\nfocus-window\nawait-pause-point\npause-point-status\nset-code-optimization\nskills\npackage\ninstall\nupdate\nuninstall\nversion\nenable-watch\nclear-watch\nget-watch-values\n" + const expected = "launch\nlist\nsync\nfocus-window\nawait-pause-point\npause-point-status\nset-code-optimization\nskills\npackage\ninstall\nupdate\nuninstall\nversion\nenable-watch\nclear-watch\nget-watch-values\n" if stdout.String() != expected { t.Fatalf("list --names output mismatch:\n got:\n%s\nwant:\n%s", stdout.String(), expected) } @@ -58,7 +58,7 @@ func TestRunResolvedProjectCommandListNamesWritesNativeAndLiveToolNames(t *testi // Verifies list accepts repeated names selectors but rejects value assignment and positional // arguments with the complete stable INVALID_ARGUMENT envelope. func TestRunListOptionBoundaries(t *testing.T) { - const namesOutput = "launch\ncompile-check\nlist\nsync\nfocus-window\nawait-pause-point\npause-point-status\nset-code-optimization\nskills\npackage\ninstall\nupdate\nuninstall\nversion\n" + const namesOutput = "launch\nlist\nsync\nfocus-window\nawait-pause-point\npause-point-status\nset-code-optimization\nskills\npackage\ninstall\nupdate\nuninstall\nversion\n" const namesAssignmentError = "{\n" + " \"Success\": false,\n" + " \"Error\": {\n" + diff --git a/cli/project-runner/internal/projectrunner/list_output.go b/cli/project-runner/internal/projectrunner/list_output.go index 1e0a576dac..9d0c52f865 100644 --- a/cli/project-runner/internal/projectrunner/list_output.go +++ b/cli/project-runner/internal/projectrunner/list_output.go @@ -61,7 +61,7 @@ func writeToolNames(result json.RawMessage, stdout io.Writer) error { return err } - for _, command := range clicore.NativeCommands { + for _, command := range clicore.VisibleNativeCommands() { clicore.WriteLine(stdout, command.Name) } for _, tool := range cache.Tools { diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 37f31ecb88..883c592925 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "6ec21831e7691104e5d8d4cb436ea0c5b9f69cf8" + "sharedInputsHash": "584ce2aa3e02c01be716d042827cfc66c9625f0e" } diff --git a/docs/compile-check.md b/docs/compile-check.md index d6efb23723..6312da152c 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -4,6 +4,10 @@ without contacting a running one. It is a dispatcher-owned command: it needs no IPC connection and no project runner. +The command is hidden: it stays runnable, but help, `uloop list --names`, completion, and the +installed skills do not list it, because it covers too few situations to recommend yet (see the +limitations below). Its skill is marked `internal: true`, so `uloop skills install` skips it. + It is not a replacement for `uloop compile`. Nothing it produces is loaded by Unity, so use it only when you want to know whether the code compiles — not when you need the result to be live in the Editor. @@ -52,12 +56,16 @@ references, scripting defines and analyzers. `compile-check` replays those respo compiles every assembly in the build and skips nothing. Assemblies this step leaves out are counted in `SkippedAssemblies`; an assembly the run later stops short of because one it references has errors is named in `BlockedAssemblies` instead, and counted nowhere. An - assembly's sources are re-globbed from its own directory, stopping at nested assembly - boundaries; the files an `.asmref` attaches to it are taken from the last build's response file - instead, wherever that folder sits — including inside a nested assembly's directory, which the - glob never reaches. Such a file is kept only while an `.asmref` still attaches its folder to this - assembly: the walk up from the file to the first folder holding an `.asmref` or an `.asmdef` says - which assembly owns it today. An `.asmref` naming an assembly the build never produced — an + assembly's sources are re-globbed from its own directory and from every folder an `.asmref` + attaches to it, wherever that folder sits — including inside a nested assembly's directory, + which the glob of its own directory never reaches — each glob stopping at nested assembly + boundaries, so a `.cs` file created in any of them since the last build is compiled. Assemblies + under `Library/PackageCache` are the exception: the package manager restores them and nothing + edits them in place, so their recorded sources are reused as-is and neither their directory nor + their `.asmref` folders are re-globbed. A source + the last build recorded for this assembly that no glob produces any more stops the run unless an + `.asmref` still attaches its folder to this assembly: the walk up from the file to the first + folder holding an `.asmref` or an `.asmdef` says which assembly owns it today. An `.asmref` naming an assembly the build never produced — an unresolvable reference, or one excluded by platform or package settings — attaches nothing, so its folder is treated as an ordinary part of the assembly around it, which is what Unity compiles it as. A package shipping a sample for a render pipeline the project does not install is the @@ -156,10 +164,6 @@ and the ones left out because every assembly they reference kept the same public - **Changed scripting defines are not detected**; the defines recorded in the last build are reused. - **The predefined assemblies** (`Assembly-CSharp` and friends) keep the source list of the last build, so a brand-new `.cs` file outside any `.asmdef` is not compiled until Unity imports it. -- **A new `.cs` file inside an `.asmref` folder is not compiled either.** The sources an `.asmref` - attaches come only from the last build's response file, because the glob stops at the folder's - assembly boundary, so a file added there waits for Unity to import it just as the predefined - assemblies do. - **An `.editorconfig` or global analyzer config that is not named by a response file does not 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