From cbee05b9c1792db9761def9a2dc1122fcacde0ed Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 23 Sep 2026 12:18:12 +0900 Subject: [PATCH 1/7] fix: The compile skill tells agents when to use hot-reload instead (#2917) --- .agents/skills/uloop-compile/SKILL.md | 10 +++++++++- .claude/skills/uloop-compile/SKILL.md | 10 +++++++++- .../Tests/Editor/SkillInstallLayoutTests.cs | 2 +- .../FirstPartyTools/Compile/Skill/SKILL.md | 10 +++++++++- cli/common/tools/default-tools.json | 2 +- docs/compile-check.md | 20 +++++++++---------- 6 files changed, 39 insertions(+), 15 deletions(-) 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/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/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/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/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index ee0943e3b5..716109a40b 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/docs/compile-check.md b/docs/compile-check.md index d6efb23723..4b247e933c 100644 --- a/docs/compile-check.md +++ b/docs/compile-check.md @@ -52,12 +52,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 +160,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 From 45c7f014baaefd0a9b1e46e83c1e190c86263140 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 23 Sep 2026 12:20:12 +0900 Subject: [PATCH 2/7] chore: Keep the unreleased compile-check command out of help, command lists, and installed skills (#2919) --- .agents/skills/uloop-compile-check/SKILL.md | 96 ------------------- .claude/skills/uloop-compile-check/SKILL.md | 96 ------------------- .../CliOnlyTools~/CompileCheck/Skill/SKILL.md | 1 + README.md | 9 +- cli/common/clicore/command_errors_test.go | 4 +- cli/common/clicore/command_registry.go | 23 ++++- cli/common/clicore/command_registry_test.go | 20 ++++ cli/common/tooldocs/skill_guidance.go | 1 - cli/common/tooldocs/skill_guidance_test.go | 28 +++++- .../internal/dispatcher/help_test.go | 10 +- .../internal/dispatcher/run_help.go | 2 +- cli/dispatcher/shared-inputs-stamp.json | 2 +- .../internal/projectrunner/list_names_test.go | 4 +- .../internal/projectrunner/list_output.go | 2 +- cli/project-runner/shared-inputs-stamp.json | 2 +- docs/compile-check.md | 4 + 16 files changed, 83 insertions(+), 221 deletions(-) delete mode 100644 .agents/skills/uloop-compile-check/SKILL.md delete mode 100644 .claude/skills/uloop-compile-check/SKILL.md 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/.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/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/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/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 4b247e933c..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. From ad7e8da0bb89bc2b9bf6e78f8ffe31d41dc3d17d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:00:32 +0900 Subject: [PATCH 3/7] chore(main): release dispatcher 3.6.0 (#2769) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- cli/dispatcher/CHANGELOG.md | 16 ++++++++++++++++ .../dispatchercontract/dispatcher-contract.json | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) 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/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" } From 8deba7cc558c7481d5cf859aae9105059e50f7cb Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 23 Sep 2026 14:04:25 +0900 Subject: [PATCH 4/7] chore: Pass a cancellation token through mouse press cleanup helpers (#2930) --- .../MouseInputPressActionExecutor.cs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) 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(); From 023b168ee20f9103879353011ec1c676b2d235ba Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Wed, 23 Sep 2026 14:04:35 +0900 Subject: [PATCH 5/7] fix: A hot reload that lists a file twice no longer tells the second copy it continues an earlier run (#2931) --- .../HotReload/HotReloadGroupProcessorTests.cs | 149 ++++++++++++++++-- .../HotReload/HotReloadFileSinks.cs | 8 +- .../HotReload/HotReloadGroupNotices.cs | 5 +- .../HotReload/HotReloadInputFileResolver.cs | 3 +- .../HotReload/HotReloadOrchestrator.cs | 1 + .../HotReload/HotReloadRunAccumulator.cs | 4 + .../HotReloadRunDisplayedRemovedMembers.cs | 51 ++++++ ...otReloadRunDisplayedRemovedMembers.cs.meta | 11 ++ .../HotReloadSiblingRebindReporter.cs | 5 +- .../HotReload/Patching/HotReloadDomain.cs | 16 ++ .../HotReload/Patching/HotReloadPatcher.cs | 11 ++ 11 files changed, 246 insertions(+), 18 deletions(-) create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunDisplayedRemovedMembers.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunDisplayedRemovedMembers.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs index 6dcb437cb6..a5e230735c 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs @@ -759,12 +759,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) { @@ -775,13 +898,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) @@ -1001,12 +1122,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); @@ -1078,7 +1201,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 @@ -1089,7 +1213,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/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadFileSinks.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadFileSinks.cs index fb4291514d..d60c487ce7 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; } @@ -53,5 +55,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 b7d59ced5c..3d26303798 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 9b7129a8a3..05145282dd 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 e6e7265974..9ecbbdd167 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRunAccumulator.cs @@ -73,6 +73,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 c532009cc1..ec562f957b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingRebindReporter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingRebindReporter.cs @@ -47,7 +47,10 @@ internal void AppendActiveSiblingsToGroup( firstFile, filesToInclude[index].ProjectRelativePath, filesToInclude[index].WorkerSourcePath, - new HotReloadFileSinks(run.SiblingDerivedWarnings, run.OneShotCallerNoteCandidates), + new HotReloadFileSinks( + run.SiblingDerivedWarnings, + run.OneShotCallerNoteCandidates, + run.DisplayedRemovedMembers), filesToInclude[index].Evidence, run.SiblingBaselineNotices)); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDomain.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDomain.cs index 6fab179931..2a7d3ab342 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDomain.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDomain.cs @@ -302,6 +302,22 @@ internal bool IsActiveMember(string projectRelativePath, string methodKey) return generation != null && generation.IsActiveMember(methodKey); } + /// + /// Answers whether the last run that reported removed members for one file reported + /// exactly this set, without changing the record. + /// + internal bool IsSameAsLastDisplayedRemovedMembers( + 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 + && _displayedRemovedMembersByPath.TryGetValue(projectRelativePath, out HashSet lastDisplayed) + && lastDisplayed.SetEquals(displayedNames); + } + /// /// The live patches this domain holds on the methods one assembly declares on one type, /// which is what a run peels when an edited body matches that assembly's own code again. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPatcher.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPatcher.cs index 368266c8e7..e1b675d389 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.IsSameAsLastDisplayedRemovedMembers(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. From 18fa43c450c89552cef9df9386827100bfedb14a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 05:40:44 +0000 Subject: [PATCH 6/7] chore: update dispatcher pin to the 3.6.0 stable release Fresh installs resolve the dispatcher named by this pin, so until it records dispatcher-v3.6.0 every new install keeps landing on the previously pinned release. minimumDispatcherVersion stays as it is: the package does not require a newer dispatcher, and raising the floor would lock out working installs. Stamped and verified against the published release attestations by the dispatcher-publish workflow. --- .uloop/project-runner-pin.json | 4 ++-- Packages/src/project-runner-pin.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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/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" } From e02dc87c1fb974fa607e44ee7a6c11f6accfe565 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 23 Sep 2026 15:16:39 +0900 Subject: [PATCH 7/7] Move the displayed removed-member record into its own ledger Merging main brought the per-file record of reported removed members into HotReloadDomain beside the integration branch's own additions, which took the file past the 500 SLOC limit. The record, its two queries, and its revert-all clear form one unit, so they move into a ledger the domain exposes the way it exposes CompanionSources. Behavior is unchanged. --- .../HotReloadDisplayedRemovedMemberLedger.cs | 69 +++++++++++++++++++ ...ReloadDisplayedRemovedMemberLedger.cs.meta | 11 +++ .../HotReload/Patching/HotReloadDomain.cs | 53 ++------------ .../HotReload/Patching/HotReloadPatcher.cs | 4 +- 4 files changed, 86 insertions(+), 51 deletions(-) create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDisplayedRemovedMemberLedger.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadDisplayedRemovedMemberLedger.cs.meta 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 7eabb5ae67..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 @@ -319,22 +316,6 @@ internal bool IsActiveMember(string projectRelativePath, string methodKey) return generation != null && generation.IsActiveMember(methodKey); } - /// - /// Answers whether the last run that reported removed members for one file reported - /// exactly this set, without changing the record. - /// - internal bool IsSameAsLastDisplayedRemovedMembers( - 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 - && _displayedRemovedMembersByPath.TryGetValue(projectRelativePath, out HashSet lastDisplayed) - && lastDisplayed.SetEquals(displayedNames); - } - /// /// The live patches this domain holds on the methods one assembly declares on one type, /// which is what a run peels when an edited body matches that assembly's own code again. @@ -615,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. @@ -714,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 e1b675d389..c1b21f2373 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPatcher.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPatcher.cs @@ -299,7 +299,7 @@ public bool IsSameAsLastDisplayedRemovedMembers( string projectRelativePath, IReadOnlyList displayedNames) { - return _domain.IsSameAsLastDisplayedRemovedMembers(projectRelativePath, displayedNames); + return _domain.DisplayedRemovedMembers.IsSameAsLastDisplayed(projectRelativePath, displayedNames); } /// @@ -310,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