diff --git a/.agents/skills/uloop-hot-reload/references/output.md b/.agents/skills/uloop-hot-reload/references/output.md index b40dd86a84..c7f418f9a9 100644 --- a/.agents/skills/uloop-hot-reload/references/output.md +++ b/.agents/skills/uloop-hot-reload/references/output.md @@ -1,5 +1,18 @@ # Hot Reload Output Fields +## Is my edit live? Read `Outcome` first + +- `Applied` — the edits of the files you asked about are live (`Patched`, `Added`, or `AlreadyActive` rows, or introduced types), and none was `Skipped`. Nothing else to do. +- `PartiallyApplied` — some of them are live, some were `Skipped`; read the `Methods[]` rows with `Kind: "Skipped"`. +- `NothingApplied` — none of them is live, and some were `Skipped`. `Warnings` (or `Methods[].Reason`) says why. +- `NothingToApply` — nothing changed against the compiled code (see `UnchangedTotal`), the files hold no method bodies, or their only rows are `Stale`. +- `Failed` — at least one `Failed` row, so `Success` is `false` — unless a fallback compile then succeeded, which turns the whole answer into `ReplacedByCompile`. +- `ReplacedByCompile` — a fallback compile ran in this same command and succeeded (`Compile` holds its response): every edit is compiled in, and `Success` is the compile's. The compile reloaded the domain, so none of this run's patches survive; the totals and `Methods[]` describe the reload that ran before the compile — read `--status` for the state after it. + +`Outcome` is written on apply runs only and judges the files you asked about; rows of a sibling file the run re-applied on its own (`ReappliedFromSibling: true`) do not change it, while `CompileFallback` may still be `Requested` for a retried sibling row. The totals beside it (`PatchedTotal`, `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal`) count every `Methods[]` row by `Kind`, siblings included. + +## Fields + Returns JSON with: - `Success` (boolean): `false` on parameter validation failure or when any method outcome is `Failed`, or when any `IntroducedTypes` row is `Failed`. `Skipped` outcomes alone never force `false` @@ -7,12 +20,15 @@ Returns JSON with: - `NextActions` (array, optional): Ordered recovery steps, present only with `ErrorCode` on a parameter validation failure. Omitted from every other response, including successful apply, plain `--status`, and `--revert-all` runs. - `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` - `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped`: that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. +- `Outcome` (string, apply runs only): `Applied`, `PartiallyApplied`, `NothingApplied`, `NothingToApply`, `Failed`, or `ReplacedByCompile` — whether the edits of the files you asked about are live; see "Is my edit live?" above. Omitted on `--status`, `--revert-all`, and validation failures. - `PatchedTotal` (number): Methods patched in this run +- `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal` (number): `Methods[]` rows of this run whose `Kind` is `Skipped`, `Added`, `Failed`, `AlreadyActive`, or `Stale`, sibling rows included, as in `PatchedTotal`. `0` on `--status`, `--revert-all`, and validation failures. - `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. It is always empty on `--status` and `--revert-all` runs; the live list is the `Methods` rows with `Kind` `AddedField`, counted by `AddedFieldTotal`. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `AddedConsts` (array): source-level names ("Type.const") of consts this reload added. They are folded into edited bodies as literals, so they are not listed in AddedFields and do not emit the added-field lifetime warning. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `ActivePatchTotal` (number): Active changes after this run — patched methods plus added members. Introduced types are not counted here; `ActiveIntroducedTypeTotal` reports those. `--revert-all` clears the patched methods and added members counted here and reports their combined count in `ClearedCount`; introduced types stay loaded until the next Domain Reload and remain in `ActiveIntroducedTypeTotal`. Does not include `AddedField` rows. Validation failures (`HOT_RELOAD_NO_CHANGED_FILES` and the other `ErrorCode` cases) also report the live ledger value, not the default 0. - `AutoRefreshHeld` (boolean): True while Auto Refresh is held because at least one hot-reload change is still active. The first apply that arms the hold appends a Message sentence telling the caller to run `uloop compile` to release it, and that `--revert-all` releases it only when no introduced type remains — a revert cannot unload the assembly carrying an introduced type. A release during Play adds a Warning that pending script edits import on the next focus return or `uloop compile`. If the post-release Refresh is skipped because an open dirty scene also changed on disk, Warnings include the sentence telling the caller to resolve that scene and then run `uloop compile`. `--status` and `--revert-all` report the live value. +- `AutoRefreshHoldMessage` (string, optional): The hold sentence this run appended to `Message` when it armed the hold; omitted when the run did not arm it. After a successful fallback compile, the CLI removes that sentence from `Message` and keeps this field as the record of what it removed. - `AddedFieldTotal` (number): Live added-field ledger rows after this run or on `--status`, added field-like events included. Those rows appear as `Kind` `AddedField` on `--status` only; they are not counted in `ActivePatchTotal` - `DroppedByPlayModeEntryCount` (number): Remaining patched-method, added-member, and introduced-type identities discarded by the Play-entry domain reload that have not been recovered by a later apply (`Patched` / `Added` methods, `Introduced` / `AlreadyActive` types — a recovered type also recovers the patches and added members inside it), `--revert-all`, or a successful compile. Omitted when the count is 0. Re-apply `uloop hot-reload`, or edit the files and run `uloop compile` - `RestoredWiredValueCount` (number, `--status` only): Values written through the added-field wiring call that the last scene reload (entering or leaving Play Mode with domain reload disabled) gave back to the rebuilt objects. It also counts the values `--status` itself gave back by reading them for a host that is back at its place. Omitted when 0. See [added-field-wiring.md](added-field-wiring.md) @@ -20,8 +36,8 @@ Returns JSON with: - `ClearedCount` (number): Patches removed by `--revert-all`, or stale patches reverted because their source matched the compiled baseline again - `IntroducedTypes` (array): Per-type `{ Kind, TypeName, AssemblyName, FilePath, Reason }` rows for the type declarations a reload met, always present and empty when there are none. On apply runs `Kind` is `Introduced` (this reload compiled the declaration into a retained assembly and it is now loaded), `AlreadyActive` (the declaration is bound from an assembly an earlier reload retained, so this reload introduced nothing for it), or `Failed` (the declaration was refused — a redefinition of a type already active, the same type declared in more than one file of the group, or a failed artifact compilation; `Reason` says which, and a `Failed` row alone makes `Success` false). On `--status` every row is `Kind` `Active` and lists a type this domain still holds. Declarations a reload simply cannot introduce are reported as `Warnings`, not rows. These rows are never counted in `PatchedTotal`, `ActivePatchTotal`, `AddedFieldTotal`, or `ClearedCount` - `ActiveIntroducedTypeTotal` (number): Introduced types this domain holds after this run or on `--status`, counted per type rather than per compiled artifact; always present and `0` when there is none. `--revert-all` cannot unload them, so its Message says how many stay loaded until the next Domain Reload, and that Auto Refresh stays held for them until `uloop compile` -- `Message` (string): Short summary. When a run carries `IntroducedTypes` rows, Message reports them: a run that only introduced or only re-bound types says so instead of reporting the methods, a refused declaration is reported as the failure of the run and points at `IntroducedTypes`, and a run the methods decided ends with `IntroducedTypes=N`. On apply runs that pulled in sibling files, Message follows `PatchedTotal` and `Added` with how many of those Patched and Added rows re-applied the siblings' earlier changes (left out when 0). On apply runs, Message counts the patched rows that carry a `LifecycleNote` in one sentence and the added Unity messages a hot-reload proxy delivers in another, both pointing at `Methods[].LifecycleNote`; forwarded `Added` rows are not in the patched count, and each sentence is left out when its count is 0. On `--status`, Message opens with how many changes are currently active — patched methods, added members, and introduced types together, which is why it can exceed `ActivePatchTotal` — and when any `Active` or `Added` row has `InvocationCount` 0 it also appends how many such rows there are and points at `Methods[].Reason`. `--revert-all` appends how many introduced types stay loaded until the next Domain Reload, and when the hold is still armed for them, that Auto Refresh stays held until `uloop compile` +- `Message` (string): Short summary. When a run carries `IntroducedTypes` rows, Message reports them: a run that only introduced or only re-bound types says so instead of reporting the methods, a refused declaration is reported as the failure of the run and points at `IntroducedTypes`, and a run the methods decided ends with `IntroducedTypes=N`. On apply runs that pulled in sibling files, Message follows `PatchedTotal` and `Added` with how many of those Patched and Added rows re-applied the siblings' earlier changes (left out when 0). On apply runs, Message counts the patched rows that carry a `LifecycleNote` in one sentence and the added Unity messages a hot-reload proxy delivers in another, both pointing at `Methods[].LifecycleNote`; forwarded `Added` rows are not in the patched count, and each sentence is left out when its count is 0. On `--status`, Message opens with how many changes are currently active — patched methods, added members, and introduced types together, which is why it can exceed `ActivePatchTotal` — and when any `Active` or `Added` row has `InvocationCount` 0 it also appends how many such rows there are and points at `Methods[].Reason`. `--revert-all` appends how many introduced types stay loaded until the next Domain Reload, and when the hold is still armed for them, that Auto Refresh stays held until `uloop compile`. When the fallback compile succeeds, the CLI sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, and removes the hold sentence (`AutoRefreshHoldMessage`) from `Message`, because the compile released the hold. - `RecommendedNextAction` (string): Present in three cases. (1) Any method or introduced-type outcome is `Failed`: a partial apply (some methods patched or added, or some types left active) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. (2) Every method of the requested files was `Skipped`, which still answers `Success`: it points first at the fix each Skipped row's `Methods[].Reason` names and offers `uloop compile` as the alternative. (3) `CompileFallback` is `HeldForPlayMode` or `BlockedByPlayModeSetting`, whatever the outcomes: the reason no compile ran is appended after any advice from (1) or (2), and it opens by saying to do any fix a `Reason` names that needs no compile before compiling. Omitted otherwise. - `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts. -- `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction` and ends `Message` with a sentence saying the compile succeeded; a failed one sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`. -- `CompileFallbackNote` (string, present only with `Compile`): why the compile ran and how it ended. +- `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction`, sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, removes the hold sentence from `Message`, and ends `Message` with a sentence saying the compile succeeded; a failed one leaves `Outcome`, `AutoRefreshHeld`, and `Message` as the reload reported them, and sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`. +- `CompileFallbackNote` (string, present only with `Compile`): why the compile ran and how it ended. When it succeeded, `Outcome` is `ReplacedByCompile`, `AutoRefreshHeld` is `false`, and `Message` no longer carries the hold sentence. diff --git a/.claude/skills/uloop-hot-reload/references/output.md b/.claude/skills/uloop-hot-reload/references/output.md index b40dd86a84..c7f418f9a9 100644 --- a/.claude/skills/uloop-hot-reload/references/output.md +++ b/.claude/skills/uloop-hot-reload/references/output.md @@ -1,5 +1,18 @@ # Hot Reload Output Fields +## Is my edit live? Read `Outcome` first + +- `Applied` — the edits of the files you asked about are live (`Patched`, `Added`, or `AlreadyActive` rows, or introduced types), and none was `Skipped`. Nothing else to do. +- `PartiallyApplied` — some of them are live, some were `Skipped`; read the `Methods[]` rows with `Kind: "Skipped"`. +- `NothingApplied` — none of them is live, and some were `Skipped`. `Warnings` (or `Methods[].Reason`) says why. +- `NothingToApply` — nothing changed against the compiled code (see `UnchangedTotal`), the files hold no method bodies, or their only rows are `Stale`. +- `Failed` — at least one `Failed` row, so `Success` is `false` — unless a fallback compile then succeeded, which turns the whole answer into `ReplacedByCompile`. +- `ReplacedByCompile` — a fallback compile ran in this same command and succeeded (`Compile` holds its response): every edit is compiled in, and `Success` is the compile's. The compile reloaded the domain, so none of this run's patches survive; the totals and `Methods[]` describe the reload that ran before the compile — read `--status` for the state after it. + +`Outcome` is written on apply runs only and judges the files you asked about; rows of a sibling file the run re-applied on its own (`ReappliedFromSibling: true`) do not change it, while `CompileFallback` may still be `Requested` for a retried sibling row. The totals beside it (`PatchedTotal`, `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal`) count every `Methods[]` row by `Kind`, siblings included. + +## Fields + Returns JSON with: - `Success` (boolean): `false` on parameter validation failure or when any method outcome is `Failed`, or when any `IntroducedTypes` row is `Failed`. `Skipped` outcomes alone never force `false` @@ -7,12 +20,15 @@ Returns JSON with: - `NextActions` (array, optional): Ordered recovery steps, present only with `ErrorCode` on a parameter validation failure. Omitted from every other response, including successful apply, plain `--status`, and `--revert-all` runs. - `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` - `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped`: that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. +- `Outcome` (string, apply runs only): `Applied`, `PartiallyApplied`, `NothingApplied`, `NothingToApply`, `Failed`, or `ReplacedByCompile` — whether the edits of the files you asked about are live; see "Is my edit live?" above. Omitted on `--status`, `--revert-all`, and validation failures. - `PatchedTotal` (number): Methods patched in this run +- `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal` (number): `Methods[]` rows of this run whose `Kind` is `Skipped`, `Added`, `Failed`, `AlreadyActive`, or `Stale`, sibling rows included, as in `PatchedTotal`. `0` on `--status`, `--revert-all`, and validation failures. - `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. It is always empty on `--status` and `--revert-all` runs; the live list is the `Methods` rows with `Kind` `AddedField`, counted by `AddedFieldTotal`. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `AddedConsts` (array): source-level names ("Type.const") of consts this reload added. They are folded into edited bodies as literals, so they are not listed in AddedFields and do not emit the added-field lifetime warning. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `ActivePatchTotal` (number): Active changes after this run — patched methods plus added members. Introduced types are not counted here; `ActiveIntroducedTypeTotal` reports those. `--revert-all` clears the patched methods and added members counted here and reports their combined count in `ClearedCount`; introduced types stay loaded until the next Domain Reload and remain in `ActiveIntroducedTypeTotal`. Does not include `AddedField` rows. Validation failures (`HOT_RELOAD_NO_CHANGED_FILES` and the other `ErrorCode` cases) also report the live ledger value, not the default 0. - `AutoRefreshHeld` (boolean): True while Auto Refresh is held because at least one hot-reload change is still active. The first apply that arms the hold appends a Message sentence telling the caller to run `uloop compile` to release it, and that `--revert-all` releases it only when no introduced type remains — a revert cannot unload the assembly carrying an introduced type. A release during Play adds a Warning that pending script edits import on the next focus return or `uloop compile`. If the post-release Refresh is skipped because an open dirty scene also changed on disk, Warnings include the sentence telling the caller to resolve that scene and then run `uloop compile`. `--status` and `--revert-all` report the live value. +- `AutoRefreshHoldMessage` (string, optional): The hold sentence this run appended to `Message` when it armed the hold; omitted when the run did not arm it. After a successful fallback compile, the CLI removes that sentence from `Message` and keeps this field as the record of what it removed. - `AddedFieldTotal` (number): Live added-field ledger rows after this run or on `--status`, added field-like events included. Those rows appear as `Kind` `AddedField` on `--status` only; they are not counted in `ActivePatchTotal` - `DroppedByPlayModeEntryCount` (number): Remaining patched-method, added-member, and introduced-type identities discarded by the Play-entry domain reload that have not been recovered by a later apply (`Patched` / `Added` methods, `Introduced` / `AlreadyActive` types — a recovered type also recovers the patches and added members inside it), `--revert-all`, or a successful compile. Omitted when the count is 0. Re-apply `uloop hot-reload`, or edit the files and run `uloop compile` - `RestoredWiredValueCount` (number, `--status` only): Values written through the added-field wiring call that the last scene reload (entering or leaving Play Mode with domain reload disabled) gave back to the rebuilt objects. It also counts the values `--status` itself gave back by reading them for a host that is back at its place. Omitted when 0. See [added-field-wiring.md](added-field-wiring.md) @@ -20,8 +36,8 @@ Returns JSON with: - `ClearedCount` (number): Patches removed by `--revert-all`, or stale patches reverted because their source matched the compiled baseline again - `IntroducedTypes` (array): Per-type `{ Kind, TypeName, AssemblyName, FilePath, Reason }` rows for the type declarations a reload met, always present and empty when there are none. On apply runs `Kind` is `Introduced` (this reload compiled the declaration into a retained assembly and it is now loaded), `AlreadyActive` (the declaration is bound from an assembly an earlier reload retained, so this reload introduced nothing for it), or `Failed` (the declaration was refused — a redefinition of a type already active, the same type declared in more than one file of the group, or a failed artifact compilation; `Reason` says which, and a `Failed` row alone makes `Success` false). On `--status` every row is `Kind` `Active` and lists a type this domain still holds. Declarations a reload simply cannot introduce are reported as `Warnings`, not rows. These rows are never counted in `PatchedTotal`, `ActivePatchTotal`, `AddedFieldTotal`, or `ClearedCount` - `ActiveIntroducedTypeTotal` (number): Introduced types this domain holds after this run or on `--status`, counted per type rather than per compiled artifact; always present and `0` when there is none. `--revert-all` cannot unload them, so its Message says how many stay loaded until the next Domain Reload, and that Auto Refresh stays held for them until `uloop compile` -- `Message` (string): Short summary. When a run carries `IntroducedTypes` rows, Message reports them: a run that only introduced or only re-bound types says so instead of reporting the methods, a refused declaration is reported as the failure of the run and points at `IntroducedTypes`, and a run the methods decided ends with `IntroducedTypes=N`. On apply runs that pulled in sibling files, Message follows `PatchedTotal` and `Added` with how many of those Patched and Added rows re-applied the siblings' earlier changes (left out when 0). On apply runs, Message counts the patched rows that carry a `LifecycleNote` in one sentence and the added Unity messages a hot-reload proxy delivers in another, both pointing at `Methods[].LifecycleNote`; forwarded `Added` rows are not in the patched count, and each sentence is left out when its count is 0. On `--status`, Message opens with how many changes are currently active — patched methods, added members, and introduced types together, which is why it can exceed `ActivePatchTotal` — and when any `Active` or `Added` row has `InvocationCount` 0 it also appends how many such rows there are and points at `Methods[].Reason`. `--revert-all` appends how many introduced types stay loaded until the next Domain Reload, and when the hold is still armed for them, that Auto Refresh stays held until `uloop compile` +- `Message` (string): Short summary. When a run carries `IntroducedTypes` rows, Message reports them: a run that only introduced or only re-bound types says so instead of reporting the methods, a refused declaration is reported as the failure of the run and points at `IntroducedTypes`, and a run the methods decided ends with `IntroducedTypes=N`. On apply runs that pulled in sibling files, Message follows `PatchedTotal` and `Added` with how many of those Patched and Added rows re-applied the siblings' earlier changes (left out when 0). On apply runs, Message counts the patched rows that carry a `LifecycleNote` in one sentence and the added Unity messages a hot-reload proxy delivers in another, both pointing at `Methods[].LifecycleNote`; forwarded `Added` rows are not in the patched count, and each sentence is left out when its count is 0. On `--status`, Message opens with how many changes are currently active — patched methods, added members, and introduced types together, which is why it can exceed `ActivePatchTotal` — and when any `Active` or `Added` row has `InvocationCount` 0 it also appends how many such rows there are and points at `Methods[].Reason`. `--revert-all` appends how many introduced types stay loaded until the next Domain Reload, and when the hold is still armed for them, that Auto Refresh stays held until `uloop compile`. When the fallback compile succeeds, the CLI sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, and removes the hold sentence (`AutoRefreshHoldMessage`) from `Message`, because the compile released the hold. - `RecommendedNextAction` (string): Present in three cases. (1) Any method or introduced-type outcome is `Failed`: a partial apply (some methods patched or added, or some types left active) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. (2) Every method of the requested files was `Skipped`, which still answers `Success`: it points first at the fix each Skipped row's `Methods[].Reason` names and offers `uloop compile` as the alternative. (3) `CompileFallback` is `HeldForPlayMode` or `BlockedByPlayModeSetting`, whatever the outcomes: the reason no compile ran is appended after any advice from (1) or (2), and it opens by saying to do any fix a `Reason` names that needs no compile before compiling. Omitted otherwise. - `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts. -- `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction` and ends `Message` with a sentence saying the compile succeeded; a failed one sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`. -- `CompileFallbackNote` (string, present only with `Compile`): why the compile ran and how it ended. +- `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction`, sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, removes the hold sentence from `Message`, and ends `Message` with a sentence saying the compile succeeded; a failed one leaves `Outcome`, `AutoRefreshHeld`, and `Message` as the reload reported them, and sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`. +- `CompileFallbackNote` (string, present only with `Compile`): why the compile ran and how it ended. When it succeeded, `Outcome` is `ReplacedByCompile`, `AutoRefreshHeld` is `false`, and `Message` no longer carries the hold sentence. diff --git a/Assets/Tests/Editor/HotReload/HotReloadApplyOutcomeTests.cs b/Assets/Tests/Editor/HotReload/HotReloadApplyOutcomeTests.cs new file mode 100644 index 0000000000..b2f5a4456b --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadApplyOutcomeTests.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers the one-word answer to "are the requested edits live now?" for an apply run, judged + /// on the requested files with the rows of re-applied sibling files left out. + /// + public sealed class HotReloadApplyOutcomeTests + { + private const string RequestedPath = "Assets/Requested.cs"; + private const string SiblingPath = "Assets/Sibling.cs"; + + /// + /// What: a run with a Failed row answers Failed even though another requested method was + /// patched, the same condition that turns Success false. + /// + [Test] + public void Decide_HasFailure_ReturnsFailed() + { + HotReloadApplyOutcomeKind outcome = HotReloadApplyOutcome.Decide( + new List + { + HotReloadMethodOutcome.Patched("Requested.M", RequestedPath), + HotReloadMethodOutcome.Failed("Requested.N", "reason", RequestedPath) + }, + Array.Empty(), + NoSiblings(), + hasFailure: true); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.Failed)); + } + + /// + /// What: a requested method that is Patched, Added, or AlreadyActive each counts as live, + /// so a run of only that row answers Applied. + /// + [Test] + public void Decide_RequestedRowPatchedAddedOrAlreadyActive_ReturnsApplied() + { + Assert.That( + DecideMethods(NoSiblings(), HotReloadMethodOutcome.Patched("Requested.M", RequestedPath)), + Is.EqualTo(HotReloadApplyOutcomeKind.Applied), + "Patched"); + Assert.That( + DecideMethods(NoSiblings(), HotReloadMethodOutcome.Added("Requested.M", RequestedPath)), + Is.EqualTo(HotReloadApplyOutcomeKind.Applied), + "Added"); + Assert.That( + DecideMethods(NoSiblings(), HotReloadMethodOutcome.AlreadyActive("Requested.M", RequestedPath)), + Is.EqualTo(HotReloadApplyOutcomeKind.Applied), + "AlreadyActive"); + } + + /// + /// What: a Skipped row of a re-applied sibling does not count against the requested file, + /// so a requested Patched row still answers Applied. + /// + [Test] + public void Decide_RequestedPatchedAndSiblingSkipped_ReturnsApplied() + { + HotReloadApplyOutcomeKind outcome = DecideMethods( + WithSibling(), + HotReloadMethodOutcome.Patched("Requested.M", RequestedPath), + HotReloadMethodOutcome.Skipped("Sibling.N", "reason", SiblingPath)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.Applied)); + } + + /// + /// What: requested methods of which some are live and some Skipped answer PartiallyApplied. + /// + [Test] + public void Decide_RequestedPatchedAndSkipped_ReturnsPartiallyApplied() + { + HotReloadApplyOutcomeKind outcome = DecideMethods( + NoSiblings(), + HotReloadMethodOutcome.Patched("Requested.M", RequestedPath), + HotReloadMethodOutcome.Skipped("Requested.N", "reason", RequestedPath)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.PartiallyApplied)); + } + + /// + /// What: requested methods that were all Skipped answer NothingApplied. + /// + [Test] + public void Decide_RequestedAllSkipped_ReturnsNothingApplied() + { + HotReloadApplyOutcomeKind outcome = DecideMethods( + NoSiblings(), + HotReloadMethodOutcome.Skipped("Requested.M", "reason", RequestedPath), + HotReloadMethodOutcome.Skipped("Requested.N", "reason", RequestedPath)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.NothingApplied)); + } + + /// + /// What: an Added row of a re-applied sibling does not make the requested file live, so a + /// requested file whose only row was Skipped answers NothingApplied, not PartiallyApplied. + /// + [Test] + public void Decide_RequestedSkippedAndSiblingAdded_ReturnsNothingApplied() + { + HotReloadApplyOutcomeKind outcome = DecideMethods( + WithSibling(), + HotReloadMethodOutcome.Skipped("Requested.M", "reason", RequestedPath), + HotReloadMethodOutcome.Added("Sibling.N", SiblingPath)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.NothingApplied)); + } + + /// + /// What: a run with no rows at all, as when every method is unchanged or the files hold + /// no method bodies, answers NothingToApply. + /// + [Test] + public void Decide_NoRows_ReturnsNothingToApply() + { + HotReloadApplyOutcomeKind outcome = DecideMethods(NoSiblings()); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.NothingToApply)); + } + + /// + /// What: Stale rows are neither live nor Skipped, so a run of only Stale rows answers + /// NothingToApply. + /// + [Test] + public void Decide_OnlyStaleRows_ReturnsNothingToApply() + { + HotReloadApplyOutcomeKind outcome = DecideMethods( + NoSiblings(), + HotReloadMethodOutcome.Stale("Requested.M", RequestedPath)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.NothingToApply)); + } + + /// + /// What: a Skipped row without a FilePath, such as a file-level row, counts as a requested + /// row even when the run re-applied a sibling, so it answers NothingApplied. + /// + [Test] + public void Decide_SkippedRowWithoutFilePath_ReturnsNothingApplied() + { + HotReloadApplyOutcomeKind outcome = DecideMethods( + WithSibling(), + HotReloadMethodOutcome.Skipped("Requested.M", "reason", string.Empty)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.NothingApplied)); + } + + /// + /// What: a requested type row that is Introduced or AlreadyActive each counts as live, so a + /// run of only that row and no method rows answers Applied. + /// + [Test] + public void Decide_OnlyRequestedIntroducedTypes_ReturnsApplied() + { + Assert.That( + DecideTypes( + NoSiblings(), + HotReloadIntroducedTypeOutcome.Introduced("Example.NewType", "Assembly-CSharp", RequestedPath)), + Is.EqualTo(HotReloadApplyOutcomeKind.Applied), + "Introduced"); + Assert.That( + DecideTypes( + NoSiblings(), + HotReloadIntroducedTypeOutcome.AlreadyActive( + "Example.OtherType", + "Assembly-CSharp", + RequestedPath, + bodyEdited: false)), + Is.EqualTo(HotReloadApplyOutcomeKind.Applied), + "AlreadyActive"); + } + + /// + /// What: an Introduced type row whose owner is a re-applied sibling does not make the + /// requested files live, so a run of only that row answers NothingToApply. + /// + [Test] + public void Decide_OnlySiblingOwnedIntroducedType_ReturnsNothingToApply() + { + HotReloadApplyOutcomeKind outcome = DecideTypes( + WithSibling(), + HotReloadIntroducedTypeOutcome.Introduced("Example.NewType", "Assembly-CSharp", SiblingPath)); + + Assert.That(outcome, Is.EqualTo(HotReloadApplyOutcomeKind.NothingToApply)); + } + + private static HotReloadApplyOutcomeKind DecideMethods( + HotReloadReappliedSiblingFiles siblingFiles, + params HotReloadMethodOutcome[] methods) + { + return HotReloadApplyOutcome.Decide( + methods, + Array.Empty(), + siblingFiles, + hasFailure: false); + } + + private static HotReloadApplyOutcomeKind DecideTypes( + HotReloadReappliedSiblingFiles siblingFiles, + params HotReloadIntroducedTypeOutcome[] introducedTypes) + { + return HotReloadApplyOutcome.Decide( + Array.Empty(), + introducedTypes, + siblingFiles, + hasFailure: false); + } + + private static HotReloadReappliedSiblingFiles NoSiblings() + { + return new HotReloadReappliedSiblingFiles(Array.Empty(), path => path); + } + + private static HotReloadReappliedSiblingFiles WithSibling() + { + return new HotReloadReappliedSiblingFiles(new[] { SiblingPath }, path => path); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadApplyOutcomeTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadApplyOutcomeTests.cs.meta new file mode 100644 index 0000000000..94cb53c4cc --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadApplyOutcomeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 086bf716c90a74792b62edd56d5614b2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs index 36105404af..0a3da50857 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeResponseTests.cs @@ -625,6 +625,14 @@ public async Task Build_TypePreparationReportsATypeFailure_FailsTheRunWithATypeR response.Success, Is.False, "A refused type declaration must fail the run."); + Assert.That( + response.FailedTotal, + Is.EqualTo(0), + "No method row failed, so only the refused declaration can make the Outcome Failed."); + Assert.That( + response.Outcome, + Is.EqualTo("Failed"), + "A refused declaration alone must answer Failed."); Assert.That(response.IntroducedTypes.Count, Is.EqualTo(1)); Assert.That(response.IntroducedTypes[0].Kind, Is.EqualTo("Failed")); Assert.That(response.IntroducedTypes[0].Reason, Is.EqualTo(InjectedTypeFailureReason)); @@ -682,6 +690,10 @@ public async Task Build_TypeFailureBesideOtherFindings_KeepsTheReusesAndNotices( HotReloadResponse response = await RunAgainstTheHostAsync(); Assert.That(response.Success, Is.False, "A refused declaration must fail the run."); + Assert.That( + response.Outcome, + Is.EqualTo("Failed"), + "A refused declaration must answer Failed even beside a bound declaration, which counts as live."); Assert.That( CountTypeRows(response, "AlreadyActive"), Is.EqualTo(1), @@ -712,6 +724,7 @@ public async Task Build_PreparationWorkerFails_ReportsTheFailureWithoutAnyTypeRo HotReloadResponse response = await RunAgainstTheHostAsync(); Assert.That(response.Success, Is.False, "A failed preparation must fail the run."); + Assert.That(response.Outcome, Is.EqualTo("Failed"), "A failed preparation must answer Failed."); Assert.That( response.IntroducedTypes.Count, Is.EqualTo(0), diff --git a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs index 682cec1c40..c71cdc053d 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs @@ -763,6 +763,71 @@ public void BuildApplyResponse_WhenSuccess_LeavesRecommendedNextActionEmpty() Assert.That(response.ShouldSerializeRecommendedNextAction(), Is.False); } + /// + /// What: a run whose requested method was Patched reports Outcome Applied, zero for every + /// other per-kind total, and no hold sentence because it did not newly arm the hold. + /// + [Test] + public void BuildApplyResponse_WhenMethodsPatched_ReportsAppliedOutcomeAndTotals() + { + HotReloadOrchestratorResult result = new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Patched("Type.Method", "Assets/A.cs") + }, + new List(), + patchedTotal: 1, + activePatchTotal: 1); + + HotReloadResponse response = HotReloadTool.BuildApplyResponse(result); + + Assert.That(response.Outcome, Is.EqualTo("Applied")); + Assert.That(response.PatchedTotal, Is.EqualTo(1)); + Assert.That(response.SkippedTotal, Is.EqualTo(0)); + Assert.That(response.AddedTotal, Is.EqualTo(0)); + Assert.That(response.FailedTotal, Is.EqualTo(0)); + Assert.That(response.AlreadyActiveTotal, Is.EqualTo(0)); + Assert.That(response.StaleTotal, Is.EqualTo(0)); + Assert.That(response.AutoRefreshHoldMessage, Is.Empty); + } + + /// + /// What: each per-kind total counts only the rows of its own Kind, and AlreadyActive rows + /// beside a Skipped row answer PartiallyApplied. Every kind has a different row count, so + /// dropping or swapping any two totals fails. + /// + [Test] + public void BuildApplyResponse_WithEveryMethodKind_CountsEachKindApart() + { + HotReloadOrchestratorResult result = new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Skipped("Type.Skipped1()", "reason", "Assets/A.cs"), + HotReloadMethodOutcome.Added("Type.Added1()", "Assets/A.cs"), + HotReloadMethodOutcome.Added("Type.Added2()", "Assets/A.cs"), + HotReloadMethodOutcome.AlreadyActive("Type.Active1()", "Assets/A.cs"), + HotReloadMethodOutcome.AlreadyActive("Type.Active2()", "Assets/A.cs"), + HotReloadMethodOutcome.AlreadyActive("Type.Active3()", "Assets/A.cs"), + HotReloadMethodOutcome.Stale("Type.Removed1()", "Assets/A.cs"), + HotReloadMethodOutcome.Stale("Type.Removed2()", "Assets/A.cs"), + HotReloadMethodOutcome.Stale("Type.Removed3()", "Assets/A.cs"), + HotReloadMethodOutcome.Stale("Type.Removed4()", "Assets/A.cs") + }, + new List(), + patchedTotal: 0, + activePatchTotal: 9); + + HotReloadResponse response = HotReloadTool.BuildApplyResponse(result); + + Assert.That(response.SkippedTotal, Is.EqualTo(1), "SkippedTotal"); + Assert.That(response.AddedTotal, Is.EqualTo(2), "AddedTotal"); + Assert.That(response.AlreadyActiveTotal, Is.EqualTo(3), "AlreadyActiveTotal"); + Assert.That(response.StaleTotal, Is.EqualTo(4), "StaleTotal"); + Assert.That(response.PatchedTotal, Is.EqualTo(0), "PatchedTotal"); + Assert.That(response.FailedTotal, Is.EqualTo(0), "FailedTotal"); + Assert.That(response.Outcome, Is.EqualTo("PartiallyApplied")); + } + /// /// What: BuildApplyResponse does not emit pause-point warnings when no markers /// were retargeted or suppressed, even if PatchedTotal > 0. @@ -1011,6 +1076,10 @@ public void BuildApplyResponse_WhenHoldNewlyArmed_AppendsFixedHoldSentence() Assert.That( response.Message, Does.EndWith(HotReloadAutoRefreshHoldConstants.NewlyArmedMessageSuffix)); + Assert.That( + response.AutoRefreshHoldMessage, + Is.EqualTo(HotReloadAutoRefreshHoldConstants.NewlyArmedMessageSuffix)); + Assert.That(response.Message, Does.EndWith(" " + response.AutoRefreshHoldMessage)); } /// @@ -1316,6 +1385,7 @@ public void BuildApplyResponse_EmptyMethodsWithUnchangedTotal_YieldsAllUnchanged response.Message, Is.EqualTo("All 8 methods are unchanged since the last compile; nothing to patch.")); Assert.That(response.UnchangedTotal, Is.EqualTo(8)); + Assert.That(response.Outcome, Is.EqualTo("NothingToApply")); } /// @@ -1621,6 +1691,29 @@ public void BuildApplyResponse_WithFailedOutcome_KeepsExistingFailureMessage() Is.EqualTo("Hot reload finished with one or more Failed method outcomes. See Methods.")); } + /// + /// What: a run with a Failed method reports Outcome Failed and counts the row in + /// FailedTotal, alongside Success false. + /// + [Test] + public void BuildApplyResponse_WhenAMethodFailed_ReportsFailedOutcomeAndFailedTotal() + { + HotReloadOrchestratorResult result = new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Failed("T.M", "reason", "file.cs") + }, + new List(), + patchedTotal: 0, + activePatchTotal: 0); + + HotReloadResponse response = HotReloadTool.BuildApplyResponse(result); + + Assert.That(response.Outcome, Is.EqualTo("Failed")); + Assert.That(response.FailedTotal, Is.EqualTo(1)); + Assert.That(response.Success, Is.False); + } + /// /// What: a file-level failure reaches Message even when cascading method failures are /// listed before it, so the cause is read before the symptoms. @@ -2579,6 +2672,30 @@ public void BuildApplyResponse_RequestedFileAllSkippedWithSiblingAdded_SaysNothi Is.EqualTo(HotReloadConstants.RequestedFilesAllSkippedRecommendedNextAction)); } + /// + /// What: the same run reports Outcome NothingApplied, because the sibling's Added row does + /// not make the requested file live, while the totals count every row, the sibling's too. + /// + [Test] + public void BuildApplyResponse_WhenRequestedFileAllSkippedWithSiblingAdded_ReportsNothingAppliedAndCountsEveryRow() + { + HotReloadResponse response = HotReloadTool.BuildApplyResponse( + new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Skipped("T.M", "reason", "Assets/Requested.cs"), + HotReloadMethodOutcome.Added("S.N", "Assets/Sibling.cs") + }, + new List(), + patchedTotal: 0, + activePatchTotal: 0, + reappliedSiblingPaths: new[] { "Assets/Sibling.cs" })); + + Assert.That(response.Outcome, Is.EqualTo("NothingApplied")); + Assert.That(response.AddedTotal, Is.EqualTo(1)); + Assert.That(response.SkippedTotal, Is.EqualTo(1)); + } + /// /// What: the same all-Skipped run without a sibling re-apply omits the sibling clause. /// @@ -2909,6 +3026,18 @@ public async Task ExecuteAsync_Status_WritesNotNeededCompileFallback() Assert.That(serialized["CompileFallback"].ToString(), Is.EqualTo("NotNeeded")); } + /// + /// What: --status omits Outcome, which answers only for an apply run. + /// + [Test] + public async Task ExecuteAsync_Status_OmitsOutcome() + { + HotReloadResponse response = await ExecuteStatusAsync(CancellationToken.None); + + JObject serialized = JObject.FromObject(response); + Assert.That(serialized.ContainsKey("Outcome"), Is.False); + } + /// /// What: a refused parameter combination writes the field too, since no run happened that /// could have left an edit unapplied. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs new file mode 100644 index 0000000000..174bbeb3cf --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs @@ -0,0 +1,141 @@ +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One-word answer to "are the requested edits live now?" for an apply run. ReplacedByCompile + /// is written by the CLI after a fallback compile succeeded and is listed here so the enum + /// names every value a caller can see. + /// + internal enum HotReloadApplyOutcomeKind + { + // Something of the requested files is live, and none of their methods was Skipped. + Applied = 0, + + // Something of the requested files is live, and some of their methods were Skipped. + PartiallyApplied = 1, + + // Nothing of the requested files is live, and some of their methods were Skipped. + NothingApplied = 2, + + // Nothing of the requested files is live, and none was Skipped: every method is + // unchanged, or the files hold no method bodies. + NothingToApply = 3, + + // A Failed row, siblings included: the same condition that turns Success false. + Failed = 4, + + // Written by the CLI only. + ReplacedByCompile = 5 + } + + /// + /// Decides the of an apply run. + /// + /// + /// Why the sibling rows are left out: a sibling file is re-applied on the run's own initiative, + /// so its rows say nothing about the edit the caller asked about (the same split the message + /// uses through HotReloadRequestedFileOutcomeSummary). A Failed row counts wherever it is, + /// because Success already turns false for it. + /// + internal static class HotReloadApplyOutcome + { + internal static HotReloadApplyOutcomeKind Decide( + IReadOnlyList methods, + IReadOnlyList introducedTypes, + HotReloadReappliedSiblingFiles siblingFiles, + bool hasFailure) + { + Debug.Assert(methods != null, "methods must not be null."); + Debug.Assert(introducedTypes != null, "introducedTypes must not be null."); + Debug.Assert(siblingFiles != null, "siblingFiles must not be null."); + if (hasFailure) + { + return HotReloadApplyOutcomeKind.Failed; + } + + int liveCount = CountRequestedLiveMethods(methods, siblingFiles) + + CountRequestedLiveTypes(introducedTypes, siblingFiles); + bool anySkipped = HasRequestedSkippedMethod(methods, siblingFiles); + if (liveCount == 0) + { + return anySkipped + ? HotReloadApplyOutcomeKind.NothingApplied + : HotReloadApplyOutcomeKind.NothingToApply; + } + + return anySkipped + ? HotReloadApplyOutcomeKind.PartiallyApplied + : HotReloadApplyOutcomeKind.Applied; + } + + // Why AlreadyActive counts: the earlier run's patch is still running, so the answer to + // "is it live now?" is yes. Why Stale does not: it is what is left of a method the source + // no longer declares, not the outcome of an edit, and StaleTotal reports it apart. + private static int CountRequestedLiveMethods( + IReadOnlyList methods, + HotReloadReappliedSiblingFiles siblingFiles) + { + int count = 0; + for (int index = 0; index < methods.Count; index++) + { + HotReloadMethodOutcome method = methods[index]; + if (siblingFiles.Contains(method.FilePath)) + { + continue; + } + + if (method.Kind == HotReloadMethodOutcomeKind.Patched + || method.Kind == HotReloadMethodOutcomeKind.Added + || method.Kind == HotReloadMethodOutcomeKind.AlreadyActive) + { + count++; + } + } + + return count; + } + + private static int CountRequestedLiveTypes( + IReadOnlyList introducedTypes, + HotReloadReappliedSiblingFiles siblingFiles) + { + int count = 0; + for (int index = 0; index < introducedTypes.Count; index++) + { + HotReloadIntroducedTypeOutcome type = introducedTypes[index]; + if (siblingFiles.Contains(type.OwnerProjectRelativePath)) + { + continue; + } + + if (type.Kind == HotReloadIntroducedTypeOutcomeKind.Introduced + || type.Kind == HotReloadIntroducedTypeOutcomeKind.AlreadyActive) + { + count++; + } + } + + return count; + } + + private static bool HasRequestedSkippedMethod( + IReadOnlyList methods, + HotReloadReappliedSiblingFiles siblingFiles) + { + for (int index = 0; index < methods.Count; index++) + { + HotReloadMethodOutcome method = methods[index]; + if (method.Kind == HotReloadMethodOutcomeKind.Skipped + && !siblingFiles.Contains(method.FilePath)) + { + return true; + } + } + + return false; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs.meta new file mode 100644 index 0000000000..89743c2738 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1b461233fe5a1455bbd6df2177c87ac5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs index 1fa8c126db..eb9f6b46cb 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs @@ -86,9 +86,20 @@ public static HotReloadResponse Build( HotReloadReappliedSiblingFiles.ForActivePatches(result, toProjectRelativeScriptPath)), allRequestedSkipped, reappliedSiblingCount); + HotReloadOutcomeTally tally = HotReloadOutcomeAggregation.CountMethodOutcomeKinds(result.Methods); return new HotReloadResponse { Success = !hasFailure, + Outcome = HotReloadApplyOutcome.Decide( + result.Methods, + result.IntroducedTypes, + reappliedSiblingFiles, + hasFailure).ToString(), + SkippedTotal = tally.SkippedCount, + AddedTotal = tally.AddedCount, + FailedTotal = tally.FailedCount, + AlreadyActiveTotal = tally.AlreadyActiveCount, + StaleTotal = tally.StaleCount, Methods = methods, Warnings = warnings.ToList(), IntroducedTypes = HotReloadIntroducedTypeResponseSection.BuildRows(result.IntroducedTypes), @@ -104,6 +115,9 @@ public static HotReloadResponse Build( Message = HotReloadAutoRefreshHoldResponseEnricher.AppendNewlyArmedMessage( message, result.AutoRefreshHoldNewlyArmed), + AutoRefreshHoldMessage = result.AutoRefreshHoldNewlyArmed + ? HotReloadAutoRefreshHoldConstants.NewlyArmedMessageSuffix + : string.Empty, RecommendedNextAction = HotReloadRecommendedNextAction.Resolve( hasFailure, result.PatchedTotal, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs index 263ef69a5e..ac56ec2d50 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs @@ -162,6 +162,45 @@ public class HotReloadResponse : UnityCliLoopToolResponse /// public bool AutoRefreshHeld { get; set; } + /// + /// The Auto Refresh hold sentence this run appended to Message, so a caller (the CLI after + /// a fallback compile) can remove exactly that sentence. Omitted when this run did not arm + /// the hold. + /// + public string AutoRefreshHoldMessage { get; set; } = string.Empty; + + /// + /// Applied, PartiallyApplied, NothingApplied, NothingToApply, or Failed on apply runs + /// (ReplacedByCompile once the CLI's fallback compile succeeded): whether the edits of the + /// requested files are live now. Omitted on --status, --revert-all and validation failures. + /// + public string Outcome { get; set; } = string.Empty; + + /// + /// Methods rows with Kind Skipped, sibling rows included like the other totals. + /// + public int SkippedTotal { get; set; } + + /// + /// Methods rows with Kind Added. + /// + public int AddedTotal { get; set; } + + /// + /// Methods rows with Kind Failed. + /// + public int FailedTotal { get; set; } + + /// + /// Methods rows with Kind AlreadyActive. + /// + public int AlreadyActiveTotal { get; set; } + + /// + /// Methods rows with Kind Stale. + /// + public int StaleTotal { get; set; } + // Why omit empty: success and validation-only payloads must not grow a next-action // field that PausePoint-style responses leave blank on the wire. public bool ShouldSerializeRecommendedNextAction() @@ -183,6 +222,16 @@ public bool ShouldSerializeErrorCode() return !string.IsNullOrEmpty(ErrorCode); } + public bool ShouldSerializeOutcome() + { + return !string.IsNullOrEmpty(Outcome); + } + + public bool ShouldSerializeAutoRefreshHoldMessage() + { + return !string.IsNullOrEmpty(AutoRefreshHoldMessage); + } + public bool ShouldSerializeNextActions() { return NextActions != null && NextActions.Length > 0; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md index b40dd86a84..c7f418f9a9 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md @@ -1,5 +1,18 @@ # Hot Reload Output Fields +## Is my edit live? Read `Outcome` first + +- `Applied` — the edits of the files you asked about are live (`Patched`, `Added`, or `AlreadyActive` rows, or introduced types), and none was `Skipped`. Nothing else to do. +- `PartiallyApplied` — some of them are live, some were `Skipped`; read the `Methods[]` rows with `Kind: "Skipped"`. +- `NothingApplied` — none of them is live, and some were `Skipped`. `Warnings` (or `Methods[].Reason`) says why. +- `NothingToApply` — nothing changed against the compiled code (see `UnchangedTotal`), the files hold no method bodies, or their only rows are `Stale`. +- `Failed` — at least one `Failed` row, so `Success` is `false` — unless a fallback compile then succeeded, which turns the whole answer into `ReplacedByCompile`. +- `ReplacedByCompile` — a fallback compile ran in this same command and succeeded (`Compile` holds its response): every edit is compiled in, and `Success` is the compile's. The compile reloaded the domain, so none of this run's patches survive; the totals and `Methods[]` describe the reload that ran before the compile — read `--status` for the state after it. + +`Outcome` is written on apply runs only and judges the files you asked about; rows of a sibling file the run re-applied on its own (`ReappliedFromSibling: true`) do not change it, while `CompileFallback` may still be `Requested` for a retried sibling row. The totals beside it (`PatchedTotal`, `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal`) count every `Methods[]` row by `Kind`, siblings included. + +## Fields + Returns JSON with: - `Success` (boolean): `false` on parameter validation failure or when any method outcome is `Failed`, or when any `IntroducedTypes` row is `Failed`. `Skipped` outcomes alone never force `false` @@ -7,12 +20,15 @@ Returns JSON with: - `NextActions` (array, optional): Ordered recovery steps, present only with `ErrorCode` on a parameter validation failure. Omitted from every other response, including successful apply, plain `--status`, and `--revert-all` runs. - `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` - `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped`: that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. +- `Outcome` (string, apply runs only): `Applied`, `PartiallyApplied`, `NothingApplied`, `NothingToApply`, `Failed`, or `ReplacedByCompile` — whether the edits of the files you asked about are live; see "Is my edit live?" above. Omitted on `--status`, `--revert-all`, and validation failures. - `PatchedTotal` (number): Methods patched in this run +- `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal` (number): `Methods[]` rows of this run whose `Kind` is `Skipped`, `Added`, `Failed`, `AlreadyActive`, or `Stale`, sibling rows included, as in `PatchedTotal`. `0` on `--status`, `--revert-all`, and validation failures. - `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. It is always empty on `--status` and `--revert-all` runs; the live list is the `Methods` rows with `Kind` `AddedField`, counted by `AddedFieldTotal`. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `AddedConsts` (array): source-level names ("Type.const") of consts this reload added. They are folded into edited bodies as literals, so they are not listed in AddedFields and do not emit the added-field lifetime warning. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `ActivePatchTotal` (number): Active changes after this run — patched methods plus added members. Introduced types are not counted here; `ActiveIntroducedTypeTotal` reports those. `--revert-all` clears the patched methods and added members counted here and reports their combined count in `ClearedCount`; introduced types stay loaded until the next Domain Reload and remain in `ActiveIntroducedTypeTotal`. Does not include `AddedField` rows. Validation failures (`HOT_RELOAD_NO_CHANGED_FILES` and the other `ErrorCode` cases) also report the live ledger value, not the default 0. - `AutoRefreshHeld` (boolean): True while Auto Refresh is held because at least one hot-reload change is still active. The first apply that arms the hold appends a Message sentence telling the caller to run `uloop compile` to release it, and that `--revert-all` releases it only when no introduced type remains — a revert cannot unload the assembly carrying an introduced type. A release during Play adds a Warning that pending script edits import on the next focus return or `uloop compile`. If the post-release Refresh is skipped because an open dirty scene also changed on disk, Warnings include the sentence telling the caller to resolve that scene and then run `uloop compile`. `--status` and `--revert-all` report the live value. +- `AutoRefreshHoldMessage` (string, optional): The hold sentence this run appended to `Message` when it armed the hold; omitted when the run did not arm it. After a successful fallback compile, the CLI removes that sentence from `Message` and keeps this field as the record of what it removed. - `AddedFieldTotal` (number): Live added-field ledger rows after this run or on `--status`, added field-like events included. Those rows appear as `Kind` `AddedField` on `--status` only; they are not counted in `ActivePatchTotal` - `DroppedByPlayModeEntryCount` (number): Remaining patched-method, added-member, and introduced-type identities discarded by the Play-entry domain reload that have not been recovered by a later apply (`Patched` / `Added` methods, `Introduced` / `AlreadyActive` types — a recovered type also recovers the patches and added members inside it), `--revert-all`, or a successful compile. Omitted when the count is 0. Re-apply `uloop hot-reload`, or edit the files and run `uloop compile` - `RestoredWiredValueCount` (number, `--status` only): Values written through the added-field wiring call that the last scene reload (entering or leaving Play Mode with domain reload disabled) gave back to the rebuilt objects. It also counts the values `--status` itself gave back by reading them for a host that is back at its place. Omitted when 0. See [added-field-wiring.md](added-field-wiring.md) @@ -20,8 +36,8 @@ Returns JSON with: - `ClearedCount` (number): Patches removed by `--revert-all`, or stale patches reverted because their source matched the compiled baseline again - `IntroducedTypes` (array): Per-type `{ Kind, TypeName, AssemblyName, FilePath, Reason }` rows for the type declarations a reload met, always present and empty when there are none. On apply runs `Kind` is `Introduced` (this reload compiled the declaration into a retained assembly and it is now loaded), `AlreadyActive` (the declaration is bound from an assembly an earlier reload retained, so this reload introduced nothing for it), or `Failed` (the declaration was refused — a redefinition of a type already active, the same type declared in more than one file of the group, or a failed artifact compilation; `Reason` says which, and a `Failed` row alone makes `Success` false). On `--status` every row is `Kind` `Active` and lists a type this domain still holds. Declarations a reload simply cannot introduce are reported as `Warnings`, not rows. These rows are never counted in `PatchedTotal`, `ActivePatchTotal`, `AddedFieldTotal`, or `ClearedCount` - `ActiveIntroducedTypeTotal` (number): Introduced types this domain holds after this run or on `--status`, counted per type rather than per compiled artifact; always present and `0` when there is none. `--revert-all` cannot unload them, so its Message says how many stay loaded until the next Domain Reload, and that Auto Refresh stays held for them until `uloop compile` -- `Message` (string): Short summary. When a run carries `IntroducedTypes` rows, Message reports them: a run that only introduced or only re-bound types says so instead of reporting the methods, a refused declaration is reported as the failure of the run and points at `IntroducedTypes`, and a run the methods decided ends with `IntroducedTypes=N`. On apply runs that pulled in sibling files, Message follows `PatchedTotal` and `Added` with how many of those Patched and Added rows re-applied the siblings' earlier changes (left out when 0). On apply runs, Message counts the patched rows that carry a `LifecycleNote` in one sentence and the added Unity messages a hot-reload proxy delivers in another, both pointing at `Methods[].LifecycleNote`; forwarded `Added` rows are not in the patched count, and each sentence is left out when its count is 0. On `--status`, Message opens with how many changes are currently active — patched methods, added members, and introduced types together, which is why it can exceed `ActivePatchTotal` — and when any `Active` or `Added` row has `InvocationCount` 0 it also appends how many such rows there are and points at `Methods[].Reason`. `--revert-all` appends how many introduced types stay loaded until the next Domain Reload, and when the hold is still armed for them, that Auto Refresh stays held until `uloop compile` +- `Message` (string): Short summary. When a run carries `IntroducedTypes` rows, Message reports them: a run that only introduced or only re-bound types says so instead of reporting the methods, a refused declaration is reported as the failure of the run and points at `IntroducedTypes`, and a run the methods decided ends with `IntroducedTypes=N`. On apply runs that pulled in sibling files, Message follows `PatchedTotal` and `Added` with how many of those Patched and Added rows re-applied the siblings' earlier changes (left out when 0). On apply runs, Message counts the patched rows that carry a `LifecycleNote` in one sentence and the added Unity messages a hot-reload proxy delivers in another, both pointing at `Methods[].LifecycleNote`; forwarded `Added` rows are not in the patched count, and each sentence is left out when its count is 0. On `--status`, Message opens with how many changes are currently active — patched methods, added members, and introduced types together, which is why it can exceed `ActivePatchTotal` — and when any `Active` or `Added` row has `InvocationCount` 0 it also appends how many such rows there are and points at `Methods[].Reason`. `--revert-all` appends how many introduced types stay loaded until the next Domain Reload, and when the hold is still armed for them, that Auto Refresh stays held until `uloop compile`. When the fallback compile succeeds, the CLI sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, and removes the hold sentence (`AutoRefreshHoldMessage`) from `Message`, because the compile released the hold. - `RecommendedNextAction` (string): Present in three cases. (1) Any method or introduced-type outcome is `Failed`: a partial apply (some methods patched or added, or some types left active) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. (2) Every method of the requested files was `Skipped`, which still answers `Success`: it points first at the fix each Skipped row's `Methods[].Reason` names and offers `uloop compile` as the alternative. (3) `CompileFallback` is `HeldForPlayMode` or `BlockedByPlayModeSetting`, whatever the outcomes: the reason no compile ran is appended after any advice from (1) or (2), and it opens by saying to do any fix a `Reason` names that needs no compile before compiling. Omitted otherwise. - `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts. -- `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction` and ends `Message` with a sentence saying the compile succeeded; a failed one sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`. -- `CompileFallbackNote` (string, present only with `Compile`): why the compile ran and how it ended. +- `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction`, sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, removes the hold sentence from `Message`, and ends `Message` with a sentence saying the compile succeeded; a failed one leaves `Outcome`, `AutoRefreshHeld`, and `Message` as the reload reported them, and sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`. +- `CompileFallbackNote` (string, present only with `Compile`): why the compile ran and how it ended. When it succeeded, `Outcome` is `ReplacedByCompile`, `AutoRefreshHeld` is `false`, and `Message` no longer carries the hold sentence. diff --git a/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback.go b/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback.go index efd80f35af..b40b1fb7ca 100644 --- a/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback.go +++ b/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback.go @@ -24,6 +24,15 @@ const ( hotReloadRecommendedNextActionField = "RecommendedNextAction" hotReloadMessageField = "Message" hotReloadWarningsField = "Warnings" + hotReloadOutcomeField = "Outcome" + hotReloadAutoRefreshHeldField = "AutoRefreshHeld" + hotReloadAutoRefreshHoldMessageField = "AutoRefreshHoldMessage" +) + +// Raw JSON values, because the response fields are edited as encoded JSON. +const ( + hotReloadOutcomeReplacedByCompileJSON = `"ReplacedByCompile"` + hotReloadAutoRefreshReleasedJSON = "false" ) const ( @@ -152,23 +161,58 @@ func injectHotReloadCompileFallback(raw json.RawMessage, compileRaw json.RawMess } // The reload's own next action says to run 'uloop compile', which this command just did. delete(fields, hotReloadRecommendedNextActionField) + // Before the compile sentence is appended, so the hold sentence is still at the end of Message. + if err := settleHotReloadStateAfterCompile(fields); err != nil { + return nil, err + } if err := appendHotReloadCompileSucceededMessage(fields); err != nil { return nil, err } return json.Marshal(fields) } +// A successful compile reloaded the domain: every edit is compiled in, the patches are gone, and +// the Auto Refresh hold is released, so the reload's own Outcome and hold sentence are stale. +// Outcome is written even for an older package that sent none, so every merged response says the +// compile replaced the reload. AutoRefreshHeld is only corrected, never added: a response without +// it comes from a package that never reported the hold. +func settleHotReloadStateAfterCompile(fields map[string]json.RawMessage) error { + fields[hotReloadOutcomeField] = json.RawMessage(hotReloadOutcomeReplacedByCompileJSON) + if _, present := fields[hotReloadAutoRefreshHeldField]; present { + fields[hotReloadAutoRefreshHeldField] = json.RawMessage(hotReloadAutoRefreshReleasedJSON) + } + return removeHotReloadHoldSentence(fields) +} + +// Removes " " from the end of Message, where the Editor appended it, so the +// CLI never needs its own copy of the sentence. Anything else is left alone: an older package sends +// no AutoRefreshHoldMessage, and a run that did not arm the hold omits it. +func removeHotReloadHoldSentence(fields map[string]json.RawMessage) error { + holdSentence, isString, err := readHotReloadStringField(fields, hotReloadAutoRefreshHoldMessageField) + if err != nil || !isString { + return err + } + message, isString, err := readHotReloadStringField(fields, hotReloadMessageField) + if err != nil || !isString { + return err + } + withoutHold, found := strings.CutSuffix(message, " "+holdSentence) + if !found { + return nil + } + trimmed, err := json.Marshal(withoutHold) + if err != nil { + return err + } + fields[hotReloadMessageField] = trimmed + return nil +} + // A Message that is missing or not a string is left alone: only an older or unexpected package // sends one, and inventing a Message would claim a reload summary the Editor never wrote. -// Why the first byte is checked: decoding JSON null into a string succeeds and leaves it empty, -// so the decode alone would turn a null Message into one that holds only the suffix. func appendHotReloadCompileSucceededMessage(fields map[string]json.RawMessage) error { - raw := fields[hotReloadMessageField] - if len(raw) == 0 || raw[0] != '"' { - return nil - } - message := "" - if err := json.Unmarshal(raw, &message); err != nil { + message, isString, err := readHotReloadStringField(fields, hotReloadMessageField) + if err != nil || !isString { return err } appended, err := json.Marshal(message + hotReloadCompileFallbackSucceededMessageSuffix) @@ -179,6 +223,22 @@ func appendHotReloadCompileSucceededMessage(fields map[string]json.RawMessage) e return nil } +// readHotReloadStringField decodes a response field that holds a JSON string, and reports false for +// a field that is missing or holds anything else. +// Why the first byte is checked: decoding JSON null into a string succeeds and leaves it empty, so +// the decode alone would treat a null field as an empty string and write a string back over it. +func readHotReloadStringField(fields map[string]json.RawMessage, name string) (string, bool, error) { + raw := fields[name] + if len(raw) == 0 || raw[0] != '"' { + return "", false, nil + } + value := "" + if err := json.Unmarshal(raw, &value); err != nil { + return "", false, err + } + return value, true, nil +} + // hotReloadUnappliedPointer names the response field that explains the unapplied edits, so the // note never sends the reader to an empty Warnings array. func hotReloadUnappliedPointer(fields map[string]json.RawMessage) string { diff --git a/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback_test.go b/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback_test.go index f0eab31bed..1683cf8d99 100644 --- a/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback_test.go +++ b/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback_test.go @@ -370,3 +370,140 @@ func TestHotReloadCompileFallbackRejectsNonObjectResponses(t *testing.T) { t.Fatalf("expected the non-object error, got %v", err) } } + +// Verifies a successful fallback compile turns the reload's Outcome into ReplacedByCompile, reports +// the Auto Refresh hold released, and removes exactly the hold sentence the reload appended to +// Message, with its leading space, while AutoRefreshHoldMessage stays as the record of what was +// removed. +func TestInjectHotReloadCompileFallback_CompileSucceeded_SettlesFinalState(t *testing.T) { + cases := []struct { + name string + reload string + wantMessage string + }{ + { + name: "nothing applied", + reload: `{"Success":true,"Outcome":"NothingApplied","CompileFallback":"Requested","AutoRefreshHeld":true,"AutoRefreshHoldMessage":"HOLD SENTENCE","Message":"Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2. HOLD SENTENCE"}`, + wantMessage: "Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2.", + }, + { + name: "partially applied", + reload: `{"Success":true,"Outcome":"PartiallyApplied","CompileFallback":"Requested","AutoRefreshHeld":true,"AutoRefreshHoldMessage":"HOLD SENTENCE","Message":"Hot reload applied. PatchedTotal=1, ActivePatchTotal=1. Skipped: 1. HOLD SENTENCE"}`, + wantMessage: "Hot reload applied. PatchedTotal=1, ActivePatchTotal=1. Skipped: 1.", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + merged, err := injectHotReloadCompileFallback(json.RawMessage(tc.reload), json.RawMessage(`{"Success":true}`)) + if err != nil { + t.Fatalf("inject failed: %v", err) + } + fields := decodeSingleJSONObject(t, string(merged)) + assertJSONStringField(t, fields, "Outcome", "ReplacedByCompile") + if string(fields["AutoRefreshHeld"]) != "false" { + t.Fatalf("AutoRefreshHeld must be false once the compile released the hold: %s", merged) + } + assertJSONStringField(t, fields, "Message", tc.wantMessage+hotReloadCompileFallbackSucceededMessageSuffix) + assertJSONStringField(t, fields, "AutoRefreshHoldMessage", "HOLD SENTENCE") + }) + } +} + +// Verifies a failed fallback compile leaves Outcome, AutoRefreshHeld, AutoRefreshHoldMessage and +// Message as the reload wrote them. +func TestInjectHotReloadCompileFallback_CompileFailed_KeepsReloadState(t *testing.T) { + merged, err := injectHotReloadCompileFallback( + json.RawMessage(`{"Success":true,"Outcome":"NothingApplied","CompileFallback":"Requested","AutoRefreshHeld":true,"AutoRefreshHoldMessage":"HOLD SENTENCE","Message":"Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2. HOLD SENTENCE"}`), + json.RawMessage(`{"Success":false,"Errors":[{"Message":"CS0103"}]}`)) + if err != nil { + t.Fatalf("inject failed: %v", err) + } + fields := decodeSingleJSONObject(t, string(merged)) + assertJSONStringField(t, fields, "Outcome", "NothingApplied") + if string(fields["AutoRefreshHeld"]) != "true" { + t.Fatalf("AutoRefreshHeld must stay as the reload reported it: %s", merged) + } + assertJSONStringField(t, fields, "Message", "Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2. HOLD SENTENCE") + assertJSONStringField(t, fields, "AutoRefreshHoldMessage", "HOLD SENTENCE") +} + +// Verifies a successful fallback compile over an older package's response, which has neither +// Outcome nor AutoRefreshHoldMessage, still adds Outcome ReplacedByCompile, appends the compile +// sentence without removing anything from Message, and turns AutoRefreshHeld false only when the +// response has the field. +func TestInjectHotReloadCompileFallback_OlderPackageWithoutOutcome_StillSettles(t *testing.T) { + cases := []struct { + name string + reload string + wantMessage string + // Raw JSON of AutoRefreshHeld after the merge; empty when the field must stay absent. + wantAutoRefreshHeld string + }{ + { + name: "with AutoRefreshHeld", + reload: `{"Success":true,"CompileFallback":"Requested","AutoRefreshHeld":true,"Message":"Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2. HOLD SENTENCE"}`, + wantMessage: "Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2. HOLD SENTENCE", + wantAutoRefreshHeld: "false", + }, + { + name: "without AutoRefreshHeld", + reload: `{"Success":true,"CompileFallback":"Requested","Message":"Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2."}`, + wantMessage: "Hot reload applied. PatchedTotal=0, ActivePatchTotal=0. Skipped: 2.", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + merged, err := injectHotReloadCompileFallback(json.RawMessage(tc.reload), json.RawMessage(`{"Success":true}`)) + if err != nil { + t.Fatalf("inject failed: %v", err) + } + fields := decodeSingleJSONObject(t, string(merged)) + assertJSONStringField(t, fields, "Outcome", "ReplacedByCompile") + assertJSONStringField(t, fields, "Message", tc.wantMessage+hotReloadCompileFallbackSucceededMessageSuffix) + if string(fields["AutoRefreshHeld"]) != tc.wantAutoRefreshHeld { + t.Fatalf("AutoRefreshHeld mismatch: want %q, got %q", tc.wantAutoRefreshHeld, fields["AutoRefreshHeld"]) + } + }) + } +} + +// Verifies a fallback compile that succeeded after a reload with Failed rows replaces the Failed +// Outcome with ReplacedByCompile, together with Success. +func TestInjectHotReloadCompileFallback_CompileSucceededAfterFailedReload_ReplacesFailedOutcome(t *testing.T) { + merged, err := injectHotReloadCompileFallback( + json.RawMessage(`{"Success":false,"Outcome":"Failed","CompileFallback":"Requested","Message":"Hot reload finished with one or more Failed outcomes."}`), + json.RawMessage(`{"Success":true}`)) + if err != nil { + t.Fatalf("inject failed: %v", err) + } + fields := decodeSingleJSONObject(t, string(merged)) + if string(fields["Success"]) != "true" { + t.Fatalf("Success must become the compile's: %s", merged) + } + assertJSONStringField(t, fields, "Outcome", "ReplacedByCompile") +} + +// Verifies a successful fallback compile removes the hold sentence only from the end of Message, +// where the reload appended it, and leaves the same text elsewhere in Message alone. +func TestInjectHotReloadCompileFallback_CompileSucceeded_RemovesTheHoldSentenceOnlyAtTheEnd(t *testing.T) { + merged, err := injectHotReloadCompileFallback( + json.RawMessage(`{"Success":true,"Outcome":"NothingApplied","CompileFallback":"Requested","AutoRefreshHeld":true,"AutoRefreshHoldMessage":"HOLD SENTENCE","Message":"Skipped: 2. HOLD SENTENCE See Warnings."}`), + json.RawMessage(`{"Success":true}`)) + if err != nil { + t.Fatalf("inject failed: %v", err) + } + fields := decodeSingleJSONObject(t, string(merged)) + assertJSONStringField(t, fields, "Message", "Skipped: 2. HOLD SENTENCE See Warnings."+hotReloadCompileFallbackSucceededMessageSuffix) +} + +// Fails the test unless the field holds want as a JSON string. +func assertJSONStringField(t *testing.T, fields map[string]json.RawMessage, name string, want string) { + t.Helper() + got := "" + if err := json.Unmarshal(fields[name], &got); err != nil { + t.Fatalf("%s must be a JSON string: %v (raw %s)", name, err, fields[name]) + } + if got != want { + t.Fatalf("%s mismatch:\nwant %q\ngot %q", name, want, got) + } +}