From 0c2e08a00f1cf79a993a148e3c25a823c16b241f Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Sat, 2 May 2026 17:49:56 -0700 Subject: [PATCH 1/4] More test and documentation extension --- .cspell.json | 7 + .llm/context.md | 10 + .../documentation/human-prose-policy.md | 185 +++ .llm/skills/index.md | 28 +- .../performance/cache-eviction-policies.md | 2 +- .../scripting/cross-platform-compatibility.md | 4 +- .../javascript-code-quality-part-2.md | 2 +- .../powershell-best-practices-part-2.md | 2 +- .../scripting/validation-patterns-part-2.md | 2 +- ...location-coverage-required-for-dispatch.md | 2 +- .../testing/comprehensive-test-coverage.md | 8 +- .../testing/git-workflow-robustness-part-1.md | 2 +- .../skills/testing/git-workflow-robustness.md | 5 +- .../testing/inspector-overlay-invariants.md | 1 + .llm/skills/testing/leak-watcher-usage.md | 259 ++++ .../skills/testing/leak-watcher-usage.md.meta | 7 + .../skills/testing/lifecycle-edge-coverage.md | 248 ++++ .../testing/lifecycle-edge-coverage.md.meta | 7 + .llm/skills/testing/script-test-coverage.md | 4 +- .llm/skills/testing/single-thread-contract.md | 2 +- .llm/skills/testing/test-code-quality.md | 2 +- .../testing/test-coverage-data-driven.md | 2 +- .../test-coverage-organization-assertions.md | 2 +- .../test-coverage-scenario-categories.md | 2 +- .../test-coverage-unity-anti-patterns.md | 2 +- .../test-failure-investigation-procedure.md | 2 +- .../testing/test-failure-investigation.md | 4 +- .../testing/test-production-code-part-2.md | 2 +- ...s-must-be-parameterized-by-message-kind.md | 4 +- .llm/skills/unity/base-call-contract.md | 261 ++++ .pre-commit-config.yaml | 9 + .vale.ini | 45 + .vale/styles/DxMessaging/Capitalization.yml | 25 + .vale/styles/DxMessaging/Hedges.yml | 16 + .vale/styles/DxMessaging/LLMFiller.yml | 22 + .vale/styles/DxMessaging/Marketing.yml | 28 + .vale/styles/Vocab/DxMessaging/accept.txt | 64 + .vale/styles/Vocab/DxMessaging/reject.txt | 40 + AGENTS.md | 6 - CHANGELOG.md | 5 +- CLAUDE.md | 7 - Editor/Analyzers/BaseCallTypeScannerCore.cs | 133 +- .../WallstopStudios.DxMessaging.Analyzer.dll | Bin 21504 -> 25088 bytes ...opStudios.DxMessaging.SourceGenerators.dll | Bin 33280 -> 33280 bytes .../MessageAwareComponentInspectorOverlay.cs | 20 +- README.md | 8 +- Runtime/Core/DataStructure/CyclicBuffer.cs | 4 +- Runtime/Core/MessageBus/IMessageBus.cs | 58 + Runtime/Core/MessageBus/MessageBus.cs | 83 ++ Runtime/Unity/MessageAwareComponent.cs | 29 + Samples~/Mini Combat/Walkthrough.md | 2 +- .../MessageAwareComponentBaseCallAnalyzer.cs | 144 +- ...sageAwareComponentBaseCallAnalyzerTests.cs | 587 ++++++++ .../Allocations.meta} | 2 +- .../AllocationMatrixExtendedTests.cs | 603 ++++++++ .../AllocationMatrixExtendedTests.cs.meta | 11 + .../Allocations}/AllocationMatrixTests.cs | 4 +- .../AllocationMatrixTests.cs.meta | 2 +- .../BenchmarkHarnessRobustnessTests.cs | 7 +- .../BenchmarkHarnessRobustnessTests.cs.meta | 2 +- .../ProviderResolutionBenchmarks.cs | 3 +- .../ProviderResolutionBenchmarks.cs.meta | 2 +- ...xMessaging.Tests.Editor.Allocations.asmdef | 46 + ...aging.Tests.Editor.Allocations.asmdef.meta | 7 + Tests/{Runtime => Editor}/Benchmarks.meta | 0 Tests/Editor/Benchmarks/AssemblyInfo.cs | 9 + Tests/Editor/Benchmarks/AssemblyInfo.cs.meta | 3 + .../Benchmarks/BenchmarkSession.cs | 2 +- .../Benchmarks/BenchmarkSession.cs.meta | 2 +- .../Benchmarks/BenchmarkTestBase.cs | 4 +- .../Benchmarks/BenchmarkTestBase.cs.meta | 2 +- .../Benchmarks/PerformanceTests.cs | 6 +- .../Benchmarks/PerformanceTests.cs.meta | 0 ...ssaging.Tests.00.Editor.Benchmarks.asmdef} | 4 +- ...ng.Tests.00.Editor.Benchmarks.asmdef.meta} | 0 Tests/Editor/Comparisons.meta | 8 + .../ComparisonPerformanceTests.cs | 14 +- .../ComparisonPerformanceTests.cs.meta | 2 +- ...essaging.Tests.00.Editor.Benchmarks.asmdef | 46 + ...ing.Tests.00.Editor.Benchmarks.asmdef.meta | 7 + Tests/Runtime/Core/BaseCallContractTests.cs | 989 +++++++++++++ .../Core/BaseCallContractTests.cs.meta | 11 + Tests/Runtime/Core/LeakWatcherSelfTests.cs | 202 +++ .../Runtime/Core/LeakWatcherSelfTests.cs.meta | 11 + Tests/Runtime/Core/LifecycleEdgeCasesTests.cs | 1001 +++++++++++++ .../Core/LifecycleEdgeCasesTests.cs.meta | 11 + .../Core/MessageHandlerGlobalBusTests.cs | 6 + .../Core/PublicSurfaceContractTests.cs | 385 +++++ .../Core/PublicSurfaceContractTests.cs.meta | 11 + .../Core/ReentrantEmissionExtendedTests.cs | 1003 +++++++++++++ .../ReentrantEmissionExtendedTests.cs.meta | 11 + Tests/Runtime/Core/Snapshots.meta | 8 + Tests/Runtime/Core/Snapshots/.gitkeep | 0 .../Runtime/Core/Snapshots/public-surface.txt | 58 + .../Core/Snapshots/public-surface.txt.meta | 7 + .../Runtime/Core/SuiteWallClockBudgetTest.cs | 217 +++ .../Core/SuiteWallClockBudgetTest.cs.meta | 11 + .../Core/TestAttributeContractTests.cs | 611 +++++++- .../Components/BaseCallContractComponents.cs | 216 +++ .../BaseCallContractComponents.cs.meta | 11 + .../QuitOnDemandMessageAwareComponent.cs | 22 + .../QuitOnDemandMessageAwareComponent.cs.meta | 11 + .../Scripts/Messages/ClassBroadcastMessage.cs | 17 + .../Messages/ClassBroadcastMessage.cs.meta | 11 + .../Scripts/Messages/ClassTargetedMessage.cs | 17 + .../Messages/ClassTargetedMessage.cs.meta | 11 + Tests/Runtime/TestUtilities/LeakWatcher.cs | 340 +++++ .../Runtime/TestUtilities/LeakWatcher.cs.meta | 11 + .../Runtime/TestUtilities/MessageScenarios.cs | 21 + docs/advanced/emit-shorthands.md | 2 +- docs/advanced/message-bus-providers.md | 2 +- docs/advanced/runtime-configuration.md | 2 +- docs/architecture/comparisons.md | 10 +- docs/architecture/performance.md | 22 +- docs/concepts/index.md | 2 +- docs/getting-started/getting-started.md | 8 + docs/getting-started/index.md | 6 +- docs/getting-started/install.md | 2 +- docs/getting-started/quick-start.md | 20 + docs/getting-started/visual-guide.md | 6 +- docs/guides/inspector-overlay.md | 8 +- docs/guides/patterns.md | 4 +- docs/integrations/index.md | 4 +- docs/integrations/zenject.md | 2 +- docs/reference/analyzers.md | 26 +- docs/reference/faq.md | 4 + docs/reference/troubleshooting.md | 2 + llms.txt | 5 +- scripts/__tests__/validate-docs-prose.test.js | 877 +++++++++++ .../validate-docs-prose.test.js.meta | 7 + scripts/validate-docs-prose.js | 1277 +++++++++++++++++ scripts/validate-docs-prose.js.meta | 7 + 132 files changed, 10586 insertions(+), 163 deletions(-) create mode 100644 .llm/skills/documentation/human-prose-policy.md create mode 100644 .llm/skills/testing/leak-watcher-usage.md create mode 100644 .llm/skills/testing/leak-watcher-usage.md.meta create mode 100644 .llm/skills/testing/lifecycle-edge-coverage.md create mode 100644 .llm/skills/testing/lifecycle-edge-coverage.md.meta create mode 100644 .llm/skills/unity/base-call-contract.md create mode 100644 .vale.ini create mode 100644 .vale/styles/DxMessaging/Capitalization.yml create mode 100644 .vale/styles/DxMessaging/Hedges.yml create mode 100644 .vale/styles/DxMessaging/LLMFiller.yml create mode 100644 .vale/styles/DxMessaging/Marketing.yml create mode 100644 .vale/styles/Vocab/DxMessaging/accept.txt create mode 100644 .vale/styles/Vocab/DxMessaging/reject.txt rename Tests/{dxmsg-csharp-underscore-repo-excluded.meta => Editor/Allocations.meta} (77%) create mode 100644 Tests/Editor/Allocations/AllocationMatrixExtendedTests.cs create mode 100644 Tests/Editor/Allocations/AllocationMatrixExtendedTests.cs.meta rename Tests/{Runtime/Benchmarks => Editor/Allocations}/AllocationMatrixTests.cs (99%) rename Tests/{Runtime/Benchmarks => Editor/Allocations}/AllocationMatrixTests.cs.meta (83%) rename Tests/{Runtime/Benchmarks => Editor/Allocations}/BenchmarkHarnessRobustnessTests.cs (98%) rename Tests/{Runtime/Benchmarks => Editor/Allocations}/BenchmarkHarnessRobustnessTests.cs.meta (83%) rename Tests/{Runtime/Benchmarks => Editor/Allocations}/ProviderResolutionBenchmarks.cs (98%) rename Tests/{Runtime/Benchmarks => Editor/Allocations}/ProviderResolutionBenchmarks.cs.meta (84%) create mode 100644 Tests/Editor/Allocations/WallstopStudios.DxMessaging.Tests.Editor.Allocations.asmdef create mode 100644 Tests/Editor/Allocations/WallstopStudios.DxMessaging.Tests.Editor.Allocations.asmdef.meta rename Tests/{Runtime => Editor}/Benchmarks.meta (100%) create mode 100644 Tests/Editor/Benchmarks/AssemblyInfo.cs create mode 100644 Tests/Editor/Benchmarks/AssemblyInfo.cs.meta rename Tests/{Runtime => Editor}/Benchmarks/BenchmarkSession.cs (99%) rename Tests/{Runtime => Editor}/Benchmarks/BenchmarkSession.cs.meta (83%) rename Tests/{Runtime => Editor}/Benchmarks/BenchmarkTestBase.cs (98%) rename Tests/{Runtime => Editor}/Benchmarks/BenchmarkTestBase.cs.meta (83%) rename Tests/{Runtime => Editor}/Benchmarks/PerformanceTests.cs (99%) rename Tests/{Runtime => Editor}/Benchmarks/PerformanceTests.cs.meta (100%) rename Tests/{Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.Runtime.Benchmarks.asmdef => Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef} (89%) rename Tests/{Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.Runtime.Benchmarks.asmdef.meta => Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef.meta} (100%) create mode 100644 Tests/Editor/Comparisons.meta rename Tests/{Runtime/Benchmarks => Editor/Comparisons}/ComparisonPerformanceTests.cs (98%) rename Tests/{Runtime/Benchmarks => Editor/Comparisons}/ComparisonPerformanceTests.cs.meta (83%) create mode 100644 Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef create mode 100644 Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef.meta create mode 100644 Tests/Runtime/Core/BaseCallContractTests.cs create mode 100644 Tests/Runtime/Core/BaseCallContractTests.cs.meta create mode 100644 Tests/Runtime/Core/LeakWatcherSelfTests.cs create mode 100644 Tests/Runtime/Core/LeakWatcherSelfTests.cs.meta create mode 100644 Tests/Runtime/Core/LifecycleEdgeCasesTests.cs create mode 100644 Tests/Runtime/Core/LifecycleEdgeCasesTests.cs.meta create mode 100644 Tests/Runtime/Core/PublicSurfaceContractTests.cs create mode 100644 Tests/Runtime/Core/PublicSurfaceContractTests.cs.meta create mode 100644 Tests/Runtime/Core/ReentrantEmissionExtendedTests.cs create mode 100644 Tests/Runtime/Core/ReentrantEmissionExtendedTests.cs.meta create mode 100644 Tests/Runtime/Core/Snapshots.meta create mode 100644 Tests/Runtime/Core/Snapshots/.gitkeep create mode 100644 Tests/Runtime/Core/Snapshots/public-surface.txt create mode 100644 Tests/Runtime/Core/Snapshots/public-surface.txt.meta create mode 100644 Tests/Runtime/Core/SuiteWallClockBudgetTest.cs create mode 100644 Tests/Runtime/Core/SuiteWallClockBudgetTest.cs.meta create mode 100644 Tests/Runtime/Scripts/Components/BaseCallContractComponents.cs create mode 100644 Tests/Runtime/Scripts/Components/BaseCallContractComponents.cs.meta create mode 100644 Tests/Runtime/Scripts/Components/QuitOnDemandMessageAwareComponent.cs create mode 100644 Tests/Runtime/Scripts/Components/QuitOnDemandMessageAwareComponent.cs.meta create mode 100644 Tests/Runtime/Scripts/Messages/ClassBroadcastMessage.cs create mode 100644 Tests/Runtime/Scripts/Messages/ClassBroadcastMessage.cs.meta create mode 100644 Tests/Runtime/Scripts/Messages/ClassTargetedMessage.cs create mode 100644 Tests/Runtime/Scripts/Messages/ClassTargetedMessage.cs.meta create mode 100644 Tests/Runtime/TestUtilities/LeakWatcher.cs create mode 100644 Tests/Runtime/TestUtilities/LeakWatcher.cs.meta create mode 100644 scripts/__tests__/validate-docs-prose.test.js create mode 100644 scripts/__tests__/validate-docs-prose.test.js.meta create mode 100644 scripts/validate-docs-prose.js create mode 100644 scripts/validate-docs-prose.js.meta diff --git a/.cspell.json b/.cspell.json index 73b0711d..d16e9ff5 100644 --- a/.cspell.json +++ b/.cspell.json @@ -37,6 +37,13 @@ "words": [ "DxMessaging", "dxmessaging", + "nofilter", + "DDOL", + "Reemit", + "reemit", + "unsub", + "vstest", + "parameterizes", "wallstop", "DXMSG", "Untargeted", diff --git a/.llm/context.md b/.llm/context.md index 3cb565d3..974912c7 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -101,6 +101,10 @@ This file is intentionally concise. It contains only critical, high-signal guida - Treat failing tests as real defects until proven otherwise. - Prefer direct testing of production code rather than re-implementation in tests. - Cover normal, negative, and edge-case scenarios for new behavior. +- Tests that exercise dispatch across more than one of `Untargeted`/`Targeted`/`Broadcast` MUST be parameterized via `MessageScenarios.AllKinds`; see [Tests Must Be Parameterized by Message Kind](./skills/testing/tests-must-be-parameterized-by-message-kind.md). +- Bus dispatch-path changes must be covered by the canonical lifecycle edge-case set (scene unload mid-dispatch, DDOL transitions, prefab pooling churn, token disable / re-enable, post-Reset emit, OnApplicationQuit drain, cross-kind reentrancy); see [Lifecycle Edge-Case Test Coverage](./skills/testing/lifecycle-edge-coverage.md). +- Tests that create and tear down message registrations should bracket the work in a `LeakWatcher` to assert no registrations survive; see [LeakWatcher: Detecting Registration Leaks in Tests](./skills/testing/leak-watcher-usage.md). +- Benchmark and performance/allocation tests must stay isolated under `Tests/Runtime/Benchmarks` in asmdef `WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks`; `.00` is a lexical prefix convention so the benchmark assembly sorts before peer test assemblies in Unity Test Runner. Keep `BenchmarkAssemblyContractTests` green when adding or moving perf tests. ## Documentation Expectations @@ -115,6 +119,8 @@ This file is intentionally concise. It contains only critical, high-signal guida - Internal fragment links must match GitHub/markdownlint heading slugs exactly (MD051). - Documentation and `///` XML doc comments must be pure ASCII; see [ASCII-Only Documentation Policy](./skills/documentation/ascii-only-docs.md). Run `node scripts/validate-docs-ascii.js` before finishing. - Every C# code sample in docs - inline, fenced, and XML `` blocks - must compile; see [Code Samples Must Compile](./skills/documentation/code-samples-must-compile.md). Run `node scripts/validate-doc-code-patterns.js` and the `DocsSnippetCompilationTests` suite before finishing. +- Documentation prose must avoid LLM-style filler, marketing adjectives, hedge transitions, and vague quantifiers; see [Human-Prose Documentation Policy](./skills/documentation/human-prose-policy.md). Run `node scripts/validate-docs-prose.js` before finishing. +- Subclasses of `MessageAwareComponent` MUST call `base.()` from every guarded lifecycle override (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, `RegisterMessageHandlers`); see [MessageAwareComponent Base-Call Contract](./skills/unity/base-call-contract.md). Five enforcement layers (Roslyn analyzer DXMSG006-010, IL scanner, Inspector overlay, runtime self-check, meta-test) keep the contract honest. ## Skills to Prefer @@ -137,5 +143,9 @@ Use the index above and then select the most relevant skill pages. Frequently us - [Documentation Updates and Maintenance](./skills/documentation/documentation-updates.md) - [ASCII-Only Documentation Policy](./skills/documentation/ascii-only-docs.md) - [Code Samples Must Compile](./skills/documentation/code-samples-must-compile.md) +- [Human-Prose Documentation Policy](./skills/documentation/human-prose-policy.md) - [Cross-Platform Script Compatibility](./skills/scripting/cross-platform-compatibility.md) - [Test Failure Investigation and Zero-Flaky Policy](./skills/testing/test-failure-investigation.md) +- [Lifecycle Edge-Case Test Coverage](./skills/testing/lifecycle-edge-coverage.md) +- [LeakWatcher: Detecting Registration Leaks in Tests](./skills/testing/leak-watcher-usage.md) +- [MessageAwareComponent Base-Call Contract](./skills/unity/base-call-contract.md) diff --git a/.llm/skills/documentation/human-prose-policy.md b/.llm/skills/documentation/human-prose-policy.md new file mode 100644 index 00000000..ff01cc99 --- /dev/null +++ b/.llm/skills/documentation/human-prose-policy.md @@ -0,0 +1,185 @@ +--- +title: "Human-Prose Documentation Policy" +id: "human-prose-policy" +category: "documentation" +version: "1.0.0" +created: "2026-05-02" +updated: "2026-05-02" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "docs/" + - path: "README.md" + - path: "Runtime/" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "documentation" + - "prose" + - "linting" + - "policy" + - "tooling" + +complexity: + level: "basic" + reasoning: "Mechanical phrase enforcement with a small allow-marker system" + +impact: + performance: + rating: "none" + details: "Documentation only" + maintainability: + rating: "high" + details: "Removes LLM drift from docs and keeps voice consistent across contributors" + testability: + rating: "low" + details: "Validator and Vale rule packs cover the policy automatically" + +prerequisites: + - "Awareness of the project's documentation linting toolchain" + +dependencies: + packages: [] + skills: + - "ascii-only-docs" + - "documentation-style-guide" + +applies_to: + languages: + - "Markdown" + - "C#" + frameworks: + - "MkDocs" + - "GitHub" + +aliases: + - "Prose policy" + - "Anti-LLM-prose policy" + - "Human voice policy" + +related: + - "ascii-only-docs" + - "documentation-style-guide" + - "code-samples-must-compile" + +status: "stable" +--- + +# Human-Prose Documentation Policy + +> **One-line summary**: All documentation prose - in `.md` files and `///` XML doc comments - must avoid marketing adjectives, LLM filler idioms, hedge transitions, vague quantifiers, and soft conversational fluff. + +## Overview + +DxMessaging documentation is written for humans reading reference material. Prose that reads like a marketing landing page or a generic LLM completion costs the reader trust and the project tokens. This policy bans a specific set of LLM-signature phrasings and is enforced mechanically by `scripts/validate-docs-prose.js` (the source of truth) plus Vale rule packs under `.vale/styles/DxMessaging/` for structural prose checks. + +## Rationale + +Marketing adjectives without a measurement (`blazing fast`, `world-class`) signal that the writer did not have a number. Filler phrases like `it goes without saying` consume context and produce no signal. Banning a small set of phrases keeps voice convergent without per-PR debates. + +## Banned Categories + +Marketing adjectives (case-insensitive, whole-word): + +`cutting-edge`, `cutting edge`, `blazing fast`, `seamless`, `seamlessly`, `seamlessness`, `powerful`, `powerfully`, `robust`, `robustly`, `elegant`, `elegantly`, `world-class`, `next-generation`, `industry-leading`, `state-of-the-art`, `comprehensive`, `comprehensively`, `unparalleled`, `revolutionary`, `game-changing`, `best-in-class`, `production-ready`, `enterprise-grade`, `lightning-fast`, `frictionless`, `battle-tested`, `bulletproof`, `rock-solid`. + +LLM filler idioms (case-insensitive, phrase match): + +`delve into`, `delving into`, `delved into`, `delves into`, `harness the power`, `navigate the complexities`, `unlock the potential`, `tapestry`, `realm of`, `dive deep into`, `dive into`, `at the heart of`, `lies the`, `treasure trove`, `it goes without saying`, `needless to say`. + +Hedge transitions (only at the start of a sentence or list item; trailing comma optional): + +`Furthermore`, `Moreover`, `In conclusion`, `In essence`, `In summary`, `It's important to note`, `It's worth noting`, `That said`, `Overall`, `Ultimately`. + +Vague quantifiers (case-insensitive, whole-word): + +`a wide variety of`, `a wide array of`, `a plethora of`, `myriad`, `numerous`. + +Soft conversational fluff (regex): + +`gives you (the )?best`, `provides you with`, `helps you to`, `allows you to easily`, `enables you to`. + +The validator's `--list-rules` flag prints the canonical set with full term lists; the JS file is the source of truth. + +## Allowed Exceptions + +- **Skill files about the policy.** Files under `.llm/skills/documentation/` are wholly exempt. +- **`CHANGELOG.md` and `comprehensive`.** Release notes legitimately use the term. The exemption is matched case-insensitively on the basename. +- **Auto-generated files.** `.llm/skills/index.md` and `llms.txt` are exempt because they are regenerated mechanically. +- **YAML frontmatter.** A leading `---\n...\n---\n` block at the top of `.md` files is skipped entirely. Schema strings inside frontmatter (such as `complexity` reasoning fields) never trigger the validator. +- **Inline allow markers.** When a banned term is genuinely the right word for a specific sentence, mark it inline using one of: + + ```markdown + + + + ``` + + Markers must fit on a single line: the opening `` must be on the same line. A multi-line marker emits a `WARN` to stderr but does not fail the run. Marker comments are themselves stripped from the scan, so they never trigger on themselves. + - `prose-allow` matches on the same line. + - `prose-allow-next-line` applies to the next non-blank scanned line. + - `prose-allow-file` applies file-wide. + + Skill files outside `.llm/skills/documentation/` should use `` near the top when a banned term is necessary in the body. Use markers sparingly. The default answer to a flagged term is to rewrite the sentence. + +## Enforcement + +The policy is fully enforced going forward. There is no grandfather list: every violation reported by `scripts/validate-docs-prose.js` is a new defect to fix. + +| Layer | What it covers | When it runs | +| ----------------------------------------- | ---------------------------------------------------------- | -------------------------------------------- | +| `scripts/validate-docs-prose.js` | All banned phrases, allow markers, exemptions | Local pre-commit hook (CI integration TBD) | +| `validate-docs-prose` pre-commit hook | Runs the JS validator on every commit touching `.md`/`.cs` | Local pre-commit | +| `.vale.ini` + `.vale/styles/DxMessaging/` | Passive voice, weasel words, additional style rules | Local-only until committed and wired into CI | + +The custom JS validator is the source of truth. The Vale configuration is additive and currently lives only in working trees; once it is committed and wired into a workflow, this row will move to "CI". File an issue if Vale flags something the JS validator missed so the `RULES` array can absorb the rule first. + +An earlier transitional baseline list has been retired; the policy is now fully enforced from a clean slate. + +## How to Fix Violations + +There is no auto-fix. Each banned phrase is a sign that the sentence around it should be rewritten. The CLI tells you the rule and the suggested replacement strategy: + +```bash +node scripts/validate-docs-prose.js +``` + +```text +docs/install.md:42:5 [marketing/marketing] 'cutting-edge' -- Marketing adjective; replace with a concrete claim. modern, current, or describe the specific feature +``` + +To see the per-category counts across the repository: + +```bash +node scripts/validate-docs-prose.js --summary +``` + +To run a single rule (useful when you are sweeping one category): + +```bash +node scripts/validate-docs-prose.js --rule marketing +``` + +To list every configured rule and its term list: + +```bash +node scripts/validate-docs-prose.js --list-rules +``` + +### Before / After + +Marketing - bad: `DxMessaging is a powerful, comprehensive messaging library.` Good: `DxMessaging is a synchronous, allocation-free message bus for Unity.` + +LLM filler - bad: `At the heart of the system lies the MessageBus.` Good: `The MessageBus is the core of the system.` + +Hedge - bad: `It's important to note that registrations are reference-counted.` Good: `Registrations are reference-counted.` + +Soft fluff - bad: `The bus enables you to dispatch messages.` Good: `The bus dispatches messages.` + +## See Also + +- [ASCII-Only Documentation Policy](./ascii-only-docs.md) +- [Documentation Style Guide](./documentation-style-guide.md) +- [Code Samples Must Compile](./code-samples-must-compile.md) +- [Documentation Updates and Maintenance](./documentation-updates.md) diff --git a/.llm/skills/index.md b/.llm/skills/index.md index 3c6b3553..3dc45e33 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -1,6 +1,6 @@ # Skills Index -> **Auto-generated** on 2026-05-01. Do not edit manually. +> **Auto-generated** on 2026-05-02. Do not edit manually. > Run `node scripts/generate-skills-index.js` to regenerate. --- @@ -9,20 +9,21 @@ | Metric | Value | | ------------ | ----- | -| Total Skills | 138 | -| Categories | 7 | +| Total Skills | 142 | +| Categories | 8 | --- ## Table of Contents -- [Documentation](#documentation) (26) +- [Documentation](#documentation) (27) - [GitHub Actions](#github-actions) (5) - [Packaging](#packaging) (2) - [Performance](#performance) (40) - [Scripting](#scripting) (15) - [Solid](#solid) (15) -- [Testing](#testing) (35) +- [Testing](#testing) (37) +- [Unity](#unity) (1) --- @@ -43,6 +44,7 @@ | [Documentation Updates and Maintenance](./documentation/documentation-updates.md) | [ok] 149 | [basic] | [stable] | [risk: none] | documentation, code-comments | | [External URL Fragment Validation](./documentation/external-url-fragment-validation.md) | [ok] 182 | [basic] | [stable] | [risk: none] | documentation, links | | [GitHub Actions Version Consistency](./documentation/github-actions-version-consistency.md) | [ok] 204 | [basic] | [stable] | [risk: none] | github-actions, ci-cd | +| [Human-Prose Documentation Policy](./documentation/human-prose-policy.md) | [ok] 186 | [basic] | [stable] | [risk: none] | documentation, prose | | [Link Quality and External URL Management](./documentation/link-quality-guidelines.md) | [ok] 120 | [basic] | [stable] | [risk: none] | documentation, links | | [Link Quality and External URL Management Part 1](./documentation/link-quality-guidelines-part-1.md) | [ok] 196 | [intermediate] | [stable] | [risk: low] | migration, split | | [Link Quality and External URL Management Part 2](./documentation/link-quality-guidelines-part-2.md) | [draft] 64 | [intermediate] | [stable] | [risk: low] | migration, split | @@ -164,14 +166,15 @@ | Skill | Lines | Complexity | Status | Performance | Tags | | ------------------------------------------------------------------------------------------------------- | ----------- | -------------- | -------- | ---------------- | ---------------------------- | | [Allocation Coverage Required for Dispatch](./testing/allocation-coverage-required-for-dispatch.md) | [ok] 259 | [intermediate] | [stable] | [risk: critical] | testing, allocation | -| [Comprehensive Test Coverage Requirements](./testing/comprehensive-test-coverage.md) | [ok] 142 | [intermediate] | [stable] | [risk: none] | testing, coverage | | [Data-Driven Coverage Patterns](./testing/test-coverage-data-driven.md) | [ok] 173 | [intermediate] | [stable] | [risk: none] | testing, data-driven | | [Data-Driven Test Sources](./testing/data-driven-tests-sources.md) | [ok] 256 | [intermediate] | [stable] | [risk: none] | testing, parameterized | | [Data-Driven Test Usage Patterns](./testing/data-driven-tests-usage.md) | [draft] 108 | [intermediate] | [stable] | [risk: none] | testing, parameterized | | [Data-Driven Tests with TestCaseSource](./testing/data-driven-tests.md) | [ok] 198 | [intermediate] | [stable] | [risk: low] | testing, parameterized | -| [Git and Parser Robustness in CI/CD](./testing/git-workflow-robustness.md) | [ok] 214 | [intermediate] | [stable] | [risk: none] | testing, git | +| [Git and Parser Robustness in CI/CD](./testing/git-workflow-robustness.md) | [ok] 215 | [intermediate] | [stable] | [risk: none] | testing, git | | [Git and Parser Robustness in CI/CD Part 1](./testing/git-workflow-robustness-part-1.md) | [ok] 188 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Inspector Overlay Invariants for MessageAwareComponent](./testing/inspector-overlay-invariants.md) | [ok] 152 | [intermediate] | [stable] | [risk: low] | testing, editor | +| [Inspector Overlay Invariants for MessageAwareComponent](./testing/inspector-overlay-invariants.md) | [ok] 153 | [intermediate] | [stable] | [risk: low] | testing, editor | +| [LeakWatcher: Detecting Registration Leaks in Tests](./testing/leak-watcher-usage.md) | [ok] 260 | [basic] | [stable] | [risk: low] | testing, leaks | +| [Lifecycle Edge-Case Test Coverage](./testing/lifecycle-edge-coverage.md) | [ok] 249 | [intermediate] | [stable] | [risk: none] | testing, lifecycle | | [Script Test Coverage Requirements](./testing/script-test-coverage.md) | [ok] 260 | [intermediate] | [stable] | [risk: none] | testing, scripts | | [Shared Fixtures: Generic Base](./testing/shared-test-fixtures-generic-base.md) | [ok] 186 | [advanced] | [stable] | [risk: high] | testing, fixtures | | [Shared Fixtures: Reference Counting](./testing/shared-test-fixtures-reference-counting.md) | [ok] 253 | [advanced] | [stable] | [risk: high] | testing, fixtures | @@ -184,6 +187,7 @@ | [Test Categories for Selective Execution Part 1](./testing/test-categories-part-1.md) | [draft] 67 | [intermediate] | [stable] | [risk: low] | migration, split | | [Test Category Execution](./testing/test-categories-execution.md) | [ok] 143 | [basic] | [stable] | [risk: none] | testing, organization | | [Test Code Quality and Accuracy](./testing/test-code-quality.md) | [ok] 244 | [intermediate] | [stable] | [risk: medium] | testing, documentation | +| [Test Coverage Requirements](./testing/comprehensive-test-coverage.md) | [ok] 142 | [intermediate] | [stable] | [risk: none] | testing, coverage | | [Test Coverage Scenario Categories](./testing/test-coverage-scenario-categories.md) | [ok] 224 | [intermediate] | [stable] | [risk: none] | testing, coverage | | [Test Diagnostics and Investigation Patterns](./testing/test-diagnostics.md) | [ok] 248 | [intermediate] | [stable] | [risk: low] | testing, diagnostics | | [Test Diagnostics Patterns](./testing/test-diagnostics-patterns.md) | [ok] 190 | [intermediate] | [stable] | [risk: low] | testing, diagnostics | @@ -196,9 +200,15 @@ | [Test Production Code Directly](./testing/test-production-code.md) | [ok] 146 | [intermediate] | [stable] | [risk: none] | testing, anti-patterns | | [Test Production Code Directly Part 1](./testing/test-production-code-part-1.md) | [ok] 205 | [intermediate] | [stable] | [risk: low] | migration, split | | [Test Production Code Directly Part 2](./testing/test-production-code-part-2.md) | [draft] 66 | [intermediate] | [stable] | [risk: low] | migration, split | -| [Tests Must Be Parameterized by Message Kind](./testing/tests-must-be-parameterized-by-message-kind.md) | [ok] 240 | [intermediate] | [stable] | [risk: none] | testing, data-driven | +| [Tests Must Be Parameterized by Message Kind](./testing/tests-must-be-parameterized-by-message-kind.md) | [ok] 242 | [intermediate] | [stable] | [risk: none] | testing, data-driven | | [Unity Test Considerations and Anti-Patterns](./testing/test-coverage-unity-anti-patterns.md) | [warn] 270 | [basic] | [stable] | [risk: none] | testing, unity | +## Unity + +| Skill | Lines | Complexity | Status | Performance | Tags | +| ------------------------------------------------------------------------- | ---------- | -------------- | -------- | ------------ | --------------- | +| [MessageAwareComponent Base-Call Contract](./unity/base-call-contract.md) | [warn] 262 | [intermediate] | [stable] | [risk: none] | unity, analyzer | + --- _Generated by `scripts/generate-skills-index.js`_ diff --git a/.llm/skills/performance/cache-eviction-policies.md b/.llm/skills/performance/cache-eviction-policies.md index 53dbfc14..d6b6f51e 100644 --- a/.llm/skills/performance/cache-eviction-policies.md +++ b/.llm/skills/performance/cache-eviction-policies.md @@ -72,7 +72,7 @@ status: "stable" # High-Performance Cache with Eviction Policies -> **One-line summary**: Build production-ready caches with LRU/LFU/SLRU eviction, TTL expiration, and statistics using a fluent builder. +> **One-line summary**: Build caches with LRU/LFU/SLRU eviction, TTL expiration, and statistics using a fluent builder. ## Overview diff --git a/.llm/skills/scripting/cross-platform-compatibility.md b/.llm/skills/scripting/cross-platform-compatibility.md index 884e48d7..686e5549 100644 --- a/.llm/skills/scripting/cross-platform-compatibility.md +++ b/.llm/skills/scripting/cross-platform-compatibility.md @@ -78,7 +78,7 @@ status: "stable" # Cross-Platform Script Compatibility > **One-line summary**: Ensure scripts work correctly across Windows, macOS, and Linux by handling -> case-sensitive paths and maintaining comprehensive test coverage. +> case-sensitive paths and covering each script with platform-specific tests. ## Overview @@ -215,7 +215,7 @@ Before merging scripts: - [Script Test Coverage](../testing/script-test-coverage.md) - Test coverage requirements for scripts - [Shell Best Practices](./shell-best-practices.md) - Shell-specific case sensitivity patterns - [PowerShell Best Practices](./powershell-best-practices.md) - PowerShell scripting patterns -- [Comprehensive Test Coverage](../testing/comprehensive-test-coverage.md) - General test coverage requirements +- [Test Coverage Requirements](../testing/comprehensive-test-coverage.md) - General test coverage requirements ## Changelog diff --git a/.llm/skills/scripting/javascript-code-quality-part-2.md b/.llm/skills/scripting/javascript-code-quality-part-2.md index 16ed928a..eea354f4 100644 --- a/.llm/skills/scripting/javascript-code-quality-part-2.md +++ b/.llm/skills/scripting/javascript-code-quality-part-2.md @@ -134,7 +134,7 @@ Before committing JavaScript code, verify: - [Script Test Coverage](../testing/script-test-coverage.md) - Test file structure and naming - [Cross-Platform Compatibility](cross-platform-compatibility.md) - Platform-specific considerations -- [Comprehensive Test Coverage](../testing/comprehensive-test-coverage.md) - Test coverage requirements +- [Test Coverage Requirements](../testing/comprehensive-test-coverage.md) - Coverage policy for new features and fixes ## Related Links diff --git a/.llm/skills/scripting/powershell-best-practices-part-2.md b/.llm/skills/scripting/powershell-best-practices-part-2.md index ef06b54b..9dd422fa 100644 --- a/.llm/skills/scripting/powershell-best-practices-part-2.md +++ b/.llm/skills/scripting/powershell-best-practices-part-2.md @@ -118,7 +118,7 @@ foreach ($case in $testCases) { } ``` -For comprehensive test coverage patterns, see [Script Test Coverage](../testing/script-test-coverage.md). +For test coverage patterns specific to PowerShell scripts, see [Script Test Coverage](../testing/script-test-coverage.md). ## See Also diff --git a/.llm/skills/scripting/validation-patterns-part-2.md b/.llm/skills/scripting/validation-patterns-part-2.md index e5ba3bed..22adf574 100644 --- a/.llm/skills/scripting/validation-patterns-part-2.md +++ b/.llm/skills/scripting/validation-patterns-part-2.md @@ -108,7 +108,7 @@ be treated as empty, add explicit `.trim()` checks. ## See Also - [JavaScript Code Quality](javascript-code-quality.md) - General JavaScript best practices -- [Comprehensive Test Coverage](../testing/comprehensive-test-coverage.md) - Test coverage requirements +- [Test Coverage Requirements](../testing/comprehensive-test-coverage.md) - Coverage policy for new features and fixes ## Related Links diff --git a/.llm/skills/testing/allocation-coverage-required-for-dispatch.md b/.llm/skills/testing/allocation-coverage-required-for-dispatch.md index c8daa396..5bdc001c 100644 --- a/.llm/skills/testing/allocation-coverage-required-for-dispatch.md +++ b/.llm/skills/testing/allocation-coverage-required-for-dispatch.md @@ -243,7 +243,7 @@ common drift point. ## See Also - [Tests Must Be Parameterized by Message Kind](tests-must-be-parameterized-by-message-kind.md) -- [Comprehensive Test Coverage Requirements](comprehensive-test-coverage.md) +- [Test Coverage Requirements](comprehensive-test-coverage.md) - [Test Categories for Selective Execution](test-categories.md) - [Single Thread Contract](single-thread-contract.md) diff --git a/.llm/skills/testing/comprehensive-test-coverage.md b/.llm/skills/testing/comprehensive-test-coverage.md index 22d482f9..f13797e9 100644 --- a/.llm/skills/testing/comprehensive-test-coverage.md +++ b/.llm/skills/testing/comprehensive-test-coverage.md @@ -1,5 +1,5 @@ --- -title: "Comprehensive Test Coverage Requirements" +title: "Test Coverage Requirements" id: "comprehensive-test-coverage" category: "testing" version: "1.0.0" @@ -33,7 +33,7 @@ impact: details: "Testing patterns only; no runtime impact" maintainability: rating: "critical" - details: "Comprehensive tests prevent regressions and document expected behavior" + details: "Thorough tests prevent regressions and document expected behavior" testability: rating: "critical" details: "Defines the standard for test quality across the codebase" @@ -82,13 +82,13 @@ related: status: "stable" --- -# Comprehensive Test Coverage Requirements +# Test Coverage Requirements > **One-line summary**: Every new feature and bug fix requires tests covering happy paths, negative scenarios, edge cases, and "impossible" situations. ## Overview -Comprehensive test coverage is not optional. Every code change must include tests that verify: +Full test coverage is not optional. Every code change must include tests that verify: 1. The feature works as intended (happy path) 1. The feature handles errors gracefully (negative scenarios) diff --git a/.llm/skills/testing/git-workflow-robustness-part-1.md b/.llm/skills/testing/git-workflow-robustness-part-1.md index b101bc83..16586a05 100644 --- a/.llm/skills/testing/git-workflow-robustness-part-1.md +++ b/.llm/skills/testing/git-workflow-robustness-part-1.md @@ -177,7 +177,7 @@ Before merging code with git commands or parsers: ## See Also -- [Comprehensive Test Coverage](../testing/comprehensive-test-coverage.md) - Detailed testing +- [Test Coverage Requirements](../testing/comprehensive-test-coverage.md) - Detailed testing strategies - [Documentation Updates](../documentation/documentation-updates.md) - Keeping docs in sync - [Shell Pattern Matching](../../context.md#shell-pattern-matching) - Main context file patterns diff --git a/.llm/skills/testing/git-workflow-robustness.md b/.llm/skills/testing/git-workflow-robustness.md index cd4fd7ca..1b33258e 100644 --- a/.llm/skills/testing/git-workflow-robustness.md +++ b/.llm/skills/testing/git-workflow-robustness.md @@ -43,8 +43,9 @@ related: ## Overview -This skill covers best practices for writing robust git commands in CI/CD pipelines and -implementing reliable markdown parsers that handle edge cases correctly. +This skill covers practices for writing git commands in CI/CD pipelines that handle +shallow clones, missing tags, initial commits, and merge bases without failing, and +implementing markdown parsers that survive malformed input without aborting. ## Solution diff --git a/.llm/skills/testing/inspector-overlay-invariants.md b/.llm/skills/testing/inspector-overlay-invariants.md index 1fa93e03..4cb9e6f0 100644 --- a/.llm/skills/testing/inspector-overlay-invariants.md +++ b/.llm/skills/testing/inspector-overlay-invariants.md @@ -146,6 +146,7 @@ The invariants above depend on Unity-internal behavior. Revisit if any of these ## See Also +- [MessageAwareComponent Base-Call Contract](../unity/base-call-contract.md) -- the analyzer + IL-scanner contract whose output the inspector overlay renders. - `Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs` -- fallback editor source and XML doc. - `Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs` -- overlay source with full Layout/Repaint invariant comments. - `Tests/Editor/MessageAwareComponentFallbackEditorTests.cs` -- regression tests. diff --git a/.llm/skills/testing/leak-watcher-usage.md b/.llm/skills/testing/leak-watcher-usage.md new file mode 100644 index 00000000..ebb776c8 --- /dev/null +++ b/.llm/skills/testing/leak-watcher-usage.md @@ -0,0 +1,259 @@ +--- +title: "LeakWatcher: Detecting Registration Leaks in Tests" +id: "leak-watcher-usage" +category: "testing" +version: "1.0.0" +created: "2026-05-02" +updated: "2026-05-02" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "Tests/Runtime/TestUtilities/LeakWatcher.cs" + - path: "Tests/Runtime/Core/LeakWatcherSelfTests.cs" + - path: "Runtime/Core/MessageBus/IMessageBus.cs" + - path: "Tests/Runtime/Core/PublicSurfaceContractTests.cs" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "testing" + - "leaks" + - "registration" + - "lifecycle" + +complexity: + level: "basic" + reasoning: "Wraps a small IDisposable around the public IMessageBus counters; usage is mechanical." + +impact: + performance: + rating: "low" + details: "Each counter snapshot is O(types) due to per-message-type interceptor / post-processor caches; restrict to region boundaries." + maintainability: + rating: "high" + details: "Centralizes leak-detection in one utility so test fixtures stop rolling their own." + testability: + rating: "high" + details: "Brings the bus's public counter surface under test discipline." + +prerequisites: + - "lifecycle-edge-coverage" + +dependencies: + packages: [] + skills: + - "lifecycle-edge-coverage" + - "tests-must-be-parameterized-by-message-kind" + +applies_to: + languages: + - "C#" + frameworks: + - "Unity" + - "NUnit" + versions: + unity: ">=2021.3" + +aliases: + - "Leak watcher" + - "Registration leak detection" + +related: + - "lifecycle-edge-coverage" + - "base-call-contract" + - "comprehensive-test-coverage" + - "tests-must-be-parameterized-by-message-kind" + +status: "stable" +--- + +# LeakWatcher: Detecting Registration Leaks in Tests + +> **One-line summary**: Any test that creates and tears down message +> registrations should bracket the work in a `LeakWatcher` to assert no +> registrations survive the watched region. + +## Overview + +`LeakWatcher` (`Tests/Runtime/TestUtilities/LeakWatcher.cs`) is an +`IDisposable` that snapshots every public registration counter on +`IMessageBus` at construction and asserts on `Dispose` that the counters +returned to their starting values. It is the canonical leak-detection +mechanism for the test suite; do not re-implement the counter math +inline. + +The watcher reads six counters in a single pass: +`RegisteredUntargeted`, `RegisteredTargeted`, `RegisteredBroadcast`, +`RegisteredInterceptors`, `RegisteredPostProcessors`, and +`RegisteredGlobalAcceptAll`. The last three close gaps that earlier +ad-hoc leak checks missed: an interceptor that survived its register / +deregister cycle, a post-processor whose owning component was destroyed +before its handle was released, and the global-accept-all listener path +used by diagnostics. + +## Public-Counter Contract + +The watcher is read-only and goes through public surface exclusively. The +counter set above IS the canonical leak-detection surface; no hidden field +of the bus is reflected. The "counter source" doc-comment block on +`LeakWatcher` documents this contract verbatim. + +If a future bus revision introduces a seventh registration kind, BOTH +`LeakWatcher.Snapshot` and `LeakWatcher.LeakedRegistrations` must be +extended in lock-step so total leak deltas remain correct. The drift is +caught by +`Tests/Runtime/Core/PublicSurfaceContractTests.cs::PublicTypeSetInDxMessagingCoreNamespaceMatchesSnapshot`, +which fails when the public type set drifts from the committed snapshot. + +## Usage Patterns + +The default form wraps the watched region in a `using` block. `Dispose` +calls `Assert.Fail` with a counter-by-counter diff if the region leaks; +the failure message names every initial / final pair so triage does not +require a breakpoint. + +```csharp +namespace DxMessaging.Tests.Runtime.Core +{ + using System.Collections; + using DxMessaging.Core; + using DxMessaging.Tests.Runtime; + using DxMessaging.Tests.Runtime.Scripts.Components; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + using UnityEngine; + using UnityEngine.TestTools; + + public sealed class LeakWatcherUsageExample : MessagingTestBase + { + [UnityTest] + public IEnumerator RegistrationDoesNotLeak( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(RegistrationDoesNotLeak) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + MessageRegistrationToken token = GetToken( + host.GetComponent() + ); + + using (LeakWatcher watcher = LeakWatcher.Watch(label: scenario.DisplayName)) + { + MessageRegistrationHandle handle = ScenarioHarness + .RegisterUntargeted( + scenario, + token, + (ref SimpleUntargetedMessage _) => { } + ); + token.RemoveRegistration(handle); + } + + yield break; + } + } +} +``` + +To inspect the leak count without failing the test, construct the watcher +with `throwOnLeak: false` and read `LeakedRegistrations` before disposal: + +```csharp +namespace DxMessaging.Tests.Runtime.Core +{ + using DxMessaging.Core; + using DxMessaging.Tests.Runtime; + using NUnit.Framework; + + internal static class LeakWatcherInspectionExample + { + public static int CountLeaksDuring(System.Action work) + { + using LeakWatcher watcher = new LeakWatcher( + bus: MessageHandler.MessageBus, + throwOnLeak: false, + label: "inspection" + ); + work(); + return watcher.LeakedRegistrations; + } + + public static void AssertLeakRaisesOnDispose() + { + LeakWatcher watcher = LeakWatcher.Watch(label: "explicit"); + // ... work that intentionally leaks ... + Assert.Throws(watcher.Dispose); + } + } +} +``` + +## Cost: O(types) per Snapshot + +Both `Snapshot` and `LeakedRegistrations` walk every per-message-type +cache backing `IMessageBus.RegisteredInterceptors` and +`IMessageBus.RegisteredPostProcessors`. Each access is O(types). Snapshot +at region boundaries; do NOT read `Snapshot` inside a tight loop. The +suite's wall-clock budget is 60 s soft / 180 s hard +(`Tests/Runtime/Core/SuiteWallClockBudgetTest.cs`). + +## Self-Tests + +`Tests/Runtime/Core/LeakWatcherSelfTests.cs` parameterizes over +`MessageScenarios.AllKinds` and exercises three behaviors: + +- `WatcherPassesWhenAllHandlesAreRemoved` -- a clean register / emit / + remove cycle disposes without raising. +- `WatcherDetectsLeakedRegistrationWhenNotThrowing` -- a leaked handle + shows up in `LeakedRegistrations` before disposal. +- `WatcherThrowsOnLeakWhenConfiguredTo` -- `Dispose` raises + `AssertionException` when `throwOnLeak: true` and a registration is + outstanding. + +## Adding a New Counter + +When the bus grows a new public registration counter: + +1. Extend `IMessageBus` with the new property; add it to + `Tests/Runtime/Core/Snapshots/public-surface.txt` (the committed + snapshot consumed by `PublicSurfaceContractTests`). +1. Add the counter to `LeakWatcher` in three places: `_initialXxx` / + `_finalXxx` fields, the `Snapshot` sum, and the `TotalDelta` parameter + list. Extend the failure-message format string so leak diagnostics + include the new pair. +1. Add a test row in `LeakWatcherSelfTests` exercising the new counter. +1. Update this skill's "Public-Counter Contract" section. + +A skipped watcher extension under-counts silently; the public-surface +snapshot test catches the drift first, and a self-test that registers +exclusively against the new counter fails loudly otherwise. + +## When NOT to Use + +- Inside a tight loop. Use one watcher around the loop body, not one per + iteration. +- For non-bus resources. The watcher reads `IMessageBus` only; GameObject + leaks and NativeArray leaks are out of scope. +- For benchmark hot paths. Allocation / Performance fixtures avoid the + watcher because the per-call O(types) cost shows up in measurements. + +## See Also + +- [Lifecycle Edge-Case Test Coverage](./lifecycle-edge-coverage.md) +- [Tests Must Be Parameterized by Message Kind](./tests-must-be-parameterized-by-message-kind.md) +- [Test Coverage Requirements](./comprehensive-test-coverage.md) +- [MessageAwareComponent Base-Call Contract](../unity/base-call-contract.md) + +## References + +- NUnit `IDisposable` cleanup pattern: https://docs.nunit.org/articles/nunit/writing-tests/setup-teardown/Tear-Down.html +- Unity Test Framework: https://docs.unity3d.com/Packages/com.unity.test-framework@latest + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | --------------- | +| 1.0.0 | 2026-05-02 | Initial version | diff --git a/.llm/skills/testing/leak-watcher-usage.md.meta b/.llm/skills/testing/leak-watcher-usage.md.meta new file mode 100644 index 00000000..d15c4601 --- /dev/null +++ b/.llm/skills/testing/leak-watcher-usage.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c2a8b4f7d6e1a9c3b5e8f2d7a4c1e6b9 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/.llm/skills/testing/lifecycle-edge-coverage.md b/.llm/skills/testing/lifecycle-edge-coverage.md new file mode 100644 index 00000000..46926050 --- /dev/null +++ b/.llm/skills/testing/lifecycle-edge-coverage.md @@ -0,0 +1,248 @@ +--- +title: "Lifecycle Edge-Case Test Coverage" +id: "lifecycle-edge-coverage" +category: "testing" +version: "1.0.0" +created: "2026-05-02" +updated: "2026-05-02" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "Tests/Runtime/Core/LifecycleEdgeCasesTests.cs" + - path: "Tests/Runtime/Core/ReentrantEmissionExtendedTests.cs" + - path: "Tests/Runtime/TestUtilities/LeakWatcher.cs" + - path: "Runtime/Core/MessageBus/MessageBus.cs" + - path: "Runtime/Core/DxMessagingStaticState.cs" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "testing" + - "lifecycle" + - "edge-cases" + - "scenes" + - "destroy" + - "regression" + +complexity: + level: "intermediate" + reasoning: "Requires understanding of Unity scene/destroy lifecycle, the bus's snapshot dispatch semantics, and the reset-generation guard." + +impact: + performance: + rating: "none" + details: "Pattern affects test coverage breadth, not runtime performance." + maintainability: + rating: "critical" + details: "Pins the lifecycle scenarios that surfaced as production defects so they cannot regress silently." + testability: + rating: "critical" + details: "Defines the canonical edge-case set every dispatch-path change must clear." + +prerequisites: + - "tests-must-be-parameterized-by-message-kind" + +dependencies: + packages: [] + skills: + - "tests-must-be-parameterized-by-message-kind" + - "comprehensive-test-coverage" + +applies_to: + languages: + - "C#" + frameworks: + - "Unity" + - "NUnit" + versions: + unity: ">=2021.3" + +aliases: + - "Lifecycle edge cases" + - "Bus dispatch regression suite" + - "Scene-aware dispatch tests" + +related: + - "tests-must-be-parameterized-by-message-kind" + - "comprehensive-test-coverage" + - "single-thread-contract" + - "base-call-contract" + - "leak-watcher-usage" + +status: "stable" +--- + +# Lifecycle Edge-Case Test Coverage + +> **One-line summary**: Every change to the bus dispatch path must be tested +> against the canonical lifecycle edge-case set; new dispatch behavior MUST +> cover scene unload mid-dispatch, DontDestroyOnLoad transitions, prefab +> pooling churn, token disable / re-enable mid-dispatch, post-Reset emit, +> OnApplicationQuit drain, and cross-kind reentrancy. + +## Overview + +The bus-freezing fix surfaced a class of bugs that the existing fixtures did +not exercise: a handler that disables its token mid-dispatch, a handler that +unloads its scene mid-dispatch, and emissions that arrive after +`DxMessagingStaticState.Reset` cleared the global bus. Each one was a real +production bug. The lifecycle edge-case fixtures pin the behavior so a +future dispatch-path change cannot reintroduce the regressions silently. + +This skill documents the scenarios that MUST be covered, where the canonical +fixtures live, and the conventions for adding new entries. + +## Required Scenario Set + +Every change that touches the bus dispatch path (registration, emission, +deregistration, interceptor / post-processor pipeline) is expected to keep +this scenario list green. New dispatch behavior must add scenarios when the +mechanism it introduces is not already covered. + +| Scenario | Pinned by | Notes | +| --------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------- | +| `SceneUnloadMidDispatchDrainsInFlightEmission` | `LifecycleEdgeCasesTests` | Handler triggers `SceneManager.UnloadSceneAsync` from inside body. | +| `SceneTransitionWithDontDestroyOnLoad` | `LifecycleEdgeCasesTests` | DDOL host survives an additive scene unload and keeps receiving. | +| `RegisterDuringSceneLoadCallback` | `LifecycleEdgeCasesTests` | `SceneManager.sceneLoaded` callback registers a handler. | +| `PrefabPoolingEnableDisableCycles` | `LifecycleEdgeCasesTests` (with `LeakWatcher`) | 100-cycle SetActive churn; bus must not leak registrations. | +| `TokenDisableMidDispatch` | `LifecycleEdgeCasesTests` | Snapshot semantics: B still runs after A disables the token. | +| `TokenReEnableMidDispatch` | `LifecycleEdgeCasesTests` | Re-enable mid-dispatch does not retroactively join current emission. | +| `EmitOnEmptyBusIsSilentNoOp` | `LifecycleEdgeCasesTests` | Emit with zero handlers must not throw or perturb counters. | +| `EmitImmediatelyAfterResetIsSilentNoOp` | `LifecycleEdgeCasesTests` | Reset-generation guard: pre-reset handlers must NOT fire. | +| `OnApplicationQuitDrainsCleanly` | `LifecycleEdgeCasesTests` (with `LeakWatcher`) | Quit must not throw and must not leak registrations. | +| `HostDestroyMidDispatchDoesNotCrash` | `LifecycleEdgeCasesTests` | `Object.Destroy(host)` from a handler must not crash dispatch. | +| `CrossKindReentrancyChainCompletes` | `ReentrantEmissionExtendedTests` | All 6 (outer, inner) cross-kind permutations. | +| `DeepRecursion10Levels` | `ReentrantEmissionExtendedTests` | Bounded self-recursion plus `IMessageBus.EmissionId` invariant. | +| `ReentrantUnsubscribeThenResubscribeSelf` | `ReentrantEmissionExtendedTests` | Snapshot semantics for self-modifying handlers. | +| `NestedHandlerThrowsDuringReentrantEmit` | `ReentrantEmissionExtendedTests` | Inner throw aborts outer trailing handlers consistently. | +| `ReentrantInterceptorVeto` | `ReentrantEmissionExtendedTests` | Inner vetoed re-emit; outer trailing handler still runs. | +| `InterceptorMutationDuringReemitObservesFreshState` | `ReentrantEmissionExtendedTests` | Interceptor sees fresh state on re-emit, no carry-over. | + +## Where the Canonical Fixtures Live + +Two fixtures, both parameterized by `MessageScenario`: + +- `Tests/Runtime/Core/LifecycleEdgeCasesTests.cs` -- destruction, scene + loading, token disable / re-enable, post-Reset, OnApplicationQuit, + empty-bus emit. Scene-loading tests carry `[Category("UnityRuntime")]`. +- `Tests/Runtime/Core/ReentrantEmissionExtendedTests.cs` -- cross-kind + reentrancy, deep recursion, self-resubscribe, nested-throw, interceptor + veto, interceptor mutation. Default category (no gating). + +The supporting `LeakWatcher` utility lives at +`Tests/Runtime/TestUtilities/LeakWatcher.cs`. See +`leak-watcher-usage.md` for its public-counter contract and usage rules. + +## Adding a New Edge Case + +When a new dispatch-path change introduces a lifecycle interaction that is +not already covered, extend the appropriate fixture: + +1. Drive the test from + `[ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))]` + so every entry covers all three kinds. The + `tests-must-be-parameterized-by-message-kind` skill is enforced by + `TestAttributeContractTests.TripletEmitTestsUseScenarioParameterization`; + per-kind triplets fail CI. +1. Track every spawned `GameObject` via `_spawned.Add(host)`. The + `MessagingTestBase` cleanup loop relies on this list; the rule is pinned + by `TestAttributeContractTests.FixturesUsingMessagingTestBaseUseSpawnedCleanupPattern`. +1. Gate scene-load / scene-unload tests behind `[Category("UnityRuntime")]`. + These tests yield frames for async ops to settle and add wall-clock to + the run; the suite-wide budget in `SuiteWallClockBudgetTest.cs` skips + the default-suite assertion when a UnityRuntime test is observed. +1. When the test creates and tears down registrations in a small region, + wrap the region in `using (LeakWatcher.Watch(...))`. The watcher reads + every public counter on `IMessageBus`; new counter kinds added to the + bus must extend the watcher (see `leak-watcher-usage.md`). +1. Pick assertion shapes that name the kind under test. The fixtures use + `[{0}] ...` format strings keyed on `scenario.Kind` so a per-kind + regression is easy to triage. Example from + `LifecycleEdgeCasesTests.TokenDisableMidDispatch`: + + ```csharp + Assert.AreEqual( + 1, + bCount, + "[{0}] Snapshot semantics: B must still run on the in-flight emission " + + "even after Disable. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + ``` + +## Common Gotchas + +These bit real pull requests during the bus-freezing fix work; document them +as the failure modes to expect. + +- **Scene-load tests must build transient scenes dynamically.** Do not + assume Build Settings contains a stub scene; the package can be consumed + by a project with no scenes registered. Use + `SceneManager.CreateScene(name)` and unload with + `SceneManager.UnloadSceneAsync(scene)` (yield a frame after `CreateScene` + before moving objects into it). +- **`LogAssert.Expect` must be placed BEFORE the triggering action.** Unity + matches expected logs against the log queue accumulated during the test, + not retroactively; an `Expect` after the throwing emit fails to match. +- **Iteration counts: stay under 100 in the default suite.** + `PrefabPoolingEnableDisableCycles` runs 100 SetActive cycles -- that is + the default ceiling. Heavier counts (1000+) belong behind + `[Category("Stress")]` or `[Category("Allocation")]` so the wall-clock + budget in `SuiteWallClockBudgetTest.cs` does not breach. +- **`LeakWatcher` is the canonical leak-detection mechanism.** Do not + re-implement counter snapshotting inline. If a leak is missed by the + watcher, the cause is a missing counter on `IMessageBus`; the fix is + extending the bus's public surface and the watcher in lock-step (see + `PublicSurfaceContractTests.PublicTypeSetInDxMessagingCoreNamespaceMatchesSnapshot`). +- **`DxMessagingStaticState.Reset` is the only legitimate way to clear the + global bus mid-test.** The post-Reset guard in + `EmitImmediatelyAfterResetIsSilentNoOp` pins that handlers registered + before a Reset cannot fire on emissions issued after it. Direct + manipulation of bus internals from a test bypasses the guard and will + drift if the reset-generation counter is renamed. + +## Why These Scenarios and Not Others + +The set above is not exhaustive; it is the floor. Each entry was added in +response to a real production-side issue: + +- Scene unload mid-dispatch: a user reported a crash when a UI handler + destroyed its parent scene from inside a click callback. +- Prefab pooling churn: a pooled enemy that flickered SetActive false / true + every frame leaked one registration per cycle on a previous version of + the bus. +- Token disable / re-enable mid-dispatch: snapshot semantics were unclear + in user reports; the tests pin the documented contract. +- Post-Reset emit: an in-editor scene-reload sequence was issuing emissions + against a freshly-reset bus and silently corrupting registration state. +- OnApplicationQuit drain: the previous version threw on shutdown when a + handler tried to deregister during its own quit callback. +- Cross-kind reentrancy: an interceptor on the targeted bus emitting + untargeted from inside its callback deadlocked one revision of the + dispatcher. + +When a future regression uncovers a lifecycle interaction not on the list, +add it to the fixture, add the row to the table above, and reference the +diagnosing PR in the test's XML doc. + +## See Also + +- [LeakWatcher: Detecting Registration Leaks in Tests](./leak-watcher-usage.md) +- [Tests Must Be Parameterized by Message Kind](./tests-must-be-parameterized-by-message-kind.md) +- [Test Coverage Requirements](./comprehensive-test-coverage.md) +- [Single Thread Contract](./single-thread-contract.md) +- [MessageAwareComponent Base-Call Contract](../unity/base-call-contract.md) + +## References + +- Unity scene management API: https://docs.unity3d.com/ScriptReference/SceneManagement.SceneManager.html +- NUnit `ValueSource` documentation: https://docs.nunit.org/articles/nunit/writing-tests/attributes/valuesource.html +- Unity Test Framework: https://docs.unity3d.com/Packages/com.unity.test-framework@latest + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | --------------- | +| 1.0.0 | 2026-05-02 | Initial version | diff --git a/.llm/skills/testing/lifecycle-edge-coverage.md.meta b/.llm/skills/testing/lifecycle-edge-coverage.md.meta new file mode 100644 index 00000000..882b7d09 --- /dev/null +++ b/.llm/skills/testing/lifecycle-edge-coverage.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7b3f1d2e8a6c4d5b9e7a3c1f8d2e4b6a +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/.llm/skills/testing/script-test-coverage.md b/.llm/skills/testing/script-test-coverage.md index 79e87021..28e0ceca 100644 --- a/.llm/skills/testing/script-test-coverage.md +++ b/.llm/skills/testing/script-test-coverage.md @@ -85,7 +85,7 @@ JavaScript using Jest, even when testing PowerShell script logic. 1. **Create test files** in `scripts/__tests__/` for each script 1. **Implement equivalent JavaScript functions** to test PowerShell logic 1. **Verify file paths** to catch case sensitivity issues -1. **Test edge cases** and error handling comprehensively +1. **Test edge cases** -- empty input, malformed input, missing files, and unexpected error paths 1. **Use `test()` not `it()`** for all Jest test declarations ## Test Location and Naming @@ -249,7 +249,7 @@ Before merging scripts: ## See Also - [Cross-Platform Compatibility](../scripting/cross-platform-compatibility.md) - Case sensitivity patterns -- [Comprehensive Test Coverage](./comprehensive-test-coverage.md) - General test coverage requirements +- [Test Coverage Requirements](./comprehensive-test-coverage.md) - General test coverage requirements - [PowerShell Best Practices](../scripting/powershell-best-practices.md) - PowerShell scripting patterns ## Changelog diff --git a/.llm/skills/testing/single-thread-contract.md b/.llm/skills/testing/single-thread-contract.md index b660d680..376d8b1e 100644 --- a/.llm/skills/testing/single-thread-contract.md +++ b/.llm/skills/testing/single-thread-contract.md @@ -187,7 +187,7 @@ review. - [Tests Must Be Parameterized by Message Kind](tests-must-be-parameterized-by-message-kind.md) - [Allocation Coverage Required for Dispatch](allocation-coverage-required-for-dispatch.md) -- [Comprehensive Test Coverage Requirements](comprehensive-test-coverage.md) +- [Test Coverage Requirements](comprehensive-test-coverage.md) ## References diff --git a/.llm/skills/testing/test-code-quality.md b/.llm/skills/testing/test-code-quality.md index ceda7d1d..a9d3599b 100644 --- a/.llm/skills/testing/test-code-quality.md +++ b/.llm/skills/testing/test-code-quality.md @@ -239,5 +239,5 @@ Before committing test code, verify: ## See Also -- [Comprehensive Test Coverage](comprehensive-test-coverage.md) +- [Test Coverage Requirements](comprehensive-test-coverage.md) - [Script Test Coverage](script-test-coverage.md) diff --git a/.llm/skills/testing/test-coverage-data-driven.md b/.llm/skills/testing/test-coverage-data-driven.md index 962514e3..917490cd 100644 --- a/.llm/skills/testing/test-coverage-data-driven.md +++ b/.llm/skills/testing/test-coverage-data-driven.md @@ -162,7 +162,7 @@ public sealed class MessageSerializationTests ## See Also -- [Comprehensive Test Coverage](comprehensive-test-coverage.md) +- [Test Coverage Requirements](comprehensive-test-coverage.md) - [Data-Driven Tests](data-driven-tests.md) ## Changelog diff --git a/.llm/skills/testing/test-coverage-organization-assertions.md b/.llm/skills/testing/test-coverage-organization-assertions.md index 2ef49a0a..233d4dff 100644 --- a/.llm/skills/testing/test-coverage-organization-assertions.md +++ b/.llm/skills/testing/test-coverage-organization-assertions.md @@ -163,7 +163,7 @@ CollectionAssert.AreEquivalent( ## See Also -- [Comprehensive Test Coverage](comprehensive-test-coverage.md) +- [Test Coverage Requirements](comprehensive-test-coverage.md) - [Unity Considerations and Anti-Patterns](test-coverage-unity-anti-patterns.md) ## Changelog diff --git a/.llm/skills/testing/test-coverage-scenario-categories.md b/.llm/skills/testing/test-coverage-scenario-categories.md index b3854e6b..757f00b1 100644 --- a/.llm/skills/testing/test-coverage-scenario-categories.md +++ b/.llm/skills/testing/test-coverage-scenario-categories.md @@ -213,7 +213,7 @@ public sealed class MessageBusDefensiveTests ## See Also -- [Comprehensive Test Coverage](comprehensive-test-coverage.md) +- [Test Coverage Requirements](comprehensive-test-coverage.md) - [Data-Driven Coverage Patterns](test-coverage-data-driven.md) ## Changelog diff --git a/.llm/skills/testing/test-coverage-unity-anti-patterns.md b/.llm/skills/testing/test-coverage-unity-anti-patterns.md index 68a7ab99..4a7a57ad 100644 --- a/.llm/skills/testing/test-coverage-unity-anti-patterns.md +++ b/.llm/skills/testing/test-coverage-unity-anti-patterns.md @@ -259,7 +259,7 @@ public void CreateMessageBus() ## See Also -- [Comprehensive Test Coverage](comprehensive-test-coverage.md) +- [Test Coverage Requirements](comprehensive-test-coverage.md) - [Test Organization and Assertions](test-coverage-organization-assertions.md) ## Changelog diff --git a/.llm/skills/testing/test-failure-investigation-procedure.md b/.llm/skills/testing/test-failure-investigation-procedure.md index cc338acb..71d43663 100644 --- a/.llm/skills/testing/test-failure-investigation-procedure.md +++ b/.llm/skills/testing/test-failure-investigation-procedure.md @@ -150,7 +150,7 @@ Categorize the failure: | **Timing Issue** | Test has race conditions | Async operation not awaited | | **Environment Issue** | Test depends on environment | File paths, time zones | -### Step 6: Fix Comprehensively +### Step 6: Fix the Root Cause Fix the actual problem, not the symptom: diff --git a/.llm/skills/testing/test-failure-investigation.md b/.llm/skills/testing/test-failure-investigation.md index 3b2f1fb3..17b9b75a 100644 --- a/.llm/skills/testing/test-failure-investigation.md +++ b/.llm/skills/testing/test-failure-investigation.md @@ -72,7 +72,7 @@ status: "stable" # Test Failure Investigation and Zero-Flaky Policy -> **One-line summary**: Every test failure reveals a real bug - investigate production behavior comprehensively before making any fix. +> **One-line summary**: Every test failure reveals a real bug - investigate the production code path, the test setup, and any shared static state before making any fix. ## Overview @@ -100,7 +100,7 @@ Ignoring or masking test failures leads to unreliable tests, hidden regressions, 1. Understand expected behavior and assertions 1. Inspect production code paths 1. Identify root cause (production vs test) -1. Fix comprehensively and verify repeatedly +1. Fix the production or test root cause and verify the fix holds across repeated runs ## Summary diff --git a/.llm/skills/testing/test-production-code-part-2.md b/.llm/skills/testing/test-production-code-part-2.md index 76f577b9..afec20d6 100644 --- a/.llm/skills/testing/test-production-code-part-2.md +++ b/.llm/skills/testing/test-production-code-part-2.md @@ -56,7 +56,7 @@ Before merging, verify tests exercise production code: ## See Also -- [Comprehensive Test Coverage skill](comprehensive-test-coverage.md) - What to test +- [Test Coverage Requirements](comprehensive-test-coverage.md) - What to test - [Script Test Coverage skill](script-test-coverage.md) - Testing scripts specifically - [Test Code Quality skill](test-code-quality.md) - Test documentation accuracy diff --git a/.llm/skills/testing/tests-must-be-parameterized-by-message-kind.md b/.llm/skills/testing/tests-must-be-parameterized-by-message-kind.md index 32613803..e38992c1 100644 --- a/.llm/skills/testing/tests-must-be-parameterized-by-message-kind.md +++ b/.llm/skills/testing/tests-must-be-parameterized-by-message-kind.md @@ -221,11 +221,13 @@ If a new test triplet sneaks in, CI fails with a pointer back to this skill. ## See Also - [Data-Driven Tests](data-driven-tests.md) -- [Comprehensive Test Coverage Requirements](comprehensive-test-coverage.md) +- [Test Coverage Requirements](comprehensive-test-coverage.md) - [Data-Driven Coverage Patterns](test-coverage-data-driven.md) - [Shared Test Fixtures](shared-test-fixtures.md) - [Allocation Coverage Required for Dispatch](allocation-coverage-required-for-dispatch.md) - [Single Thread Contract](single-thread-contract.md) +- [Lifecycle Edge-Case Test Coverage](lifecycle-edge-coverage.md) +- [LeakWatcher: Detecting Registration Leaks in Tests](leak-watcher-usage.md) ## References diff --git a/.llm/skills/unity/base-call-contract.md b/.llm/skills/unity/base-call-contract.md new file mode 100644 index 00000000..85f6bae3 --- /dev/null +++ b/.llm/skills/unity/base-call-contract.md @@ -0,0 +1,261 @@ +--- +title: "MessageAwareComponent Base-Call Contract" +id: "base-call-contract" +category: "unity" +version: "1.0.0" +created: "2026-05-02" +updated: "2026-05-02" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "Runtime/Unity/MessageAwareComponent.cs" + - path: "SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs" + - path: "Editor/Analyzers/BaseCallTypeScannerCore.cs" + - path: "Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "unity" + - "analyzer" + - "lifecycle" + - "diagnostics" + - "messageawarecomponent" + - "base-call" + - "dxmsg006" + +complexity: + level: "intermediate" + reasoning: "Requires understanding of Unity lifecycle methods, Roslyn analyzers, and IL inspection." + +impact: + performance: + rating: "none" + details: "Contract enforcement runs at compile and edit time; no runtime cost in release builds." + maintainability: + rating: "high" + details: "Catches missing base calls before they ship; the meta-test forces alignment when the contract changes." + testability: + rating: "high" + details: "Five enforcement layers each verify the contract from a different angle." + +prerequisites: + - "Familiarity with Unity MonoBehaviour lifecycle methods" + - "Awareness of Roslyn analyzers" + +dependencies: + packages: [] + skills: + - "ascii-only-docs" + - "code-samples-must-compile" + - "tests-must-be-parameterized-by-message-kind" + +applies_to: + languages: + - "C#" + frameworks: + - "Unity" + versions: + unity: ">=2021.3" + +aliases: + - "Base call contract" + - "DXMSG006 contract" + - "Lifecycle base-call invariant" + +related: + - "ascii-only-docs" + - "code-samples-must-compile" + - "tests-must-be-parameterized-by-message-kind" + +status: "stable" +--- + +# MessageAwareComponent Base-Call Contract + +> **One-line summary**: Subclasses of `MessageAwareComponent` MUST call +> `base.()` from every guarded lifecycle override, or DxMessaging stops +> working on that component; five enforcement layers catch the omission. + +## Overview + +`MessageAwareComponent` is the base type users derive from to plug a +MonoBehaviour into the DxMessaging registration system. It owns a +`MessageRegistrationToken`, creates it during `Awake`, enables/disables it +alongside the component, and releases it on destroy. Each of those steps lives +in a `protected virtual` lifecycle method on the base class. If a subclass +overrides the method without calling `base.()`, the framework work is +silently skipped; the symptom is "messages stop being received" with no +exception. + +The contract: + +- Five guarded methods carry framework work and MUST be chained via `base`: + `Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, `RegisterMessageHandlers`. +- Two additional methods are guarded prospectively for their canonical Unity + one-arg-bool signature: `OnApplicationFocus(bool)` and + `OnApplicationPause(bool)`. `MessageAwareComponent` does not currently declare + these, so the analyzer never actually fires DXMSG006 for them today; the + guard exists so that adding a virtual body to the base class in a future + release immediately gets DXMSG006 / DXMSG010 coverage on existing subclasses + without an analyzer revision. +- One method (`OnApplicationQuit`) is virtual but intentionally empty; missing + the base call there is harmless. It lives on the + `AllowListIntentionallyUnguarded` allow list. +- No other Unity lifecycle method on `MessageAwareComponent` performs framework + work today. A meta-test pins this so adding one without updating the guarded + set, the consequence-text dictionary, the IL scanner, AND the test allow list + fails the build. + +## Guarded Methods and Consequences + +When `base.()` is missed, the per-method consequence determines what +breaks at runtime: + +| Method | Framework work performed by base | Consequence if skipped | +| ------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `Awake` | Creates the `MessageRegistrationToken`; calls `RegisterMessageHandlers`. | Token is never created; handlers cannot register. | +| `OnEnable` | Re-enables the token (subject to `MessageRegistrationTiedToEnableStatus`). | Handlers are not re-enabled when the component is enabled. | +| `OnDisable` | Disables the token (subject to `MessageRegistrationTiedToEnableStatus`). | Handlers are not disabled; the component keeps processing while ostensibly off. | +| `OnDestroy` | Releases the messaging component, disables the token, clears refs. | Handlers are not deregistered; token leaks; memory leak. | +| `RegisterMessageHandlers` | Registers default `StringMessage` / `GlobalStringMessage` handlers. | Default string handlers do not register. | + +To suppress the `RegisterMessageHandlers` warning intentionally, override +`RegisterForStringMessages` to return literal `false` (the analyzer detects +this and lowers the diagnostic to Info; see Smart Case below). + +The diagnostic message text for DXMSG006 is per-method and lives in two +places that MUST stay in sync: + +- `MessageAwareComponentBaseCallAnalyzer.MissingBaseCallMessageFormatsByMethod` + (the Roslyn analyzer; emits the diagnostic message at compile time). +- `BaseCallTypeScannerCore.MissingBaseCallMessageFormatsByMethod` and + `GetMissingBaseConsequenceLine(...)` (the IL scanner; the inspector overlay + HelpBox renders the same per-method text at edit time). + +A meta-test asserts every entry in `GuardedMethodNames` has a matching +consequence row. + +## Enforcement Layers + +The contract is enforced at five layers, each catching the omission from a +different angle: + +1. **Roslyn analyzer (compile time)** - + `MessageAwareComponentBaseCallAnalyzer` emits DXMSG006 (override missing + base call), DXMSG007 (`new`-modifier hide), DXMSG008 (opt-out marker), + DXMSG009 (implicit hide; CS0114 equivalent), and DXMSG010 (transitive + broken chain). These show up as build warnings. +1. **IL scanner (edit time)** - `BaseCallTypeScanner` walks loaded + subclasses via Unity's `TypeCache` and probes IL for missing + `call`/`callvirt` instructions to the base method. Deterministic across + Unity 2021 cache hits where the analyzer console pipe drops warnings. +1. **Inspector overlay** - + `MessageAwareComponentInspectorOverlay` reads the IL scanner's snapshot + and shows a HelpBox above the offending component, listing the missing + method and the per-method consequence sentence. +1. **Runtime self-check breadcrumb** - In `Editor` and `Debug` builds, + `MessageAwareComponent.OnEnable` checks for a null registration token + and emits a one-time `Debug.LogError` per instance pointing at + `docs/reference/analyzers.md`. Catches the case where a user has + disabled the analyzer or opened the project on a Unity version that + does not load the analyzer DLL. +1. **Meta-test invariant (CI)** - + `MessageAwareComponentBaseCallAnalyzerTests.GuardedMethodListMatchesAllVirtualLifecycleMethodsOnPublicBaseClasses` + parses the actual `Runtime/Unity/MessageAwareComponent.cs` source and + fails the build if any virtual lifecycle method with a non-empty body + is missing from `GuardedMethodNames` or + `AllowListIntentionallyUnguarded`. + +## Adding a New Guarded Method + +When the framework grows a new lifecycle method that performs work, every +layer must update together. The meta-test fails until all four are aligned: + +1. Add the method to + `MessageAwareComponentBaseCallAnalyzer.GuardedMethodNames` (the Roslyn + analyzer's guarded set). +1. Add a per-method consequence row to + `MessageAwareComponentBaseCallAnalyzer.MissingBaseCallMessageFormatsByMethod` + describing what breaks at runtime when the base call is missed. +1. Add the method to `BaseCallTypeScannerCore.GuardedMethodNames` AND its + `MissingBaseCallMessageFormatsByMethod`. The IL scanner runs at edit + time; both dictionaries must mirror the analyzer's content. +1. Add tests: a DXMSG006 case (override with body, no `base.X()`), a + DXMSG007 case (`new`-modifier), a DXMSG009 case (implicit hide), and an + assertion that the per-method consequence text appears in the + diagnostic message. + +If the new method is intentionally empty (does no framework work), add it +to `AllowListIntentionallyUnguarded` instead. Document the rationale in a +`///` comment on the method body. + +## Opt-Out Mechanisms + +Three escape hatches exist for users who genuinely want to suppress the +diagnostic on a specific class or method: + +- `[DxIgnoreMissingBaseCall]` attribute - apply at class scope to suppress + for every guarded method on the type, or at method scope to suppress for + one method only. The analyzer emits DXMSG008 (Info) so the suppression is + visible in the build log. +- Project ignore list - `Assets/Editor/DxMessaging.BaseCallIgnore.txt` (or + whatever path `IgnoreListReader.IgnoreFileName` resolves to). One + fully-qualified type name per line. Same DXMSG008 behavior as the + attribute. +- `.editorconfig` severity override - e.g. + `dotnet_diagnostic.DXMSG006.severity = none` to silence the analyzer + globally. Still leaves DXMSG009/010 etc. firing under their own ids. + +The IL scanner respects the attribute and the ignore list; the inspector +overlay reads the ignore list directly to render its "Stop ignoring" +HelpBox. + +## Smart Case: `RegisterForStringMessages => false` + +A subclass that overrides `RegisterForStringMessages` to return literal +`false` is opting out of the default string-message registrations. In that +case, missing the base call on `RegisterMessageHandlers` is a documented +intentional pattern. The analyzer detects this syntactically and lowers +DXMSG006 from Warning to Info on `RegisterMessageHandlers` only. Other +overrides on the same class still emit DXMSG006 at full Warning severity. +The lowering is literal-only; ternaries, `is false` patterns, and switch +expressions that "happen to return false" do not trigger smart case. + +## Why Five Layers and Not One + +Each layer covers a gap the others have: + +- The analyzer can be disabled per project (`` MSBuild + property, `.editorconfig` severity override, or just opting out of the + package). +- The IL scanner runs every domain reload but never blocks the build; a + user who ignores the inspector HelpBox can still ship. +- The runtime breadcrumb fires only after the failure has already occurred + on a live component; useful for catching "the analyzer didn't run" but + late. +- The meta-test catches the inverse case: the contract drifts inside the + package itself when a contributor adds a new lifecycle method. + +The cost of each layer is low (no runtime overhead in release builds; one +NUnit test; a per-domain-reload reflection scan), so we keep all five. + +## See Also + +- [ASCII-Only Documentation Policy](../documentation/ascii-only-docs.md) +- [Code Samples Must Compile](../documentation/code-samples-must-compile.md) +- [Tests Must Be Parameterized by Message Kind](../testing/tests-must-be-parameterized-by-message-kind.md) +- [Lifecycle Edge-Case Test Coverage](../testing/lifecycle-edge-coverage.md) +- [LeakWatcher: Detecting Registration Leaks in Tests](../testing/leak-watcher-usage.md) +- [Inspector Overlay Invariants](../testing/inspector-overlay-invariants.md) + +## References + +- DxMessaging analyzer reference (DXMSG006-DXMSG010): `docs/reference/analyzers.md` +- Unity MonoBehaviour event reference: https://docs.unity3d.com/ScriptReference/MonoBehaviour.html + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | --------------- | +| 1.0.0 | 2026-05-02 | Initial version | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fe3f3137..059cc448 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -106,6 +106,15 @@ repos: stages: - pre-commit description: Enforce the doc-sample pattern catalog (e.g. no "new X().Emit()"). See .llm/skills/documentation/code-samples-must-compile.md. + - id: validate-docs-prose + name: Validate docs prose against the human-prose policy + entry: node scripts/validate-docs-prose.js + language: system + files: '\.(md|cs)$' + pass_filenames: false + stages: + - pre-commit + description: Enforce the human-prose policy across .md files and /// XML doc comments. See .llm/skills/documentation/human-prose-policy.md. - repo: https://github.com/adrienverge/yamllint rev: v1.38.0 diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 00000000..ea593dca --- /dev/null +++ b/.vale.ini @@ -0,0 +1,45 @@ +; Vale configuration for the DxMessaging documentation surface. +; +; The custom JS validator scripts/validate-docs-prose.js is the authoritative +; source for the banned-phrase list. Vale runs alongside it in CI to catch +; structural prose problems (passive voice, sentence complexity, capitalization) +; that regex cannot easily express. +; +; See .llm/skills/documentation/human-prose-policy.md for the policy. + +StylesPath = .vale/styles + +Packages = write-good + +MinAlertLevel = warning + +Vocab = DxMessaging + +[*.md] +BasedOnStyles = Vale, DxMessaging, write-good + +; Exclude inline code spans, fenced code blocks, and HTML attributes from +; prose scanning. Vale's TokenIgnores and BlockIgnores accept regular +; expressions delimited with parentheses. +TokenIgnores = (`[^`]+`) +BlockIgnores = (?s)(```.*?```) + +; The DxMessaging skill files describe the policy itself, so the marketing, +; hedge, and filler rules cannot reasonably apply. Capitalization rules are +; still enforced. +[.llm/skills/documentation/*.md] +BasedOnStyles = DxMessaging +DxMessaging.Marketing = NO +DxMessaging.Hedges = NO +DxMessaging.LLMFiller = NO + +; CHANGELOG.md legitimately uses "comprehensive" for release notes, so the +; marketing rule is muted there. +[CHANGELOG.md] +DxMessaging.Marketing = NO + +; Scan only XML doc comments inside .cs sources. Anything that is not a /// +; line is treated as a block to ignore. +[*.cs] +BasedOnStyles = DxMessaging +BlockIgnores = (?m)^[^/].*$, (?m)^/[^/].*$, (?m)^//[^/].*$ diff --git a/.vale/styles/DxMessaging/Capitalization.yml b/.vale/styles/DxMessaging/Capitalization.yml new file mode 100644 index 00000000..dfe4d697 --- /dev/null +++ b/.vale/styles/DxMessaging/Capitalization.yml @@ -0,0 +1,25 @@ +extends: substitution +message: "Use the canonical capitalization '%s' instead of '%s'." +level: warning +ignorecase: false +nonword: true +swap: + '\b(?:Github|github)\b': GitHub + '\bcsharp\b': C# + '\bMonobehaviour\b': MonoBehaviour + '\bMonobehavior\b': MonoBehaviour + '\bMonoBehavior\b': MonoBehaviour + '\bunity\b': Unity + '\bdxmessaging\b': DxMessaging + '\bDXMessaging\b': DxMessaging + '\bDxmessaging\b': DxMessaging + '\bopenupm\b': OpenUPM + '\bOpenUpm\b': OpenUPM + '\bvcontainer\b': VContainer + '\bVcontainer\b': VContainer + '\bzenject\b': Zenject + '\breflex\b': Reflex + '\broslyn\b': Roslyn + '\bnunit\b': NUnit + '\bNunit\b': NUnit + '\bmarkdown\b': Markdown diff --git a/.vale/styles/DxMessaging/Hedges.yml b/.vale/styles/DxMessaging/Hedges.yml new file mode 100644 index 00000000..f8ee5812 --- /dev/null +++ b/.vale/styles/DxMessaging/Hedges.yml @@ -0,0 +1,16 @@ +extends: substitution +message: "Hedge transition '%s'; lead with the fact (%s)." +level: warning +ignorecase: true +nonword: true +swap: + '\bFurthermore\b,?': "remove the transition; lead with the new fact" + '\bMoreover\b,?': "remove the transition; lead with the new fact" + '\bIn conclusion\b,?': "remove or replace with a concrete summary" + '\bIn essence\b,?': "remove and state the point directly" + '\bIn summary\b,?': "remove or replace with a concrete summary" + "It's important to note": "state the fact directly" + "It's worth noting": "state the fact directly" + '\bThat said\b,?': "remove or replace with the contrast directly" + '\bOverall\b,?': "remove or replace with a concrete summary" + '\bUltimately\b,?': "remove or replace with the actual conclusion" diff --git a/.vale/styles/DxMessaging/LLMFiller.yml b/.vale/styles/DxMessaging/LLMFiller.yml new file mode 100644 index 00000000..eba21448 --- /dev/null +++ b/.vale/styles/DxMessaging/LLMFiller.yml @@ -0,0 +1,22 @@ +extends: substitution +message: "LLM-signature filler '%s'; rewrite with concrete language (%s)." +level: error +ignorecase: true +nonword: false +swap: + delve into: "discuss or describe" + delving into: "discussing or describing" + delved into: "discussed or described" + delves into: "discusses or describes" + harness the power: "use" + navigate the complexities: "handle the details of" + unlock the potential: "use the feature to" + tapestry: "remove" + realm of: "remove or replace with the topic name" + dive deep into: "explain in detail" + dive into: "explain or cover" + at the heart of: "the core of" + lies the: "is the" + treasure trove: "set or collection" + it goes without saying: "remove" + needless to say: "remove" diff --git a/.vale/styles/DxMessaging/Marketing.yml b/.vale/styles/DxMessaging/Marketing.yml new file mode 100644 index 00000000..2ff674b3 --- /dev/null +++ b/.vale/styles/DxMessaging/Marketing.yml @@ -0,0 +1,28 @@ +extends: substitution +message: "Marketing adjective: replace '%s' with a concrete claim (%s)." +level: warning +ignorecase: true +nonword: false +swap: + cutting-edge: "modern, current, or describe the specific feature" + cutting edge: "modern, current, or describe the specific feature" + blazing fast: "give a measurement or remove" + seamless: "describe what is integrated and how" + seamlessly: "describe the integration concretely" + seamlessness: "describe what is integrated and how" + powerful: "describe the capability concretely" + powerfully: "describe the capability concretely" + robust: "describe the failure modes it handles" + robustly: "describe the failure modes it handles" + elegant: "describe the design choice" + elegantly: "describe the design choice" + world-class: "describe the actual quality" + next-generation: "describe the version or capability" + industry-leading: "remove or cite a benchmark" + state-of-the-art: "describe the current technique" + comprehensive: "list what is covered" + comprehensively: "list what is covered" + unparalleled: "remove or cite a comparison" + revolutionary: "describe the change" + game-changing: "describe the impact" + best-in-class: "remove or cite a benchmark" diff --git a/.vale/styles/Vocab/DxMessaging/accept.txt b/.vale/styles/Vocab/DxMessaging/accept.txt new file mode 100644 index 00000000..03a78bd0 --- /dev/null +++ b/.vale/styles/Vocab/DxMessaging/accept.txt @@ -0,0 +1,64 @@ +DxMessaging +MessageAwareComponent +MessagingComponent +MessageBus +MessageRegistrationToken +MessageRegistrationHandle +InstanceId +RegisterMessageHandler +RegisterGameObjectTargeted +RegisterGlobalAcceptAll +IMessageRegistrationBuilder +IUntargetedMessage +ITargetedMessage +IBroadcastMessage +DxIgnoreMissingBaseCall +DocsSnippetCompilationTests +EmitTargeted +EmitUntargeted +EmitBroadcast +untargeted +broadcast +asmdef +asmref +csharpier +markdownlint +yamllint +cspell +markdownlint-cli2 +preflight +OpenUPM +VContainer +Zenject +Reflex +Roslyn +NUnit +MonoBehaviour +DxMessagingAnalyzer +DxAnalyzer +PostMessageReceived +PreMessageReceived +postcondition +precondition +gameObject +GameObject +LLM +LLMs +TestCaseSource +SerializeField +ScriptableObject +ScriptableObjects +GFM +yaml +markdownlint-cli2 +runtime +dxmessaging +wallstop +WallstopStudios +SourceGenerator +SourceGenerators +mkdocs +MkDocs +ASCII +ASCII-only +nofilter diff --git a/.vale/styles/Vocab/DxMessaging/reject.txt b/.vale/styles/Vocab/DxMessaging/reject.txt new file mode 100644 index 00000000..e329a924 --- /dev/null +++ b/.vale/styles/Vocab/DxMessaging/reject.txt @@ -0,0 +1,40 @@ +Github +github +DxMesaging +Dxmessaging +DXMessaging +Monobehaviour +Monobehavior +MonoBehavior +Vcontainer +NotificiationCenter +overhead-free +overheadless +OpenUpm +openupm +NotIfication +recieve +recieved +recieving +seperate +seperator +seperately +occured +occurence +occurances +neccessary +neccesary +priviledge +priviledged +priviledges +referer +sucessfully +sucess +sucessful +suceed +suceeded +sucessful +publically +calender +publicly available +publically available diff --git a/AGENTS.md b/AGENTS.md index c4ba59d6..3e51c91a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,3 @@ # Repository Guidelines See the [AI Agent Guidelines](./.llm/context.md) for all AI agent guidelines. - -Three project-wide rules: - -- Documentation must be pure ASCII (see [ASCII-only documentation guideline](./.llm/skills/documentation/ascii-only-docs.md)). -- Code samples must compile (see [Code samples must compile guideline](./.llm/skills/documentation/code-samples-must-compile.md)). -- For user-visible code changes (`Runtime/`, `Samples~/`, user-facing `Editor/`, `SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/`, or `SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/`), run `npm run validate:changelog:coverage` and rewrite `W002` entries around user impact before finishing. diff --git a/CHANGELOG.md b/CHANGELOG.md index 445077dd..8ac58512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- New Roslyn base-call analyzer (`MessageAwareComponentBaseCallAnalyzer`) that flags `MessageAwareComponent` subclasses whose lifecycle overrides forget to invoke `base.Awake()`, `base.OnEnable()`, `base.OnDisable()`, `base.OnDestroy()`, or `base.RegisterMessageHandlers()`. Introduces diagnostics `DXMSG006` (missing base call), `DXMSG007` (lifecycle method hidden with `new`), `DXMSG008` (opt-out marker), `DXMSG009` (method implicitly hides a lifecycle method without `override`/`new`), and `DXMSG010` (`base.{method}()` chains into an override that does not reach `MessageAwareComponent`). Severity is tunable per project via `.editorconfig` (e.g. `dotnet_diagnostic.DXMSG006.severity = error`). Ships as a separate `WallstopStudios.DxMessaging.Analyzer.dll` deployed alongside the existing source-generator DLL by `SetupCscRsp` so it loads under both Unity 2021's Roslyn 3.8 analyzer host and newer Unity versions. Diagnostic help links now open the current analyzer reference page in the DxMessaging repository. +- New Roslyn base-call analyzer (`MessageAwareComponentBaseCallAnalyzer`) that flags `MessageAwareComponent` subclasses whose lifecycle overrides forget to invoke `base.Awake()`, `base.OnEnable()`, `base.OnDisable()`, `base.OnDestroy()`, or `base.RegisterMessageHandlers()`. Introduces diagnostics `DXMSG006` (missing base call), `DXMSG007` (lifecycle method hidden with `new`), `DXMSG008` (opt-out marker), `DXMSG009` (method implicitly hides a lifecycle method without `override`/`new`), and `DXMSG010` (`base.{method}()` chains into an override that does not reach `MessageAwareComponent`). DXMSG006's diagnostic message is now per-method: each guarded method emits a sentence describing the runtime consequence (registration token never created, handlers not re-enabled, memory leak, etc.) so users immediately see what breaks. The inspector overlay HelpBox renders the same per-method sentences. Severity is tunable per project via `.editorconfig` (e.g. `dotnet_diagnostic.DXMSG006.severity = error`). Ships as a separate `WallstopStudios.DxMessaging.Analyzer.dll` deployed alongside the existing source-generator DLL by `SetupCscRsp` so it loads under both Unity 2021's Roslyn 3.8 analyzer host and newer Unity versions. Diagnostic help links now open the current analyzer reference page in the DxMessaging repository. +- Runtime self-check breadcrumb on `MessageAwareComponent`: `OnEnable` now logs a one-time `Debug.LogError` per instance when the registration token is null, with a link to the DXMSG006 reference. Gated on `UNITY_EDITOR || DEBUG`, so release builds pay no cost. Catches the case where the analyzer DLL was disabled or did not run, surfacing the silent failure as a loud editor error instead. - New public `[DxIgnoreMissingBaseCall]` attribute (`DxMessaging.Core.Attributes`) for source-level opt-out of the base-call analyzer. Applied to a class, every guarded lifecycle method on that class is exempt; applied to a single method, only that method is exempt. The analyzer still emits an Info-level `DXMSG008` at the suppression site so opt-outs remain auditable, and the inspector overlay's snapshot honours the same scoping (method-level suppresses only the annotated method, type-level opts out the entire type). Not inherited -- derived classes must opt out explicitly. - New inspector overlay (`MessageAwareComponentInspectorOverlay`) for every `MessageAwareComponent` subclass: missing-base-call warnings reported by the analyzer or harvested from the Unity console are surfaced as a HelpBox in the inspector header without clobbering user-defined `[CustomEditor]`s (the overlay hooks `Editor.finishedDefaultHeaderGUI`). The overlay restores the previous session's report immediately on Unity Editor startup (loaded from `Library/DxMessaging/baseCallReport.json`) instead of waiting for the first post-reload scan to complete; the HelpBox is annotated `(cached from previous session -- refreshing...)` until the first scan refreshes it. A companion fallback editor (`MessageAwareComponentFallbackEditor`) hosts the overlay for subclasses with no other custom editor and renders the body via `DrawDefaultInspector()` so subclasses with no serialized fields no longer leave an empty vertical gap below the inspector header. - New DxMessaging project-wide settings asset (`DxMessagingSettings`, stored at `Assets/Editor/DxMessagingSettings.asset`) accessible from Unity's Project Settings. Controls diagnostics targets applied to `IMessageBus.GlobalDiagnosticsTargets`, the editor message buffer size, the domain-reload warning suppression, the base-call analyzer toggle, the project-wide base-call ignore list, and the optional Unity console bridge that feeds the inspector overlay. @@ -22,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `[Category("Stress")]`, `[Category("Performance")]`, and `[Category("Allocation")]` tagging across the suite to enable filtered runs and a default-suite speed budget under 60 seconds. - `SuiteSpeedBudgetTest` as a default-suite speed guard rail. - `TestAttributeContractTests` extensions enforcing kind-parameterization and allocation coverage discipline. +- Three new public read-only registration counters on `IMessageBus`: `RegisteredInterceptors`, `RegisteredPostProcessors`, and `RegisteredGlobalAcceptAll`. Lets diagnostic and leak-check tooling distinguish interceptor / post-processor / global accept-all leaks from regular handler leaks, and lets external monitors aggregate the bus's registration footprint without reflecting on internals. `MessageBus` aggregates the counters on each read by walking the per-message-type caches; consumers polling these properties in tight loops should snapshot at region boundaries. ### Fixed @@ -34,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Mutation tests now exercise every messaging kind (Untargeted/Targeted/Broadcast) via a single parameterized fixture (`[ValueSource(MessageScenarios.AllKinds)]`) across `MutationDuringEmissionTests`, `MutationInterceptorTests`, and `MutationDestructionTests`. Users get tighter cross-kind parity guarantees; no runtime API change. (~720 lines of duplication removed; test count preserved.) - `MessagingTestBase` now reseeds a deterministic `Random` with a logged seed (env var `DXMESSAGING_TEST_SEED` to override), polls a generous 1.5-second timeout for handler cleanup, drains the prior test's deferred destroy queue before resetting `DxMessagingStaticState` in `[UnitySetUp]`, and asserts the bus returns to a fresh state at the end of `[UnityTearDown]`. - Renamed `UntargetedTests`, `TargetedTests`, `BroadcastTests` to `EmitUntargetedSpecificTests`, `EmitTargetedSpecificTests`, `EmitBroadcastSpecificTests` to clarify that kind-common tests live in `EmitTests` and kind-specific tests live in the renamed files. (Test-suite hardening is test-only; no `Runtime/` behavior was modified.) +- Documentation now warns up front that `MessageAwareComponent` subclasses must call `base.Awake()`, `base.OnEnable()`, `base.OnDisable()`, `base.OnDestroy()`, and `base.RegisterMessageHandlers()` from any override; admonitions added to the Quick Start, Getting Started Guide, Visual Guide, README, FAQ, and Troubleshooting pages all link to [`DXMSG006`](docs/reference/analyzers.md#dxmsg006-missing-base-call) (issue #195). ## [2.2.0] diff --git a/CLAUDE.md b/CLAUDE.md index 1993d2a7..462f5895 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,3 @@ # Claude Configuration See the [AI Agent Guidelines](./.llm/context.md) for all AI agent guidelines. - -Four project-wide rules: - -- Documentation must be pure ASCII (see [ASCII-only documentation guideline](./.llm/skills/documentation/ascii-only-docs.md)). -- Code samples must compile (see [Code samples must compile guideline](./.llm/skills/documentation/code-samples-must-compile.md)). -- For user-visible code changes (`Runtime/`, `Samples~/`, user-facing `Editor/`, `SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/`, or `SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/`), run `npm run validate:changelog:coverage` and rewrite `W002` entries around user impact before finishing. -- Tests for message dispatch must be parameterized over MessageKind (see [Tests Must Be Parameterized by Message Kind](./.llm/skills/testing/tests-must-be-parameterized-by-message-kind.md)). diff --git a/Editor/Analyzers/BaseCallTypeScannerCore.cs b/Editor/Analyzers/BaseCallTypeScannerCore.cs index 8163bcc8..7e2fcab5 100644 --- a/Editor/Analyzers/BaseCallTypeScannerCore.cs +++ b/Editor/Analyzers/BaseCallTypeScannerCore.cs @@ -38,9 +38,14 @@ namespace DxMessaging.Editor.Analyzers public static class BaseCallTypeScannerCore { /// - /// The five guarded lifecycle methods on MessageAwareComponent. Method names are - /// matched ordinally; only zero-parameter, void-returning, instance methods are - /// considered. + /// The guarded lifecycle methods on MessageAwareComponent. Method names are + /// matched ordinally. Most are zero-parameter, void-returning instance methods; + /// OnApplicationFocus and OnApplicationPause are guarded prospectively + /// for their canonical Unity (bool) signature even though the base class does + /// not currently declare them. See + /// MessageAwareComponentBaseCallAnalyzer.GuardedMethodNames / + /// GuardedMethodsWithBoolSignature; the two sets MUST stay in sync (a meta-test + /// in the dotnet-test project asserts set-equality). /// public static readonly string[] GuardedMethodNames = { @@ -48,9 +53,98 @@ public static class BaseCallTypeScannerCore "OnEnable", "OnDisable", "OnDestroy", + "OnApplicationFocus", + "OnApplicationPause", "RegisterMessageHandlers", }; + /// + /// Guarded methods whose canonical Unity signature takes a single bool + /// (OnApplicationFocus(bool focused), OnApplicationPause(bool paused)). + /// All other guarded methods are zero-argument. + /// + public static readonly HashSet GuardedMethodsWithBoolSignature = new( + StringComparer.Ordinal + ) + { + "OnApplicationFocus", + "OnApplicationPause", + }; + + /// + /// Per-method consequence text shown by the inspector overlay HelpBox when an override + /// is missing its base call. Mirrors + /// MessageAwareComponentBaseCallAnalyzer.MissingBaseCallMessageFormatsByMethod + /// keyed by method name. The two dictionaries MUST stay in sync; the inspector overlay + /// already calls per missing-method row, and + /// the meta-test on the analyzer side keeps the analyzer's dictionary populated for every + /// guarded method, but a future contributor adding a new guarded method MUST update both. + /// ASCII-only by policy. + /// + public static readonly IReadOnlyDictionary< + string, + string + > MissingBaseCallMessageFormatsByMethod = new Dictionary( + StringComparer.Ordinal + ) + { + { + "Awake", + "'{0}' overrides MessageAwareComponent.Awake but does not call base.Awake(); the message registration token will never be created and handlers cannot register." + }, + { + "OnEnable", + "'{0}' overrides MessageAwareComponent.OnEnable but does not call base.OnEnable(); handlers will not be re-enabled when this component is enabled." + }, + { + "OnDisable", + "'{0}' overrides MessageAwareComponent.OnDisable but does not call base.OnDisable(); handlers will not be disabled when this component is disabled, causing unwanted message processing." + }, + { + "OnDestroy", + "'{0}' overrides MessageAwareComponent.OnDestroy but does not call base.OnDestroy(); handlers will not be deregistered and the registration token will not be released, causing a memory leak." + }, + { + "RegisterMessageHandlers", + "'{0}' overrides MessageAwareComponent.RegisterMessageHandlers but does not call base.RegisterMessageHandlers(); default string-message handlers will not be registered (override RegisterForStringMessages to suppress this warning)." + }, + // Prospective entries. MessageAwareComponent does not currently declare these + // methods; entries exist so future changes immediately surface actionable + // consequence text. Keep aligned with the analyzer's dictionary. + { + "OnApplicationFocus", + "'{0}' overrides MessageAwareComponent.OnApplicationFocus but does not call base.OnApplicationFocus(); the messaging system may not function correctly on this component when focus changes." + }, + { + "OnApplicationPause", + "'{0}' overrides MessageAwareComponent.OnApplicationPause but does not call base.OnApplicationPause(); the messaging system may not function correctly on this component when the application pauses." + }, + }; + + private const string GenericMissingBaseCallMessageFormat = + "'{0}' overrides MessageAwareComponent.{1} but does not call base.{1}(); the messaging system may not function correctly on this component."; + + /// + /// Returns the per-method consequence sentence for a given guarded method name, formatted + /// against the supplied type display string. Falls back to the generic format if the + /// method is unknown so that inserting a new guarded method does not blank the overlay. + /// + public static string GetMissingBaseConsequenceLine(string methodName, string typeDisplay) + { + string format = MissingBaseCallMessageFormatsByMethod.TryGetValue( + methodName ?? string.Empty, + out string perMethodFormat + ) + ? perMethodFormat + : GenericMissingBaseCallMessageFormat; + return string.Format( + System.Globalization.CultureInfo.InvariantCulture, + format, + typeDisplay ?? string.Empty, + methodName ?? string.Empty + ); + } + private const string IgnoreAttributeFullName = "DxMessaging.Core.Attributes.DxIgnoreMissingBaseCallAttribute"; @@ -253,7 +347,7 @@ HashSet methodLevelIgnore // independent; we only record the FIRST classification for the leaf in // entry.MissingBaseFor since the overlay HelpBox shows one row per method per type. - MethodInfo declared = GetDeclaredZeroArgInstance(concrete, methodName); + MethodInfo declared = GetDeclaredInstance(concrete, methodName); if (declared == null) { // Type does not declare this method at all; nothing to flag at this level. @@ -353,6 +447,37 @@ private static MethodInfo GetDeclaredZeroArgInstance(Type type, string methodNam ); } + /// + /// Resolves the declared instance method for a guarded name. Most guarded methods are + /// zero-arg (and we look up via ), but the + /// canonical Unity signature for OnApplicationFocus / OnApplicationPause + /// takes a single bool; for those names we try the zero-arg lookup first + /// (defensive against an unusual subclass that declared a zero-arg variant) and fall + /// back to the bool variant. + /// + private static MethodInfo GetDeclaredInstance(Type type, string methodName) + { + MethodInfo zeroArg = GetDeclaredZeroArgInstance(type, methodName); + if (zeroArg != null) + { + return zeroArg; + } + if (!GuardedMethodsWithBoolSignature.Contains(methodName)) + { + return null; + } + return type.GetMethod( + methodName, + BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Instance + | BindingFlags.DeclaredOnly, + null, + new[] { typeof(bool) }, + null + ); + } + private static bool BaseHasSameNamedVirtual(Type baseType, string methodName) { while (baseType != null && baseType != typeof(object)) diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll index c9cc83d4149b2f1c6df941717e8562b6414ade30..5beed50067dc6a20f6542b5c43798a18cbc4ac8f 100644 GIT binary patch delta 9033 zcmbVR4PaE&l|J{qH}B1R^OyG~nMnvg6Cm)CnPC11L_qofDbNBTB1MED1c(F%CJBfr zZ?b3=g;x5i6)Pb&y8)BHHVbC7%oxnE>V8A1caRFUtxW6*e-oHo zR0r^VCqSq?$5(9FPDT|qGajs+LIDnV0eT7uq*sBjcnZG03d8ypW)j9|kyT_*<_nXyMIr13kgB-j6F*V&ZOntfOUv37Mr^ZHTTQPj^b0X8>RlJD+I70UO0-*s}nYI8~gxfuWJ}a2Tp?&&D*CnFADh(U2Ju$NKShy|Ss(yDPhD zSJR&w4WCP{;qw*kyt3BUT~iT?@sFqI^a&J6on;17Q?V^7@aj~RTw~kvVl$8#ha}{) z*T+~sq0#VF8IMM`;W{dsWoG6=(W%n1_aOA7WSY?e`;%aq1!h>YQRYU}(z1^++F;KE zk(#30VdOqFE_({R>}vqivT<5Qyf_}uzRLwURhYe1!cwHLu+Uz>t;8Z^#=Nuqkm|zX zkJgPsH|psU?z&9Zqv_zfn^L9;wvA*;%`C*EUqQGN{e?@#vRd_0tkP3Nt)N$N#b(F{ zEM}v_!9cY|_9DzO=#)m#pGx@q*#a}0mTAKP8<*TjN%2a^FLsamwuQ3(lX09xddb?p=FkHfixg!NunpodBaLP^jb{KtZ)bEV?r$u z=vWCbq)e-u*5k!I#-P6n+xK_ReNZoKScS!qCEKepat~us1=Jc)Q6J*-*=?9w+Qp(6 zyfC%QZ6LcwxIfiPowb-&eGfO^r)cwIm0G414ET+EpJ~VR`gxZwU5Z!o%zq6D#FNC7 zjIsMMy++<4pEWP%>iTXjmHinQnRQs=5wGXglg&te2D7eu#PE|?C9H0QcGUqj)8w8q1~};X{5c@S@F2)y zg~z33Ixw>fpbjn`c?MrE40`iS7Z_31QteHA;!6&MC!6_HGquEVGCfB&0?`C@Z;zQ`6Mtqa*H_RNCYnm-PEP2SZr_DTd=&KhFk|*MaJ%rF zVQ9y~s_D--?vX%=gC_%m!NDYIdflju;b43;5qahxsdN{fC1|GGaLx)^={DYk4wM&* z&U>J|M0B$Q9B=161E0T?)GGI$5Rq|{N+BHp>%0?7)V{~PATCmt@7DQ=w zs~880=5WAPVJF9*ti`=bo?P6M^Va!;E4v?hw#LN^K@Qr5Cqij?Oze?8iM5Wq;m1M2 zjb}^2$SP_du2WiGoUP*Q3at;jw7euciE|(I_u~!_{$=AvKW=H3dzbxHpdWXj@HM+M z)R$|+OT$LZCv&`KW*yP^c%u_NQ?$V1xYzG&ysRWU?u9~*XFvPvFr?n+aC-#6yCoNO z3y^^;qWple(@hB6%;EbSxM&=rQ>}iY$cm$6{~OG#9tGUX!84nkSA_XD;{D|LlPh~$ zF&6IRP*!q9{Eh*bKQO4DIDg_)j_N?fY7TEFO66e5zK>VM9UX{AEtl^aOb&lJg3D^h z4y+V++|Sp0<)%wp2Sd+(08HK$Rak=$YA(^&TCJQo0EWGjtAcpEFY%?z1kvQCPfTXS zh<}3kZmJ(u?fyDcFkxYUXc(@^ag2qix?xS7*|znoajWqPix-(;XciS%l6)I0C|DA%sdeTiWSv43|3aS8NIQ`9nrX5k2Ym+)!zzb}*>Y zqXKVKn}ZR01HP$rm--t`rEYDqq0$+Z$efkKMSjfU!@bYhgtMl=vs6LsM1fsF=&;{wdM=?$1%64*yJ*eLh^lq z%|L_Rpe3Q-(lH0oSTtS;O%`}e2yFtYbdY$W>W~kWh6Z?6eii0fIfBhpvi+R@h{5r= z%<*;14Vs87TJ)B5%rIyloVI9x=$K*A3&QdnV*9nopGwDM=STeHgzrJ&^$Z`6;KeY< zVG0*IWE4W?f-tR!9K2?7{@3NFU^*>~+QQwx4m<^|8j<7*ShJ{5gtS25{?L+8n7U0K zzm0h-hk@E9jsxTgdJVl1m<~(@IZhU63H&4YW%P_RTW_FEY@r@Q4sJ%~ z4x4S5zpJkk{LPrBXe;n$fg0PY&r9%}ex~?%K;=4@DMk*X)q-@XMf!}+=%Ao>)7!q! z=raAf40^Pi?pHpq`=|m>VNk^{1ipxEM+@o&{{z7z?+uF-=d zJS%#nNB7fD^3-kWv%2n3^+<2=b^Qct_qjY(7kN<+(id!R?P*_+9-(;^9`&g2WgS(3 zmt5}JF8`~#Necz#MQG7JZ%sIA(NifQnWw%js9s6u1wn2{yZEJU(VKl1qx5!u?QS}c z=g%cUdC^BHP{oaUW}*}o)OKp1_aK?bQ!79fQdOQZ*?T(gyNu3;_`f2!XvoazOVDKW zEk+;UMXwi|@W#R@%|&UV%+)raOEbCzNj~Rwzfow_3&~cKuYO<19~SeiV&0279wLlP zph_h`$Dj%f1ym!jQQ#zjvjol;xL9CXU>gvP8CZlXcRWx^8wDpwVaujM%qQZ?oz!lk z;q*-{gSY2y|L3Vh@&z8DJ$Uy$g7SPS@GYvR=19h&qqI{}=_P9PpQZ|FcjzU|&qRJo zXUGnnrLz!yi_Ssxx3q&|LZ(UnFG$|5bNLzm3&3-s_kh>=F9H|%Wj3DHNG3Z+J0o${ zD81=Zu^FF_r*m>CtB}r{+ydNXPKER}Gsy;u2+CMCRKv>HHuSrxB91avVN7Bb>{0n% zR>Pu!U2HLXE^-===SM;&_>cKr;9dG2mX==kf0?zhGT&jgQMwp!gx}|?GpJ$Ei zkOWuQd4a+3t85{0*B3gpM*mNiW{U3*Y_~MU_dYiGkxo(@8{$```RpBENUC68XQjZC zp`p@#$qkPL8cLOP3q7yhARU!%_sx(hq*uaofxAPCq!ZGG@KT|>Qrazr;Ls$t+Bgj7 zYC~U>R*K}e^W@V;<}42%X6+(5huHbxt5UZ#z;|Bigiu(%QwVjlXY?|8M;@msBjvLa z&%`;2r}VtUA`9fZ5$kffk@@7cavS?@aHG7FE}3z*Q*8DHVP_AkH6M__AojXj9MF5x zJ5K0X`7t4MPzbfLO>&$a6hcSY9@D2hBQ|F8)<{6n>c62hGXKi|p?3OyxOBM=sEFBFATiof7#S-wH)X*`ofXcYIEl!iT?7zKaMRRx0H0 zm|s?ElfA_nq-Uk)C7)a@FOs*)+vSt;X?dV>ozkRyR(W-WqU3nT z>ie0AcOt0|L-EZ(=i~q0iAIKG}cW=p%07e>rL*ue#wGHlm3lSgqx&pxOtN1!uQz zxhdV=)jBWTmhHKJK!gpR)ZU)iJg2RrbJqIK*7cojnf2-R_ASlpS9hheD_h+!mOeOS zZd*qOUfL(8J6fk;ac*lzM|ySZ^h|bLy0c^QmY&4GA2NC2mJRN?L1XJyw{|XT+R)n4 zmTm{Dl{z`h5XM%{ZS7o>S=q6ut#i%fOs2i5ZT0$eXIHjo_~1E`yfC|^XG7wBy>MY> z*1C0Fo#_?rty9}tghyF-P32@Z!TrUQV)uqA!`($tAD1MYNS3w5Dd=p&@&L4(V*;-|)+w>2L!ZLmz;6GqZjl+0Z;5?3YT zZb;r}PNK~)vKA&1v;u;i?%y}Y-9OY@`sEe(H_1T`U(Z5nHB5EDShkO|3D6l~V?Crc zL!upj5(uM}HsPBUwz-8CVS|U+N-II9A;ZTt`B}S=?hulEZ9VLEg31G5c{_N%V^$!B5M-I}b=XMyYR;>pk!Sn5}B`7HF6 z`2S>qS4aO^L8-YL>!VIz{>LldtE+(5R~ixU;=NYx(uj#?YaQatiU>W8wY*-X?tkLt z;5rSXw5vkrJf}CLEha%NXf}}?b8caTe zncWrFX>lvJCv4^RTDkp__W>b=!FnRh@sOE2Qe>u?l{*T;mmn+mG7wxo@2*lXUmPNt zo9M2xx<{Lfh0uO3`VuTkezC(W zZm4^%F#3d=U}39!zS!@OmD>d`2Mq)h?j3?B)?B=kVBMc!K(pIv){3m$4us=RNK%vv zqD%-Z)=7b#rQv}CeI#KSKh3S~bhrjTqjHB3c6ZwBZpB~8k11clM#A;pF`T#S)3Eja zs3sSgt(ZOI`Tq=fJSayb?wNd4d=x^ara=8$kUwGNPV(FoCcMlbxguW}B`GA%R^-d- zZbPad&(CujpK$Zs2GZ1POQh&Mk+hA&HO_WkgeZ=dpRVWGY02~9G>+X|tf*G^7A`2{ z4CE2!P1qEs_Fg`h)A5O-6$R{ zAz`;DD=XmjjdH^xPvkVVP)OuS6H1D~i>-ih>yd=caK*yB%);S8VFN0Aq1Vj9Ay^bt z6s5mkAzU#Y0xAU|^cYhbmAGFOTZO|o!`v=VqSSV|^Jfflyh|>t)^Gz-`Rf-TI0xI- zT%0>$XS}bo%O||cyF|Nj5qP)2fQ0S$hGUvY-=1)QWN`O}3jp5I%ii!_2+x2yiW-4+cp9$4>oTWTP%VuC-9im$@nf*wiuD@& om`nrH&|HO;TFmS4r@m*#yl0t?|8WdrzYPsN=7Qg`-PPKk0637gK>z>% delta 6838 zcmbVReRLF6mcQ>+b$xYrbys!1AOSj%K#_DHA&DUf3J3`DF+}7ej!Hs6zSL$C@LQ@| z@B@xGn<-i4D075CM^`wSjo(LiM9~osIC74Q;^G`|mQ@^Q#C7$Ei?F}-SUsIzoe!VOC3Y83aa7XfN;nmwEOp%ZPp< z1R0`x)$Z~{+;Xs@Y+nx)mSDlOdzJ`rB%!sL+h32R=DJN9p zIa*DHk-k`jS!SfiV@wqo=?NGUaX0Qw1W|MvyHb?UVPe80(l^k&ty6uAh(BQKe?Hxr`blH%gdZ^28hpz|7zDZ8Mcz{64Yt%MsR$L-s-wFxBQ*nK1Dx2 zYmoD^-3eZ{`uoda7je0_0%N#(F7{xhSgIcDM$Ob@GkqP{)HqXyoCskh8Kqi1$W?Pu zYWXNN8Kf5z=MpJlTE_HYQ5BqC4=**_`*wd!>zOlWimDQ|+^6?ZNVIu}uR(_qO`8|t zlwb=j>`X*Yr$KpfIE8eT@Phe*NRCeb3W9{@YF?)xLe*u6uS<}+qbFa(L&94PE`{D0 zkyTm)uI4|mNkvsg`bIE5l8l(=EMlZ@680+E!FWea-z*r@HN9Iv6pw)2Il?0ER!G<2 zPG;!3(yL>nZ-Xosu*{I~j3Y}Xc#%vHTav8n3+|tY z5;}(L!rLqR$=0^LvcK##+r1D@_8sCW%Dp?V41AeXf~F|0(oK_srz1ms(KfbwxFM=D z;^~)b5278&z9Ab$NpY?#csjJYGVqiUZJ=|m3azCAjtLMwMX2KLT6K=92(hylvd%XJ zH0pWPVyO1${&~8ELU*wNjT;60kAR%p&Q~qa718t5MI-P00J6^G0`9keRR1&v`g`|0 zb-@y|qL*>@?)kxzi?Ldf>oL`M+!2_^A? z0ilp|S8ym+>LV8tTaIw$2YZNXfFWcEonF=w5sf0cz@E^aaEw~Sy8(R}YSlIRTI(@2 znl90hSSj%%iEoC)>PHNXK8Xt45(4@(!xqdKmWWt1KKelzM~xjZZ2A-3 zq-b=)n5xgGTTuX;zJ#QMDo_NAnq%)kKN(YF7QJDrv97#)tE$GP(>`Qj(cRhyVT%r1 z0>_4h{*^I7&jz~G9Pfm!-)4x3dn8Ua1zjNVX(@S%3CR>m-wax#-L#Rp^h2yO=28h< zX!Ic@7CDO0d`)W30`1a0n04t1skvOzdq7+C3B42fREF!)Q@)(&kys(|7?d>HL82=4 zsDwt{mZ--ZThwD07SrfKSw4?be1zurU6hkO3#iJFLOO4WV3(%sE zY>`HrGSSlLpUD@okA}#icM-KlCDuCZ*<$IgOBeDc=bea6E$k-6qTAq9qlHR&)TQ@i zW)I463&Z76iypOv{zG!%b#lqw65~+X zcOuQ8bKG_uAHi|TU~Z1JF*+HvLJZuO;O@eo5IQO4ak?7@lc^siNWt7CIKGpql!|B_ zxYcs54E^*+%>m&2p_DXVG3I23svc{}{3Y(OI%-w^013Jxo0EB-2|OZkw3H-)EtwIH zz*cEBLE=pkPbxzGsKioR&}Sg8qxF&Zj5=xwe*k<~Vvj(dvXWT|ET@h9uyFw`Wyg#n zs-fd3RH^el=q2W9DL2d_R3ZXgAW>%#b9NGm(%mH^apy2vaF!GGTPnGLuHeNcqaBj# zriGzmQ=!kwVMcy8HL63*5DmwZgQ&ZtG3;Ug8zuLa@qIW2Zd{I|ary<|ZqI4b3Ui#P zQ^`pG(OoQ6;+~Lr4)1(*k+${5Q)1pbka+=%iE6oU%d6M(V3r#X(^kI&rNa$KK zPA?A6sh-lWHyt`Gxm<>LS*wzBGEkRhRG-T&kX)Z;o8;Ef1+?09$vC_&1IjU$IesTl~)bZMrgjA=N5r0pg-n0hix=PPh~V4e;sk+Xtw9mJviu$R^SDv z5AjeW=F@wI5dD=4vr-|V8z2$1IORvA*+D6PAHAJXlXP8*#{i?xrEMRYG&0P{0cyAl zMxxUV2ga#RVza~v5~oRQ0s3)jl}x+DrT3OnyTD4PL)iTMy9|S{2zvDJl=YN)5EfPE!wE%{4kePw4xpoKy4w=&7-f=ny>^ zIZ8*N`CmRAhw5Kx1KlN+PV*B`{nQW!&+Fd+7er42f2*Gc?$#AHmRh;Pj?@3z#jK9c z4{2CT&|~QVrIeNP%N*eW;esCIRI@UfKtFagQpftSzu^E+l4a$aF|>~)S% z*sT(8iymZGkO+Mmn`nLptNZo8vsShw^bNc{#uQ%88lo28z_}gc8+`tpQpiPd2J$WZ zbF~8WWV?z#$rsxVT$o+Rck`#Qa5Fn-J`Q)=BKPpCWRVxkA}?ouiayR3%NlHBZINDn z3x7p@n_mZ=V|=yLxrKS=cRZWN6Uu3Rh>L`ea$l70IA3KCS8hj6O-da*$R{f8tUfYT zSx*a`Vzyo`cAs?A&9=oCDfh|6ZkHSJ6<-wXRvwT#k4T+%R<0DYN2Jb^%y14XJ#yib z{B!#gg(c>OgX>xfLXPN9#?;@id7y^ZG4;c z5->vF0t?93LpB}*CBQNo4ouP*UqMP0yJdL7t67iu<*rW=4`X)AC%z7eu< zr1~4f$fZ-(2ny4~<_J85b^uH9ax#L3&^}-_6=1fWE&^Ui(}9=J0w2S4IWb4#FX=w( z66&BmKnvI*>5e?TlJ;7^qb@pOy&>gCB>qLB!i3cji45;ad#u$|!RE8;c^BWtRb_>; zQ)yO1=o&<)!`&tqPfhX%BeN;Agr29jD9lE)e`QBlH;&?A{v{uwT&`THJfiGSzE(7~ zO1)84GotOBd%b{HGxV;r*KgJTmaNkEwIODHufBtKM#6*o$LksOCIws^F`$X%uivW` z!S4Qp2ENChri1(>%}{@!C+Q46@)~VuJ`-Qwwu}FX0~#9f#x3x@**?@2cltGASc41w2Cbk#zMv?)Y?6eS}#o1Cg(AdM+2-bC8{l__=(< zVw5_LuA;s#LH$)Xvs>g{;3sowqjGS}I7ZH+a?4~f-0Tw60S4G62tWw;!byU}&Pivo zBHswiUSFXL@te@ZhKW7P1-6&xV%Uq#bXrx-&90GpzEHGKHn@+%r*yUDYzlkxDTq;| zuvbStGY6d3p}H=83t>-gi5@qzKd+Tv(`kk0{G^@t)RXtrGg7zQ%)k3?=8&8DSmMz_ zCvzPBKE`-VjPmzydG0G2+98=`))J>8r)2aIGH5sug~>ZIvFRe*Y^5|l`lH!bc{4u) zzrsZB=gEkuJdX50%H04sSW@K9&55))6Em@-$!uKWpxE{R+d}oaQNp>*jwZ!T1TKLi zzFEbWC9NU~Y&N1W_T82^v%lu!7NhPDqv!3qU{+)AjkAZT7h^v%8w@R1stt-4E9SmEtgfSDQSIo_O-*&9M=h#v zudg4~P+Qm3(A>Oa@uH5#_NLm#+Q#Yzn$jF{A=nmO#>&!H~g%ArVL>0(GVVWsHDqV<0ry JJiRWH832>`OThpD delta 234 zcmZo@VQOe$n$W>AefEu(jXff%0_AH?)y=!n;lA4BDu4RaWK0g6 z+~~g~Q|gH!qo32{ii!&gP(k?=s31_aK5MbE_qQVpc{JLlH*C(Re8l2w$dC+#Nel)I z7C>kYgoZ%c2q2;aR E0N?^h7ytkO diff --git a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs index 8f5d9a1a..65784691 100644 --- a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs +++ b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs @@ -284,6 +284,19 @@ BaseCallReportEntry entry ) { string missingMethods = string.Join(", ", entry.missingBaseFor); + // Per-method consequence lines mirror the analyzer's DXMSG006 message text. Reading + // the dictionary on BaseCallTypeScannerCore keeps the overlay copy in lockstep with + // the analyzer; both are updated together when a new guarded method is added. + System.Text.StringBuilder consequenceBuilder = new(); + foreach (string missingMethod in entry.missingBaseFor) + { + consequenceBuilder.Append("\n- "); + consequenceBuilder.Append( + BaseCallTypeScannerCore.GetMissingBaseConsequenceLine(missingMethod, fullName) + ); + } + string consequenceLines = consequenceBuilder.ToString(); + // Cached-vs-fresh suffix is appended to the SAME HelpBox string rather than emitted // as a sibling control, which keeps the Layout and Repaint passes emitting an // identical sequence of EditorGUILayout.* calls regardless of harvester freshness. @@ -293,10 +306,11 @@ BaseCallReportEntry entry // fires, the overlay redraws without the suffix. string freshnessSuffix = DxMessagingConsoleHarvester.IsFreshThisSession ? string.Empty - : "\n(cached from previous session; refreshing…)"; + : "\n(cached from previous session; refreshing...)"; string message = - $"{fullName} has lifecycle methods that don't chain to MessageAwareComponent ({missingMethods}); DxMessaging will not function on this component.\n" - + "See docs/reference/analyzers.md." + $"{fullName} has lifecycle methods that don't chain to MessageAwareComponent ({missingMethods}); DxMessaging will not function on this component." + + consequenceLines + + "\nSee docs/reference/analyzers.md." + freshnessSuffix; EditorGUILayout.HelpBox(message, MessageType.Warning); diff --git a/README.md b/README.md index 518ec77e..d70169f4 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,8 @@ public class ChestController : MessageAwareComponent { } ``` +> **Heads-up:** When you override `Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, or `RegisterMessageHandlers`, call the base method first or handlers fail silently. See [DXMSG006](docs/reference/analyzers.md#dxmsg006-missing-base-call). + ### 4. Send It ```csharp @@ -336,7 +338,7 @@ flowchart TD Q3 -->|NO| A4[" Keep it simple"] ``` -**Rule of thumb:** If you're reading this README and thinking "this could address several challenges I'm facing," then DxMessaging may be a good fit. If you're thinking "this seems complicated," start with the [Visual Guide](docs/getting-started/visual-guide.md) or stick with simpler patterns. +**Rule of thumb:** If decoupling, lifecycle leaks, or handler-ordering bugs match what you're hitting today, DxMessaging fits. If none of those describe your project, start with the [Visual Guide](docs/getting-started/visual-guide.md) or stick with simpler patterns. Looking for hard numbers? See OS-specific [Performance Benchmarks](docs/architecture/performance.md). @@ -900,13 +902,13 @@ Created and maintained by [wallstop studios](https://wallstopstudios.com) ## AI Agent Integration -DxMessaging provides comprehensive AI agent context through [llms.txt](llms.txt), following the [llmstxt.org](https://llmstxt.org/) standard for LLM-friendly documentation. +DxMessaging publishes machine-readable context for AI agents through [llms.txt](llms.txt), following the [llmstxt.org](https://llmstxt.org/) standard. The file collects the project overview, API surface, conventions, and links into one document. ### For AI Agents - **[llms.txt](llms.txt)** -- Complete project overview, API reference, and context in a single file - **[Repository Guidelines](.llm/context.md)** -- Coding standards and development workflows -- **[AI Agent Skills](.llm/skills/)** -- 90+ specialized skill documents covering documentation, testing, GitHub Actions, and more +- **[AI Agent Skills](.llm/skills/)** -- 140+ specialized skill documents covering documentation, testing, GitHub Actions, and more The `llms.txt` file is automatically updated via CI/CD to stay current with project changes. It includes: diff --git a/Runtime/Core/DataStructure/CyclicBuffer.cs b/Runtime/Core/DataStructure/CyclicBuffer.cs index 85c160d5..fe322f91 100644 --- a/Runtime/Core/DataStructure/CyclicBuffer.cs +++ b/Runtime/Core/DataStructure/CyclicBuffer.cs @@ -15,7 +15,7 @@ namespace DxMessaging.Core.DataStructure [Serializable] internal sealed class CyclicBuffer : IReadOnlyList { - public struct CyclicBufferEnumerator : IEnumerator + internal struct CyclicBufferEnumerator : IEnumerator { private readonly CyclicBuffer _buffer; @@ -123,7 +123,7 @@ public CyclicBuffer(int capacity, IEnumerable initialContents = null) /// /// Creates an enumerator that iterates from the oldest element to the most recently added. /// - public CyclicBufferEnumerator GetEnumerator() + internal CyclicBufferEnumerator GetEnumerator() { return new CyclicBufferEnumerator(this); } diff --git a/Runtime/Core/MessageBus/IMessageBus.cs b/Runtime/Core/MessageBus/IMessageBus.cs index dda10818..1d660d64 100644 --- a/Runtime/Core/MessageBus/IMessageBus.cs +++ b/Runtime/Core/MessageBus/IMessageBus.cs @@ -26,6 +26,13 @@ namespace DxMessaging.Core.MessageBus /// -- Directed at a specific . /// -- Emitted from a source for any listener. /// + /// + /// Contract note: emitting any message on a bus with no registered handlers must + /// be a silent no-op. Implementations must NOT throw, log, or otherwise surface + /// the empty-bus state to the caller. The + /// LifecycleEdgeCasesTests.EmitOnEmptyBusIsSilentNoOp test pins this + /// contract for every message kind. + /// /// /// /// @@ -118,6 +125,57 @@ internal static bool ShouldEnableDiagnostics() int RegisteredUntargeted { get; } + /// + /// Total number of registered interceptors across all message kinds. + /// + /// + /// + /// Counts the unique (interceptor delegate, priority) pairs registered via + /// , , + /// and . Test infrastructure such as + /// LeakWatcher reads this counter to detect interceptor handles that escape + /// a watched region. Single-thread contract: read on the same thread that drives dispatch. + /// + /// + /// Aggregated by walking the per-kind interceptor caches; the call is O(n) in the + /// number of message types known to the bus. Snapshot at the start of a tracked + /// region rather than reading in a hot loop. + /// + /// + int RegisteredInterceptors { get; } + + /// + /// Total number of registered post-processors across all message kinds. + /// + /// + /// + /// Sums per-message-type post-processor handler counts across the untargeted, + /// targeted, targeted-without-targeting, broadcast, and broadcast-without-source + /// post-processor sinks. Test infrastructure such as LeakWatcher reads this + /// counter to detect post-processor handles that escape a watched region. Same + /// single-thread contract as the other counters. + /// + /// + /// Aggregated on each read; prefer snapshotting at region boundaries instead of + /// polling every frame. + /// + /// + int RegisteredPostProcessors { get; } + + /// + /// Number of registered global accept-all handlers on this bus. + /// + /// + /// + /// Counts the distinct instances registered via + /// . Test infrastructure such as + /// LeakWatcher reads this counter to detect global accept-all handles + /// that escape a watched region. Same single-thread contract as the other + /// counters. + /// + /// + int RegisteredGlobalAcceptAll { get; } + /// /// Interceptor delegate for untargeted messages to transform or cancel them. /// diff --git a/Runtime/Core/MessageBus/MessageBus.cs b/Runtime/Core/MessageBus/MessageBus.cs index 43806a0b..3027dbd2 100644 --- a/Runtime/Core/MessageBus/MessageBus.cs +++ b/Runtime/Core/MessageBus/MessageBus.cs @@ -353,6 +353,89 @@ public int RegisteredUntargeted } } + public int RegisteredInterceptors + { + get + { + int count = 0; + count += SumInterceptorCache(_untargetedInterceptsByType); + count += SumInterceptorCache(_targetedInterceptsByType); + count += SumInterceptorCache(_broadcastInterceptsByType); + return count; + } + } + + public int RegisteredPostProcessors + { + get + { + int count = 0; + foreach (HandlerCache entry in _postProcessingSinks) + { + count += entry?.handlers?.Count ?? 0; + } + count += SumTargetedSinks(_postProcessingTargetedSinks); + count += SumTargetedSinks(_postProcessingBroadcastSinks); + foreach ( + HandlerCache< + int, + HandlerCache + > entry in _postProcessingTargetedWithoutTargetingSinks + ) + { + count += entry?.handlers?.Count ?? 0; + } + foreach ( + HandlerCache< + int, + HandlerCache + > entry in _postProcessingBroadcastWithoutSourceSinks + ) + { + count += entry?.handlers?.Count ?? 0; + } + return count; + } + } + + public int RegisteredGlobalAcceptAll => _globalSinks.handlers.Count; + + private static int SumInterceptorCache(MessageCache> cache) + { + int count = 0; + foreach (InterceptorCache entry in cache) + { + if (entry == null) + { + continue; + } + foreach (KeyValuePair> bucket in entry.handlers) + { + count += bucket.Value?.Count ?? 0; + } + } + return count; + } + + private static int SumTargetedSinks( + MessageCache>> cache + ) + { + int count = 0; + foreach (Dictionary> entry in cache) + { + if (entry == null) + { + continue; + } + foreach (KeyValuePair> kvp in entry) + { + count += kvp.Value?.handlers?.Count ?? 0; + } + } + return count; + } + public bool DiagnosticsMode { get => _diagnosticsMode; diff --git a/Runtime/Unity/MessageAwareComponent.cs b/Runtime/Unity/MessageAwareComponent.cs index 873876d1..171d9e95 100644 --- a/Runtime/Unity/MessageAwareComponent.cs +++ b/Runtime/Unity/MessageAwareComponent.cs @@ -1,6 +1,7 @@ #if UNITY_2021_3_OR_NEWER namespace DxMessaging.Unity { + using System; using Core; using Core.MessageBus; using Core.Messages; @@ -71,6 +72,15 @@ public abstract class MessageAwareComponent : MonoBehaviour protected IMessageBusProvider _configuredMessageBusProvider; protected MessageBusProviderHandle _configuredMessageBusProviderHandle; +#if UNITY_EDITOR || DEBUG + // G6: latch the OnEnable self-check log to fire at most once per component instance. + // [NonSerialized] keeps the latch from being saved into the scene; every fresh + // instance starts with the latch clear and gets at most one breadcrumb if it really is + // missing the base.Awake() call. + [NonSerialized] + private bool _selfCheckLogged; +#endif + /// /// Creates the , token, and calls . /// @@ -135,6 +145,25 @@ protected virtual void OnEnable() { _messageRegistrationToken?.Enable(); } +#if UNITY_EDITOR || DEBUG + // G6: belt-and-braces self-check. If we got here without a registration token, the + // most common cause is a subclass that overrode Awake without calling base.Awake(); + // the analyzer DXMSG006 catches this at compile time but only if the project loads + // the analyzer DLL (e.g. user has disabled analyzers, opened the project on a Unity + // version that does not load the DLL, or the analyzer suppressed via attribute / + // ignore list). We surface a one-time-per-instance LogError so the failure mode is + // not silent. Gated on UNITY_EDITOR || DEBUG so release builds pay zero cost. + if (_messageRegistrationToken == null && !_selfCheckLogged) + { + _selfCheckLogged = true; + Debug.LogError( + $"[DxMessaging] {GetType().Name} appears to be missing a base.Awake() call; " + + "no registration token exists, so this component will not receive messages. " + + "See https://github.com/wallstop/DxMessaging/blob/master/docs/reference/analyzers.md#dxmsg006-missing-base-call", + this + ); + } +#endif } /// diff --git a/Samples~/Mini Combat/Walkthrough.md b/Samples~/Mini Combat/Walkthrough.md index 49b9ef70..ab6c9b17 100644 --- a/Samples~/Mini Combat/Walkthrough.md +++ b/Samples~/Mini Combat/Walkthrough.md @@ -408,7 +408,7 @@ Try modifying the sample: ### Learn More - **[Message Types Documentation](../../docs/concepts/message-types.md)** - Complete message type reference -- **[Targeting and Context Guide](../../docs/concepts/targeting-and-context.md)** - Deep dive into targeting +- **[Targeting and Context Guide](../../docs/concepts/targeting-and-context.md)** - How GameObject and Component targets resolve to InstanceIds - **[Patterns Guide](../../docs/guides/patterns.md)** - Common messaging patterns - **[Performance Guide](../../docs/architecture/performance.md)** - Optimization tips diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs index ecc541c7..9708e09c 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs @@ -1,5 +1,6 @@ namespace WallstopStudios.DxMessaging.SourceGenerators.Analyzers { + using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -76,21 +77,100 @@ public sealed class MessageAwareComponentBaseCallAnalyzer : DiagnosticAnalyzer private const string MissingBaseCallTitle = "Missing base call in MessageAwareComponent override"; + // G1: generic fallback retained as a safety net. The analyzer normally selects the + // per-method consequence text from ; + // if a future contributor adds a new guarded method without populating the dictionary the + // generic format keeps the diagnostic intact (and the + // GuardedMethodListMatchesAllVirtualLifecycleMethodsOnPublicBaseClasses meta-test + // catches the omission so the slip cannot reach a release build). private const string MissingBaseCallMessageFormat = "'{0}' overrides MessageAwareComponent.{1} but does not call base.{1}(); the messaging system may not function correctly on this component."; private const string HelpLinkBase = "https://github.com/wallstop/DxMessaging/blob/master/docs/reference/analyzers.md#"; - private static readonly ImmutableHashSet GuardedMethodNames = + internal static readonly ImmutableHashSet GuardedMethodNames = ImmutableHashSet.Create( "Awake", "OnEnable", "OnDisable", "OnDestroy", + "OnApplicationFocus", + "OnApplicationPause", RegisterMessageHandlersMethodName ); + /// + /// Guarded methods whose canonical Unity signature takes a single bool + /// (OnApplicationFocus(bool focused), OnApplicationPause(bool paused)). + /// All other guarded methods are zero-argument. Used to relax the DXMSG009 + /// signature filter for these specific names so an implicit hide of the bool-arg + /// variant is also surfaced. does NOT currently + /// declare these methods; they are guarded prospectively so that adding a virtual + /// body in a future release immediately gets DXMSG006 / DXMSG010 coverage on + /// existing subclasses without a separate analyzer revision. + /// + internal static readonly ImmutableHashSet GuardedMethodsWithBoolSignature = + ImmutableHashSet.Create("OnApplicationFocus", "OnApplicationPause"); + + /// + /// Lifecycle methods that declares as virtual but whose + /// base body is intentionally empty. Adding them to would + /// produce noise without protecting any real framework work; the meta-test + /// GuardedMethodListMatchesAllVirtualLifecycleMethodsOnPublicBaseClasses uses this + /// allow-list to assert that any other virtual lifecycle method MUST be guarded. + /// + internal static readonly ImmutableHashSet AllowListIntentionallyUnguarded = + ImmutableHashSet.Create("OnApplicationQuit"); + + /// + /// Per-method consequence text for DXMSG006. Each entry tells the user precisely what + /// breaks at runtime when the corresponding base call is missing, so the diagnostic + /// (and the inspector overlay HelpBox, which mirrors this dictionary in + /// BaseCallTypeScannerCore.MissingBaseCallMessageFormatsByMethod) is actionable + /// rather than generic. ASCII-only by policy. New guarded methods MUST add an entry here + /// or the meta-test will fail. + /// + internal static readonly ImmutableDictionary< + string, + string + > MissingBaseCallMessageFormatsByMethod = new[] + { + new KeyValuePair( + "Awake", + "'{0}' overrides MessageAwareComponent.Awake but does not call base.Awake(); the message registration token will never be created and handlers cannot register." + ), + new KeyValuePair( + "OnEnable", + "'{0}' overrides MessageAwareComponent.OnEnable but does not call base.OnEnable(); handlers will not be re-enabled when this component is enabled." + ), + new KeyValuePair( + "OnDisable", + "'{0}' overrides MessageAwareComponent.OnDisable but does not call base.OnDisable(); handlers will not be disabled when this component is disabled, causing unwanted message processing." + ), + new KeyValuePair( + "OnDestroy", + "'{0}' overrides MessageAwareComponent.OnDestroy but does not call base.OnDestroy(); handlers will not be deregistered and the registration token will not be released, causing a memory leak." + ), + new KeyValuePair( + RegisterMessageHandlersMethodName, + "'{0}' overrides MessageAwareComponent.RegisterMessageHandlers but does not call base.RegisterMessageHandlers(); default string-message handlers will not be registered (override RegisterForStringMessages to suppress this warning)." + ), + // OnApplicationFocus / OnApplicationPause are guarded prospectively. MessageAwareComponent + // does not currently declare these methods, so the analyzer never actually fires DXMSG006 + // for them today; the entries exist so that adding a virtual body to the base class in a + // future release immediately produces actionable per-method consequence text without an + // analyzer revision. The meta-test forces these dictionaries to mirror GuardedMethodNames. + new KeyValuePair( + "OnApplicationFocus", + "'{0}' overrides MessageAwareComponent.OnApplicationFocus but does not call base.OnApplicationFocus(); the messaging system may not function correctly on this component when focus changes." + ), + new KeyValuePair( + "OnApplicationPause", + "'{0}' overrides MessageAwareComponent.OnApplicationPause but does not call base.OnApplicationPause(); the messaging system may not function correctly on this component when the application pauses." + ), + }.ToImmutableDictionary(StringComparer.Ordinal); + private static readonly DiagnosticDescriptor MissingBaseCallDescriptor = new( id: MissingBaseCallDiagnosticId, title: MissingBaseCallTitle, @@ -212,13 +292,25 @@ is not IMethodSymbol methodSymbol // non-static, non-generic; so unrelated overloads like `void OnEnable(int)`, // unrelated static helpers, and `void Awake()` (which coexists with the base method // because of differing generic arity and does not trigger CS0114) all stay silent. + // Special case: OnApplicationFocus / OnApplicationPause have a canonical Unity + // signature `(bool)`; for those names we accept either zero parameters OR exactly one + // bool parameter so an implicit hide of the bool variant also surfaces. + bool signatureMatchesLifecycleShape = + methodSymbol.ReturnsVoid + && !methodSymbol.IsGenericMethod + && ( + methodSymbol.Parameters.Length == 0 + || ( + GuardedMethodsWithBoolSignature.Contains(methodName) + && methodSymbol.Parameters.Length == 1 + && methodSymbol.Parameters[0].Type.SpecialType == SpecialType.System_Boolean + ) + ); bool wouldFireMissingModifier = !hasNewModifier && !hasOverrideModifier && !hasStaticModifier - && !methodSymbol.IsGenericMethod - && methodSymbol.ReturnsVoid - && methodSymbol.Parameters.Length == 0; + && signatureMatchesLifecycleShape; // Bail when this method does not match any of our diagnostic shapes. This protects // unrelated methods on subclasses (e.g., a private helper named `Awake` that takes a @@ -365,6 +457,23 @@ out brokenChainAncestor string typeDisplay = containingType.ToDisplayString(); Location location = methodDecl.Identifier.GetLocation(); + // G1: select per-method consequence text. The dictionary's keys are the same set as + // GuardedMethodNames, but we fall back to the generic format defensively so that + // adding a guarded method without populating the dictionary still produces a usable + // (if generic) diagnostic. The meta-test forces the dictionary to stay aligned. + string consequenceFormat = MissingBaseCallMessageFormatsByMethod.TryGetValue( + methodName, + out string perMethodFormat + ) + ? perMethodFormat + : MissingBaseCallMessageFormat; + string formattedMessage = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + consequenceFormat, + typeDisplay, + methodName + ); + // Smart-case: lower DXMSG006 to Info when the class also overrides // RegisterForStringMessages and that override returns the literal `false`. // We keep the id stable as DXMSG006 by constructing the lowered Diagnostic via the @@ -375,12 +484,6 @@ out brokenChainAncestor && ClassOverridesRegisterForStringMessagesAsFalse(containingType) ) { - string formattedMessage = string.Format( - System.Globalization.CultureInfo.InvariantCulture, - MissingBaseCallMessageFormat, - typeDisplay, - methodName - ); Diagnostic loweredDiagnostic = Diagnostic.Create( id: MissingBaseCallDiagnosticId, category: Category, @@ -400,9 +503,26 @@ out brokenChainAncestor return; } - context.ReportDiagnostic( - Diagnostic.Create(MissingBaseCallDescriptor, location, typeDisplay, methodName) + // Standard DXMSG006 path; emit with the per-method consequence message. We call + // Diagnostic.Create with the descriptor only for the metadata (id, category, + // severity, help link), and supply the formatted message explicitly via the + // string-id overload so our per-method wording reaches the consumer verbatim. + Diagnostic perMethodDiagnostic = Diagnostic.Create( + id: MissingBaseCallDiagnosticId, + category: Category, + message: formattedMessage, + severity: DiagnosticSeverity.Warning, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + warningLevel: 1, + title: MissingBaseCallTitle, + description: null, + helpLink: HelpLinkBase + "dxmsg006", + location: location, + additionalLocations: null, + customTags: null ); + context.ReportDiagnostic(perMethodDiagnostic); } private static bool StrictlyInheritsFromMessageAwareComponent(INamedTypeSymbol type) diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs index dfbd8a76..f6c6f8f8 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs @@ -1,8 +1,11 @@ +using System.Collections.Generic; using System.Collections.Immutable; +using System.IO; using System.Linq; using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; using NUnit.Framework; using WallstopStudios.DxMessaging.SourceGenerators.Analyzers; @@ -187,6 +190,226 @@ public sealed class Player : DxMessaging.Unity.MessageAwareComponent ); } + // -- G3: DXMSG007 (new modifier) parametric coverage across all five guarded methods ------ + + [TestCase("Awake")] + [TestCase("OnEnable")] + [TestCase("OnDisable")] + [TestCase("OnDestroy")] + [TestCase("RegisterMessageHandlers")] + public void EachGuardedMethodWithNewModifierEmitsDxmsg007(string methodName) + { + // The pre-existing focused DXMSG007 test only covers Awake. This parametric test + // pins the same contract for every guarded method so a regression that changes the + // guarded set or the new-modifier classification cannot land silently for the other + // four methods. + string source = $$""" +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected new void {{methodName}}() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG007", DiagnosticSeverity.Warning); + AssertNoSiblings(diagnostics, "DXMSG007"); + Diagnostic dxmsg007 = diagnostics.Single(d => d.Id == "DXMSG007"); + Assert.That( + dxmsg007 + .Location.SourceTree!.GetText() + .GetSubText(dxmsg007.Location.SourceSpan) + .ToString(), + Is.EqualTo(methodName) + ); + Assert.That(dxmsg007.GetMessage(), Does.Contain(methodName)); + } + + // -- G4: focused DXMSG009 tests for OnDisable and OnDestroy -------------------------------- + // Pre-existing focused tests cover OnEnable, Awake, RegisterMessageHandlers, and the no- + // accessibility variant. The two below mirror the OnEnable pattern so a regression that + // only breaks OnDisable or OnDestroy classification fails LOUDLY in a focused test rather + // than only in the parametric one. (Focused tests pin which method regressed; the + // parametric covers all methods uniformly.) + + [Test] + public void PrivateOnDisableWithoutModifierEmitsDxmsg009() + { + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + private void OnDisable() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG009", DiagnosticSeverity.Warning); + AssertNoSiblings(diagnostics, "DXMSG009"); + Diagnostic dxmsg009 = diagnostics.Single(d => d.Id == "DXMSG009"); + Assert.That(dxmsg009.GetMessage(), Does.Contain("Sample.BrokenThing")); + Assert.That(dxmsg009.GetMessage(), Does.Contain("OnDisable")); + Assert.That(dxmsg009.GetMessage(), Does.Contain("CS0114")); + string spanText = dxmsg009 + .Location.SourceTree!.GetText() + .GetSubText(dxmsg009.Location.SourceSpan) + .ToString(); + Assert.That(spanText, Is.EqualTo("OnDisable")); + } + + [Test] + public void PrivateOnDestroyWithoutModifierEmitsDxmsg009() + { + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + private void OnDestroy() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG009", DiagnosticSeverity.Warning); + AssertNoSiblings(diagnostics, "DXMSG009"); + Diagnostic dxmsg009 = diagnostics.Single(d => d.Id == "DXMSG009"); + Assert.That(dxmsg009.GetMessage(), Does.Contain("Sample.BrokenThing")); + Assert.That(dxmsg009.GetMessage(), Does.Contain("OnDestroy")); + Assert.That(dxmsg009.GetMessage(), Does.Contain("CS0114")); + string spanText = dxmsg009 + .Location.SourceTree!.GetText() + .GetSubText(dxmsg009.Location.SourceSpan) + .ToString(); + Assert.That(spanText, Is.EqualTo("OnDestroy")); + } + + // -- G5: DXMSG006 per-method consequence text --------------------------------------------- + // The diagnostic message is per-method (G1); these tests pin the load-bearing consequence + // phrase for each method so a future generic rewrite cannot drop the actionable wording. + + [Test] + public void Dxmsg006MessageForAwakeMentionsRegistrationToken() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + Diagnostic dxmsg006 = diagnostics.Single(d => d.Id == "DXMSG006"); + Assert.That( + dxmsg006.GetMessage(), + Does.Contain("the message registration token will never be created") + ); + Assert.That(dxmsg006.GetMessage(), Does.Contain("Sample.Player")); + Assert.That(dxmsg006.GetMessage(), Does.Contain("base.Awake()")); + } + + [Test] + public void Dxmsg006MessageForOnEnableMentionsHandlersNotReEnabled() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void OnEnable() { int x = 1; } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + Diagnostic dxmsg006 = diagnostics.Single(d => d.Id == "DXMSG006"); + Assert.That(dxmsg006.GetMessage(), Does.Contain("handlers will not be re-enabled")); + Assert.That(dxmsg006.GetMessage(), Does.Contain("base.OnEnable()")); + } + + [Test] + public void Dxmsg006MessageForOnDisableMentionsUnwantedMessageProcessing() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void OnDisable() { int x = 1; } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + Diagnostic dxmsg006 = diagnostics.Single(d => d.Id == "DXMSG006"); + Assert.That(dxmsg006.GetMessage(), Does.Contain("unwanted message processing")); + Assert.That(dxmsg006.GetMessage(), Does.Contain("base.OnDisable()")); + } + + [Test] + public void Dxmsg006MessageForOnDestroyMentionsMemoryLeak() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void OnDestroy() { int x = 1; } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + Diagnostic dxmsg006 = diagnostics.Single(d => d.Id == "DXMSG006"); + Assert.That(dxmsg006.GetMessage(), Does.Contain("memory leak")); + Assert.That(dxmsg006.GetMessage(), Does.Contain("base.OnDestroy()")); + } + + [Test] + public void Dxmsg006MessageForRegisterMessageHandlersMentionsStringMessageHandlers() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void RegisterMessageHandlers() { int x = 1; } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + Diagnostic dxmsg006 = diagnostics.Single(d => d.Id == "DXMSG006"); + Assert.That( + dxmsg006.GetMessage(), + Does.Contain("default string-message handlers will not be registered") + ); + Assert.That(dxmsg006.GetMessage(), Does.Contain("RegisterForStringMessages")); + } + [Test] public void RegisterMessageHandlersWithoutBaseAndStringMessagesDisabledIsLoweredToInfo() { @@ -2478,4 +2701,368 @@ public sealed class Player : DxMessaging.Unity.MessageAwareComponent Assert.That(diagnostics, Is.Empty); } + + // -- G2: meta-test pinning the guarded-method invariant ----------------------------------- + + /// + /// Names recognised by Unity as engine-driven lifecycle hooks. The analyzer's guarded set is a + /// strict subset; any virtual-with-body lifecycle method on MessageAwareComponent that + /// is in this list AND not in the analyzer's allow-list MUST be in the guarded set. + /// + private static readonly ImmutableHashSet KnownUnityLifecycleNames = + ImmutableHashSet.Create( + "Awake", + "Start", + "OnEnable", + "OnDisable", + "OnDestroy", + "OnApplicationQuit", + "OnApplicationPause", + "OnApplicationFocus", + "Reset", + "OnValidate", + "OnTransformParentChanged", + "OnTransformChildrenChanged", + "OnBecameVisible", + "OnBecameInvisible" + ); + + /// + /// Unity lifecycle hooks whose canonical signature takes a single bool argument. + /// All other Unity lifecycle hooks are zero-argument; the meta-test parameter filter accepts + /// either zero parameters or exactly one bool for these specific names. + /// + private static readonly ImmutableHashSet KnownOneArgBoolLifecycleNames = + ImmutableHashSet.Create("OnApplicationFocus", "OnApplicationPause"); + + [Test] + public void GuardedMethodListMatchesAllVirtualLifecycleMethodsOnPublicBaseClasses() + { + // Locate the MessageAwareComponent source file by walking up from the test assembly's + // build output toward the repo root. The test project links the editor IL helpers; the + // runtime file lives at Runtime/Unity/MessageAwareComponent.cs at repo root. Going via + // the source file (rather than reflecting on a compiled assembly) avoids needing a Unity + // reference inside the dotnet-test project. + string macSourcePath = LocateRuntimeMessageAwareComponentSource(); + Assert.That( + File.Exists(macSourcePath), + Is.True, + $"Could not locate Runtime/Unity/MessageAwareComponent.cs at expected path '{macSourcePath}'." + ); + + // The runtime file is gated on UNITY_2021_3_OR_NEWER; without that symbol the class + // declaration is preprocessed away and the test misses every method. Define the symbol + // explicitly so we see the same syntax tree the Unity compiler does. + CSharpParseOptions parseOptions = CSharpParseOptions.Default.WithPreprocessorSymbols( + "UNITY_2021_3_OR_NEWER", + "UNITY_EDITOR", + "DEBUG" + ); + SyntaxTree tree = CSharpSyntaxTree.ParseText(File.ReadAllText(macSourcePath), parseOptions); + ClassDeclarationSyntax macClass = tree.GetRoot() + .DescendantNodes() + .OfType() + .Single(c => c.Identifier.ValueText == "MessageAwareComponent"); + + // Enumerate every method declaration that LOOKS like a Unity lifecycle hook on the + // MessageAwareComponent class itself (not nested types). "Looks like" means: parameter- + // less, void-returning, non-static, non-generic, with a known Unity lifecycle name OR the + // project-specific RegisterMessageHandlers method (a framework hook the analyzer also + // guards). For each, classify as virtual-with-body (must be guarded) or empty + // intentionally (must be in the allow list). + HashSet mustBeGuarded = new(System.StringComparer.Ordinal); + HashSet emptyVirtuals = new(System.StringComparer.Ordinal); + + foreach ( + MethodDeclarationSyntax methodDecl in macClass.Members.OfType() + ) + { + string name = methodDecl.Identifier.ValueText; + bool isLifecycleName = KnownUnityLifecycleNames.Contains(name); + bool isFrameworkHook = string.Equals( + name, + "RegisterMessageHandlers", + System.StringComparison.Ordinal + ); + if (!isLifecycleName && !isFrameworkHook) + { + continue; + } + + // Signature filter: only zero-arg void instance non-generic methods are Unity engine + // targets, EXCEPT for the canonical 1-arg-bool lifecycle hooks + // (`OnApplicationFocus(bool)`, `OnApplicationPause(bool)`) which are also valid Unity + // signatures. We accept either zero parameters or, for those specific names, exactly + // one bool parameter; everything else (e.g. `void OnEnable(int)`) stays filtered out. + int paramCount = methodDecl.ParameterList.Parameters.Count; + bool isOneArgBoolLifecycleHook = + paramCount == 1 + && KnownOneArgBoolLifecycleNames.Contains(name) + && methodDecl.ParameterList.Parameters[0].Type is PredefinedTypeSyntax paramType + && paramType.Keyword.ValueText == "bool"; + if (paramCount != 0 && !isOneArgBoolLifecycleHook) + { + continue; + } + if ( + methodDecl.ReturnType is not PredefinedTypeSyntax pts + || pts.Keyword.ValueText != "void" + ) + { + continue; + } + if (methodDecl.TypeParameterList is not null) + { + continue; + } + bool isStatic = methodDecl.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword)); + bool isVirtual = methodDecl.Modifiers.Any(m => m.IsKind(SyntaxKind.VirtualKeyword)); + if (isStatic || !isVirtual) + { + continue; + } + + bool bodyDoesFrameworkWork = MethodBodyHasMeaningfulStatements(methodDecl); + if (bodyDoesFrameworkWork) + { + mustBeGuarded.Add(name); + } + else + { + emptyVirtuals.Add(name); + } + } + + // Assertion 1: every virtual method that performs framework work is in the guarded set. + // If a future contributor adds a new virtual `void OnApplicationFocus()` body that does + // framework work, this assertion fails until the guarded set, the consequence-text + // dictionary, and the IL scanner GuardedMethodNames are all updated together. + IEnumerable shouldBeGuardedButIsNot = mustBeGuarded.Where(n => + !MessageAwareComponentBaseCallAnalyzer.AllowListIntentionallyUnguarded.Contains(n) + && !ContainsOrdinal(GetGuardedMethodNamesViaReflection(), n) + ); + Assert.That( + shouldBeGuardedButIsNot, + Is.Empty, + "MessageAwareComponent declares virtual lifecycle method(s) with a non-empty body that the analyzer does not guard. " + + "Add the method to GuardedMethodNames, populate MissingBaseCallMessageFormatsByMethod, " + + "mirror in BaseCallTypeScannerCore.GuardedMethodNames + MissingBaseCallMessageFormatsByMethod, " + + "OR add it to AllowListIntentionallyUnguarded if missing the base call is genuinely harmless. " + + "Offenders: " + + string.Join(", ", shouldBeGuardedButIsNot) + ); + + // Assertion 2: every method on the allow list is actually present on + // MessageAwareComponent and has an empty (intentionally-no-op) body. This catches the + // inverse drift: a refactor that renames or removes the OnApplicationQuit hook without + // updating the allow list. + foreach ( + string allowed in MessageAwareComponentBaseCallAnalyzer.AllowListIntentionallyUnguarded + ) + { + Assert.That( + emptyVirtuals.Contains(allowed) || mustBeGuarded.Contains(allowed), + Is.True, + $"Allow-list entry '{allowed}' is no longer declared as a virtual lifecycle method on MessageAwareComponent; " + + "either remove it from AllowListIntentionallyUnguarded or restore the declaration." + ); + Assert.That( + mustBeGuarded.Contains(allowed), + Is.False, + $"Allow-list entry '{allowed}' has acquired a non-empty body and now performs framework work; " + + "remove from AllowListIntentionallyUnguarded and add to GuardedMethodNames + per-method consequence text." + ); + } + + // Assertion 3: every guarded method must have a per-method consequence-text entry. This + // is the safety net behind the fall-back-to-generic logic in AnalyzeMethodDeclaration: + // a guarded method without consequence text would emit the generic message (still useful) + // but signals incomplete authoring; we want this loud at test time. + IEnumerable guardedWithoutConsequenceText = GetGuardedMethodNamesViaReflection() + .Where(n => + !MessageAwareComponentBaseCallAnalyzer.MissingBaseCallMessageFormatsByMethod.ContainsKey( + n + ) + ); + Assert.That( + guardedWithoutConsequenceText, + Is.Empty, + "Guarded method(s) lack a per-method consequence message in MissingBaseCallMessageFormatsByMethod. " + + "Add an entry describing what breaks when base.() is missed. Offenders: " + + string.Join(", ", guardedWithoutConsequenceText) + ); + } + + /// + /// True if the method has a non-empty body that performs more than a single comment / empty + /// statement. We use a lenient definition: a body containing any statement at all counts as + /// "framework work" for the purposes of the guard contract. The only intentional zero-body + /// virtuals on MessageAwareComponent are OnApplicationQuit (which the comment marks + /// as "Intentionally left blank") and the demo handlers (which take a parameter and are + /// therefore filtered out at the signature stage). + /// + private static bool MethodBodyHasMeaningfulStatements(MethodDeclarationSyntax method) + { + if (method.ExpressionBody is not null) + { + return true; + } + if (method.Body is null) + { + return false; + } + return method.Body.Statements.Count > 0; + } + + /// + /// Reflects on the analyzer's GuardedMethodNames field via the + /// InternalsVisibleTo bridge; the field is internal-static-readonly. Direct access + /// (without reflection) is also possible because the field is internal and the test + /// project sees it via InternalsVisibleTo; this helper kept the original meta-test + /// resilient to a rename of the field. Falls back to hard-coded names if reflection + /// fails (would only happen if the analyzer source rename breaks the field name; in + /// that case the meta-test must be updated alongside). + /// + private static IReadOnlyCollection GetGuardedMethodNamesViaReflection() + { + System.Reflection.FieldInfo? field = typeof(MessageAwareComponentBaseCallAnalyzer).GetField( + "GuardedMethodNames", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic + ); + if (field?.GetValue(null) is ImmutableHashSet set) + { + return set; + } + return new[] { "Awake", "OnEnable", "OnDisable", "OnDestroy", "RegisterMessageHandlers" }; + } + + private static bool ContainsOrdinal(IReadOnlyCollection set, string value) + { + foreach (string item in set) + { + if (string.Equals(item, value, System.StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + + /// + /// Locates Runtime/Unity/MessageAwareComponent.cs by walking up from the test + /// assembly's load directory until a sibling Runtime folder appears. Resilient to + /// running in either SourceGenerators/.../bin/Debug/... or via dotnet test's + /// alternative output layout. + /// + private static string LocateRuntimeMessageAwareComponentSource() + { + string? current = + Path.GetDirectoryName( + typeof(MessageAwareComponentBaseCallAnalyzerTests).Assembly.Location + ) ?? Directory.GetCurrentDirectory(); + for (int hop = 0; hop < 10 && current is not null; hop++) + { + string candidate = Path.Combine( + current, + "Runtime", + "Unity", + "MessageAwareComponent.cs" + ); + if (File.Exists(candidate)) + { + return candidate; + } + current = Path.GetDirectoryName(current); + } + // Final fallback: assume tests run from repo root. + return Path.Combine( + Directory.GetCurrentDirectory(), + "Runtime", + "Unity", + "MessageAwareComponent.cs" + ); + } + + // -- M2: parity tests between the analyzer's tables and the IL scanner's tables ----------- + + /// + /// The Roslyn analyzer () and the IL + /// scanner () each carry + /// their own copy of the per-method consequence dictionary because they live in different + /// assemblies (Roslyn analyzers must be self-contained; the editor scanner cannot reference + /// the analyzer DLL). Drift between the two would mean the inspector overlay HelpBox and + /// the compile-time diagnostic say different things for the same method. This test asserts + /// the two dictionaries are byte-for-byte equal so a single skill-page recommendation keeps + /// both surfaces aligned. + /// + [Test] + public void AnalyzerAndScannerCoreShareIdenticalMissingBaseCallMessageFormats() + { + IReadOnlyDictionary analyzerFormats = + MessageAwareComponentBaseCallAnalyzer.MissingBaseCallMessageFormatsByMethod; + IReadOnlyDictionary scannerFormats = global::DxMessaging + .Editor + .Analyzers + .BaseCallTypeScannerCore + .MissingBaseCallMessageFormatsByMethod; + + // Set equality on keys. + HashSet analyzerKeys = new(analyzerFormats.Keys, System.StringComparer.Ordinal); + HashSet scannerKeys = new(scannerFormats.Keys, System.StringComparer.Ordinal); + Assert.That( + analyzerKeys.SetEquals(scannerKeys), + Is.True, + "MissingBaseCallMessageFormatsByMethod key sets diverged. Analyzer-only keys: [" + + string.Join(", ", analyzerKeys.Except(scannerKeys)) + + "]; Scanner-only keys: [" + + string.Join(", ", scannerKeys.Except(analyzerKeys)) + + "]. Update both dictionaries together when adding or removing a guarded method." + ); + + // Byte-for-byte value equality per key (Ordinal). + foreach (string key in analyzerKeys) + { + string analyzerValue = analyzerFormats[key]; + string scannerValue = scannerFormats[key]; + Assert.That( + string.Equals(analyzerValue, scannerValue, System.StringComparison.Ordinal), + Is.True, + $"MissingBaseCallMessageFormatsByMethod['{key}'] diverged between analyzer and scanner.\n" + + $" Analyzer: {analyzerValue}\n" + + $" Scanner: {scannerValue}\n" + + "Update both dictionaries to share the same per-method consequence text." + ); + } + } + + /// + /// Same parity contract for the guarded-method name list: the analyzer's + /// GuardedMethodNames immutable hash set must equal the scanner's + /// GuardedMethodNames array (treated as a set). Adding a guarded method without + /// updating both sides would mean the analyzer flags subclasses at compile time that the + /// IL scanner never sees, or vice versa. + /// + [Test] + public void AnalyzerAndScannerCoreShareIdenticalGuardedMethodNames() + { + IReadOnlyCollection analyzerGuarded = + MessageAwareComponentBaseCallAnalyzer.GuardedMethodNames; + IReadOnlyCollection scannerGuarded = global::DxMessaging + .Editor + .Analyzers + .BaseCallTypeScannerCore + .GuardedMethodNames; + + HashSet analyzerSet = new(analyzerGuarded, System.StringComparer.Ordinal); + HashSet scannerSet = new(scannerGuarded, System.StringComparer.Ordinal); + Assert.That( + analyzerSet.SetEquals(scannerSet), + Is.True, + "GuardedMethodNames sets diverged. Analyzer-only: [" + + string.Join(", ", analyzerSet.Except(scannerSet)) + + "]; Scanner-only: [" + + string.Join(", ", scannerSet.Except(analyzerSet)) + + "]. Adding a guarded method requires updating both lists." + ); + } } diff --git a/Tests/dxmsg-csharp-underscore-repo-excluded.meta b/Tests/Editor/Allocations.meta similarity index 77% rename from Tests/dxmsg-csharp-underscore-repo-excluded.meta rename to Tests/Editor/Allocations.meta index 7be230d2..37776986 100644 --- a/Tests/dxmsg-csharp-underscore-repo-excluded.meta +++ b/Tests/Editor/Allocations.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: c80da12a27ca09b4e840b0592fd8001a +guid: 8231e13733c5d6241a7802bbc6a084ca folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Tests/Editor/Allocations/AllocationMatrixExtendedTests.cs b/Tests/Editor/Allocations/AllocationMatrixExtendedTests.cs new file mode 100644 index 00000000..62634ac0 --- /dev/null +++ b/Tests/Editor/Allocations/AllocationMatrixExtendedTests.cs @@ -0,0 +1,603 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Editor.Allocations +{ + using System; + using DxMessaging.Core; + using DxMessaging.Core.Extensions; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Messages; + using DxMessaging.Tests.Editor.Benchmarks; + using DxMessaging.Tests.Runtime; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + + /// + /// Extends with rows that the existing + /// fixture intentionally skips: class-message dispatch (boxed reference + /// type), long-running registration churn, global accept-all dispatch, + /// and the cross-product of interceptor/post-processor presence per kind. + /// All tests are gated behind [Category("Allocation")] so they do + /// not run in the default suite and the wall-clock budget remains within + /// the 60-second target. + /// + [Category("Allocation")] + public sealed class AllocationMatrixExtendedTests : BenchmarkTestBase + { + private const int RegistrationChurnCycles = 1_000; + + private static readonly InstanceId StableTarget = new InstanceId(0x4242_5757); + private static readonly InstanceId StableSource = new InstanceId(0x6464_3232); + private static readonly InstanceId HandlerOwner = new InstanceId(0x1313_8989); + + private DiagnosticsTarget _savedGlobalDiagnostics; + private Action _savedLogFunction; + + protected override bool MessagingDebugEnabled => false; + + [SetUp] + public void CaptureDiagnosticsState() + { + _savedGlobalDiagnostics = IMessageBus.GlobalDiagnosticsTargets; + _savedLogFunction = MessagingDebug.LogFunction; + MessagingDebug.LogFunction = null; + IMessageBus.GlobalDiagnosticsTargets = DiagnosticsTarget.Off; + } + + [TearDown] + public void RestoreDiagnosticsState() + { + IMessageBus.GlobalDiagnosticsTargets = _savedGlobalDiagnostics; + MessagingDebug.LogFunction = _savedLogFunction; + } + + /// + /// Pins zero-allocation emission for class-typed (reference) messages. + /// Class messages reuse the same emit path as struct messages (the + /// instance is a long-lived field captured in the closure), so the + /// dispatch loop must not allocate. + /// + [Test] + [Category("Allocation")] + public void EmitClassMessageIsZeroAlloc( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + Action emit = BuildClassEmitClosure(scenario, bus); + RegisterClassHandler(scenario, token); + AllocationAssertions.AssertNoAllocations($"EmitClass-{scenario.Kind}", emit); + } + ); + } + + /// + /// Pins zero-allocation emission across the joint distribution of + /// interceptor presence x post-processor presence x kind, restricted + /// to combinations where at least one feature is enabled. The + /// (interceptor=false, post-processor=false) baseline is the bare + /// emit path already pinned by + /// ; the existing + /// per-axis tests in cover each + /// feature in isolation. This row exists to catch interaction-only + /// regressions when both features are wired in together. + /// + [Test] + [Category("Allocation")] + public void AllocationAcrossInterceptorAndPostProcessor( + [ValueSource( + typeof(MessageScenarios), + nameof(MessageScenarios.WithAtLeastOneFeatureToggle) + )] + MessageScenario scenario + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + Action emit = BuildEmitClosure(scenario, bus); + RegisterStructHandler(scenario, token); + if (scenario.UseInterceptor) + { + RegisterAllowingInterceptor(scenario, token); + } + if (scenario.UsePostProcessor) + { + RegisterPostProcessor(scenario, token); + } + + string interceptorTag = scenario.UseInterceptor ? "I+" : "I-"; + string postProcessorTag = scenario.UsePostProcessor ? "P+" : "P-"; + AllocationAssertions.AssertNoAllocations( + $"EmitInterceptorPostProcessor-{interceptorTag}{postProcessorTag}-{scenario.Kind}", + emit + ); + } + ); + } + + /// + /// Long-running registration churn: 1000 register/emit/unregister + /// cycles. After warm-up the per-cycle allocation budget must scale + /// linearly with the registration cycles (not super-linearly), so + /// the total measured budget is bounded. + /// + [Test] + [Category("Allocation")] + public void LongRunningRegistrationChurn1000Cycles( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + // Budget: per-cycle worst case is dominated by closure + + // dictionary churn (see AllocationMatrixTests.PerRegistrationByteBudget=512). + // We allow 1.5x that ceiling per cycle to absorb interim + // dictionary resizing across the longer run. + const long PerCycleBudgetBytes = 768L; + long totalBudget = PerCycleBudgetBytes * RegistrationChurnCycles; + + RunWithFreshHarness( + scenario, + (token, bus) => + { + Action emit = BuildEmitClosure(scenario, bus); + + // Warm: a few cycles to settle dictionary capacity. + for (int i = 0; i < 32; ++i) + { + MessageRegistrationHandle warm = RegisterStructHandler(scenario, token); + emit(); + token.RemoveRegistration(warm); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + long before = GC.GetAllocatedBytesForCurrentThread(); + + for (int i = 0; i < RegistrationChurnCycles; ++i) + { + MessageRegistrationHandle handle = RegisterStructHandler(scenario, token); + emit(); + token.RemoveRegistration(handle); + } + + long after = GC.GetAllocatedBytesForCurrentThread(); + long delta = after - before; + + // Always log the per-cycle average so a passing run still + // surfaces the baseline (useful when tightening the budget + // later). On failure the per-cycle figure is the actionable + // signal a maintainer needs to decide whether the regression + // is per-cycle or a one-off resize. + long perCycleAvg = delta / RegistrationChurnCycles; + UnityEngine.Debug.Log( + $"RegistrationChurn-{scenario.Kind}: {delta} bytes / " + + $"{RegistrationChurnCycles} cycles = {perCycleAvg} avg/cycle " + + $"(budget {PerCycleBudgetBytes} avg/cycle, total {totalBudget})." + ); + + Assert.That( + delta, + Is.LessThanOrEqualTo(totalBudget), + $"RegistrationChurn-{scenario.Kind} allocated {delta} bytes " + + $"across {RegistrationChurnCycles} cycles " + + $"({perCycleAvg} avg/cycle), " + + $"exceeding the {totalBudget}-byte budget " + + $"({PerCycleBudgetBytes} avg/cycle)." + ); + } + ); + } + + /// + /// Pins zero-allocation steady-state emission for the global accept-all + /// dispatch path. The global accept-all delegate signatures take + /// ref IUntargetedMessage / ref ITargetedMessage / + /// ref IBroadcastMessage, so emitting a struct message under + /// a registered global accept-all forces a struct-to-interface box + /// at the dispatch site + /// (MessageBus.cs lines 1290, 1471, 2485). That box is + /// structural to the API and cannot be eliminated without changing + /// the global-handler signature, so this test is restricted to + /// class-typed (reference) messages, where no boxing occurs and the + /// dispatch loop's own zero-allocation contract is the property + /// under test. The struct-message budget for the same path is + /// pinned separately by + /// . + /// + [Test] + [Category("Allocation")] + public void GlobalAcceptAllAllocationIsZeroSteadyState( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + RunWithFreshHarness( + scenario, + (token, bus) => + { + // Class messages are reference types, so the dispatch + // path's IUntargetedMessage/ITargetedMessage/ + // IBroadcastMessage interface upcast is a pointer + // copy rather than a box. See the docstring above. + Action emit = BuildClassEmitClosure(scenario, bus); + RegisterClassHandler(scenario, token); + _ = token.RegisterGlobalAcceptAll( + AcceptAllUntargeted, + AcceptAllTargeted, + AcceptAllBroadcast + ); + AllocationAssertions.AssertNoAllocations( + $"EmitGlobalAcceptAll-{scenario.Kind}", + emit + ); + } + ); + } + + /// + /// Pins a per-emit upper bound on the struct-message global accept-all + /// dispatch path. Emitting a struct message under a registered global + /// accept-all incurs an unavoidable struct-to-interface box at the + /// dispatch site (MessageBus.cs lines 1290, 1471, 2485) -- the + /// box is structural to the API. This test documents that cost as a + /// bounded budget rather than a zero-allocation contract so a future + /// regression that adds more allocations on the same path is still + /// caught. + /// + [Test] + [Category("Allocation")] + public void GlobalAcceptAllStructMessageBudgetIsBounded( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + // 128 bytes/emit covers struct-to-interface boxes up to 64 bytes + // plus 64 bytes of per-emit overhead for any future bookkeeping; + // 64 bytes was at the edge of safety relative to current struct + // sizes, so 128 provides meaningful margin for field growth. + const long PerEmitBudgetBytes = 128L; + long totalBudget = PerEmitBudgetBytes * AllocationAssertions.DefaultMeasuredIterations; + + RunWithFreshHarness( + scenario, + (token, bus) => + { + Action emit = BuildEmitClosure(scenario, bus); + RegisterStructHandler(scenario, token); + _ = token.RegisterGlobalAcceptAll( + AcceptAllUntargeted, + AcceptAllTargeted, + AcceptAllBroadcast + ); + + // Warm: settle any one-shot allocations (delegate caches, + // dictionary capacity) before measurement. + for (int i = 0; i < AllocationAssertions.DefaultMeasuredIterations; ++i) + { + emit(); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < AllocationAssertions.DefaultMeasuredIterations; ++i) + { + emit(); + } + long after = GC.GetAllocatedBytesForCurrentThread(); + long delta = after - before; + long perEmit = delta / AllocationAssertions.DefaultMeasuredIterations; + + // Always log the measured per-emit cost so a passing run + // still surfaces the baseline. Future maintainers can use + // the printed figure to decide whether the budget can be + // tightened. + UnityEngine.Debug.Log( + $"GlobalAcceptAllStruct-{scenario.Kind}: {delta} bytes / " + + $"{AllocationAssertions.DefaultMeasuredIterations} emissions = " + + $"{perEmit} avg/emit " + + $"(budget {PerEmitBudgetBytes} avg/emit, total {totalBudget})." + ); + + Assert.That( + delta, + Is.LessThanOrEqualTo(totalBudget), + $"GlobalAcceptAllStruct-{scenario.Kind} allocated {delta} bytes " + + $"({perEmit} avg/emit) across " + + $"{AllocationAssertions.DefaultMeasuredIterations} emissions, " + + $"exceeding the {totalBudget}-byte budget " + + $"({PerEmitBudgetBytes} avg/emit)." + ); + } + ); + } + + private void RunWithFreshHarness( + MessageScenario scenario, + Action body + ) + { + if (scenario == null) + { + throw new ArgumentNullException(nameof(scenario)); + } + + if (body == null) + { + throw new ArgumentNullException(nameof(body)); + } + + MessageBus bus = new MessageBus(); + MessageHandler handler = new MessageHandler(HandlerOwner, bus) { active = true }; + MessageRegistrationToken token = MessageRegistrationToken.Create(handler, bus); + try + { + token.Enable(); + body(token, bus); + } + finally + { + token.UnregisterAll(); + token.Dispose(); + } + } + + private static MessageRegistrationHandle RegisterStructHandler( + MessageScenario scenario, + MessageRegistrationToken token, + int priority = 0 + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargeted( + scenario, + token, + NoOpUntargeted, + priority: priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargeted( + scenario, + token, + StableTarget, + NoOpTargeted, + priority: priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcast( + scenario, + token, + StableSource, + NoOpBroadcast, + priority: priority + ); + } + default: + { + throw new InvalidOperationException($"Unhandled MessageKind {scenario.Kind}."); + } + } + } + + private static MessageRegistrationHandle RegisterClassHandler( + MessageScenario scenario, + MessageRegistrationToken token + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return token.RegisterUntargeted(NoOpClassUntargeted); + } + case MessageKind.Targeted: + { + return token.RegisterTargeted( + StableTarget, + NoOpClassTargeted + ); + } + case MessageKind.Broadcast: + { + return token.RegisterBroadcast( + StableSource, + NoOpClassBroadcast + ); + } + default: + { + throw new InvalidOperationException($"Unhandled MessageKind {scenario.Kind}."); + } + } + } + + private static MessageRegistrationHandle RegisterAllowingInterceptor( + MessageScenario scenario, + MessageRegistrationToken token + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargetedInterceptor( + scenario, + token, + AllowUntargeted + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargetedInterceptor( + scenario, + token, + AllowTargeted + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcastInterceptor( + scenario, + token, + AllowBroadcast + ); + } + default: + { + throw new InvalidOperationException($"Unhandled MessageKind {scenario.Kind}."); + } + } + } + + private static MessageRegistrationHandle RegisterPostProcessor( + MessageScenario scenario, + MessageRegistrationToken token + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargetedPostProcessor( + scenario, + token, + NoOpUntargeted + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargetedPostProcessor( + scenario, + token, + StableTarget, + NoOpTargeted + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcastPostProcessor( + scenario, + token, + StableSource, + NoOpBroadcast + ); + } + default: + { + throw new InvalidOperationException($"Unhandled MessageKind {scenario.Kind}."); + } + } + } + + private static Action BuildEmitClosure(MessageScenario scenario, IMessageBus bus) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + SimpleUntargetedMessage untargeted = new SimpleUntargetedMessage(); + return () => untargeted.EmitUntargeted(bus); + } + case MessageKind.Targeted: + { + SimpleTargetedMessage targeted = new SimpleTargetedMessage(); + InstanceId target = StableTarget; + return () => targeted.EmitTargeted(target, bus); + } + case MessageKind.Broadcast: + { + SimpleBroadcastMessage broadcast = new SimpleBroadcastMessage(); + InstanceId source = StableSource; + return () => broadcast.EmitBroadcast(source, bus); + } + default: + { + throw new InvalidOperationException($"Unhandled MessageKind {scenario.Kind}."); + } + } + } + + private static Action BuildClassEmitClosure(MessageScenario scenario, IMessageBus bus) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + ClassUntargetedMessage untargeted = new ClassUntargetedMessage("steady"); + return () => untargeted.EmitUntargeted(bus); + } + case MessageKind.Targeted: + { + ClassTargetedMessage targeted = new ClassTargetedMessage("steady"); + InstanceId target = StableTarget; + return () => targeted.EmitTargeted(target, bus); + } + case MessageKind.Broadcast: + { + ClassBroadcastMessage broadcast = new ClassBroadcastMessage("steady"); + InstanceId source = StableSource; + return () => broadcast.EmitBroadcast(source, bus); + } + default: + { + throw new InvalidOperationException($"Unhandled MessageKind {scenario.Kind}."); + } + } + } + + private static void AcceptAllUntargeted(ref IUntargetedMessage message) { } + + private static void AcceptAllTargeted( + ref InstanceId target, + ref ITargetedMessage message + ) { } + + private static void AcceptAllBroadcast( + ref InstanceId source, + ref IBroadcastMessage message + ) { } + + private static void NoOpUntargeted(ref SimpleUntargetedMessage message) { } + + private static void NoOpTargeted(ref SimpleTargetedMessage message) { } + + private static void NoOpBroadcast(ref SimpleBroadcastMessage message) { } + + private static void NoOpClassUntargeted(ref ClassUntargetedMessage message) { } + + private static void NoOpClassTargeted(ref ClassTargetedMessage message) { } + + private static void NoOpClassBroadcast(ref ClassBroadcastMessage message) { } + + private static bool AllowUntargeted(ref SimpleUntargetedMessage message) + { + return true; + } + + private static bool AllowTargeted(ref InstanceId target, ref SimpleTargetedMessage message) + { + return true; + } + + private static bool AllowBroadcast( + ref InstanceId source, + ref SimpleBroadcastMessage message + ) + { + return true; + } + } +} +#endif diff --git a/Tests/Editor/Allocations/AllocationMatrixExtendedTests.cs.meta b/Tests/Editor/Allocations/AllocationMatrixExtendedTests.cs.meta new file mode 100644 index 00000000..1ac3d4af --- /dev/null +++ b/Tests/Editor/Allocations/AllocationMatrixExtendedTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 42c42a6ed00debc4ebddc5ea962ba00f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Benchmarks/AllocationMatrixTests.cs b/Tests/Editor/Allocations/AllocationMatrixTests.cs similarity index 99% rename from Tests/Runtime/Benchmarks/AllocationMatrixTests.cs rename to Tests/Editor/Allocations/AllocationMatrixTests.cs index 060a34f4..f8edb041 100644 --- a/Tests/Runtime/Benchmarks/AllocationMatrixTests.cs +++ b/Tests/Editor/Allocations/AllocationMatrixTests.cs @@ -1,11 +1,13 @@ #if UNITY_2021_3_OR_NEWER -namespace DxMessaging.Tests.Runtime.Benchmarks +namespace DxMessaging.Tests.Editor.Allocations { using System; using System.Collections.Generic; using DxMessaging.Core; using DxMessaging.Core.Extensions; using DxMessaging.Core.MessageBus; + using DxMessaging.Tests.Editor.Benchmarks; + using DxMessaging.Tests.Runtime; using DxMessaging.Tests.Runtime.Scripts.Messages; using NUnit.Framework; diff --git a/Tests/Runtime/Benchmarks/AllocationMatrixTests.cs.meta b/Tests/Editor/Allocations/AllocationMatrixTests.cs.meta similarity index 83% rename from Tests/Runtime/Benchmarks/AllocationMatrixTests.cs.meta rename to Tests/Editor/Allocations/AllocationMatrixTests.cs.meta index 9c4e3e63..4656e103 100644 --- a/Tests/Runtime/Benchmarks/AllocationMatrixTests.cs.meta +++ b/Tests/Editor/Allocations/AllocationMatrixTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 79706c4ba5c54e9587755b93da4aa07e +guid: c680eb73dc1c52747bd5063a8e77a810 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs b/Tests/Editor/Allocations/BenchmarkHarnessRobustnessTests.cs similarity index 98% rename from Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs rename to Tests/Editor/Allocations/BenchmarkHarnessRobustnessTests.cs index 277b7c6d..7077f4ed 100644 --- a/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs +++ b/Tests/Editor/Allocations/BenchmarkHarnessRobustnessTests.cs @@ -1,5 +1,5 @@ #if UNITY_2021_3_OR_NEWER -namespace DxMessaging.Tests.Runtime.Benchmarks +namespace DxMessaging.Tests.Editor.Allocations { using System; using System.Collections; @@ -7,9 +7,10 @@ namespace DxMessaging.Tests.Runtime.Benchmarks using DxMessaging.Core.Extensions; using DxMessaging.Core.MessageBus; using DxMessaging.Core.Messages; + using DxMessaging.Tests.Editor.Benchmarks; + using DxMessaging.Tests.Runtime.Scripts.Components; + using DxMessaging.Tests.Runtime.Scripts.Messages; using NUnit.Framework; - using Scripts.Components; - using Scripts.Messages; using UnityEngine; using UnityEngine.TestTools; diff --git a/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs.meta b/Tests/Editor/Allocations/BenchmarkHarnessRobustnessTests.cs.meta similarity index 83% rename from Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs.meta rename to Tests/Editor/Allocations/BenchmarkHarnessRobustnessTests.cs.meta index ee03df94..cfb75365 100644 --- a/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs.meta +++ b/Tests/Editor/Allocations/BenchmarkHarnessRobustnessTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 64773754a1842524e92ee4cd96aff4f0 +guid: 76348eb14004d6b42acbb60a8c7eeefb MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Tests/Runtime/Benchmarks/ProviderResolutionBenchmarks.cs b/Tests/Editor/Allocations/ProviderResolutionBenchmarks.cs similarity index 98% rename from Tests/Runtime/Benchmarks/ProviderResolutionBenchmarks.cs rename to Tests/Editor/Allocations/ProviderResolutionBenchmarks.cs index b08df9db..9ed4f2e2 100644 --- a/Tests/Runtime/Benchmarks/ProviderResolutionBenchmarks.cs +++ b/Tests/Editor/Allocations/ProviderResolutionBenchmarks.cs @@ -1,4 +1,4 @@ -namespace DxMessaging.Tests.Runtime.Benchmarks +namespace DxMessaging.Tests.Editor.Allocations { using System; using System.Diagnostics; @@ -6,6 +6,7 @@ namespace DxMessaging.Tests.Runtime.Benchmarks using DxMessaging.Core.Extensions; using DxMessaging.Core.MessageBus; using DxMessaging.Core.Messages; + using DxMessaging.Tests.Editor.Benchmarks; using NUnit.Framework; [Category("Performance")] diff --git a/Tests/Runtime/Benchmarks/ProviderResolutionBenchmarks.cs.meta b/Tests/Editor/Allocations/ProviderResolutionBenchmarks.cs.meta similarity index 84% rename from Tests/Runtime/Benchmarks/ProviderResolutionBenchmarks.cs.meta rename to Tests/Editor/Allocations/ProviderResolutionBenchmarks.cs.meta index d01bd5aa..66d6c0c9 100644 --- a/Tests/Runtime/Benchmarks/ProviderResolutionBenchmarks.cs.meta +++ b/Tests/Editor/Allocations/ProviderResolutionBenchmarks.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: e3285ba401ba49a4aa87a7a5609638c0 +guid: c07413945f3068442bd9bb32fc290e17 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Tests/Editor/Allocations/WallstopStudios.DxMessaging.Tests.Editor.Allocations.asmdef b/Tests/Editor/Allocations/WallstopStudios.DxMessaging.Tests.Editor.Allocations.asmdef new file mode 100644 index 00000000..15ab912b --- /dev/null +++ b/Tests/Editor/Allocations/WallstopStudios.DxMessaging.Tests.Editor.Allocations.asmdef @@ -0,0 +1,46 @@ +{ + "name": "WallstopStudios.DxMessaging.Tests.Editor.Allocations", + "rootNamespace": "DxMessaging.Tests.Editor.Allocations", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "WallstopStudios.DxMessaging", + "WallstopStudios.DxMessaging.Tests.Runtime", + "Unity.PerformanceTesting", + "Zenject", + "MessagePipe", + "UniRx", + "UniTask", + "WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks" + ], + "includePlatforms": ["Editor"], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": ["nunit.framework.dll"], + "autoReferenced": true, + "defineConstraints": ["UNITY_INCLUDE_TESTS"], + "versionDefines": [ + { + "name": "com.cysharp.messagepipe", + "expression": "0.0.1", + "define": "MESSAGEPIPE_PRESENT" + }, + { + "name": "com.neuecc.unirx", + "expression": "0.0.1", + "define": "UNIRX_PRESENT" + }, + { + "name": "com.cysharp.unirx", + "expression": "0.0.1", + "define": "UNIRX_PRESENT" + }, + { + "name": "com.svermeulen.extenject", + "expression": "0.0.1", + "define": "ZENJECT_PRESENT" + } + ], + "noEngineReferences": false +} diff --git a/Tests/Editor/Allocations/WallstopStudios.DxMessaging.Tests.Editor.Allocations.asmdef.meta b/Tests/Editor/Allocations/WallstopStudios.DxMessaging.Tests.Editor.Allocations.asmdef.meta new file mode 100644 index 00000000..1187996d --- /dev/null +++ b/Tests/Editor/Allocations/WallstopStudios.DxMessaging.Tests.Editor.Allocations.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 78775a6f123de0f458ee6f1c58d06755 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Benchmarks.meta b/Tests/Editor/Benchmarks.meta similarity index 100% rename from Tests/Runtime/Benchmarks.meta rename to Tests/Editor/Benchmarks.meta diff --git a/Tests/Editor/Benchmarks/AssemblyInfo.cs b/Tests/Editor/Benchmarks/AssemblyInfo.cs new file mode 100644 index 00000000..0e763b98 --- /dev/null +++ b/Tests/Editor/Benchmarks/AssemblyInfo.cs @@ -0,0 +1,9 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo( + assemblyName: "WallstopStudios.DxMessaging.Tests.Runtime", + AllInternalsVisible = true +)] +[assembly: InternalsVisibleTo("WallstopStudios.DxMessaging.Tests.Editor")] +[assembly: InternalsVisibleTo("WallstopStudios.DxMessaging.Tests.Editor.Allocations")] +[assembly: InternalsVisibleTo("WallstopStudios.DxMessaging.Tests.01.Editor.Comparisons")] diff --git a/Tests/Editor/Benchmarks/AssemblyInfo.cs.meta b/Tests/Editor/Benchmarks/AssemblyInfo.cs.meta new file mode 100644 index 00000000..62682be7 --- /dev/null +++ b/Tests/Editor/Benchmarks/AssemblyInfo.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e2835a7f85424468b530ceb00ea2251f +timeCreated: 1777766627 \ No newline at end of file diff --git a/Tests/Runtime/Benchmarks/BenchmarkSession.cs b/Tests/Editor/Benchmarks/BenchmarkSession.cs similarity index 99% rename from Tests/Runtime/Benchmarks/BenchmarkSession.cs rename to Tests/Editor/Benchmarks/BenchmarkSession.cs index b06cd0a0..bbf5e439 100644 --- a/Tests/Runtime/Benchmarks/BenchmarkSession.cs +++ b/Tests/Editor/Benchmarks/BenchmarkSession.cs @@ -1,5 +1,5 @@ #if UNITY_2021_3_OR_NEWER -namespace DxMessaging.Tests.Runtime.Benchmarks +namespace DxMessaging.Tests.Editor.Benchmarks { using System; using System.Collections.Generic; diff --git a/Tests/Runtime/Benchmarks/BenchmarkSession.cs.meta b/Tests/Editor/Benchmarks/BenchmarkSession.cs.meta similarity index 83% rename from Tests/Runtime/Benchmarks/BenchmarkSession.cs.meta rename to Tests/Editor/Benchmarks/BenchmarkSession.cs.meta index 8699e781..66906e4f 100644 --- a/Tests/Runtime/Benchmarks/BenchmarkSession.cs.meta +++ b/Tests/Editor/Benchmarks/BenchmarkSession.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: a4f22c4eaa83480cb7a50f9702c19454 +guid: 6c6f23fbe543c384297a4b1424602171 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Tests/Runtime/Benchmarks/BenchmarkTestBase.cs b/Tests/Editor/Benchmarks/BenchmarkTestBase.cs similarity index 98% rename from Tests/Runtime/Benchmarks/BenchmarkTestBase.cs rename to Tests/Editor/Benchmarks/BenchmarkTestBase.cs index 93978132..0b4f8059 100644 --- a/Tests/Runtime/Benchmarks/BenchmarkTestBase.cs +++ b/Tests/Editor/Benchmarks/BenchmarkTestBase.cs @@ -1,12 +1,12 @@ #if UNITY_2021_3_OR_NEWER -namespace DxMessaging.Tests.Runtime.Benchmarks +namespace DxMessaging.Tests.Editor.Benchmarks { using System; using System.Globalization; using DxMessaging.Core; using DxMessaging.Tests.Runtime.Core; + using DxMessaging.Tests.Runtime.Scripts.Components; using DxMessaging.Unity; - using Scripts.Components; using UnityEngine; using Object = UnityEngine.Object; diff --git a/Tests/Runtime/Benchmarks/BenchmarkTestBase.cs.meta b/Tests/Editor/Benchmarks/BenchmarkTestBase.cs.meta similarity index 83% rename from Tests/Runtime/Benchmarks/BenchmarkTestBase.cs.meta rename to Tests/Editor/Benchmarks/BenchmarkTestBase.cs.meta index eb0fddf9..ac0325e7 100644 --- a/Tests/Runtime/Benchmarks/BenchmarkTestBase.cs.meta +++ b/Tests/Editor/Benchmarks/BenchmarkTestBase.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 93674124ccfd4bcbb1fa16ce2bb68f86 +guid: 1a6a4ad5a40b2004eaabb65cdc522fd9 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Tests/Runtime/Benchmarks/PerformanceTests.cs b/Tests/Editor/Benchmarks/PerformanceTests.cs similarity index 99% rename from Tests/Runtime/Benchmarks/PerformanceTests.cs rename to Tests/Editor/Benchmarks/PerformanceTests.cs index c77c9450..5ea8c13b 100644 --- a/Tests/Runtime/Benchmarks/PerformanceTests.cs +++ b/Tests/Editor/Benchmarks/PerformanceTests.cs @@ -1,14 +1,14 @@ #if UNITY_2021_3_OR_NEWER -namespace DxMessaging.Tests.Runtime.Benchmarks +namespace DxMessaging.Tests.Editor.Benchmarks { using System; using System.Diagnostics; using DxMessaging.Core; using DxMessaging.Core.Extensions; using DxMessaging.Core.Messages; + using DxMessaging.Tests.Runtime.Scripts.Components; + using DxMessaging.Tests.Runtime.Scripts.Messages; using NUnit.Framework; - using Scripts.Components; - using Scripts.Messages; using UnityEngine; using UnityEngine.TestTools.Constraints; using Is = NUnit.Framework.Is; diff --git a/Tests/Runtime/Benchmarks/PerformanceTests.cs.meta b/Tests/Editor/Benchmarks/PerformanceTests.cs.meta similarity index 100% rename from Tests/Runtime/Benchmarks/PerformanceTests.cs.meta rename to Tests/Editor/Benchmarks/PerformanceTests.cs.meta diff --git a/Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.Runtime.Benchmarks.asmdef b/Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef similarity index 89% rename from Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.Runtime.Benchmarks.asmdef rename to Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef index 8449531d..431067de 100644 --- a/Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.Runtime.Benchmarks.asmdef +++ b/Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef @@ -1,6 +1,6 @@ { - "name": "WallstopStudios.DxMessaging.Tests.Runtime.Benchmarks", - "rootNamespace": "DxMessaging.Tests.Runtime.Benchmarks", + "name": "WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks", + "rootNamespace": "DxMessaging.Tests.Editor.Benchmarks", "references": [ "UnityEngine.TestRunner", "UnityEditor.TestRunner", diff --git a/Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.Runtime.Benchmarks.asmdef.meta b/Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef.meta similarity index 100% rename from Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.Runtime.Benchmarks.asmdef.meta rename to Tests/Editor/Benchmarks/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef.meta diff --git a/Tests/Editor/Comparisons.meta b/Tests/Editor/Comparisons.meta new file mode 100644 index 00000000..5212ec59 --- /dev/null +++ b/Tests/Editor/Comparisons.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9511c1855132dda479d9052ded01775f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs b/Tests/Editor/Comparisons/ComparisonPerformanceTests.cs similarity index 98% rename from Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs rename to Tests/Editor/Comparisons/ComparisonPerformanceTests.cs index 26daa464..f8eb0b96 100644 --- a/Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs +++ b/Tests/Editor/Comparisons/ComparisonPerformanceTests.cs @@ -1,23 +1,21 @@ #if UNITY_2021_3_OR_NEWER -namespace DxMessaging.Tests.Runtime.Benchmarks +namespace DxMessaging.Tests.Editor.Comparisons { using System; using System.Diagnostics; - using DxMessaging.Core; using DxMessaging.Core.Extensions; + using DxMessaging.Tests.Editor.Benchmarks; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using MessagePipe; using NUnit.Framework; - using Scripts.Messages; + using UniRx; using UnityEngine.TestTools.Constraints; + using Zenject; using Debug = UnityEngine.Debug; using Is = NUnit.Framework.Is; #if UNIRX_PRESENT - using UniRx; #endif #if MESSAGEPIPE_PRESENT - using MessagePipe; -#endif -#if ZENJECT_PRESENT - using Zenject; #endif [Category("Performance")] diff --git a/Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs.meta b/Tests/Editor/Comparisons/ComparisonPerformanceTests.cs.meta similarity index 83% rename from Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs.meta rename to Tests/Editor/Comparisons/ComparisonPerformanceTests.cs.meta index da7fd250..7783e8ff 100644 --- a/Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs.meta +++ b/Tests/Editor/Comparisons/ComparisonPerformanceTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: f4e0156a9d3e4fc997620b462733ccc8 +guid: f27beb559e734184480252e68b4707ff MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef b/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef new file mode 100644 index 00000000..067b9a7f --- /dev/null +++ b/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef @@ -0,0 +1,46 @@ +{ + "name": "WallstopStudios.DxMessaging.Tests.01.Editor.Comparisons", + "rootNamespace": "DxMessaging.Tests.Editor.Comparisons", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "WallstopStudios.DxMessaging", + "WallstopStudios.DxMessaging.Tests.Runtime", + "Unity.PerformanceTesting", + "Zenject", + "MessagePipe", + "UniRx", + "UniTask", + "WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks" + ], + "includePlatforms": ["Editor"], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": ["nunit.framework.dll"], + "autoReferenced": true, + "defineConstraints": ["UNITY_INCLUDE_TESTS"], + "versionDefines": [ + { + "name": "com.cysharp.messagepipe", + "expression": "0.0.1", + "define": "MESSAGEPIPE_PRESENT" + }, + { + "name": "com.neuecc.unirx", + "expression": "0.0.1", + "define": "UNIRX_PRESENT" + }, + { + "name": "com.cysharp.unirx", + "expression": "0.0.1", + "define": "UNIRX_PRESENT" + }, + { + "name": "com.svermeulen.extenject", + "expression": "0.0.1", + "define": "ZENJECT_PRESENT" + } + ], + "noEngineReferences": false +} diff --git a/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef.meta b/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef.meta new file mode 100644 index 00000000..72573ea3 --- /dev/null +++ b/Tests/Editor/Comparisons/WallstopStudios.DxMessaging.Tests.00.Editor.Benchmarks.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7e1928395bb221646ad96ca1081f503f +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/BaseCallContractTests.cs b/Tests/Runtime/Core/BaseCallContractTests.cs new file mode 100644 index 00000000..61c9730e --- /dev/null +++ b/Tests/Runtime/Core/BaseCallContractTests.cs @@ -0,0 +1,989 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System.Collections; + using System.Text.RegularExpressions; + using DxMessaging.Core; + using DxMessaging.Core.Extensions; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Messages; + using DxMessaging.Tests.Runtime; + using DxMessaging.Tests.Runtime.Scripts.Components; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + using UnityEngine; + using UnityEngine.TestTools; + + /// + /// Pins the runtime consequence of forgetting a base.X() call when + /// subclassing . + /// Complements the compile-time analyzer (DXMSG006) and edit-time IL + /// scanner by asserting the actual user-visible failure mode at runtime, + /// so future refactors of the base class cannot silently change what + /// happens when a subclass omits the chain call. + /// + /// + /// + /// Most tests parameterized over + /// drive the dispatch portion of the assertion through the canonical + /// entry points so the same body covers + /// untargeted, targeted, and broadcast registration paths. A few tests + /// (the ones that exercise base-class default handlers directly) run + /// once without scenario parameterization. The breadcrumb log assertion + /// is gated on UNITY_EDITOR || DEBUG on the runtime side; this + /// fixture runs in the Unity editor so that condition holds. + /// + /// + /// The leak tests + /// ( and + /// ) + /// intentionally produce registrations that survive component + /// destruction. The leaked registrations are cleaned up by the global + /// bus reset that performs at + /// the start of every test, so they cannot bleed into subsequent tests; + /// both tests additionally invoke DxMessagingStaticState.Reset + /// after observing the leak so the bus is drained before + /// asserts the bus is fresh. + /// + /// + public sealed class BaseCallContractTests : MessagingTestBase + { + private static readonly Regex MissingBaseAwakeBreadcrumbPattern = new( + @"\[DxMessaging\].*missing a base\.Awake\(\) call", + RegexOptions.Compiled | RegexOptions.CultureInvariant + ); + + /// + /// Number of default handlers + /// + /// installs on a freshly-spawned subclass when + /// RegisterForStringMessages is left at its default true. + /// Two go to RegisteredTargeted + /// (RegisterGameObjectTargeted<StringMessage> and + /// RegisterComponentTargeted<StringMessage>), one to + /// RegisteredUntargeted + /// (RegisterUntargeted<GlobalStringMessage>). + /// + /// + /// This number is load-bearing for the leak math in + /// and + /// : both tests + /// observe the bus across a spawn-then-destroy round trip and must + /// know how many handlers the framework adds on its own. If the base + /// class adds or removes a default handler, update this constant in + /// lock-step. + /// + private const int DefaultStringMessageHandlerCount = 3; + + /// + /// Skipping base.Awake() means the framework never creates the + /// registration token. Asserts that the runtime self-check breadcrumb + /// fires once, the token is null, attempting to register through it + /// throws (instead of failing silently), and emitted messages do not + /// produce handler invocations. + /// + [UnityTest] + public IEnumerator OmitBaseAwakeYieldsNoTokenAndNoDispatch( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + // Expect the self-check breadcrumb BEFORE the action that triggers it. + LogAssert.Expect(LogType.Error, MissingBaseAwakeBreadcrumbPattern); + + GameObject host = new( + nameof(OmitBaseAwakeYieldsNoTokenAndNoDispatch) + scenario.Kind, + typeof(MissingBaseAwakeComponent) + ); + _spawned.Add(host); + + MissingBaseAwakeComponent component = host.GetComponent(); + Assert.IsNotNull(component, "[{0}] Component should be present.", scenario.Kind); + Assert.IsNull( + component.Token, + "[{0}] Token must remain null when base.Awake() is skipped.", + scenario.Kind + ); + + // Calling through a null token throws NullReferenceException. + // The exact exception type is not the contract; the contract is + // "the call fails in a defined way rather than silently dropping + // the registration", which a thrown exception satisfies. + Assert.Throws( + () => + _ = component.Token.RegisterUntargeted( + (ref SimpleUntargetedMessage _) => { } + ), + "[{0}] Registering through a null token must throw, not silently no-op.", + scenario.Kind + ); + + IEnumerator fresh = WaitUntilMessageHandlerIsFresh(); + while (fresh.MoveNext()) + { + yield return fresh.Current; + } + + // No handler is registered (the registration above threw); emit + // anyway and confirm dispatch is a no-op. The bus must remain + // fresh because the broken component never installed a handler. + EmitDirectly(scenario, host); + + IMessageBus bus = MessageHandler.MessageBus; + Assert.Zero( + bus.RegisteredUntargeted, + "[{0}] No untargeted registrations should exist.", + scenario.Kind + ); + Assert.Zero( + bus.RegisteredTargeted, + "[{0}] No targeted registrations should exist.", + scenario.Kind + ); + Assert.Zero( + bus.RegisteredBroadcast, + "[{0}] No broadcast registrations should exist.", + scenario.Kind + ); + + yield break; + } + + /// + /// Skipping base.OnEnable() prevents the registration token from + /// transitioning to the enabled state, so even though the token exists + /// the registered handler does not fire. + /// + [UnityTest] + public IEnumerator OmitBaseOnEnableLeavesHandlerDisabled( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(OmitBaseOnEnableLeavesHandlerDisabled) + scenario.Kind, + typeof(MissingBaseOnEnableComponent) + ); + _spawned.Add(host); + + MissingBaseOnEnableComponent component = + host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + Assert.IsNotNull( + token, + "[{0}] Token must be created because base.Awake() still runs.", + scenario.Kind + ); + + int handlerInvocations = 0; + MessageRegistrationHandle handle = RegisterCounter( + scenario, + token, + host, + () => handlerInvocations++ + ); + try + { + EmitDirectly(scenario, host); + + Assert.AreEqual( + 0, + handlerInvocations, + "[{0}] Handler must not fire while the token is never enabled.", + scenario.Kind + ); + } + finally + { + token.RemoveRegistration(handle); + } + + yield break; + } + + /// + /// Skipping base.OnDisable() means the registration token is + /// never disabled, so the handler keeps firing while the component is + /// ostensibly off. + /// + [UnityTest] + public IEnumerator OmitBaseOnDisableLeavesHandlerLive( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(OmitBaseOnDisableLeavesHandlerLive) + scenario.Kind, + typeof(MissingBaseOnDisableComponent) + ); + _spawned.Add(host); + + MissingBaseOnDisableComponent component = + host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + Assert.IsNotNull( + token, + "[{0}] Token must be created because base.Awake() still runs.", + scenario.Kind + ); + + int handlerInvocations = 0; + MessageRegistrationHandle handle = RegisterCounter( + scenario, + token, + host, + () => handlerInvocations++ + ); + try + { + // Disable the component; because the override skips + // base.OnDisable(), the token stays enabled. + component.enabled = false; + EmitDirectly(scenario, host); + + Assert.AreEqual( + 1, + handlerInvocations, + "[{0}] Handler must still fire because the token was never disabled.", + scenario.Kind + ); + } + finally + { + token.RemoveRegistration(handle); + } + + yield break; + } + + /// + /// Skipping BOTH base.OnDisable() and base.OnDestroy() + /// means the framework never releases the messaging component or + /// disables the token, so the registration outlives the GameObject + /// and the bus's registration counter does not return to the baseline + /// captured by . The fixture + /// intentionally skips + /// both base calls because Unity's destroy lifecycle fires + /// OnDisable before OnDestroy; if only OnDestroy + /// were skipped, the inherited OnDisable would deregister the + /// handlers during destruction and the leak would be masked. The + /// companion test + /// pins that + /// masking behavior explicitly. + /// Cleanup choice: the test calls + /// after observing the leak + /// so the bus returns to a clean state before + /// asserts the bus is + /// fresh; this is the simplest deterministic way to drop an orphaned + /// registration whose owning GameObject no longer exists. + /// + [UnityTest] + public IEnumerator OmitBaseOnDisableAndOnDestroyLeaksRegistration( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + // Construct the watcher BEFORE spawning the host so the baseline + // is the truly-fresh bus (0) that MessagingTestBase.UnitySetup + // guarantees. Capturing the baseline after spawn would understate + // the leak: the 3 default StringMessage handlers added by the + // inherited RegisterMessageHandlers would already be in the + // baseline, so a leak that includes them would only show as the + // user counter (1) instead of the full failure surface (4). + // Watching from before spawn lets the assertion pin the EXACT + // leak count (defaults + counter) and proves that skipping both + // base.OnDisable and base.OnDestroy strands every handler the + // framework added on Awake, not just the user-added one. + LeakWatcher watcher = new( + bus: MessageHandler.MessageBus, + throwOnLeak: false, + label: scenario.DisplayName + ); + + int observedLeak; + string deltaDescription; + try + { + GameObject host = new( + nameof(OmitBaseOnDisableAndOnDestroyLeaksRegistration) + scenario.Kind, + typeof(MissingBaseOnDestroyComponent) + ); + _spawned.Add(host); + + MissingBaseOnDestroyComponent component = + host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + Assert.IsNotNull( + token, + "[{0}] Token must be created because base.Awake() still runs.", + scenario.Kind + ); + + int snapshotAfterSpawn = watcher.Snapshot; + Assert.AreEqual( + DefaultStringMessageHandlerCount, + snapshotAfterSpawn, + "[{0}] Spawning a MessageAwareComponent subclass that does not " + + "override RegisterForStringMessages must add exactly the " + + "default StringMessage handler count to the bus. {1}", + scenario.Kind, + watcher.DescribeDelta() + ); + + _ = RegisterCounter(scenario, token, host, () => { }); + Assert.AreEqual( + snapshotAfterSpawn + 1, + watcher.Snapshot, + "[{0}] Bus must reflect the new registration before destroy. {1}", + scenario.Kind, + watcher.DescribeDelta() + ); + + // Destroy the component / GameObject; because the override skips + // BOTH base.OnDisable() and base.OnDestroy(), neither the token's + // handler list nor the framework's MessagingComponent are torn + // down, and every handler installed during Awake leaks. The + // expected leak is therefore the default StringMessage handlers + // PLUS the counter handler, not just the counter. + UnityEngine.Object.Destroy(host); + _spawned.Remove(host); + + if (Application.isPlaying) + { + yield return null; + } + + // Capture the live leak BEFORE Dispose/Reset so the assertion + // sees the actual orphaned count even if a later step throws. + observedLeak = watcher.LeakedRegistrations; + deltaDescription = watcher.DescribeDelta(); + } + finally + { + // Idempotent: protects the watcher from being left undisposed + // if any earlier Assert in the try block throws. The values + // captured into observedLeak/deltaDescription above are taken + // from the live bus, so the assertion below remains correct + // regardless of when Dispose runs. + watcher.Dispose(); + } + + // Drop the orphaned registrations so they cannot bleed into the + // next test; UnityCleanup's WaitUntilMessageHandlerIsFresh would + // otherwise time out asserting bus staleness. + DxMessagingStaticState.Reset(); + + // Exact equality: the leak surface must be the default handlers + // PLUS the user counter. Asserting >= 1 (the prior behaviour) + // would silently allow a future regression that loses one or more + // of the default handlers but still leaks the counter. + const int expectedLeak = DefaultStringMessageHandlerCount + 1; + Assert.AreEqual( + expectedLeak, + observedLeak, + "[{0}] Skipping base.OnDisable() and base.OnDestroy() must leak " + + "exactly {1} registrations ({2} default StringMessage " + + "handlers + 1 counter), proving that NEITHER user nor " + + "default handlers are deregistered when both base calls " + + "are absent. {3}", + scenario.Kind, + expectedLeak, + DefaultStringMessageHandlerCount, + deltaDescription + ); + + yield break; + } + + /// + /// Pins Unity's destroy lifecycle interaction: omitting only + /// base.OnDestroy() while leaving the inherited + /// base.OnDisable() intact does NOT leak, because Unity fires + /// OnDisable before OnDestroy during destruction and + /// the inherited OnDisable calls + /// _messageRegistrationToken?.Disable(), deregistering every + /// active registration before the broken OnDestroy runs. This + /// test is the negative control for + /// and + /// documents why that test's fixture must skip both base calls. + /// + [UnityTest] + public IEnumerator OnDisableDuringDestroyMasksOnDestroyLeak( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + // Construct the watcher BEFORE spawning the host so the baseline + // is the truly-fresh bus (0) that MessagingTestBase.UnitySetup + // guarantees. Capturing the baseline AFTER spawn would fold the + // 3 default StringMessage handlers (registered by the inherited + // MessageAwareComponent.RegisterMessageHandlers) into the + // baseline, so the "leak" delta would be the negative of those + // 3 handlers when base.OnDisable() drains them at destroy time. + // Watching from before spawn pins the FULL round-trip: every + // handler the framework added on Awake (defaults + counter) must + // be removed by the inherited OnDisable during the destroy + // lifecycle, so the final delta is exactly 0. + using LeakWatcher watcher = new( + bus: MessageHandler.MessageBus, + throwOnLeak: false, + label: scenario.DisplayName + ); + + GameObject host = new( + nameof(OnDisableDuringDestroyMasksOnDestroyLeak) + scenario.Kind, + typeof(MissingBaseOnDestroyOnlyComponent) + ); + _spawned.Add(host); + + MissingBaseOnDestroyOnlyComponent component = + host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + Assert.IsNotNull( + token, + "[{0}] Token must be created because base.Awake() still runs.", + scenario.Kind + ); + + int snapshotAfterSpawn = watcher.Snapshot; + Assert.AreEqual( + DefaultStringMessageHandlerCount, + snapshotAfterSpawn, + "[{0}] Spawning a MessageAwareComponent subclass that does not " + + "override RegisterForStringMessages must add exactly the " + + "default StringMessage handler count to the bus. {1}", + scenario.Kind, + watcher.DescribeDelta() + ); + + _ = RegisterCounter(scenario, token, host, () => { }); + Assert.AreEqual( + snapshotAfterSpawn + 1, + watcher.Snapshot, + "[{0}] Bus must reflect the new registration before destroy. {1}", + scenario.Kind, + watcher.DescribeDelta() + ); + + // Destroy the GameObject. Unity fires OnDisable then OnDestroy; + // the inherited base.OnDisable() runs (the override is absent on + // this fixture) and disables the token before the broken + // OnDestroy runs, so no registration leaks - including the + // default StringMessage handlers, which is what makes the masking + // observable end-to-end. + UnityEngine.Object.Destroy(host); + _spawned.Remove(host); + + if (Application.isPlaying) + { + yield return null; + } + + Assert.AreEqual( + 0, + watcher.LeakedRegistrations, + "[{0}] Inherited base.OnDisable() must deregister ALL handlers " + + "during destroy (counter + {1} default StringMessage " + + "handlers), masking the broken OnDestroy. {2}", + scenario.Kind, + DefaultStringMessageHandlerCount, + watcher.DescribeDelta() + ); + + // Belt-and-braces: the live bus counters must each be 0 after the + // host is gone, not just the aggregate. Guards against a future + // refactor that nets to zero by accidentally deregistering + // unrelated registrations along with the user counter. + IMessageBus bus = MessageHandler.MessageBus; + Assert.Zero( + bus.RegisteredUntargeted, + "[{0}] No untargeted registrations should remain after destroy. {1}", + scenario.Kind, + watcher.DescribeDelta() + ); + Assert.Zero( + bus.RegisteredTargeted, + "[{0}] No targeted registrations should remain after destroy. {1}", + scenario.Kind, + watcher.DescribeDelta() + ); + Assert.Zero( + bus.RegisteredBroadcast, + "[{0}] No broadcast registrations should remain after destroy. {1}", + scenario.Kind, + watcher.DescribeDelta() + ); + + yield break; + } + + /// + /// Pins the per-counter shape of the leak when both + /// base.OnDisable() and base.OnDestroy() are skipped. + /// Distinct from + /// , which + /// asserts the aggregate count: this test reads each registration kind + /// individually so a future "fix" that accidentally only deregisters + /// user handlers from one path (or that loses one of the default + /// handlers but keeps another) cannot pass while still masking the + /// regression. The expected per-counter shape after destroy is: + /// Targeted == 2 (the two default StringMessage handlers) plus 1 if + /// the scenario registers a targeted/broadcast counter, + /// Untargeted == 1 (the default GlobalStringMessage handler) plus 1 + /// if the scenario registers an untargeted counter, Broadcast == 1 + /// only when the scenario registers a broadcast counter. + /// + [UnityTest] + public IEnumerator OmitBaseOnDisableAndOnDestroyLeaksDefaultHandlersToo( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + // Watch from before spawn so the per-counter accounting is + // anchored to a fresh bus. + using LeakWatcher watcher = new( + bus: MessageHandler.MessageBus, + throwOnLeak: false, + label: scenario.DisplayName + ); + + GameObject host = new( + nameof(OmitBaseOnDisableAndOnDestroyLeaksDefaultHandlersToo) + scenario.Kind, + typeof(MissingBaseOnDestroyComponent) + ); + _spawned.Add(host); + + MissingBaseOnDestroyComponent component = + host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + Assert.IsNotNull( + token, + "[{0}] Token must be created because base.Awake() still runs.", + scenario.Kind + ); + + _ = RegisterCounter(scenario, token, host, () => { }); + + UnityEngine.Object.Destroy(host); + _spawned.Remove(host); + + if (Application.isPlaying) + { + yield return null; + } + + IMessageBus bus = MessageHandler.MessageBus; + + // The two default StringMessage handlers ALWAYS land on Targeted + // regardless of scenario, and the default GlobalStringMessage + // handler ALWAYS lands on Untargeted. The counter handler lands + // on the counter that matches the scenario kind. + int expectedTargeted = 2 + (scenario.Kind == MessageKind.Targeted ? 1 : 0); + int expectedUntargeted = 1 + (scenario.Kind == MessageKind.Untargeted ? 1 : 0); + int expectedBroadcast = scenario.Kind == MessageKind.Broadcast ? 1 : 0; + + string deltaDescription = watcher.DescribeDelta(); + + Assert.AreEqual( + expectedTargeted, + bus.RegisteredTargeted, + "[{0}] Targeted leak must include the 2 default StringMessage " + + "handlers plus any counter for this scenario. Expected={1}. {2}", + scenario.Kind, + expectedTargeted, + deltaDescription + ); + Assert.AreEqual( + expectedUntargeted, + bus.RegisteredUntargeted, + "[{0}] Untargeted leak must include the 1 default " + + "GlobalStringMessage handler plus any counter for this " + + "scenario. Expected={1}. {2}", + scenario.Kind, + expectedUntargeted, + deltaDescription + ); + Assert.AreEqual( + expectedBroadcast, + bus.RegisteredBroadcast, + "[{0}] Broadcast leak must equal the scenario's counter " + + "contribution; the base class registers no default " + + "broadcast handlers. Expected={1}. {2}", + scenario.Kind, + expectedBroadcast, + deltaDescription + ); + + // Drop the orphaned registrations so they cannot bleed into the + // next test; UnityCleanup's WaitUntilMessageHandlerIsFresh would + // otherwise time out asserting bus staleness. + DxMessagingStaticState.Reset(); + + yield break; + } + + /// + /// Pins that the masking observed in + /// covers the + /// default StringMessage / GlobalStringMessage handlers + /// the base class registers, not just user-added handlers. After + /// destroy, emitting both default-handler triggers is a no-op because + /// every default handler was deregistered by the inherited + /// OnDisable during the destroy lifecycle. This guards against + /// a future regression where the framework only deregisters user + /// handlers in some code path, leaving default handlers stranded + /// against a destroyed host. + /// + [UnityTest] + public IEnumerator OnDisableDuringDestroyDeregistersDefaultStringHandlers() + { + using LeakWatcher watcher = new( + bus: MessageHandler.MessageBus, + throwOnLeak: false, + label: nameof(OnDisableDuringDestroyDeregistersDefaultStringHandlers) + ); + + GameObject host = new( + nameof(OnDisableDuringDestroyDeregistersDefaultStringHandlers), + typeof(MissingBaseOnDestroyOnlyComponent) + ); + _spawned.Add(host); + + MissingBaseOnDestroyOnlyComponent component = + host.GetComponent(); + Assert.IsNotNull(GetToken(component), "Token must be created."); + + // Capture the InstanceId BEFORE destroy so the post-destroy + // emission targets the same id without dereferencing a + // fake-null Unity wrapper. + InstanceId hostId = host; + + // Sanity: spawning installs exactly the default handler count. + Assert.AreEqual( + DefaultStringMessageHandlerCount, + watcher.Snapshot, + "Spawn must add exactly the default handler count. {0}", + watcher.DescribeDelta() + ); + + UnityEngine.Object.Destroy(host); + _spawned.Remove(host); + + if (Application.isPlaying) + { + yield return null; + } + + IMessageBus bus = MessageHandler.MessageBus; + Assert.Zero( + bus.RegisteredTargeted, + "Default StringMessage Targeted handlers must be removed by " + + "the inherited base.OnDisable() during destroy. {0}", + watcher.DescribeDelta() + ); + Assert.Zero( + bus.RegisteredUntargeted, + "Default GlobalStringMessage Untargeted handler must be removed " + + "by the inherited base.OnDisable() during destroy. {0}", + watcher.DescribeDelta() + ); + + // Emit the default-handler triggers against the captured id; with + // every default handler deregistered the bus has no work to do + // and no listener to dispatch to. This pins the user-observable + // consequence of the masking: not just zero counters, but also + // zero reachable handlers for the messages the framework would + // normally route by default. + // Use the untyped overload because Assert.DoesNotThrow takes a + // delegate and the typed TargetedBroadcast takes a ref parameter, + // which lambdas cannot capture. + Assert.DoesNotThrow( + () => + { + StringMessage stringMessage = new("after-destroy"); + bus.UntypedTargetedBroadcast(hostId, stringMessage); + }, + "Targeted broadcast against the destroyed host's id must not " + + "throw and must dispatch to nobody." + ); + Assert.DoesNotThrow( + () => + { + GlobalStringMessage globalMessage = new("after-destroy-global"); + globalMessage.EmitUntargeted(); + }, + "Emitting GlobalStringMessage after the host is destroyed must not throw." + ); + + Assert.AreEqual( + 0, + watcher.LeakedRegistrations, + "No registrations may remain after the inherited base.OnDisable " + + "drains the token during destroy. {0}", + watcher.DescribeDelta() + ); + + yield break; + } + + /// + /// Skipping base.RegisterMessageHandlers() means the default + /// StringMessage / GlobalStringMessage registrations + /// the base class normally adds are never installed, while user-added + /// registrations in the override still apply because the token itself + /// was created by the untouched Awake. + /// + [UnityTest] + public IEnumerator OmitBaseRegisterMessageHandlersDoesNotRegisterDefaultStringHandlers() + { + GameObject host = new( + nameof(OmitBaseRegisterMessageHandlersDoesNotRegisterDefaultStringHandlers), + typeof(MissingBaseRegisterMessageHandlersComponent) + ); + _spawned.Add(host); + + MissingBaseRegisterMessageHandlersComponent component = + host.GetComponent(); + Assert.IsNotNull( + GetToken(component), + "Token must exist because base.Awake() still runs." + ); + + // Emit the default-handler messages: a component-targeted StringMessage + // and an untargeted GlobalStringMessage. Without the base call, the + // handlers the base would normally register for these are absent. + StringMessage stringMessage = new("payload"); + stringMessage.EmitComponentTargeted(component); + GlobalStringMessage globalMessage = new("global-payload"); + globalMessage.EmitUntargeted(); + + Assert.AreEqual( + 0, + component.defaultHandlerInvocations, + "Default base-class string handlers must not fire when base.RegisterMessageHandlers() is skipped." + ); + + // Confirm the user's own registration (added inside the override) + // does fire, proving the token itself is operational. + SimpleUntargetedMessage userMessage = new(); + userMessage.EmitUntargeted(); + + Assert.AreEqual( + 1, + component.userHandlerInvocations, + "User-registered handler in the override must still fire because the token is created." + ); + + yield break; + } + + /// + /// Positive control: a subclass that correctly chains base on + /// every guarded lifecycle method passes every check the failure + /// fixtures use - the handler fires while enabled, stops firing while + /// disabled, and the bus returns to baseline on destroy. + /// + [UnityTest] + public IEnumerator CorrectSubclassingPassesAllChecks( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + // Construct the watcher BEFORE spawning the host so the baseline is + // the truly-fresh bus (0). The fixture + // CorrectBaseCallContractComponent overrides + // RegisterForStringMessages => false today, so this happens to + // match the post-spawn count - but anchoring to the pre-spawn bus + // removes the hidden coupling: if a future maintainer flips that + // override to true, a "leak" of the default handlers would be + // folded into a post-spawn baseline and silently masked. Watching + // from before spawn pins the full round-trip (baseline=0, + // after-spawn=0 with the override in place, after-register=1, + // after-destroy=0, leaked=0) regardless of the override's value. + using (LeakWatcher watcher = LeakWatcher.Watch(label: scenario.DisplayName)) + { + GameObject host = new( + nameof(CorrectSubclassingPassesAllChecks) + scenario.Kind, + typeof(CorrectBaseCallContractComponent) + ); + _spawned.Add(host); + + CorrectBaseCallContractComponent component = + host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + Assert.IsNotNull(token, "[{0}] Token must be created.", scenario.Kind); + + int handlerInvocations = 0; + MessageRegistrationHandle handle = RegisterCounter( + scenario, + token, + host, + () => handlerInvocations++ + ); + + EmitDirectly(scenario, host); + Assert.AreEqual( + 1, + handlerInvocations, + "[{0}] Handler must fire while the component is enabled.", + scenario.Kind + ); + + component.enabled = false; + EmitDirectly(scenario, host); + Assert.AreEqual( + 1, + handlerInvocations, + "[{0}] Handler must not fire after the component is disabled.", + scenario.Kind + ); + + token.RemoveRegistration(handle); + UnityEngine.Object.Destroy(host); + _spawned.Remove(host); + if (Application.isPlaying) + { + yield return null; + } + + Assert.AreEqual( + 0, + watcher.LeakedRegistrations, + "[{0}] Correct base-call chaining must leave no leaked registrations.", + scenario.Kind + ); + } + + yield break; + } + + /// + /// Spawns a correct subclass and a broken subclass on different + /// GameObjects and confirms the bus delivers messages only to the + /// correct one. Pins that a single broken component does not + /// suppress dispatch to its siblings. + /// + [UnityTest] + public IEnumerator MultipleSubclassesDoNotCrossContaminate() + { + // A broken Awake means we will see one breadcrumb when the broken + // host enables; declare the expectation up front. + LogAssert.Expect(LogType.Error, MissingBaseAwakeBreadcrumbPattern); + + GameObject correctHost = new( + nameof(MultipleSubclassesDoNotCrossContaminate) + "_Correct", + typeof(CorrectBaseCallContractComponent) + ); + _spawned.Add(correctHost); + + GameObject brokenHost = new( + nameof(MultipleSubclassesDoNotCrossContaminate) + "_Broken", + typeof(MissingBaseAwakeComponent) + ); + _spawned.Add(brokenHost); + + CorrectBaseCallContractComponent correct = + correctHost.GetComponent(); + MissingBaseAwakeComponent broken = brokenHost.GetComponent(); + + Assert.IsNotNull(GetToken(correct), "Correct host must have a token."); + Assert.IsNull(broken.Token, "Broken host must not have a token."); + + SimpleUntargetedMessage message = new(); + message.EmitUntargeted(); + + Assert.AreEqual( + 1, + correct.userHandlerInvocations, + "Correct host must receive the message." + ); + // The broken host has no token and no registration, so it cannot + // observe a counter increment; assert via the only public surface + // it exposes (the null token and a fresh emit-with-no-effect). + Assert.IsNull(broken.Token, "Broken host must remain unable to register handlers."); + + yield break; + } + + private static MessageRegistrationHandle RegisterCounter( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + System.Action onInvoked + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargeted( + scenario, + token, + (ref SimpleUntargetedMessage _) => onInvoked() + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargeted( + scenario, + token, + target, + (ref SimpleTargetedMessage _) => onInvoked() + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcast( + scenario, + token, + target, + (ref SimpleBroadcastMessage _) => onInvoked() + ); + } + default: + { + throw new System.ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static void EmitDirectly(MessageScenario scenario, InstanceId target) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + SimpleUntargetedMessage message = new(); + ScenarioHarness.EmitUntargeted(scenario, ref message); + return; + } + case MessageKind.Targeted: + { + SimpleTargetedMessage message = new(); + ScenarioHarness.EmitTargeted(scenario, ref message, target); + return; + } + case MessageKind.Broadcast: + { + SimpleBroadcastMessage message = new(); + ScenarioHarness.EmitBroadcast(scenario, ref message, target); + return; + } + default: + { + throw new System.ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + } +} + +#endif diff --git a/Tests/Runtime/Core/BaseCallContractTests.cs.meta b/Tests/Runtime/Core/BaseCallContractTests.cs.meta new file mode 100644 index 00000000..b664051b --- /dev/null +++ b/Tests/Runtime/Core/BaseCallContractTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2d038b6ebed74261a5605761f34f6483 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/LeakWatcherSelfTests.cs b/Tests/Runtime/Core/LeakWatcherSelfTests.cs new file mode 100644 index 00000000..ba594a49 --- /dev/null +++ b/Tests/Runtime/Core/LeakWatcherSelfTests.cs @@ -0,0 +1,202 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System.Collections; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Tests.Runtime; + using DxMessaging.Tests.Runtime.Scripts.Components; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + using UnityEngine; + using UnityEngine.TestTools; + + /// + /// Self-tests for . Confirms the watcher detects a + /// known leak (a registration that escapes its using region) and + /// does not flag clean code (a registration removed before + /// ). + /// + public sealed class LeakWatcherSelfTests : MessagingTestBase + { + [UnityTest] + public IEnumerator WatcherPassesWhenAllHandlesAreRemoved( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(WatcherPassesWhenAllHandlesAreRemoved) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + using (LeakWatcher watcher = LeakWatcher.Watch(label: scenario.DisplayName)) + { + int initial = watcher.InitialSnapshot; + MessageRegistrationHandle handle = RegisterCountingHandler(scenario, token, hostId); + Assert.GreaterOrEqual( + watcher.Snapshot, + initial + 1, + "[{0}] Watcher.Snapshot must reflect the new registration in real time.", + scenario.Kind + ); + token.RemoveRegistration(handle); + Assert.AreEqual( + initial, + watcher.Snapshot, + "[{0}] Watcher.Snapshot must return to the initial value after removal.", + scenario.Kind + ); + } + + yield break; + } + + [UnityTest] + public IEnumerator WatcherDetectsLeakedRegistrationWhenNotThrowing( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(WatcherDetectsLeakedRegistrationWhenNotThrowing) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + MessageRegistrationHandle leaked = default; + bool leakedRegistered = false; + int observedLeak = 0; + try + { + using ( + LeakWatcher watcher = new LeakWatcher( + bus: MessageHandler.MessageBus, + throwOnLeak: false, + label: scenario.DisplayName + ) + ) + { + leaked = RegisterCountingHandler(scenario, token, hostId); + leakedRegistered = true; + // Intentionally NOT removing the registration before Dispose so + // the watcher records the leak. + Assert.GreaterOrEqual( + watcher.LeakedRegistrations, + 1, + "[{0}] LeakedRegistrations must report >=1 while a leaked handle is still live.", + scenario.Kind + ); + observedLeak = watcher.LeakedRegistrations; + } + + Assert.GreaterOrEqual( + observedLeak, + 1, + "[{0}] Watcher must observe at least one leaked registration before disposal.", + scenario.Kind + ); + } + finally + { + // Clean up the leaked handle outside the using block, in a + // finally that runs even if any of the assertions above + // throw (so the next test does not inherit the leaked + // registration). The cleanup is best-effort: a registration + // wiped by a Reset triggered earlier is a no-op here. + if (leakedRegistered) + { + token.RemoveRegistration(leaked); + } + } + yield break; + } + + [UnityTest] + public IEnumerator WatcherThrowsOnLeakWhenConfiguredTo( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(WatcherThrowsOnLeakWhenConfiguredTo) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + LeakWatcher watcher = LeakWatcher.Watch(label: scenario.DisplayName); + MessageRegistrationHandle leaked = RegisterCountingHandler(scenario, token, hostId); + + try + { + Assert.Throws( + watcher.Dispose, + "[{0}] LeakWatcher.Dispose with throwOnLeak=true must surface a failed assertion when registrations leak.", + scenario.Kind + ); + } + finally + { + token.RemoveRegistration(leaked); + } + + yield break; + } + + private static MessageRegistrationHandle RegisterCountingHandler( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargeted( + scenario, + token, + (ref SimpleUntargetedMessage _) => { } + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargeted( + scenario, + token, + target, + (ref SimpleTargetedMessage _) => { } + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcast( + scenario, + token, + target, + (ref SimpleBroadcastMessage _) => { } + ); + } + default: + { + throw new System.ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + } +} +#endif diff --git a/Tests/Runtime/Core/LeakWatcherSelfTests.cs.meta b/Tests/Runtime/Core/LeakWatcherSelfTests.cs.meta new file mode 100644 index 00000000..a019ff35 --- /dev/null +++ b/Tests/Runtime/Core/LeakWatcherSelfTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8b2d50e0c2ad4ed29a16e4c75f4d2a01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/LifecycleEdgeCasesTests.cs b/Tests/Runtime/Core/LifecycleEdgeCasesTests.cs new file mode 100644 index 00000000..d1ce40b1 --- /dev/null +++ b/Tests/Runtime/Core/LifecycleEdgeCasesTests.cs @@ -0,0 +1,1001 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using System.Collections; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Tests.Runtime; + using DxMessaging.Tests.Runtime.Scripts.Components; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + using UnityEngine; + using UnityEngine.SceneManagement; + using UnityEngine.TestTools; + + /// + /// Pins lifecycle invariants that arise from interactions between the + /// DxMessaging bus and Unity GameObject / Token state changes. Tests in + /// this fixture exercise destruction, enable/disable cycles, and token + /// disable/re-enable mid-emission. Scene-loading paths are gated behind + /// the UnityRuntime category because they require the editor's + /// runtime to be live and add several seconds to the wall clock; the + /// rest of the fixture stays in the default suite. + /// + /// + /// + /// IMessageBus-not-IDisposable rationale: does + /// not extend in the public surface because the + /// bus's lifetime is owned by the application (typically the global + /// singleton or an explicit container scope), not by the consumers that + /// register handlers on it. There is no "EmitOnDisposedBus" test in this + /// fixture because the contract is "no handlers means a silent no-op" + /// (pinned by ) plus "Reset + /// invalidates handles via the bus's reset generation" (pinned by + /// ). Adding an + /// explicit dispose contract would push lifetime control onto handler + /// authors; the existing reset-generation guard solves the same problem + /// without that surface area. + /// + /// + public sealed class LifecycleEdgeCasesTests : MessagingTestBase + { + private const int PrefabPoolingCycleCount = 100; + + [UnityTest] + public IEnumerator PrefabPoolingEnableDisableCycles( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(PrefabPoolingEnableDisableCycles) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int handlerCount = 0; + using (LeakWatcher watcher = LeakWatcher.Watch(label: scenario.DisplayName)) + { + MessageRegistrationHandle handle = RegisterCountingHandler( + scenario, + token, + hostId, + () => ++handlerCount + ); + + for (int i = 0; i < PrefabPoolingCycleCount; ++i) + { + host.SetActive(false); + host.SetActive(true); + } + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + handlerCount, + "[{0}] Handler must still receive messages after enable/disable churn (cycles={1}).", + scenario.Kind, + PrefabPoolingCycleCount + ); + + token.RemoveRegistration(handle); + } + + yield break; + } + + [UnityTest] + public IEnumerator TokenDisableMidDispatch( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(TokenDisableMidDispatch) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int aCount = 0; + int bCount = 0; + + // Handler A (priority 0) disables the token, Handler B (priority 1) + // is registered after A so it sees the same emission's snapshot. + MessageRegistrationHandle aHandle = RegisterCountingHandler( + scenario, + token, + hostId, + () => + { + ++aCount; + token.Disable(); + }, + priority: 0 + ); + MessageRegistrationHandle bHandle = RegisterCountingHandler( + scenario, + token, + hostId, + () => ++bCount, + priority: 1 + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + aCount, + "[{0}] Handler A must run on the in-flight emission. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + Assert.AreEqual( + 1, + bCount, + "[{0}] Snapshot semantics: B must still run on the in-flight emission even after Disable. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + aCount, + "[{0}] After Disable, the next emission must NOT invoke A. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + Assert.AreEqual( + 1, + bCount, + "[{0}] After Disable, the next emission must NOT invoke B. aCount={1}, bCount={2}.", + scenario.Kind, + aCount, + bCount + ); + + token.Enable(); + token.RemoveRegistration(aHandle); + token.RemoveRegistration(bHandle); + yield break; + } + + [UnityTest] + public IEnumerator TokenReEnableMidDispatch( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(TokenReEnableMidDispatch) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + // Use a dedicated component so we can disable its token without + // affecting the dispatch loop on the host's token. + GameObject auxHost = new( + nameof(TokenReEnableMidDispatch) + "Aux" + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(auxHost); + EmptyMessageAwareComponent auxComponent = + auxHost.GetComponent(); + MessageRegistrationToken auxToken = GetToken(auxComponent); + + int hostHandlerCount = 0; + int auxHandlerCount = 0; + + // Pre-register an aux handler then Disable its token before the + // first emission, simulating a previously-disabled handler that + // gets re-enabled mid-dispatch on a different bus client. + MessageRegistrationHandle auxHandle = RegisterCountingHandler( + scenario, + auxToken, + hostId, + () => ++auxHandlerCount + ); + auxToken.Disable(); + + MessageRegistrationHandle hostHandle = RegisterCountingHandler( + scenario, + token, + hostId, + () => + { + ++hostHandlerCount; + auxToken.Enable(); + } + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + hostHandlerCount, + "[{0}] Host handler must run on the first emission. host={1}, aux={2}.", + scenario.Kind, + hostHandlerCount, + auxHandlerCount + ); + Assert.AreEqual( + 0, + auxHandlerCount, + "[{0}] Aux handler re-enabled during dispatch must NOT run on current emission (snapshot frozen before Enable). host={1}, aux={2}.", + scenario.Kind, + hostHandlerCount, + auxHandlerCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 2, + hostHandlerCount, + "[{0}] Host handler must run on the second emission. host={1}, aux={2}.", + scenario.Kind, + hostHandlerCount, + auxHandlerCount + ); + Assert.AreEqual( + 1, + auxHandlerCount, + "[{0}] Aux handler must run on the next emission after the re-enable settles. host={1}, aux={2}.", + scenario.Kind, + hostHandlerCount, + auxHandlerCount + ); + + token.RemoveRegistration(hostHandle); + auxToken.RemoveRegistration(auxHandle); + yield break; + } + + /// + /// does NOT extend + /// in the public surface, so an "EmitOnDisposedBus" test does not + /// translate directly. The closest defined behavior is "no handlers + /// registered emit is a silent no-op" - emitting on an empty bus + /// must succeed without throwing. This pins that contract for every + /// kind. + /// + /// + /// Per-kind differentiating assertions: the test asserts the correct + /// per-kind counter remains zero AFTER the emit (as opposed to a + /// kind-agnostic "no exception"), so that a regression in (say) the + /// untargeted path that accidentally bumps the targeted counter + /// would surface as a test failure. + /// + [UnityTest] + public IEnumerator EmitOnEmptyBusIsSilentNoOp( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(EmitOnEmptyBusIsSilentNoOp) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + // Disable the host's token so the only registrations on the bus + // are zero (or the test base's own pristine state). Emitting + // must not throw. + token.Disable(); + + IMessageBus bus = MessageHandler.MessageBus; + int initialUntargeted = bus.RegisteredUntargeted; + int initialTargeted = bus.RegisteredTargeted; + int initialBroadcast = bus.RegisteredBroadcast; + + Assert.DoesNotThrow(() => EmitForScenario(scenario, hostId)); + + // Per-kind assertion: the counter for the emitted kind must + // remain at its baseline (no spurious registrations introduced + // by the empty-bus emit), and so must the OTHER two counters + // (proves the no-op did not leak into another kind's bookkeeping). + switch (scenario.Kind) + { + case MessageKind.Untargeted: + Assert.AreEqual( + initialUntargeted, + bus.RegisteredUntargeted, + "[Untargeted] Empty-bus emit must leave RegisteredUntargeted unchanged." + ); + break; + case MessageKind.Targeted: + Assert.AreEqual( + initialTargeted, + bus.RegisteredTargeted, + "[Targeted] Empty-bus emit must leave RegisteredTargeted unchanged." + ); + break; + case MessageKind.Broadcast: + Assert.AreEqual( + initialBroadcast, + bus.RegisteredBroadcast, + "[Broadcast] Empty-bus emit must leave RegisteredBroadcast unchanged." + ); + break; + } + + // No-leak invariants for the other counters. + Assert.AreEqual( + initialUntargeted, + bus.RegisteredUntargeted, + "[{0}] Untargeted counter must not move after empty-bus emit.", + scenario.Kind + ); + Assert.AreEqual( + initialTargeted, + bus.RegisteredTargeted, + "[{0}] Targeted counter must not move after empty-bus emit.", + scenario.Kind + ); + Assert.AreEqual( + initialBroadcast, + bus.RegisteredBroadcast, + "[{0}] Broadcast counter must not move after empty-bus emit.", + scenario.Kind + ); + + token.Enable(); + yield break; + } + + /// + /// Pins the reset-generation guard introduced by the bus-freezing + /// fix: handlers registered before + /// must NOT be invoked by emissions issued after the reset. The + /// reset increments the bus's internal generation counter; deregister + /// closures captured before the bump short-circuit silently, and the + /// post-reset emit must therefore find no handlers to dispatch to. + /// + [UnityTest] + public IEnumerator EmitImmediatelyAfterResetIsSilentNoOp( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(EmitImmediatelyAfterResetIsSilentNoOp) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int handlerCount = 0; + // Register a handler whose callback would bump handlerCount; we + // never want to see it fire after the reset. + _ = RegisterCountingHandler(scenario, token, hostId, () => ++handlerCount); + + // Reset wipes every registration AND bumps the reset generation + // so any deregister closures captured before the reset turn into + // no-ops. After the reset, emit a fresh message: no handler may + // run because the prior registration was wiped. + DxMessagingStaticState.Reset(); + + Assert.DoesNotThrow( + () => EmitForScenario(scenario, hostId), + "[{0}] Emitting after Reset must not throw.", + scenario.Kind + ); + Assert.AreEqual( + 0, + handlerCount, + "[{0}] Pre-reset handler must NOT fire after Reset (handlerCount={1}).", + scenario.Kind, + handlerCount + ); + + // Defensive sanity: the bus must report zero registrations on + // every counter after Reset, regardless of what the test ran. + IMessageBus bus = MessageHandler.MessageBus; + Assert.AreEqual( + 0, + bus.RegisteredUntargeted, + "[{0}] Untargeted counter must be zero after Reset.", + scenario.Kind + ); + Assert.AreEqual( + 0, + bus.RegisteredTargeted, + "[{0}] Targeted counter must be zero after Reset.", + scenario.Kind + ); + Assert.AreEqual( + 0, + bus.RegisteredBroadcast, + "[{0}] Broadcast counter must be zero after Reset.", + scenario.Kind + ); + Assert.AreEqual( + 0, + bus.RegisteredInterceptors, + "[{0}] Interceptor counter must be zero after Reset.", + scenario.Kind + ); + Assert.AreEqual( + 0, + bus.RegisteredPostProcessors, + "[{0}] Post-processor counter must be zero after Reset.", + scenario.Kind + ); + Assert.AreEqual( + 0, + bus.RegisteredGlobalAcceptAll, + "[{0}] GlobalAcceptAll counter must be zero after Reset.", + scenario.Kind + ); + + yield break; + } + + [UnityTest] + [Category("UnityRuntime")] + public IEnumerator SceneTransitionWithDontDestroyOnLoad( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + // The active scene at test start is the test runner's scene. We + // mark a freshly created GameObject as DontDestroyOnLoad so it + // survives a scene unload, then load an empty scene additively + // to simulate a transition without touching the test runner + // scene. This avoids destroying the test runner's GameObjects + // (which would terminate the test prematurely). + GameObject host = new( + nameof(SceneTransitionWithDontDestroyOnLoad) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + UnityEngine.Object.DontDestroyOnLoad(host); + _spawned.Add(host); + + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int handlerCount = 0; + MessageRegistrationHandle handle = RegisterCountingHandler( + scenario, + token, + hostId, + () => ++handlerCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + handlerCount, + "[{0}] DDOL handler must receive its first emission.", + scenario.Kind + ); + + // Create + unload an empty scene additively; the host should + // survive thanks to DontDestroyOnLoad. + Scene transient = SceneManager.CreateScene( + nameof(SceneTransitionWithDontDestroyOnLoad) + scenario.Kind + "-Transient" + ); + yield return null; + + AsyncOperation unload = SceneManager.UnloadSceneAsync(transient); + while (unload != null && !unload.isDone) + { + yield return null; + } + + // After the additive scene unloads, the DDOL host must still be + // alive and continue receiving messages. + Assert.IsTrue( + host != null, + "[{0}] DDOL host must survive the additive scene unload.", + scenario.Kind + ); + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 2, + handlerCount, + "[{0}] DDOL handler must continue to receive messages after scene transition.", + scenario.Kind + ); + + token.RemoveRegistration(handle); + yield break; + } + + /// + /// Pins that a registration made from inside a closure subscribed to + /// becomes immediately effective + /// for subsequent emissions, with no deferred-frame requirement. The + /// subscribe / unsubscribe pair documents the API surface a user would + /// touch (the standard delegate pattern for Unity scene events). + /// + /// + /// + /// This test does NOT drive a real scene load. Per Unity's documented + /// contract, only fires from + /// LoadScene / LoadSceneAsync, never from + /// CreateScene; and LoadSceneAsync requires a scene + /// asset present in BuildSettings, which a package's own unit-test + /// suite cannot rely on. The closure is therefore invoked manually so + /// the test is deterministic across EditMode and PlayMode while still + /// pinning the contract that matters: a registration installed from + /// inside a user callback flows through the bus's normal dispatch + /// path on the very next emission. + /// + /// + [UnityTest] + public IEnumerator RegisterFromInsideSceneLoadedClosureBecomesImmediatelyEffective( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(RegisterFromInsideSceneLoadedClosureBecomesImmediatelyEffective) + + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + IMessageBus bus = MessageHandler.MessageBus; + int initialUntargeted = bus.RegisteredUntargeted; + int initialTargeted = bus.RegisteredTargeted; + int initialBroadcast = bus.RegisteredBroadcast; + + int handlerCount = 0; + MessageRegistrationHandle? deferredHandle = null; + + // Closure stored in a UnityAction so the same delegate instance + // can be both subscribed and unsubscribed. Lambda parameter + // discards (Scene _, LoadSceneMode _) follow the project + // convention; local functions cannot reuse `_` as a parameter + // name across slots. + UnityEngine.Events.UnityAction onSceneLoaded = ( + Scene _, + LoadSceneMode _ + ) => + { + deferredHandle ??= RegisterCountingHandler( + scenario, + token, + hostId, + () => ++handlerCount + ); + }; + + // Subscribe / unsubscribe pair documents the API surface users + // would touch even though the closure is invoked manually below. + SceneManager.sceneLoaded += onSceneLoaded; + try + { + // Manual invoke: see the XML doc above. SceneManager.CreateScene + // does not raise sceneLoaded, and LoadSceneAsync needs a scene + // asset in BuildSettings, so we drive the closure directly to + // exercise the registration code path deterministically. + Scene activeScene = SceneManager.GetActiveScene(); + onSceneLoaded(activeScene, LoadSceneMode.Additive); + + Assert.IsTrue( + deferredHandle.HasValue, + "[{0}] sceneLoaded closure must have installed the handler.", + scenario.Kind + ); + + // Defensive: assert the bus's per-kind counter actually moved + // by exactly 1. Guards against a regression where the closure + // runs but the registration silently fails to install on the + // bus (e.g. a future short-circuit path). + switch (scenario.Kind) + { + case MessageKind.Untargeted: + Assert.AreEqual( + initialUntargeted + 1, + bus.RegisteredUntargeted, + "[Untargeted] Bus RegisteredUntargeted must increase by exactly 1 after the in-closure registration." + ); + break; + case MessageKind.Targeted: + Assert.AreEqual( + initialTargeted + 1, + bus.RegisteredTargeted, + "[Targeted] Bus RegisteredTargeted must increase by exactly 1 after the in-closure registration." + ); + break; + case MessageKind.Broadcast: + Assert.AreEqual( + initialBroadcast + 1, + bus.RegisteredBroadcast, + "[Broadcast] Bus RegisteredBroadcast must increase by exactly 1 after the in-closure registration." + ); + break; + } + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + handlerCount, + "[{0}] Handler registered from inside the sceneLoaded closure must receive the next emission via the bus's dispatch path.", + scenario.Kind + ); + } + finally + { + SceneManager.sceneLoaded -= onSceneLoaded; + if (deferredHandle.HasValue) + { + token.RemoveRegistration(deferredHandle.Value); + } + } + + yield break; + } + + /// + /// Pins that destroying the host GameObject mid-emission via + /// (deferred destroy) does + /// not crash the dispatch loop. The current emission completes + /// against its frozen snapshot; the next emission (after a frame + /// for the destroy to flush) must skip the destroyed handler. + /// + [UnityTest] + public IEnumerator HostDestroyMidDispatchDoesNotCrash( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(HostDestroyMidDispatchDoesNotCrash) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + // A peer host so we can keep dispatching after host destruction. + GameObject peer = new( + nameof(HostDestroyMidDispatchDoesNotCrash) + scenario.Kind + "-Peer", + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(peer); + EmptyMessageAwareComponent peerComponent = + peer.GetComponent(); + MessageRegistrationToken peerToken = GetToken(peerComponent); + + int peerCount = 0; + int destroyedCount = 0; + MessageRegistrationHandle peerHandle = RegisterCountingHandler( + scenario, + peerToken, + hostId, + () => ++peerCount, + priority: 1 + ); + _ = RegisterCountingHandler( + scenario, + token, + hostId, + () => + { + ++destroyedCount; + UnityEngine.Object.Destroy(host); + }, + priority: 0 + ); + + Assert.DoesNotThrow(() => EmitForScenario(scenario, hostId)); + Assert.AreEqual( + 1, + destroyedCount, + "[{0}] Self-destroying handler must run exactly once.", + scenario.Kind + ); + Assert.AreEqual( + 1, + peerCount, + "[{0}] Peer handler must complete on the in-flight snapshot. peerCount={1}.", + scenario.Kind, + peerCount + ); + + // Yield a frame so Object.Destroy is processed and the + // destroyed component's OnDestroy unregisters it. + yield return null; + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + destroyedCount, + "[{0}] Destroyed host's handler must NOT fire on subsequent emits.", + scenario.Kind + ); + + peerToken.RemoveRegistration(peerHandle); + yield break; + } + + /// + /// Pins the mid-dispatch scene unload contract: a handler firing on an + /// in-flight emission triggers + /// against the scene that owns the handler's host. The current + /// emission's remaining handlers (in priority order) must complete + /// against their frozen snapshot, the bus must not throw, and on the + /// NEXT emission - after the unload completes - no handler from the + /// unloaded scene can fire. + /// + [UnityTest] + [Category("UnityRuntime")] + public IEnumerator SceneUnloadMidDispatchDrainsInFlightEmission( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + // Create a transient scene; the handler-host lives there. + Scene transient = SceneManager.CreateScene( + nameof(SceneUnloadMidDispatchDrainsInFlightEmission) + scenario.Kind + "-Transient" + ); + yield return null; + + GameObject host = new( + nameof(SceneUnloadMidDispatchDrainsInFlightEmission) + scenario.Kind + "-Host", + typeof(EmptyMessageAwareComponent) + ); + SceneManager.MoveGameObjectToScene(host, transient); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + // Peer host in the test runner's active scene so we can keep + // dispatching after the transient scene unloads. + GameObject peer = new( + nameof(SceneUnloadMidDispatchDrainsInFlightEmission) + scenario.Kind + "-Peer", + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(peer); + EmptyMessageAwareComponent peerComponent = + peer.GetComponent(); + MessageRegistrationToken peerToken = GetToken(peerComponent); + + int peerCount = 0; + int hostCount = 0; + AsyncOperation unloadOp = null; + bool emittedFromHost = false; + + // Peer at priority 1 to assert it still runs on the in-flight + // emission AFTER the host's priority-0 handler triggers an + // unload. + MessageRegistrationHandle peerHandle = RegisterCountingHandler( + scenario, + peerToken, + hostId, + () => ++peerCount, + priority: 1 + ); + _ = RegisterCountingHandler( + scenario, + token, + hostId, + () => + { + ++hostCount; + if (!emittedFromHost) + { + emittedFromHost = true; + unloadOp = SceneManager.UnloadSceneAsync(transient); + } + }, + priority: 0 + ); + + Assert.DoesNotThrow( + () => EmitForScenario(scenario, hostId), + "[{0}] Bus must not throw when a handler triggers UnloadSceneAsync mid-dispatch.", + scenario.Kind + ); + Assert.AreEqual( + 1, + hostCount, + "[{0}] Host handler must run on the in-flight emission. host={1}, peer={2}.", + scenario.Kind, + hostCount, + peerCount + ); + Assert.AreEqual( + 1, + peerCount, + "[{0}] Peer handler must complete the in-flight emission's snapshot. host={1}, peer={2}.", + scenario.Kind, + hostCount, + peerCount + ); + + // Wait for the actual scene unload to finish before re-emitting. + while (unloadOp != null && !unloadOp.isDone) + { + yield return null; + } + // One more frame so OnDestroy callbacks land. + yield return null; + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + hostCount, + "[{0}] After scene unload, the unloaded handler must NOT fire. host={1}, peer={2}.", + scenario.Kind, + hostCount, + peerCount + ); + Assert.AreEqual( + 2, + peerCount, + "[{0}] Peer handler must continue receiving emissions after the unload. host={1}, peer={2}.", + scenario.Kind, + hostCount, + peerCount + ); + + peerToken.RemoveRegistration(peerHandle); + yield break; + } + + /// + /// Pins that calling OnApplicationQuit on a registered + /// drains cleanly: no + /// exceptions thrown, and (under ) no + /// registration leaks remain. Production code overrides + /// OnApplicationQuit to log/persist on shutdown; the bus + /// should tolerate the call without surfacing errors. + /// + [UnityTest] + [Category("UnityRuntime")] + public IEnumerator OnApplicationQuitDrainsCleanly( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(OnApplicationQuitDrainsCleanly) + scenario.Kind, + typeof(QuitOnDemandMessageAwareComponent) + ); + _spawned.Add(host); + QuitOnDemandMessageAwareComponent component = + host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int handlerCount = 0; + using (LeakWatcher watcher = LeakWatcher.Watch(label: scenario.DisplayName)) + { + MessageRegistrationHandle handle = RegisterCountingHandler( + scenario, + token, + hostId, + () => ++handlerCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + handlerCount, + "[{0}] Handler must run before quit.", + scenario.Kind + ); + + // Drive OnApplicationQuit explicitly. + Assert.DoesNotThrow( + () => component.RaiseOnApplicationQuit(), + "[{0}] OnApplicationQuit must not throw.", + scenario.Kind + ); + + token.RemoveRegistration(handle); + } + + yield break; + } + + private static MessageRegistrationHandle RegisterCountingHandler( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + Action onInvoked, + int priority = 0 + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargeted( + scenario, + token, + (ref SimpleUntargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargeted( + scenario, + token, + target, + (ref SimpleTargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcast( + scenario, + token, + target, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static void EmitForScenario(MessageScenario scenario, InstanceId target) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + SimpleUntargetedMessage message = new(); + ScenarioHarness.EmitUntargeted(scenario, ref message); + return; + } + case MessageKind.Targeted: + { + SimpleTargetedMessage message = new(); + ScenarioHarness.EmitTargeted(scenario, ref message, target); + return; + } + case MessageKind.Broadcast: + { + SimpleBroadcastMessage message = new(); + ScenarioHarness.EmitBroadcast(scenario, ref message, target); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + } +} +#endif diff --git a/Tests/Runtime/Core/LifecycleEdgeCasesTests.cs.meta b/Tests/Runtime/Core/LifecycleEdgeCasesTests.cs.meta new file mode 100644 index 00000000..784e7a79 --- /dev/null +++ b/Tests/Runtime/Core/LifecycleEdgeCasesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5ad19e7faa224e1bb29df72a4eaf3322 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/MessageHandlerGlobalBusTests.cs b/Tests/Runtime/Core/MessageHandlerGlobalBusTests.cs index f912c1e4..74206bb3 100644 --- a/Tests/Runtime/Core/MessageHandlerGlobalBusTests.cs +++ b/Tests/Runtime/Core/MessageHandlerGlobalBusTests.cs @@ -89,6 +89,12 @@ public WrapperMessageBus(IMessageBus inner) public int RegisteredUntargeted => _inner.RegisteredUntargeted; + public int RegisteredInterceptors => _inner.RegisteredInterceptors; + + public int RegisteredPostProcessors => _inner.RegisteredPostProcessors; + + public int RegisteredGlobalAcceptAll => _inner.RegisteredGlobalAcceptAll; + public RegistrationLog Log => _inner.Log; public long EmissionId => _inner.EmissionId; diff --git a/Tests/Runtime/Core/PublicSurfaceContractTests.cs b/Tests/Runtime/Core/PublicSurfaceContractTests.cs new file mode 100644 index 00000000..c78d950b --- /dev/null +++ b/Tests/Runtime/Core/PublicSurfaceContractTests.cs @@ -0,0 +1,385 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Reflection; + using System.Text; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Tests.Runtime; + using NUnit.Framework; + using UnityEngine; + using UnityEngine.TestTools; + + /// + /// Pins invariants on the DxMessaging public surface. The fixture is + /// intentionally read-only (it does NOT touch the bus during the + /// tests), so it can run in the default suite without a noticeable + /// wall-clock cost. + /// + public sealed class PublicSurfaceContractTests + { + private const string SnapshotResourceFolderName = "Snapshots"; + private const string PublicSurfaceSnapshotFileName = "public-surface.txt"; + + private const string CoreNamespacePrefix = "DxMessaging.Core"; + + /// + /// Enumerates every public type in the + /// DxMessaging.Core namespace (and its sub-namespaces) and + /// compares the sorted list to a stored snapshot. The snapshot file + /// is generated on first run if missing; subsequent runs fail when + /// the type list drifts. The diff format is one fully-qualified + /// name per line so a failing test points directly at the + /// added/removed type. + /// + [Test] + public void PublicTypeSetInDxMessagingCoreNamespaceMatchesSnapshot() + { + List live = EnumeratePublicCoreTypeNames(); + string liveSnapshot = string.Join("\n", live); + + string snapshotPath = TryResolveSnapshotPath(out bool snapshotPathResolved); + + if (!snapshotPathResolved) + { + Assert.Inconclusive( + "Could not resolve the public-surface snapshot path. The Tests/Runtime/Core/Snapshots " + + "directory must exist in the package source tree (it is excluded from build " + + "but not from the editor's reflection of test assemblies). Skipping the diff check." + ); + return; + } + + if (!File.Exists(snapshotPath)) + { + // Auto-generate the snapshot for the developer's convenience, then + // FAIL the test so the snapshot is not silently rubber-stamped. + File.WriteAllText(snapshotPath, liveSnapshot, new UTF8Encoding(false)); + Assert.Fail( + "Public surface snapshot was missing and has been auto-generated at " + + $"{snapshotPath}. Run the test once locally, review the auto-generated " + + "snapshot (and its .meta sibling), and commit it. The test will only " + + "pass with a committed snapshot." + ); + return; + } + + string stored = File.ReadAllText(snapshotPath).Replace("\r\n", "\n").TrimEnd(); + string actual = liveSnapshot.TrimEnd(); + + if (string.Equals(stored, actual, StringComparison.Ordinal)) + { + return; + } + + HashSet storedSet = new HashSet( + stored.Split('\n', StringSplitOptions.RemoveEmptyEntries), + StringComparer.Ordinal + ); + HashSet actualSet = new HashSet( + actual.Split('\n', StringSplitOptions.RemoveEmptyEntries), + StringComparer.Ordinal + ); + + List added = actualSet + .Except(storedSet) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + List removed = storedSet + .Except(actualSet) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + + string addedTxt = added.Count == 0 ? "(none)" : string.Join("\n ", added); + string removedTxt = removed.Count == 0 ? "(none)" : string.Join("\n ", removed); + + Assert.Fail( + $"DxMessaging.Core public surface drift detected. Snapshot file: {snapshotPath}\n" + + $"Added types:\n {addedTxt}\n" + + $"Removed types:\n {removedTxt}\n" + + "If the change is intentional, regenerate the snapshot by deleting the file and re-running this test." + ); + } + + /// + /// Enumerates every method on and asserts + /// each method name appears at least once as a textual token in the + /// runtime test source tree. This is intentionally a substring grep: + /// the test confirms each method is mentioned somewhere in the + /// fixtures (which is a strong proxy that someone has invoked or + /// referenced it), not that every method is exercised by a real + /// invocation inside a [Test]/[UnityTest] body. A + /// stronger structural check would require Roslyn (not currently a + /// dependency of the runtime test asmdef) or a runtime invocation + /// trace; until that lands, the substring grep is our pragmatic + /// floor. + /// + /// + /// The historical name of this test was + /// EveryIMessageBusMethodHasAtLeastOneTest; the rename clarifies + /// what is actually being checked so failure messages do not overstate + /// coverage. + /// + [Test] + public void EveryIMessageBusMethodIsTextuallyMentioned() + { + HashSet methodNames = new HashSet( + typeof(IMessageBus) + .GetMethods( + BindingFlags.Public + | BindingFlags.Instance + | BindingFlags.DeclaredOnly + | BindingFlags.Static + ) + .Where(m => !m.IsSpecialName) + .Select(m => m.Name), + StringComparer.Ordinal + ); + + // Walk the source files for the test assembly. We keep a list + // of search roots: the parent of one well-known test fixture's + // file (resolved from the running assembly metadata). + List searchRoots = ResolveTestSourceRoots(); + if (searchRoots.Count == 0) + { + Assert.Inconclusive( + "Could not resolve any test source root for IMessageBus method coverage check." + ); + return; + } + + HashSet covered = new HashSet(StringComparer.Ordinal); + foreach (string root in searchRoots) + { + if (!Directory.Exists(root)) + { + continue; + } + + foreach ( + string file in Directory.EnumerateFiles( + root, + "*.cs", + SearchOption.AllDirectories + ) + ) + { + string text; + try + { + text = File.ReadAllText(file); + } + catch (IOException) + { + continue; + } + + foreach (string name in methodNames) + { + if (covered.Contains(name)) + { + continue; + } + + if (text.IndexOf(name, StringComparison.Ordinal) >= 0) + { + covered.Add(name); + } + } + } + } + + List missing = methodNames + .Except(covered) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + Assert.That( + missing, + Is.Empty, + "IMessageBus methods with no textual mention anywhere in the test source tree (this is " + + "a substring grep, not a structural call-site check):\n " + + string.Join("\n ", missing) + ); + } + + /// + /// Tightens the existing + /// + /// invariant by enumerating directly and + /// asserting every value appears in + /// . The contract tests already + /// pin this; this test is an explicit duplicate so a mistake in one + /// location surfaces in the other. + /// + [Test] + public void EveryMessageKindAppearsInAllKinds() + { + HashSet covered = new HashSet( + MessageScenarios.AllKinds.Select(scenario => scenario.Kind) + ); + + List missing = new List(); + foreach (MessageKind kind in Enum.GetValues(typeof(MessageKind))) + { + if (!covered.Contains(kind)) + { + missing.Add(kind.ToString()); + } + } + + Assert.That( + missing, + Is.Empty, + "MessageScenarios.AllKinds must yield every MessageKind. Missing: " + + string.Join(", ", missing) + ); + } + + private static List EnumeratePublicCoreTypeNames() + { + HashSet seen = new HashSet(StringComparer.Ordinal); + List names = new List(); + + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types.Where(t => t != null).ToArray(); + } + + foreach (Type type in types) + { + if (!type.IsPublic && !type.IsNestedPublic) + { + continue; + } + + if (type.Namespace == null) + { + continue; + } + + if ( + !type.Namespace.Equals(CoreNamespacePrefix, StringComparison.Ordinal) + && !type.Namespace.StartsWith( + CoreNamespacePrefix + ".", + StringComparison.Ordinal + ) + ) + { + continue; + } + + string fullName = type.FullName; + if (string.IsNullOrEmpty(fullName)) + { + continue; + } + + if (seen.Add(fullName)) + { + names.Add(fullName); + } + } + } + + names.Sort(StringComparer.Ordinal); + return names; + } + + private static string TryResolveSnapshotPath(out bool resolved) + { + // Walk up from the running assembly's location toward the + // package root. The snapshot lives at + // Tests/Runtime/Core/Snapshots/public-surface.txt relative to + // the package's content root. Application.dataPath is the + // Unity project's Assets/, but the package may be a + // package-manager package outside of Assets/, so we instead + // probe for the well-known directory chain. + string[] candidates = + { + Path.Combine( + Application.dataPath, + "..", + "Packages", + "com.wallstop-studios.dxmessaging", + "Tests", + "Runtime", + "Core", + SnapshotResourceFolderName, + PublicSurfaceSnapshotFileName + ), + Path.Combine( + Application.dataPath, + "..", + "Tests", + "Runtime", + "Core", + SnapshotResourceFolderName, + PublicSurfaceSnapshotFileName + ), + Path.Combine( + Directory.GetCurrentDirectory(), + "Tests", + "Runtime", + "Core", + SnapshotResourceFolderName, + PublicSurfaceSnapshotFileName + ), + }; + + foreach (string candidate in candidates) + { + string full = Path.GetFullPath(candidate); + string parent = Path.GetDirectoryName(full); + if (parent != null && Directory.Exists(parent)) + { + resolved = true; + return full; + } + } + + resolved = false; + return string.Empty; + } + + private static List ResolveTestSourceRoots() + { + List roots = new List(); + string[] candidates = + { + Path.Combine( + Application.dataPath, + "..", + "Packages", + "com.wallstop-studios.dxmessaging", + "Tests", + "Runtime" + ), + Path.Combine(Application.dataPath, "..", "Tests", "Runtime"), + Path.Combine(Directory.GetCurrentDirectory(), "Tests", "Runtime"), + }; + + foreach (string candidate in candidates) + { + string full = Path.GetFullPath(candidate); + if (Directory.Exists(full)) + { + roots.Add(full); + } + } + + return roots; + } + } +} +#endif diff --git a/Tests/Runtime/Core/PublicSurfaceContractTests.cs.meta b/Tests/Runtime/Core/PublicSurfaceContractTests.cs.meta new file mode 100644 index 00000000..decd75c2 --- /dev/null +++ b/Tests/Runtime/Core/PublicSurfaceContractTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a4b5c6d7e8f4011a1b2c3d4e5f60718 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/ReentrantEmissionExtendedTests.cs b/Tests/Runtime/Core/ReentrantEmissionExtendedTests.cs new file mode 100644 index 00000000..9044ea71 --- /dev/null +++ b/Tests/Runtime/Core/ReentrantEmissionExtendedTests.cs @@ -0,0 +1,1003 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using System.Collections; + using System.Collections.Generic; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using DxMessaging.Tests.Runtime; + using DxMessaging.Tests.Runtime.Scripts.Components; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using NUnit.Framework; + using UnityEngine; + using UnityEngine.TestTools; + + /// + /// Extends the coverage. Each test in + /// this fixture either crosses message-kind boundaries (a handler for kind + /// X emits kind Y from inside its callback) or stresses a re-entrancy + /// corner case the base fixture does not cover (interceptor-veto during + /// a re-emit, deep recursion, mid-dispatch self-resubscribe). Per the + /// project parameterization rule the tests still drive + /// so the same test name covers + /// every entry point. + /// + public sealed class ReentrantEmissionExtendedTests : MessagingTestBase + { + private const int DeepRecursionLimit = 10; + + /// + /// Yields every (outer, inner) pair from + /// . The diagonal (same-kind) + /// is intentionally excluded because same-kind reentrancy already has + /// dedicated coverage in ; the + /// cross-product produces 6 pairs (3 kinds * 3 kinds - 3 diagonal). + /// + public static IEnumerable CrossKindReentrancyPairs + { + get + { + foreach (MessageScenario outer in MessageScenarios.AllKinds) + { + foreach (MessageScenario inner in MessageScenarios.AllKinds) + { + if (outer.Kind == inner.Kind) + { + continue; + } + + yield return new CrossKindReentrancyCase(outer, inner); + } + } + } + } + + /// + /// Pair (outer, inner) consumed by + /// . Wraps two + /// values so the parameter source can + /// surface a stable, readable display name in the test runner. + /// + public sealed class CrossKindReentrancyCase + { + public MessageScenario Outer { get; } + public MessageScenario Inner { get; } + + public CrossKindReentrancyCase(MessageScenario outer, MessageScenario inner) + { + Outer = outer; + Inner = inner; + } + + public override string ToString() + { + return $"{Outer.Kind}->{Inner.Kind}"; + } + } + + /// + /// A handler that fires on 's outer kind + /// triggers an emission of the pair's inner kind from inside its + /// callback. Both chains must complete in order without deadlock. + /// Test count: 6 (every cross-kind permutation excluding the + /// same-kind diagonal, which is exercised by + /// ). + /// + [UnityTest] + public IEnumerator CrossKindReentrancyChainCompletes( + [ValueSource(nameof(CrossKindReentrancyPairs))] CrossKindReentrancyCase pair + ) + { + MessageScenario scenario = pair.Outer; + MessageScenario innerScenario = pair.Inner; + + GameObject host = new( + nameof(CrossKindReentrancyChainCompletes) + scenario.Kind + innerScenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int outerCount = 0; + int innerCount = 0; + List trace = new List(4); + + _ = RegisterCountingHandler( + innerScenario, + token, + hostId, + () => + { + trace.Add("inner"); + ++innerCount; + } + ); + MessageRegistrationHandle outerHandle = RegisterCountingHandler( + scenario, + token, + hostId, + () => + { + trace.Add("outer-start"); + ++outerCount; + EmitForScenario(innerScenario, hostId); + trace.Add("outer-end"); + } + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + outerCount, + "[{0}->{1}] Outer handler must run exactly once. trace=[{2}]", + scenario.Kind, + innerScenario.Kind, + string.Join(",", trace) + ); + Assert.AreEqual( + 1, + innerCount, + "[{0}->{1}] Inner handler must run inside outer's callback. trace=[{2}]", + scenario.Kind, + innerScenario.Kind, + string.Join(",", trace) + ); + Assert.AreEqual( + "outer-start", + trace[0], + "[{0}->{1}] Outer must begin before inner. trace=[{2}]", + scenario.Kind, + innerScenario.Kind, + string.Join(",", trace) + ); + Assert.AreEqual( + "inner", + trace[1], + "[{0}->{1}] Inner must run between outer's two halves. trace=[{2}]", + scenario.Kind, + innerScenario.Kind, + string.Join(",", trace) + ); + Assert.AreEqual( + "outer-end", + trace[2], + "[{0}->{1}] Outer must complete after inner returns. trace=[{2}]", + scenario.Kind, + innerScenario.Kind, + string.Join(",", trace) + ); + + token.RemoveRegistration(outerHandle); + yield break; + } + + /// + /// Self-recursion bounded at levels. + /// Cross-checks invocation count via the test-side depth + /// counter AND via the bus's public + /// counter, which the bus + /// increments once per emit. Each nested emit must bump + /// EmissionId, so the difference between the entry and exit + /// EmissionId values is a hard invariant separate from the + /// production-side bool used to gate recursion. + /// + [UnityTest] + public IEnumerator DeepRecursion10Levels( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(DeepRecursion10Levels) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int depth = 0; + int invocations = 0; + IMessageBus bus = MessageHandler.MessageBus; + long initialEmissionId = bus.EmissionId; + + MessageRegistrationHandle handle = RegisterCountingHandler( + scenario, + token, + hostId, + () => + { + ++invocations; + if (depth >= DeepRecursionLimit) + { + return; + } + + ++depth; + try + { + EmitForScenario(scenario, hostId); + } + finally + { + --depth; + } + } + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + DeepRecursionLimit + 1, + invocations, + "[{0}] Self-recursion bounded at {1} levels must invoke handler {2} times.", + scenario.Kind, + DeepRecursionLimit, + DeepRecursionLimit + 1 + ); + + // EmissionId invariant: every emit (outer + each nested re-emit) + // bumps the counter once. The 11 invocations are the result of + // 11 emits (one outer + ten recursive), so the EmissionId must + // advance by at least 11 between entry and exit. Checking + // ">=" rather than "==" tolerates background frame emits that + // a Unity test runner may interleave. + long deltaEmissions = bus.EmissionId - initialEmissionId; + Assert.GreaterOrEqual( + deltaEmissions, + DeepRecursionLimit + 1, + "[{0}] Bus EmissionId must advance by at least {1} during deep recursion (saw {2}).", + scenario.Kind, + DeepRecursionLimit + 1, + deltaEmissions + ); + + token.RemoveRegistration(handle); + yield break; + } + + [UnityTest] + public IEnumerator RecursionWithPriorityHandlersRespectsOrderingPerEmission( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(RecursionWithPriorityHandlersRespectsOrderingPerEmission) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + List trace = new List(); + int depth = 0; + + // Three priorities: 0, 5, 10. The middle priority emits a + // recursive message; the other two record their slot. Each + // emission must record [p0, p5(re-emit), p10] in order, with the + // reentrant inner emission interleaved between p5's start and + // p10's run on the outer emission. + MessageRegistrationHandle p0Handle = RegisterCountingHandler( + scenario, + token, + hostId, + () => trace.Add($"d{depth}:p0"), + priority: 0 + ); + MessageRegistrationHandle p5Handle = RegisterCountingHandler( + scenario, + token, + hostId, + () => + { + trace.Add($"d{depth}:p5-start"); + if (depth < 1) + { + ++depth; + try + { + EmitForScenario(scenario, hostId); + } + finally + { + --depth; + } + } + trace.Add($"d{depth}:p5-end"); + }, + priority: 5 + ); + MessageRegistrationHandle p10Handle = RegisterCountingHandler( + scenario, + token, + hostId, + () => trace.Add($"d{depth}:p10"), + priority: 10 + ); + + EmitForScenario(scenario, hostId); + + // Expected sequence at the outer emission: + // d0:p0 + // d0:p5-start + // d1:p0 + // d1:p5-start (depth==1, no recurse) + // d1:p5-end + // d1:p10 + // d0:p5-end + // d0:p10 + // This shows that priority order is preserved INSIDE each + // emission frame, even though the inner emission interleaves. + string[] expected = + { + "d0:p0", + "d0:p5-start", + "d1:p0", + "d1:p5-start", + "d1:p5-end", + "d1:p10", + "d0:p5-end", + "d0:p10", + }; + CollectionAssert.AreEqual( + expected, + trace, + "[{0}] Priority order must be preserved per emission frame. trace=[{1}]", + scenario.Kind, + string.Join(",", trace) + ); + + token.RemoveRegistration(p0Handle); + token.RemoveRegistration(p5Handle); + token.RemoveRegistration(p10Handle); + yield break; + } + + /// + /// A handler unsubscribes itself and immediately re-subscribes. The + /// re-subscribed handler must NOT run on the in-flight emission + /// (snapshot semantics) but MUST run on the next emission. + /// + [UnityTest] + public IEnumerator ReentrantUnsubscribeThenResubscribeSelf( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(ReentrantUnsubscribeThenResubscribeSelf) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int firstHandlerCount = 0; + int respawnedHandlerCount = 0; + MessageRegistrationHandle firstHandle = default; + MessageRegistrationHandle? respawnedHandle = null; + + firstHandle = RegisterCountingHandler( + scenario, + token, + hostId, + () => + { + ++firstHandlerCount; + if (firstHandle != default) + { + token.RemoveRegistration(firstHandle); + firstHandle = default; + } + + respawnedHandle ??= RegisterCountingHandler( + scenario, + token, + hostId, + () => ++respawnedHandlerCount + ); + } + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + firstHandlerCount, + "[{0}] First handler must complete its in-flight emission. first={1}, respawned={2}.", + scenario.Kind, + firstHandlerCount, + respawnedHandlerCount + ); + Assert.AreEqual( + 0, + respawnedHandlerCount, + "[{0}] Re-subscribed handler must NOT run on the same emission. first={1}, respawned={2}.", + scenario.Kind, + firstHandlerCount, + respawnedHandlerCount + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + firstHandlerCount, + "[{0}] First handler must NOT run after self-unsub. first={1}, respawned={2}.", + scenario.Kind, + firstHandlerCount, + respawnedHandlerCount + ); + Assert.AreEqual( + 1, + respawnedHandlerCount, + "[{0}] Re-subscribed handler must run on the next emission. first={1}, respawned={2}.", + scenario.Kind, + firstHandlerCount, + respawnedHandlerCount + ); + + if (respawnedHandle.HasValue) + { + token.RemoveRegistration(respawnedHandle.Value); + } + yield break; + } + + /// + /// An inner re-emit throws inside a nested handler. The outer + /// emission's remaining handlers must still abort consistently + /// with : bus does not swallow + /// exceptions, propagation aborts the bucket walk on the outer + /// frame too. + /// + [UnityTest] + public IEnumerator NestedHandlerThrowsDuringReentrantEmit( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(NestedHandlerThrowsDuringReentrantEmit) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + const string ThrowMessage = "DxMessaging-test-nested-reentrant-throw"; + int outerStartCount = 0; + int outerTrailingCount = 0; + int innerCount = 0; + int depth = 0; + + // Outer at p0 self-emits, recursing once. The inner depth-1 + // handler throws. The exception propagates out of the inner + // emission, through the outer p0 handler's body, and aborts + // the outer p1 handler. + MessageRegistrationHandle outerHandle = RegisterCountingHandler( + scenario, + token, + hostId, + () => + { + if (depth == 0) + { + ++outerStartCount; + ++depth; + try + { + EmitForScenario(scenario, hostId); + } + finally + { + --depth; + } + } + else + { + ++innerCount; + throw new InvalidOperationException(ThrowMessage); + } + }, + priority: 0 + ); + MessageRegistrationHandle trailingHandle = RegisterCountingHandler( + scenario, + token, + hostId, + () => ++outerTrailingCount, + priority: 1 + ); + + InvalidOperationException captured = Assert.Throws(() => + EmitForScenario(scenario, hostId) + ); + Assert.AreEqual(ThrowMessage, captured.Message); + Assert.AreEqual( + 1, + outerStartCount, + "[{0}] Outer must begin its recursion exactly once. outerStart={1}, inner={2}, trailing={3}.", + scenario.Kind, + outerStartCount, + innerCount, + outerTrailingCount + ); + Assert.AreEqual( + 1, + innerCount, + "[{0}] Inner depth-1 handler must run and throw. outerStart={1}, inner={2}, trailing={3}.", + scenario.Kind, + outerStartCount, + innerCount, + outerTrailingCount + ); + Assert.AreEqual( + 0, + outerTrailingCount, + "[{0}] Outer trailing handler must NOT run after inner throws. outerStart={1}, inner={2}, trailing={3}.", + scenario.Kind, + outerStartCount, + innerCount, + outerTrailingCount + ); + + token.RemoveRegistration(outerHandle); + token.RemoveRegistration(trailingHandle); + yield break; + } + + /// + /// An interceptor cancels (returns false) during a re-emit. The + /// outer emission's remaining handlers must still run because the + /// interceptor only cancels the inner re-emit. The test records a + /// trace list so the assertion is order-explicit (not just count- + /// based): the interceptor must fire for every emission, and the + /// trailing handler must run AFTER the vetoed inner emission, on + /// the outer emission's frame. + /// + [UnityTest] + public IEnumerator ReentrantInterceptorVeto( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(ReentrantInterceptorVeto) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + int depth = 0; + int interceptorCount = 0; + int outerCount = 0; + int innerHandlerCount = 0; + int trailingCount = 0; + List trace = new List(8); + + // Interceptor cancels at depth >= 1, allows depth 0. + RegisterDepthLimitedInterceptor( + scenario, + token, + threshold: 1, + getDepth: () => depth, + onInvoked: () => + { + trace.Add($"d{depth}:interceptor"); + ++interceptorCount; + } + ); + + MessageRegistrationHandle outerHandle = RegisterCountingHandler( + scenario, + token, + hostId, + () => + { + if (depth == 0) + { + ++outerCount; + trace.Add("d0:outer-start"); + ++depth; + try + { + EmitForScenario(scenario, hostId); + } + finally + { + --depth; + } + trace.Add("d0:outer-end"); + } + else + { + ++innerHandlerCount; + trace.Add("d1:inner"); + } + }, + priority: 0 + ); + MessageRegistrationHandle trailingHandle = RegisterCountingHandler( + scenario, + token, + hostId, + () => + { + ++trailingCount; + trace.Add($"d{depth}:trailing"); + }, + priority: 1 + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + outerCount, + "[{0}] Outer must run on depth 0. outer={1}, innerHandler={2}, trailing={3}, interceptor={4}.", + scenario.Kind, + outerCount, + innerHandlerCount, + trailingCount, + interceptorCount + ); + Assert.AreEqual( + 0, + innerHandlerCount, + "[{0}] Vetoed inner handler must NOT run. outer={1}, innerHandler={2}, trailing={3}, interceptor={4}.", + scenario.Kind, + outerCount, + innerHandlerCount, + trailingCount, + interceptorCount + ); + Assert.AreEqual( + 1, + trailingCount, + "[{0}] Outer trailing handler must run after inner is vetoed. outer={1}, innerHandler={2}, trailing={3}, interceptor={4}.", + scenario.Kind, + outerCount, + innerHandlerCount, + trailingCount, + interceptorCount + ); + Assert.AreEqual( + 2, + interceptorCount, + "[{0}] Interceptor must fire once per emission (outer and inner). outer={1}, innerHandler={2}, trailing={3}, interceptor={4}.", + scenario.Kind, + outerCount, + innerHandlerCount, + trailingCount, + interceptorCount + ); + + // Explicit ordering assertion: the interceptor must fire BEFORE + // each handler bucket walk. The inner emission's interceptor + // must run AFTER the outer-start (because the outer handler is + // what triggers the inner emit), and the trailing handler must + // run AFTER the inner emission completes (vetoed) and on the + // outer frame (depth 0). + string[] expectedTrace = + { + "d0:interceptor", + "d0:outer-start", + "d1:interceptor", + "d0:outer-end", + "d0:trailing", + }; + CollectionAssert.AreEqual( + expectedTrace, + trace, + "[{0}] Vetoed re-emit must produce the documented trace. trace=[{1}]", + scenario.Kind, + string.Join(",", trace) + ); + + token.RemoveRegistration(outerHandle); + token.RemoveRegistration(trailingHandle); + yield break; + } + + /// + /// An interceptor mutates the message; a handler triggers a re-emit + /// against a fresh message instance. The interceptor must see the + /// new emission as fresh state with no carry-over from the parent + /// emission. + /// + [UnityTest] + public IEnumerator InterceptorMutationDuringReemitObservesFreshState( + [ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] + MessageScenario scenario + ) + { + GameObject host = new( + nameof(InterceptorMutationDuringReemitObservesFreshState) + scenario.Kind, + typeof(EmptyMessageAwareComponent) + ); + _spawned.Add(host); + EmptyMessageAwareComponent component = host.GetComponent(); + MessageRegistrationToken token = GetToken(component); + InstanceId hostId = host; + + // Track interceptor invocations so we can confirm both emissions + // hit the interceptor without state bleed. The simple message + // structs do not have payloads, so the "freshness" of the inner + // emission is asserted indirectly by the interceptor count and + // depth monitoring. + int interceptorInvocations = 0; + int depth = 0; + int outerCount = 0; + int innerCount = 0; + const int InnerRecursionLimit = 1; + + RegisterAllowingInterceptor(scenario, token, () => ++interceptorInvocations); + + MessageRegistrationHandle handle = RegisterCountingHandler( + scenario, + token, + hostId, + () => + { + if (depth == 0) + { + ++outerCount; + ++depth; + try + { + EmitForScenario(scenario, hostId); + } + finally + { + --depth; + } + } + else if (depth <= InnerRecursionLimit) + { + ++innerCount; + } + } + ); + + EmitForScenario(scenario, hostId); + Assert.AreEqual( + 1, + outerCount, + "[{0}] Outer handler must run once. outer={1}, inner={2}, interceptor={3}.", + scenario.Kind, + outerCount, + innerCount, + interceptorInvocations + ); + Assert.AreEqual( + 1, + innerCount, + "[{0}] Inner re-emit handler must run once. outer={1}, inner={2}, interceptor={3}.", + scenario.Kind, + outerCount, + innerCount, + interceptorInvocations + ); + Assert.AreEqual( + 2, + interceptorInvocations, + "[{0}] Interceptor must run twice (once per emission, no carry-over). outer={1}, inner={2}, interceptor={3}.", + scenario.Kind, + outerCount, + innerCount, + interceptorInvocations + ); + + token.RemoveRegistration(handle); + yield break; + } + + private static MessageRegistrationHandle RegisterCountingHandler( + MessageScenario scenario, + MessageRegistrationToken token, + InstanceId target, + Action onInvoked, + int priority = 0 + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + return ScenarioHarness.RegisterUntargeted( + scenario, + token, + (ref SimpleUntargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Targeted: + { + return ScenarioHarness.RegisterTargeted( + scenario, + token, + target, + (ref SimpleTargetedMessage _) => onInvoked(), + priority + ); + } + case MessageKind.Broadcast: + { + return ScenarioHarness.RegisterBroadcast( + scenario, + token, + target, + (ref SimpleBroadcastMessage _) => onInvoked(), + priority + ); + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static void RegisterDepthLimitedInterceptor( + MessageScenario scenario, + MessageRegistrationToken token, + int threshold, + Func getDepth, + Action onInvoked + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + _ = ScenarioHarness.RegisterUntargetedInterceptor( + scenario, + token, + (ref SimpleUntargetedMessage _) => + { + onInvoked(); + return getDepth() < threshold; + } + ); + return; + } + case MessageKind.Targeted: + { + _ = ScenarioHarness.RegisterTargetedInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleTargetedMessage _) => + { + onInvoked(); + return getDepth() < threshold; + } + ); + return; + } + case MessageKind.Broadcast: + { + _ = ScenarioHarness.RegisterBroadcastInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleBroadcastMessage _) => + { + onInvoked(); + return getDepth() < threshold; + } + ); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static void RegisterAllowingInterceptor( + MessageScenario scenario, + MessageRegistrationToken token, + Action onInvoked + ) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + _ = ScenarioHarness.RegisterUntargetedInterceptor( + scenario, + token, + (ref SimpleUntargetedMessage _) => + { + onInvoked(); + return true; + } + ); + return; + } + case MessageKind.Targeted: + { + _ = ScenarioHarness.RegisterTargetedInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleTargetedMessage _) => + { + onInvoked(); + return true; + } + ); + return; + } + case MessageKind.Broadcast: + { + _ = ScenarioHarness.RegisterBroadcastInterceptor( + scenario, + token, + (ref InstanceId _, ref SimpleBroadcastMessage _) => + { + onInvoked(); + return true; + } + ); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + + private static void EmitForScenario(MessageScenario scenario, InstanceId target) + { + switch (scenario.Kind) + { + case MessageKind.Untargeted: + { + SimpleUntargetedMessage message = new(); + ScenarioHarness.EmitUntargeted(scenario, ref message); + return; + } + case MessageKind.Targeted: + { + SimpleTargetedMessage message = new(); + ScenarioHarness.EmitTargeted(scenario, ref message, target); + return; + } + case MessageKind.Broadcast: + { + SimpleBroadcastMessage message = new(); + ScenarioHarness.EmitBroadcast(scenario, ref message, target); + return; + } + default: + { + throw new ArgumentOutOfRangeException( + nameof(scenario), + scenario.Kind, + "Unsupported message kind." + ); + } + } + } + } +} +#endif diff --git a/Tests/Runtime/Core/ReentrantEmissionExtendedTests.cs.meta b/Tests/Runtime/Core/ReentrantEmissionExtendedTests.cs.meta new file mode 100644 index 00000000..cce7c6ea --- /dev/null +++ b/Tests/Runtime/Core/ReentrantEmissionExtendedTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e2c1f8aab8e4f1d8e7c0d8e9b1a3304 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/Snapshots.meta b/Tests/Runtime/Core/Snapshots.meta new file mode 100644 index 00000000..e3ad3ab7 --- /dev/null +++ b/Tests/Runtime/Core/Snapshots.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1f3c0b4afaaa49aebfc0e6b3a4d68b0a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/Snapshots/.gitkeep b/Tests/Runtime/Core/Snapshots/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Tests/Runtime/Core/Snapshots/public-surface.txt b/Tests/Runtime/Core/Snapshots/public-surface.txt new file mode 100644 index 00000000..6c8aa016 --- /dev/null +++ b/Tests/Runtime/Core/Snapshots/public-surface.txt @@ -0,0 +1,58 @@ +DxMessaging.Core.Attributes.DxAutoConstructorAttribute +DxMessaging.Core.Attributes.DxBroadcastMessageAttribute +DxMessaging.Core.Attributes.DxIgnoreMissingBaseCallAttribute +DxMessaging.Core.Attributes.DxOptionalParameterAttribute +DxMessaging.Core.Attributes.DxTargetedMessageAttribute +DxMessaging.Core.Attributes.DxUntargetedMessageAttribute +DxMessaging.Core.Diagnostics.MessageEmissionData +DxMessaging.Core.Diagnostics.MessageRegistrationMetadata +DxMessaging.Core.Diagnostics.MessageRegistrationType +DxMessaging.Core.DxMessagingStaticState +DxMessaging.Core.Extensions.MessageBusExtensions +DxMessaging.Core.Extensions.MessageExtensions +DxMessaging.Core.Helper.MessageCache`1 +DxMessaging.Core.Helper.MessageCache`1+MessageCacheEnumerator +DxMessaging.Core.Helper.MessageHelperIndexer +DxMessaging.Core.Helper.MessageHelperIndexer`1 +DxMessaging.Core.IMessage +DxMessaging.Core.InstanceId +DxMessaging.Core.LogLevel +DxMessaging.Core.MessageBus.DiagnosticsTarget +DxMessaging.Core.MessageBus.FixedMessageBusProvider +DxMessaging.Core.MessageBus.GlobalMessageBusProvider +DxMessaging.Core.MessageBus.IMessageBus +DxMessaging.Core.MessageBus.IMessageBus+BroadcastInterceptor`1 +DxMessaging.Core.MessageBus.IMessageBus+TargetedInterceptor`1 +DxMessaging.Core.MessageBus.IMessageBus+UntargetedInterceptor`1 +DxMessaging.Core.MessageBus.IMessageBusProvider +DxMessaging.Core.MessageBus.IMessageRegistrationBuilder +DxMessaging.Core.MessageBus.MessageBus +DxMessaging.Core.MessageBus.MessageBusRebindMode +DxMessaging.Core.MessageBus.MessageRegistrationBuildOptions +DxMessaging.Core.MessageBus.MessageRegistrationBuilder +DxMessaging.Core.MessageBus.MessageRegistrationLease +DxMessaging.Core.MessageBus.MessageRegistrationLifecycle +DxMessaging.Core.MessageBus.MessagingRegistration +DxMessaging.Core.MessageBus.RegistrationLog +DxMessaging.Core.MessageBus.RegistrationMethod +DxMessaging.Core.MessageBus.RegistrationType +DxMessaging.Core.MessageHandler +DxMessaging.Core.MessageHandler+FastHandlerWithContext`1 +DxMessaging.Core.MessageHandler+FastHandler`1 +DxMessaging.Core.MessageHandler+GlobalMessageBusScope +DxMessaging.Core.MessageRegistrationHandle +DxMessaging.Core.MessageRegistrationToken +DxMessaging.Core.MessageRegistrationToken+RegistrationDisposable +DxMessaging.Core.Messages.GlobalStringMessage +DxMessaging.Core.Messages.IBroadcastMessage +DxMessaging.Core.Messages.IBroadcastMessage`1 +DxMessaging.Core.Messages.ITargetedMessage +DxMessaging.Core.Messages.ITargetedMessage`1 +DxMessaging.Core.Messages.IUntargetedMessage +DxMessaging.Core.Messages.IUntargetedMessage`1 +DxMessaging.Core.Messages.MethodSignatureKey +DxMessaging.Core.Messages.ReflexiveMessage +DxMessaging.Core.Messages.ReflexiveSendMode +DxMessaging.Core.Messages.SourcedStringMessage +DxMessaging.Core.Messages.StringMessage +DxMessaging.Core.MessagingDebug diff --git a/Tests/Runtime/Core/Snapshots/public-surface.txt.meta b/Tests/Runtime/Core/Snapshots/public-surface.txt.meta new file mode 100644 index 00000000..7f573c1a --- /dev/null +++ b/Tests/Runtime/Core/Snapshots/public-surface.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c75eeb0b961140b785c94688c8fdb96f +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs b/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs new file mode 100644 index 00000000..c0295842 --- /dev/null +++ b/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs @@ -0,0 +1,217 @@ +#if UNITY_2021_3_OR_NEWER +[assembly: DxMessaging.Tests.Runtime.NoteGatedCategoryAction] + +namespace DxMessaging.Tests.Runtime +{ + using System; + using System.Diagnostics; + using NUnit.Framework; + using NUnit.Framework.Interfaces; + + /// + /// Suite-level wall-clock budget covering every test under the + /// DxMessaging.Tests.Runtime namespace and its sub-namespaces + /// (Core, Integrations, Unity) within the runtime test assembly. The + /// default Unity Edit + Play mode test run is supposed to finish in + /// under 60 seconds once the Stress, Allocation, + /// Performance, and UnityRuntime categories are filtered + /// out. This setup fixture captures a timestamp at suite start (via + /// ) and asserts the elapsed wall + /// clock at suite end is below a soft and hard budget. + /// + /// + /// + /// Cross-assembly note: the Benchmarks tests live in a separate test + /// assembly (WallstopStudios.DxMessaging.Tests.00.Runtime.Benchmarks) + /// and run independently; this fixture's hooks do not fire for those + /// tests because NUnit applies per + /// assembly. Benchmarks tests are gated behind + /// [Category("Allocation")]/"Performance" categories and + /// have their own CI budgets. + /// + /// + /// Implementation: NUnit's declares + /// a fixture that runs at the assembly level rather than per-test + /// fixture. Placing the fixture in the top-level + /// DxMessaging.Tests.Runtime namespace causes NUnit to apply the + /// / + /// hooks across every fixture in that namespace AND every sub-namespace + /// (Core, Integrations, Unity) within the runtime test assembly. The + /// Unity Test Runner honours both attributes inside its NUnit-derived + /// runner. + /// + /// + /// Gated-category detection: an assembly-scoped + /// runs for every test in the run. + /// The action reads the test's NUnit categories (from + /// with the "Category" key, as + /// defined by NUnit's internal PropertyNames table) and forwards + /// each one to . + /// If any test in the session is in the gated set + /// (Stress, Performance, Allocation, or UnityRuntime), + /// the wall-clock budget assertion is skipped because the gated suites + /// have their own CI-side timing budgets. + /// + /// + /// Mechanism alternative: a CI-side timer (e.g. measuring + /// vstest.console wall clock) is a perfectly fine fallback. We + /// keep this in-NUnit fixture because it is self-contained, runs + /// alongside the tests, and reports failure with the same error + /// formatting the rest of the suite uses. + /// + /// + [SetUpFixture] + public sealed class SuiteWallClockBudgetTest + { + /// + /// Soft budget: the default suite is expected to complete under + /// this duration. A breach triggers a warning but does not fail + /// the suite. + /// + public static readonly TimeSpan SoftBudget = TimeSpan.FromSeconds(60); + + /// + /// Hard budget: a default-suite breach above this threshold fails + /// the suite (so a regression is unmissable). + /// + public static readonly TimeSpan HardBudget = TimeSpan.FromSeconds(180); + + private static readonly string[] GatedCategories = + { + "Stress", + "Performance", + "Allocation", + "UnityRuntime", + }; + + private static Stopwatch _suiteTimer; + private static volatile bool _gatedCategoryDetected; + + /// + /// Captures the suite's start timestamp. + /// + [OneTimeSetUp] + public void StartSuiteTimer() + { + _suiteTimer = Stopwatch.StartNew(); + _gatedCategoryDetected = false; + } + + /// + /// Stops the timer and asserts the elapsed wall clock is within + /// the soft / hard budget. The assertion is skipped if any tracked + /// test in the run belongs to a gated category (see remarks). + /// + [OneTimeTearDown] + public void EndSuiteTimer() + { + if (_suiteTimer == null) + { + return; + } + + _suiteTimer.Stop(); + TimeSpan elapsed = _suiteTimer.Elapsed; + + // Sanity: dump the elapsed time so CI logs make the budget + // proximity visible without a failure. + UnityEngine.Debug.Log( + $"DxMessaging suite wall clock: {elapsed.TotalSeconds:0.00}s " + + $"(soft budget {SoftBudget.TotalSeconds:0.0}s, hard budget {HardBudget.TotalSeconds:0.0}s)." + ); + + if (_gatedCategoryDetected) + { + UnityEngine.Debug.Log( + "Skipping default-suite wall-clock assertion: a Stress/Performance/Allocation/UnityRuntime " + + "test was observed in this run." + ); + return; + } + + if (elapsed > HardBudget) + { + Assert.Fail( + $"Default suite wall clock ({elapsed.TotalSeconds:0.00}s) exceeded the hard budget " + + $"({HardBudget.TotalSeconds:0.0}s). Reduce iteration counts or move offending tests " + + "behind a gated category (Stress/Performance/Allocation/UnityRuntime)." + ); + } + else if (elapsed > SoftBudget) + { + UnityEngine.Debug.LogWarning( + $"Default suite wall clock ({elapsed.TotalSeconds:0.00}s) exceeded the soft budget " + + $"({SoftBudget.TotalSeconds:0.0}s). The hard budget is " + + $"{HardBudget.TotalSeconds:0.0}s; reduce iteration counts before it breaches." + ); + } + } + + /// + /// Marks the current run as containing a gated test. Called from + /// for every test + /// before it runs, so the teardown assertion can short-circuit + /// when a gated category is in scope. + /// + public static void NoteGatedCategoryObserved(string category) + { + if (string.IsNullOrEmpty(category)) + { + return; + } + + for (int i = 0; i < GatedCategories.Length; ++i) + { + if (string.Equals(GatedCategories[i], category, StringComparison.OrdinalIgnoreCase)) + { + _gatedCategoryDetected = true; + return; + } + } + } + } + + /// + /// Assembly-scoped that fires before every + /// test runs, scans the test's NUnit categories, and forwards each one + /// to . + /// Combined with the assembly-level attribute application (see the + /// [assembly: NoteGatedCategoryAction] declaration at the top + /// of this file) this covers every test in every fixture in the + /// assembly without requiring a base class. + /// + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] + public sealed class NoteGatedCategoryAction : Attribute, ITestAction + { + public ActionTargets Targets => ActionTargets.Test; + + public void BeforeTest(ITest test) + { + if (test == null) + { + return; + } + + // ITest.Properties is a flat IPropertyBag; categories live under + // the well-known "Category" key (NUnit 3.x's PropertyNames.Category + // resolves to the same literal). Each test may have multiple + // categories, and NUnit applies fixture-level [Category] + // attributes to each child test automatically, so a class-level + // [Category("Allocation")] also shows up here. + const string CategoryPropertyName = "Category"; + System.Collections.IList categories = test.Properties[CategoryPropertyName]; + if (categories == null) + { + return; + } + + for (int i = 0; i < categories.Count; ++i) + { + SuiteWallClockBudgetTest.NoteGatedCategoryObserved(categories[i] as string); + } + } + + public void AfterTest(ITest test) { } + } +} +#endif diff --git a/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs.meta b/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs.meta new file mode 100644 index 00000000..b440f84b --- /dev/null +++ b/Tests/Runtime/Core/SuiteWallClockBudgetTest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4b5c6d7e8f9011a2b3c4d5e6f7081920 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Core/TestAttributeContractTests.cs b/Tests/Runtime/Core/TestAttributeContractTests.cs index 451c4604..d354a9a3 100644 --- a/Tests/Runtime/Core/TestAttributeContractTests.cs +++ b/Tests/Runtime/Core/TestAttributeContractTests.cs @@ -6,6 +6,7 @@ namespace DxMessaging.Tests.Runtime.Core using System.Collections.Generic; using System.Linq; using System.Reflection; + using System.Text.RegularExpressions; using DxMessaging.Core; using DxMessaging.Core.MessageBus; using DxMessaging.Tests.Runtime; @@ -14,6 +15,18 @@ namespace DxMessaging.Tests.Runtime.Core public sealed class TestAttributeContractTests { + /// + /// Matches the C# 9 target-typed pattern GameObject identifier = new(...). + /// Used by + /// to detect spawn calls that the older substring grep for + /// "new GameObject(" would miss. Compiled once because the + /// fixture scan runs across every test source file. + /// + private static readonly Regex GameObjectTargetTypedNewPattern = new( + @"\bGameObject\b\s+\w+\s*=\s*new\s*\(", + RegexOptions.Compiled | RegexOptions.CultureInvariant + ); + [Test] public void UnityTestsDoNotUseTestCaseAttributes() { @@ -185,6 +198,10 @@ out Dictionary> nameMap // only available for those kinds). The bodies are not structurally // identical, and consolidating would weaken the kind-asymmetric // coverage the longer variants provide. + // Remove when the Targeted/Broadcast variants drop their extra + // Component-* Run blocks OR the harness gains a uniform way to + // declare per-kind extra emit paths (then the body becomes + // identical and consolidation is safe). "DxMessaging.Tests.Runtime.Core.NominalTests.RemoveOrder", // OrderingManyRegistrationsTests.PostProcessorsManyRegistrationsMaintainOrder: // the Untargeted variant registers only fast post-processors with @@ -194,6 +211,9 @@ out Dictionary> nameMap // consolidation would either drop assertions or test a code path // (action post-processors) that is not exercised today on the // untargeted bus. + // Remove when action post-processors are wired into the + // untargeted bus (so the untargeted variant uses the same + // dual-list shape as the targeted/broadcast variants). "DxMessaging.Tests.Runtime.Core.OrderingManyRegistrationsTests.PostProcessorsManyRegistrationsMaintainOrder", // RegistrationTests.Interceptor: the Untargeted variant emits via // a single EmitUntargeted path, while Targeted and Broadcast each @@ -202,6 +222,9 @@ out Dictionary> nameMap // the post-deregistration assertion to prove deregistration applies // across both targeting/source variants. Consolidation would // reduce the targeted/broadcast assertions to a single emit path. + // Remove when the Untargeted variant grows a Component-style + // second emit path OR the helper harness collapses the per-kind + // emit list so each variant exercises the same number of paths. "DxMessaging.Tests.Runtime.Core.RegistrationTests.Interceptor", }; @@ -331,26 +354,555 @@ ref DxMessaging.Tests.Runtime.Scripts.Messages.SimpleUntargetedMessage _ } } + /// + /// Tightens : + /// once a fixture adopts -parameterized + /// tests (any [UnityTest] in the fixture takes a + /// via [ValueSource]), every + /// kind-named [UnityTest] method in the SAME fixture must + /// also be parameterized. Mixing parameterized and per-kind methods + /// in the same fixture is almost always an oversight - either the + /// kind-named methods predate the consolidation and were missed, or + /// they exercise materially different mechanics and should move + /// into a *Specific* fixture. + /// + /// + /// Fixtures with materially asymmetric overload assertions (e.g. + /// NominalTests's TargetedWithoutTargeting tests that + /// have no Untargeted counterpart) are exempted via the + /// allowedMixedFixtures set below; each entry includes a + /// short justification. Removing an exemption later is the + /// consolidation milestone. + /// + [Test] + public void MixedParameterizationAndKindNamedTestsInSameFixture() + { + string[] kindTokens = { "Untargeted", "Targeted", "Broadcast" }; + Dictionary> kindNamedByFixture = new(); + HashSet fixturesWithParameterization = new(); + + foreach (MethodInfo method in GetRuntimeTestMethods()) + { + if (!HasAttribute(method)) + { + continue; + } + + Type fixture = method.DeclaringType; + if (fixture == null) + { + continue; + } + + if ( + fixture.Name.IndexOf("Specific", StringComparison.Ordinal) >= 0 + || fixture.Name.IndexOf("Equivalence", StringComparison.Ordinal) >= 0 + || fixture.Name.IndexOf("Prefreeze", StringComparison.Ordinal) >= 0 + ) + { + continue; + } + + if (HasMessageScenarioParameter(method)) + { + fixturesWithParameterization.Add(fixture); + continue; + } + + bool nameHasKindToken = false; + foreach (string token in kindTokens) + { + if (method.Name.IndexOf(token, StringComparison.Ordinal) >= 0) + { + nameHasKindToken = true; + break; + } + } + + if (!nameHasKindToken) + { + continue; + } + + if (!kindNamedByFixture.TryGetValue(fixture, out List bucket)) + { + bucket = new List(); + kindNamedByFixture[fixture] = bucket; + } + + bucket.Add(method); + } + + // Allowlist: fixtures known to mix kind-named and parameterized + // tests for justified reasons. Adding a NEW fixture should be + // accompanied by a comment explaining why consolidation is unsafe. + HashSet allowedMixedFixtures = new(StringComparer.Ordinal) + { + // MutationDestructionTests pairs a parameterized + // DestroyOtherListenerDoesNotRun with kind-asymmetric + // overloads (TargetedComponent / TargetedWithoutTargeting / + // BroadcastComponent / BroadcastWithoutSource) that have + // no Untargeted counterpart. Consolidating would erase the + // overload-specific assertions. + // Remove when the Untargeted bus grows a Component or + // *WithoutTargeting analogue (so every overload has a + // counterpart and the asymmetric methods can collapse). + "DxMessaging.Tests.Runtime.Core.MutationDestructionTests", + // MutationDuringEmissionTests pins a wide matrix of mutation + // x emission permutations. Several methods cover the + // *WithoutTargeting / *WithoutSource overloads which are + // kind-asymmetric (only Targeted and Broadcast have + // without-* variants). + // Remove when the Untargeted bus exposes equivalent + // *WithoutTargeting / *WithoutSource overloads (so every + // mutation entry has a corresponding Untargeted variant). + "DxMessaging.Tests.Runtime.Core.MutationDuringEmissionTests", + // OrderingManyRegistrationsTests has the same shape: the + // *WithoutTargeting and per-kind PostProcessor variants + // are kind-asymmetric. + // Remove when action post-processors and *WithoutTargeting + // are unified across kinds (matching the resolution + // condition above for the same fixture's triplet entry). + "DxMessaging.Tests.Runtime.Core.OrderingManyRegistrationsTests", + }; + + List offenders = new(); + foreach (KeyValuePair> pair in kindNamedByFixture) + { + if (!fixturesWithParameterization.Contains(pair.Key)) + { + continue; + } + + if (allowedMixedFixtures.Contains(pair.Key.FullName)) + { + continue; + } + + foreach (MethodInfo method in pair.Value) + { + offenders.Add(FormatMethod(method)); + } + } + + Assert.That( + offenders, + Is.Empty, + "Fixtures that adopt MessageScenario parameterization must consolidate ALL kind-named " + + "[UnityTest] methods, OR be added to the allowlist with a justification comment. " + + "Offenders:\n" + + string.Join("\n", offenders) + ); + } + + /// + /// Pins the cleanup pattern: every fixture inheriting from + /// MessagingTestBase must rely on the _spawned list + /// for GameObject teardown rather than calling + /// UnityEngine.Object.Destroy(go) directly without registering + /// the object with _spawned first. This is enforced by + /// scanning fixture sources for Object.Destroy(/Destroy( + /// occurrences that are not preceded by a _spawned.Add; we + /// only flag occurrences in test fixtures (not in the base class + /// itself, which legitimately calls Destroy as part of the cleanup + /// loop). Source-text heuristic: any _spawned.Add( token in + /// the file is treated as evidence the fixture follows the + /// convention. + /// + [Test] + public void FixturesUsingMessagingTestBaseUseSpawnedCleanupPattern() + { + // Scan every loaded test assembly (Runtime + Benchmarks + + // siblings) so the rule applies uniformly across the test + // surface, not just to the assembly that hosts this fixture. + HashSet messagingBaseFixtures = new(); + foreach (Assembly testAssembly in AppDomain.CurrentDomain.GetAssemblies()) + { + string testAssemblyName = testAssembly.GetName().Name; + if ( + testAssemblyName == null + || !testAssemblyName.StartsWith( + "WallstopStudios.DxMessaging.Tests", + StringComparison.Ordinal + ) + ) + { + continue; + } + + Type[] assemblyTypes; + try + { + assemblyTypes = testAssembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + assemblyTypes = ex.Types.Where(t => t != null).ToArray(); + } + + foreach (Type type in assemblyTypes) + { + if (type == null) + { + continue; + } + + if ( + type.Namespace == null + || !type.Namespace.StartsWith( + "DxMessaging.Tests.Runtime", + StringComparison.Ordinal + ) + ) + { + continue; + } + + if (type.IsAbstract) + { + continue; + } + + if ( + !typeof(DxMessaging.Tests.Runtime.Core.MessagingTestBase).IsAssignableFrom( + type + ) + ) + { + continue; + } + + // Skip fixtures that have NO test methods (likely helper + // scaffolding); the rule applies to fixtures that exercise + // the bus. + bool hasTest = false; + foreach ( + MethodInfo method in type.GetMethods( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic + ) + ) + { + if ( + HasAttribute(method) + || HasAttribute(method) + ) + { + hasTest = true; + break; + } + } + + if (!hasTest) + { + continue; + } + + messagingBaseFixtures.Add(type); + } + } + + // Source-text approximation: walk the test source roots and + // for each fixture pair its source file, then flag fixtures + // whose source spawns GameObjects (`new GameObject(`) but + // never calls `_spawned.Add(`. Files that cannot be located + // on disk are simply not classified (they fall into the + // "uncovered" bucket and do not fail the test). + List sourceRoots = ResolveTestSourceRootsFallback(); + Dictionary fixtureToSource = new(StringComparer.Ordinal); + + foreach (string root in sourceRoots) + { + if (!System.IO.Directory.Exists(root)) + { + continue; + } + + foreach ( + string file in System.IO.Directory.EnumerateFiles( + root, + "*.cs", + System.IO.SearchOption.AllDirectories + ) + ) + { + string text; + try + { + text = System.IO.File.ReadAllText(file); + } + catch (System.IO.IOException) + { + continue; + } + + foreach (Type fixture in messagingBaseFixtures) + { + // Match by simple type name; the file name pattern + // mirrors the fixture name across the test tree. + if ( + !string.Equals( + System.IO.Path.GetFileNameWithoutExtension(file), + fixture.Name, + StringComparison.Ordinal + ) + ) + { + continue; + } + + fixtureToSource[fixture.FullName] = text; + break; + } + } + } + + List offenders = new(); + foreach (Type fixture in messagingBaseFixtures) + { + if (!fixtureToSource.TryGetValue(fixture.FullName, out string text)) + { + continue; + } + + // Match BOTH classic `new GameObject(...)` AND C# 9 target-typed + // `GameObject identifier = new(...)` patterns. The latter became + // common when fixtures adopted target-typed instantiation; without + // this alternate the check would silently miss spawn calls written + // in the new style. Roslyn would be more robust but the runtime + // tests asmdef does not reference the syntax APIs, so the regex + // form is the pragmatic option. + bool spawnsGameObjects = + text.IndexOf("new GameObject(", StringComparison.Ordinal) >= 0 + || GameObjectTargetTypedNewPattern.IsMatch(text); + bool tracksWithSpawned = + text.IndexOf("_spawned.Add", StringComparison.Ordinal) >= 0; + + if (spawnsGameObjects && !tracksWithSpawned) + { + offenders.Add(fixture.FullName); + } + } + + Assert.That( + offenders, + Is.Empty, + "Fixtures inheriting MessagingTestBase that spawn GameObjects must register them via _spawned.Add(...) " + + "for proper teardown. Offenders:\n " + + string.Join("\n ", offenders) + ); + } + + /// + /// Pins that no namespace contains more than one + /// [SetUpFixture]. NUnit applies a SetUpFixture's hooks to + /// every test in the namespace and its sub-namespaces; multiple + /// fixtures in the same namespace produce undefined ordering and + /// can race each other's [OneTimeSetUp] / + /// [OneTimeTearDown] bodies. + /// + [Test] + public void AtMostOneSetUpFixturePerNamespace() + { + Dictionary> fixturesByNamespace = new(StringComparer.Ordinal); + + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + string assemblyName = assembly.GetName().Name; + if ( + assemblyName == null + || !assemblyName.StartsWith( + "WallstopStudios.DxMessaging.Tests", + StringComparison.Ordinal + ) + ) + { + continue; + } + + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types.Where(t => t != null).ToArray(); + } + + foreach (Type type in types) + { + if (type == null || type.Namespace == null) + { + continue; + } + + if ( + !type.Namespace.StartsWith( + "DxMessaging.Tests.Runtime", + StringComparison.Ordinal + ) + ) + { + continue; + } + + if ( + type.GetCustomAttributes( + typeof(SetUpFixtureAttribute), + inherit: false + ).Length == 0 + ) + { + continue; + } + + if (!fixturesByNamespace.TryGetValue(type.Namespace, out List bucket)) + { + bucket = new List(); + fixturesByNamespace[type.Namespace] = bucket; + } + + bucket.Add(type.FullName); + } + } + + List offenders = new(); + foreach (KeyValuePair> pair in fixturesByNamespace) + { + if (pair.Value.Count > 1) + { + offenders.Add( + $"{pair.Key}: {pair.Value.Count} SetUpFixture types (" + + string.Join(", ", pair.Value) + + ")" + ); + } + } + + Assert.That( + offenders, + Is.Empty, + "Namespaces with multiple [SetUpFixture] declarations:\n " + + string.Join("\n ", offenders) + ); + } + + /// + /// Pins that parameter sources on + /// [UnityTest] methods always reference one of the canonical + /// scenario sources (, + /// , etc.). + /// + [Test] + public void MessageScenarioParametersUseValueSource() + { + List offenders = new(); + + foreach (MethodInfo method in GetRuntimeTestMethods()) + { + if ( + !HasAttribute(method) + && !HasAttribute(method) + ) + { + continue; + } + + foreach (ParameterInfo parameter in method.GetParameters()) + { + if (parameter.ParameterType != typeof(MessageScenario)) + { + continue; + } + + bool hasValueSource = + parameter + .GetCustomAttributes( + typeof(NUnit.Framework.ValueSourceAttribute), + inherit: false + ) + .Length > 0; + if (hasValueSource) + { + continue; + } + + offenders.Add(FormatMethod(method) + " (parameter " + parameter.Name + ")"); + } + } + + Assert.That( + offenders, + Is.Empty, + "Found MessageScenario parameters without [ValueSource]. Use " + + "[ValueSource(typeof(MessageScenarios), nameof(MessageScenarios.AllKinds))] " + + "(or another canonical source) to drive parameterized tests:\n" + + string.Join("\n", offenders) + ); + } + + private static List ResolveTestSourceRootsFallback() + { + List roots = new List(); + string[] candidates = + { + System.IO.Path.Combine( + UnityEngine.Application.dataPath, + "..", + "Packages", + "com.wallstop-studios.dxmessaging", + "Tests", + "Runtime" + ), + System.IO.Path.Combine(UnityEngine.Application.dataPath, "..", "Tests", "Runtime"), + System.IO.Path.Combine( + System.IO.Directory.GetCurrentDirectory(), + "Tests", + "Runtime" + ), + }; + + foreach (string candidate in candidates) + { + string full = System.IO.Path.GetFullPath(candidate); + if (System.IO.Directory.Exists(full)) + { + roots.Add(full); + } + } + + return roots; + } + private static IEnumerable FindMethods(Func predicate) { return GetRuntimeTestMethods().Where(predicate); } + /// + /// Enumerates every test method (any method with [Test], + /// [UnityTest], [TestCase], or [TestCaseSource]) in + /// every loaded assembly whose name begins with + /// WallstopStudios.DxMessaging.Tests. This intentionally covers + /// the runtime test asmdef AND the Benchmarks asmdef (and any future + /// sibling test assemblies), so contract tests apply uniformly across + /// the test surface and not just the assembly that hosts this fixture. + /// private static IEnumerable GetRuntimeTestMethods() { - Assembly assembly = typeof(TestAttributeContractTests).Assembly; BindingFlags methodFlags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; - foreach (Type type in assembly.GetTypes()) + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { + string assemblyName = assembly.GetName().Name; if ( - type.Namespace == null - || !type.Namespace.StartsWith( - "DxMessaging.Tests.Runtime", + assemblyName == null + || !assemblyName.StartsWith( + "WallstopStudios.DxMessaging.Tests", StringComparison.Ordinal ) ) @@ -358,23 +910,52 @@ private static IEnumerable GetRuntimeTestMethods() continue; } - foreach (MethodInfo method in type.GetMethods(methodFlags)) + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types.Where(t => t != null).ToArray(); + } + + foreach (Type type in types) { - if (method.IsSpecialName) + if (type == null) { continue; } - bool isTestMethod = - HasAttribute(method) - || HasAttribute(method) - || HasAttribute(method) - || HasAttribute(method); - // ValueSource is parameter data only and is always paired with a test-defining attribute. + if ( + type.Namespace == null + || !type.Namespace.StartsWith( + "DxMessaging.Tests.Runtime", + StringComparison.Ordinal + ) + ) + { + continue; + } - if (isTestMethod) + foreach (MethodInfo method in type.GetMethods(methodFlags)) { - yield return method; + if (method.IsSpecialName) + { + continue; + } + + bool isTestMethod = + HasAttribute(method) + || HasAttribute(method) + || HasAttribute(method) + || HasAttribute(method); + // ValueSource is parameter data only and is always paired with a test-defining attribute. + + if (isTestMethod) + { + yield return method; + } } } } diff --git a/Tests/Runtime/Scripts/Components/BaseCallContractComponents.cs b/Tests/Runtime/Scripts/Components/BaseCallContractComponents.cs new file mode 100644 index 00000000..282225ef --- /dev/null +++ b/Tests/Runtime/Scripts/Components/BaseCallContractComponents.cs @@ -0,0 +1,216 @@ +namespace DxMessaging.Tests.Runtime.Scripts.Components +{ + using DxMessaging.Core.Attributes; + using DxMessaging.Core.Messages; + using DxMessaging.Tests.Runtime.Scripts.Messages; + using DxMessaging.Unity; + + /// + /// Test fixtures that pin the runtime consequence of forgetting a + /// base.X() call when subclassing . + /// Each component in this file deliberately violates the base-call contract + /// for one specific lifecycle method so the tests in + /// BaseCallContractTests can observe the runtime symptom directly. + /// + /// + /// + /// Every contract-violating class carries + /// at class scope so the + /// Roslyn analyzer (DXMSG006/007/009/010) and the IL scanner do not + /// surface warnings for these intentional violations. The attribute is + /// the canonical opt-out for this exact pattern. + /// + /// + /// The type is the positive + /// control: every guarded lifecycle method is overridden correctly with + /// a base.X() call so the contract test can pin the happy path + /// end-to-end. + /// + /// + public static class BaseCallContractComponents + { + // Marker class so the file has a public type matching the file name; + // the actual test fixtures live as siblings below. + } + + /// + /// Subclass that overrides + /// without calling base.Awake(). The framework's token creation + /// is therefore skipped and _messageRegistrationToken stays + /// null, which is the failure mode the runtime self-check breadcrumb + /// in exists to detect. + /// + [DxIgnoreMissingBaseCall] + public sealed class MissingBaseAwakeComponent : MessageAwareComponent + { + protected override void Awake() + { + // Intentional: do NOT call base.Awake(). + } + } + + /// + /// Subclass that overrides + /// without calling base.OnEnable(). The token is created by the + /// untouched Awake but never enabled, so handlers registered on + /// the token do not fire even when the component is enabled. + /// + [DxIgnoreMissingBaseCall] + public sealed class MissingBaseOnEnableComponent : MessageAwareComponent + { + protected override void OnEnable() + { + // Intentional: do NOT call base.OnEnable(). + } + } + + /// + /// Subclass that overrides + /// without calling base.OnDisable(). The token is therefore not + /// disabled when the component is disabled, so handlers continue to + /// fire while the component appears to be off. + /// + [DxIgnoreMissingBaseCall] + public sealed class MissingBaseOnDisableComponent : MessageAwareComponent + { + protected override void OnDisable() + { + // Intentional: do NOT call base.OnDisable(). + } + } + + /// + /// Subclass that overrides BOTH + /// and without calling either + /// base method. Both must be skipped to demonstrate the on-destroy + /// leak: Unity's destroy lifecycle fires OnDisable before + /// OnDestroy, and the inherited OnDisable calls + /// _messageRegistrationToken?.Disable() which deregisters every + /// active registration. If only OnDestroy were skipped, the + /// inherited OnDisable would silently clean up the registrations + /// during destruction and the test would observe no leak. With both + /// skipped, the framework never releases the messaging component or + /// disables the token, so the registrations leak into the bus and the + /// registration counters do not return to their pre-spawn baseline. + /// + [DxIgnoreMissingBaseCall] + public sealed class MissingBaseOnDestroyComponent : MessageAwareComponent + { + protected override void OnDisable() + { + // Intentional: do NOT call base.OnDisable(). Without this skip, + // Unity's destroy lifecycle (OnDisable -> OnDestroy) would + // deregister the handlers via the inherited OnDisable before the + // overridden OnDestroy runs, masking the leak the test pins. + } + + protected override void OnDestroy() + { + // Intentional: do NOT call base.OnDestroy(). The leaked + // registration is cleaned up by the test via a bus reset in + // teardown. + } + } + + /// + /// Subclass that overrides only + /// without calling base.OnDestroy(), but leaves the inherited + /// intact. Used to pin that + /// Unity's destroy lifecycle (OnDisable -> OnDestroy) deregisters handlers + /// via the inherited OnDisable before the overridden OnDestroy runs, so + /// no leak is observed even though base.OnDestroy() is skipped. This + /// fixture exists to document the lifecycle interaction explicitly and + /// distinguishes "skipping OnDestroy alone" (no leak) from "skipping both + /// OnDisable and OnDestroy" (leak), the latter of which is modelled by + /// . + /// + [DxIgnoreMissingBaseCall] + public sealed class MissingBaseOnDestroyOnlyComponent : MessageAwareComponent + { + protected override void OnDestroy() + { + // Intentional: do NOT call base.OnDestroy(). The inherited + // base.OnDisable() still runs as part of Unity's destroy + // lifecycle and disables the token, so no registration leaks. + } + } + + /// + /// Subclass that overrides + /// without + /// calling base.RegisterMessageHandlers(). The token is created + /// (Awake is untouched) and the user's own handler is registered, but + /// the default StringMessage / GlobalStringMessage handlers + /// the base class normally registers are skipped. + /// + [DxIgnoreMissingBaseCall] + public sealed class MissingBaseRegisterMessageHandlersComponent : MessageAwareComponent + { + public int defaultHandlerInvocations; + public int userHandlerInvocations; + + protected override void RegisterMessageHandlers() + { + // Intentional: do NOT call base.RegisterMessageHandlers(). + _ = Token.RegisterUntargeted(HandleUserUntargeted); + } + + protected override void HandleStringComponentMessage(ref StringMessage message) + { + // The base class normally registers this as a handler. Without the + // base call in RegisterMessageHandlers, this should never run for + // emitted StringMessage instances during the test window. + defaultHandlerInvocations++; + } + + private void HandleUserUntargeted(ref SimpleUntargetedMessage message) + { + userHandlerInvocations++; + } + } + + /// + /// Positive-control subclass that overrides every guarded lifecycle + /// method correctly (each one chains via base.X()). Used to pin + /// the happy path for the base-call contract: handlers register, fire + /// after enable, stop firing after disable, and deregister cleanly on + /// destroy with no bus leak. + /// + public sealed class CorrectBaseCallContractComponent : MessageAwareComponent + { + public int userHandlerInvocations; + + protected override bool RegisterForStringMessages => false; + + protected override void Awake() + { + base.Awake(); + } + + protected override void OnEnable() + { + base.OnEnable(); + } + + protected override void OnDisable() + { + base.OnDisable(); + } + + protected override void OnDestroy() + { + base.OnDestroy(); + } + + protected override void RegisterMessageHandlers() + { + base.RegisterMessageHandlers(); + _ = Token.RegisterUntargeted(HandleUserUntargeted); + } + + private void HandleUserUntargeted(ref SimpleUntargetedMessage message) + { + userHandlerInvocations++; + } + } +} diff --git a/Tests/Runtime/Scripts/Components/BaseCallContractComponents.cs.meta b/Tests/Runtime/Scripts/Components/BaseCallContractComponents.cs.meta new file mode 100644 index 00000000..0ca73a9f --- /dev/null +++ b/Tests/Runtime/Scripts/Components/BaseCallContractComponents.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f905fcdcc5284a6e89a1a1063ec534d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Scripts/Components/QuitOnDemandMessageAwareComponent.cs b/Tests/Runtime/Scripts/Components/QuitOnDemandMessageAwareComponent.cs new file mode 100644 index 00000000..16f1636f --- /dev/null +++ b/Tests/Runtime/Scripts/Components/QuitOnDemandMessageAwareComponent.cs @@ -0,0 +1,22 @@ +namespace DxMessaging.Tests.Runtime.Scripts.Components +{ + using DxMessaging.Unity; + + /// + /// Test fixture component exposing a way to invoke the otherwise-protected + /// hook so tests can + /// drive the quit lifecycle without spinning up a full Unity quit sequence. + /// + public sealed class QuitOnDemandMessageAwareComponent : MessageAwareComponent + { + /// + /// Forwards to the protected override + /// so the test fixture can drive the lifecycle hook without + /// terminating the Unity Editor. + /// + public void RaiseOnApplicationQuit() + { + OnApplicationQuit(); + } + } +} diff --git a/Tests/Runtime/Scripts/Components/QuitOnDemandMessageAwareComponent.cs.meta b/Tests/Runtime/Scripts/Components/QuitOnDemandMessageAwareComponent.cs.meta new file mode 100644 index 00000000..f8e41711 --- /dev/null +++ b/Tests/Runtime/Scripts/Components/QuitOnDemandMessageAwareComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 25e7ba22b0b04cf9ac3305ca3d748eaf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Scripts/Messages/ClassBroadcastMessage.cs b/Tests/Runtime/Scripts/Messages/ClassBroadcastMessage.cs new file mode 100644 index 00000000..ebfdc5ca --- /dev/null +++ b/Tests/Runtime/Scripts/Messages/ClassBroadcastMessage.cs @@ -0,0 +1,17 @@ +namespace DxMessaging.Tests.Runtime.Scripts.Messages +{ + using DxMessaging.Core.Messages; + + // Class-based broadcast message for testing class overloads. + public sealed class ClassBroadcastMessage : IBroadcastMessage + { + public readonly string text; + + public ClassBroadcastMessage() { } + + public ClassBroadcastMessage(string text) + { + this.text = text; + } + } +} diff --git a/Tests/Runtime/Scripts/Messages/ClassBroadcastMessage.cs.meta b/Tests/Runtime/Scripts/Messages/ClassBroadcastMessage.cs.meta new file mode 100644 index 00000000..86b25546 --- /dev/null +++ b/Tests/Runtime/Scripts/Messages/ClassBroadcastMessage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2bf81fe7c0bb4dbb8e5e8c4cb0b9d1a2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Scripts/Messages/ClassTargetedMessage.cs b/Tests/Runtime/Scripts/Messages/ClassTargetedMessage.cs new file mode 100644 index 00000000..2d35bf55 --- /dev/null +++ b/Tests/Runtime/Scripts/Messages/ClassTargetedMessage.cs @@ -0,0 +1,17 @@ +namespace DxMessaging.Tests.Runtime.Scripts.Messages +{ + using DxMessaging.Core.Messages; + + // Class-based targeted message for testing class overloads. + public sealed class ClassTargetedMessage : ITargetedMessage + { + public readonly string text; + + public ClassTargetedMessage() { } + + public ClassTargetedMessage(string text) + { + this.text = text; + } + } +} diff --git a/Tests/Runtime/Scripts/Messages/ClassTargetedMessage.cs.meta b/Tests/Runtime/Scripts/Messages/ClassTargetedMessage.cs.meta new file mode 100644 index 00000000..e02cee62 --- /dev/null +++ b/Tests/Runtime/Scripts/Messages/ClassTargetedMessage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6c3a18b7d6494b71a4f8d0e9ca2c1109 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/TestUtilities/LeakWatcher.cs b/Tests/Runtime/TestUtilities/LeakWatcher.cs new file mode 100644 index 00000000..8c4baa6a --- /dev/null +++ b/Tests/Runtime/TestUtilities/LeakWatcher.cs @@ -0,0 +1,340 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime +{ + using System; + using System.Globalization; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using NUnit.Framework; + + /// + /// Snapshots the public registration counters of an + /// on construction and asserts on + /// that the counters returned to their starting + /// values. Intended to bracket a unit of work that should leave the bus + /// without leaks (for example a register/dispatch/deregister cycle). + /// + /// + /// + /// Counter source: this watcher reads every public registration counter on + /// : , + /// , + /// , + /// , + /// , and + /// . Every counter is part of + /// the public surface, so the watcher does not lift any internal field. If a + /// future API adds a seventh registration kind, and + /// need to be extended in lock-step. + /// + /// + /// Allocation note: the getter and the + /// getter both walk the per-message-type + /// caches that back and + /// . Each call is O(types), + /// so callers reading these properties in tight loops should snapshot at + /// region boundaries instead of polling every frame. + /// + /// + /// + /// + /// using (LeakWatcher.Watch()) + /// { + /// MessageRegistrationHandle handle = token.RegisterUntargeted<Foo>(_ => { }); + /// Foo message = new(); + /// message.EmitUntargeted(); + /// token.RemoveRegistration(handle); + /// } + /// // Dispose asserts the bus drained. + /// + /// + public sealed class LeakWatcher : IDisposable + { + private readonly IMessageBus _bus; + private readonly bool _throwOnLeak; + private readonly string _label; + private readonly int _initialUntargeted; + private readonly int _initialTargeted; + private readonly int _initialBroadcast; + private readonly int _initialInterceptors; + private readonly int _initialPostProcessors; + private readonly int _initialGlobalAcceptAll; + + private bool _disposed; + private int _finalUntargeted; + private int _finalTargeted; + private int _finalBroadcast; + private int _finalInterceptors; + private int _finalPostProcessors; + private int _finalGlobalAcceptAll; + + /// + /// Captures the initial registration counts on the supplied + /// . Pass the global bus by passing + /// or omitting the argument. + /// + /// Bus to watch. Defaults to the global bus. + /// + /// When (default), + /// fails the current test via + /// if the post-disposal counts differ from the pre-construction + /// counts. When , the leak is captured into + /// for inspection. + /// + /// + /// Optional label included in the failure message. Useful for + /// distinguishing multiple watchers in a single test. + /// + public LeakWatcher(IMessageBus bus = null, bool throwOnLeak = true, string label = null) + { + _bus = bus ?? MessageHandler.MessageBus; + if (_bus == null) + { + throw new InvalidOperationException( + "LeakWatcher requires a non-null bus. The global bus is null; " + + "ensure DxMessagingStaticState.Reset has run." + ); + } + + _throwOnLeak = throwOnLeak; + _label = label; + _initialUntargeted = _bus.RegisteredUntargeted; + _initialTargeted = _bus.RegisteredTargeted; + _initialBroadcast = _bus.RegisteredBroadcast; + _initialInterceptors = _bus.RegisteredInterceptors; + _initialPostProcessors = _bus.RegisteredPostProcessors; + _initialGlobalAcceptAll = _bus.RegisteredGlobalAcceptAll; + } + + /// + /// Convenience factory that builds a watcher with default options on + /// the global bus. Use inside a using block to bracket the + /// region under test. + /// + public static LeakWatcher Watch(string label = null) + { + return new LeakWatcher(bus: null, throwOnLeak: true, label: label); + } + + /// + /// The total registration count read from the live bus across all + /// six counter kinds (handler counts plus interceptor, post-processor, + /// and global-accept-all counts). Updates on every read so callers + /// can compare against . + /// + /// + /// Each access walks the per-message-type caches behind + /// and + /// , so the call is + /// O(types). Avoid polling this property inside a tight loop. + /// + public int Snapshot + { + get + { + return _bus.RegisteredUntargeted + + _bus.RegisteredTargeted + + _bus.RegisteredBroadcast + + _bus.RegisteredInterceptors + + _bus.RegisteredPostProcessors + + _bus.RegisteredGlobalAcceptAll; + } + } + + /// + /// The total registration count captured at construction. Frozen for + /// the lifetime of the watcher. + /// + public int InitialSnapshot => + _initialUntargeted + + _initialTargeted + + _initialBroadcast + + _initialInterceptors + + _initialPostProcessors + + _initialGlobalAcceptAll; + + /// + /// Number of additional registrations leaked relative to the initial + /// snapshot. Negative values indicate a regression where the watched + /// region removed registrations beyond what it owned. Reads the live + /// bus on each access until is called, after + /// which the disposal-time delta is returned. + /// + /// + /// Each pre-disposal access pays the same O(types) walk as + /// . Snapshot at region boundaries. + /// + public int LeakedRegistrations + { + get + { + if (_disposed) + { + return TotalDelta( + _finalUntargeted, + _finalTargeted, + _finalBroadcast, + _finalInterceptors, + _finalPostProcessors, + _finalGlobalAcceptAll + ); + } + + return TotalDelta( + _bus.RegisteredUntargeted, + _bus.RegisteredTargeted, + _bus.RegisteredBroadcast, + _bus.RegisteredInterceptors, + _bus.RegisteredPostProcessors, + _bus.RegisteredGlobalAcceptAll + ); + } + } + + /// + /// Returns a one-line per-counter description of the delta between the + /// initial snapshot and the current (or final, post-disposal) bus + /// counts. Intended for inclusion in NUnit assertion messages so a + /// failure surfaces the exact registration kinds that drifted instead + /// of just the aggregate delta. + /// + /// + /// Allocates one string per call. Pre-disposal calls walk the live bus + /// counters (O(types)); post-disposal calls read the snapshot taken + /// inside . Safe to call before or after disposal. + /// + public string DescribeDelta() + { + int currentUntargeted = _disposed ? _finalUntargeted : _bus.RegisteredUntargeted; + int currentTargeted = _disposed ? _finalTargeted : _bus.RegisteredTargeted; + int currentBroadcast = _disposed ? _finalBroadcast : _bus.RegisteredBroadcast; + int currentInterceptors = _disposed ? _finalInterceptors : _bus.RegisteredInterceptors; + int currentPostProcessors = _disposed + ? _finalPostProcessors + : _bus.RegisteredPostProcessors; + int currentGlobalAcceptAll = _disposed + ? _finalGlobalAcceptAll + : _bus.RegisteredGlobalAcceptAll; + + int delta = TotalDelta( + currentUntargeted, + currentTargeted, + currentBroadcast, + currentInterceptors, + currentPostProcessors, + currentGlobalAcceptAll + ); + + string scope = string.IsNullOrEmpty(_label) ? string.Empty : $" ({_label})"; + return string.Format( + CultureInfo.InvariantCulture, + "LeakWatcher{0}: delta={1} (Untargeted {2}->{3}, Targeted {4}->{5}, " + + "Broadcast {6}->{7}, Interceptors {8}->{9}, PostProcessors {10}->{11}, " + + "GlobalAcceptAll {12}->{13}).", + scope, + delta, + _initialUntargeted, + currentUntargeted, + _initialTargeted, + currentTargeted, + _initialBroadcast, + currentBroadcast, + _initialInterceptors, + currentInterceptors, + _initialPostProcessors, + currentPostProcessors, + _initialGlobalAcceptAll, + currentGlobalAcceptAll + ); + } + + /// + /// Compares the current bus counts to the initial snapshot. When + /// throwOnLeak is true the diff is asserted via NUnit and the + /// test fails on any mismatch. Idempotent. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + _finalUntargeted = _bus.RegisteredUntargeted; + _finalTargeted = _bus.RegisteredTargeted; + _finalBroadcast = _bus.RegisteredBroadcast; + _finalInterceptors = _bus.RegisteredInterceptors; + _finalPostProcessors = _bus.RegisteredPostProcessors; + _finalGlobalAcceptAll = _bus.RegisteredGlobalAcceptAll; + + int delta = TotalDelta( + _finalUntargeted, + _finalTargeted, + _finalBroadcast, + _finalInterceptors, + _finalPostProcessors, + _finalGlobalAcceptAll + ); + if (delta == 0) + { + return; + } + + if (!_throwOnLeak) + { + return; + } + + Assert.Fail(BuildFailureMessage(delta)); + } + + private string BuildFailureMessage(int delta) + { + string scope = string.IsNullOrEmpty(_label) ? string.Empty : $" ({_label})"; + return string.Format( + CultureInfo.InvariantCulture, + "LeakWatcher{0}: registration count changed by {1} during the watched region. " + + "Untargeted {2}->{3}, Targeted {4}->{5}, Broadcast {6}->{7}, " + + "Interceptors {8}->{9}, PostProcessors {10}->{11}, GlobalAcceptAll {12}->{13}.", + scope, + delta, + _initialUntargeted, + _finalUntargeted, + _initialTargeted, + _finalTargeted, + _initialBroadcast, + _finalBroadcast, + _initialInterceptors, + _finalInterceptors, + _initialPostProcessors, + _finalPostProcessors, + _initialGlobalAcceptAll, + _finalGlobalAcceptAll + ); + } + + private int TotalDelta( + int untargeted, + int targeted, + int broadcast, + int interceptors, + int postProcessors, + int globalAcceptAll + ) + { + int sumNow = + untargeted + targeted + broadcast + interceptors + postProcessors + globalAcceptAll; + int sumThen = + _initialUntargeted + + _initialTargeted + + _initialBroadcast + + _initialInterceptors + + _initialPostProcessors + + _initialGlobalAcceptAll; + return sumNow - sumThen; + } + } +} +#endif diff --git a/Tests/Runtime/TestUtilities/LeakWatcher.cs.meta b/Tests/Runtime/TestUtilities/LeakWatcher.cs.meta new file mode 100644 index 00000000..2ae6f2cf --- /dev/null +++ b/Tests/Runtime/TestUtilities/LeakWatcher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7f1e0a7c41d6429187b41cb6e09beb01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/TestUtilities/MessageScenarios.cs b/Tests/Runtime/TestUtilities/MessageScenarios.cs index a74588b7..7878c8cf 100644 --- a/Tests/Runtime/TestUtilities/MessageScenarios.cs +++ b/Tests/Runtime/TestUtilities/MessageScenarios.cs @@ -76,6 +76,27 @@ public static IEnumerable WithDiagnosticsToggle } } } + + /// + /// Cross-product of interceptor x post-processor presence per kind, + /// minus the (false, false) baseline. That row was removed for two + /// reasons: it duplicated the no-feature emit path already pinned by + /// AllocationMatrixTests.EmitIsZeroAlloc, and the original + /// (Untargeted, false, false) case proved empirically unstable when + /// run through this harness. + /// + public static IEnumerable WithAtLeastOneFeatureToggle + { + get + { + foreach (MessageScenario scenario in AllKinds) + { + yield return scenario.WithInterceptor(false).WithPostProcessor(true); + yield return scenario.WithInterceptor(true).WithPostProcessor(false); + yield return scenario.WithInterceptor(true).WithPostProcessor(true); + } + } + } } } #endif diff --git a/docs/advanced/emit-shorthands.md b/docs/advanced/emit-shorthands.md index d82d4993..f5af6771 100644 --- a/docs/advanced/emit-shorthands.md +++ b/docs/advanced/emit-shorthands.md @@ -463,6 +463,6 @@ public class AchievementTracker : MessageAwareComponent - **[Quick Reference](../reference/quick-reference.md)** -- API cheat sheet for all emit methods - **[Message Types](../concepts/message-types.md)** -- Understand Untargeted, Targeted, and Broadcast messages -- **[Targeting & Context](../concepts/targeting-and-context.md)** -- Deep dive into GameObject vs Component +- **[Targeting & Context](../concepts/targeting-and-context.md)** -- GameObject vs Component as message context - **[String Messages](string-messages.md)** -- More about string message helpers - **[Diagnostics](../guides/diagnostics.md)** -- Debugging tools and Inspector integration diff --git a/docs/advanced/message-bus-providers.md b/docs/advanced/message-bus-providers.md index 426dcfe9..108dc16e 100644 --- a/docs/advanced/message-bus-providers.md +++ b/docs/advanced/message-bus-providers.md @@ -150,7 +150,7 @@ public class DiagnosticLogger : MonoBehaviour - A ScriptableObject provider (design-time configuration) - A runtime provider instance (runtime configuration) -This gives you the best of both worlds: editor-friendly assets and runtime flexibility. +The handle resolves the runtime provider when one is set; otherwise it resolves the asset. That way the same field accepts an editor-assigned ScriptableObject or a runtime override without branching at every call site. ### Key Methods diff --git a/docs/advanced/runtime-configuration.md b/docs/advanced/runtime-configuration.md index 0fed72a8..4f0b604d 100644 --- a/docs/advanced/runtime-configuration.md +++ b/docs/advanced/runtime-configuration.md @@ -404,5 +404,5 @@ public class DynamicComponentManager - **[Message Bus Providers](message-bus-providers.md)** -- ScriptableObject-based provider system for design-time configuration - **[Registration Builders](registration-builders.md)** -- Fluent API for building message registrations with priority and lifecycle control - **[DI Integration Guides](../integrations/index.md)** -- Zenject, VContainer, and Reflex integration patterns -- **[Testing Guide](../guides/testing.md)** -- Comprehensive testing patterns with DxMessaging +- **[Testing Guide](../guides/testing.md)** -- Patterns for unit-testing handlers, isolating per-test buses, and asserting on emission history - **[Back to Documentation Hub](../getting-started/index.md)** -- Browse all docs diff --git a/docs/architecture/comparisons.md b/docs/architecture/comparisons.md index aa352ed7..e79a9fa4 100644 --- a/docs/architecture/comparisons.md +++ b/docs/architecture/comparisons.md @@ -34,10 +34,10 @@ These sections are auto-updated by the PlayMode comparison benchmarks in the [Co | Message Tech | Operations / Second | Allocations? | | ---------------------------------- | ------------------- | ------------ | -| DxMessaging (Untargeted) - No-Copy | 19,410,250 | No | -| UniRx MessageBroker | 17,983,998 | No | -| MessagePipe (Global) | 97,769,139 | No | -| Zenject SignalBus | 2,420,160 | Yes | +| DxMessaging (Untargeted) - No-Copy | 19,177,424 | No | +| UniRx MessageBroker | 17,990,754 | No | +| MessagePipe (Global) | 97,475,572 | No | +| Zenject SignalBus | 2,381,081 | Yes | ### Comparisons (macOS) @@ -483,7 +483,7 @@ public class AchievementSystem #### What Problems It Solves - [x] **Decoupling:** Classes communicate without direct references -- [x] **DI integration:** Seamless with Zenject dependency injection +- [x] **DI integration:** Binds directly into Zenject containers - [x] **Testability:** Easy to mock SignalBus in tests - [x] **Type safety:** Strongly-typed signal classes - [x] **Subscriber validation:** Can enforce required subscribers diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index 408dc786..ef0e8154 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -32,17 +32,17 @@ You can run these benchmarks yourself to get results specific to your environmen | Message Tech | Operations / Second | Allocations? | | ------------------------------------------ | ------------------- | ------------ | -| Unity | 2,381,723 | Yes | -| DxMessaging (GameObject) - Normal | 10,899,636 | No | -| DxMessaging (Component) - Normal | 10,986,548 | No | -| DxMessaging (GameObject) - No-Copy | 12,517,418 | No | -| DxMessaging (Component) - No-Copy | 9,296,087 | No | -| DxMessaging (Untargeted) - No-Copy | 19,309,752 | No | -| DxMessaging (Untargeted) - Interceptors | 7,696,568 | No | -| DxMessaging (Untargeted) - Post-Processors | 6,545,742 | No | -| Reflexive (One Argument) | 2,846,178 | No | -| Reflexive (Two Arguments) | 2,345,039 | No | -| Reflexive (Three Arguments) | 2,330,838 | No | +| Unity | 2,271,329 | Yes | +| DxMessaging (GameObject) - Normal | 9,933,405 | No | +| DxMessaging (Component) - Normal | 9,945,873 | No | +| DxMessaging (GameObject) - No-Copy | 11,314,718 | No | +| DxMessaging (Component) - No-Copy | 8,632,119 | No | +| DxMessaging (Untargeted) - No-Copy | 19,115,797 | No | +| DxMessaging (Untargeted) - Interceptors | 7,570,187 | No | +| DxMessaging (Untargeted) - Post-Processors | 6,501,129 | No | +| Reflexive (One Argument) | 2,798,912 | No | +| Reflexive (Two Arguments) | 2,349,744 | No | +| Reflexive (Three Arguments) | 2,331,920 | No | ## macOS diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 5ddd0b46..9093fe02 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -12,7 +12,7 @@ This section explains the core concepts behind DxMessaging. **Concepts** are the ## Core Concepts -- **[Message Types](message-types.md)** -- Deep dive into Untargeted, Targeted, and Broadcast messages with code examples and decision guides. +- **[Message Types](message-types.md)** -- The three categories (Untargeted, Targeted, Broadcast) with code examples and decision guides. - **[Targeting and Context](targeting-and-context.md)** -- How DxMessaging uses GameObjects and Components as message context, and the role of `InstanceId`. diff --git a/docs/getting-started/getting-started.md b/docs/getting-started/getting-started.md index 66922e21..1586fae0 100644 --- a/docs/getting-started/getting-started.md +++ b/docs/getting-started/getting-started.md @@ -107,6 +107,14 @@ var heal = new Heal(10); heal.EmitGameObjectTargeted(playerGameObject); ``` +### Important: Inheritance and base calls + +> **Important** +> +> If you override `Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, or `RegisterMessageHandlers` on a `MessageAwareComponent`, your override **must** call the matching base method first. Forgetting any of them silently breaks message dispatch on the component -- no errors, no compile failure, just dead handlers. The Roslyn analyzer raises DXMSG006 and the [Inspector overlay](../guides/inspector-overlay.md) shows a HelpBox once the code is on disk. + +For the full table of guarded methods and the exact failure mode for each, see [Inheritance and base calls](quick-start.md#important-inheritance-and-base-calls) in the quickstart and [DXMSG006 in the analyzer reference](../reference/analyzers.md#dxmsg006-missing-base-call). + ## Message Types ### New to messaging? Use this decision tree diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 2fd282fa..a1609f32 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -40,7 +40,7 @@ flowchart TD 1. **[Mental Model](../concepts/mental-model.md)** - How to think about DxMessaging (10 min) 1. **[Visual Guide](visual-guide.md)** - Beginner-friendly visual introduction (5 min) -1. **[Getting Started Guide](getting-started.md)** - Comprehensive guide with examples (10 min) +1. **[Getting Started Guide](getting-started.md)** - Walkthrough with code examples (10 min) 1. **[Quick Start](quick-start.md)** - Your first working message (5 min) 1. **[Overview](overview.md)** - What DxMessaging is and why it exists (5 min) @@ -59,7 +59,7 @@ flowchart TD 1. Master [Interceptors & Ordering](../concepts/interceptors-and-ordering.md) 1. Explore [Listening Patterns](../concepts/listening-patterns.md) -1. Deep dive into [Design & Architecture](../architecture/design-and-architecture.md) +1. Read [Design & Architecture](../architecture/design-and-architecture.md) 1. Review [Advanced Topics](../guides/advanced.md) ## Core Documentation @@ -87,7 +87,7 @@ flowchart TD ### Architecture & Performance -- **[Design & Architecture](../architecture/design-and-architecture.md)** - Deep dive into internals and optimizations +- **[Design & Architecture](../architecture/design-and-architecture.md)** - Internals, allocation behavior, and optimization choices - **[Performance Benchmarks](../architecture/performance.md)** - OS-specific tables auto-generated by tests - **[Advanced](../guides/advanced.md)** - Lifecycles, safety, manual control diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 2787dee3..cd3c9464 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -48,7 +48,7 @@ https://github.com/wallstop/DxMessaging.git ### NPM Scoped Registry 1. Open Unity Package Manager -1. (Optional) Enable Pre-release packages to get the latest, cutting-edge builds +1. (Optional) Enable Pre-release packages to receive pre-release builds (RCs and betas) before they ship as stable 1. Open the Advanced Package Settings 1. Add an entry for a new "Scoped Registry" - Name: `NPM` diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 8ef48f66..b5049e65 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -96,6 +96,26 @@ public sealed class HealthUI : MessageAwareComponent } ``` +#### Important: Inheritance and base calls + +> **Important** +> +> If you override any of the lifecycle methods that DxMessaging hooks, your override **must** call the matching base method first. Forgetting this is silent: no errors, no compile failure, just dead handlers. The Roslyn analyzer (DXMSG006) and the [Inspector overlay](../guides/inspector-overlay.md) will flag the mistake, but they fire after the broken code is already written. + +The five guarded methods, with what breaks if you forget the base call: + +| Method | What breaks | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `base.Awake()` | The registration token is never created; no handler on this component runs. | +| `base.OnEnable()` | When `MessageRegistrationTiedToEnableStatus` is true, your handlers never re-enable with the component. | +| `base.OnDisable()` | Handlers stay live while the component is disabled, processing messages they should not see. | +| `base.OnDestroy()` | Registrations leak past the component's lifetime; held references prevent GC. | +| `base.RegisterMessageHandlers()` | The default `StringMessage` handlers never register. Override `RegisterForStringMessages => false` if you do not want them. | + +`Start`, `Update`, `FixedUpdate`, `LateUpdate`, and `OnApplicationQuit` are not hooked. You can override them without calling base. + +See [DXMSG006 in the analyzer reference](../reference/analyzers.md#dxmsg006-missing-base-call) and the symptom-first [troubleshooting guide](../reference/troubleshooting.md). + ### Step 3: Send messages ```csharp diff --git a/docs/getting-started/visual-guide.md b/docs/getting-started/visual-guide.md index 55de805b..c6955f80 100644 --- a/docs/getting-started/visual-guide.md +++ b/docs/getting-started/visual-guide.md @@ -250,6 +250,8 @@ public class Player : MessageAwareComponent { - Deactivates in `OnDisable()` - Cleans up in `OnDestroy()` +> **Important**: If you override `Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, or `RegisterMessageHandlers` on a `MessageAwareComponent`, call `base.X()` first or your handlers stop working silently. See the [analyzer reference](../reference/analyzers.md#dxmsg006-missing-base-call). + ### Step 3: Send It ```csharp @@ -592,12 +594,12 @@ If you checked all these, you are following best practices. ## Next Steps -Ready to dive deeper? +Ready for more? 1. **[Mental Model](../concepts/mental-model.md)** - Understand the philosophy 1. **[Getting Started Guide](getting-started.md)** - Full guide with more details 1. **[Common Patterns](../guides/patterns.md)** - Real-world examples -1. **[Message Types](../concepts/message-types.md)** - Deep dive into when to use what +1. **[Message Types](../concepts/message-types.md)** - When to pick Untargeted, Targeted, or Broadcast 1. **[Diagnostics](../guides/diagnostics.md)** - Master the Inspector tools --- diff --git a/docs/guides/inspector-overlay.md b/docs/guides/inspector-overlay.md index 79e1224d..9ece863a 100644 --- a/docs/guides/inspector-overlay.md +++ b/docs/guides/inspector-overlay.md @@ -10,7 +10,7 @@ you fix it. This guide covers when warnings appear, what the Inspector HelpBox looks like, the three actions it offers, the Project Settings panel, and the -manual rescan menu. For the comprehensive reference -- every diagnostic id, +manual rescan menu. For the full reference -- every diagnostic id, exact detection policy, suppression precedence, and Unity 2021 setup notes -- see [Roslyn Analyzers & Diagnostics](../reference/analyzers.md). @@ -273,9 +273,9 @@ Each suppression emits an audit-only [`DXMSG008`](../reference/analyzers.md#dxms ## Related -- [Roslyn Analyzers & Diagnostics](../reference/analyzers.md) -- the - comprehensive reference for every diagnostic id, the - suppression-precedence ordering, and the Unity 2021 setup notes. +- [Roslyn Analyzers & Diagnostics](../reference/analyzers.md) -- every + diagnostic id, the suppression-precedence ordering, and the Unity 2021 + setup notes. - [Unity Integration](unity-integration.md) -- the inheritance contract the analyzer enforces and the recommended `MessageAwareComponent` patterns. diff --git a/docs/guides/patterns.md b/docs/guides/patterns.md index c43c05a7..c4308b4f 100644 --- a/docs/guides/patterns.md +++ b/docs/guides/patterns.md @@ -774,7 +774,7 @@ public class MatchStats : MessageAwareComponent { **Note:** Scriptable Object Architecture (SOA) is a debated pattern in the Unity community. It has both proponents who value its designer-friendly workflow and critics who raise concerns about scalability and maintainability. See [Anti-ScriptableObject Architecture](https://github.com/cathei/AntiScriptableObjectArchitecture) for one perspective on the criticisms. Teams should evaluate SOA based on their specific needs. Alternatives include dependency injection (Zenject, VContainer), reactive systems (UniRx), or messaging systems (DxMessaging, MessagePipe). -That said, if your project uses or requires SOA, DxMessaging can work alongside it. +If your project uses or requires SOA, DxMessaging can work alongside it. ### What is Scriptable Object Architecture? @@ -800,7 +800,7 @@ From [Anti-ScriptableObject Architecture](https://github.com/cathei/AntiScriptab 1. **Wrong Purpose** - ScriptableObjects are designed for immutable design data, not runtime mutable state 1. **Redundant Complexity** - Standard C# objects achieve the same goals without SO restrictions 1. **Inspector Dependency** - Binds architecture to Unity's GUI, complicating debugging and maintenance -1. **Limited Scalability** - Runtime-created variables undermine the pattern; managing numerous assets becomes unwieldy +1. **Limited Scalability** - Runtime-created variables undermine the pattern; managing hundreds of `*Variable` and `GameEvent` assets across scenes becomes unwieldy 1. **Domain Reload Issues** - Disabled domain reloading causes ScriptableObjects to retain values unpredictably 1. **Testability Concerns** - SO assets persist between tests, requiring manual cleanup diff --git a/docs/integrations/index.md b/docs/integrations/index.md index 981746cd..5766382f 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -1,10 +1,10 @@ # DI Framework Integrations -DxMessaging integrates seamlessly with popular Unity dependency injection (DI) frameworks. This section covers how to combine DxMessaging's reactive event system with constructor-based dependency injection for a powerful, scalable architecture. +DxMessaging integrates with three Unity DI frameworks: VContainer, Zenject, and Reflex. This section shows how to bind `IMessageBus` in each container, use scoped lifetimes for per-scene buses, and inject `IMessageRegistrationBuilder` so handlers stay testable. ## Why Combine DI + Messaging? -Use the best of both worlds: +DI and messaging cover different concerns: - **Constructor Injection** -- for service dependencies (repositories, managers, configuration) - **Messaging** -- for reactive events (damage taken, item collected, game state changes) diff --git a/docs/integrations/zenject.md b/docs/integrations/zenject.md index ef4397b0..6dc6a95f 100644 --- a/docs/integrations/zenject.md +++ b/docs/integrations/zenject.md @@ -6,7 +6,7 @@ ## Overview -**Zenject** (also known as Extenject) is a powerful dependency injection framework for Unity. DxMessaging integrates with Zenject, allowing you to: +**Zenject** (also known as Extenject) is a dependency injection framework for Unity with extensive container, sub-container, and factory support. DxMessaging integrates with Zenject, allowing you to: - **Inject `IMessageBus`** as a singleton dependency in any class - **Use DI for construction** + DxMessaging for events (best of both worlds) diff --git a/docs/reference/analyzers.md b/docs/reference/analyzers.md index 66c248a1..bbb56a01 100644 --- a/docs/reference/analyzers.md +++ b/docs/reference/analyzers.md @@ -125,17 +125,27 @@ public readonly partial struct Damage - **Severity:** Warning (lowered to Info under the smart-case described below) - **Source:** `MessageAwareComponentBaseCallAnalyzer` - **Triggered when:** A class deriving from `DxMessaging.Unity.MessageAwareComponent` overrides one of the **five guarded methods** without invoking the base implementation. -- **Message:** `'{0}' overrides MessageAwareComponent.{1} but does not call base.{1}(); the messaging system may not function correctly on this component.` +- **Messages (per guarded method):** the analyzer emits a per-method consequence sentence so the diagnostic is actionable rather than generic. The message strings are sourced verbatim from `MessageAwareComponentBaseCallAnalyzer.MissingBaseCallMessageFormatsByMethod`: + +| Method | Message format (`{0}` is the offending type) | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Awake` | `'{0}' overrides MessageAwareComponent.Awake but does not call base.Awake(); the message registration token will never be created and handlers cannot register.` | +| `OnEnable` | `'{0}' overrides MessageAwareComponent.OnEnable but does not call base.OnEnable(); handlers will not be re-enabled when this component is enabled.` | +| `OnDisable` | `'{0}' overrides MessageAwareComponent.OnDisable but does not call base.OnDisable(); handlers will not be disabled when this component is disabled, causing unwanted message processing.` | +| `OnDestroy` | `'{0}' overrides MessageAwareComponent.OnDestroy but does not call base.OnDestroy(); handlers will not be deregistered and the registration token will not be released, causing a memory leak.` | +| `RegisterMessageHandlers` | `'{0}' overrides MessageAwareComponent.RegisterMessageHandlers but does not call base.RegisterMessageHandlers(); default string-message handlers will not be registered (override RegisterForStringMessages to suppress this warning).` | + +A generic fallback (`'{0}' overrides MessageAwareComponent.{1} but does not call base.{1}(); the messaging system may not function correctly on this component.`) exists as a safety net for any future guarded method whose entry has not yet been populated. A meta-test forces the per-method dictionary to stay aligned with the guarded set so the fallback is never reached in practice. ### Guarded methods -| Method | Why the base call matters | -| ------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `Awake` | Creates the `MessageRegistrationToken`; without it, every handler is dead. | -| `OnEnable` | Calls `Token.Enable()` so handlers actually receive messages. | -| `OnDisable` | Calls `Token.Disable()` so handlers stop firing while the component is disabled. | -| `OnDestroy` | Disposes the token and cleans up registrations. | -| `RegisterMessageHandlers` | Default implementation registers built-in string-message handlers; skipping it silently disables those demos. | +| Method | Why the base call matters | +| ------------------------- | --------------------------------------------------------------------------------------------------------- | +| `Awake` | The message registration token will never be created and handlers cannot register. | +| `OnEnable` | Handlers will not be re-enabled when this component is enabled. | +| `OnDisable` | Handlers will not be disabled when this component is disabled, causing unwanted message processing. | +| `OnDestroy` | Handlers will not be deregistered and the registration token will not be released, causing a memory leak. | +| `RegisterMessageHandlers` | Default string-message handlers will not be registered. Override `RegisterForStringMessages` to suppress. | !!! note `OnApplicationQuit` is intentionally **not** guarded. The base implementation is a documented no-op -- missing a base call there is harmless and the analyzer ignores it. diff --git a/docs/reference/faq.md b/docs/reference/faq.md index 9f0f6181..ffd78421 100644 --- a/docs/reference/faq.md +++ b/docs/reference/faq.md @@ -26,6 +26,10 @@ - Enable logs and diagnostics: [Diagnostics](../guides/diagnostics.md). +## My MessageAwareComponent subclass does not receive messages. What is wrong? + +The most common cause is forgetting to call `base.Awake()` (or `base.OnEnable()`, `base.OnDisable()`, `base.OnDestroy()`, `base.RegisterMessageHandlers()`) when you override one of those methods. The framework's setup runs in those base calls; without them, your registration token is never created or your handlers never enable. The Roslyn analyzer flags this as DXMSG006. See [Inheritance and base calls](../getting-started/quick-start.md#important-inheritance-and-base-calls) for the full list of guarded methods. + ## What happens if I register a listener inside a message handler? - The newly registered listener will **not** run for the current message emission. It will only become active starting with the **next** message emission. diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 63cab250..f3ca1c2b 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -14,7 +14,9 @@ - **`base.RegisterMessageHandlers()`** - Call this FIRST in your override to preserve default setup (including string message demos) and parent class registrations. - **`base.Awake()`** - Call this if you override `Awake()`, or your token won't be created (this is the #1 cause of handlers not firing). - **`base.OnEnable()` / `base.OnDisable()`** - Call these so the token actually enables/disables. + - **`base.OnDestroy()`** - Call this if you override `OnDestroy()`, or registrations leak past the component's lifetime and held references prevent GC. - **Never use `new` to hide Unity methods** (e.g., `new void OnEnable()`); always use `override` and call `base.*`. + - For the complete table of guarded methods and the exact failure mode for each, see [Inheritance and base calls](../getting-started/quick-start.md#important-inheritance-and-base-calls) in the quickstart. Registration timing diff --git a/llms.txt b/llms.txt index fd645b1c..2fa228d4 100644 --- a/llms.txt +++ b/llms.txt @@ -208,7 +208,7 @@ npx cspell "**/*" This repository includes comprehensive AI agent guidance in the `.llm/` directory: - **[.llm/context.md](https://github.com/wallstop/DxMessaging/blob/master/.llm/context.md)** - Repository guidelines, coding standards, testing policies -- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 138+ specialized skill documents covering: +- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 142+ specialized skill documents covering: - **documentation/** - **github-actions/** - **packaging/** @@ -216,6 +216,7 @@ This repository includes comprehensive AI agent guidance in the `.llm/` director - **scripting/** - **solid/** - **testing/** + - **unity/** ## Common Pitfalls & Solutions @@ -286,5 +287,5 @@ Copyright (c) 2017-2026 Wallstop Studios --- -**Last Updated:** 2026-05-02 +**Last Updated:** 2026-05-03 **Generated by:** scripts/update-llms-txt.js using package.json v2.2.0 and .llm/skills metadata diff --git a/scripts/__tests__/validate-docs-prose.test.js b/scripts/__tests__/validate-docs-prose.test.js new file mode 100644 index 00000000..8f6dfb6b --- /dev/null +++ b/scripts/__tests__/validate-docs-prose.test.js @@ -0,0 +1,877 @@ +/** + * @fileoverview Tests for scripts/validate-docs-prose.js. + * + * Drives the validator both through its module exports and as a child + * process against fixture files. Coverage focuses on: + * - Each rule's positive match and the suggestion text. + * - Code-fence and inline-code skip behavior. + * - URL and HTML attribute skip behavior. + * - Allow markers (same-line, next-line, file-wide). + * - Per-file exemptions (CHANGELOG, .llm/skills/documentation/). + * - CLI flags: --paths, --rule, --list-rules, --summary, exit codes. + */ + +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const childProcess = require("child_process"); + +const VALIDATOR_SCRIPT_PATH = path.resolve( + __dirname, + "../validate-docs-prose.js", +); +const REPO_ROOT = path.resolve(__dirname, "../.."); + +const { + scanContent, + RULES, + RULE_INDEX, + parseBaseline, + baselineKey, + formatBaselineEntry, + EXCLUDE_DIRS, + MARKETING_TERMS, + LLM_FILLER_TERMS, +} = require("../validate-docs-prose.js"); + +function runValidator(args) { + return childProcess.spawnSync( + process.execPath, + [VALIDATOR_SCRIPT_PATH, ...args], + { cwd: REPO_ROOT, encoding: "utf8" }, + ); +} + +function withFixture(suffix, contents, callback) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-prose-")); + const filePath = path.join(tempDir, `fixture${suffix}`); + try { + fs.writeFileSync(filePath, contents, "utf8"); + callback(filePath, tempDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function scan(content, filename) { + const filePath = filename || "/tmp/fake.md"; + return scanContent(filePath, content, {}); +} + +describe("validate-docs-prose rule matching", () => { + test("marketing: 'powerful' is flagged whole-word", () => { + const r = scan("This is a powerful library.\n"); + expect(r.violations).toHaveLength(1); + expect(r.violations[0].rule).toBe("marketing"); + expect(r.violations[0].term.toLowerCase()).toBe("powerful"); + }); + + test("marketing: substring inside a longer word does not match", () => { + const r = scan("The powerfulness of names should not match.\n"); + expect(r.violations).toHaveLength(0); + }); + + test("marketing: case-insensitive", () => { + const r = scan("Build a Robust API.\n"); + expect(r.violations).toHaveLength(1); + expect(r.violations[0].rule).toBe("marketing"); + }); + + test("marketing: hyphenated terms match", () => { + const r = scan("Our cutting-edge runtime.\n"); + expect(r.violations).toHaveLength(1); + expect(r.violations[0].term.toLowerCase()).toBe("cutting-edge"); + }); + + test("llm-filler: phrase match across spaces", () => { + const r = scan("We will delve into the topic.\n"); + expect(r.violations.some((v) => v.rule === "llm-filler")).toBe(true); + }); + + test("llm-filler: 'realm of' matches", () => { + const r = scan("In the realm of messaging, there is X.\n"); + expect(r.violations.some((v) => v.rule === "llm-filler")).toBe(true); + }); + + test("hedge: 'Furthermore,' at line start is flagged", () => { + const r = scan("Furthermore, the bus is sync.\n"); + expect(r.violations.some((v) => v.rule === "hedge")).toBe(true); + }); + + test("hedge: 'Furthermore,' mid-line is NOT flagged", () => { + const r = scan("This is fine and Furthermore, ignore.\n"); + expect(r.violations.some((v) => v.rule === "hedge")).toBe(false); + }); + + test("hedge: list item 'Furthermore,' is flagged", () => { + const r = scan("- Furthermore, the bus is sync.\n"); + expect(r.violations.some((v) => v.rule === "hedge")).toBe(true); + }); + + test("hedge: 'It's worth noting' triggers", () => { + const r = scan("It's worth noting that X is Y.\n"); + expect(r.violations.some((v) => v.rule === "hedge")).toBe(true); + }); + + test("vague-quantifier: 'a wide variety of' is flagged", () => { + const r = scan("We support a wide variety of senders.\n"); + expect(r.violations.some((v) => v.rule === "vague-quantifier")).toBe( + true, + ); + }); + + test("vague-quantifier: 'numerous' alone matches", () => { + const r = scan("There are numerous reasons for this.\n"); + expect(r.violations.some((v) => v.rule === "vague-quantifier")).toBe( + true, + ); + }); + + test("soft-fluff: 'allows you to easily' is flagged", () => { + const r = scan("This allows you to easily emit messages.\n"); + expect(r.violations.some((v) => v.rule === "soft-fluff")).toBe(true); + }); + + test("soft-fluff: 'enables you to' is flagged", () => { + const r = scan("This enables you to register a handler.\n"); + expect(r.violations.some((v) => v.rule === "soft-fluff")).toBe(true); + }); +}); + +describe("validate-docs-prose skip rules", () => { + test("words inside fenced code blocks are NOT flagged", () => { + const md = + "Intro paragraph.\n\n```csharp\n// powerful is fine inside code\nvar x = \"powerful\";\n```\n\nMore prose.\n"; + const r = scan(md); + expect(r.violations).toHaveLength(0); + }); + + test("words inside inline backticks are NOT flagged", () => { + const r = scan("The flag `powerful` toggles things.\n"); + expect(r.violations).toHaveLength(0); + }); + + test("words inside URLs are NOT flagged", () => { + const r = scan( + "See https://example.com/powerful/blazing-fast for more.\n", + ); + expect(r.violations).toHaveLength(0); + }); + + test("words inside HTML attributes are NOT flagged", () => { + const r = scan( + 'comprehensive guide\n', + ); + expect(r.violations).toHaveLength(0); + }); + + test("multiple inline-code spans on one line are all stripped", () => { + const r = scan( + "`powerful` and `seamless` both stay; outside it's fine.\n", + ); + expect(r.violations).toHaveLength(0); + }); +}); + +describe("validate-docs-prose allow markers", () => { + test("same-line allow disables a single term", () => { + const r = scan( + "We are powerful here. \n", + ); + expect(r.violations).toHaveLength(0); + }); + + test("same-line allow only affects that line", () => { + const md = + "We are powerful here. \nWe are powerful again.\n"; + const r = scan(md); + expect(r.violations).toHaveLength(1); + expect(r.violations[0].line).toBe(2); + }); + + test("file-wide allow applies anywhere in the file", () => { + const md = + "\n\nFirst powerful line.\nSecond powerful line.\n"; + const r = scan(md); + expect(r.violations).toHaveLength(0); + }); + + test("file-wide allow comment line itself does not trigger", () => { + const md = "\n"; + const r = scan(md); + expect(r.violations).toHaveLength(0); + }); + + test("next-line allow applies to the next non-blank line", () => { + const md = + "\n\nThis is powerful.\nAnother powerful line.\n"; + const r = scan(md); + // First "powerful" is allowed, second is not. + expect(r.violations).toHaveLength(1); + expect(r.violations[0].line).toBe(4); + }); + + test("allow markers are case-insensitive in their term list", () => { + const r = scan( + "We are POWERFUL here. \n", + ); + expect(r.violations).toHaveLength(0); + }); +}); + +describe("validate-docs-prose per-file exemptions", () => { + test("CHANGELOG.md is exempt from 'comprehensive'", () => { + const r = scanContent( + "/x/CHANGELOG.md", + "Comprehensive overhaul of the bus.\n", + {}, + ); + expect(r.violations).toHaveLength(0); + }); + + test("CHANGELOG.md still flags 'powerful'", () => { + const r = scanContent( + "/x/CHANGELOG.md", + "Powerful overhaul of the bus.\n", + {}, + ); + expect(r.violations.length).toBeGreaterThan(0); + }); + + test(".llm/skills/documentation/ files are wholly exempt", () => { + const skillsRoot = path.join( + REPO_ROOT, + ".llm", + "skills", + "documentation", + "fake-policy.md", + ); + const r = scanContent( + skillsRoot, + "We discuss powerful, seamless, and delve into.\n", + {}, + ); + expect(r.fileExempt).toBe(true); + expect(r.violations).toHaveLength(0); + }); +}); + +describe("validate-docs-prose CLI", () => { + test("--list-rules prints all rules and exits 0", () => { + const result = runValidator(["--list-rules"]); + expect(result.status).toBe(0); + for (const rule of RULES) { + expect(result.stdout).toContain(rule.id); + } + }); + + test("--paths with a clean file exits 0", () => { + withFixture(".md", "# Title\n\nA short clean paragraph.\n", (fp) => { + const result = runValidator(["--paths", fp]); + expect(result.status).toBe(0); + expect(result.stdout).toContain("0 violations"); + }); + }); + + test("--paths with a dirty file exits 1 and reports rule", () => { + withFixture( + ".md", + "This is a powerful library.\n", + (fp) => { + const result = runValidator(["--paths", fp]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("[marketing/marketing]"); + expect(result.stderr).toContain("powerful"); + }, + ); + }); + + test("--rule narrows scanning to one rule", () => { + withFixture( + ".md", + "Furthermore, this is powerful.\n", + (fp) => { + const onlyMarketing = runValidator([ + "--paths", + fp, + "--rule", + "marketing", + ]); + expect(onlyMarketing.status).toBe(1); + expect(onlyMarketing.stderr).not.toContain("hedge"); + expect(onlyMarketing.stderr).toContain("marketing"); + + const onlyHedge = runValidator([ + "--paths", + fp, + "--rule", + "hedge", + ]); + expect(onlyHedge.status).toBe(1); + expect(onlyHedge.stderr).toContain("hedge"); + expect(onlyHedge.stderr).not.toContain("marketing"); + }, + ); + }); + + test("--rule with unknown id exits 1 with a clear error", () => { + const result = runValidator(["--rule", "no-such-rule"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Unknown rule id"); + }); + + test("--summary prints per-category counts and exits 1 on dirty file", () => { + withFixture( + ".md", + "This is a powerful library that is also robust.\n", + (fp) => { + const result = runValidator([ + "--paths", + fp, + "--summary", + ]); + expect(result.status).toBe(1); + expect(result.stdout).toMatch(/marketing:\s*2/); + expect(result.stdout).toMatch(/2 violation\(s\)/); + }, + ); + }); + + test("--summary on a clean file exits 0", () => { + withFixture(".md", "# Clean\n\nNothing to see.\n", (fp) => { + const result = runValidator(["--paths", fp, "--summary"]); + expect(result.status).toBe(0); + }); + }); + + test("--help exits 0 and prints usage", () => { + const result = runValidator(["--help"]); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Usage:"); + }); + + test("unknown option exits 1", () => { + const result = runValidator(["--bogus-flag"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Unknown option"); + }); + + test("explicit file argument is scanned", () => { + withFixture(".md", "Powerful claim.\n", (fp) => { + const result = runValidator([fp]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("[marketing/marketing]"); + }); + }); +}); + +describe("validate-docs-prose C# XML doc handling", () => { + test("only /// lines are scanned in .cs files", () => { + const cs = + "namespace Foo\n{\n /// This is powerful.\n public class Bar { string s = \"powerful\"; }\n}\n"; + const r = scanContent("/x/Bar.cs", cs, {}); + expect(r.violations).toHaveLength(1); + expect(r.violations[0].line).toBe(3); + }); + + test("non-doc lines in .cs files are ignored", () => { + const cs = + "namespace Foo\n{\n public class Bar { string s = \"powerful\"; }\n}\n"; + const r = scanContent("/x/Bar.cs", cs, {}); + expect(r.violations).toHaveLength(0); + }); +}); + +describe("validate-docs-prose module exports", () => { + test("RULES is non-empty and each rule has the expected shape", () => { + expect(RULES.length).toBeGreaterThan(0); + for (const rule of RULES) { + expect(typeof rule.id).toBe("string"); + expect(typeof rule.category).toBe("string"); + expect(typeof rule.severity).toBe("string"); + expect(typeof rule.matchLine).toBe("function"); + } + }); + + test("RULE_INDEX exposes lookups by id", () => { + for (const rule of RULES) { + expect(RULE_INDEX.get(rule.id)).toBe(rule); + } + }); + + test("EXCLUDE_DIRS contains the gitignored directories the reviewer flagged", () => { + expect(EXCLUDE_DIRS.has(".venv")).toBe(true); + expect(EXCLUDE_DIRS.has("venv")).toBe(true); + expect(EXCLUDE_DIRS.has(".artifacts")).toBe(true); + expect(EXCLUDE_DIRS.has("progress")).toBe(true); + expect(EXCLUDE_DIRS.has(".vs")).toBe(true); + expect(EXCLUDE_DIRS.has(".claude")).toBe(true); + expect(EXCLUDE_DIRS.has(".devcontainer")).toBe(true); + expect(EXCLUDE_DIRS.has(".config")).toBe(true); + }); +}); + +// --- Reviewer-driven hardening tests --------------------------------------- + +describe("validate-docs-prose C# /// hedge prefix", () => { + test("hedge fires when /// prefix precedes the term", () => { + const cs = "/// Furthermore, this is a fact.\n"; + const r = scanContent("/x/Bar.cs", cs, {}); + expect(r.violations.some((v) => v.rule === "hedge")).toBe(true); + }); + + test("hedge handles indented /// in C#", () => { + const cs = " /// Moreover, more facts.\n"; + const r = scanContent("/x/Bar.cs", cs, {}); + expect(r.violations.some((v) => v.rule === "hedge")).toBe(true); + }); +}); + +describe("validate-docs-prose BOM handling", () => { + test("leading BOM is stripped before scanning", () => { + const md = "This is a powerful library.\n"; + const r = scanContent("/x/file.md", md, {}); + expect(r.violations).toHaveLength(1); + // Column counts must NOT include the BOM. + expect(r.violations[0].column).toBe(11); // "This is a " = 10 chars + expect(r.violations[0].line).toBe(1); + }); + + test("BOM does not break frontmatter detection", () => { + const md = "---\ntitle: foo\n---\n\nThis is powerful.\n"; + const r = scanContent("/x/file.md", md, {}); + expect(r.violations).toHaveLength(1); + expect(r.violations[0].line).toBe(5); + }); +}); + +describe("validate-docs-prose CRLF line endings", () => { + test("CRLF is normalized; line numbers point at logical lines", () => { + const md = "Line 1\r\nThis is powerful.\r\nLine 3\r\n"; + const r = scanContent("/x/file.md", md, {}); + expect(r.violations).toHaveLength(1); + expect(r.violations[0].line).toBe(2); + }); + + test("lone CR also normalizes", () => { + const md = "Line 1\rThis is powerful.\rLine 3\r"; + const r = scanContent("/x/file.md", md, {}); + expect(r.violations).toHaveLength(1); + expect(r.violations[0].line).toBe(2); + }); +}); + +describe("validate-docs-prose tab handling", () => { + test("tabs inside a paragraph preserve column positions byte-faithfully", () => { + // The validator counts byte-by-byte; a hard tab inside a sentence + // remains a single column. (Indented code blocks are skipped, so + // we use a tab in the middle of a paragraph here.) + const md = "Intro line with no leading whitespace.\nA\tpowerful claim here.\n"; + const r = scanContent("/x/file.md", md, {}); + expect(r.violations).toHaveLength(1); + // 'A\t' = 2 bytes, then 'powerful' starts at byte index 2 (0-based) + // -> 1-based column 3. + expect(r.violations[0].column).toBe(3); + expect(r.violations[0].line).toBe(2); + }); +}); + +describe("validate-docs-prose multi-line allow markers", () => { + test("multi-line marker is reported as malformed (warning, not failure)", () => { + const md = + "\n\nThis is powerful.\n"; + const r = scanContent("/x/file.md", md, {}); + // Marker did NOT take effect, so 'powerful' is still flagged. + expect(r.violations.some((v) => v.rule === "marketing")).toBe(true); + expect(Array.isArray(r.malformedMarkers)).toBe(true); + expect(r.malformedMarkers.length).toBeGreaterThan(0); + expect(r.malformedMarkers[0].line).toBe(1); + }); + + test("single-line allow marker still works after multi-line warning was added", () => { + const md = "Powerful claim here. \n"; + const r = scanContent("/x/file.md", md, {}); + expect(r.violations).toHaveLength(0); + }); +}); + +describe("validate-docs-prose indented code blocks", () => { + test("indented (4-space) code blocks after a blank line are skipped", () => { + const md = + "Intro paragraph.\n\n powerful = true; // not flagged\n seamless = false;\n\nMore prose.\n"; + const r = scanContent("/x/file.md", md, {}); + expect(r.violations).toHaveLength(0); + }); + + test("tab-indented code blocks are skipped", () => { + const md = + "Intro.\n\n\tpowerful = 1;\n\trobust = 2;\n\nOutside is scanned.\n"; + const r = scanContent("/x/file.md", md, {}); + expect(r.violations).toHaveLength(0); + }); + + test("indented text NOT preceded by blank is not a code block", () => { + const md = "Intro line\n This is a powerful line.\n"; + const r = scanContent("/x/file.md", md, {}); + // Continuation paragraph -- still scanned. + expect(r.violations.some((v) => v.rule === "marketing")).toBe(true); + }); +}); + +describe("validate-docs-prose closing fence with trailing content", () => { + test("text after the closing ``` on the same line is scanned as prose", () => { + const md = "```\nfoo\n``` and this is powerful prose.\n"; + const r = scanContent("/x/file.md", md, {}); + expect(r.violations.some((v) => v.rule === "marketing")).toBe(true); + }); +}); + +describe("validate-docs-prose YAML frontmatter", () => { + test("frontmatter is skipped wholly", () => { + const md = + "---\ntitle: powerful library\ndescription: comprehensive guide\n---\n\nClean prose.\n"; + const r = scanContent("/x/file.md", md, {}); + expect(r.violations).toHaveLength(0); + }); + + test("frontmatter does not skip body violations", () => { + const md = + "---\ntitle: ok\n---\n\nThis is powerful prose.\n"; + const r = scanContent("/x/file.md", md, {}); + expect(r.violations).toHaveLength(1); + expect(r.violations[0].line).toBe(5); + }); + + test("missing closing --- means frontmatter is not detected", () => { + const md = "---\ntitle: powerful here\n\nbody powerful here.\n"; + const r = scanContent("/x/file.md", md, {}); + // No frontmatter detected; both 'powerful' get flagged. + expect(r.violations.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe("validate-docs-prose multi-line HTML tag", () => { + test("HTML tag spanning multiple lines is masked", () => { + const md = + "link\n"; + const r = scanContent("/x/file.md", md, {}); + expect(r.violations).toHaveLength(0); + }); +}); + +describe("validate-docs-prose inflected forms (M2)", () => { + test.each([ + ["robustly", "marketing"], + ["powerfully", "marketing"], + ["comprehensively", "marketing"], + ["elegantly", "marketing"], + ["seamlessness", "marketing"], + ])("marketing inflected form '%s' fires the %s rule", (term, rule) => { + const r = scanContent("/x/f.md", `This is ${term}.\n`, {}); + expect(r.violations.some((v) => v.rule === rule)).toBe(true); + }); + + test("LLM filler 'delved into' is matched", () => { + const r = scanContent("/x/f.md", "We delved into the topic.\n", {}); + expect(r.violations.some((v) => v.rule === "llm-filler")).toBe(true); + }); + + test("LLM filler 'delves into' is matched", () => { + const r = scanContent("/x/f.md", "She delves into the topic.\n", {}); + expect(r.violations.some((v) => v.rule === "llm-filler")).toBe(true); + }); + + test("'cutting edge' (no hyphen) is flagged as marketing", () => { + const r = scanContent("/x/f.md", "Our cutting edge runtime.\n", {}); + expect(r.violations.some((v) => v.rule === "marketing")).toBe(true); + }); +}); + +describe("validate-docs-prose hedge without trailing comma (M3)", () => { + test("'Furthermore' without comma is flagged", () => { + const r = scanContent("/x/f.md", "Furthermore the bus is sync.\n", {}); + expect(r.violations.some((v) => v.rule === "hedge")).toBe(true); + }); + + test("'Overall' without comma is flagged", () => { + const r = scanContent("/x/f.md", "Overall the design works.\n", {}); + expect(r.violations.some((v) => v.rule === "hedge")).toBe(true); + }); + + test("'Furthermore,' with comma still flagged", () => { + const r = scanContent("/x/f.md", "Furthermore, the bus is sync.\n", {}); + expect(r.violations.some((v) => v.rule === "hedge")).toBe(true); + }); + + test("hedge does not fire on a partial token like 'Overalls'", () => { + const r = scanContent("/x/f.md", "Overalls are a kind of clothing.\n", {}); + expect(r.violations.some((v) => v.rule === "hedge")).toBe(false); + }); +}); + +describe("validate-docs-prose CHANGELOG case-insensitive (m3)", () => { + test("changelog.md (lowercase) is exempt from 'comprehensive'", () => { + const r = scanContent( + "/x/changelog.md", + "Comprehensive overhaul.\n", + {}, + ); + expect(r.violations).toHaveLength(0); + }); + + test("CHANGELOG.markdown is exempt from 'comprehensive'", () => { + const r = scanContent( + "/x/CHANGELOG.markdown", + "Comprehensive overhaul.\n", + {}, + ); + expect(r.violations).toHaveLength(0); + }); +}); + +describe("validate-docs-prose generated file exemptions (C8)", () => { + test("llms.txt at the repo root is exempt", () => { + const target = path.join(REPO_ROOT, "llms.txt"); + const r = scanContent( + target, + "This is a comprehensive overview.\n", + {}, + ); + expect(r.fileExempt).toBe(true); + expect(r.violations).toHaveLength(0); + }); + + test(".llm/skills/index.md is exempt", () => { + const target = path.join(REPO_ROOT, ".llm", "skills", "index.md"); + const r = scanContent( + target, + "Comprehensive index of all skills.\n", + {}, + ); + expect(r.fileExempt).toBe(true); + expect(r.violations).toHaveLength(0); + }); +}); + +describe("validate-docs-prose --paths walks .cs anywhere (C9)", () => { + test("--paths walks .cs files outside CS_SCAN_ROOTS", () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "dxmsg-paths-cs-"), + ); + try { + const subDir = path.join(tempDir, "Random"); + fs.mkdirSync(subDir); + const csFile = path.join(subDir, "Foo.cs"); + fs.writeFileSync( + csFile, + "/// This is a powerful summary.\nclass Foo {}\n", + "utf8", + ); + const result = childProcess.spawnSync( + process.execPath, + [VALIDATOR_SCRIPT_PATH, "--paths", tempDir], + { cwd: REPO_ROOT, encoding: "utf8" }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("powerful"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + +describe("validate-docs-prose baseline (C5)", () => { + test("baseline file skips matching violations", () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "dxmsg-baseline-"), + ); + try { + const dirty = path.join(tempDir, "dirty.md"); + fs.writeFileSync(dirty, "This is a powerful library.\n", "utf8"); + // First pass: write the baseline. + const baselinePath = path.join(tempDir, "baseline.txt"); + const writeResult = childProcess.spawnSync( + process.execPath, + [ + VALIDATOR_SCRIPT_PATH, + "--paths", + tempDir, + "--write-baseline", + baselinePath, + ], + { cwd: REPO_ROOT, encoding: "utf8" }, + ); + expect(writeResult.status).toBe(0); + const baselineText = fs.readFileSync(baselinePath, "utf8"); + expect(baselineText).toContain("dirty.md"); + expect(baselineText).toContain("[marketing]"); + expect(baselineText).toContain("powerful"); + + // Second pass: with the baseline, exit 0. + const cleanResult = childProcess.spawnSync( + process.execPath, + [ + VALIDATOR_SCRIPT_PATH, + "--paths", + tempDir, + "--baseline", + baselinePath, + ], + { cwd: REPO_ROOT, encoding: "utf8" }, + ); + expect(cleanResult.status).toBe(0); + expect(cleanResult.stdout).toContain("0 violations"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("baseline does NOT match if line/column moves", () => { + const v1 = { + file: path.join(REPO_ROOT, "doc.md"), + line: 10, + column: 5, + rule: "marketing", + term: "powerful", + }; + const v2 = { ...v1, line: 11 }; + const text = formatBaselineEntry(v1); + const keys = parseBaseline(text); + expect(keys.has(baselineKey(v1))).toBe(true); + expect(keys.has(baselineKey(v2))).toBe(false); + }); + + test("baseline file with comment lines parses correctly", () => { + const text = "# header\n# more comments\n\ndoc.md:1:1 [marketing] powerful\n"; + const keys = parseBaseline(text); + expect(keys.size).toBe(1); + expect(keys.has("doc.md|1|1|marketing|powerful")).toBe(true); + }); + + test("--baseline points to a missing file -> exit 1", () => { + const result = childProcess.spawnSync( + process.execPath, + [ + VALIDATOR_SCRIPT_PATH, + "--paths", + "/tmp/nope-not-exist", + "--baseline", + "/tmp/no-such-baseline-file.txt", + ], + { cwd: REPO_ROOT, encoding: "utf8" }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("baseline file not found"); + }); +}); + +describe("validate-docs-prose --list-rules dumps term lists (m4)", () => { + test("--list-rules output contains the marketing term list", () => { + const result = childProcess.spawnSync( + process.execPath, + [VALIDATOR_SCRIPT_PATH, "--list-rules"], + { cwd: REPO_ROOT, encoding: "utf8" }, + ); + expect(result.status).toBe(0); + expect(result.stdout).toContain("powerful"); + expect(result.stdout).toContain("comprehensive"); + expect(result.stdout).toContain("delve into"); + }); +}); + +describe("validate-docs-prose --summary trailer goes to stdout (m10)", () => { + test("--summary trailer is on stdout", () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "dxmsg-summary-"), + ); + try { + const f = path.join(tempDir, "f.md"); + fs.writeFileSync(f, "Powerful and robust prose.\n", "utf8"); + const result = childProcess.spawnSync( + process.execPath, + [VALIDATOR_SCRIPT_PATH, "--paths", f, "--summary"], + { cwd: REPO_ROOT, encoding: "utf8" }, + ); + expect(result.status).toBe(1); + // Trailer text is on stdout (m10), not stderr. + expect(result.stdout).toMatch(/violation\(s\) across/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + +describe("validate-docs-prose extended marketing terms", () => { + test.each([ + ["production-ready"], + ["enterprise-grade"], + ["lightning-fast"], + ["frictionless"], + ["battle-tested"], + ["bulletproof"], + ["rock-solid"], + ])("marketing term '%s' is flagged whole-word", (term) => { + const r = scanContent( + "/x/f.md", + `This is ${term} software.\n`, + {}, + ); + expect(r.violations.some((v) => v.rule === "marketing")).toBe(true); + expect( + r.violations.some( + (v) => v.term.toLowerCase() === term.toLowerCase(), + ), + ).toBe(true); + }); + + test("marketing extended terms are case-insensitive", () => { + const r = scanContent( + "/x/f.md", + "Production-Ready and BULLETPROOF claims.\n", + {}, + ); + expect(r.violations.filter((v) => v.rule === "marketing")).toHaveLength( + 2, + ); + }); + + test("marketing extended terms appear in MARKETING_TERMS export", () => { + for (const term of [ + "production-ready", + "enterprise-grade", + "lightning-fast", + "frictionless", + "battle-tested", + "bulletproof", + "rock-solid", + ]) { + expect(MARKETING_TERMS).toContain(term); + } + }); +}); + +describe("validate-docs-prose absolute path fallback (m2)", () => { + test("violation outside the repo root falls back to absolute path", () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "dxmsg-outside-"), + ); + try { + const f = path.join(tempDir, "outside.md"); + fs.writeFileSync(f, "Powerful claim.\n", "utf8"); + const result = childProcess.spawnSync( + process.execPath, + [VALIDATOR_SCRIPT_PATH, "--paths", f], + { cwd: REPO_ROOT, encoding: "utf8" }, + ); + expect(result.status).toBe(1); + // The reported path is the absolute path (it begins with "/" or + // the temp dir path), not "../...". + expect(result.stderr).not.toMatch(/^\.\./m); + expect(result.stderr).toContain(f); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/__tests__/validate-docs-prose.test.js.meta b/scripts/__tests__/validate-docs-prose.test.js.meta new file mode 100644 index 00000000..b45e31f5 --- /dev/null +++ b/scripts/__tests__/validate-docs-prose.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a8f85133de834847879bc0aebd53df29 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/validate-docs-prose.js b/scripts/validate-docs-prose.js new file mode 100644 index 00000000..caaa0780 --- /dev/null +++ b/scripts/validate-docs-prose.js @@ -0,0 +1,1277 @@ +#!/usr/bin/env node + +/** + * validate-docs-prose.js + * + * Enforces the human-prose policy for documentation. Detects LLM-style + * marketing prose, hedge transitions, filler idioms, vague quantifiers, + * and soft conversational fluff in human-readable documentation. + * + * Targets: + * - All .md files in the repository. + * - All .cs files under Runtime/, Editor/, Tests/, SourceGenerators/ -- but + * only the contents of /// XML doc comment lines are scanned. + * - The generated llms.txt at the repo root. + * + * Companion to scripts/validate-docs-ascii.js. The two validators share + * structure, CLI shape, and exit-code contract on purpose. + * + * Skipped content within an otherwise-scanned file: + * - Lines inside fenced code blocks (``` ... ```). + * - Inline code spans (between ` ` on the same line). + * - URLs (http://... or https://...). + * - HTML tags and attributes (anything inside <...>, including those + * spanning multiple lines). + * - Markdown YAML frontmatter (--- ... --- at the top of .md files). + * - Indented code blocks (4+ spaces or 1 tab after a blank line). + * + * Inline allow markers (HTML comment forms, single-line only): + * same line + * next non-blank line + * file-wide (anywhere) + * + * The marker comments themselves are stripped from the scanned text so + * they cannot accidentally trigger their own banned terms. Multi-line + * markers (closing --> on a different line than the opening /i; +const ALLOW_NEXT_RE = + //i; +const ALLOW_FILE_RE = + //gi; + +// Detect a malformed (multi-line) marker: line opens "" on the same line. +const MALFORMED_MARKER_RE = + //i) && + MALFORMED_MARKER_RE.test(rawLine) + ) { + malformedMarkers.push({ file: filePath, line: lineIndex + 1 }); + } + + const sameLineMatch = rawLine.match(ALLOW_LINE_RE); + const sameLineAllow = sameLineMatch + ? new Set(parseAllowList(sameLineMatch[1])) + : null; + + if (isCsharp && !shouldScanLineCs(rawLine)) { + prevBlank = lineIsBlank; + continue; + } + + let workLine = stripAllowMarkerComments(rawLine); + + // Empty lines never produce findings, but we should not consume the + // pending-next-line allow on a blank line. + if (workLine.trim() === "") { + prevBlank = true; + continue; + } + + // Apply the pending next-line allow if we have one and have not yet + // applied it. + let activeNextLineAllow = null; + if (pendingNextLineAllow && !pendingNextLineUsed) { + activeNextLineAllow = pendingNextLineAllow; + pendingNextLineUsed = true; + } + + const maskState = { inHtmlTag }; + const masked = maskScanLine(workLine, maskState); + inHtmlTag = maskState.inHtmlTag; + + const ctx = { fileBasename }; + + for (const rule of RULES) { + if (ruleFilter && rule.id !== ruleFilter) continue; + const findings = rule.matchLine(masked, ctx); + for (const f of findings) { + const termLower = f.term.toLowerCase(); + if (fileAllow.has(termLower)) continue; + if (sameLineAllow && sameLineAllow.has(termLower)) continue; + if (activeNextLineAllow && activeNextLineAllow.has(termLower)) + continue; + violations.push({ + file: filePath, + line: lineIndex + 1, + column: f.column + 1, + rule: rule.id, + category: rule.category, + severity: rule.severity, + term: f.term, + message: rule.description, + suggestion: f.suggestion, + }); + } + } + + // Pending-next-line allow expires after one scanned content line. + if (activeNextLineAllow) { + pendingNextLineAllow = null; + } + + prevBlank = false; + } + + return { violations, fileExempt: false, malformedMarkers }; +} + +/** + * Inner helper used by the closing-fence-residue path. + * + * We re-implement only the rule-matching slice of the main loop because + * the residue scan does not need to update fence/allow state. It does + * need to obey the same filtering rules so that residue findings are + * reported with the original line number. + */ +function scanLine( + paddedLine, + lineIndex, + violations, + ruleFilter, + fileAllow, + pendingNextLineAllow, + pendingNextLineUsed, + fileBasename, + isCsharp, + filePath, +) { + if (isCsharp && !shouldScanLineCs(paddedLine)) return; + const stripped = stripAllowMarkerComments(paddedLine); + if (stripped.trim() === "") return; + const sameLineMatch = paddedLine.match(ALLOW_LINE_RE); + const sameLineAllow = sameLineMatch + ? new Set(parseAllowList(sameLineMatch[1])) + : null; + const maskState = { inHtmlTag: false }; + const masked = maskScanLine(stripped, maskState); + const ctx = { fileBasename }; + for (const rule of RULES) { + if (ruleFilter && rule.id !== ruleFilter) continue; + const findings = rule.matchLine(masked, ctx); + for (const f of findings) { + const termLower = f.term.toLowerCase(); + if (fileAllow.has(termLower)) continue; + if (sameLineAllow && sameLineAllow.has(termLower)) continue; + if (pendingNextLineAllow && pendingNextLineAllow.has(termLower)) { + continue; + } + violations.push({ + file: filePath, + line: lineIndex + 1, + column: f.column + 1, + rule: rule.id, + category: rule.category, + severity: rule.severity, + term: f.term, + message: rule.description, + suggestion: f.suggestion, + }); + } + } +} + +/** + * Replace every span we should not scan with spaces of equal length, so + * column numbers stay aligned with the original line. State allows us to + * keep track of HTML tags that span multiple lines. + * + * Fast path: when the previous line did not leave us inside a tag and this + * line contains no '<' character, no HTML masking is needed. + */ +function maskScanLine(line, state) { + let out = line; + // URLs + out = out.replace(/https?:\/\/\S+/gi, (m) => " ".repeat(m.length)); + // Inline code: backtick-delimited spans on a single line. + if (out.indexOf("`") !== -1) { + out = out.replace(/`[^`]*`/g, (m) => " ".repeat(m.length)); + } + + const inTagInitial = state && state.inHtmlTag; + if (!inTagInitial && out.indexOf("<") === -1) { + // No tag carryover, no '<' in line -- nothing to mask. + return out; + } + + // HTML tags. We must handle multi-line tags via state. Walk char-by-char + // and replace any character inside a tag with a space. + let inTag = inTagInitial; + let result = ""; + for (let i = 0; i < out.length; i++) { + const ch = out.charCodeAt(i); + if (!inTag) { + if (ch === 60) { // '<' + inTag = true; + result += " "; + } else { + result += out[i]; + } + } else { + result += " "; + if (ch === 62) { // '>' + inTag = false; + } + } + } + if (state) state.inHtmlTag = inTag; + return result; +} + +function scanFile(filePath, options) { + let content; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch (error) { + return { violations: [], fileExempt: false }; + } + return scanContent(filePath, content, options); +} + +// --- Baseline support ------------------------------------------------------- + +/** + * Format a single violation as a baseline entry. The format is stable: + * ":: [] " + * Each part is significant -- changing any field breaks the match. + */ +function formatBaselineEntry(violation) { + const rel = + path.relative(ROOT_DIR, violation.file) || violation.file; + return `${rel}:${violation.line}:${violation.column} [${violation.rule}] ${violation.term}`; +} + +/** + * Produce a stable string key for matching a violation against a baseline. + * Uses the same components as formatBaselineEntry but stripped to the + * minimum: path, line, col, rule, term. + */ +function baselineKey(violation) { + const rel = + path.relative(ROOT_DIR, violation.file) || violation.file; + return `${rel}|${violation.line}|${violation.column}|${violation.rule}|${violation.term}`; +} + +/** + * Parse a baseline file's text into a Set of keys. Entries follow the + * format produced by formatBaselineEntry. Blank lines and lines starting + * with "#" are ignored. + */ +function parseBaseline(text) { + const keys = new Set(); + if (!text) return keys; + const lines = text.replace(/\r\n/g, "\n").split("\n"); + for (const line of lines) { + if (!line || line.startsWith("#")) continue; + // path:line:col [rule] term + const m = line.match(/^(.+?):(\d+):(\d+)\s+\[([^\]]+)\]\s+(.+)$/); + if (!m) continue; + const [, p, ln, col, rule, term] = m; + keys.add(`${p}|${ln}|${col}|${rule}|${term}`); + } + return keys; +} + +// --- CLI -------------------------------------------------------------------- + +function parseArgs(argv) { + const args = { + check: true, + paths: null, + files: [], + listRules: false, + rule: null, + summary: false, + baseline: null, + writeBaseline: null, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--check") { + args.check = true; + } else if (a === "--paths") { + args.paths = argv[++i]; + } else if (a === "--rule") { + args.rule = argv[++i]; + } else if (a === "--list-rules") { + args.listRules = true; + } else if (a === "--summary") { + args.summary = true; + } else if (a === "--baseline") { + args.baseline = argv[++i]; + } else if (a === "--write-baseline") { + args.writeBaseline = argv[++i]; + } else if (a === "--help" || a === "-h") { + printHelp(); + process.exit(0); + } else if (a.startsWith("--")) { + console.error(`Unknown option: ${a}`); + process.exit(1); + } else { + args.files.push(a); + } + } + return args; +} + +function printHelp() { + process.stdout.write( + [ + "Usage: node scripts/validate-docs-prose.js [options] [files...]", + "", + "Options:", + " --check Default. Exit 1 on any banned phrase.", + " --paths Comma-separated paths or directory roots.", + " --rule Run only the named rule.", + " --list-rules Print rules and term lists, then exit.", + " --summary Print per-category counts instead of per-line.", + " --baseline Skip violations whose path/line/col/rule/term", + " match a line in the baseline file (transitional).", + " --write-baseline Write the current violation set to ", + " in baseline format and exit.", + " -h, --help Show this message.", + "", + ].join("\n"), + ); +} + +function printRuleList() { + process.stdout.write("Configured prose rules:\n\n"); + for (const rule of RULES) { + process.stdout.write( + ` ${rule.id.padEnd(20)} category=${rule.category.padEnd(20)} severity=${rule.severity}\n`, + ); + process.stdout.write(` ${rule.description}\n`); + if (Array.isArray(rule.terms) && rule.terms.length > 0) { + process.stdout.write(` terms: ${rule.terms.join(", ")}\n`); + } + } +} + +function resolveFileList(args) { + if (args.files.length > 0) { + return args.files.map((f) => path.resolve(process.cwd(), f)); + } + if (args.paths) { + const out = []; + for (const entry of args.paths.split(",")) { + const abs = path.resolve(process.cwd(), entry); + if (!fs.existsSync(abs)) continue; + const stat = fs.statSync(abs); + if (stat.isDirectory()) { + // When --paths targets a directory, walk both .md and .cs + // files unconditionally -- the user explicitly opted in. + walk( + abs, + (p) => p.endsWith(".md") || p.endsWith(".cs"), + out, + ); + } else if (stat.isFile()) { + out.push(abs); + } + } + return out; + } + return defaultFileSet(); +} + +function relativeOrAbsolute(filePath) { + // When the relative path would escape the repo root (starts with "..") + // fall back to the absolute path so the violation address is unambiguous. + const rel = path.relative(ROOT_DIR, filePath); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) { + return filePath; + } + return rel; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.listRules) { + printRuleList(); + return 0; + } + + if (args.rule && !RULE_INDEX.has(args.rule)) { + process.stderr.write(`Unknown rule id: ${args.rule}\n`); + return 1; + } + + const files = resolveFileList(args); + + let baselineKeys = new Set(); + if (args.baseline) { + if (fs.existsSync(args.baseline)) { + const text = fs.readFileSync(args.baseline, "utf8"); + baselineKeys = parseBaseline(text); + } else { + process.stderr.write( + `validate-docs-prose: baseline file not found: ${args.baseline}\n`, + ); + return 1; + } + } + + const allViolations = []; + const allMalformed = []; + for (const file of files) { + const result = scanFile(file, { rule: args.rule }); + if (result.violations && result.violations.length > 0) { + allViolations.push(...result.violations); + } + if (result.malformedMarkers && result.malformedMarkers.length > 0) { + allMalformed.push(...result.malformedMarkers); + } + } + + // Always emit malformed-marker warnings to stderr (non-fatal). + for (const m of allMalformed) { + const rel = relativeOrAbsolute(m.file); + process.stderr.write( + `WARN malformed prose-allow marker spans multiple lines at ${rel}:${m.line}\n`, + ); + } + + // --write-baseline mode: dump current violations and exit 0. + if (args.writeBaseline) { + const lines = [ + "# validate-docs-prose baseline", + "# Generated by: node scripts/validate-docs-prose.js --write-baseline", + "# Format: path:line:col [rule] term", + "# Each entry grandfathers ONE specific violation. New violations are", + "# still flagged. Phase B1 will rewrite these and delete the file.", + "", + ]; + const sorted = allViolations + .map(formatBaselineEntry) + .sort(); + for (const entry of sorted) lines.push(entry); + const text = lines.join("\n") + "\n"; + fs.writeFileSync(args.writeBaseline, text, "utf8"); + process.stdout.write( + `validate-docs-prose: wrote ${allViolations.length} entries to ${args.writeBaseline}\n`, + ); + return 0; + } + + // Filter out violations matched by the baseline. + const filtered = baselineKeys.size > 0 + ? allViolations.filter((v) => !baselineKeys.has(baselineKey(v))) + : allViolations; + + const baselineGrandfathered = allViolations.length - filtered.length; + + if (args.summary) { + // In summary mode, summary trailer goes to stdout (it's the success + // signal). The error trailer remains on stderr. + if (filtered.length === 0) { + process.stdout.write( + `validate-docs-prose: 0 violations across ${files.length} file(s).\n`, + ); + if (baselineGrandfathered > 0) { + process.stdout.write( + `validate-docs-prose: ${baselineGrandfathered} baseline-grandfathered violation(s).\n`, + ); + } + return 0; + } + const counts = new Map(); + for (const v of filtered) { + counts.set(v.category, (counts.get(v.category) || 0) + 1); + } + for (const [cat, count] of [...counts.entries()].sort()) { + process.stdout.write(`${cat}: ${count}\n`); + } + process.stdout.write( + `\nvalidate-docs-prose: ${filtered.length} violation(s) across ${counts.size} categories.\n`, + ); + if (baselineGrandfathered > 0) { + process.stdout.write( + `validate-docs-prose: ${baselineGrandfathered} baseline-grandfathered violation(s).\n`, + ); + } + return 1; + } + + if (filtered.length === 0) { + process.stdout.write( + `validate-docs-prose: 0 violations across ${files.length} file(s).\n`, + ); + if (baselineGrandfathered > 0) { + process.stdout.write( + `validate-docs-prose: ${baselineGrandfathered} baseline-grandfathered violation(s).\n`, + ); + } + return 0; + } + + for (const v of filtered) { + const rel = relativeOrAbsolute(v.file); + process.stderr.write( + `${rel}:${v.line}:${v.column} [${v.category}/${v.rule}] '${v.term}' -- ${v.message} ${v.suggestion}\n`, + ); + } + process.stderr.write( + `\nvalidate-docs-prose: ${filtered.length} violation(s) found.\n`, + ); + if (baselineGrandfathered > 0) { + process.stderr.write( + `validate-docs-prose: ${baselineGrandfathered} baseline-grandfathered violation(s) skipped.\n`, + ); + } + return 1; +} + +if (require.main === module) { + process.exit(main()); +} + +/** + * Public module surface. Tests and downstream tooling consume these + * exports; treat any change here as a breaking-change for callers. + * + * @typedef {Object} Violation + * @property {string} file Absolute path to the offending file. + * @property {number} line 1-based line number. + * @property {number} column 1-based column number. + * @property {string} rule Rule id (see RULES[].id). + * @property {string} category Category id; for current rules == id. + * @property {string} severity Always "error" today. + * @property {string} term The exact text that matched. + * @property {string} message Short human-readable rule description. + * @property {string} suggestion Suggested fix strategy. + * + * @typedef {Object} ScanResult + * @property {Array} violations + * @property {boolean} fileExempt + * @property {Array<{file:string,line:number}>=} malformedMarkers + * + * @typedef {Object} ScanOptions + * @property {string=} rule Restrict scanning to a single rule id. + */ +module.exports = { + scanContent, + scanFile, + RULES, + RULE_INDEX, + MARKETING_TERMS, + LLM_FILLER_TERMS, + HEDGE_TRANSITIONS, + VAGUE_QUANTIFIER_TERMS, + parseBaseline, + baselineKey, + formatBaselineEntry, + EXCLUDE_DIRS, +}; diff --git a/scripts/validate-docs-prose.js.meta b/scripts/validate-docs-prose.js.meta new file mode 100644 index 00000000..0e19231a --- /dev/null +++ b/scripts/validate-docs-prose.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1a812aa98c7c4a6fa66f322ae19d6238 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From c34572853fe4f48be3a174224d99c3fac161db55 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Sat, 2 May 2026 21:37:45 -0700 Subject: [PATCH 2/4] Optimize githooks --- .cspell.json | 3 + .github/workflows/docs-lint.yml | 22 + .github/workflows/hook-perf-measurement.yml | 76 ++ .llm/context.md | 16 +- .llm/skills/documentation/ascii-only-docs.md | 2 +- .../code-samples-must-compile.md | 2 +- .../documentation/human-prose-policy.md | 11 +- .llm/skills/index.md | 8 +- .../git-hook-performance-tooling.md | 239 ++++++ .../git-hook-performance-tooling.md.meta | 7 + .../performance/git-hook-performance.md | 298 +++++++ .../performance/git-hook-performance.md.meta | 7 + .pre-commit-config.yaml | 147 ++-- CONTRIBUTING.md | 2 +- llms.txt | 2 +- package.json | 5 +- .../__tests__/cspell-version-parity.test.js | 108 +++ .../cspell-version-parity.test.js.meta | 7 + ...tect-shell-redirection-antipattern.test.js | 77 +- scripts/__tests__/hook-perf-budget.test.js | 118 +++ .../__tests__/hook-perf-budget.test.js.meta | 7 + .../pre-commit-hook-stage-policy.test.js | 217 +++-- .../__tests__/precommit-perf-score.test.js | 788 ++++++++++++++++++ .../precommit-perf-score.test.js.meta | 7 + scripts/__tests__/precommit-yaml.test.js | 232 ++++++ scripts/__tests__/precommit-yaml.test.js.meta | 7 + .../__tests__/prettier-version-parity.test.js | 116 +++ .../prettier-version-parity.test.js.meta | 7 + .../__tests__/run-staged-md-pipeline.test.js | 304 +++++++ .../run-staged-md-pipeline.test.js.meta | 7 + .../__tests__/run-staged-validators.test.js | 157 ++++ .../run-staged-validators.test.js.meta | 7 + .../validate-pre-commit-tooling.test.js | 185 +++- scripts/fix-md029-md051.js | 34 +- scripts/fix-md036-headings.js | 89 +- scripts/lib/precommit-perf-score.js | 587 +++++++++++++ scripts/lib/precommit-perf-score.js.meta | 7 + scripts/lib/precommit-yaml.js | 133 +++ scripts/lib/precommit-yaml.js.meta | 7 + scripts/lib/staged-doc-formatters.js | 55 ++ scripts/lib/staged-doc-formatters.js.meta | 7 + scripts/measure-hook-wallclock.js | 287 +++++++ scripts/measure-hook-wallclock.js.meta | 7 + scripts/run-staged-md-pipeline.js | 471 +++++++++++ scripts/run-staged-md-pipeline.js.meta | 7 + scripts/run-staged-validators.js | 249 ++++++ scripts/run-staged-validators.js.meta | 7 + scripts/validate-pre-commit-tooling.js | 84 +- 48 files changed, 5004 insertions(+), 225 deletions(-) create mode 100644 .github/workflows/hook-perf-measurement.yml create mode 100644 .llm/skills/performance/git-hook-performance-tooling.md create mode 100644 .llm/skills/performance/git-hook-performance-tooling.md.meta create mode 100644 .llm/skills/performance/git-hook-performance.md create mode 100644 .llm/skills/performance/git-hook-performance.md.meta create mode 100644 scripts/__tests__/cspell-version-parity.test.js create mode 100644 scripts/__tests__/cspell-version-parity.test.js.meta create mode 100644 scripts/__tests__/hook-perf-budget.test.js create mode 100644 scripts/__tests__/hook-perf-budget.test.js.meta create mode 100644 scripts/__tests__/precommit-perf-score.test.js create mode 100644 scripts/__tests__/precommit-perf-score.test.js.meta create mode 100644 scripts/__tests__/precommit-yaml.test.js create mode 100644 scripts/__tests__/precommit-yaml.test.js.meta create mode 100644 scripts/__tests__/prettier-version-parity.test.js create mode 100644 scripts/__tests__/prettier-version-parity.test.js.meta create mode 100644 scripts/__tests__/run-staged-md-pipeline.test.js create mode 100644 scripts/__tests__/run-staged-md-pipeline.test.js.meta create mode 100644 scripts/__tests__/run-staged-validators.test.js create mode 100644 scripts/__tests__/run-staged-validators.test.js.meta create mode 100644 scripts/lib/precommit-perf-score.js create mode 100644 scripts/lib/precommit-perf-score.js.meta create mode 100644 scripts/lib/precommit-yaml.js create mode 100644 scripts/lib/precommit-yaml.js.meta create mode 100644 scripts/lib/staged-doc-formatters.js create mode 100644 scripts/lib/staged-doc-formatters.js.meta create mode 100644 scripts/measure-hook-wallclock.js create mode 100644 scripts/measure-hook-wallclock.js.meta create mode 100644 scripts/run-staged-md-pipeline.js create mode 100644 scripts/run-staged-md-pipeline.js.meta create mode 100644 scripts/run-staged-validators.js create mode 100644 scripts/run-staged-validators.js.meta diff --git a/.cspell.json b/.cspell.json index d16e9ff5..3b4b4dc3 100644 --- a/.cspell.json +++ b/.cspell.json @@ -37,11 +37,14 @@ "words": [ "DxMessaging", "dxmessaging", + "mtimes", "nofilter", + "relitigate", "DDOL", "Reemit", "reemit", "unsub", + "unwaived", "vstest", "parameterizes", "wallstop", diff --git a/.github/workflows/docs-lint.yml b/.github/workflows/docs-lint.yml index 7604e989..122f38ea 100644 --- a/.github/workflows/docs-lint.yml +++ b/.github/workflows/docs-lint.yml @@ -7,6 +7,7 @@ on: - "**/*.cs" - "scripts/validate-docs-ascii.js" - "scripts/validate-doc-code-patterns.js" + - "scripts/validate-docs-prose.js" push: branches: - main @@ -16,6 +17,7 @@ on: - "**/*.cs" - "scripts/validate-docs-ascii.js" - "scripts/validate-doc-code-patterns.js" + - "scripts/validate-docs-prose.js" workflow_dispatch: concurrency: @@ -65,3 +67,23 @@ jobs: - name: Run validate-doc-code-patterns run: node scripts/validate-doc-code-patterns.js + + validate-docs-prose: + name: Validate human-prose policy + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: package.json + + - name: Run validate-docs-prose + run: node scripts/validate-docs-prose.js diff --git a/.github/workflows/hook-perf-measurement.yml b/.github/workflows/hook-perf-measurement.yml new file mode 100644 index 00000000..3b469faf --- /dev/null +++ b/.github/workflows/hook-perf-measurement.yml @@ -0,0 +1,76 @@ +name: Hook Performance Measurement + +on: + pull_request: + paths: + - ".pre-commit-config.yaml" + - "scripts/**.js" + - "scripts/measure-hook-wallclock.js" + - ".github/workflows/hook-perf-measurement.yml" + schedule: + - cron: "13 6 * * 1" # Monday 06:13 UTC; nightly was overkill for a perf gate. + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + measure: + name: Measure git hook wall-clock + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: package.json + + - name: Setup Python (for pre-commit) + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install npm dependencies + run: | + if [ -f package-lock.json ]; then + npm ci + else + npm i --no-audit --no-fund + fi + + - name: Install pre-commit + run: pip install pre-commit==4.6.0 + + - name: Configure git user (pre-commit needs an author) + run: | + git config user.name "perf-measurement-bot" + git config user.email "perf-measurement-bot@users.noreply.github.com" + + - name: Install pre-commit hooks + run: pre-commit install --install-hooks + + - name: Warm up the hook caches (first run is always cold) + run: | + pre-commit run --hook-stage pre-commit \ + --files Runtime/Core/MessageBus/MessageBus.cs >/dev/null 2>&1 || true + pre-commit run --hook-stage pre-commit \ + --files .llm/skills/performance/git-hook-performance.md >/dev/null 2>&1 || true + + - name: Measure wall-clock + run: node scripts/measure-hook-wallclock.js + + - name: Emit JSON for downstream tooling + if: always() + run: node scripts/measure-hook-wallclock.js --json diff --git a/.llm/context.md b/.llm/context.md index 974912c7..33cdfcd9 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -29,7 +29,9 @@ This file is intentionally concise. It contains only critical, high-signal guida - For user-visible code edits (`Runtime/`, `Samples~/`, user-facing `Editor/`, or shipped `SourceGenerators/` code), run `npm run validate:changelog:coverage` before finishing and resolve any `W002` warnings by rewriting entries around user impact. - When editing `.cs`, `.md`, `.json`, `.yml`, `.yaml`, `.ps1`, or `.js` files, run file-scoped cspell on touched files and update `.cspell.json` in the same change for legitimate domain terms. - For Node child-process calls in `scripts/*.js`, prefer argument-array invocations (`spawnSync` / `execFileSync`) and `stdio` options instead of shell redirection. +- For dynamic `import()` in `scripts/*.js`, convert filesystem paths with `pathToFileURL(...).href` before importing (raw Windows drive-letter paths fail Node's ESM loader). - When editing `.pre-commit-config.yaml`, `scripts/*` hook tooling, `.github/workflows/*.yml`, or hook-related scripts in `package.json`, run `npm run preflight:pre-commit` before finishing. +- When editing `.pre-commit-config.yaml` or hook scripts, the new performance budget test (`scripts/__tests__/hook-perf-budget.test.js`) must pass; see [Git Hook Performance Budget](./skills/performance/git-hook-performance.md). ## Build and Test Commands @@ -38,7 +40,7 @@ This file is intentionally concise. It contains only critical, high-signal guida - Script tests: `npm run test:scripts` - Validate pre-commit Node tooling policy: `npm run validate:pre-commit-tooling` - Pre-commit Node tooling preflight: `npm run preflight:pre-commit` -- Run parser hook suite exactly as pre-commit executes it: `pre-commit run script-parser-tests --all-files` +- Run parser hook suite exactly as pre-push executes it: `pre-commit run --hook-stage pre-push script-parser-tests --all-files` - Check package.json format explicitly: `npm run check:package-json-format` - Check hook-managed Prettier targets: `npm run check:prettier:hooks` - Validate YAML formatting and lint policy: `npm run check:yaml` @@ -49,6 +51,7 @@ This file is intentionally concise. It contains only critical, high-signal guida - File-scoped spellcheck: `npx --yes cspell@9 --no-progress --no-summary ` - Script-wide spellcheck preflight: `npm run check:cspell:scripts` - Note: Prettier does not auto-wrap long YAML lines; yamllint enforces the 200-character limit. +- For long `.pre-commit-config.yaml` values (especially `description:` fields), use YAML folded scalars (`>-`) instead of single-line strings. - Auto-fix markdown fragments/lists: `node scripts/fix-md029-md051.js ` - Lint markdown: `npx markdownlint-cli2 ` - Validate skills + context: `node scripts/validate-skills.js` @@ -75,12 +78,12 @@ This file is intentionally concise. It contains only critical, high-signal guida - For pre-commit hooks that operate on staged files, remember pre-commit stashes unstaged changes and runs hooks against the staged snapshot on disk; reproduce failures through commit-equivalent hook runs when validating behavior. - For auto-fix hooks that restage files, guard restaging with `git diff --quiet -- "$@" || git add "$@"` so no-op runs do not touch the git index. - For Jest in hooks or npm scripts, use `node scripts/run-managed-jest.js` instead of bare `jest` invocations. -- For Prettier in hooks or npm scripts, use `node scripts/run-managed-prettier.js` instead of hardcoded `prettier@X.Y.Z` commands. The managed runner resolves versions in this order: package-lock.json, package.json, then static fallback. +- For Prettier in npm scripts (`format:*`, `check:prettier:hooks`) and ad-hoc invocations, use `node scripts/run-managed-prettier.js` instead of hardcoded `prettier@X.Y.Z` commands. The managed runner resolves versions in this order: package-lock.json, package.json, then static fallback. Pre-commit hook entries themselves use the inline `bash -c '[ -f node_modules/prettier/bin/prettier.cjs ] && exec node ...; else exec npx --yes --package=prettier@ prettier ...; fi'` pattern (cspell/markdownlint shape) plus the parity test at `scripts/__tests__/prettier-version-parity.test.js`. - For `npm`/`npx` child-process calls in `scripts/*.js` (`spawnSync`, `execFileSync`, `execSync`), use `spawnPlatformCommandSync()` from `scripts/lib/shell-command.js`. Do not call `spawnSync(toShellCommand(...))` directly; the helper applies Windows shell-shim execution rules consistently. - For validators that depend on `git` metadata (for example ignore-policy checks), treat `ENOENT`/missing-git failures as hard errors; never silently default to permissive behavior. - When editing `scripts/validate-npm-meta.js`, `scripts/__tests__/validate-npm-meta.test.js`, or npm package metadata, run `npm run validate:npm-meta` before finishing. - When editing `scripts/fix-csharp-underscore-methods.js` or its tests, run `node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/fix-csharp-underscore-methods.test.js` and then `npm run preflight:pre-commit` before finishing. -- For parser-script failures, verify both isolated and hook-parity execution before concluding root cause: run the focused Jest path first, then run `pre-commit run script-parser-tests --all-files` from the same shell used for commit operations. +- For parser-script failures, verify both isolated and hook-parity execution before concluding root cause: run the focused Jest path first, then run `pre-commit run --hook-stage pre-push script-parser-tests --all-files` from the same shell used for commit operations. - When editing `.pre-commit-config.yaml` or `scripts/validate-pre-commit-tooling.js`, run `node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/pre-commit-hook-stage-policy.test.js scripts/__tests__/validate-pre-commit-tooling.test.js` before `npm run preflight:pre-commit`. - On Windows, verify `npm --version` in the active shell before running hook-related checks (especially when using nvm/fnm). - On Windows hosts, run `npm run preflight:pre-commit` in the same shell you use for `git commit` so hook PATH/init, npm version drift, package.json formatting, and yamllint issues are caught before commit. @@ -117,9 +120,9 @@ This file is intentionally concise. It contains only critical, high-signal guida - For edited Markdown files, run `node scripts/fix-md029-md051.js` and then `npx markdownlint-cli2` before finishing. - Ordered lists must follow MD029 `one` style (`1.` for each item). - Internal fragment links must match GitHub/markdownlint heading slugs exactly (MD051). -- Documentation and `///` XML doc comments must be pure ASCII; see [ASCII-Only Documentation Policy](./skills/documentation/ascii-only-docs.md). Run `node scripts/validate-docs-ascii.js` before finishing. -- Every C# code sample in docs - inline, fenced, and XML `` blocks - must compile; see [Code Samples Must Compile](./skills/documentation/code-samples-must-compile.md). Run `node scripts/validate-doc-code-patterns.js` and the `DocsSnippetCompilationTests` suite before finishing. -- Documentation prose must avoid LLM-style filler, marketing adjectives, hedge transitions, and vague quantifiers; see [Human-Prose Documentation Policy](./skills/documentation/human-prose-policy.md). Run `node scripts/validate-docs-prose.js` before finishing. +- Documentation and `///` XML doc comments must be pure ASCII; see [ASCII-Only Documentation Policy](./skills/documentation/ascii-only-docs.md). Run `node scripts/validate-docs-ascii.js` (or, for the hook-equivalent batch run, `node scripts/run-staged-md-pipeline.js ` for `.md` and `node scripts/run-staged-validators.js ` for `.cs`) before finishing. +- Every C# code sample in docs - inline, fenced, and XML `` blocks - must compile; see [Code Samples Must Compile](./skills/documentation/code-samples-must-compile.md). Run `node scripts/validate-doc-code-patterns.js` (or, for the hook-equivalent batch run, `node scripts/run-staged-md-pipeline.js ` for `.md` and `node scripts/run-staged-validators.js ` for `.cs`) and the `DocsSnippetCompilationTests` suite before finishing. +- Documentation prose must avoid LLM-style filler, marketing adjectives, hedge transitions, and vague quantifiers; see [Human-Prose Documentation Policy](./skills/documentation/human-prose-policy.md). Run `node scripts/validate-docs-prose.js` (or, for the hook-equivalent batch run, `node scripts/run-staged-md-pipeline.js ` for `.md` and `node scripts/run-staged-validators.js ` for `.cs`) before finishing. - Subclasses of `MessageAwareComponent` MUST call `base.()` from every guarded lifecycle override (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, `RegisterMessageHandlers`); see [MessageAwareComponent Base-Call Contract](./skills/unity/base-call-contract.md). Five enforcement layers (Roslyn analyzer DXMSG006-010, IL scanner, Inspector overlay, runtime self-check, meta-test) keep the contract honest. ## Skills to Prefer @@ -149,3 +152,4 @@ Use the index above and then select the most relevant skill pages. Frequently us - [Lifecycle Edge-Case Test Coverage](./skills/testing/lifecycle-edge-coverage.md) - [LeakWatcher: Detecting Registration Leaks in Tests](./skills/testing/leak-watcher-usage.md) - [MessageAwareComponent Base-Call Contract](./skills/unity/base-call-contract.md) +- [Git Hook Performance Budget](./skills/performance/git-hook-performance.md) diff --git a/.llm/skills/documentation/ascii-only-docs.md b/.llm/skills/documentation/ascii-only-docs.md index 9db7e08c..8831ec2f 100644 --- a/.llm/skills/documentation/ascii-only-docs.md +++ b/.llm/skills/documentation/ascii-only-docs.md @@ -149,7 +149,7 @@ Three layers, all wired up: 1. **`scripts/validate-docs-ascii.js`** - the runtime check, exits non-zero on any banned character. Reports `file:line:column` with codepoint and char. 1. **`scripts/normalize-docs-ascii.js`** - the auto-fixer, idempotent, applies the substitution table. Run with `--check` for a dry run. -1. **Pre-commit hook** (`validate-docs-ascii` in `.pre-commit-config.yaml`) and **CI workflow** (`.github/workflows/docs-lint.yml`) run the validator on every commit and PR. +1. **Pre-commit hooks** - the validator runs as part of `run-staged-md-pipeline` (for `.md` / `.markdown` files) and `run-staged-validators` (for `.cs` files) in `.pre-commit-config.yaml`. The standalone CLI `node scripts/validate-docs-ascii.js` is preserved for ad-hoc invocations. The same validator runs on every PR via the **CI workflow** at `.github/workflows/docs-lint.yml`. ## How to Fix Violations diff --git a/.llm/skills/documentation/code-samples-must-compile.md b/.llm/skills/documentation/code-samples-must-compile.md index 00bf1a49..955dd314 100644 --- a/.llm/skills/documentation/code-samples-must-compile.md +++ b/.llm/skills/documentation/code-samples-must-compile.md @@ -110,7 +110,7 @@ Three layers, all wired up. The two layers split responsibility cleanly: - `DocumentationSnippetsCompile` - fenced ` ```csharp ` blocks across `docs/`. - `InlineTableSnippetsCompile` - inline backtick code spans inside table rows. Filtered via `IsApiSignatureDocumentation` and a "must contain `(` and end with `)` or `;`" heuristic so single identifiers and bare type names don't get tested. - `XmlDocCodeBlocksCompile` - `...` and `...` blocks across `Runtime/`, `Editor/`, `SourceGenerators/`. -1. **Pre-commit hook** (`validate-doc-code-patterns` in `.pre-commit-config.yaml`) and **CI workflow** (`.github/workflows/docs-lint.yml`). +1. **Pre-commit hooks** - the validator runs as part of `run-staged-md-pipeline` (for `.md` / `.markdown` files) and `run-staged-validators` (for `.cs` files) in `.pre-commit-config.yaml`. The standalone CLI `node scripts/validate-doc-code-patterns.js` is preserved for ad-hoc invocations. The same validator runs on every PR via the **CI workflow** at `.github/workflows/docs-lint.yml`. The harness uses a minimal stub set (`GeneratorTestUtilities.SharedStubs`) rather than the full runtime, so doc snippets that reference real DxMessaging APIs without redeclaring them work. The corresponding diagnostic IDs (`CS0103`, `CS0246`, `CS1061`, etc., for missing identifiers and types) are tolerated via `IgnoredSnippetDiagnosticIds` so the test focuses on real semantic bugs that don't depend on external symbols. The trade-off: stub coverage gaps require ignoring `CS1510`, which means the textual lint is the only mechanism that catches the struct-rvalue-Emit bug class. diff --git a/.llm/skills/documentation/human-prose-policy.md b/.llm/skills/documentation/human-prose-policy.md index ff01cc99..75ddca96 100644 --- a/.llm/skills/documentation/human-prose-policy.md +++ b/.llm/skills/documentation/human-prose-policy.md @@ -127,11 +127,12 @@ The validator's `--list-rules` flag prints the canonical set with full term list The policy is fully enforced going forward. There is no grandfather list: every violation reported by `scripts/validate-docs-prose.js` is a new defect to fix. -| Layer | What it covers | When it runs | -| ----------------------------------------- | ---------------------------------------------------------- | -------------------------------------------- | -| `scripts/validate-docs-prose.js` | All banned phrases, allow markers, exemptions | Local pre-commit hook (CI integration TBD) | -| `validate-docs-prose` pre-commit hook | Runs the JS validator on every commit touching `.md`/`.cs` | Local pre-commit | -| `.vale.ini` + `.vale/styles/DxMessaging/` | Passive voice, weasel words, additional style rules | Local-only until committed and wired into CI | +| Layer | What it covers | When it runs | +| ----------------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `scripts/validate-docs-prose.js` | All banned phrases, allow markers, exemptions | Standalone CLI for ad-hoc runs; same module also called in-process by the consolidated runners below | +| `run-staged-md-pipeline` pre-commit hook | Runs the prose validator in-process on staged `.md` files | Local pre-commit (consolidated `.md` pipeline that also handles fixers, prettier, markdownlint, ascii, and code-pattern lint) | +| `run-staged-validators` pre-commit hook | Runs the prose validator in-process on staged `.cs` files | Local pre-commit (consolidated `.cs` validator runner that also handles ascii and code-pattern lint) | +| `.vale.ini` + `.vale/styles/DxMessaging/` | Passive voice, weasel words, additional style rules | Local-only until committed and wired into CI | The custom JS validator is the source of truth. The Vale configuration is additive and currently lives only in working trees; once it is committed and wired into a workflow, this row will move to "CI". File an issue if Vale flags something the JS validator missed so the `RULES` array can absorb the rule first. diff --git a/.llm/skills/index.md b/.llm/skills/index.md index 3dc45e33..f97ee021 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -9,7 +9,7 @@ | Metric | Value | | ------------ | ----- | -| Total Skills | 142 | +| Total Skills | 144 | | Categories | 8 | --- @@ -19,7 +19,7 @@ - [Documentation](#documentation) (27) - [GitHub Actions](#github-actions) (5) - [Packaging](#packaging) (2) -- [Performance](#performance) (40) +- [Performance](#performance) (42) - [Scripting](#scripting) (15) - [Solid](#solid) (15) - [Testing](#testing) (37) @@ -44,7 +44,7 @@ | [Documentation Updates and Maintenance](./documentation/documentation-updates.md) | [ok] 149 | [basic] | [stable] | [risk: none] | documentation, code-comments | | [External URL Fragment Validation](./documentation/external-url-fragment-validation.md) | [ok] 182 | [basic] | [stable] | [risk: none] | documentation, links | | [GitHub Actions Version Consistency](./documentation/github-actions-version-consistency.md) | [ok] 204 | [basic] | [stable] | [risk: none] | github-actions, ci-cd | -| [Human-Prose Documentation Policy](./documentation/human-prose-policy.md) | [ok] 186 | [basic] | [stable] | [risk: none] | documentation, prose | +| [Human-Prose Documentation Policy](./documentation/human-prose-policy.md) | [ok] 187 | [basic] | [stable] | [risk: none] | documentation, prose | | [Link Quality and External URL Management](./documentation/link-quality-guidelines.md) | [ok] 120 | [basic] | [stable] | [risk: none] | documentation, links | | [Link Quality and External URL Management Part 1](./documentation/link-quality-guidelines-part-1.md) | [ok] 196 | [intermediate] | [stable] | [risk: low] | migration, split | | [Link Quality and External URL Management Part 2](./documentation/link-quality-guidelines-part-2.md) | [draft] 64 | [intermediate] | [stable] | [risk: low] | migration, split | @@ -96,6 +96,8 @@ | [Collection Pooling with RAII Pattern](./performance/collection-pooling.md) | [draft] 119 | [intermediate] | [stable] | [risk: high] | memory, allocation | | [Collection Pooling with RAII Pattern Part 1](./performance/collection-pooling-part-1.md) | [ok] 206 | [intermediate] | [stable] | [risk: low] | migration, split | | [Collection Pooling with RAII Pattern Part 2](./performance/collection-pooling-part-2.md) | [draft] 57 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Git Hook Performance Budget](./performance/git-hook-performance.md) | [warn] 299 | [intermediate] | [stable] | [risk: high] | git-hooks, pre-commit | +| [Git Hook Performance: Stages and Tooling](./performance/git-hook-performance-tooling.md) | [ok] 240 | [intermediate] | [stable] | [risk: high] | git-hooks, pre-commit | | [High-Performance Cache with Eviction Policies](./performance/cache-eviction-policies.md) | [ok] 177 | [advanced] | [stable] | [risk: high] | caching, memory | | [Object Pooling Anti-Patterns](./performance/object-pooling-anti-patterns.md) | [ok] 145 | [intermediate] | [stable] | [risk: high] | memory, allocation | | [Object Pooling for Zero-Allocation Messaging](./performance/object-pooling.md) | [ok] 124 | [intermediate] | [stable] | [risk: high] | memory, allocation | diff --git a/.llm/skills/performance/git-hook-performance-tooling.md b/.llm/skills/performance/git-hook-performance-tooling.md new file mode 100644 index 00000000..720fac11 --- /dev/null +++ b/.llm/skills/performance/git-hook-performance-tooling.md @@ -0,0 +1,239 @@ +--- +title: "Git Hook Performance: Stages and Tooling" +id: "git-hook-performance-tooling" +category: "performance" +version: "1.1.0" +created: "2026-05-02" +updated: "2026-05-02" + +source: + repository: "wallstop/DxMessaging" + files: + - path: ".pre-commit-config.yaml" + - path: "scripts/run-staged-validators.js" + - path: "scripts/run-staged-md-pipeline.js" + - path: "scripts/measure-hook-wallclock.js" + - path: ".github/workflows/hook-perf-measurement.yml" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "git-hooks" + - "pre-commit" + - "ci-cd" + - "performance" + - "developer-experience" + - "tooling" + +complexity: + level: "intermediate" + reasoning: "Requires familiarity with pre-commit hook stages and Node startup cost" + +impact: + performance: + rating: "high" + details: "Documents the stage placement and consolidation rules that keep the pipeline under budget" + maintainability: + rating: "high" + details: "Centralizes the operational guidance that the budget skill links to" + testability: + rating: "medium" + details: "Tooling described here is enforced by tests in the budget skill" + +prerequisites: + - "Familiarity with the budget skill (git-hook-performance)" + +dependencies: + packages: [] + skills: + - "git-hook-performance" + +applies_to: + languages: + - "JavaScript" + - "YAML" + frameworks: + - "pre-commit" + versions: + pre-commit: ">=3.0" + +aliases: + - "Hook stages" + - "Hook tooling" + +related: + - "git-hook-performance" + +status: "stable" +--- + +# Git Hook Performance: Stages and Tooling + +> **One-line summary**: Where each hook lives (pre-commit / pre-push / CI), how to consolidate per-file validators, how to measure wall-clock, and the new-hook checklist. + +This page is the operational companion to the budget skill at +[Git Hook Performance Budget](git-hook-performance.md). Read the budget +skill first for the scoring rules and the waiver mechanics; this page +covers the workflow questions that drop out of those rules. + +## What lives where + +The pipeline divides along three axes: cost, scope, and recovery. + +- pre-commit (must be fast and per-file): + - Formatters that mutate the staged file (csharpier, prettier for + JSON/YAML/asmdef/asmref, fix-eol, fix-csharp-underscore-methods, + sync-banner-version). + - The consolidated markdown pipeline at + `scripts/run-staged-md-pipeline.js` (round-4): one Node process + that runs fix-md036-headings, fix-md029-md051, prettier --write, + markdownlint-cli2 --fix, and the three doc validators + (validate-docs-ascii, validate-doc-code-patterns, + validate-docs-prose) in sequence on every staged `.md` / + `.markdown` file. Replaces five separate hooks. + - The consolidated C# validator runner at + `scripts/run-staged-validators.js` (the same three validators, + narrowed to `.cs` for the round-4 pipeline split). + - Cheap structural validators that read only one or two files + (validate-skills, validate-vscode-settings, + validate-pre-commit-tooling, validate-lychee-config, + validate-changelog-policy, eol-bom-check, conflict-markers, + skills-index-regen, update-llms-txt). +- pre-push (cost gate; tests, networked checks, repo-wide scans): + - cspell (spell-check; about 5.5 s per fire is too slow for commit + cadence). + - skills-index-check, validate-npm-meta, script-parser-tests, + script-tests, actionlint, yamllint. + - check-llms-txt-fresh (cheap diff against a freshly generated + `llms.txt`). + - run-staged-validators on `.cs` (provides the validator gate + redundantly at push time so unrelated commits cannot land + documentation drift via the C# XML doc-comment surface). +- CI only (anything over 5 seconds or that reads from the network): + - Full Jest suite (`.github/workflows/script-tests.yml`). + - validate-llms-txt full generator-contract suite + (`.github/workflows/validate-llms-txt.yml`). + - markdownlint sweep across the whole repo + (`.github/workflows/markdownlint.yml`). + - The wall-clock measurement harness + (`.github/workflows/hook-perf-measurement.yml`). + - validate-docs-prose, validate-docs-ascii, and + validate-doc-code-patterns each run as standalone jobs on every + PR via `.github/workflows/docs-lint.yml` (round-4 added the prose + job). + +## Consolidating validators and fixers + +There are two consolidated runners. Adding a new check should target +one of them rather than introducing a fresh hook. + +### `scripts/run-staged-md-pipeline.js` (markdown path) + +For any new check that runs on `.md` / `.markdown` files, wire it into +the markdown pipeline. The pipeline currently chains, in one Node +process: + +1. `fix-md036-headings.processMarkdownContent` (in-process auto-fix). +1. `fix-md029-md051.processMarkdownContent` (in-process auto-fix). +1. `prettier --write` via the `prettier` programmatic API + (`format()` + `resolveConfig()`). +1. `markdownlint-cli2 --fix` via the `main(params)` API the package + exports from its `.mjs` entry (round-4 used dynamic import from + CommonJS). +1. `validate-docs-ascii.scanContent`, + `validate-doc-code-patterns.scanMarkdown`, and + `validate-docs-prose.scanContent`. + +Round-4 superseded the earlier guidance that "fixers must remain +separate hooks." Pre-commit reports "files were modified by this +hook" the same way whether five hooks or one hook performed the +rewrite, so consolidating the fixers does not change the user-visible +UX. The pipeline tracks rewrites via mtime/size so it can report a +stable "auto-fixed N file(s); re-stage to commit" message. + +A new markdown check qualifies for inclusion when: + +- It is per-file (input is a single file's content; no cross-file state). +- It exports a stable `processMarkdownContent(content)` (for fixers) + or `scanContent(filePath, content)` / `scanMarkdown(...)` (for + validators) function. + +### `scripts/run-staged-validators.js` (C# path) + +For any new per-file `.cs` validator, prefer adding it to +`scripts/run-staged-validators.js`. The runner imports each +validator's `scanContent` API and calls them in one Node process; +each extra hook entry costs 200 to 600 ms of Node startup on Windows. + +A new validator qualifies for consolidation when: + +- It is per-file (input is a single file's content; no cross-file state). +- It exports a stable `scanContent(filePath, content)` or + `scanFile(filePath)` function whose return shape includes + `violations[]`. +- Its `files:` regex is a subset of the consolidated runner's filter + (`\.cs$` excluding `Library/`, `Temp/`, `node_modules/`, `obj/`, + `bin/`, and `*/bin/` `*/obj/`). + +When consolidation is not appropriate, document the decision in the +new hook's description field so the next reviewer does not relitigate +it. + +## Wall-clock measurement + +The harness at `scripts/measure-hook-wallclock.js` measures real +wall-clock for a small set of representative scenarios and fails when +any scenario exceeds its per-scenario budget (8 seconds on Linux). + +```bash +node scripts/measure-hook-wallclock.js # human-readable +node scripts/measure-hook-wallclock.js --json # machine-readable +``` + +The harness is not a pre-commit hook (it touches files and is too slow +for that cadence). The `.github/workflows/hook-perf-measurement.yml` +workflow runs it on every PR that touches `.pre-commit-config.yaml` or +any `scripts/` file, and on a weekly cron, and fails the PR if any +scenario regresses past budget. + +## Adding a new hook (checklist) + +1. Default to `stages: [pre-push]` for tests, network calls, and tool + spawns. +1. Reserve `pre-commit` for staged-file formatters, the consolidated + validator runner, and cheap structural validators. +1. Always set both `files:` and (where the script can ignore generated + directories) `exclude:` filters. +1. For external-process hooks (`dotnet`, `java`, language toolchains), + set `require_serial: true` so pre-commit batches all staged files + into a single invocation. +1. Avoid `bash -lc`. Use `bash -c` unless you have a very specific + reason to load login profiles. +1. After editing `.pre-commit-config.yaml`, run: + + ```bash + node scripts/run-managed-jest.js --runTestsByPath \ + scripts/__tests__/hook-perf-budget.test.js \ + scripts/__tests__/precommit-perf-score.test.js \ + scripts/__tests__/pre-commit-hook-stage-policy.test.js + ``` + +1. For changes that look like they could affect wall-clock (a new hook, + a hook moved between stages, a wrapper script added or removed), run + `node scripts/measure-hook-wallclock.js` locally before pushing. + +## See Also + +- [Git Hook Performance Budget](git-hook-performance.md) +- [Cross-Platform Script Compatibility](../scripting/cross-platform-compatibility.md) + +## References + +- [pre-commit hook stages](https://pre-commit.com/#confining-hooks-to-run-at-certain-stages) +- [pre-commit require_serial](https://pre-commit.com/#hooks-require_serial) + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1.0.0 | 2026-05-02 | Initial split from git-hook-performance to honor the 300-line skill cap. | +| 1.1.0 | 2026-05-02 | Round-4: documented the new run-staged-md-pipeline.js (in-process .md fixer + prettier + markdownlint + validators), revised the "fixers cannot be consolidated" carve-out to reflect that pre-commit's modified-file UX is identical with one hook, and recorded the new wall-clock projections. | diff --git a/.llm/skills/performance/git-hook-performance-tooling.md.meta b/.llm/skills/performance/git-hook-performance-tooling.md.meta new file mode 100644 index 00000000..e0d4dcc6 --- /dev/null +++ b/.llm/skills/performance/git-hook-performance-tooling.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5a751ae07b41d3ae2d3ee36685d978f5 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/.llm/skills/performance/git-hook-performance.md b/.llm/skills/performance/git-hook-performance.md new file mode 100644 index 00000000..721426bf --- /dev/null +++ b/.llm/skills/performance/git-hook-performance.md @@ -0,0 +1,298 @@ +--- +title: "Git Hook Performance Budget" +id: "git-hook-performance" +category: "performance" +version: "1.3.0" +created: "2026-05-02" +updated: "2026-05-02" + +source: + repository: "wallstop/DxMessaging" + files: + - path: ".pre-commit-config.yaml" + - path: "scripts/lib/precommit-perf-score.js" + - path: "scripts/__tests__/hook-perf-budget.test.js" + - path: "scripts/measure-hook-wallclock.js" + - path: "scripts/run-staged-validators.js" + - path: "scripts/run-staged-md-pipeline.js" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "git-hooks" + - "pre-commit" + - "ci-cd" + - "performance" + - "developer-experience" + - "tooling" + +complexity: + level: "intermediate" + reasoning: "Requires understanding of pre-commit hook execution model and Windows process spawn cost" + +impact: + performance: + rating: "high" + details: "Keeps pre-commit under 8s on Linux (proxy for under 10s on Windows) on single-file commits; protects developer flow" + maintainability: + rating: "high" + details: "Static scorer plus wall-clock CI measurement catch regressions in PR review" + testability: + rating: "high" + details: "Integration test, unit-style scorer tests, version-parity tests, and a wall-clock harness all enforce the budget" + +prerequisites: + - "Familiarity with pre-commit hook configuration" + - "Understanding of process spawn cost on Windows" + +dependencies: + packages: [] + skills: [] + +applies_to: + languages: + - "JavaScript" + - "YAML" + frameworks: + - "pre-commit" + versions: + pre-commit: ">=3.0" + +aliases: + - "Pre-commit performance" + - "Hook budget" + +related: + - "cross-platform-compatibility" + - "git-hook-performance-tooling" + +status: "stable" +--- + +# Git Hook Performance Budget + +> **One-line summary**: Pre-commit on a single-file commit must finish under 8 seconds on the Linux dev container (proxy for under 10 seconds on Windows); a static scorer plus a wall-clock CI job enforce the budget. + +## Budget + +- pre-commit on a single-file commit: under 8 seconds wall-clock on Linux + (proxies to under 16 seconds on Windows; the goal is under 10 seconds on + Windows for the .md path through aggressive Node-spawn minimization). +- pre-push on a single-file push: under 8 seconds wall-clock on Linux. +- The static scorer enforces TWO ceilings: + - Total budget (10): cumulative anti-pattern score across all + pre-commit-stage hooks. Catches accumulated drift. + - Per-hook ceiling (3): final score on any single hook. Catches + single-rule regressions that would hide under the total budget's + slack (with the real config at score 2, a stray `bash -lc` (5) + lands at total 7 -- under 10 but well over the per-hook ceiling). +- Hooks that legitimately need a high-cost pattern must declare so via a + `# perf-allow[]: ` comment that names + every waived rule. See [How to opt out](#how-to-opt-out). A waived rule + does NOT count toward either ceiling. +- The wall-clock harness at `scripts/measure-hook-wallclock.js` enforces + the per-scenario Linux budget directly; the scorer is the + cross-platform proxy. + +### Why two ceilings + +Every defined rule is either =< 3 (small-cost) or >= 5 (high-cost), so +the per-hook ceiling of 3 mechanically partitions them: any single +high-cost rule trips the per-hook test on its own, and so does any +combination of small-cost rules summing above 3 on one entry. Examples: + +- `bash -lc` (3) + `npx --yes` (2) on one hook -> final 5 -> per-hook + violation (total budget would have allowed it). +- `npm install` (5) on one hook -> final 5 -> per-hook violation. +- `bash -lc` alone -> final 3 -> AT the ceiling, not in violation. +- `npm pack` (5) waived by `# perf-allow[npm-spawn]: ` -> + final 0 -> no violation (post-waiver score is what counts). + +## Anti-patterns + +The scorer at `scripts/lib/precommit-perf-score.js` walks every hook in +`.pre-commit-config.yaml`. For hooks that run at the `pre-commit` stage +(including hooks with no `stages:` declaration, since pre-commit defaults +to `[pre-commit]`), each rule below adds points to the pipeline budget. +Each rule has a stable ID used by the perf-allow waiver format described +in [How to opt out](#how-to-opt-out). + +- `+5` `[scans-the-world]` `pass_filenames: false` with no `files:` filter. + The hook scans the entire repo on every commit. Add a `files:` regex or + pass staged paths through to the script. +- `+3` `[scans-the-world-with-files]` `pass_filenames: false` with a + `files:` filter. The hook still pays the scan cost (the script does not + receive the staged file list as argv). Switch to `pass_filenames: true` + and accept `[files...]` argv when the script can consume them. +- `+5` `[always-run]` `always_run: true`. The hook fires on every commit + regardless of staged input. Replace with a `files:` regex. +- `+5` `[npm-spawn]` Entry contains `npm pack`, `npm install`, `npm exec`, + `npm test`, or `npm run validate:npm-meta`. These spawn heavy npm child + processes and belong at pre-push. +- `+5` `[dotnet-no-batch]` Entry uses `dotnet tool run` without + `require_serial: true`. Without serialization, pre-commit spawns one + tool process per file. With `require_serial`, all staged files batch + into one invocation. +- `+5` `[jest-at-pre-commit]` Entry runs Jest (via `run-managed-jest.js` + or bare `jest`) at the pre-commit stage. Jest startup alone costs five + to fifteen seconds. Move test runs to pre-push. +- `+2` `[npx-cold-start]` Entry uses `npx --yes`. On a cold cache the + package downloads before the hook runs. Most hooks should prefer the + `bash -c '[ -f node_modules// ] && node ...; else npx --yes +@ ...'` shape so cold-cache fallback works without a + managed Node wrapper. +- `+3` `[bash-login-shell]` Entry uses `bash -lc` or `bash --login -c`. + Login shells load `~/.bash_profile`, nvm/fnm init, and similar profile + scripts. That adds 100 to 500 ms per fire for nothing. Use `bash -c`. +- `+3` `[node-double-spawn]` Entry runs + `node scripts/run-managed-.js` where the wrapper exists only to + spawn another Node or npx process. The double-spawn cost is roughly + 600 to 1200 ms on Windows. Inline the version-pinned fallback into a + `bash -c` entry instead, and validate the pinned version against + `package.json` with a parity test (see + `scripts/__tests__/cspell-version-parity.test.js` and + `scripts/__tests__/prettier-version-parity.test.js`). Only the Jest + wrapper is exempt because managed Jest orchestrates a deterministic + local-vs-fallback Jest invocation that cannot be expressed inline, + and Jest only fires at pre-push so the cost is paid once per push. +- `+3` `[npm-run-at-hook]` Entry uses `npm run