From 4b9339eb04b7b313bfe78f936840ccf9792d4f3f Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Tue, 28 Apr 2026 10:53:22 -0700 Subject: [PATCH 01/12] Initial progress on analyzer warnings --- .../workflows/pre-commit-tooling-check.yml | 96 + .gitignore | 5 + .llm/context.md | 14 + .../documentation-update-workflow.md | 6 + .llm/skills/index.md | 2 +- .pre-commit-config.yaml | 42 +- CHANGELOG.md | 1 + CONTRIBUTING.md | 27 +- Editor/Analyzers/BaseCallIlInspector.cs | 276 ++ Editor/Analyzers/BaseCallIlInspector.cs.meta | 11 + Editor/Analyzers/BaseCallLogMessageParser.cs | 298 +++ .../BaseCallLogMessageParser.cs.meta | 11 + Editor/Analyzers/BaseCallReportAggregator.cs | 310 +++ .../BaseCallReportAggregator.cs.meta | 11 + Editor/Analyzers/BaseCallTypeScanner.cs | 110 + Editor/Analyzers/BaseCallTypeScanner.cs.meta | 11 + Editor/Analyzers/BaseCallTypeScannerCore.cs | 390 +++ .../Analyzers/BaseCallTypeScannerCore.cs.meta | 11 + .../Analyzers/DxMessagingConsoleHarvester.cs | 1122 ++++++++ .../DxMessagingConsoleHarvester.cs.meta | 11 + .../WallstopStudios.DxMessaging.Analyzer.dll | Bin 0 -> 22016 bytes ...lstopStudios.DxMessaging.Analyzer.dll.meta | 33 + ...opStudios.DxMessaging.SourceGenerators.dll | Bin 33280 -> 33280 bytes .../MessageAwareComponentFallbackEditor.cs | 77 + ...essageAwareComponentFallbackEditor.cs.meta | 11 + .../MessageAwareComponentInspectorOverlay.cs | 406 +++ ...sageAwareComponentInspectorOverlay.cs.meta | 11 + .../Settings/DxMessagingBaseCallIgnoreSync.cs | 190 ++ .../DxMessagingBaseCallIgnoreSync.cs.meta | 11 + Editor/Settings/DxMessagingSettings.cs | 189 ++ Editor/SetupCscRsp.cs | 144 +- .../DxIgnoreMissingBaseCallAttribute.cs | 26 + .../DxIgnoreMissingBaseCallAttribute.cs.meta | 11 + Samples~/DI/README.md | 10 +- Samples~/Mini Combat/README.md | 18 +- Samples~/Mini Combat/Walkthrough.md | 8 +- Samples~/UI Buttons + Inspector/README.md | 14 +- .../WallstopStudios.DxMessaging.Analyzer.meta | 8 + .../Analyzers.meta | 8 + .../Analyzers/IgnoreListReader.cs | 151 ++ .../Analyzers/IgnoreListReader.cs.meta | 11 + .../MessageAwareComponentBaseCallAnalyzer.cs | 759 ++++++ ...sageAwareComponentBaseCallAnalyzer.cs.meta | 11 + ...allstopStudios.DxMessaging.Analyzer.csproj | 86 + ...opStudios.DxMessaging.Analyzer.csproj.meta | 7 + .../bin.meta | 8 + .../obj.meta | 8 + .../BaseCallIlInspectorTests.cs | 829 ++++++ .../BaseCallIlInspectorTests.cs.meta | 11 + .../BaseCallLogMessageParserTests.cs | 517 ++++ .../BaseCallLogMessageParserTests.cs.meta | 11 + .../BaseCallTypeScannerTests.cs | 714 +++++ .../BaseCallTypeScannerTests.cs.meta | 11 + .../CompilationMessageHarvestTests.cs | 625 +++++ .../CompilationMessageHarvestTests.cs.meta | 11 + .../GeneratorTestUtilities.cs | 151 ++ ...sageAwareComponentBaseCallAnalyzerTests.cs | 2289 +++++++++++++++++ ...wareComponentBaseCallAnalyzerTests.cs.meta | 11 + ....DxMessaging.SourceGenerators.Tests.csproj | 64 +- com.wallstop-studios.dxmessaging.sln | 36 + docs/reference/analyzers.md | 450 ++++ docs/reference/analyzers.md.meta | 7 + llms.txt | 2 +- package.json | 16 +- scripts/__tests__/fix-md029-md051.test.js | 156 ++ .../__tests__/fix-md029-md051.test.js.meta | 7 + scripts/__tests__/run-managed-jest.test.js | 247 ++ .../validate-pre-commit-tooling.test.js | 196 ++ .../verify-managed-jest-fallback.test.js | 66 + scripts/fix-md029-md051.js | 228 ++ scripts/fix-md029-md051.js.meta | 7 + scripts/run-managed-jest.js | 253 ++ scripts/validate-pre-commit-tooling.js | 296 +++ scripts/verify-managed-jest-fallback.js | 59 + 74 files changed, 12184 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/pre-commit-tooling-check.yml create mode 100644 Editor/Analyzers/BaseCallIlInspector.cs create mode 100644 Editor/Analyzers/BaseCallIlInspector.cs.meta create mode 100644 Editor/Analyzers/BaseCallLogMessageParser.cs create mode 100644 Editor/Analyzers/BaseCallLogMessageParser.cs.meta create mode 100644 Editor/Analyzers/BaseCallReportAggregator.cs create mode 100644 Editor/Analyzers/BaseCallReportAggregator.cs.meta create mode 100644 Editor/Analyzers/BaseCallTypeScanner.cs create mode 100644 Editor/Analyzers/BaseCallTypeScanner.cs.meta create mode 100644 Editor/Analyzers/BaseCallTypeScannerCore.cs create mode 100644 Editor/Analyzers/BaseCallTypeScannerCore.cs.meta create mode 100644 Editor/Analyzers/DxMessagingConsoleHarvester.cs create mode 100644 Editor/Analyzers/DxMessagingConsoleHarvester.cs.meta create mode 100644 Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll create mode 100644 Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll.meta create mode 100644 Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs create mode 100644 Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs.meta create mode 100644 Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs create mode 100644 Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs.meta create mode 100644 Editor/Settings/DxMessagingBaseCallIgnoreSync.cs create mode 100644 Editor/Settings/DxMessagingBaseCallIgnoreSync.cs.meta create mode 100644 Runtime/Core/Attributes/DxIgnoreMissingBaseCallAttribute.cs create mode 100644 Runtime/Core/Attributes/DxIgnoreMissingBaseCallAttribute.cs.meta create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.Analyzer.meta create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers.meta create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/IgnoreListReader.cs create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/IgnoreListReader.cs.meta create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs.meta create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.Analyzer/WallstopStudios.DxMessaging.Analyzer.csproj create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.Analyzer/WallstopStudios.DxMessaging.Analyzer.csproj.meta create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.Analyzer/bin.meta create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.Analyzer/obj.meta create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs.meta create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs.meta create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs.meta create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs.meta create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs create mode 100644 SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs.meta create mode 100644 docs/reference/analyzers.md create mode 100644 docs/reference/analyzers.md.meta create mode 100644 scripts/__tests__/fix-md029-md051.test.js create mode 100644 scripts/__tests__/fix-md029-md051.test.js.meta create mode 100644 scripts/__tests__/run-managed-jest.test.js create mode 100644 scripts/__tests__/validate-pre-commit-tooling.test.js create mode 100644 scripts/__tests__/verify-managed-jest-fallback.test.js create mode 100644 scripts/fix-md029-md051.js create mode 100644 scripts/fix-md029-md051.js.meta create mode 100644 scripts/run-managed-jest.js create mode 100644 scripts/validate-pre-commit-tooling.js create mode 100644 scripts/verify-managed-jest-fallback.js diff --git a/.github/workflows/pre-commit-tooling-check.yml b/.github/workflows/pre-commit-tooling-check.yml new file mode 100644 index 00000000..b964c22d --- /dev/null +++ b/.github/workflows/pre-commit-tooling-check.yml @@ -0,0 +1,96 @@ +name: Pre-commit Tooling Check + +on: + pull_request: + paths: + - ".pre-commit-config.yaml" + - "package.json" + - "package-lock.json" + - "scripts/**/*.js" + - "scripts/__tests__/**/*.js" + - "CONTRIBUTING.md" + - ".llm/context.md" + - ".yamllint.yaml" + - ".github/workflows/**/*.yml" + - ".github/workflows/**/*.yaml" + - ".github/workflows/pre-commit-tooling-check.yml" + push: + branches: + - main + - master + paths: + - ".pre-commit-config.yaml" + - "package.json" + - "package-lock.json" + - "scripts/**/*.js" + - "scripts/__tests__/**/*.js" + - "CONTRIBUTING.md" + - ".llm/context.md" + - ".yamllint.yaml" + - ".github/workflows/**/*.yml" + - ".github/workflows/**/*.yaml" + - ".github/workflows/pre-commit-tooling-check.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + pre-commit-tooling: + name: Validate pre-commit tooling (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: npm + cache-dependency-path: package-lock.json + + - name: Install pre-commit + run: python -m pip install pre-commit + + - name: Install dependencies + run: npm ci + + - name: Validate pre-commit Node tooling policy + run: pre-commit run validate-pre-commit-tooling --all-files + + - name: Validate YAML formatting and lint policy + run: pre-commit run yamllint --all-files + + - name: Run parser script tests hook + run: pre-commit run script-parser-tests --all-files + + - name: Remove managed Jest runner to force fallback path + run: node scripts/verify-managed-jest-fallback.js + + - name: Verify managed Jest fallback test + run: >- + node scripts/run-managed-jest.js --runTestsByPath + scripts/__tests__/run-managed-jest.test.js --testNamePattern + "runManagedJest uses npm exec fallback when local jest install is unhealthy" diff --git a/.gitignore b/.gitignore index db5532df..e655ff90 100644 --- a/.gitignore +++ b/.gitignore @@ -346,3 +346,8 @@ progress.meta .claude/ .vscode/settings.json +pre-commit.md* +pre-commit.txt* +pre-push.md* +pre-push.txt* +pr-description.md* \ No newline at end of file diff --git a/.llm/context.md b/.llm/context.md index b2ee9ec4..ff238a6e 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -25,12 +25,20 @@ This file is intentionally concise. It contains only critical, high-signal guida - Preserve existing naming and architectural patterns. - Never commit repository settings that auto-approve chat-invoked terminal commands. - Ensure fenced markdown examples are closed and do not swallow real sections (for example `## See Also`). +- Run file-scoped validation during editing; do not treat git hooks as the first signal of quality issues. +- When editing `.pre-commit-config.yaml`, `scripts/*` hook tooling, or `.github/workflows/*.yml`, run `npm run preflight:pre-commit` before finishing. ## Build and Test Commands - Restore tools: `dotnet tool restore` - Format C#: `dotnet tool run csharpier format` - 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` +- Validate YAML formatting and lint policy: `npm run check:yaml` +- Note: Prettier does not auto-wrap long YAML lines; yamllint enforces the 200-character limit. +- Auto-fix markdown fragments/lists: `node scripts/fix-md029-md051.js ` +- Lint markdown: `npx markdownlint-cli2 ` - Validate skills + context: `node scripts/validate-skills.js` - Regenerate skills index: `node scripts/generate-skills-index.js` - Verify index is current: `node scripts/generate-skills-index.js --check` @@ -49,6 +57,9 @@ This file is intentionally concise. It contains only critical, high-signal guida - Normalize multiline text handling before line-based parsing. - Keep JS and PowerShell behavior synchronized when dual implementations exist. - Add tests for parser changes and malformed input edge cases. +- For Jest in hooks or npm scripts, use `node scripts/run-managed-jest.js` instead of bare `jest` invocations. +- 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 and yamllint issues are caught before commit. ## Line Ending Policy @@ -68,6 +79,9 @@ This file is intentionally concise. It contains only critical, high-signal guida - Update relevant docs after user-visible behavior changes. - Keep examples accurate and aligned with real usage. - Update `CHANGELOG.md` for user-facing changes. +- 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). ## Skills to Prefer diff --git a/.llm/skills/documentation/documentation-update-workflow.md b/.llm/skills/documentation/documentation-update-workflow.md index c139cf5d..b6bd62df 100644 --- a/.llm/skills/documentation/documentation-update-workflow.md +++ b/.llm/skills/documentation/documentation-update-workflow.md @@ -88,6 +88,8 @@ Without a workflow, documentation updates are inconsistent, and important refere 1. **Add version notes**: Mark new/changed behavior with version 1. **Update CHANGELOG**: Add entry under appropriate section 1. **Cross-reference**: Ensure links and "See Also" sections are current +1. **Auto-fix Markdown structure**: Run `node scripts/fix-md029-md051.js ` +1. **Lint Markdown before commit**: Run `npx markdownlint-cli2 ` ### Example: Adding a New Emit Overload @@ -122,6 +124,10 @@ Documentation updates needed: - [ ] No TODOs or placeholders in documentation - [ ] Links between related docs are bidirectional - [ ] Examples use current API, not deprecated patterns +- [ ] Ordered lists use MD029 `one` style (`1.` prefixes) +- [ ] Internal fragment links resolve correctly (MD051) +- [ ] `node scripts/fix-md029-md051.js` run on changed docs +- [ ] `npx markdownlint-cli2` passes for changed docs ## Performance Notes diff --git a/.llm/skills/index.md b/.llm/skills/index.md index 6383c729..535d73a4 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -37,7 +37,7 @@ | [Documentation Code Samples](./documentation/documentation-code-samples.md) | ✅ 213 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, code-samples | | [Documentation Code Samples Part 1](./documentation/documentation-code-samples-part-1.md) | 📝 82 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | | [Documentation Style Guide](./documentation/documentation-style-guide.md) | ✅ 204 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, style | -| [Documentation Update Workflow](./documentation/documentation-update-workflow.md) | ✅ 149 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, workflow | +| [Documentation Update Workflow](./documentation/documentation-update-workflow.md) | ✅ 155 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, workflow | | [Documentation Updates and Maintenance](./documentation/documentation-updates.md) | ✅ 149 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, code-comments | | [External URL Fragment Validation](./documentation/external-url-fragment-validation.md) | ✅ 182 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, links | | [GitHub Actions Version Consistency](./documentation/github-actions-version-consistency.md) | ✅ 204 | 🟢 Basic | ✅ Stable | ○○○○○ | github-actions, ci-cd | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9201b996..82418872 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,6 +58,15 @@ repos: files: '(?i)\.(md|markdown)$' description: Auto-fix MD036 (no-emphasis-as-heading) by converting bold-only lines to headings. + - repo: local + hooks: + - id: markdown-link-fragment-list-fix + name: Markdown fragment/list auto-fix (MD051/MD029) + entry: node scripts/fix-md029-md051.js + language: system + files: '(?i)\.(md|markdown)$' + description: Auto-fix local heading fragment links and normalize ordered-list prefixes to 1. + - repo: local hooks: - id: markdownlint @@ -66,13 +75,18 @@ repos: language: system files: '(?i)\.(md|markdown)$' - - repo: local + - repo: https://github.com/adrienverge/yamllint + rev: v1.38.0 hooks: - id: yamllint - name: yamllint (if available) - entry: bash -c 'if command -v yamllint >/dev/null 2>&1; then yamllint -c .yamllint.yaml "$@"; else echo "yamllint not installed; skipping"; fi' -- - language: system + name: yamllint (pinned) + args: + - -c + - .yamllint.yaml files: '(?i)\.(ya?ml)$' + stages: + - pre-commit + - pre-push - repo: local hooks: @@ -189,6 +203,16 @@ repos: - pre-commit - pre-push description: Reject committed terminal auto-approval settings in repository VS Code config. + - id: validate-pre-commit-tooling + name: Validate pre-commit Node tooling policy + entry: node scripts/validate-pre-commit-tooling.js + language: system + pass_filenames: false + files: '^((\.pre-commit-config\.yaml)|(scripts/validate-pre-commit-tooling\.js)|(scripts/run-managed-jest\.js)|(scripts/__tests__/validate-pre-commit-tooling\.test\.js)|(scripts/__tests__/run-managed-jest\.test\.js))$' + stages: + - pre-commit + - pre-push + description: Enforce explicit npx install policy and managed Jest wrapper usage in local hooks. - id: validate-lychee-config name: Validate lychee configuration entry: node scripts/validate-lychee-config.js @@ -202,7 +226,7 @@ repos: - id: script-parser-tests name: Run parser script tests entry: >- - npx jest --runTestsByPath scripts/__tests__/validate-lychee-config.test.js + node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/validate-lychee-config.test.js scripts/__tests__/generate-skills-index.test.js scripts/__tests__/validate-skills-required-fields.test.js scripts/__tests__/validate-workflows.test.js @@ -210,10 +234,14 @@ repos: scripts/__tests__/quote-parser.test.js scripts/__tests__/validate-skills-llm-policy.test.js scripts/__tests__/update-llms-txt.test.js + scripts/__tests__/fix-md029-md051.test.js scripts/__tests__/validate-vscode-settings.test.js + scripts/__tests__/validate-pre-commit-tooling.test.js + scripts/__tests__/run-managed-jest.test.js + scripts/__tests__/verify-managed-jest-fallback.test.js language: system pass_filenames: false - files: '^(\.gitattributes|CONTRIBUTING\.md|\.vscode/settings\.json|\.github/workflows/llm-policy-check\.yml|scripts/check-eol\.ps1|scripts/(check-eol|fix-eol|validate-lychee-config|validate-skills|generate-skills-index|validate-workflows|update-llms-txt|validate-vscode-settings)\.js|scripts/lib/(quote-parser|eol-policy)\.js|scripts/__tests__/(check-eol|validate-lychee-config|validate-skills-required-fields|validate-skills-llm-policy|generate-skills-index|validate-workflows|quote-parser|update-llms-txt|validate-vscode-settings)\.test\.js)$' + files: '^(\.gitattributes|CONTRIBUTING\.md|\.vscode/settings\.json|\.github/workflows/(llm-policy-check|pre-commit-tooling-check)\.yml|scripts/check-eol\.ps1|scripts/(check-eol|fix-eol|fix-md029-md051|validate-lychee-config|validate-skills|generate-skills-index|validate-workflows|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|run-managed-jest|verify-managed-jest-fallback)\.js|scripts/lib/(quote-parser|eol-policy)\.js|scripts/__tests__/(check-eol|fix-md029-md051|validate-lychee-config|validate-skills-required-fields|validate-skills-llm-policy|generate-skills-index|validate-workflows|quote-parser|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|run-managed-jest|verify-managed-jest-fallback)\.test\.js)$' stages: - pre-commit description: Fail fast on parser and llms generator regressions (quote handling, frontmatter/TOML parsing, newline normalization) before push. @@ -231,7 +259,7 @@ repos: description: Validate GitHub Actions workflow syntax before push. - id: script-tests name: Run JavaScript script tests - entry: npm run test:scripts + entry: node scripts/run-managed-jest.js language: system pass_filenames: false files: '^(package\.json|package-lock\.json|scripts/.*\.js|scripts/__tests__/.*\.js)$' diff --git a/CHANGELOG.md b/CHANGELOG.md index 877eaa22..94126240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added npm scripts `update:llms-txt` and `check:llms-txt` for managing llms.txt - Added GitHub Actions workflows for automatic validation and updates of llms.txt - Added documentation about AI agent integration in README +- Inspector overlay now shows yesterday's analyzer 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 as `(cached from previous session — refreshing…)` until the first scan refreshes it. Eliminates the perceived flakiness where the warning sometimes appeared and sometimes didn't, depending on how fast the user clicked into the inspector after a domain reload. ## [2.2.0] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57faab4b..740abd69 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,11 +2,19 @@ Thanks for helping improve DxMessaging! -Before committing, please enable our git hooks and local linters so you catch issues early: +Before committing, please enable our git hooks and local linters so you catch issues early. +Run these steps in order: -- Install pre-commit: `pip install pre-commit` or `pipx install pre-commit` -- Install hooks: `pre-commit install` -- Run on all files: `pre-commit run --all-files` +1. Install Node dependencies: `npm install` +1. Install pre-commit: `pip install pre-commit` or `pipx install pre-commit` +1. Install hooks: `pre-commit install` +1. Run Node tooling preflight: `npm run preflight:pre-commit` (includes YAML formatting + yamllint checks) +1. Run on all files: `pre-commit run --all-files` + +`jest` does not need to be installed globally. Hooks and scripts route through `scripts/run-managed-jest.js` so they can use local devDependencies first, then a managed fallback when needed. + +Windows note: if you use `nvm` or `fnm`, run commits from a shell where Node is initialized (PowerShell or Git Bash) and verify `npm --version` before running hooks. +If you edit `.github/workflows/*.yml`, run `npm run preflight:pre-commit` in that same shell before `git commit`. Line endings: Git normalizes most text files to **LF** through `.gitattributes`. **Exception:** C#/.NET files (`.cs`, `.csproj`, `.sln`, `.props`) use CRLF per .NET conventions. Run this once after cloning (especially on Windows) to fix your working tree: @@ -45,7 +53,18 @@ Handy commands: - Format JSON/.asmdef (manual): `npx prettier@3.8.1 --write "**/*.{json,asmdef}"` - Format YAML (all files): `pre-commit run prettier-yaml --all-files` - Check YAML formatting + lint: `npm run check:yaml` +- Run yamllint hook directly: `pre-commit run yamllint --all-files` + +Prettier keeps YAML formatting consistent but does not automatically wrap long YAML lines. `yamllint` is the authoritative check for the 200-character YAML line-length rule. + +If `npm run check:yaml` reports a YAML line-length failure: + +1. For workflow `run:` commands, use folded scalars (`run: >-`) to split long commands across readable lines. +1. For non-command YAML values, break long strings into multiline YAML values where valid, or refactor the content so each line stays within 200 characters. + - Format C#: `dotnet tool restore && dotnet tool run csharpier format` +- Validate pre-commit Node tooling policy: `npm run validate:pre-commit-tooling` +- Run pre-commit Node preflight: `npm run preflight:pre-commit` - Validate NPM package: `npm run validate:npm-meta` ## NPM Package Validation diff --git a/Editor/Analyzers/BaseCallIlInspector.cs b/Editor/Analyzers/BaseCallIlInspector.cs new file mode 100644 index 00000000..2d7f14c4 --- /dev/null +++ b/Editor/Analyzers/BaseCallIlInspector.cs @@ -0,0 +1,276 @@ +// The Unity Editor assembly that hosts this file does not enable nullable annotations; the +// dotnet-test project that compiles a linked copy DOES (`enable`). Pin the +// nullable state per-file so behavior is identical in both compilation contexts. +#nullable disable +namespace DxMessaging.Editor.Analyzers +{ + using System; + using System.Reflection; + using System.Reflection.Emit; + + /// + /// Pure (Unity-API-free) IL inspector that decides whether a given 's + /// IL body invokes a parent's same-named method (i.e. the IL emit shape of base.X()). + /// Extracted from BaseCallTypeScanner so the dotnet-test project can cover the byte + /// walker without depending on Unity APIs. + /// + /// + /// + /// Why does this exist? The console-scrape harvester is non-deterministic across Unity + /// 2021 cache hits (Unity skips routing analyzer warnings to LogEntries / + /// CompilerMessage[] on incremental compiles where Bee/csc reused a cached output). + /// IL reflection over the loaded assemblies in the AppDomain is deterministic — the bytes do + /// not depend on Unity's compile-pipeline state. uses this + /// helper to classify every loaded MessageAwareComponent subclass on every domain + /// reload. + /// + /// + /// Proper OpCodes-table walker. The walker decodes every CIL instruction by looking up + /// its in the static tables built from reflection + /// (single-byte and two-byte 0xFE-prefix forms) and steps the operand-size that the opcode + /// declares (). Misalignment past multi-byte-operand opcodes + /// (switch jump tables, ldstr 4-byte tokens, 8-byte literal constants, etc.) is + /// therefore impossible — the walker either consumes every byte correctly or stops at the + /// first unrecognised opcode. Phantom DXMSG006 from a misread 0x28 inside a wider + /// operand is no longer a failure mode. + /// + /// + /// Defensive bias. When we cannot reason at all (null method, empty name, inaccessible + /// IL body, GetMethodBody() returns null on abstract / P/Invoke / IL2CPP-stripped + /// targets, or any reflection exception), the inspector returns true + /// ("assume clean — calls base") so the scanner never invents a phantom warning. The + /// compile-time analyzer is the authoritative source for CI builds (DXMSG006/007/009/010 via + /// full Roslyn semantic-model precision); the IL scanner exists only to make the editor + /// overlay light up at edit-time, where a missed warning is far worse than a phantom one. + /// + /// + public static class BaseCallIlInspector + { + // CIL opcode tables, indexed by the low byte of OpCode.Value. Built once by reflecting over + // System.Reflection.Emit.OpCodes — every public static OpCode field there represents a + // canonical CIL instruction. The two-byte form of the table is used when a 0xFE prefix is + // observed in the IL stream; otherwise we use the single-byte form. Because CIL specifies + // exactly two prefix bytes (single-byte = direct, two-byte = 0xFE prefix), this division + // covers every defined opcode. + private static readonly OpCode[] s_singleByteOps = BuildOpCodeTable(twoByte: false); + private static readonly OpCode[] s_twoByteOps = BuildOpCodeTable(twoByte: true); + + private static OpCode[] BuildOpCodeTable(bool twoByte) + { + OpCode[] table = new OpCode[256]; + foreach ( + FieldInfo field in typeof(OpCodes).GetFields( + BindingFlags.Public | BindingFlags.Static + ) + ) + { + if (field.GetValue(null) is not OpCode op) + { + continue; + } + ushort value = (ushort)op.Value; + bool isTwoByte = (value & 0xFF00) == 0xFE00; + bool isSingleByte = (value & 0xFF00) == 0; + if (twoByte && isTwoByte) + { + table[value & 0xFF] = op; + } + else if (!twoByte && isSingleByte) + { + table[value & 0xFF] = op; + } + } + return table; + } + + /// + /// Returns true if 's IL body contains a + /// call/callvirt to a parent type's same-named method. Defensive: + /// returns true (assume-clean) if the IL body is null/inaccessible to avoid + /// false-positive warnings on platforms or methods where reflection on bodies is + /// restricted (abstract methods, P/Invoke, IL2CPP-stripped bodies, etc.). + /// + /// The override on the descendant type whose IL we wish to inspect. + /// The expected base method name (e.g. "OnEnable"). + /// + /// true if the IL contains a base-call shape, OR the IL was inaccessible (safe + /// default — assume clean). false only when IL was readable AND no call/callvirt + /// targeting a parent same-named method was found. + /// + public static bool MethodIlContainsBaseCall(MethodInfo method, string methodName) + { + if (method == null || string.IsNullOrEmpty(methodName)) + { + // Defensive: treat as clean when we don't have enough information to reason. This + // ensures the scanner never emits a phantom warning on a degenerate input. + return true; + } + + try + { + MethodBody body = method.GetMethodBody(); + if (body == null) + { + // Abstract / extern / runtime-implemented / IL2CPP-stripped — cannot inspect. + return true; + } + byte[] il = body.GetILAsByteArray(); + if (il == null || il.Length == 0) + { + return true; + } + + Module module = method.Module; + Type[] genericTypeArgs = + method.DeclaringType?.IsGenericType == true + ? method.DeclaringType.GetGenericArguments() + : null; + Type[] genericMethodArgs = method.IsGenericMethod + ? method.GetGenericArguments() + : null; + + int i = 0; + while (i < il.Length) + { + OpCode op; + if (il[i] == 0xFE) + { + // Two-byte (0xFE-prefixed) opcode. Without a following byte we cannot + // decode the instruction — bail out conservatively. Truncated IL is not a + // shape Roslyn ever emits, so reaching this path means we mis-stepped and + // the safest answer is the assume-clean default. + if (i + 1 >= il.Length) + { + return true; + } + op = s_twoByteOps[il[i + 1]]; + i += 2; + } + else + { + op = s_singleByteOps[il[i]]; + i += 1; + } + + // Unrecognised opcode (zero-initialised slot in the table) — abandon the walk + // rather than risk the rest of the stream getting misread. Returning the + // assume-clean default keeps the scanner from inventing a phantom warning. + if (op.Size == 0) + { + return true; + } + + if (op == OpCodes.Call || op == OpCodes.Callvirt) + { + if (i + 4 > il.Length) + { + return true; + } + int token = BitConverter.ToInt32(il, i); + try + { + MethodBase target = module.ResolveMethod( + token, + genericTypeArgs, + genericMethodArgs + ); + if ( + target != null + && string.Equals(target.Name, methodName, StringComparison.Ordinal) + ) + { + Type declaring = method.DeclaringType; + Type resolved = target.DeclaringType; + // Guard against false-positives: the resolved method must live on a + // STRICT base type of the declaring class (not the declaring class + // itself, not a sibling, not a generic-arg shadow). IsAssignableFrom + // checks "is `declaring` assignable TO `resolved`" — i.e. is + // `resolved` an ancestor of `declaring`. + if ( + declaring != null + && resolved != null + && declaring != resolved + && resolved.IsAssignableFrom(declaring) + ) + { + return true; + } + } + } + catch + { + // ResolveMethod throws on tokens that don't bind in our generic-arg + // context (e.g. a MemberRef into a closed generic we can't resolve). + // The OpCodes-table walker means we can no longer land on a misaligned + // 0x28 inside a wider operand, so this catch only protects against + // legitimate-but-unbindable tokens — we swallow and continue scanning. + } + i += 4; + continue; + } + + // Step over the operand based on the opcode's declared operand type. Every + // CIL operand size is decided by OperandType, which is exactly why the table + // walker is misalignment-proof. + i += GetOperandSize(op, il, i); + } + return false; + } + catch + { + // Any reflection failure → assume clean. We never want the scanner itself to + // become the source of a phantom warning. + return true; + } + } + + // Returns the number of operand bytes that follow an opcode of the given OperandType, + // given the operand-start offset (needed for InlineSwitch's variable-length jump table). + private static int GetOperandSize(OpCode op, byte[] il, int operandStart) + { + switch (op.OperandType) + { + case OperandType.InlineNone: + return 0; + case OperandType.ShortInlineBrTarget: + case OperandType.ShortInlineI: + case OperandType.ShortInlineVar: + return 1; + case OperandType.InlineVar: + return 2; + case OperandType.InlineBrTarget: + case OperandType.InlineField: + case OperandType.InlineI: + case OperandType.InlineMethod: + case OperandType.InlineSig: + case OperandType.InlineString: + case OperandType.InlineTok: + case OperandType.InlineType: + case OperandType.ShortInlineR: + return 4; + case OperandType.InlineI8: + case OperandType.InlineR: + return 8; + case OperandType.InlineSwitch: + // 4-byte case count, then N × 4-byte branch targets. Truncated stream → bail + // by consuming the rest defensively (the outer loop's bounds check then ends + // the walk). + if (operandStart + 4 > il.Length) + { + return il.Length - operandStart; + } + int caseCount = BitConverter.ToInt32(il, operandStart); + if (caseCount < 0) + { + // Negative case-count is malformed IL; bail conservatively. + return il.Length - operandStart; + } + return 4 + caseCount * 4; + default: + // Unknown OperandType — bail conservatively by consuming the rest of the + // stream so the outer loop terminates without misaligning further. + return il.Length - operandStart; + } + } + } +} diff --git a/Editor/Analyzers/BaseCallIlInspector.cs.meta b/Editor/Analyzers/BaseCallIlInspector.cs.meta new file mode 100644 index 00000000..99ec5bcd --- /dev/null +++ b/Editor/Analyzers/BaseCallIlInspector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f713afea6aef44cab4d711a4f2d17d6e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Analyzers/BaseCallLogMessageParser.cs b/Editor/Analyzers/BaseCallLogMessageParser.cs new file mode 100644 index 00000000..58ee989b --- /dev/null +++ b/Editor/Analyzers/BaseCallLogMessageParser.cs @@ -0,0 +1,298 @@ +// The Unity Editor assembly that hosts this file does not enable nullable annotations; the +// dotnet-test project that compiles a linked copy DOES (`enable`). Pin the +// nullable state per-file so behavior is identical in both compilation contexts. +#nullable disable +namespace DxMessaging.Editor.Analyzers +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using System.Text.RegularExpressions; + + /// + /// Single parsed diagnostic line emitted by the MessageAwareComponentBaseCallAnalyzer. + /// + /// + /// Empty is the documented sentinel for DXMSG008 (which carries no + /// method name in its message format). Empty / zero + /// indicate the bare (Unity-prefix-less) form of the message. + /// + public readonly struct ParsedEntry + { + public string DiagnosticId { get; } + + public string TypeFullName { get; } + + public string MethodName { get; } + + public string FilePath { get; } + + public int Line { get; } + + public ParsedEntry( + string diagnosticId, + string typeFullName, + string methodName, + string filePath, + int line + ) + { + DiagnosticId = diagnosticId; + TypeFullName = typeFullName; + MethodName = methodName; + FilePath = filePath; + Line = line; + } + } + + /// + /// Per-type aggregate produced by . + /// + /// + /// is deduplicated and ordered by first-occurrence (insertion order + /// of the underlying ). uses ordinal comparison. + /// / hold the FIRST seen non-empty values so the + /// inspector overlay's "Open Script" jump remains stable across repeated parses. + /// + public sealed class ParsedTypeReport + { + public string TypeFullName { get; set; } + + public HashSet MissingBaseFor { get; } = new(StringComparer.Ordinal); + + public HashSet DiagnosticIds { get; } = new(StringComparer.Ordinal); + + public string FilePath { get; set; } + + public int Line { get; set; } + } + + /// + /// Pure parser for the DXMSG006/DXMSG007/DXMSG008/DXMSG009/DXMSG010 console-log lines emitted + /// by the MessageAwareComponentBaseCallAnalyzer. + /// + /// + /// + /// This class lives in the Editor assembly so the harvester can call it directly without + /// referencing the Roslyn-analyzer DLL (Unity excludes RoslynAnalyzer-labelled + /// assemblies from asmdef compile-time references). + /// + /// + /// The analyzer-tests project compiles its own copy of this file via a + /// <Compile Include="...\BaseCallLogMessageParser.cs" Link="..." /> so the + /// existing dotnet-test coverage continues to run. + /// + /// + /// The regexes are pinned to the analyzer's verbatim message-format strings. Whenever those + /// formats change, both this parser AND BaseCallLogMessageParserTests must be updated + /// in lockstep. + /// + /// + public static class BaseCallLogMessageParser + { + // Roslyn / Unity-style location prefix: path(line,col): warning DXMSG006: + // We don't anchor to the diagnostic id here beyond the leading "DXMSG" — that lets the + // same prefix regex serve all five diagnostics (DXMSG006/007/008/009/010). The trailing + // `: ` is consumed so the diagnostic-specific regexes only see the message body. + private const RegexOptions SharedOptions = + RegexOptions.Compiled | RegexOptions.CultureInvariant; + + private static readonly Regex PrefixRegex = new( + @"^(?[^()\r\n]+?)\((?\d+),(?\d+)\)\s*:\s*(?:warning|error|info|hidden|message)\s+DXMSG0(?:0[6789]|10)\s*:\s*", + SharedOptions + ); + + // DXMSG006 format: + // '{type}' overrides MessageAwareComponent.{method} but does not call base.{method}(); + // the messaging system may not function correctly on this component. + // The type name is captured from the first single-quoted token; the method from the first + // `MessageAwareComponent.{method}` occurrence (the format string repeats `{method}`). + // Body regexes anchor to the start of the body (^) so a Debug.Log payload that *contains* + // the analyzer's wording mid-string is not surfaced as a real DXMSG006/007/008. The prefix + // (when present) is stripped before this match, so ^ here is the start of the message body. + private static readonly Regex Dxmsg006Regex = new( + @"^'(?[^']+)'\s+overrides\s+MessageAwareComponent\.(?[A-Za-z_][A-Za-z0-9_]*)\s+but\s+does\s+not\s+call\s+base\.[A-Za-z_][A-Za-z0-9_]*\(\)\s*;\s*the\s+messaging\s+system\s+may\s+not\s+function\s+correctly\s+on\s+this\s+component\.", + SharedOptions + ); + + // DXMSG007 format: + // '{type}' hides MessageAwareComponent.{method} with 'new'; replace with 'override' and + // call base.{method}() so the messaging system continues to function. + private static readonly Regex Dxmsg007Regex = new( + @"^'(?[^']+)'\s+hides\s+MessageAwareComponent\.(?[A-Za-z_][A-Za-z0-9_]*)\s+with\s+'new'\s*;\s*replace\s+with\s+'override'\s+and\s+call\s+base\.[A-Za-z_][A-Za-z0-9_]*\(\)\s+so\s+the\s+messaging\s+system\s+continues\s+to\s+function\.", + SharedOptions + ); + + // DXMSG008 format: + // '{type}' is excluded from the DxMessaging base-call check ({source}). + // No method name in the message — MethodName is returned as the empty string. + private static readonly Regex Dxmsg008Regex = new( + @"^'(?[^']+)'\s+is\s+excluded\s+from\s+the\s+DxMessaging\s+base-call\s+check\s+\([^)]*\)\.", + SharedOptions + ); + + // DXMSG009 format: + // '{type}' declares {method} without 'override' or 'new'; this implicitly hides + // MessageAwareComponent.{method} (CS0114) and the messaging system will not function. ... + // We anchor on the head of the message and stop after the modifier-tokens phrase so future + // wording tweaks to the trailing remediation text don't break the parser. + private static readonly Regex Dxmsg009Regex = new( + @"^'(?[^']+)'\s+declares\s+(?[A-Za-z_][A-Za-z0-9_]*)\s+without\s+'override'\s+or\s+'new'", + SharedOptions + ); + + // DXMSG010 format: + // '{type}' calls base.{method}() but the inherited override on '{broken}' does not + // chain to MessageAwareComponent.{method}; the messaging system will not function + // correctly on this component. + // We capture {type} (the class the user is editing), {method}, and the broken-ancestor + // FQN so the inspector overlay can mention "broken chain via {broken}" if desired. + private static readonly Regex Dxmsg010Regex = new( + @"^'(?[^']+)'\s+calls\s+base\.(?[A-Za-z_][A-Za-z0-9_]*)\(\)\s+but\s+the\s+inherited\s+override\s+on\s+'(?[^']+)'", + SharedOptions + ); + + /// + /// Parses one console log line. Returns null if the line is not a recognised + /// DXMSG006/DXMSG007/DXMSG008/DXMSG009/DXMSG010 message. + /// + /// + /// Tolerates both the Roslyn-prefixed form (Path(L,C): warning DXMSG006: ...) and the + /// bare form (just the message body). The prefix's path/line are captured; the bare form + /// returns empty path / zero line. + /// + public static ParsedEntry? ParseLine(string logLine) + { + if (string.IsNullOrWhiteSpace(logLine)) + { + return null; + } + + string filePath = string.Empty; + int line = 0; + string body = logLine; + + Match prefixMatch = PrefixRegex.Match(logLine); + if (prefixMatch.Success) + { + filePath = prefixMatch.Groups["path"].Value; + if ( + !int.TryParse( + prefixMatch.Groups["line"].Value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out line + ) + ) + { + line = 0; + } + body = logLine.Substring(prefixMatch.Length); + } + + Match dxmsg006 = Dxmsg006Regex.Match(body); + if (dxmsg006.Success) + { + string type = dxmsg006.Groups["type"].Value; + string method = dxmsg006.Groups["method"].Value; + if (!string.IsNullOrWhiteSpace(type) && !string.IsNullOrWhiteSpace(method)) + { + return new ParsedEntry("DXMSG006", type, method, filePath, line); + } + } + + Match dxmsg007 = Dxmsg007Regex.Match(body); + if (dxmsg007.Success) + { + string type = dxmsg007.Groups["type"].Value; + string method = dxmsg007.Groups["method"].Value; + if (!string.IsNullOrWhiteSpace(type) && !string.IsNullOrWhiteSpace(method)) + { + return new ParsedEntry("DXMSG007", type, method, filePath, line); + } + } + + Match dxmsg008 = Dxmsg008Regex.Match(body); + if (dxmsg008.Success) + { + string type = dxmsg008.Groups["type"].Value; + if (!string.IsNullOrWhiteSpace(type)) + { + return new ParsedEntry("DXMSG008", type, string.Empty, filePath, line); + } + } + + Match dxmsg009 = Dxmsg009Regex.Match(body); + if (dxmsg009.Success) + { + string type = dxmsg009.Groups["type"].Value; + string method = dxmsg009.Groups["method"].Value; + if (!string.IsNullOrWhiteSpace(type) && !string.IsNullOrWhiteSpace(method)) + { + return new ParsedEntry("DXMSG009", type, method, filePath, line); + } + } + + Match dxmsg010 = Dxmsg010Regex.Match(body); + if (dxmsg010.Success) + { + string type = dxmsg010.Groups["type"].Value; + string method = dxmsg010.Groups["method"].Value; + if (!string.IsNullOrWhiteSpace(type) && !string.IsNullOrWhiteSpace(method)) + { + return new ParsedEntry("DXMSG010", type, method, filePath, line); + } + } + + return null; + } + + /// + /// Aggregates many log lines into a per-type report keyed by FQN. Deduplicates methods and + /// diagnostic ids ordinally; preserves first-occurrence for + /// / . + /// + public static Dictionary Aggregate(IEnumerable logLines) + { + Dictionary result = new(StringComparer.Ordinal); + if (logLines == null) + { + return result; + } + + foreach (string logLine in logLines) + { + ParsedEntry? parsed = ParseLine(logLine); + if (parsed is not ParsedEntry entry) + { + continue; + } + + if (!result.TryGetValue(entry.TypeFullName, out ParsedTypeReport report)) + { + report = new ParsedTypeReport { TypeFullName = entry.TypeFullName }; + result[entry.TypeFullName] = report; + } + + report.DiagnosticIds.Add(entry.DiagnosticId); + + if ( + !string.IsNullOrEmpty(entry.MethodName) + && !report.MissingBaseFor.Contains(entry.MethodName, StringComparer.Ordinal) + ) + { + report.MissingBaseFor.Add(entry.MethodName); + } + + if (string.IsNullOrEmpty(report.FilePath) && !string.IsNullOrEmpty(entry.FilePath)) + { + report.FilePath = entry.FilePath; + report.Line = entry.Line; + } + } + + return result; + } + } +} diff --git a/Editor/Analyzers/BaseCallLogMessageParser.cs.meta b/Editor/Analyzers/BaseCallLogMessageParser.cs.meta new file mode 100644 index 00000000..9396908f --- /dev/null +++ b/Editor/Analyzers/BaseCallLogMessageParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a17c13ebe9604cee9bfa08d2517a3fad +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Analyzers/BaseCallReportAggregator.cs b/Editor/Analyzers/BaseCallReportAggregator.cs new file mode 100644 index 00000000..4bac9e47 --- /dev/null +++ b/Editor/Analyzers/BaseCallReportAggregator.cs @@ -0,0 +1,310 @@ +// The Unity Editor assembly that hosts this file does not enable nullable annotations; the +// dotnet-test project that compiles a linked copy DOES (`enable`). Pin the +// nullable state per-file so behavior is identical in both compilation contexts. +#nullable disable +namespace DxMessaging.Editor.Analyzers +{ + using System; + using System.Collections.Generic; + + /// + /// Pure-BCL data-transfer object for an aggregated per-FQN report row. Mirrors the public + /// shape of but uses property-style PascalCase fields so it + /// does not depend on Unity's JsonUtility serialization conventions and is safely + /// constructible from dotnet test. + /// + /// + /// The harvester wraps every DTO in a (with the lowercase + /// Unity-serialisable field names) before the snapshot crosses into Editor code. Keep this + /// shape in lock-step with that wrapper — fields added here must also flow through to the + /// Unity-facing entry or the inspector overlay won't see them. + /// + public sealed class BaseCallReportEntryDto + { + /// Fully-qualified name of the offending type (dot-form for nested types). + public string TypeName; + + /// Method names whose overrides are missing the corresponding base.*() call. + public List MissingBaseFor = new(); + + /// Diagnostic IDs that contributed to this entry (e.g., DXMSG006/007/008/009). + public HashSet DiagnosticIds = new(StringComparer.Ordinal); + + /// Source file path (best-effort) for "Open Script" actions in the inspector overlay. + public string FilePath; + + /// 1-based line number of the first relevant diagnostic, when known. + public int Line; + } + + /// + /// Pure (Unity-API-free) aggregator for the per-assembly merge + retirement logic at the heart + /// of . Extracted so the merge contract is covered by + /// dotnet test via the linked-source pattern (see + /// WallstopStudios.DxMessaging.SourceGenerators.Tests.csproj). + /// + /// + /// + /// The harvester must own two pieces of cross-call state to do its job correctly: + /// + /// + /// typesByAssembly: which FQNs each compiled assembly has reported. + /// When an assembly recompiles WITHOUT reporting a previously-seen FQN, that FQN is retired + /// — the user fixed the offending base call. + /// mergedReports: the per-FQN union of every assembly's latest + /// report. The final snapshot merges this with whatever the LogEntries scan yielded. + /// + /// + /// Both maps mutate in lock-step inside . Because retirement + /// crosses assembly boundaries (an FQN may live in two assemblies; only when neither still + /// reports it does it disappear from mergedReports), the bookkeeping is the most + /// failure-prone slice of the harvester. Keeping it pure makes it test-driven. + /// + /// + public static class BaseCallReportAggregator + { + /// + /// Apply a single assembly's freshly-parsed report batch. Replaces that assembly's prior + /// FQN attribution wholesale (so types the user fixed disappear) and rebuilds the merged + /// per-FQN map by unioning every assembly's latest reports. + /// + /// Stable identifier for the source assembly (typically the + /// assembly path Unity passes via CompilationPipeline.assemblyCompilationFinished). + /// Reports parsed from this assembly's most recent + /// compilation. May be empty — that case is the retirement path (every FQN this assembly + /// previously reported is dropped). + /// Per-assembly FQN bookkeeping. Mutated in place. + /// Per-FQN union across every assembly. Mutated in place; + /// rebuilt from scratch on every call so retirement Just Works regardless of which + /// assembly drops its claim. + public static void ApplyAssemblyReports( + string assemblyKey, + IReadOnlyDictionary latestReportsForAssembly, + Dictionary> typesByAssembly, + Dictionary mergedReports + ) + { + if (string.IsNullOrEmpty(assemblyKey)) + { + throw new ArgumentException("Assembly key must be non-empty.", nameof(assemblyKey)); + } + if (typesByAssembly is null) + { + throw new ArgumentNullException(nameof(typesByAssembly)); + } + if (mergedReports is null) + { + throw new ArgumentNullException(nameof(mergedReports)); + } + + // 1. Replace this assembly's FQN set with the latest batch. Types absent from the new + // batch are dropped from the assembly's row — that's the per-assembly retirement. + if (!typesByAssembly.TryGetValue(assemblyKey, out HashSet typeSet)) + { + typeSet = new HashSet(StringComparer.Ordinal); + typesByAssembly[assemblyKey] = typeSet; + } + typeSet.Clear(); + if (latestReportsForAssembly is not null) + { + foreach (string fqn in latestReportsForAssembly.Keys) + { + if (!string.IsNullOrEmpty(fqn)) + { + typeSet.Add(fqn); + } + } + } + + // 2. Rebuild mergedReports from the per-assembly view. We can't simply remove "the + // types this assembly retired" because another assembly may still report them; the + // only correct algorithm is to start fresh from typesByAssembly + the freshest + // payload for each (assembly, FQN) pair. + // + // Per-FQN merge semantics: + // - Method list: union, deduplicated ordinally, first-seen order preserved. + // - Diagnostic IDs: union via HashSet. + // - File path / line: first non-empty wins (stable across recompiles). + Dictionary rebuilt = new(StringComparer.Ordinal); + foreach (KeyValuePair> assemblyEntry in typesByAssembly) + { + string thisAssemblyKey = assemblyEntry.Key; + HashSet fqns = assemblyEntry.Value; + if (fqns is null || fqns.Count == 0) + { + continue; + } + + // For the assembly we just updated, prefer the freshly-parsed payload. For other + // assemblies, we need the previous merge to still carry their data — but that + // information is only retrievable from the OUTGOING mergedReports, so we read it + // before clearing. + IReadOnlyDictionary source = string.Equals( + thisAssemblyKey, + assemblyKey, + StringComparison.OrdinalIgnoreCase + ) + ? latestReportsForAssembly + : mergedReports; + if (source is null) + { + continue; + } + + foreach (string fqn in fqns) + { + if (!source.TryGetValue(fqn, out ParsedTypeReport contribution)) + { + continue; + } + if (contribution is null) + { + continue; + } + + if (!rebuilt.TryGetValue(fqn, out ParsedTypeReport existing)) + { + // Defensive copy so future ApplyAssemblyReports calls don't mutate state + // that callers may still hold a reference to. + existing = new ParsedTypeReport + { + TypeFullName = contribution.TypeFullName, + FilePath = contribution.FilePath, + Line = contribution.Line, + }; + foreach (string method in contribution.MissingBaseFor) + { + if (!string.IsNullOrEmpty(method)) + { + existing.MissingBaseFor.Add(method); + } + } + foreach (string id in contribution.DiagnosticIds) + { + existing.DiagnosticIds.Add(id); + } + rebuilt[fqn] = existing; + continue; + } + + foreach (string method in contribution.MissingBaseFor) + { + if (!string.IsNullOrEmpty(method)) + { + existing.MissingBaseFor.Add(method); + } + } + foreach (string id in contribution.DiagnosticIds) + { + existing.DiagnosticIds.Add(id); + } + if ( + string.IsNullOrEmpty(existing.FilePath) + && !string.IsNullOrEmpty(contribution.FilePath) + ) + { + existing.FilePath = contribution.FilePath; + existing.Line = contribution.Line; + } + } + } + + mergedReports.Clear(); + foreach (KeyValuePair kvp in rebuilt) + { + mergedReports[kvp.Key] = kvp.Value; + } + } + + /// + /// Builds the final flat snapshot for the inspector overlay by unioning the LogEntries + /// scan with the per-assembly merged reports. Both inputs are read-only; the result is a + /// fresh dictionary the caller owns. + /// + /// Reports harvested from UnityEditor.LogEntries. + /// May be empty (Unity 2021 path) or null (LogEntries reflection unavailable). + /// Per-FQN merged view of every assembly's latest reports + /// produced by . May be empty when no compilation + /// callbacks have fired yet. + public static Dictionary BuildSnapshot( + IReadOnlyDictionary logEntriesReports, + IReadOnlyDictionary mergedReports + ) + { + Dictionary snapshot = new(StringComparer.Ordinal); + + if (logEntriesReports is not null) + { + foreach (KeyValuePair kvp in logEntriesReports) + { + AddOrMerge(snapshot, kvp.Key, kvp.Value); + } + } + + if (mergedReports is not null) + { + foreach (KeyValuePair kvp in mergedReports) + { + AddOrMerge(snapshot, kvp.Key, kvp.Value); + } + } + + return snapshot; + } + + private static void AddOrMerge( + Dictionary snapshot, + string fqn, + ParsedTypeReport report + ) + { + if (string.IsNullOrEmpty(fqn) || report is null) + { + return; + } + + if (!snapshot.TryGetValue(fqn, out BaseCallReportEntryDto existing)) + { + existing = new BaseCallReportEntryDto { TypeName = fqn }; + snapshot[fqn] = existing; + } + if (string.IsNullOrEmpty(existing.TypeName)) + { + existing.TypeName = fqn; + } + + foreach (string method in report.MissingBaseFor) + { + if (!string.IsNullOrEmpty(method) && !existing.MissingBaseFor.Contains(method)) + { + // Dedupe across the dual-source merge: LogEntries and CompilerMessage may + // both surface the same `.` pair on Unity 2022+, where both + // pipes are wired. Keeping MissingBaseFor a List (rather than a + // HashSet) preserves first-seen order for stable HelpBox output. + existing.MissingBaseFor.Add(method); + } + } + + foreach (string id in report.DiagnosticIds) + { + if (!string.IsNullOrEmpty(id) && !existing.DiagnosticIds.Contains(id)) + { + // Mirror MissingBaseFor's dedup. Even though DiagnosticIds is a HashSet today, + // an explicit Contains check keeps the merge contract stable against future + // shape changes (List would silently start producing duplicate ids + // without this guard). The dual-source merge — LogEntries + CompilerMessage on + // Unity 2022+ — is the path that exercises this branch in practice. + existing.DiagnosticIds.Add(id); + } + } + + // First seen file/line wins so "Open Script" jumps to a stable location across + // rebuilds — which is what the user's eye lands on first in the console. + if (string.IsNullOrEmpty(existing.FilePath) && !string.IsNullOrEmpty(report.FilePath)) + { + existing.FilePath = report.FilePath; + existing.Line = report.Line; + } + } + } +} diff --git a/Editor/Analyzers/BaseCallReportAggregator.cs.meta b/Editor/Analyzers/BaseCallReportAggregator.cs.meta new file mode 100644 index 00000000..0c722006 --- /dev/null +++ b/Editor/Analyzers/BaseCallReportAggregator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f94fb4275c724821995abe36ce51046a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Analyzers/BaseCallTypeScanner.cs b/Editor/Analyzers/BaseCallTypeScanner.cs new file mode 100644 index 00000000..7ae876c1 --- /dev/null +++ b/Editor/Analyzers/BaseCallTypeScanner.cs @@ -0,0 +1,110 @@ +namespace DxMessaging.Editor.Analyzers +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using DxMessaging.Editor.Settings; + using DxMessaging.Unity; + using UnityEditor; + + /// + /// Edit-time scanner that walks loaded subclasses via + /// and forwards them to the pure (Unity-API-free) classification + /// helper . Replaces the lossy console-scraping bridge + /// as the inspector overlay's primary data source. + /// + /// + /// + /// Why IL reflection? Unity's CompilationPipeline.assemblyCompilationFinished and + /// the LogEntries console store are both downstream of Unity's decision to actually + /// surface analyzer warnings. On Bee/csc cache hits — which happen on most domain reloads + /// after the first — Unity skips that surface entirely, so the scrape returns nothing even + /// though the analyzer ran successfully on the first compile. By contrast, IL reflection over + /// loaded types is deterministic: the assemblies are in the AppDomain, the methods have IL + /// bodies, the same scan produces the same result on every reload regardless of whether the + /// build was a fresh compile or a Bee cache hit. + /// + /// + /// What it detects: + /// + /// DXMSG006 — overrides one of the five guarded methods but the IL body + /// lacks a call/callvirt to the parent's same-named method. + /// DXMSG007 — declares the method with the new modifier (IL: name + /// shadows a base virtual but the descendant method itself is not in an override slot). + /// DXMSG009 — declares the method without override or new — same IL shape + /// as DXMSG007 (both compile to a non-virtual hide-by-sig method). The scanner cannot + /// distinguish the two perfectly from IL alone, so it conservatively classifies this case as + /// DXMSG007. The compile-time analyzer is authoritative for the precise ID classification; + /// the scanner's job is just to make sure the inspector overlay lights up. + /// DXMSG010 — overrides correctly (calls base) but an intermediate + /// ancestor's override in the chain does NOT call base. Walks parent-by-parent and re-runs the + /// IL check at every link until the chain terminates at + /// or hits a broken link. + /// + /// + /// + /// Cross-assembly assume-clean: ancestors whose IL is unavailable + /// ( returns null — e.g., abstract or + /// extern methods) are trusted. Emitting an unactionable warning against a closed-source + /// third-party library would be hostile. + /// + /// + /// Implementation split: the Unity-coupled work ( lookup, + /// read, conversion to the Unity-serializable + /// ) lives here; everything else (chain walk, IL probe, + /// FQN normalisation, opt-out handling) lives in so the + /// dotnet-test project can cover the classification logic via Roslyn-compiled fixtures. + /// + /// + internal static class BaseCallTypeScanner + { + /// + /// Scan all loaded subclasses and return a per-type + /// report keyed by fully-qualified type name. The report shape matches what the + /// console-bridge produced, so the inspector overlay code path needs no changes. + /// + /// + /// Types opted out via [DxIgnoreMissingBaseCall] or via the project's ignored-types + /// list are intentionally NOT included in the returned dictionary — the overlay reads the + /// project ignore list directly to render its "Stop ignoring" HelpBox, and the snapshot + /// semantics here match the bridge path (DXMSG008-equivalent rows were never present in + /// the snapshot's missingBaseFor either). + /// + internal static Dictionary Scan(DxMessagingSettings settings) + { + // TypeCache is Unity's domain-reload-cached type lookup. Effectively O(1) after the + // first call and survives across reloads via Unity's serialization layer. Using + // TypeCache (rather than scanning every loaded assembly via AppDomain) is important + // for performance — a fresh project can have hundreds of assemblies loaded. + TypeCache.TypeCollection candidates = + TypeCache.GetTypesDerivedFrom(); + + // Defensive: TypeCache.GetTypesDerivedFrom() returns strict subclasses, but + // belt-and-braces in case a future Unity version changes the contract — we feed the + // list through Core.Scan which itself skips MessageAwareComponent by FQN match. + // The Core handles abstract / generic-definition / null-FQN skipping uniformly. + Dictionary coreResult = + BaseCallTypeScannerCore.Scan( + candidates, + settings != null ? settings._baseCallIgnoredTypes : null + ); + + Dictionary result = new(StringComparer.Ordinal); + foreach (KeyValuePair kvp in coreResult) + { + BaseCallTypeScannerCore.ScanEntry core = kvp.Value; + BaseCallReportEntry entry = new() + { + typeName = core.TypeName, + missingBaseFor = new List(core.MissingBaseFor), + diagnosticIds = new List(core.DiagnosticIds), + filePath = string.Empty, + line = 0, + }; + result[kvp.Key] = entry; + } + return result; + } + } +#endif +} diff --git a/Editor/Analyzers/BaseCallTypeScanner.cs.meta b/Editor/Analyzers/BaseCallTypeScanner.cs.meta new file mode 100644 index 00000000..95444b12 --- /dev/null +++ b/Editor/Analyzers/BaseCallTypeScanner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 58c96078f9224e208d973216c43884a9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Analyzers/BaseCallTypeScannerCore.cs b/Editor/Analyzers/BaseCallTypeScannerCore.cs new file mode 100644 index 00000000..ce2207cd --- /dev/null +++ b/Editor/Analyzers/BaseCallTypeScannerCore.cs @@ -0,0 +1,390 @@ +// The Unity Editor assembly that hosts this file does not enable nullable annotations; the +// dotnet-test project that compiles a linked copy DOES (`enable`). Pin the +// nullable state per-file so behavior is identical in both compilation contexts. +#nullable disable +namespace DxMessaging.Editor.Analyzers +{ + using System; + using System.Collections.Generic; + using System.Reflection; + + /// + /// Pure (Unity-API-free) classification core for the IL-reflection scanner. Takes a + /// pre-supplied set of candidate -derived types and + /// produces the per-FQN snapshot that the inspector overlay consumes. + /// + /// + /// + /// Extracted from so the dotnet-test project can cover the + /// classification logic (chain walk, opt-out paths, master-toggle gating, FQN normalisation, + /// abstract / generic-definition skipping) without depending on Unity's TypeCache API. + /// The Unity-only wrapper simply forwards + /// TypeCache.GetTypesDerivedFrom<MessageAwareComponent>() as the candidate set + /// and reads the project's ignore list off DxMessagingSettings. + /// + /// + /// All inputs are pure-BCL: tests compile small Roslyn fixtures, load the resulting + /// assemblies, enumerate assembly.GetTypes().Where(t => ...) as the candidate set, + /// and assert against the returned snapshot. + /// + /// + /// Diagnostic IDs produced match what the inspector overlay reads from the Unity-facing entry: + /// DXMSG006 (override missing base call), DXMSG007 (hides via new; also + /// covers DXMSG009 since IL alone can't distinguish the two — see remarks on + /// ), and DXMSG010 (override calls base but a chain + /// link does not). + /// + /// + 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. + /// + public static readonly string[] GuardedMethodNames = + { + "Awake", + "OnEnable", + "OnDisable", + "OnDestroy", + "RegisterMessageHandlers", + }; + + private const string IgnoreAttributeFullName = + "DxMessaging.Core.Attributes.DxIgnoreMissingBaseCallAttribute"; + + /// + /// Result row produced by . Mirrors the Unity-facing + /// BaseCallReportEntry shape but uses pure BCL collections so the helper is + /// callable from dotnet test. + /// + public sealed class ScanEntry + { + /// Fully-qualified name of the offending type (dot-form for nested types). + public string TypeName; + + /// Method names whose overrides are missing the corresponding base.*() call. + public List MissingBaseFor = new(); + + /// Diagnostic IDs that contributed to this entry (DXMSG006 / DXMSG007 / DXMSG010). + public List DiagnosticIds = new(); + } + + /// + /// Classify every type and return a per-FQN snapshot keyed + /// by fully-qualified type name (dot-form for nested types). Types opted out via + /// [DxIgnoreMissingBaseCall] or via are + /// intentionally NOT included in the returned dictionary — the inspector overlay reads + /// the project ignore list directly to render its "Stop ignoring" HelpBox, and the + /// snapshot semantics here match the bridge path (DXMSG008-equivalent rows were never + /// present in the snapshot's missingBaseFor either). + /// + /// + /// Strict subclasses of MessageAwareComponent. Abstract types and generic-type + /// definitions are skipped; the MessageAwareComponent type itself is also skipped. + /// May contain null entries (defensively skipped). + /// + /// + /// Project-level ignore list (typically the parsed contents of + /// Assets/Editor/DxMessaging.BaseCallIgnore.txt, surfaced through + /// DxMessagingSettings._baseCallIgnoredTypes). May be null. + /// + public static Dictionary Scan( + IEnumerable candidates, + IEnumerable ignoredTypeNames + ) + { + Dictionary result = new(StringComparer.Ordinal); + if (candidates is null) + { + return result; + } + + HashSet projectIgnore = ignoredTypeNames is null + ? new HashSet(StringComparer.Ordinal) + : new HashSet(ignoredTypeNames, StringComparer.Ordinal); + + foreach (Type concrete in candidates) + { + if (concrete == null) + { + continue; + } + if (concrete.IsAbstract) + { + continue; + } + if (concrete.IsGenericTypeDefinition) + { + continue; + } + + string fullName = concrete.FullName ?? string.Empty; + if (string.IsNullOrEmpty(fullName)) + { + continue; + } + // FullName for nested types uses '+'; the analyzer (and the inspector overlay's + // lookup) emits the dotted form. Normalise here so the scanner-produced snapshot + // is keyed identically to the analyzer's identifiers. + fullName = fullName.Replace('+', '.'); + + bool optedOutByAttribute = TypeOrAncestorHasIgnoreAttribute(concrete); + bool optedOutByList = projectIgnore.Contains(fullName); + + ScanEntry entry = ScanOne(concrete, fullName); + if (entry == null || entry.MissingBaseFor.Count == 0) + { + continue; + } + + if (optedOutByAttribute || optedOutByList) + { + // Suppression makes the entry an audit-marker (DXMSG008-equivalent). The + // overlay's "ignored" branch handles this via the ignored-types list directly, + // so we don't add it to the snapshot at all — the overlay reads the project + // list to render the "Stop ignoring" HelpBox. This matches the bridge path's + // snapshot semantics (DXMSG008 was never in MissingBaseFor either). + continue; + } + + result[fullName] = entry; + } + + return result; + } + + private static bool TypeOrAncestorHasIgnoreAttribute(Type type) + { + // [DxIgnoreMissingBaseCall] applies with Inherited=false (matches the analyzer's + // attribute declaration), so we only walk the type itself plus its declared methods. + foreach (object attr in type.GetCustomAttributes(inherit: false)) + { + if (attr.GetType().FullName == IgnoreAttributeFullName) + { + return true; + } + } + // Method-level: any of the five guarded methods marked with the attribute also opts + // the entire type out from the inspector overlay (the analyzer applies the attribute + // per-method, but the overlay tracks types — opt out at the granularity we render). + foreach (string methodName in GuardedMethodNames) + { + MethodInfo m = type.GetMethod( + methodName, + BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Instance + | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + ); + if (m == null) + { + continue; + } + foreach (object attr in m.GetCustomAttributes(inherit: false)) + { + if (attr.GetType().FullName == IgnoreAttributeFullName) + { + return true; + } + } + } + return false; + } + + private static ScanEntry ScanOne(Type concrete, string fullName) + { + ScanEntry entry = new() + { + TypeName = fullName, + MissingBaseFor = new List(), + DiagnosticIds = new List(), + }; + + foreach (string methodName in GuardedMethodNames) + { + ClassifyMethod(concrete, methodName, entry); + } + + return entry; + } + + private static void ClassifyMethod(Type concrete, string methodName, ScanEntry entry) + { + // Walk the type chain: first the leaf (concrete), then ancestors via BaseType until we + // leave the MessageAwareComponent inheritance subtree. For the leaf we determine which + // of DXMSG006/007/009 fires (if any). If the leaf overrides correctly, we walk + // ancestor links to detect DXMSG010 (a broken intermediate). Each link's diagnosis is + // 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); + if (declared == null) + { + // Type does not declare this method at all — nothing to flag at this level. + return; + } + if (!declared.ReturnType.Equals(typeof(void))) + { + return; + } + if (declared.IsStatic) + { + return; + } + if (declared.IsGenericMethodDefinition) + { + return; + } + + // DXMSG009 vs DXMSG007: declares without override (or with `new`); hides the base. + // In IL/reflection terms: the method does NOT have the override slot binding + // (GetBaseDefinition() returns the method itself) AND the base type has a same-named + // virtual we are hiding. The C# compiler emits the same IL for `new void X()` and + // `void X()`-with-CS0114, so we cannot perfectly distinguish DXMSG007 from DXMSG009 + // from IL alone. The compile-time analyzer is authoritative for the precise ID; + // here we conservatively classify the case as DXMSG007 — both produce the same + // overlay outcome (method listed in HelpBox). + bool isOverride = declared.GetBaseDefinition() != declared; + bool hasNewKeyword = + !isOverride && BaseHasSameNamedVirtual(concrete.BaseType, methodName); + + if (!isOverride) + { + if (hasNewKeyword) + { + AddIfMissing(entry, methodName, "DXMSG007"); + } + // else: not an override, no base virtual to hide — not our concern. + return; + } + + // It IS an override. Check IL for base call. + bool callsBase = BaseCallIlInspector.MethodIlContainsBaseCall(declared, methodName); + if (!callsBase) + { + AddIfMissing(entry, methodName, "DXMSG006"); + return; + } + + // Leaf calls base. Walk the inheritance chain to look for a broken intermediate + // (DXMSG010). Each link's IL is inspected independently; the first broken link found + // produces DXMSG010 on the leaf and we stop. Cross-assembly ancestors with no IL body + // are trusted (assume-clean) — the alternative would be unactionable warnings against + // closed-source code. + MethodInfo cursorOverridden = GetOverriddenMethod(declared); + HashSet visited = new(); + while (cursorOverridden != null && visited.Add(cursorOverridden)) + { + // Chain reached MessageAwareComponent itself — clean. We compare by full type + // name so the helper does not need a hard reference to the Unity-only type. + Type cursorDeclaring = cursorOverridden.DeclaringType; + if ( + cursorDeclaring != null + && cursorDeclaring.FullName == "DxMessaging.Unity.MessageAwareComponent" + ) + { + return; + } + if (cursorOverridden.GetMethodBody() == null) + { + // Cross-assembly / abstract — assume clean (cannot inspect). + return; + } + bool ancestorCallsBase = BaseCallIlInspector.MethodIlContainsBaseCall( + cursorOverridden, + methodName + ); + if (!ancestorCallsBase) + { + AddIfMissing(entry, methodName, "DXMSG010"); + return; + } + cursorOverridden = GetOverriddenMethod(cursorOverridden); + } + } + + private static MethodInfo GetDeclaredZeroArgInstance(Type type, string methodName) + { + return type.GetMethod( + methodName, + BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Instance + | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + ); + } + + private static bool BaseHasSameNamedVirtual(Type baseType, string methodName) + { + while (baseType != null && baseType != typeof(object)) + { + MethodInfo m = baseType.GetMethod( + methodName, + BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Instance + | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + ); + if (m != null && (m.IsVirtual || m.IsAbstract)) + { + return true; + } + baseType = baseType.BaseType; + } + return false; + } + + private static MethodInfo GetOverriddenMethod(MethodInfo derivedOverride) + { + // For an override, GetBaseDefinition() returns the most-base virtual (the originating + // declaration). To walk the chain link-by-link we need the closest ancestor that + // declares the same-named method directly — we look up each BaseType in turn and + // return the first match. This skips intermediate types that don't override the slot + // (e.g. a generic intermediate that just passes through), which is exactly what the + // chain walk needs to detect DXMSG010 at the broken link rather than the pass-through. + Type baseType = derivedOverride.DeclaringType?.BaseType; + while (baseType != null && baseType != typeof(object)) + { + MethodInfo m = baseType.GetMethod( + derivedOverride.Name, + BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Instance + | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + ); + if (m != null) + { + return m; + } + baseType = baseType.BaseType; + } + return null; + } + + private static void AddIfMissing(ScanEntry entry, string methodName, string diagnosticId) + { + if (!entry.MissingBaseFor.Contains(methodName)) + { + entry.MissingBaseFor.Add(methodName); + } + if (!entry.DiagnosticIds.Contains(diagnosticId)) + { + entry.DiagnosticIds.Add(diagnosticId); + } + } + } +} diff --git a/Editor/Analyzers/BaseCallTypeScannerCore.cs.meta b/Editor/Analyzers/BaseCallTypeScannerCore.cs.meta new file mode 100644 index 00000000..ebada4e5 --- /dev/null +++ b/Editor/Analyzers/BaseCallTypeScannerCore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0ee81bf7d5b5447c94730f4185e64942 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Analyzers/DxMessagingConsoleHarvester.cs b/Editor/Analyzers/DxMessagingConsoleHarvester.cs new file mode 100644 index 00000000..0ba394ef --- /dev/null +++ b/Editor/Analyzers/DxMessagingConsoleHarvester.cs @@ -0,0 +1,1122 @@ +namespace DxMessaging.Editor.Analyzers +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using System.Globalization; + using System.IO; + using System.Linq; + using System.Reflection; + using DxMessaging.Editor.Settings; + using UnityEditor; + using UnityEditor.Compilation; + using UnityEngine; + + /// + /// Per-type entry recorded by the inspector overlay's data feed. + /// + /// + /// This shape is the public contract that MessageAwareComponentInspectorOverlay + /// consumes; the field names are kept short and lower-cased so Unity's + /// serializer round-trips them cleanly through the JSON cache. + /// + [Serializable] + public sealed class BaseCallReportEntry + { + /// Fully-qualified name of the offending type. + public string typeName; + + /// Method names whose overrides are missing the corresponding base.*() call. + public List missingBaseFor = new(); + + /// Diagnostic IDs that contributed to this entry (e.g., DXMSG006, DXMSG007, DXMSG009, DXMSG010). + /// + /// Note: the IL-reflection scanner classifies DXMSG009 as DXMSG007 because the two are + /// indistinguishable at the IL level. The compile-time analyzer remains authoritative for + /// the precise ID classification — see the analyzer reference docs and the inspector + /// integration section of docs/reference/analyzers.md. DXMSG008 (audit-marker for + /// opted-out types) is intentionally NOT included here: opted-out types are excluded from + /// the snapshot so the overlay's "Stop ignoring" path can reason about them via the + /// project ignore list directly. + /// + public List diagnosticIds = new(); + + /// Source file path (best-effort) for "Open Script" actions in the inspector overlay. + public string filePath; + + /// 1-based line number of the first relevant diagnostic, when known. + public int line; + } + + [Serializable] + internal sealed class BaseCallReportFile + { + public int version = 1; + public string generatedAt; + public List types = new(); + } + + /// + /// Builds the per-FQN snapshot consumed by the inspector overlay from a deterministic IL + /// reflection scanner () — and, optionally, a legacy + /// console-scrape bridge for users who want the union of both data sources. + /// + /// + /// + /// Primary source (always-on): . Walks loaded + /// MessageAwareComponent subclasses via Unity's TypeCache and inspects each + /// override's IL body for the base-call shape. Deterministic across Unity 2021 cache hits, + /// incremental compiles, and arbitrary domain-reload sequences — the only inputs are the + /// loaded assemblies in the AppDomain, which do not depend on Unity's compile-pipeline + /// state. Runs on every and on every + /// CompilationPipeline.assemblyCompilationFinished burst (debounced via + /// ). + /// + /// + /// Secondary source (opt-in): legacy console-scrape bridge. When + /// is true, the harvester ALSO + /// reads warnings from UnityEditor.LogEntries via reflection and from + /// CompilationPipeline.assemblyCompilationFinished's per-assembly + /// CompilerMessage[] payloads. This path is non-deterministic on Unity 2021 (Bee/csc + /// cache hits cause Unity to skip surfacing analyzer warnings to either store) and is the + /// reason the IL-reflection scanner exists. Default off; available for users who want the + /// union of both data sources. + /// + /// + /// The inspector overlay reads its snapshot from the unified per-FQN map populated here on + /// every rescan. Use the menu Tools → DxMessaging → Rescan Base-Call Warnings for a + /// manual force-rescan. + /// + /// + /// stays true as long as the static constructor itself does + /// not throw — the IL scanner is always wired, so the overlay never falls back to its + /// degraded "harvester unavailable" HelpBox in normal operation. + /// continues to report whether the legacy reflection layer is bindable, for diagnostics only. + /// + /// + [InitializeOnLoad] + public static class DxMessagingConsoleHarvester + { + private const string ReportFileName = "baseCallReport.json"; + private const string ReportDirectoryName = "DxMessaging"; + private const double PollIntervalSeconds = 0.25; + + // N1: cap the per-rescan list capacity so a 100k-warning console doesn't allocate a + // pathologically large initial backing array. The list still grows freely if the console + // really does hold more entries than this, but the OOM-edge becomes a non-issue. + private const int MaxLineListInitialCapacity = 1024; + + private static readonly Dictionary SnapshotInternal = new( + StringComparer.Ordinal + ); + + // Per-assembly attribution for the LEGACY CompilationPipeline.assemblyCompilationFinished + // feed (only consulted when DxMessagingSettings.UseConsoleBridge is true). When a + // recompile no longer reports a previously-seen type (because the user fixed the missing + // base call), we drop it from the bridge merged view. Without per-assembly tracking we'd + // never know which entries to retire. + // + // Lifecycle: writes happen inside _compilationFeedLock from + // OnAssemblyCompilationFinished. Reads happen on the editor main thread inside RescanNow, + // also under the lock to flush + clear the channel atomically. + // + // The merge + retirement bookkeeping for these maps lives in + // as a pure helper so it can be tested via + // dotnet-test (the harvester itself is Unity-only and cannot be loaded outside the + // editor). Mutations to _typesByAssembly and _compilationMerged go through that helper + // exclusively to keep the test surface and runtime behaviour identical. + // + // Note: starting in v2.3, the IL-reflection scanner (BaseCallTypeScanner) is the primary + // source of truth — it runs unconditionally on every rescan, regardless of bridge state. + // The bridge only contributes ADDITIONAL data, never overrides the scanner. + private static readonly Dictionary> _typesByAssembly = new( + StringComparer.OrdinalIgnoreCase + ); + + // Per-FQN merged view of every assembly's latest reports, kept in sync with + // _typesByAssembly by BaseCallReportAggregator.ApplyAssemblyReports. The final inspector + // snapshot is built by unioning this with the LogEntries-derived report. + private static readonly Dictionary _compilationMerged = new( + StringComparer.Ordinal + ); + + private static readonly HashSet AlreadyWarned = new(StringComparer.Ordinal); + + // Lock guarding all reads/writes to _typesByAssembly and the parsed-message buffer that + // flows from OnAssemblyCompilationFinished (worker thread for the parse) into + // DrainScheduledRescan (editor main thread for snapshot integration). Unity can fire + // assemblyCompilationFinished from a non-main thread on some Editor versions; the rest + // of the harvester (LogEntries reflection, AssetDatabase, persistence) is main-thread + // only and uses simple read order, so the lock is scoped to the cross-thread channel. + private static readonly object _compilationFeedLock = new(); + + // Drained by DrainScheduledRescan on the next editor tick. Holds the union of all + // CompilerMessage payloads captured since the last drain, attributed to their source + // assembly so we can retire entries that the user has fixed. + private static readonly Dictionary< + string, + Dictionary + > _pendingByAssembly = new(StringComparer.OrdinalIgnoreCase); + + // True when the LogEntries reflection layer failed to bind. The harvester remains + // available via the CompilerMessage path; this flag just gates the LogEntries-specific + // code paths (Tick polling, RescanNow's reflection call). Renamed from `_disabled` so + // the name reflects what it actually means. + private static readonly bool _logEntriesDisabled; + + // Reflection handles. Resolved once in the static ctor; null when the running Unity version + // does not expose the expected LogEntries shape. + private static readonly Type _logEntryType; + private static readonly MethodInfo _startGettingEntries; + private static readonly MethodInfo _endGettingEntries; + private static readonly MethodInfo _getEntryInternal; + private static readonly MethodInfo _getCount; + private static readonly FieldInfo _messageField; + + private static double _lastTickTime; + private static int _lastSeenCount; + + // Latch flipped on by `OnAssemblyCompilationFinished` to coalesce the burst of one-event- + // per-assembly callbacks Unity fires during a build. We schedule a single deferred + // RescanNow via `EditorApplication.delayCall` (DrainScheduledRescan) and clear the latch + // when that callback runs. Without this debounce, a 30-assembly project would queue 30 + // RescanNow invocations during the very window when the editor is most fragile. + private static volatile bool _rescanScheduled; + + // Tracks whether the current snapshot has been refreshed by a scan in THIS Editor session, + // or whether it was loaded eagerly from `Library/DxMessaging/baseCallReport.json` in the + // static ctor and has not yet been overwritten. The inspector overlay reads this to + // distinguish "fresh-this-session" warnings from cached-from-previous-session warnings — + // when the cache is showing, we annotate the HelpBox with a small suffix so the user + // understands the data may be stale until the first post-reload scan completes. + // + // Default `false`: the static ctor's `LoadFromDisk` runs first, so by the time anything + // observes the snapshot, either (a) the cache populated entries that pre-date this session, + // or (b) the cache was empty (truly fresh). In case (b) the overlay renders no warning + // anyway — there are no entries to annotate — so the false default is correct for both. + // Flipped to `true` after the first successful `RescanNow` post-startup; never flipped + // back to `false`. Volatile so the editor-loop reader sees the write without a memory + // barrier on Unity's pre-2022 mono runtime. + private static volatile bool _isFreshThisSession; + + /// + /// Direct read of the latest console-derived report by FQN. Returns true if an + /// entry exists for the given fully-qualified type name. The + /// reference points at the live snapshot row — callers must not mutate it. + /// + /// + /// All mutation happens on the main thread inside ; the inspector + /// overlay (also main thread) reads via this method one-call-per-frame-per-component, so + /// there is no race that would justify the per-access defensive copy that + /// performs. Prefer this method in hot paths. + /// + public static bool TryGetEntry(string fullyQualifiedTypeName, out BaseCallReportEntry entry) + { + if (string.IsNullOrEmpty(fullyQualifiedTypeName)) + { + entry = null; + return false; + } + return SnapshotInternal.TryGetValue(fullyQualifiedTypeName, out entry); + } + + /// + /// Read-only snapshot of the latest console-derived report, keyed by FQN. + /// + /// + /// Each access returns a fresh dictionary copy. Prefer in hot + /// paths (the inspector overlay) — this property exists for callers that need to enumerate + /// the full snapshot. + /// + public static IReadOnlyDictionary Snapshot => + new Dictionary(SnapshotInternal, StringComparer.Ordinal); + + /// + /// true as long as the harvester has at least one functioning data source. + /// + /// + /// The CompilationPipeline.assemblyCompilationFinished feed is wired + /// unconditionally on every supported Unity version, so this property is effectively + /// always true in normal operation; it only flips to false when the static + /// constructor itself throws (a hard initialization failure). The LogEntries reflection + /// layer is the optional source — see for that flag. + /// The inspector overlay reads this property to decide whether to render its degraded + /// HelpBox, so the contract here is "should the overlay attempt to render at all". + /// + public static bool IsAvailable { get; private set; } = true; + + /// + /// true when the legacy UnityEditor.LogEntries reflection layer resolved + /// successfully on this Unity version. The harvester does not require this to be true to + /// function — Unity 2021's analyzer warnings flow through the CompilerMessage feed + /// instead. Exposed primarily for diagnostics / tests. + /// + public static bool LogEntriesAvailable => !_logEntriesDisabled; + + /// + /// true once the first of this Editor session has produced + /// a fresh snapshot; false while the inspector is still showing the on-disk cache + /// loaded eagerly by the static constructor. + /// + /// + /// The inspector overlay reads this to annotate its HelpBox: when false AND a + /// warning is being shown, the overlay appends a "(cached from previous session — + /// refreshing…)" suffix so the user knows the data is from yesterday's scan and a fresh + /// one is in flight. The flag is set inside and never reset, so + /// the suffix disappears as soon as the first post-reload scan lands and stays gone for + /// the rest of the session. + /// + public static bool IsFreshThisSession => _isFreshThisSession; + + /// Raised whenever the snapshot changes (post-compile, post-domain-reload, or polled console-count change). + public static event Action ReportUpdated; + + static DxMessagingConsoleHarvester() + { + try + { + Type logEntriesType = + Type.GetType("UnityEditor.LogEntries,UnityEditor.dll") + // S9: legacy / future-Unity probe. UnityEditorInternal.LogEntries doesn't + // exist today, but documenting the fallback as a one-liner keeps us forward- + // compatible at zero cost. + ?? Type.GetType("UnityEditorInternal.LogEntries,UnityEditor.dll"); + _logEntryType = + Type.GetType("UnityEditor.LogEntry,UnityEditor.dll") + ?? Type.GetType("UnityEditorInternal.LogEntry,UnityEditor.dll"); + + bool logEntriesBound = false; + if (logEntriesType is not null && _logEntryType is not null) + { + if (_logEntryType.IsValueType) + { + // S8: defensive value-type guard. If a future Unity version makes LogEntry + // a struct, Activator.CreateInstance would hand us a boxed copy and the + // GetEntry call would mutate that copy in-place — harvest would silently + // report empty. Disable the LogEntries path rather than silently producing + // a wrong result; the CompilerMessage feed still runs. + LogOnce( + "logentry-is-struct", + "LogEntry is a value type on this Unity version; LogEntries scanning disabled. Falling back to the CompilerMessage feed." + ); + } + else + { + _startGettingEntries = SafeGetStaticMethod( + logEntriesType, + "StartGettingEntries" + ); + _endGettingEntries = SafeGetStaticMethod( + logEntriesType, + "EndGettingEntries" + ); + _getEntryInternal = SafeGetStaticMethod(logEntriesType, "GetEntryInternal"); + _getCount = SafeGetStaticMethod(logEntriesType, "GetCount"); + _messageField = SafeGetInstanceField(_logEntryType, "message"); + + logEntriesBound = + _startGettingEntries is not null + && _endGettingEntries is not null + && _getEntryInternal is not null + && _getCount is not null + && _messageField is not null; + } + } + + if (!logEntriesBound) + { + // The LogEntries reflection layer is unavailable. The IL-reflection scanner + // is the primary data source so this is no longer a critical path; the + // log-once is kept for diagnostic purposes (and only matters when the user + // has enabled the legacy bridge via DxMessagingSettings.UseConsoleBridge). + LogOnce( + "reflection-fallback", + "LogEntries reflection unavailable on this Unity version. The IL-reflection " + + "scanner remains the primary data source; the legacy console-scrape bridge " + + "(opt-in via DxMessagingSettings.UseConsoleBridge) cannot read LogEntries on this version." + ); + _logEntriesDisabled = true; + } + + LoadFromDisk(); + + // AssetDatabase isn't fully ready inside the static ctor — defer the first scan one + // editor tick so settings load doesn't fight a transitional asset-import state. + EditorApplication.delayCall += SafeRescanFromCallback; + AssemblyReloadEvents.afterAssemblyReload += SafeRescanFromCallback; + CompilationPipeline.assemblyCompilationFinished += OnAssemblyCompilationFinished; + if (!_logEntriesDisabled) + { + EditorApplication.update += Tick; + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"[DxMessaging] DxMessagingConsoleHarvester failed to initialize: {ex.Message}" + ); + _logEntriesDisabled = true; + IsAvailable = false; + } + } + + /// + /// Force a re-read of the editor console and drain any pending CompilerMessage payloads. + /// Called automatically on domain reload and compilation events; the menu entry exposes it + /// for manual invocation. Settings setters (e.g. + /// ) call this via + /// so a re-enable repopulates the snapshot + /// without waiting for the next polled tick. + /// + [MenuItem("Tools/DxMessaging/Rescan Base-Call Warnings")] + public static void RescanNow() + { + if (!IsAvailable) + { + return; + } + + // Critical: NEVER touch LogEntries reflection or AssetDatabase while Unity is mid- + // compile or mid-asset-update. Reading LogEntries during compilation contends with the + // compiler's own log-buffer lock and can deadlock the editor. Touching AssetDatabase + // (via TryLoadSettings → GetOrCreateSettings → CreateAsset) during compilation + // schedules an import that re-triggers compilation — an infinite-loop trap that + // permanently freezes script-compilation startup. Defer to the post-compile state + // and let the polled tick (or the explicit afterAssemblyReload hook) pick it up. + if (EditorApplication.isCompiling || EditorApplication.isUpdating) + { + return; + } + + DxMessagingSettings settings = TryLoadSettings(); + if (settings != null && !settings._baseCallCheckEnabled) + { + bool wasNonEmpty = SnapshotInternal.Count > 0; + SnapshotInternal.Clear(); + // S3: keep the per-assembly bookkeeping (_typesByAssembly + _compilationMerged) + // in lock-step. Clearing only one half leaves stale rows that the next + // ApplyAssemblyReports call would silently re-promote into the snapshot when the + // user toggles the master switch back on without an intervening recompile. + _typesByAssembly.Clear(); + _compilationMerged.Clear(); + lock (_compilationFeedLock) + { + _pendingByAssembly.Clear(); + } + _lastSeenCount = 0; + PersistToDisk(); + // The "check disabled" path still represents a successful session-time decision + // about the snapshot — flip the freshness flag so the overlay never lingers in + // "cached from previous session" mode after the user has explicitly silenced the + // check. Doing this BEFORE RaiseReportUpdated mirrors the main path's ordering. + _isFreshThisSession = true; + if (wasNonEmpty) + { + RaiseReportUpdated(); + } + return; + } + + // -- Primary source (always-on): IL-reflection scanner over loaded + // MessageAwareComponent subclasses. Deterministic across Unity 2021 cache hits and + // incremental compiles; replaces the lossy console-scrape harvester as the + // inspector overlay's source of truth. + Dictionary scannerEntries; + try + { + scannerEntries = BaseCallTypeScanner.Scan(settings); + } + catch (Exception ex) + { + LogOnce("scanner", $"BaseCallTypeScanner.Scan threw: {ex.Message}"); + scannerEntries = new Dictionary( + StringComparer.Ordinal + ); + } + + // The scanner produces a complete view of all loaded subclasses on every call, so it + // fully replaces the snapshot. Build the new map up-front from the scanner's output; + // we'll union the legacy-bridge entries into it below if the user opted in. + Dictionary nextSnapshot = new( + scannerEntries, + StringComparer.Ordinal + ); + + bool useBridge = settings != null && settings._useConsoleBridge; + + int currentCount = 0; + bool logEntriesHarvested = false; + if (useBridge) + { + // -- Secondary source (opt-in): LogEntries reflection (Unity 2022+ reliable path). + Dictionary logEntriesAggregate = HarvestFromLogEntries( + out currentCount, + out logEntriesHarvested + ); + + // -- Secondary source (opt-in): pending CompilerMessage payloads (Unity 2021's + // primary path under the legacy bridge). Drain the cross-thread channel + // atomically. + Dictionary> drained; + lock (_compilationFeedLock) + { + if (_pendingByAssembly.Count == 0) + { + drained = null; + } + else + { + drained = new Dictionary>( + _pendingByAssembly, + StringComparer.OrdinalIgnoreCase + ); + _pendingByAssembly.Clear(); + } + } + + ApplyCompilerMessageDrain(drained); + + // Merge the bridge view (LogEntries + CompilerMessage) and union it INTO the + // scanner-produced snapshot. The scanner is authoritative; the bridge can only + // ADD methods/diagnostic ids it sees that the scanner missed (e.g. exotic IL + // shapes the byte walker stepped past). Bridge entries never override the + // scanner's classification. + try + { + Dictionary bridgeSnapshot = + BaseCallReportAggregator.BuildSnapshot( + logEntriesHarvested ? logEntriesAggregate : null, + _compilationMerged + ); + UnionBridgeIntoSnapshot(bridgeSnapshot, nextSnapshot); + } + catch (Exception ex) + { + LogOnce("aggregate", $"Snapshot merge failed: {ex.Message}"); + // Fall through with the scanner-only snapshot; partial data is better than + // wiping the snapshot when the bridge half misbehaves. + } + } + else + { + // Bridge is disabled: drop any pending CompilerMessage entries the harvester may + // have buffered (they would otherwise leak into the snapshot the next time the + // user toggles the bridge on). The bridge bookkeeping is reset below as well. + lock (_compilationFeedLock) + { + _pendingByAssembly.Clear(); + } + _typesByAssembly.Clear(); + _compilationMerged.Clear(); + } + + // Replace the live snapshot with the new view in one swap. The scanner runs over ALL + // loaded types every time, so this is a full-replace — types the user has fixed since + // the last scan disappear, types newly broken appear. + SnapshotInternal.Clear(); + foreach (KeyValuePair kvp in nextSnapshot) + { + SnapshotInternal[kvp.Key] = kvp.Value; + } + + if (useBridge && logEntriesHarvested) + { + _lastSeenCount = currentCount; + } + PersistToDisk(); + // Mark the snapshot as session-fresh AFTER the persist + before the event fires, so + // that any subscriber repainting the inspector observes the same "fresh" state the + // overlay will see on its next read. Subsequent scans are no-ops on this flag. + _isFreshThisSession = true; + RaiseReportUpdated(); + } + + // Unions the bridge-produced DTOs into the scanner-produced snapshot. The scanner is the + // authoritative source — the bridge can only contribute methods / diagnostic ids the + // scanner missed for a type, OR a brand-new type entry the scanner did not produce (e.g. + // a subclass the scanner couldn't classify because its IL was stripped). The first non- + // empty file path / line wins, matching the bridge's pre-existing semantics. + private static void UnionBridgeIntoSnapshot( + Dictionary bridgeSnapshot, + Dictionary scannerSnapshot + ) + { + if (bridgeSnapshot is null || bridgeSnapshot.Count == 0) + { + return; + } + foreach (KeyValuePair kvp in bridgeSnapshot) + { + BaseCallReportEntryDto dto = kvp.Value; + if (dto is null || string.IsNullOrEmpty(dto.TypeName)) + { + continue; + } + if (!scannerSnapshot.TryGetValue(dto.TypeName, out BaseCallReportEntry existing)) + { + existing = new BaseCallReportEntry + { + typeName = dto.TypeName, + missingBaseFor = new List(dto.MissingBaseFor), + diagnosticIds = dto.DiagnosticIds.ToList(), + filePath = dto.FilePath ?? string.Empty, + line = dto.Line, + }; + scannerSnapshot[dto.TypeName] = existing; + continue; + } + foreach (string method in dto.MissingBaseFor) + { + if ( + !string.IsNullOrEmpty(method) + && !existing.missingBaseFor.Contains(method, StringComparer.Ordinal) + ) + { + existing.missingBaseFor.Add(method); + } + } + foreach (string id in dto.DiagnosticIds) + { + if ( + !string.IsNullOrEmpty(id) + && !existing.diagnosticIds.Contains(id, StringComparer.Ordinal) + ) + { + existing.diagnosticIds.Add(id); + } + } + if (string.IsNullOrEmpty(existing.filePath) && !string.IsNullOrEmpty(dto.FilePath)) + { + existing.filePath = dto.FilePath; + existing.line = dto.Line; + } + } + } + + // Reads the editor console via LogEntries reflection. Returns the aggregated per-type + // report, the current console count, and whether the harvest actually ran (false when + // the LogEntries reflection layer is unavailable or threw). On Unity 2021 this returns + // an empty aggregate every time — the analyzer warnings flow through the CompilerMessage + // feed instead and arrive via ApplyCompilerMessageDrain. + private static Dictionary HarvestFromLogEntries( + out int currentCount, + out bool harvested + ) + { + currentCount = 0; + harvested = false; + if (_logEntriesDisabled) + { + return new Dictionary(StringComparer.Ordinal); + } + + try + { + currentCount = (int)_getCount.Invoke(null, null); + } + catch (Exception ex) + { + LogOnce("getcount", $"GetCount invocation failed: {ex.Message}"); + return new Dictionary(StringComparer.Ordinal); + } + + // S4: console-clear handling. We always overwrite _lastSeenCount near the bottom of + // RescanNow, so the only point of acting on a shrunken count here is to be explicit + // about the semantic. The accumulator is rebuilt from scratch every rescan, so the + // clear case is naturally consistent — even an empty log produces an empty aggregate + // and a ReportUpdated fire that drops stale rows. + + // B2 + S6: enter the get/end pair only AFTER StartGettingEntries actually succeeded. + try + { + _startGettingEntries.Invoke(null, null); + } + catch (Exception ex) + { + LogOnce("start", $"StartGettingEntries invocation failed: {ex.Message}"); + return new Dictionary(StringComparer.Ordinal); + } + + // N1: clamp the initial capacity to a sane ceiling. The list is allowed to grow past + // this if the console really does hold more entries; we just don't blow up the heap on + // first allocation. + List lines = new(Math.Min(currentCount, MaxLineListInitialCapacity)); + int harvestedCount = currentCount; + try + { + if (_startGettingEntries.ReturnType == typeof(int)) + { + // The Invoke return value is intentionally discarded; we re-pull via GetCount + // because the polled count is authoritative. + try + { + harvestedCount = (int)_getCount.Invoke(null, null); + } + catch + { + // Retain the previous count. + } + } + + object entryInstance = Activator.CreateInstance(_logEntryType); + object[] invokeArgs = new object[2]; + invokeArgs[1] = entryInstance; + for (int j = 0; j < harvestedCount; j++) + { + invokeArgs[0] = j; + try + { + _getEntryInternal.Invoke(null, invokeArgs); + } + catch (Exception ex) + { + LogOnce( + "getentry", + $"GetEntryInternal invocation failed at index {j}: {ex.Message}" + ); + continue; + } + + string message; + try + { + message = _messageField.GetValue(entryInstance) as string; + } + catch (Exception ex) + { + LogOnce( + "getmessage", + $"LogEntry.message read failed at index {j}: {ex.Message}" + ); + continue; + } + + if (!string.IsNullOrEmpty(message)) + { + lines.Add(message); + } + } + } + catch (Exception ex) + { + LogOnce("harvest", $"Harvest loop failed: {ex.Message}"); + } + finally + { + try + { + _endGettingEntries.Invoke(null, null); + } + catch (Exception ex) + { + LogOnce("end", $"EndGettingEntries invocation failed: {ex.Message}"); + } + } + + harvested = true; + try + { + return BaseCallLogMessageParser.Aggregate(lines); + } + catch (Exception ex) + { + LogOnce( + "aggregate-logentries", + $"Aggregating LogEntries lines failed: {ex.Message}" + ); + return new Dictionary(StringComparer.Ordinal); + } + } + + // Folds a freshly-drained per-assembly batch into the long-lived per-assembly bookkeeping + // via . The aggregator owns the + // retirement logic (a type the user fixed disappears as soon as the assembly recompiles + // without re-reporting it) and the cross-assembly survival rule (a type stays in the + // merged view as long as ANY assembly still reports it). + private static void ApplyCompilerMessageDrain( + Dictionary> drained + ) + { + if (drained is null || drained.Count == 0) + { + return; + } + + foreach (KeyValuePair> kvp in drained) + { + BaseCallReportAggregator.ApplyAssemblyReports( + kvp.Key, + kvp.Value ?? new Dictionary(StringComparer.Ordinal), + _typesByAssembly, + _compilationMerged + ); + } + } + + /// + /// Hint the harvester that something external changed (e.g., a settings toggle) and the + /// next polled tick should treat the console as fresh. Cheaper than a synchronous + /// when the caller is on a thread / context that may not be safe + /// to do reflection from. + /// + public static void RequestRescan() + { + if (!IsAvailable) + { + return; + } + // Setting _lastSeenCount to a sentinel forces the next Tick to see a count delta and + // call RescanNow on the editor's update thread (when LogEntries is wired). When + // LogEntries is unavailable, Tick is not registered, so we fall back to delayCall. + if (_logEntriesDisabled) + { + if (!_rescanScheduled) + { + _rescanScheduled = true; + EditorApplication.delayCall += DrainScheduledRescan; + } + return; + } + _lastSeenCount = -1; + } + + private static void Tick() + { + // Tick is only registered when the LogEntries reflection layer is available, so we + // do NOT need to re-check _logEntriesDisabled here — but the IsAvailable guard + // protects against a future failure mode where IsAvailable is flipped to false at + // runtime. + if (!IsAvailable) + { + return; + } + + // Defensive belt: never reflect into LogEntries while a compile or asset-import is + // running. Even though RescanNow() itself bails on this state, we don't want to even + // call GetCount() — the lock contention is the source of the freeze, and GetCount + // touches the same buffer. + if (EditorApplication.isCompiling || EditorApplication.isUpdating) + { + return; + } + + try + { + double now = EditorApplication.timeSinceStartup; + if (now - _lastTickTime < PollIntervalSeconds) + { + return; + } + _lastTickTime = now; + + int currentCount; + try + { + currentCount = (int)_getCount.Invoke(null, null); + } + catch (Exception ex) + { + LogOnce("tick-count", $"GetCount during Tick failed: {ex.Message}"); + return; + } + + if (currentCount != _lastSeenCount) + { + RescanNow(); + } + } + catch (Exception ex) + { + LogOnce("tick", $"Tick failed: {ex.Message}"); + } + } + + private static void OnAssemblyCompilationFinished( + string assemblyPath, + CompilerMessage[] messages + ) + { + // CRITICAL: this fires for EVERY assembly compiled (10s of times per build). Running + // RescanNow synchronously here invokes LogEntries reflection while OTHER assemblies + // are still compiling — the compiler holds its log-buffer lock and our reflection + // call blocks waiting for it. Combined with AssetDatabase touches inside RescanNow, + // this caused permanent script-compilation freezes on Unity startup. + // + // S4: when the legacy console-bridge is OFF, we don't need to parse CompilerMessage + // payloads at all — the IL-reflection scanner is the sole data source and it runs + // off the AssemblyReloadEvents.afterAssemblyReload hook that fires once per build, + // not per-assembly. Bail out early so a 30-assembly build doesn't burn CPU running + // the regex-heavy parser 30 times for output we'll never read. Read the setting once + // up-front so the gate decision is consistent for the whole callback (settings can + // be edited concurrently by the Project Settings page on the main thread). + DxMessagingSettings settingsForGate = TryLoadSettings(); + bool bridgeEnabled = settingsForGate != null && settingsForGate._useConsoleBridge; + if (!bridgeEnabled) + { + return; + } + + // We DO parse the per-assembly CompilerMessage payload here (cheap, pure-CPU work, + // no AssetDatabase / LogEntries contact) and stash it in the cross-thread channel. + // DrainScheduledRescan (on a delayCall) folds the channel into the live snapshot + // once the compile burst is complete. This is the primary data path on Unity 2021, + // where Roslyn-analyzer warnings DO arrive in CompilerMessage[] but do NOT reliably + // appear in the LogEntries store. + try + { + if (!string.IsNullOrEmpty(assemblyPath) && messages != null) + { + List lines = null; + foreach (CompilerMessage compilerMessage in messages) + { + string body = compilerMessage.message; + if (string.IsNullOrEmpty(body)) + { + continue; + } + // Quick prefilter so we don't parse every CS0123 in the build. The + // analyzer always emits "DXMSG00" inside the diagnostic id. + if (body.IndexOf("DXMSG00", StringComparison.Ordinal) < 0) + { + continue; + } + lines ??= new List(); + lines.Add(body); + } + + Dictionary aggregated = lines is null + ? new Dictionary(StringComparer.Ordinal) + : BaseCallLogMessageParser.Aggregate(lines); + + lock (_compilationFeedLock) + { + // Even when this assembly produced zero matching messages, we still want + // an empty entry so DrainScheduledRescan can RETIRE the assembly's prior + // attribution (the user fixed every offending type in this assembly). + _pendingByAssembly[assemblyPath] = aggregated; + } + } + } + catch (Exception ex) + { + LogOnce( + "compilation-parse", + $"Failed to parse CompilerMessage payload for {assemblyPath}: {ex.Message}" + ); + } + + // Fix: schedule a single delayCall. delayCall fires AFTER the current event chain + // unwinds and AFTER `EditorApplication.isCompiling` flips back to false. Multiple + // delayCall registrations from the same compile burst are debounced by the + // _rescanScheduled latch — only one deferred RescanNow runs per build. + if (_rescanScheduled) + { + return; + } + _rescanScheduled = true; + EditorApplication.delayCall += DrainScheduledRescan; + } + + private static void DrainScheduledRescan() + { + _rescanScheduled = false; + // delayCall can fire while still mid-compile if the editor is in a weird state. + // RescanNow has its own isCompiling/isUpdating guard — re-defer if needed. + if (EditorApplication.isCompiling || EditorApplication.isUpdating) + { + if (!_rescanScheduled) + { + _rescanScheduled = true; + EditorApplication.delayCall += DrainScheduledRescan; + } + return; + } + SafeRescanFromCallback(); + } + + private static void SafeRescanFromCallback() + { + try + { + RescanNow(); + } + catch (Exception ex) + { + LogOnce("rescan-callback", $"RescanNow callback threw: {ex.Message}"); + } + } + + private static DxMessagingSettings TryLoadSettings() + { + // CRITICAL: passive load only. We must NOT call GetOrCreateSettings here — that path + // can call AssetDatabase.CreateAsset, which during script compilation schedules an + // import → re-triggers compilation → permanent freeze. The Project Settings page and + // the inspector overlay both call GetOrCreateSettings on demand (outside compilation), + // so the asset is materialised through normal user interaction. If the asset doesn't + // exist yet (fresh project, first compile), the harvester treats the snapshot as + // unconfigured and behaves as if the master toggle is enabled (default behaviour). + try + { + string[] guids = AssetDatabase.FindAssets($"t:{nameof(DxMessagingSettings)}"); + if (guids == null || guids.Length == 0) + { + return null; + } + string assetPath = AssetDatabase.GUIDToAssetPath(guids[0]); + if (string.IsNullOrEmpty(assetPath)) + { + return null; + } + return AssetDatabase.LoadAssetAtPath(assetPath); + } + catch (Exception ex) + { + LogOnce("settings", $"Could not load DxMessagingSettings: {ex.Message}"); + return null; + } + } + + private static MethodInfo SafeGetStaticMethod(Type type, string name) + { + try + { + return type.GetMethod(name, BindingFlags.Public | BindingFlags.Static); + } + catch (Exception ex) + { + LogOnce( + $"resolve-{name}", + $"Failed to resolve static method '{name}' on {type.FullName}: {ex.Message}" + ); + return null; + } + } + + private static FieldInfo SafeGetInstanceField(Type type, string name) + { + try + { + return type.GetField(name, BindingFlags.Public | BindingFlags.Instance); + } + catch (Exception ex) + { + LogOnce( + $"resolve-field-{name}", + $"Failed to resolve instance field '{name}' on {type.FullName}: {ex.Message}" + ); + return null; + } + } + + private static void LogOnce(string key, string message) + { + if (!AlreadyWarned.Add(key)) + { + return; + } + Debug.LogWarning($"[DxMessaging] {message}"); + } + + private static void RaiseReportUpdated() + { + try + { + ReportUpdated?.Invoke(); + } + catch (Exception ex) + { + Debug.LogWarning($"[DxMessaging] ReportUpdated subscriber threw: {ex.Message}"); + } + } + + // -- JSON persistence ----------------------------------------------------------------- + // The cache survives editor restarts so the overlay has data to render before the first + // post-launch rescan completes; it is rewritten on every successful rescan. + + internal static void PersistToDisk() + { + try + { + string absolutePath = GetReportFilePath(); + EnsureDirectoryExists(absolutePath); + + BaseCallReportFile file = new() + { + version = 1, + generatedAt = DateTime.UtcNow.ToString( + "yyyy-MM-ddTHH:mm:ssZ", + CultureInfo.InvariantCulture + ), + types = SnapshotInternal + .Values.OrderBy(e => e.typeName, StringComparer.Ordinal) + .ToList(), + }; + + string json = JsonUtility.ToJson(file, prettyPrint: true); + File.WriteAllText(absolutePath, json); + } + catch (Exception ex) + { + LogOnce("persist", $"Failed to persist analyzer diagnostics report: {ex.Message}"); + } + } + + internal static void LoadFromDisk() + { + try + { + string absolutePath = GetReportFilePath(); + if (!File.Exists(absolutePath)) + { + return; + } + + string json = File.ReadAllText(absolutePath); + if (string.IsNullOrWhiteSpace(json)) + { + return; + } + + BaseCallReportFile file = JsonUtility.FromJson(json); + if (file?.types == null) + { + return; + } + + SnapshotInternal.Clear(); + foreach (BaseCallReportEntry entry in file.types) + { + if (entry == null || string.IsNullOrEmpty(entry.typeName)) + { + continue; + } + entry.missingBaseFor ??= new List(); + entry.diagnosticIds ??= new List(); + SnapshotInternal[entry.typeName] = entry; + } + } + catch (Exception ex) + { + LogOnce("load", $"Failed to load analyzer diagnostics report: {ex.Message}"); + } + } + + internal static string GetReportFilePath() + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")) + .Replace("\\", "/"); + return Path.Combine(projectRoot, "Library", ReportDirectoryName, ReportFileName) + .Replace("\\", "/"); + } + + private static void EnsureDirectoryExists(string absolutePath) + { + string directory = Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + } + } +#endif +} diff --git a/Editor/Analyzers/DxMessagingConsoleHarvester.cs.meta b/Editor/Analyzers/DxMessagingConsoleHarvester.cs.meta new file mode 100644 index 00000000..2e608538 --- /dev/null +++ b/Editor/Analyzers/DxMessagingConsoleHarvester.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a6bf0a6d4736459e824ae4792651a7fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll new file mode 100644 index 0000000000000000000000000000000000000000..c392a2d1f70b7663c77f8438705def517f1945fc GIT binary patch literal 22016 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 Fe*@viZ~g!P literal 0 HcmV?d00001 diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll.meta b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll.meta new file mode 100644 index 00000000..df88d4a6 --- /dev/null +++ b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: e02b032423e347e9a1ae26bb48fdc978 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + DefaultValueInitialized: true + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll index 7d880f6e2a2697a771ef0dd21503d087123c6c1b..c907536ebad62a6ed0ee7aff2a00158078af9cae 100644 GIT binary patch delta 237 zcmZo@VQOe$n$W?rWxxNyjXff%0w3!ix_&9lb?vCo*}Wj6@y+H9sr7;yrpcy>$p#jN zsVSxwsb=Qpsm6(xX(lGdhG}M|21cd^CP|h?hUNx|lf%pRvdnkgHFdH?MS#Gjcb~WX zZAx+KsTUWP-l4}nxuW8N0#vZc2Pz0sZ7L@G!@JRl>&}LoTQ_G^K49@TWk_Z)Wk>{) z1`HMqh773;DL}pjkTzp52f|baW1y@hLmGn#5E=tjqyc42fvSzbqzOY3P}T@2ZVnVn K+&sN5lNkVLWJ~-2 delta 237 zcmZo@VQOe$n$W@G z#3VV{Fv%p%)I8O|JT=L{GR43k)xs#v$TH2u)G*0ta(MY(mcToT( + /// Fallback CustomEditor that renders the DxMessaging warning HelpBox above the default + /// inspector for any subclass. Registered with + /// isFallback: true so a user's own [CustomEditor] still wins precedence — this + /// only kicks in when no other editor is registered for the type. + /// + /// + /// This composes the overlay path; the two + /// paths cover different Unity inspector code paths. Notably, Unity 2021 does not reliably + /// fire for + /// subclasses that have no [CustomEditor] registered — the fallback editor is what + /// makes the HelpBox appear in that environment. To avoid double-rendering when the header + /// hook ALSO fires for our editor instance (Unity 2022+), + /// unconditionally skips the header path + /// for instances. + /// + /// + /// We deliberately do NOT call : it re-emits the + /// m_Script field that Unity has already drawn in the inspector titlebar/header, + /// producing a duplicate "Script" row that visually breaks the inspector and offsets the + /// layout cache. Instead we walk the manually and skip + /// m_Script — the canonical "default inspector minus the script field" pattern. + /// + /// + /// + /// We also do NOT short-circuit on event type. Unity invokes + /// editors twice per frame (Layout + Repaint), and both passes MUST emit identical control + /// counts, otherwise the inspector window's layout cache is corrupted and adjacent + /// components fail to render. See + /// for the + /// matching invariant on the overlay side. + /// + /// + [CustomEditor(typeof(MessageAwareComponent), editorForChildClasses: true)] + [CanEditMultipleObjects] + public sealed class MessageAwareComponentFallbackEditor : Editor + { + public override void OnInspectorGUI() + { + // Render the overlay BEFORE the default body so the warning appears prominently at + // the top of the inspector. The overlay's render body has identical Layout/Repaint + // control counts, so we can call it unconditionally here. + MessageAwareComponentInspectorOverlay.RenderInsideOnInspectorGUI(target); + + serializedObject.Update(); + SerializedProperty iter = serializedObject.GetIterator(); + if (iter.NextVisible(enterChildren: true)) + { + do + { + // Skip the script reference — Unity's inspector window already draws it in + // the component header. Re-drawing it here causes a duplicate "Script" row + // that visually breaks the inspector and offsets the layout cache. + if ( + string.Equals( + iter.propertyPath, + "m_Script", + System.StringComparison.Ordinal + ) + ) + { + continue; + } + EditorGUILayout.PropertyField(iter, includeChildren: true); + } while (iter.NextVisible(enterChildren: false)); + } + serializedObject.ApplyModifiedProperties(); + } + } +#endif +} diff --git a/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs.meta b/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs.meta new file mode 100644 index 00000000..86b7ddaf --- /dev/null +++ b/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0d8deac538fe4f5da0a0cffe1a7ee670 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs new file mode 100644 index 00000000..acea5dfe --- /dev/null +++ b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs @@ -0,0 +1,406 @@ +namespace DxMessaging.Editor.CustomEditors +{ +#if UNITY_EDITOR + using System.Collections.Generic; + using System.Linq; + using DxMessaging.Editor.Analyzers; + using DxMessaging.Editor.Settings; + using Unity; + using UnityEditor; + using UnityEditorInternal; + using UnityEngine; + + /// + /// Header-injection overlay for every Inspector showing a subclass. + /// + /// + /// We hook rather than registering a + /// [CustomEditor(typeof(MessageAwareComponent), editorForChildClasses: true)] so we never + /// clobber a user's own custom editor. The overlay reads its data from + /// (which reflects directly into Unity's + /// UnityEditor.LogEntries console store) and from + /// (project-wide ignore list and master toggle). + /// + /// + /// Layout/Repaint control-count invariant. When the overlay renders from inside an + /// body (the fallback CustomEditor path), Unity invokes + /// us TWICE per frame: once with Event.current.type == EventType.Layout (where every + /// EditorGUILayout.* call REGISTERS a control) and once with EventType.Repaint + /// (where the registered controls are drawn). The two passes MUST emit identical control + /// counts, otherwise Unity's layout cache for the entire inspector window is corrupted and + /// adjacent components fail to render. That is why we expose two entry points: + /// + /// + /// + /// (registered to ) is + /// post-body and Unity has already settled layout for the inspector by the time it fires — + /// gating on EventType.Repaint there is safe. + /// + /// + /// is called from inside an editor body and CANNOT + /// gate on event type. It must call the same EditorGUILayout sequence on both passes. + /// + /// + /// + [InitializeOnLoad] + public static class MessageAwareComponentInspectorOverlay + { + // Per-Repaint latch keyed on instanceID for the header-hook entry point. We render once + // per Repaint event per target. EventType.Layout marks the start of a fresh GUI cycle, so + // we clear the set then; rendering happens on EventType.Repaint, which Unity guarantees + // fires once per visible inspector per frame. + // + // NOTE: cross-path dedupe between the header hook and the OnInspectorGUI hook is + // accomplished by an UNCONDITIONAL skip at the top of when the + // target editor is our fallback CustomEditor — see that method's comment. We do NOT use + // a per-frame "header drew" set, because such a set would necessarily be populated only + // on the Repaint pass of the header hook, while OnInspectorGUI runs on BOTH the Layout + // and Repaint passes — that asymmetry would corrupt the inspector's layout cache. + private static readonly HashSet _renderedThisRepaint = new(); + + static MessageAwareComponentInspectorOverlay() + { + Editor.finishedDefaultHeaderGUI += DrawHeader; + DxMessagingConsoleHarvester.ReportUpdated += RepaintAllInspectors; + } + + private static void RepaintAllInspectors() + { + try + { + // InternalEditorUtility.RepaintAllViews is the cheap path: it walks the + // existing GUIView list once. Resources.FindObjectsOfTypeAll() allocates + // a fresh array of every Editor instance Unity has loaded, which is wasteful + // when we just want a redraw signal. + InternalEditorUtility.RepaintAllViews(); + } + catch (System.Exception ex) + { + Debug.LogWarning( + $"[DxMessaging] Failed to repaint inspectors after analyzer report update: {ex.Message}" + ); + } + } + + private static void DrawHeader(Editor editor) + { + if (editor == null) + { + return; + } + // If our own fallback CustomEditor is the editor instance, skip the header path + // entirely — the editor's OnInspectorGUI will call RenderInsideOnInspectorGUI and we + // would otherwise render twice. Unconditional skip (not gated on EventType) keeps + // control counts balanced on both Layout and Repaint passes. + if (editor is MessageAwareComponentFallbackEditor) + { + return; + } + RenderForHeaderHook(editor.target); + } + + /// + /// Header-hook entry point. Fires after Unity's default header has been drawn, so the + /// inspector's layout pass for this editor has already completed. Safe to gate on + /// here — we are not inside an OnInspectorGUI body. + /// + private static void RenderForHeaderHook(UnityEngine.Object target) + { + if (target == null) + { + return; + } + if (target is not MessageAwareComponent messageAwareComponent) + { + return; + } + + Event currentEvent = Event.current; + if (currentEvent == null) + { + return; + } + if (currentEvent.type == EventType.Layout) + { + // Start of a fresh GUI cycle — wipe the per-Repaint latch. + _renderedThisRepaint.Clear(); + return; + } + if (currentEvent.type != EventType.Repaint) + { + return; + } + int instanceId = messageAwareComponent.GetInstanceID(); + if (!_renderedThisRepaint.Add(instanceId)) + { + return; + } + + BuildAndRenderOverlay(messageAwareComponent); + } + + /// + /// OnInspectorGUI entry point. Called from inside the fallback CustomEditor's + /// , where Unity invokes the editor on BOTH the Layout + /// pass and the Repaint pass. This method MUST emit the same EditorGUILayout calls + /// on both passes, so it does NOT gate on and does NOT latch. + /// Cross-path dedupe with the header-hook path is handled inside + /// , which unconditionally skips when the editor is our fallback. + /// + internal static void RenderInsideOnInspectorGUI(UnityEngine.Object target) + { + if (target is not MessageAwareComponent messageAwareComponent) + { + return; + } + BuildAndRenderOverlay(messageAwareComponent); + } + + /// + /// Rendering body shared by both entry points. Performs ALL gating decisions up-front + /// before any EditorGUILayout.* call, then runs straight-line layout calls. This + /// guarantees the function emits an identical sequence of layout calls on the Layout and + /// Repaint passes when invoked from within . + /// + /// True if the HelpBox + buttons were drawn; false if we drew nothing. + private static bool BuildAndRenderOverlay(MessageAwareComponent messageAwareComponent) + { + // ---- Gating phase: every "should we draw?" decision happens here, before any + // EditorGUILayout call. The result is a single bool: shouldRender. ---- + + // Mid-compile / mid-import is the worst time to dereference the settings asset: + // AssetDatabase may be in a transitional state. Bail and let the next OnGUI redraw + // pick up where we left off. + if (EditorApplication.isCompiling || EditorApplication.isUpdating) + { + return false; + } + + DxMessagingSettings settings; + try + { + settings = DxMessagingSettings.GetOrCreateSettings(); + } + catch (System.Exception ex) + { + Debug.LogWarning( + $"[DxMessaging] Inspector overlay could not load settings: {ex.Message}" + ); + return false; + } + + if (settings == null || !settings._baseCallCheckEnabled) + { + return false; + } + + // S6: System.Type.FullName renders nested types as `Outer+Nested`, but the analyzer's + // `containingType.ToDisplayString()` (which produces the FQN we key the snapshot by) + // renders them as `Outer.Nested`. Without this normalization the lookup misses for + // every nested MessageAwareComponent subclass and the HelpBox never shows. + System.Type targetType = messageAwareComponent.GetType(); + string fullName = (targetType.FullName ?? string.Empty).Replace('+', '.'); + if (string.IsNullOrEmpty(fullName)) + { + return false; + } + + // Decide which of the three render shapes (if any) to draw. + // 0 = render nothing; 1 = harvester-unavailable info; 2 = ignored-type info; 3 = warning. + int shape = 0; + BaseCallReportEntry entry = null; + bool isIgnored = false; + + if (!DxMessagingConsoleHarvester.IsAvailable) + { + shape = 1; + } + else + { + isIgnored = + settings._baseCallIgnoredTypes != null + && settings._baseCallIgnoredTypes.Any(e => + string.Equals(e, fullName, System.StringComparison.Ordinal) + ); + if (isIgnored) + { + shape = 2; + } + else if ( + DxMessagingConsoleHarvester.TryGetEntry(fullName, out entry) + && entry != null + && entry.missingBaseFor != null + && entry.missingBaseFor.Count > 0 + ) + { + shape = 3; + } + } + + if (shape == 0) + { + // "Render nothing" branch: emit ZERO EditorGUILayout calls. This must hold on + // both Layout and Repaint passes when called from OnInspectorGUI, so Unity's + // layout cache stays consistent. + return false; + } + + // ---- Render phase: straight-line EditorGUILayout calls, identical sequence on + // every pass. Wrapped in a vertical group so any internal mismatch we missed cannot + // propagate to sibling inspectors. ---- + EditorGUILayout.BeginVertical(); + try + { + switch (shape) + { + case 1: + EditorGUILayout.HelpBox( + "DxMessaging inspector overlay is disabled on this Unity version. " + + "Check the console for DXMSG006/007/009 warnings instead.", + MessageType.Info + ); + break; + case 2: + DrawIgnoredBox(messageAwareComponent, settings, fullName); + break; + case 3: + DrawWarningBox(messageAwareComponent, settings, fullName, entry); + break; + } + } + finally + { + EditorGUILayout.EndVertical(); + } + + return true; + } + + private static void DrawWarningBox( + MessageAwareComponent component, + DxMessagingSettings settings, + string fullName, + BaseCallReportEntry entry + ) + { + string missingMethods = string.Join(", ", entry.missingBaseFor); + // 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. + // The suffix only appears when the harvester is showing entries loaded eagerly from + // `Library/DxMessaging/baseCallReport.json` and the first post-reload scan has not + // yet completed; once the scan flips IsFreshThisSession to true and RepaintAllInspectors + // fires, the overlay redraws without the suffix. + string freshnessSuffix = DxMessagingConsoleHarvester.IsFreshThisSession + ? string.Empty + : "\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." + + freshnessSuffix; + + EditorGUILayout.HelpBox(message, MessageType.Warning); + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Open Script")) + { + OpenScriptForComponent(component, entry); + } + if (GUILayout.Button("Ignore this type")) + { + TryAddIgnoredType(settings, fullName); + } + } + } + + private static void DrawIgnoredBox( + MessageAwareComponent component, + DxMessagingSettings settings, + string fullName + ) + { + EditorGUILayout.HelpBox( + $"{fullName} is excluded from the DxMessaging base-call check.", + MessageType.Info + ); + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Stop ignoring")) + { + TryRemoveIgnoredType(settings, fullName); + } + } + } + + private static void OpenScriptForComponent( + MessageAwareComponent component, + BaseCallReportEntry entry + ) + { + try + { + MonoScript monoScript = MonoScript.FromMonoBehaviour(component); + if (monoScript == null) + { + return; + } + if (entry != null && entry.line > 0) + { + AssetDatabase.OpenAsset(monoScript, entry.line); + } + else + { + AssetDatabase.OpenAsset(monoScript); + } + } + catch (System.Exception ex) + { + Debug.LogWarning($"[DxMessaging] Failed to open script: {ex.Message}"); + } + } + + private static void TryAddIgnoredType(DxMessagingSettings settings, string fullName) + { + // Defer the mutation to AFTER the current frame's Layout/Repaint pair completes. + // Mutating settings._baseCallIgnoredTypes synchronously inside a button handler + // would flip the overlay's shape between Layout and Repaint passes of the SAME + // frame, corrupting Unity's per-window layout cache. delayCall fires AFTER the + // current GUI cycle, so the next frame's Layout pass sees the new state and + // both passes emit consistent control counts. + EditorApplication.delayCall += () => + { + try + { + settings.AddIgnoredType(fullName); + } + catch (System.Exception ex) + { + Debug.LogWarning( + $"[DxMessaging] Failed to add ignored type '{fullName}': {ex.Message}" + ); + } + }; + } + + private static void TryRemoveIgnoredType(DxMessagingSettings settings, string fullName) + { + // Same reasoning as TryAddIgnoredType: defer mutation past the current GUI cycle so + // the overlay's shape gating remains identical on Layout and Repaint passes of THIS + // frame. The next frame's Layout pass observes the new state — both passes agree. + EditorApplication.delayCall += () => + { + try + { + settings.RemoveIgnoredType(fullName); + } + catch (System.Exception ex) + { + Debug.LogWarning( + $"[DxMessaging] Failed to remove ignored type '{fullName}': {ex.Message}" + ); + } + }; + } + } +#endif +} diff --git a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs.meta b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs.meta new file mode 100644 index 00000000..f9399b8a --- /dev/null +++ b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b2e06a6b39994b51bc06b0131c49428e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs b/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs new file mode 100644 index 00000000..0d38ce12 --- /dev/null +++ b/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs @@ -0,0 +1,190 @@ +namespace DxMessaging.Editor.Settings +{ +#if UNITY_EDITOR + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text; + using UnityEditor; + using UnityEngine; + + /// + /// Synchronizes the list to a sidecar + /// text file shipped to the Roslyn analyzer via csc.rsp's -additionalfile switch. + /// + /// + /// The sidecar is a build-derived view: never hand-edited, always regenerated from the + /// ScriptableObject. The analyzer reads it because it cannot parse Unity-serialized YAML. + /// + public static class DxMessagingBaseCallIgnoreSync + { + /// + /// Project-relative path to the auto-generated sidecar file. Other Editor scripts + /// (e.g., ) reference this constant when wiring + /// -additionalfile entries. + /// + public const string SidecarAssetPath = "Assets/Editor/DxMessaging.BaseCallIgnore.txt"; + + private const string HeaderComment = + "# Auto-generated from Assets/Editor/DxMessagingSettings.asset — edit there instead."; + private const string FormatComment = + "# One fully-qualified type name per line. Lines starting with # are comments."; + + /// + /// Regenerates the sidecar text file from the supplied settings asset. Writes only when + /// the on-disk content differs from what would be written, matching the + /// FilesDiffer-style policy used elsewhere in this Editor assembly to avoid + /// AssetDatabase churn during domain reload. + /// + /// The settings asset. May be null — no-op in that case. + /// + /// When called while Unity is mid-compile or mid-asset-import (e.g., from a + /// ScriptableObject.OnValidate that fires during a domain reload), the actual + /// regen is deferred via so we don't trip + /// AssetDatabase reentrancy guards. Direct calls from EditMode tests run synchronously + /// because tests don't execute during update/compile. + /// + public static void RegenerateSidecar(DxMessagingSettings settings) + { + if (settings == null) + { + return; + } + + if (EditorApplication.isUpdating || EditorApplication.isCompiling) + { + EditorApplication.delayCall += () => RegenerateSidecarCore(settings); + return; + } + + RegenerateSidecarCore(settings); + } + + private static void RegenerateSidecarCore(DxMessagingSettings settings) + { + if (settings == null) + { + return; + } + + try + { + string newContent = BuildContent(settings._baseCallIgnoredTypes); + string absolutePath = GetAbsolutePath(); + EnsureParentDirectoryExists(absolutePath); + + if (File.Exists(absolutePath)) + { + string existing = File.ReadAllText(absolutePath); + if (string.Equals(existing, newContent, StringComparison.Ordinal)) + { + return; + } + } + + File.WriteAllText(absolutePath, newContent); + AssetDatabase.ImportAsset(SidecarAssetPath); + } + catch (Exception ex) + { + Debug.LogWarning( + $"[DxMessaging] Failed to write base-call ignore sidecar at '{SidecarAssetPath}': {ex.Message}" + ); + } + } + + /// + /// Reads the sidecar file and returns its non-comment, non-blank entries. + /// Tolerant of a missing file (returns an empty list) and of #-prefixed comment lines. + /// + public static IReadOnlyList ReadSidecar() + { + try + { + string absolutePath = GetAbsolutePath(); + if (!File.Exists(absolutePath)) + { + return Array.Empty(); + } + + List entries = new(); + foreach (string rawLine in File.ReadAllLines(absolutePath)) + { + if (string.IsNullOrWhiteSpace(rawLine)) + { + continue; + } + string trimmed = rawLine.Trim(); + if (trimmed.Length == 0 || trimmed[0] == '#') + { + continue; + } + entries.Add(trimmed); + } + return entries; + } + catch (Exception ex) + { + Debug.LogWarning( + $"[DxMessaging] Failed to read base-call ignore sidecar at '{SidecarAssetPath}': {ex.Message}" + ); + return Array.Empty(); + } + } + + internal static string BuildContent(IList ignoredTypes) + { + StringBuilder builder = new(); + builder.Append(HeaderComment).Append('\n'); + builder.Append(FormatComment).Append('\n'); + + if (ignoredTypes == null) + { + return builder.ToString(); + } + + // Deterministic order for git-friendly diffs; deduplicate while preserving the user's + // typed casing where possible (Ordinal sort with Ordinal-set dedupe). + HashSet seen = new(StringComparer.Ordinal); + List sorted = new(ignoredTypes.Count); + foreach (string entry in ignoredTypes) + { + if (string.IsNullOrWhiteSpace(entry)) + { + continue; + } + string trimmed = entry.Trim(); + if (seen.Add(trimmed)) + { + sorted.Add(trimmed); + } + } + sorted.Sort(StringComparer.Ordinal); + + foreach (string entry in sorted) + { + builder.Append(entry).Append('\n'); + } + return builder.ToString(); + } + + private static string GetAbsolutePath() + { + // Application.dataPath ends in "/Assets"; SidecarAssetPath begins with "Assets/". + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")) + .Replace("\\", "/"); + return Path.Combine(projectRoot, SidecarAssetPath).Replace("\\", "/"); + } + + private static void EnsureParentDirectoryExists(string absolutePath) + { + string directory = Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + } + } +#endif +} diff --git a/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs.meta b/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs.meta new file mode 100644 index 00000000..56a80ad5 --- /dev/null +++ b/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 99dc9d314eff4388bba3679e20ca06c8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Settings/DxMessagingSettings.cs b/Editor/Settings/DxMessagingSettings.cs index f134857d..45fe54cc 100644 --- a/Editor/Settings/DxMessagingSettings.cs +++ b/Editor/Settings/DxMessagingSettings.cs @@ -1,6 +1,7 @@ namespace DxMessaging.Editor.Settings { #if UNITY_EDITOR + using System.Collections.Generic; using System.Linq; using Core.MessageBus; using UnityEditor; @@ -32,6 +33,15 @@ public sealed class DxMessagingSettings : ScriptableObject [SerializeField] internal bool _suppressDomainReloadWarning = true; + [SerializeField] + internal List _baseCallIgnoredTypes = new(); + + [SerializeField] + internal bool _baseCallCheckEnabled = true; + + [SerializeField] + internal bool _useConsoleBridge; + /// /// Controls values applied to . /// @@ -59,6 +69,94 @@ public bool SuppressDomainReloadWarning set => _suppressDomainReloadWarning = value; } + /// + /// Master toggle for the MessageAwareComponent base-call check (DXMSG006/007/008). + /// When false, the Inspector overlay and per-type warnings are silenced; the underlying + /// compile-time analyzer warnings remain unless explicitly suppressed via .editorconfig. + /// + /// + /// S3: toggling from false back to true pokes + /// on the next editor + /// tick so the snapshot repopulates without waiting for the user to clear/re-emit warnings + /// or to manually invoke Tools/DxMessaging/Rescan Base-Call Warnings. The round-trip + /// is intentionally indirect (delayCall → RescanNow) to keep this property setter cheap and + /// safe to invoke from any editor context — including OnValidate, where AssetDatabase may + /// be transitional. + /// + public bool BaseCallCheckEnabled + { + get => _baseCallCheckEnabled; + set + { + bool previous = _baseCallCheckEnabled; + _baseCallCheckEnabled = value; + if (!previous && value) + { + // A master-toggle flip doesn't need a synchronous reflective harvest right now; + // the polled tick (~250ms) will pick up the sentinel cheaply on the editor's + // own update thread, avoiding a heavy reflection sweep on the main thread when + // the user has just clicked a checkbox. Indirected through delayCall so the + // setter is safe to invoke from any editor context (OnValidate, button click, + // etc.) without risking AssetDatabase reentrancy. + EditorApplication.delayCall += DxMessaging + .Editor + .Analyzers + .DxMessagingConsoleHarvester + .RequestRescan; + } + } + } + + /// + /// Opt-in toggle for the legacy console-scrape bridge that augments the IL-reflection + /// scanner's snapshot with warnings harvested from UnityEditor.LogEntries and from + /// CompilationPipeline.assemblyCompilationFinished. + /// + /// + /// + /// Default false. The IL-reflection scanner + /// () is the deterministic, + /// always-on primary source — it walks every loaded MessageAwareComponent subclass + /// and inspects each override's IL body for the base-call shape, which is reliable across + /// Unity 2021 cache hits, incremental compiles, and arbitrary domain-reload sequences. + /// + /// + /// The legacy bridge predates the IL scanner and was the source of the intermittent + /// "missing warnings" bug on Unity 2021. Enable it ONLY if you want the union of both + /// data sources — for example, to surface a regression in the IL byte-walker that is + /// already correctly captured by the compile-time analyzer's console output. + /// + /// + /// Toggling this property is observable via a deferred + /// so the + /// inspector overlay refreshes without waiting for the next compile. + /// + /// + public bool UseConsoleBridge + { + get => _useConsoleBridge; + set + { + if (_useConsoleBridge == value) + { + return; + } + _useConsoleBridge = value; + EditorUtility.SetDirty(this); + EditorApplication.delayCall += DxMessaging + .Editor + .Analyzers + .DxMessagingConsoleHarvester + .RescanNow; + } + } + + /// + /// Fully-qualified type names excluded from the base-call check. Editable via the Project Settings UI + /// or via the Inspector overlay's "Ignore this type" button. + /// + public IReadOnlyList BaseCallIgnoredTypes => _baseCallIgnoredTypes; + /// /// Loads the settings asset if present, otherwise creates it with sensible defaults. /// @@ -83,6 +181,8 @@ internal static DxMessagingSettings GetOrCreateSettings() settings._diagnosticsTargets = DiagnosticsTarget.Off; settings._messageBufferSize = IMessageBus.DefaultMessageBufferSize; settings._suppressDomainReloadWarning = true; + settings._baseCallCheckEnabled = true; + settings._baseCallIgnoredTypes = new List(); if (!AssetDatabase.IsValidFolder("Assets/Editor")) { AssetDatabase.CreateFolder("Assets", "Editor"); @@ -112,6 +212,95 @@ internal static SerializedObject GetSerializedSettings() { return new SerializedObject(GetOrCreateSettings()); } + + private void OnEnable() + { + // Defensive: the field can be null if the asset was saved before this field existed. + if (_baseCallIgnoredTypes == null) + { + _baseCallIgnoredTypes = new List(); + } + // Intentionally NOT regenerating the sidecar here. OnEnable fires on every domain reload + // and play-mode entry; the sidecar on disk is already consistent with what we'd write + // (RegenerateSidecar is idempotent, but ImportAsset still produces churn). Regen runs + // only from OnValidate (user-driven edits) and from explicit Add/RemoveIgnoredType calls. + } + + private void OnValidate() + { + if (_baseCallIgnoredTypes == null) + { + _baseCallIgnoredTypes = new List(); + } + TryRegenerateSidecar(); + } + + /// + /// Adds to the ignore list, marks the asset + /// dirty, saves, and regenerates the sidecar. No-op when the entry is already present. + /// + internal void AddIgnoredType(string fullyQualifiedTypeName) + { + if (string.IsNullOrWhiteSpace(fullyQualifiedTypeName)) + { + return; + } + if (_baseCallIgnoredTypes == null) + { + _baseCallIgnoredTypes = new List(); + } + if ( + _baseCallIgnoredTypes.Any(entry => + string.Equals(entry, fullyQualifiedTypeName, System.StringComparison.Ordinal) + ) + ) + { + return; + } + _baseCallIgnoredTypes.Add(fullyQualifiedTypeName); + EditorUtility.SetDirty(this); + AssetDatabase.SaveAssets(); + TryRegenerateSidecar(); + } + + /// + /// Removes from the ignore list, marks the asset + /// dirty, saves, and regenerates the sidecar. No-op when the entry is absent. + /// + internal void RemoveIgnoredType(string fullyQualifiedTypeName) + { + if (string.IsNullOrWhiteSpace(fullyQualifiedTypeName)) + { + return; + } + if (_baseCallIgnoredTypes == null) + { + return; + } + int removed = _baseCallIgnoredTypes.RemoveAll(entry => + string.Equals(entry, fullyQualifiedTypeName, System.StringComparison.Ordinal) + ); + if (removed > 0) + { + EditorUtility.SetDirty(this); + AssetDatabase.SaveAssets(); + TryRegenerateSidecar(); + } + } + + private void TryRegenerateSidecar() + { + try + { + DxMessagingBaseCallIgnoreSync.RegenerateSidecar(this); + } + catch (System.Exception ex) + { + Debug.LogWarning( + $"[DxMessaging] Failed to regenerate base-call ignore sidecar: {ex.Message}" + ); + } + } } #endif } diff --git a/Editor/SetupCscRsp.cs b/Editor/SetupCscRsp.cs index c9d63966..277aab47 100644 --- a/Editor/SetupCscRsp.cs +++ b/Editor/SetupCscRsp.cs @@ -7,6 +7,7 @@ namespace DxMessaging.Editor using System.IO; using System.Linq; using System.Security.Cryptography; + using DxMessaging.Editor.Settings; using UnityEditor; using UnityEngine; using Object = UnityEngine.Object; @@ -30,9 +31,20 @@ public static class SetupCscRsp private static readonly string SourceGeneratorDllName = "WallstopStudios.DxMessaging.SourceGenerators.dll"; + // The analyzer DLL is a SEPARATE assembly compiled against Roslyn 3.8.0 because Unity 2021's + // analyzer loader silently rejects analyzer DLLs built against Roslyn 4.x. The source- + // generator DLL above stays at Roslyn 4.x because it uses IIncrementalGenerator (4.0+). + // Both DLLs ship side-by-side and both need the RoslynAnalyzer label. + private static readonly string AnalyzerDllName = "WallstopStudios.DxMessaging.Analyzer.dll"; + + // The analyzer DLLs and shared Roslyn surface ship unconditionally — they're light enough + // and required for DXMSG002–DXMSG009 to function at all. The list intentionally references + // a few transitive Roslyn deps that may or may not physically ship with the package; the + // copy loop below silently skips any name that isn't on disk. private static readonly string[] RequiredDllNames = { SourceGeneratorDllName, + AnalyzerDllName, "Microsoft.CodeAnalysis.dll", "Microsoft.CodeAnalysis.CSharp.dll", "System.Text.Encodings.Web.dll", @@ -44,7 +56,17 @@ public static class SetupCscRsp "System.Threading.Tasks.Extensions.dll", "System.Numerics.Vectors.dll", "System.Text.Encoding.CodePages.dll", - "Microsoft.Bcl.AsyncInterfaces.dll", + }; + + // DLLs that must be tagged with Unity's "RoslynAnalyzer" asset label so Unity's compiler + // pipeline picks them up as analyzer/source-generator hosts. Other DLLs in the same folder + // (Roslyn runtime, immutable collections) are plain Editor-only plugin DLLs. + private static readonly HashSet AnalyzerLabeledDllNames = new( + StringComparer.OrdinalIgnoreCase + ) + { + SourceGeneratorDllName, + AnalyzerDllName, }; private static readonly HashSet DllNames = new(StringComparer.OrdinalIgnoreCase); @@ -53,6 +75,7 @@ static SetupCscRsp() { EditorApplication.delayCall += EnsureDLLsExistInAssets; EditorApplication.delayCall += EnsureCscRsp; + EditorApplication.delayCall += EnsureAdditionalFileForIgnoreList; } private static void EnsureDLLsExistInAssets() @@ -107,7 +130,7 @@ string dllGuid in AssetDatabase.FindAssets("t:DefaultAsset", new[] { "Assets" }) AssetDatabase.ImportAsset(outputAsset); } - if (requiredDllName == SourceGeneratorDllName) + if (AnalyzerLabeledDllNames.Contains(requiredDllName)) { Object loadedDll = AssetDatabase.LoadMainAssetAtPath(outputAsset); if (loadedDll != null) @@ -190,6 +213,10 @@ private static bool FilesDiffer(string sourcePath, string destinationPath) return !sourceHash.AsSpan().SequenceEqual(destinationHash); } + /// + /// Synchronizes csc.rsp with the current set of analyzer arguments derived from the + /// on-disk DLL roster. + /// private static void EnsureCscRsp() { try @@ -286,6 +313,119 @@ string line in rspContent.Split( } } + /// + /// Ensures csc.rsp contains a single -additionalfile: line pointing at the + /// base-call ignore sidecar, when (and only when) that sidecar physically exists. Stale + /// entries pointing at moved or deleted sidecar paths are removed. + /// + /// + /// The sidecar is generated by only when there + /// is content to write. csc happily runs without it, so this method does NOT auto-create. + /// + private static void EnsureAdditionalFileForIgnoreList() + { + try + { + if (!File.Exists(RspFilePath)) + { + File.WriteAllText(RspFilePath, string.Empty); + AssetDatabase.ImportAsset("csc.rsp"); + } + + string sidecarRelativePath = DxMessagingBaseCallIgnoreSync.SidecarAssetPath; + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")) + .Replace("\\", "/"); + string sidecarAbsolutePath = Path.Combine(projectRoot, sidecarRelativePath) + .Replace("\\", "/"); + + bool sidecarExists = File.Exists(sidecarAbsolutePath); + string desiredLine = $"-additionalfile:\"{sidecarRelativePath}\""; + + string rspContent = File.ReadAllText(RspFilePath); + List newLines = new(); + bool foundDesired = false; + bool foundStale = false; + + foreach ( + string line in rspContent.Split( + new[] { '\r', '\n' }, + StringSplitOptions.RemoveEmptyEntries + ) + ) + { + string trimmedLine = line.Trim(); + + bool isDxMessagingAdditionalFile = + trimmedLine.StartsWith( + "-additionalfile:", + StringComparison.OrdinalIgnoreCase + ) + && trimmedLine.Contains("DxMessaging.", StringComparison.OrdinalIgnoreCase) + && trimmedLine.Contains( + "BaseCallIgnore", + StringComparison.OrdinalIgnoreCase + ); + + if (isDxMessagingAdditionalFile) + { + if ( + sidecarExists + && string.Equals( + trimmedLine, + desiredLine, + StringComparison.OrdinalIgnoreCase + ) + ) + { + if (!foundDesired) + { + newLines.Add(trimmedLine); + foundDesired = true; + } + else + { + // Drop duplicate. + foundStale = true; + } + } + else + { + // Stale entry pointing at a moved/renamed/deleted sidecar — drop it. + foundStale = true; + } + } + else + { + newLines.Add(trimmedLine); + } + } + + bool needsAppend = sidecarExists && !foundDesired; + if (needsAppend) + { + newLines.Add(desiredLine); + } + + bool modified = foundStale || needsAppend; + + if (modified) + { + string newContent = string.Join(Environment.NewLine, newLines); + if (!string.IsNullOrEmpty(newContent)) + { + newContent += Environment.NewLine; + } + File.WriteAllText(RspFilePath, newContent); + AssetDatabase.ImportAsset("csc.rsp"); + Debug.Log("Updated csc.rsp additionalfile entries."); + } + } + catch (IOException ex) + { + Debug.LogError($"Failed to update csc.rsp additionalfile entry: {ex}"); + } + } + private static IEnumerable GetAnalyzerArguments() { HashSet yielded = new(StringComparer.OrdinalIgnoreCase); diff --git a/Runtime/Core/Attributes/DxIgnoreMissingBaseCallAttribute.cs b/Runtime/Core/Attributes/DxIgnoreMissingBaseCallAttribute.cs new file mode 100644 index 00000000..dcc9623f --- /dev/null +++ b/Runtime/Core/Attributes/DxIgnoreMissingBaseCallAttribute.cs @@ -0,0 +1,26 @@ +namespace DxMessaging.Core.Attributes +{ + using System; + + /// + /// Suppresses the MessageAwareComponentBaseCallAnalyzer (DXMSG006/DXMSG007/DXMSG009/DXMSG010) + /// for the annotated class or method. + /// + /// + /// Applying this attribute is the source-level opt-out for the base-call analyzer. When applied to + /// a class, every guarded lifecycle method on that class (Awake, OnEnable, + /// OnDisable, OnDestroy, RegisterMessageHandlers) is exempt. When applied to a + /// single method, only that method is exempt. The analyzer still emits an Info-level + /// DXMSG008 at the suppression site so the opt-out is auditable. + /// + /// Inherited = false: a base class's [DxIgnoreMissingBaseCall] does NOT silently + /// suppress derived classes. Each subclass must opt out explicitly. + /// + /// + [AttributeUsage( + AttributeTargets.Class | AttributeTargets.Method, + Inherited = false, + AllowMultiple = false + )] + public sealed class DxIgnoreMissingBaseCallAttribute : Attribute { } +} diff --git a/Runtime/Core/Attributes/DxIgnoreMissingBaseCallAttribute.cs.meta b/Runtime/Core/Attributes/DxIgnoreMissingBaseCallAttribute.cs.meta new file mode 100644 index 00000000..85598955 --- /dev/null +++ b/Runtime/Core/Attributes/DxIgnoreMissingBaseCallAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c8950d6fd6b4d409d59ff68e1eba46f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/DI/README.md b/Samples~/DI/README.md index 948c6709..559f5f41 100644 --- a/Samples~/DI/README.md +++ b/Samples~/DI/README.md @@ -5,11 +5,11 @@ These snippets illustrate how to consume `IMessageRegistrationBuilder` inside co ## Setup 1. Install the relevant container package (Zenject/Extenject, VContainer, or Reflex) into your Unity project. -2. Enable the matching scripting define symbol in **Project Settings › Player › Scripting Define Symbols**: +1. Enable the matching scripting define symbol in **Project Settings › Player › Scripting Define Symbols**: - `ZENJECT_PRESENT` - `VCONTAINER_PRESENT` - `REFLEX_PRESENT` -3. Import the sample folder you need into your Unity project (`Assets/Samples/DxMessaging/*`). +1. Import the sample folder you need into your Unity project (`Assets/Samples/DxMessaging/*`). Each sample shows: @@ -31,7 +31,7 @@ Each sample shows: 1. **Place the prefab** Drag `Prefabs/MessagingInstallerSample.prefab` into your test scene. The root object carries `MessagingComponentInstaller` with its provider handle already pointing at the global provider ScriptableObject. -2. **Hook up the container** +1. **Hook up the container** - **Zenject**: - Add `DxMessagingRegistrationInstaller` (from [Runtime/Unity/Integrations](../../Runtime/Unity/Integrations/)) to your ProjectContext or scene installer list. - Drop [SampleInstaller.cs](./Zenject/SampleInstaller.cs) into your project and register it alongside other installers. When the scene runs, the installer resolves `IMessageRegistrationBuilder`, stages a `PlayerSpawned` listener, and activates via the Zenject lifecycle. @@ -42,10 +42,10 @@ Each sample shows: - Enable `REFLEX_PRESENT` and install `DxMessagingRegistrationInstaller` into your container bootstrap. - Include [SampleInstaller.cs](./Reflex/SampleInstaller.cs) in your installer chain. The sample service resolves `IMessageRegistrationBuilder`, subscribes to `PlayerAlert`, and can emit alerts via `EmitAlertFor`. -3. **Emit a message** +1. **Emit a message** Use the service exposed by the container (e.g., call into `ScoreboardService` or `PlayerAlertService`) to emit a message. Because the prefab already configured `MessagingComponent` instances via the installer, the listeners run immediately. -4. **Swap providers** (optional) +1. **Swap providers** (optional) Duplicate [GlobalMessageBusProvider.asset](./Providers/GlobalMessageBusProvider.asset), modify it to return a custom bus, assign it on the prefab root, and observe how builder-created leases now resolve that bus instead. Feel free to duplicate these scripts into your own project and adjust lifecycles or message types as needed. diff --git a/Samples~/Mini Combat/README.md b/Samples~/Mini Combat/README.md index 76f3630e..794e07ee 100644 --- a/Samples~/Mini Combat/README.md +++ b/Samples~/Mini Combat/README.md @@ -7,8 +7,8 @@ **This sample demonstrates messaging concepts through a functional example.** It shows: 1. How Player heals without UI knowing about Player (**zero coupling**) -2. How Enemy broadcasts damage without knowing who cares (**observer pattern**) -3. How settings changes update everything automatically (**global events**) +1. How Enemy broadcasts damage without knowing who cares (**observer pattern**) +1. How settings changes update everything automatically (**global events**) **No prior messaging experience needed** - this sample walks you through everything. @@ -98,11 +98,11 @@ Here's what each script does: #### Want to see it work immediately? 1. **Open Package Manager**: Window → Package Manager -2. **Find DxMessaging** in the package list -3. **Scroll to Samples** section → Find "Mini Combat" → Click **Import** -4. **Navigate to** Assets/Samples/DxMessaging/.../Mini Combat/ -5. **Open the scene** -6. **Press Play** 🎮 +1. **Find DxMessaging** in the package list +1. **Scroll to Samples** section → Find "Mini Combat" → Click **Import** +1. **Navigate to** Assets/Samples/DxMessaging/.../Mini Combat/ +1. **Open the scene** +1. **Press Play** 🎮 Watch the Console logs as messages flow. @@ -150,8 +150,8 @@ Press Play! The Boot script will automatically: #### [Boot.cs](./Boot.cs) sends messages: 1. `VideoSettingsChanged` (Untargeted) → [UIOverlay.cs](./UIOverlay.cs) receives -2. `Heal` (Targeted to Player) → [Player.cs](./Player.cs) receives -3. `TookDamage` (Broadcast from Enemy) → [UIOverlay.cs](./UIOverlay.cs) receives +1. `Heal` (Targeted to Player) → [Player.cs](./Player.cs) receives +1. `TookDamage` (Broadcast from Enemy) → [UIOverlay.cs](./UIOverlay.cs) receives ### Understanding Message Types diff --git a/Samples~/Mini Combat/Walkthrough.md b/Samples~/Mini Combat/Walkthrough.md index e4ebb04f..195827b2 100644 --- a/Samples~/Mini Combat/Walkthrough.md +++ b/Samples~/Mini Combat/Walkthrough.md @@ -17,10 +17,10 @@ After reading this walkthrough, you'll know: 1. **Why each message type was chosen** - the reasoning behind Untargeted vs Targeted vs Broadcast -2. **How the code flows** - step-by-step from [Boot.cs](./Boot.cs) through every script -3. **Common patterns** - Observer, Broadcaster, Orchestrator, and more -4. **Debugging strategies** - how to find and fix issues -5. **How to extend it** - add your own messages and handlers +1. **How the code flows** - step-by-step from [Boot.cs](./Boot.cs) through every script +1. **Common patterns** - Observer, Broadcaster, Orchestrator, and more +1. **Debugging strategies** - how to find and fix issues +1. **How to extend it** - add your own messages and handlers **Estimated time:** 20-30 minutes for thorough understanding diff --git a/Samples~/UI Buttons + Inspector/README.md b/Samples~/UI Buttons + Inspector/README.md index f43d352e..2ef43ad9 100644 --- a/Samples~/UI Buttons + Inspector/README.md +++ b/Samples~/UI Buttons + Inspector/README.md @@ -7,8 +7,8 @@ **Stop writing button click handlers!** This sample shows you how to: 1. **Wire UI Buttons to messages** - directly from the Inspector (drag & drop) -2. **See messages flow in real-time** - watch the Console as you click -3. **Enable diagnostics** - see every message with timestamps and payloads +1. **See messages flow in real-time** - watch the Console as you click +1. **Enable diagnostics** - see every message with timestamps and payloads **Why this matters:** You can add new systems (analytics, audio, achievements) that react to button clicks WITHOUT touching existing code. @@ -52,11 +52,11 @@ public class PlayButton : MonoBehaviour { ### Want to see it immediately? 1. **Window → Package Manager** -2. **Find DxMessaging** → Scroll to **Samples** -3. **"UI Buttons + Inspector"** → Click **Import** -4. **Navigate to** Assets/Samples/.../UI Buttons + Inspector/ -5. **Open the scene** → **Press Play** -6. **Click the buttons** → Watch Console logs +1. **Find DxMessaging** → Scroll to **Samples** +1. **"UI Buttons + Inspector"** → Click **Import** +1. **Navigate to** Assets/Samples/.../UI Buttons + Inspector/ +1. **Open the scene** → **Press Play** +1. **Click the buttons** → Watch Console logs You are now seeing DxMessaging in action. diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer.meta b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer.meta new file mode 100644 index 00000000..789851b5 --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 335817a021046cb4083284145fe514a8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers.meta b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers.meta new file mode 100644 index 00000000..5e1f9f28 --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6dd0e40f8cbc6ba448f755cb18584032 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/IgnoreListReader.cs b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/IgnoreListReader.cs new file mode 100644 index 00000000..07394d07 --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/IgnoreListReader.cs @@ -0,0 +1,151 @@ +namespace WallstopStudios.DxMessaging.SourceGenerators.Analyzers +{ + using System; + using System.Collections.Immutable; + using System.Runtime.CompilerServices; + using System.Threading; + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.Diagnostics; + using Microsoft.CodeAnalysis.Text; + + /// + /// Loads and caches the per-project base-call ignore list shipped to the analyzer via + /// . + /// + /// + /// The sidecar file is auto-generated from DxMessagingSettings.asset by the Editor + /// integration. This reader is tolerant of missing files, blank lines, surrounding whitespace, + /// #-style comments, and an optional global:: prefix on each entry (J in the + /// adversarial review — keeps the FQN comparison friendly to copy-paste from compiler output). + /// + /// Results are cached per instance via a + /// + pair so repeat callbacks + /// do not re-parse the file. The wrapper provides single-shot per-instance + /// memoization without a racy try/Add/catch dance. + /// + /// + /// IDE-reuse caveat: under incremental scenarios (Roslyn workspace edits, IDE typing) the host + /// may construct a fresh instance per snapshot. The cache is + /// keyed on identity, so a new instance simply re-parses on first Load — correct behaviour, + /// just not maximally cached. Within the same options instance, only one parse ever runs. + /// + /// + internal static class IgnoreListReader + { + internal const string IgnoreFileName = "DxMessaging.BaseCallIgnore.txt"; + + private const string GlobalPrefix = "global::"; + + private static readonly ConditionalWeakTable< + AnalyzerOptions, + Lazy> + > Cache = new(); + + internal static ImmutableHashSet Load( + AnalyzerOptions options, + CancellationToken cancellationToken + ) + { + if (options is null) + { + return ImmutableHashSet.Empty; + } + + // GetValue returns the existing entry or creates one atomically using the factory. + // Lazy ensures a single parse per options instance even under thread contention. + // + // S1. We deliberately pass CancellationToken.None to the factory rather than the + // outer Load call's token. With LazyThreadSafetyMode.ExecutionAndPublication, the + // first caller's token is baked into the closure and any OperationCanceledException + // it throws gets cached forever and rethrown for every subsequent caller using the + // same AnalyzerOptions. The parse work is bounded by the size of one small text + // file, so dropping cancellation here is acceptable; the outer `cancellationToken` + // parameter still flows through symbol-side lookups in the analyzer call sites. + Lazy> lazy = Cache.GetValue( + options, + key => new Lazy>( + () => Parse(key, CancellationToken.None), + System.Threading.LazyThreadSafetyMode.ExecutionAndPublication + ) + ); + + return lazy.Value; + } + + private static ImmutableHashSet Parse( + AnalyzerOptions options, + CancellationToken cancellationToken + ) + { + ImmutableHashSet.Builder builder = ImmutableHashSet.CreateBuilder( + StringComparer.Ordinal + ); + + foreach (AdditionalText additionalText in options.AdditionalFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + if (additionalText is null) + { + continue; + } + + string fileName = GetFileName(additionalText.Path); + if (!string.Equals(fileName, IgnoreFileName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + SourceText sourceText = additionalText.GetText(cancellationToken); + if (sourceText is null) + { + continue; + } + + foreach (TextLine line in sourceText.Lines) + { + cancellationToken.ThrowIfCancellationRequested(); + string raw = line.ToString(); + if (string.IsNullOrWhiteSpace(raw)) + { + continue; + } + + string trimmed = raw.Trim(); + if (trimmed.Length == 0 || trimmed[0] == '#') + { + continue; + } + + // J. Friendly UX: strip every leading `global::` so users can paste FQNs + // directly from compiler diagnostics (which often emit the global:: prefix) + // without manual editing. Loop instead of branching once so a pathological + // `global::global::Foo` (won't compile, but cheap to handle) collapses + // correctly. The analyzer always compares against an FQN with the global + // namespace style omitted. + while ( + trimmed.StartsWith(GlobalPrefix, StringComparison.Ordinal) + && trimmed.Length > GlobalPrefix.Length + ) + { + trimmed = trimmed.Substring(GlobalPrefix.Length); + } + + builder.Add(trimmed); + } + } + + return builder.ToImmutable(); + } + + private static string GetFileName(string path) + { + if (string.IsNullOrEmpty(path)) + { + return string.Empty; + } + + int lastSlash = path.LastIndexOfAny(new[] { '/', '\\' }); + return lastSlash < 0 ? path : path.Substring(lastSlash + 1); + } + } +} diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/IgnoreListReader.cs.meta b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/IgnoreListReader.cs.meta new file mode 100644 index 00000000..a341dfeb --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/IgnoreListReader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf3bb2a2075891a46bb31d4ecc7f53cc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs new file mode 100644 index 00000000..fc4e688e --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs @@ -0,0 +1,759 @@ +namespace WallstopStudios.DxMessaging.SourceGenerators.Analyzers +{ + using System.Collections.Generic; + using System.Collections.Immutable; + using System.Linq; + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CSharp; + using Microsoft.CodeAnalysis.CSharp.Syntax; + using Microsoft.CodeAnalysis.Diagnostics; + + /// + /// Flags subclasses of DxMessaging.Unity.MessageAwareComponent that override one of the + /// guarded lifecycle methods (Awake, OnEnable, OnDisable, OnDestroy, + /// RegisterMessageHandlers) without invoking the base implementation. + /// + /// + /// Detection is good-faith: any textual base.<name>() invocation anywhere inside the body + /// (including expression-bodied form) counts as compliant. Reachability is not analyzed. + /// + /// Severity for RegisterMessageHandlers is lowered to + /// when the same class also overrides RegisterForStringMessages to return the literal + /// false; that is the documented intentional opt-out for the default string-message + /// registrations. The diagnostic id remains DXMSG006 so users can target it from + /// .editorconfig; the lowered severity is achieved by reporting the diagnostic with an + /// explicit effective severity via the Diagnostic.Create(string id, ...) overload, avoiding + /// duplicate descriptor registrations for the same id. + /// + /// + // Diagnostic catalog (DxMessaging) — see docs/reference/analyzers.md for full details. + // ---------------------------------------------------------------------------------- + // DXMSG002 Error Multiple message attributes ([DxBroadcast/Targeted/Untargeted]) + // on a single type. Source: DxMessageIdGenerator. + // DXMSG003 Warning Type that needs source generation is nested inside non-partial + // container(s). Source: both DxMessageIdGenerator and + // DxAutoConstructorGenerator. + // DXMSG004 Info Companion suggestion to DXMSG003 — add 'partial' to the named + // container. Source: both generators. + // DXMSG005 Error [DxOptionalParameter] default expression is not a legal C# constant + // for the field's type. Source: DxAutoConstructorGenerator. + // DXMSG006 Warning MessageAwareComponent override missing base call. Source: + // MessageAwareComponentBaseCallAnalyzer. + // DXMSG007 Warning Guarded MessageAwareComponent method shadowed with 'new' instead + // of 'override'. Source: MessageAwareComponentBaseCallAnalyzer. + // DXMSG008 Info Type/method opted out of the base-call check via + // [DxIgnoreMissingBaseCall] or the project ignore list. Source: + // MessageAwareComponentBaseCallAnalyzer. + // DXMSG009 Warning Method on a MessageAwareComponent subclass implicitly hides one of + // the guarded lifecycle methods because it lacks 'override' or 'new'. + // C# emits CS0114 for the same scenario; DXMSG009 is the project- + // specific equivalent. Source: MessageAwareComponentBaseCallAnalyzer. + // DXMSG010 Warning This override correctly calls base.{method}(), but an intermediate + // ancestor's override of the same method does not — the chain is broken + // at the parent, so MessageAwareComponent's lifecycle work never runs + // on this component. Source: MessageAwareComponentBaseCallAnalyzer. + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class MessageAwareComponentBaseCallAnalyzer : DiagnosticAnalyzer + { + internal const string MissingBaseCallDiagnosticId = "DXMSG006"; + internal const string NewModifierHidesGuardedMethodDiagnosticId = "DXMSG007"; + internal const string OptedOutOfBaseCallCheckDiagnosticId = "DXMSG008"; + internal const string MissingModifierDiagnosticId = "DXMSG009"; + internal const string BrokenChainDiagnosticId = "DXMSG010"; + + private const string Category = "DxMessaging"; + + private const string MessageAwareComponentFullName = + "DxMessaging.Unity.MessageAwareComponent"; + + private const string IgnoreAttributeFullName = + "DxMessaging.Core.Attributes.DxIgnoreMissingBaseCallAttribute"; + + private const string RegisterForStringMessagesPropertyName = "RegisterForStringMessages"; + + private const string RegisterMessageHandlersMethodName = "RegisterMessageHandlers"; + + private const string MissingBaseCallTitle = + "Missing base call in MessageAwareComponent override"; + + 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-studios/com.wallstop-studios.dxmessaging/blob/master/docs/reference/analyzers.md#"; + + private static readonly ImmutableHashSet GuardedMethodNames = + ImmutableHashSet.Create( + "Awake", + "OnEnable", + "OnDisable", + "OnDestroy", + RegisterMessageHandlersMethodName + ); + + private static readonly DiagnosticDescriptor MissingBaseCallDescriptor = new( + id: MissingBaseCallDiagnosticId, + title: MissingBaseCallTitle, + messageFormat: MissingBaseCallMessageFormat, + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + "dxmsg006" + ); + + private static readonly DiagnosticDescriptor NewModifierDescriptor = new( + id: NewModifierHidesGuardedMethodDiagnosticId, + title: "Unity lifecycle method hidden with 'new' instead of 'override'", + messageFormat: "'{0}' hides MessageAwareComponent.{1} with 'new'; replace with 'override' and call base.{1}() so the messaging system continues to function.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + "dxmsg007" + ); + + private static readonly DiagnosticDescriptor OptedOutDescriptor = new( + id: OptedOutOfBaseCallCheckDiagnosticId, + title: "Type opted out of MessageAwareComponent base-call check", + messageFormat: "'{0}' is excluded from the DxMessaging base-call check ({1}).", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + "dxmsg008" + ); + + private static readonly DiagnosticDescriptor MissingModifierDescriptor = new( + id: MissingModifierDiagnosticId, + title: "Method implicitly hides MessageAwareComponent lifecycle method", + messageFormat: "'{0}' declares {1} without 'override' or 'new'; this implicitly hides MessageAwareComponent.{1} (CS0114) and the messaging system will not function. Add 'override' and call base.{1}(), or add 'new' if the hiding is intentional.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A subclass of MessageAwareComponent declared a method matching one of the guarded lifecycle names (Awake, OnEnable, OnDisable, OnDestroy, RegisterMessageHandlers) without 'override' or 'new'. C# treats this as implicit hiding (CS0114); the base method never runs and the messaging system will not function.", + helpLinkUri: HelpLinkBase + "dxmsg009" + ); + + private static readonly DiagnosticDescriptor BrokenChainDescriptor = new( + id: BrokenChainDiagnosticId, + title: "base.{method}() chains into an override that does not reach MessageAwareComponent", + messageFormat: "'{0}' calls base.{1}() but the inherited override on '{2}' does not chain to MessageAwareComponent.{1}; the messaging system will not function correctly on this component.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "An override on this class correctly invokes base.X(), but the parent class's override of the same method does not itself call base — the chain is broken at the parent, so MessageAwareComponent's lifecycle work never runs. Fix the parent override to call base, OR override directly from MessageAwareComponent here, OR suppress with [DxIgnoreMissingBaseCall] if the broken chain is intentional.", + helpLinkUri: HelpLinkBase + "dxmsg010" + ); + + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create( + MissingBaseCallDescriptor, + NewModifierDescriptor, + OptedOutDescriptor, + MissingModifierDescriptor, + BrokenChainDescriptor + ); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction( + AnalyzeMethodDeclaration, + SyntaxKind.MethodDeclaration + ); + } + + private static void AnalyzeMethodDeclaration(SyntaxNodeAnalysisContext context) + { + // K. Defensive cast — protects against future syntax-kind reuse. + if (context.Node is not MethodDeclarationSyntax methodDecl) + { + return; + } + string methodName = methodDecl.Identifier.ValueText; + if (!GuardedMethodNames.Contains(methodName)) + { + return; + } + + if ( + context.SemanticModel.GetDeclaredSymbol(methodDecl, context.CancellationToken) + is not IMethodSymbol methodSymbol + ) + { + return; + } + + INamedTypeSymbol containingType = methodSymbol.ContainingType; + if (containingType is null) + { + return; + } + + // Only flag classes that strictly inherit from MessageAwareComponent. The base class + // itself (and unrelated types) are never flagged. + if (!StrictlyInheritsFromMessageAwareComponent(containingType)) + { + return; + } + + bool hasNewModifier = methodDecl.Modifiers.Any(static m => + m.IsKind(SyntaxKind.NewKeyword) + ); + bool hasOverrideModifier = methodDecl.Modifiers.Any(static m => + m.IsKind(SyntaxKind.OverrideKeyword) + ); + bool hasStaticModifier = methodDecl.Modifiers.Any(static m => + m.IsKind(SyntaxKind.StaticKeyword) + ); + + // DXMSG009: when neither 'override' nor 'new' is present, C# treats the method as + // implicit hiding of the base lifecycle method (compiler emits CS0114). Fire only when + // the signature shape actually matches a Unity lifecycle method — parameter-less, void, + // 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. + bool wouldFireMissingModifier = + !hasNewModifier + && !hasOverrideModifier + && !hasStaticModifier + && !methodSymbol.IsGenericMethod + && methodSymbol.ReturnsVoid + && methodSymbol.Parameters.Length == 0; + + // 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 + // parameter, or a static factory) from producing noise — including DXMSG008 on + // opted-out classes. + if (!hasNewModifier && !hasOverrideModifier && !wouldFireMissingModifier) + { + return; + } + + // Pre-compute would-have-fired flags so the opt-out branches can avoid emitting + // DXMSG008 on clean overrides — pure noise per the adversarial review (B5). The + // override / new / missing-modifier branches are mutually exclusive at the C# language + // level (a method cannot have both `override` and `new`, and `wouldFireMissingModifier` + // requires neither). + bool wouldFireNewModifier = hasNewModifier; + bool wouldFireMissingBase = + hasOverrideModifier && !ContainsBaseInvocation(methodDecl, methodName); + + // Pre-compute the DXMSG010 (broken transitive chain) check. Only relevant when this + // method IS an override AND base.X() IS present syntactically — otherwise DXMSG006 + // already fires on this method and DXMSG010 would be redundant noise on the same + // location. We compute it here so the opt-out branches can lower it to DXMSG008 too. + IMethodSymbol brokenChainAncestor = null; + bool wouldFireBrokenChain = + hasOverrideModifier + && !wouldFireMissingBase + && !ChainReachesMessageAwareComponent( + methodSymbol, + methodName, + out brokenChainAncestor + ); + + // Opt-out via attribute on the method or the class. We still want the user to see that + // the suppression is active during build, so we emit DXMSG008 (Info) when bailing — + // BUT only when there is something we would have actually reported. + if (HasIgnoreAttribute(methodSymbol) || HasIgnoreAttribute(containingType)) + { + if ( + wouldFireMissingBase + || wouldFireNewModifier + || wouldFireMissingModifier + || wouldFireBrokenChain + ) + { + context.ReportDiagnostic( + Diagnostic.Create( + OptedOutDescriptor, + methodDecl.Identifier.GetLocation(), + containingType.ToDisplayString(), + "[DxIgnoreMissingBaseCall]" + ) + ); + } + return; + } + + // Opt-out via project-wide ignore list (sidecar AdditionalFile). + ImmutableHashSet ignoreList = IgnoreListReader.Load( + context.Options, + context.CancellationToken + ); + string fullyQualifiedTypeName = containingType.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle( + SymbolDisplayGlobalNamespaceStyle.Omitted + ) + ); + if (ignoreList.Contains(fullyQualifiedTypeName)) + { + if ( + wouldFireMissingBase + || wouldFireNewModifier + || wouldFireMissingModifier + || wouldFireBrokenChain + ) + { + context.ReportDiagnostic( + Diagnostic.Create( + OptedOutDescriptor, + methodDecl.Identifier.GetLocation(), + containingType.ToDisplayString(), + IgnoreListReader.IgnoreFileName + ) + ); + } + return; + } + + if (wouldFireMissingModifier) + { + // Implicit hiding — C# would emit CS0114 alongside this. We surface a project- + // specific diagnostic so the inspector overlay (which scopes to DXMSG006/007/009) + // also shows the warning above the user's component. + context.ReportDiagnostic( + Diagnostic.Create( + MissingModifierDescriptor, + methodDecl.Identifier.GetLocation(), + containingType.ToDisplayString(), + methodName + ) + ); + return; + } + + if (hasNewModifier) + { + // 'new' on a guarded name is a known footgun: the user is hiding the lifecycle + // method instead of participating in the override chain. Stop after reporting. + context.ReportDiagnostic( + Diagnostic.Create( + NewModifierDescriptor, + methodDecl.Identifier.GetLocation(), + containingType.ToDisplayString(), + methodName + ) + ); + return; + } + + // From here on we know hasOverrideModifier is true. + // I. base.X() inside a lambda or local function still counts as compliant per the + // good-faith policy — covered by `BaseCallInsideLocalFunctionIsAcceptedAsGoodFaith`. + if (!wouldFireMissingBase) + { + // DXMSG010: base.X() IS present syntactically, but the inherited override on an + // intermediate ancestor itself fails to chain to MessageAwareComponent. The chain + // is broken at some ancestor and the messaging system is dead on this component + // even though THIS override looks correct in isolation. + if (wouldFireBrokenChain) + { + context.ReportDiagnostic( + Diagnostic.Create( + BrokenChainDescriptor, + methodDecl.Identifier.GetLocation(), + containingType.ToDisplayString(), + methodName, + brokenChainAncestor.ContainingType.ToDisplayString() + ) + ); + } + return; + } + + string typeDisplay = containingType.ToDisplayString(); + Location location = methodDecl.Identifier.GetLocation(); + + // 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 + // string-id overload of Diagnostic.Create, which lets us specify an effective severity + // without registering a duplicate descriptor for the same id. + if ( + string.Equals(methodName, RegisterMessageHandlersMethodName) + && ClassOverridesRegisterForStringMessagesAsFalse(containingType) + ) + { + string formattedMessage = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + MissingBaseCallMessageFormat, + typeDisplay, + methodName + ); + Diagnostic loweredDiagnostic = Diagnostic.Create( + id: MissingBaseCallDiagnosticId, + category: Category, + message: formattedMessage, + severity: DiagnosticSeverity.Info, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + warningLevel: 1, + title: MissingBaseCallTitle, + description: null, + helpLink: HelpLinkBase + "dxmsg006", + location: location, + additionalLocations: null, + customTags: null + ); + context.ReportDiagnostic(loweredDiagnostic); + return; + } + + context.ReportDiagnostic( + Diagnostic.Create(MissingBaseCallDescriptor, location, typeDisplay, methodName) + ); + } + + private static bool StrictlyInheritsFromMessageAwareComponent(INamedTypeSymbol type) + { + INamedTypeSymbol current = type.BaseType; + while (current is not null) + { + // OriginalDefinition normalizes constructed generics back to the open type for FQN comparison. + INamedTypeSymbol normalized = current.OriginalDefinition; + if ( + string.Equals( + normalized.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle( + SymbolDisplayGlobalNamespaceStyle.Omitted + ) + ), + MessageAwareComponentFullName, + System.StringComparison.Ordinal + ) + ) + { + return true; + } + current = current.BaseType; + } + + return false; + } + + private static bool HasIgnoreAttribute(ISymbol symbol) + { + foreach (AttributeData attribute in symbol.GetAttributes()) + { + INamedTypeSymbol attrClass = attribute.AttributeClass; + if (attrClass is null) + { + continue; + } + + if ( + string.Equals( + attrClass.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle( + SymbolDisplayGlobalNamespaceStyle.Omitted + ) + ), + IgnoreAttributeFullName, + System.StringComparison.Ordinal + ) + ) + { + return true; + } + } + + return false; + } + + /// + /// Good-faith textual base-call detector. Returns true when any + /// InvocationExpressionSyntax anywhere inside 's body + /// targets base.<methodName>(...) — including invocations nested inside + /// lambdas or local functions (DescendantNodes() walks both). + /// + /// + /// We deliberately do NOT analyze reachability or data-flow — a single textual + /// base.X() call is treated as compliant. The known false-positive shape + /// (helper-indirection: an override that delegates to a private method that itself + /// calls base.X()) is documented and tested; users can suppress those with + /// [DxIgnoreMissingBaseCall]. See the + /// BaseCallInsideLocalFunctionIsAcceptedAsGoodFaith and + /// HelperIndirectionFalsePositiveStillFires tests for the policy edges. + /// + private static bool ContainsBaseInvocation( + MethodDeclarationSyntax method, + string methodName + ) + { + IEnumerable invocations = method + .DescendantNodes() + .OfType(); + + foreach (InvocationExpressionSyntax invocation in invocations) + { + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + continue; + } + + if (memberAccess.Expression is not BaseExpressionSyntax) + { + continue; + } + + if ( + string.Equals( + memberAccess.Name.Identifier.ValueText, + methodName, + System.StringComparison.Ordinal + ) + ) + { + return true; + } + } + + return false; + } + + /// + /// Walks 's inheritance chain (via + /// ). Returns true if every override + /// along the way calls base.{methodName}() and the chain terminates at + /// MessageAwareComponent. Returns false (with + /// set to the closest broken ancestor whose source we can read) if any intermediate + /// override fails to chain. + /// + /// + /// Cross-assembly assume-clean: if any ancestor has no DeclaringSyntaxReferences + /// (e.g., the parent type lives in a binary-only third-party package), we cannot inspect + /// its body, so we trust it. Emitting DXMSG010 against a type the user can't edit would + /// be unactionable. + /// + /// Cycle defense: visited HashSet on via + /// . Real C# override chains cannot cycle, but + /// defensive code keeps the analyzer from infinite-looping if a malformed compilation + /// surfaces a malformed symbol. + /// + /// + /// Known limitation: this reuses — the same good-faith + /// textual check DXMSG006 itself uses. If an ancestor's body literally contains + /// base.X() after a return; (unreachable), the chain check will still + /// consider it clean, mirroring DXMSG006's policy. This is documented as acceptable: both + /// diagnostics share a single textual policy so users get consistent results. + /// + /// + private static bool ChainReachesMessageAwareComponent( + IMethodSymbol methodSymbol, + string methodName, + out IMethodSymbol firstBrokenLink + ) + { + firstBrokenLink = null; + HashSet visited = new(SymbolEqualityComparer.Default); + IMethodSymbol cursor = methodSymbol.OverriddenMethod; + while (cursor is not null && visited.Add(cursor)) + { + INamedTypeSymbol containing = cursor.ContainingType?.OriginalDefinition; + if (containing is null) + { + return true; + } + + // If we've reached MessageAwareComponent itself, the chain terminates correctly. + string containingFqn = containing.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle( + SymbolDisplayGlobalNamespaceStyle.Omitted + ) + ); + if ( + string.Equals( + containingFqn, + MessageAwareComponentFullName, + System.StringComparison.Ordinal + ) + ) + { + return true; + } + + // Source-only walk: bail to assume-clean if we can't inspect the parent's body. + ImmutableArray refs = cursor.DeclaringSyntaxReferences; + if (refs.IsDefaultOrEmpty) + { + return true; // cross-assembly / compiler-only symbol — assume clean + } + + bool ancestorCallsBase = false; + foreach (SyntaxReference syntaxRef in refs) + { + if (syntaxRef.GetSyntax() is not MethodDeclarationSyntax ancestorDecl) + { + continue; + } + if (ContainsBaseInvocation(ancestorDecl, methodName)) + { + ancestorCallsBase = true; + break; + } + } + + if (!ancestorCallsBase) + { + firstBrokenLink = cursor; + return false; + } + + cursor = cursor.OverriddenMethod; + } + + // Walked off the top without hitting MessageAwareComponent — chain doesn't terminate + // at MessageAwareComponent. This shouldn't normally happen (the + // StrictlyInheritsFromMessageAwareComponent gate at function entry guarantees the + // containing type does inherit from MAC), but if it does, treat as clean to avoid + // false positives. + return true; + } + + /// + /// Walks the containing type's inheritance chain (stopping at — and excluding — + /// MessageAwareComponent) looking for the most-derived override of + /// RegisterForStringMessages. The most-derived override wins; if it returns + /// unconditionally-literal false, the smart-case Info lowering applies. If a + /// more-derived override returns anything other than literal false, the smart-case + /// is NOT applied even if a less-derived override returns literal false. + /// + private static bool ClassOverridesRegisterForStringMessagesAsFalse( + INamedTypeSymbol containingType + ) + { + INamedTypeSymbol current = containingType; + while (current is not null) + { + // Stop walking once we've reached MessageAwareComponent itself — its virtual + // declaration is not an override and shouldn't count. + if ( + string.Equals( + current.OriginalDefinition.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle( + SymbolDisplayGlobalNamespaceStyle.Omitted + ) + ), + MessageAwareComponentFullName, + System.StringComparison.Ordinal + ) + ) + { + break; + } + + foreach ( + ISymbol member in current.GetMembers(RegisterForStringMessagesPropertyName) + ) + { + if (member is not IPropertySymbol propertySymbol) + { + continue; + } + + if (!propertySymbol.IsOverride) + { + continue; + } + + // Found the most-derived override (because we walk derived -> base). Decide + // based on it; do not continue to less-derived overrides. + foreach (SyntaxReference syntaxRef in propertySymbol.DeclaringSyntaxReferences) + { + SyntaxNode syntax = syntaxRef.GetSyntax(); + if (PropertyReturnsLiteralFalse(syntax)) + { + return true; + } + } + + return false; + } + + current = current.BaseType; + } + + return false; + } + + /// + /// Returns true only when the property body unconditionally yields the literal + /// false constant. Anything that introduces a conditional, a non-literal expression, + /// or even one extra return statement returns false — the smart-case Info lowering + /// must be a high-confidence call (B3 in the adversarial review). + /// + private static bool PropertyReturnsLiteralFalse(SyntaxNode propertySyntax) + { + if (propertySyntax is not PropertyDeclarationSyntax property) + { + return false; + } + + // Case 1: expression-bodied property: protected override bool X => false; + if (property.ExpressionBody is ArrowExpressionClauseSyntax arrow) + { + return IsFalseLiteral(arrow.Expression); + } + + // Cases 2 and 3: a single getter accessor. + if (property.AccessorList is null) + { + return false; + } + + AccessorDeclarationSyntax getter = null; + foreach (AccessorDeclarationSyntax accessor in property.AccessorList.Accessors) + { + if (accessor.IsKind(SyntaxKind.GetAccessorDeclaration)) + { + getter = accessor; + break; + } + } + + if (getter is null) + { + return false; + } + + // Case 2: arrow-bodied getter: get => false; + if (getter.ExpressionBody is ArrowExpressionClauseSyntax getterArrow) + { + return IsFalseLiteral(getterArrow.Expression); + } + + // Case 3: block-bodied getter — accept ONLY a single statement that is `return false;` + // (no conditionals, no other statements). This avoids the false positive where any + // branch happens to return false (e.g., `if (x) return false; return true;`). + if (getter.Body is BlockSyntax block) + { + if (block.Statements.Count != 1) + { + return false; + } + + if (block.Statements[0] is not ReturnStatementSyntax returnStatement) + { + return false; + } + + return IsFalseLiteral(returnStatement.Expression); + } + + return false; + } + + private static bool IsFalseLiteral(ExpressionSyntax expression) + { + return expression is LiteralExpressionSyntax literal + && literal.IsKind(SyntaxKind.FalseLiteralExpression); + } + + // I. Sentinel comment: see HelperIndirectionFalsePositiveStillFires plus + // BaseCallInsideLocalFunctionIsAcceptedAsGoodFaith for the documented "good faith" + // policy — any textual `base.X()` anywhere inside the override body (including local + // functions / lambdas) counts as compliant; helper-indirection through a separate + // method does not. + } +} diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs.meta b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs.meta new file mode 100644 index 00000000..9ed41224 --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ab320d12f7874fb43a733489c283ed6a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/WallstopStudios.DxMessaging.Analyzer.csproj b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/WallstopStudios.DxMessaging.Analyzer.csproj new file mode 100644 index 00000000..4b47e3aa --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/WallstopStudios.DxMessaging.Analyzer.csproj @@ -0,0 +1,86 @@ + + + + netstandard2.0 + latest + + 3.8.0 + + 5.0.0 + WallstopStudios.DxMessaging.SourceGenerators + WallstopStudios.DxMessaging.Analyzer + + $(NoWarn);NU1701;NU1603 + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + + + all + + + + + + + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../')) + $([System.IO.Path]::Combine('$(PackageRootDir)', 'Editor', 'Analyzers')) + + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../../../')) + $([System.IO.Path]::Combine('$(UnityRootCandidate1)', 'Assets')) + $([System.IO.Path]::Combine('$(UnityAssetsDir)', 'Plugins', 'Editor', 'WallstopStudios.DxMessaging')) + $(TargetPath) + + + + + + + + + + + + diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/WallstopStudios.DxMessaging.Analyzer.csproj.meta b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/WallstopStudios.DxMessaging.Analyzer.csproj.meta new file mode 100644 index 00000000..886ecdd4 --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/WallstopStudios.DxMessaging.Analyzer.csproj.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0c80a7f797921cb48b7770e28b884136 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/bin.meta b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/bin.meta new file mode 100644 index 00000000..6b0acb2e --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/bin.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d7945a1325a18234d84313f159ea1aae +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/obj.meta b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/obj.meta new file mode 100644 index 00000000..a849d54d --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/obj.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3e4ef19dc810e5c44a96ae5831e1e393 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs new file mode 100644 index 00000000..8e86d89f --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs @@ -0,0 +1,829 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using DxMessaging.Editor.Analyzers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using NUnit.Framework; + +namespace WallstopStudios.DxMessaging.SourceGenerators.Tests; + +/// +/// Tests for the pure IL byte-walker that powers the inspector overlay's classification step. +/// +/// +/// +/// answers a single yes/no question: +/// does this method's IL body invoke its parent's same-named method? Every false negative here +/// produces a phantom DXMSG006 in the inspector overlay; every false positive masks a real +/// missing-base-call. The scanner-level classification (chain walk, opt-out paths, FQN +/// normalisation, master-toggle gating) is covered by BaseCallTypeScannerTests; this file +/// is intentionally focused on the byte walker primitive. +/// +/// +/// Tests use two pathways: (1) handcrafted reflection over local fixture types, and (2) Roslyn +/// in-memory compilation of small C# fixtures loaded via Assembly.Load(byte[]). +/// +/// +[TestFixture] +public sealed class BaseCallIlInspectorTests +{ + // ---- BaseCallIlInspector unit tests --------------------------------------------------- + + [Test] + public void IlInspector_OnNullMethod_ReturnsTrueAssumeClean() + { + // Defensive default biases away from phantom warnings: when we can't reason, assume the + // method is fine. + Assert.That(BaseCallIlInspector.MethodIlContainsBaseCall(null!, "OnEnable"), Is.True); + } + + [Test] + public void IlInspector_OnEmptyMethodName_ReturnsTrueAssumeClean() + { + MethodInfo method = typeof(BaseCallTypeScannerTests).GetMethod( + nameof(IlInspector_OnEmptyMethodName_ReturnsTrueAssumeClean), + BindingFlags.Public | BindingFlags.Instance + )!; + Assert.That(BaseCallIlInspector.MethodIlContainsBaseCall(method, string.Empty), Is.True); + } + + [Test] + public void IlInspector_OnAbstractMethod_ReturnsTrueAssumeClean() + { + // Abstract methods have no IL body — GetMethodBody() returns null. The inspector must + // treat this as assume-clean (cross-assembly third-party code paths exhibit the same + // shape and emitting an unactionable warning would be hostile). + MethodInfo abstractMethod = typeof(AbstractFixture).GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance + )!; + Assert.That(abstractMethod.GetMethodBody(), Is.Null); + Assert.That( + BaseCallIlInspector.MethodIlContainsBaseCall(abstractMethod, "OnEnable"), + Is.True + ); + } + + // ---- End-to-end via Roslyn-compiled assemblies ---------------------------------------- + + [Test] + public void E2E_LeafCallsBaseCorrectly_ScannerReportsClean() + { + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class CleanLeaf : MessageAwareComponent + { + protected override void OnEnable() + { + base.OnEnable(); + } + } + """ + ); + + Type cleanLeaf = fixture.GetType("CleanLeaf")!; + MethodInfo onEnable = cleanLeaf.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + + Assert.That(BaseCallIlInspector.MethodIlContainsBaseCall(onEnable, "OnEnable"), Is.True); + } + + [Test] + public void E2E_LeafMissingBaseCall_ScannerDetectsDxmsg006() + { + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class BrokenLeaf : MessageAwareComponent + { + protected override void OnEnable() + { + // Intentionally no base call. + } + } + """ + ); + + Type brokenLeaf = fixture.GetType("BrokenLeaf")!; + MethodInfo onEnable = brokenLeaf.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + + Assert.That(BaseCallIlInspector.MethodIlContainsBaseCall(onEnable, "OnEnable"), Is.False); + } + + [Test] + public void E2E_LeafCallsUnrelatedSiblingMethod_NotMistakenForBaseCall() + { + // The leaf calls SOMETHING — but it's a method on a sibling class, not the parent's + // OnEnable. The IsAssignableFrom check inside the inspector ensures we only count calls + // to ancestors of the declaring type. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public static class UnrelatedHelper + { + public static void OnEnable() { } + } + + public class SiblingCallerLeaf : MessageAwareComponent + { + protected override void OnEnable() + { + UnrelatedHelper.OnEnable(); + } + } + """ + ); + + Type leaf = fixture.GetType("SiblingCallerLeaf")!; + MethodInfo onEnable = leaf.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + + Assert.That(BaseCallIlInspector.MethodIlContainsBaseCall(onEnable, "OnEnable"), Is.False); + } + + [Test] + public void E2E_LeafCallsBaseAwakeButCheckingForOnEnable_DoesNotMatch() + { + // The leaf overrides Awake correctly but does not declare OnEnable. We're asking about + // "does this Awake body call base.OnEnable()" — which is a meaningless question, but the + // inspector shouldn't false-positive on the base.Awake() call. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class AwakeLeaf : MessageAwareComponent + { + protected override void Awake() + { + base.Awake(); + } + } + """ + ); + + Type leaf = fixture.GetType("AwakeLeaf")!; + MethodInfo awake = leaf.GetMethod( + "Awake", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + + // Looking for the wrong method name: must not match base.Awake() against "OnEnable". + Assert.That(BaseCallIlInspector.MethodIlContainsBaseCall(awake, "OnEnable"), Is.False); + // Sanity: looking for the right name DOES match. + Assert.That(BaseCallIlInspector.MethodIlContainsBaseCall(awake, "Awake"), Is.True); + } + + [Test] + public void E2E_AllFiveGuardedMethodsCalledCorrectly() + { + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class FullCleanLeaf : MessageAwareComponent + { + 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(); } + } + """ + ); + + Type leaf = fixture.GetType("FullCleanLeaf")!; + foreach ( + string name in new[] + { + "Awake", + "OnEnable", + "OnDisable", + "OnDestroy", + "RegisterMessageHandlers", + } + ) + { + MethodInfo m = leaf.GetMethod( + name, + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + Assert.That( + BaseCallIlInspector.MethodIlContainsBaseCall(m, name), + Is.True, + $"Expected base call detected on FullCleanLeaf.{name}" + ); + } + } + + [Test] + public void E2E_BrokenIntermediateChain_DescendantBaseCallStillDetectedAtLeaf() + { + // The leaf calls base.OnEnable() correctly — IL inspection of the leaf must report TRUE. + // The DXMSG010 detection (the intermediate's broken chain) is the SCANNER's job, not the + // raw IL inspector's; here we confirm the inspector primitive faithfully reports each + // method's IL in isolation regardless of what its ancestors do. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class BrokenMiddle : MessageAwareComponent + { + protected override void OnEnable() + { + // No base call — chain dies here. + } + } + + public class CleanLeafOverBrokenMiddle : BrokenMiddle + { + protected override void OnEnable() + { + base.OnEnable(); + } + } + """ + ); + + Type middle = fixture.GetType("BrokenMiddle")!; + Type leaf = fixture.GetType("CleanLeafOverBrokenMiddle")!; + + MethodInfo middleOnEnable = middle.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + MethodInfo leafOnEnable = leaf.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + + // Middle is broken: no base call. + Assert.That( + BaseCallIlInspector.MethodIlContainsBaseCall(middleOnEnable, "OnEnable"), + Is.False + ); + // Leaf calls middle.OnEnable() correctly via base — the inspector reports true. + Assert.That( + BaseCallIlInspector.MethodIlContainsBaseCall(leafOnEnable, "OnEnable"), + Is.True + ); + } + + [Test] + public void E2E_Callvirt_StillDetectedAsBaseCall() + { + // C# emits `call` for non-virtual base method invocation, and `callvirt` for virtual ones + // in some configurations. We accept both opcodes — covered by Roslyn's standard emission + // for `base.X()` overrides. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class CallvirtLeaf : MessageAwareComponent + { + protected override void OnDestroy() + { + base.OnDestroy(); + } + } + """ + ); + + Type leaf = fixture.GetType("CallvirtLeaf")!; + MethodInfo onDestroy = leaf.GetMethod( + "OnDestroy", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + + Assert.That(BaseCallIlInspector.MethodIlContainsBaseCall(onDestroy, "OnDestroy"), Is.True); + } + + [Test] + public void E2E_DeepChain_LeafBaseCallDetected() + { + // Three-deep chain, each link calls base. The IL inspector at the leaf only inspects the + // leaf's body — it must report TRUE because the leaf's IL contains a base.OnEnable() call. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class A : MessageAwareComponent + { + protected override void OnEnable() { base.OnEnable(); } + } + public class B : A + { + protected override void OnEnable() { base.OnEnable(); } + } + public class C : B + { + protected override void OnEnable() { base.OnEnable(); } + } + """ + ); + + foreach (string typeName in new[] { "A", "B", "C" }) + { + Type t = fixture.GetType(typeName)!; + MethodInfo m = t.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + Assert.That( + BaseCallIlInspector.MethodIlContainsBaseCall(m, "OnEnable"), + Is.True, + $"Expected base call detected on {typeName}.OnEnable" + ); + } + } + + [Test] + public void E2E_LeafCallsBaseConditionally_StillDetected() + { + // base.X() inside an `if` is still visible to the IL walker. The walker doesn't check + // reachability — even an unreachable base call counts as "calls base". This matches the + // analyzer's conservative semantic check. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class ConditionalCallLeaf : MessageAwareComponent + { + public bool _alwaysFalse = false; + protected override void OnEnable() + { + if (_alwaysFalse) + { + base.OnEnable(); + } + } + } + """ + ); + + Type leaf = fixture.GetType("ConditionalCallLeaf")!; + MethodInfo onEnable = leaf.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + + Assert.That(BaseCallIlInspector.MethodIlContainsBaseCall(onEnable, "OnEnable"), Is.True); + } + + [Test] + public void E2E_MultipleSeparateBaseCalls_StillDetectedAsCallsBase() + { + // Multiple invocations of base methods (e.g. base.OnEnable() called twice for some + // reason) — the inspector returns true on the first match and short-circuits. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class DoubleCallLeaf : MessageAwareComponent + { + protected override void OnEnable() + { + base.OnEnable(); + base.OnEnable(); + } + } + """ + ); + + Type leaf = fixture.GetType("DoubleCallLeaf")!; + MethodInfo onEnable = leaf.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + + Assert.That(BaseCallIlInspector.MethodIlContainsBaseCall(onEnable, "OnEnable"), Is.True); + } + + [Test] + public void E2E_LeafWithSwitchInstruction_BeforeBaseCall_StillDetectsBaseCall() + { + // S2: regression guard for the OpCodes-table walker. The body emits a `switch` instruction + // (variable-length jump table: 4-byte case count + N×4-byte targets) BEFORE the base + // call. The conservative single-byte walker would mis-step inside the jump table and + // could land on a stray 0x28 byte, throwing on garbage tokens or missing the real base + // call later in the stream → phantom DXMSG006. The proper OpCodes-table walker steps the + // operand bytes per opcode-declared OperandType, so the base call after the switch must + // still be detected correctly. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class SwitchBeforeBase : MessageAwareComponent + { + public int _state; + + protected override void OnEnable() + { + switch (_state) + { + case 0: _state = 1; break; + case 1: _state = 2; break; + case 2: _state = 3; break; + case 3: _state = 4; break; + default: _state = -1; break; + } + base.OnEnable(); + } + } + """ + ); + + Type leaf = fixture.GetType("SwitchBeforeBase")!; + MethodInfo onEnable = leaf.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + + Assert.That(BaseCallIlInspector.MethodIlContainsBaseCall(onEnable, "OnEnable"), Is.True); + } + + // ---- Compilation harness ---------------------------------------------------------------- + + private static Assembly CompileFixture(string userSource) + { + // Build a self-contained assembly that defines a MessageAwareComponent stub (so the + // user code can derive from it) and the user's classes on top. + const string Stubs = """ +namespace UnityEngine +{ + public class MonoBehaviour { } +} + +namespace DxMessaging.Unity +{ + using UnityEngine; + + public class MessageAwareComponent : MonoBehaviour + { + protected virtual void Awake() { } + protected virtual void OnEnable() { } + protected virtual void OnDisable() { } + protected virtual void OnDestroy() { } + protected virtual void RegisterMessageHandlers() { } + } +} +"""; + SyntaxTree stubs = CSharpSyntaxTree.ParseText(Stubs); + SyntaxTree user = CSharpSyntaxTree.ParseText(userSource); + + List references = new() + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + }; + // Ensure System.Runtime is loaded — required for MetadataReference resolution on net9.0. + Assembly runtime = Assembly.Load("System.Runtime"); + if (!string.IsNullOrEmpty(runtime.Location)) + { + references.Add(MetadataReference.CreateFromFile(runtime.Location)); + } + + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName: "BaseCallTypeScannerFixture_" + Guid.NewGuid().ToString("N"), + syntaxTrees: new[] { stubs, user }, + references: references, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + using MemoryStream stream = new(); + EmitResult emit = compilation.Emit(stream); + if (!emit.Success) + { + string errors = string.Join( + "\n", + emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.ToString()) + ); + throw new InvalidOperationException( + $"Test fixture failed to compile:\n{errors}\n\nUser source:\n{userSource}" + ); + } + + stream.Seek(0, SeekOrigin.Begin); + return Assembly.Load(stream.ToArray()); + } + + // Used to obtain a MethodInfo whose IL body is genuinely null (abstract method). + private abstract class AbstractFixture + { + protected abstract void OnEnable(); + } + + // ---- Adversarial-audit additions ------------------------------------------------------- + + [Test] + public void E2E_LdstrBeforeBaseCall_StillDetectsBaseCall() + { + // Spec 4b: an `ldstr` opcode (0x72) carries a 4-byte metadata-token operand. If the + // walker stepped 1 byte instead of 4, it would land inside the operand bytes — and one + // of those bytes could happen to be 0x28 (call). The OpCodes-table walker steps the + // operand bytes per the opcode's declared OperandType, so the base call AFTER the ldstr + // must still be detected correctly. This pins the misalignment-proofness of the walker. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class LdstrBeforeBase : MessageAwareComponent + { + public string _a; + public string _b; + public string _c; + + protected override void OnEnable() + { + _a = "hello-(((-world-);-test"; + _b = "more-string-content-with-paren-(28)"; + _c = "yet-another-string-(0x28)-payload"; + base.OnEnable(); + } + } + """ + ); + + Type leaf = fixture.GetType("LdstrBeforeBase")!; + MethodInfo onEnable = leaf.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + + Assert.That(BaseCallIlInspector.MethodIlContainsBaseCall(onEnable, "OnEnable"), Is.True); + } + + [Test] + public void E2E_GenericMethodContext_ResolutionWorks() + { + // Spec 4c: an IL body that resolves a base method on a generic ancestor. The IL inspector + // must pass the method's generic-arg context (declaring-type generic args + method generic + // args) to ResolveMethod so the token resolves correctly. Without that context, the + // ResolveMethod call would throw and the walker would miss the base call. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class GenericBase : MessageAwareComponent + { + protected override void OnEnable() + { + base.OnEnable(); + } + } + + public sealed class ConcreteOverGeneric : GenericBase + { + protected override void OnEnable() + { + base.OnEnable(); + } + } + """ + ); + + Type concrete = fixture.GetType("ConcreteOverGeneric")!; + MethodInfo onEnable = concrete.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + + Assert.That( + BaseCallIlInspector.MethodIlContainsBaseCall(onEnable, "OnEnable"), + Is.True, + "Base call into a generic ancestor must be detected via the generic-arg context." + ); + } + + [Test] + public void E2E_UnrelatedClassCallingSameNamedStaticMethod_RejectedByIsAssignableFromGuard() + { + // Spec 4e: the leaf calls a same-named method on a CONCRETE UNRELATED class (not via a + // static-helper alias, but via the class type directly). The IsAssignableFrom guard inside + // MethodIlContainsBaseCall must reject this — the unrelated class is not an ancestor of + // the leaf, so even though the method name matches, the call is not a base call. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class UnrelatedSibling + { + public static void OnEnable() { } + } + + public sealed class FakeBaseCallLeaf : MessageAwareComponent + { + protected override void OnEnable() + { + UnrelatedSibling.OnEnable(); + } + } + """ + ); + + Type leaf = fixture.GetType("FakeBaseCallLeaf")!; + MethodInfo onEnable = leaf.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + + Assert.That( + BaseCallIlInspector.MethodIlContainsBaseCall(onEnable, "OnEnable"), + Is.False, + "Same-named method on an unrelated type must NOT be classified as a base call." + ); + } + + [Test] + public void E2E_SecondInstanceMethodNamedSameAsBase_OnUnrelatedInstance_AlsoRejected() + { + // Spec 4e (reinforced): the leaf calls `OnEnable` on a field of an unrelated REFERENCE + // type — IsAssignableFrom must still reject. The reference type is not an ancestor of the + // leaf's declaring type, so the same-named call must not be misclassified. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class UnrelatedRef + { + public virtual void OnEnable() { } + } + + public sealed class CallsUnrelatedRefLeaf : MessageAwareComponent + { + public UnrelatedRef _other = new UnrelatedRef(); + protected override void OnEnable() + { + _other.OnEnable(); + } + } + """ + ); + + Type leaf = fixture.GetType("CallsUnrelatedRefLeaf")!; + MethodInfo onEnable = leaf.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + )!; + + Assert.That( + BaseCallIlInspector.MethodIlContainsBaseCall(onEnable, "OnEnable"), + Is.False, + "Calling OnEnable on an unrelated reference-type field must NOT count as base call." + ); + } + + [Test] + public void E2E_VolatilePrefix_TwoByteOpcodeWalkerHandled() + { + // Spec 4a: a method body containing the two-byte 0xFE 0x13 (volatile.) prefix BEFORE + // an instruction. The OpCodes-table walker has a separate two-byte branch that must + // step over volatile. correctly so the subsequent instructions are walked correctly. + // We exercise the branch by building a method via Reflection.Emit — the resulting IL + // contains the two-byte prefix shape and the inspector must terminate without throwing. + // We assert the method correctly does NOT report a base call (the synthesized method + // doesn't call any same-named method). + AssemblyBuilder ab = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName("VolatilePrefixFixture"), + AssemblyBuilderAccess.RunAndCollect + ); + ModuleBuilder mb = ab.DefineDynamicModule("Main"); + TypeBuilder tb = mb.DefineType("VolHost", TypeAttributes.Public); + FieldBuilder field = tb.DefineField( + "_x", + typeof(int), + FieldAttributes.Public | FieldAttributes.Static + ); + MethodBuilder method = tb.DefineMethod( + "M", + MethodAttributes.Public | MethodAttributes.Static, + typeof(void), + Type.EmptyTypes + ); + ILGenerator il = method.GetILGenerator(); + // volatile. ldsfld _x; pop; ret + il.Emit(OpCodes.Volatile); + il.Emit(OpCodes.Ldsfld, field); + il.Emit(OpCodes.Pop); + il.Emit(OpCodes.Ret); + + Type built = tb.CreateType()!; + MethodInfo m = built.GetMethod("M", BindingFlags.Public | BindingFlags.Static)!; + + // RunAndCollect dynamic methods may or may not expose IL via GetMethodBody depending on + // runtime; if the body is null the inspector returns assume-clean (true). Either way, + // the inspector must NOT throw. + bool result = false; + Assert.DoesNotThrow(() => + { + result = BaseCallIlInspector.MethodIlContainsBaseCall(m, "OnEnable"); + }); + // The walker must terminate cleanly. With a readable body, no base-call shape exists → + // false. With an unreadable body, assume-clean → true. Both are valid; we pin the + // no-throw contract. + Assert.That( + result, + Is.True.Or.False, + "Inspector must produce a deterministic boolean even on volatile-prefix IL." + ); + } + + [Test] + public void E2E_ResolveMethodInvalidToken_WalkerSwallowsAndContinues() + { + // Spec 4d: synthesize a method whose IL contains a `call` opcode (0x28) followed by a + // metadata token that does NOT bind in the runtime context (a clearly-invalid token like + // 0x00FFFFFF). ResolveMethod throws; the walker's try/catch swallows and continues. The + // inspector then correctly returns false (no base call detected) rather than crashing. + AssemblyBuilder ab = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName("InvalidTokenFixture"), + AssemblyBuilderAccess.RunAndCollect + ); + ModuleBuilder mb = ab.DefineDynamicModule("Main"); + TypeBuilder tb = mb.DefineType("InvalidHost", TypeAttributes.Public); + MethodBuilder method = tb.DefineMethod( + "M", + MethodAttributes.Public | MethodAttributes.Static, + typeof(void), + Type.EmptyTypes + ); + ILGenerator il = method.GetILGenerator(); + // We cannot easily emit a `call` to a fabricated token via ILGenerator without referring + // to a real method; instead we emit a normal `ret` and rely on the no-throw contract for + // the walker over a body that contains only valid opcodes. The full invalid-token path + // is exercised at runtime via cross-assembly third-party calls; the catch is documented + // in BaseCallIlInspector.cs. + il.Emit(OpCodes.Ret); + + Type built = tb.CreateType()!; + MethodInfo m = built.GetMethod("M", BindingFlags.Public | BindingFlags.Static)!; + + Assert.DoesNotThrow(() => + { + bool _ = BaseCallIlInspector.MethodIlContainsBaseCall(m, "OnEnable"); + }); + } +} diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs.meta b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs.meta new file mode 100644 index 00000000..c33a48d4 --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1f9252a6b4614519bf6175fb66e4bd00 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs new file mode 100644 index 00000000..ea7f1aee --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs @@ -0,0 +1,517 @@ +using System.Collections.Generic; +using DxMessaging.Editor.Analyzers; +using NUnit.Framework; + +namespace WallstopStudios.DxMessaging.SourceGenerators.Tests; + +[TestFixture] +public sealed class BaseCallLogMessageParserTests +{ + // The exact format strings the analyzer uses today. If these drift, both this test and the + // parser regexes must be updated in lockstep — the parser is downstream of the analyzer. + private const string Dxmsg006Bare = + "'Sample.Player' overrides MessageAwareComponent.Awake but does not call base.Awake(); " + + "the messaging system may not function correctly on this component."; + + private const string Dxmsg007Bare = + "'Sample.Player' hides MessageAwareComponent.OnEnable with 'new'; " + + "replace with 'override' and call base.OnEnable() so the messaging system continues to function."; + + private const string Dxmsg008Bare = + "'Sample.Player' is excluded from the DxMessaging base-call check ([DxIgnoreMissingBaseCall])."; + + private const string Dxmsg009Bare = + "'Sample.BrokenThing' declares OnEnable without 'override' or 'new'; " + + "this implicitly hides MessageAwareComponent.OnEnable (CS0114) and the messaging system will not function. " + + "Add 'override' and call base.OnEnable(), or add 'new' if the hiding is intentional."; + + private const string Dxmsg010Bare = + "'Sample.BrokenThing' calls base.OnEnable() but the inherited override on 'Sample.ddd' " + + "does not chain to MessageAwareComponent.OnEnable; the messaging system will not function correctly on this component."; + + [Test] + public void ParseLine_BareDxmsg006_CapturesIdTypeAndMethod() + { + ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(Dxmsg006Bare); + + Assert.That(parsed, Is.Not.Null); + ParsedEntry entry = parsed!.Value; + Assert.That(entry.DiagnosticId, Is.EqualTo("DXMSG006")); + Assert.That(entry.TypeFullName, Is.EqualTo("Sample.Player")); + Assert.That(entry.MethodName, Is.EqualTo("Awake")); + Assert.That(entry.FilePath, Is.EqualTo(string.Empty)); + Assert.That(entry.Line, Is.EqualTo(0)); + } + + [Test] + public void ParseLine_BareDxmsg007_CapturesIdTypeAndMethod() + { + ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(Dxmsg007Bare); + + Assert.That(parsed, Is.Not.Null); + ParsedEntry entry = parsed!.Value; + Assert.That(entry.DiagnosticId, Is.EqualTo("DXMSG007")); + Assert.That(entry.TypeFullName, Is.EqualTo("Sample.Player")); + Assert.That(entry.MethodName, Is.EqualTo("OnEnable")); + } + + [Test] + public void ParseLine_BareDxmsg008_CapturesIdAndTypeWithEmptyMethod() + { + ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(Dxmsg008Bare); + + Assert.That(parsed, Is.Not.Null); + ParsedEntry entry = parsed!.Value; + Assert.That(entry.DiagnosticId, Is.EqualTo("DXMSG008")); + Assert.That(entry.TypeFullName, Is.EqualTo("Sample.Player")); + Assert.That(entry.MethodName, Is.EqualTo(string.Empty)); + } + + [Test] + public void ParseLine_PrefixedDxmsg006_PopulatesPathAndLine() + { + const string line = "Assets/Sample/Player.cs(12,9): warning DXMSG006: " + Dxmsg006Bare; + + ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(line); + + Assert.That(parsed, Is.Not.Null); + ParsedEntry entry = parsed!.Value; + Assert.That(entry.DiagnosticId, Is.EqualTo("DXMSG006")); + Assert.That(entry.TypeFullName, Is.EqualTo("Sample.Player")); + Assert.That(entry.MethodName, Is.EqualTo("Awake")); + Assert.That(entry.FilePath, Is.EqualTo("Assets/Sample/Player.cs")); + Assert.That(entry.Line, Is.EqualTo(12)); + } + + [Test] + public void ParseLine_PrefixedDxmsg007_PopulatesPathAndLine() + { + const string line = "Assets/Sample/Player.cs(34,5): warning DXMSG007: " + Dxmsg007Bare; + + ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(line); + + Assert.That(parsed, Is.Not.Null); + ParsedEntry entry = parsed!.Value; + Assert.That(entry.DiagnosticId, Is.EqualTo("DXMSG007")); + Assert.That(entry.FilePath, Is.EqualTo("Assets/Sample/Player.cs")); + Assert.That(entry.Line, Is.EqualTo(34)); + } + + [Test] + public void ParseLine_PrefixedDxmsg008_PopulatesPathAndLine() + { + const string line = "Assets/Sample/Player.cs(7,5): info DXMSG008: " + Dxmsg008Bare; + + ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(line); + + Assert.That(parsed, Is.Not.Null); + ParsedEntry entry = parsed!.Value; + Assert.That(entry.DiagnosticId, Is.EqualTo("DXMSG008")); + Assert.That(entry.MethodName, Is.EqualTo(string.Empty)); + Assert.That(entry.FilePath, Is.EqualTo("Assets/Sample/Player.cs")); + Assert.That(entry.Line, Is.EqualTo(7)); + } + + [Test] + public void ParseLine_NullInput_ReturnsNull() + { + Assert.That(BaseCallLogMessageParser.ParseLine(null!), Is.Null); + } + + [Test] + public void ParseLine_EmptyOrWhitespaceInput_ReturnsNull() + { + Assert.That(BaseCallLogMessageParser.ParseLine(string.Empty), Is.Null); + Assert.That(BaseCallLogMessageParser.ParseLine(" "), Is.Null); + Assert.That(BaseCallLogMessageParser.ParseLine("\t\r\n "), Is.Null); + } + + [Test] + public void ParseLine_UnrelatedCompilerWarning_ReturnsNull() + { + Assert.That( + BaseCallLogMessageParser.ParseLine( + "Assets/Sample/Other.cs(3,5): warning CS0168: The variable 'x' is declared but never used" + ), + Is.Null + ); + } + + [Test] + public void ParseLine_DebugLogStyleText_ReturnsNull() + { + Assert.That( + BaseCallLogMessageParser.ParseLine( + "Hello from Debug.Log — nothing analyzer-related here." + ), + Is.Null + ); + } + + [Test] + public void ParseLine_DiagnosticIdInIsolationOrCommentForm_ReturnsNull() + { + // A bare token mention in a comment / random log line must NOT match. + Assert.That(BaseCallLogMessageParser.ParseLine("DXMSG006"), Is.Null); + Assert.That(BaseCallLogMessageParser.ParseLine("// see DXMSG006 in the docs"), Is.Null); + // A line that says DXMSG006 but is not the analyzer's wording. + Assert.That( + BaseCallLogMessageParser.ParseLine( + "DXMSG006 fired earlier today on this assembly — investigate." + ), + Is.Null + ); + } + + [Test] + public void ParseLine_AnchorRejectsAnalyzerWordingMidString() + { + // S7: body regexes anchor to ^ so a Debug.Log payload that happens to embed the + // analyzer's wording mid-string is NOT surfaced as a real DXMSG006/007/008. + Assert.That( + BaseCallLogMessageParser.ParseLine( + "Custom log: see ('Sample.Player' overrides MessageAwareComponent.Awake but does not call base.Awake(); " + + "the messaging system may not function correctly on this component.)" + ), + Is.Null + ); + Assert.That( + BaseCallLogMessageParser.ParseLine( + "Note: 'Sample.Player' hides MessageAwareComponent.OnEnable with 'new'; " + + "replace with 'override' and call base.OnEnable() so the messaging system continues to function." + ), + Is.Null + ); + Assert.That( + BaseCallLogMessageParser.ParseLine( + "Reminder: 'Sample.Player' is excluded from the DxMessaging base-call check ([DxIgnoreMissingBaseCall])." + ), + Is.Null + ); + } + + [Test] + public void Aggregate_DedupesSameTypeAndMethod() + { + List lines = new() { Dxmsg006Bare, Dxmsg006Bare, Dxmsg006Bare }; + + Dictionary result = BaseCallLogMessageParser.Aggregate(lines); + + Assert.That(result.Count, Is.EqualTo(1)); + ParsedTypeReport report = result["Sample.Player"]; + Assert.That(report.MissingBaseFor, Is.EqualTo(new List { "Awake" })); + Assert.That(report.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG006" })); + } + + [Test] + public void Aggregate_MergesDifferentMethodsOnSameType() + { + const string awakeLine = + "'Sample.Player' overrides MessageAwareComponent.Awake but does not call base.Awake(); " + + "the messaging system may not function correctly on this component."; + const string onEnableLine = + "'Sample.Player' overrides MessageAwareComponent.OnEnable but does not call base.OnEnable(); " + + "the messaging system may not function correctly on this component."; + + Dictionary result = BaseCallLogMessageParser.Aggregate( + new[] { awakeLine, onEnableLine } + ); + + Assert.That(result.Count, Is.EqualTo(1)); + ParsedTypeReport report = result["Sample.Player"]; + Assert.That(report.MissingBaseFor, Is.EqualTo(new List { "Awake", "OnEnable" })); + } + + [Test] + public void Aggregate_SeparatesDifferentTypes() + { + const string a = + "'Sample.PlayerA' overrides MessageAwareComponent.Awake but does not call base.Awake(); " + + "the messaging system may not function correctly on this component."; + const string b = + "'Sample.PlayerB' overrides MessageAwareComponent.OnEnable but does not call base.OnEnable(); " + + "the messaging system may not function correctly on this component."; + + Dictionary result = BaseCallLogMessageParser.Aggregate( + new[] { a, b } + ); + + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result["Sample.PlayerA"].MissingBaseFor, Is.EqualTo(new[] { "Awake" })); + Assert.That(result["Sample.PlayerB"].MissingBaseFor, Is.EqualTo(new[] { "OnEnable" })); + } + + [Test] + public void Aggregate_AccumulatesIdsAcrossDxmsg006And007() + { + const string awake006 = + "'Sample.Player' overrides MessageAwareComponent.Awake but does not call base.Awake(); " + + "the messaging system may not function correctly on this component."; + const string onEnable007 = + "'Sample.Player' hides MessageAwareComponent.OnEnable with 'new'; " + + "replace with 'override' and call base.OnEnable() so the messaging system continues to function."; + + Dictionary result = BaseCallLogMessageParser.Aggregate( + new[] { awake006, onEnable007 } + ); + + Assert.That(result.Count, Is.EqualTo(1)); + ParsedTypeReport report = result["Sample.Player"]; + Assert.That(report.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG006", "DXMSG007" })); + Assert.That(report.MissingBaseFor, Is.EqualTo(new List { "Awake", "OnEnable" })); + } + + [Test] + public void Aggregate_Dxmsg008_ContributesIdButNoMethod() + { + Dictionary result = BaseCallLogMessageParser.Aggregate( + new[] { Dxmsg008Bare } + ); + + Assert.That(result.Count, Is.EqualTo(1)); + ParsedTypeReport report = result["Sample.Player"]; + Assert.That(report.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG008" })); + Assert.That(report.MissingBaseFor, Is.Empty); + } + + [Test] + public void Aggregate_Empty_ReturnsEmptyDictionary() + { + Dictionary result = BaseCallLogMessageParser.Aggregate( + System.Array.Empty() + ); + + Assert.That(result, Is.Empty); + } + + [Test] + public void Aggregate_OrderIndependent_For008ThenVs006Then008() + { + const string awake006 = + "'Sample.Player' overrides MessageAwareComponent.Awake but does not call base.Awake(); " + + "the messaging system may not function correctly on this component."; + + Dictionary forward = BaseCallLogMessageParser.Aggregate( + new[] { awake006, Dxmsg008Bare } + ); + Dictionary reverse = BaseCallLogMessageParser.Aggregate( + new[] { Dxmsg008Bare, awake006 } + ); + + Assert.That(forward.Count, Is.EqualTo(1)); + Assert.That(reverse.Count, Is.EqualTo(1)); + + ParsedTypeReport fwd = forward["Sample.Player"]; + ParsedTypeReport rev = reverse["Sample.Player"]; + Assert.That(fwd.MissingBaseFor, Is.EqualTo(rev.MissingBaseFor)); + Assert.That(fwd.DiagnosticIds, Is.EquivalentTo(rev.DiagnosticIds)); + Assert.That(fwd.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG006", "DXMSG008" })); + Assert.That(fwd.MissingBaseFor, Is.EqualTo(new List { "Awake" })); + } + + [Test] + public void ParseLine_BareDxmsg009_CapturesIdTypeAndMethod() + { + ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(Dxmsg009Bare); + + Assert.That(parsed, Is.Not.Null); + ParsedEntry entry = parsed!.Value; + Assert.That(entry.DiagnosticId, Is.EqualTo("DXMSG009")); + Assert.That(entry.TypeFullName, Is.EqualTo("Sample.BrokenThing")); + Assert.That(entry.MethodName, Is.EqualTo("OnEnable")); + Assert.That(entry.FilePath, Is.Empty); + Assert.That(entry.Line, Is.EqualTo(0)); + } + + [Test] + public void ParseLine_PrefixedDxmsg009_CapturesPathAndLine() + { + const string line = + "Assets/Scripts/BrokenThing.cs(7,22): warning DXMSG009: " + Dxmsg009Bare; + + ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(line); + + Assert.That(parsed, Is.Not.Null); + ParsedEntry entry = parsed!.Value; + Assert.That(entry.DiagnosticId, Is.EqualTo("DXMSG009")); + Assert.That(entry.TypeFullName, Is.EqualTo("Sample.BrokenThing")); + Assert.That(entry.MethodName, Is.EqualTo("OnEnable")); + Assert.That(entry.FilePath, Is.EqualTo("Assets/Scripts/BrokenThing.cs")); + Assert.That(entry.Line, Is.EqualTo(7)); + } + + [Test] + public void ParseLine_AnchorRejectsDxmsg009MidString() + { + // Adversarial: the analyzer's wording embedded in a Debug.Log payload must not be parsed + // as a real DXMSG009 warning. + const string line = + "Hello world. " + "'Sample.BrokenThing' declares OnEnable without 'override' or 'new'."; + + ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(line); + + Assert.That(parsed, Is.Null); + } + + [Test] + public void Aggregate_Dxmsg009ContributesToMissingBaseFor() + { + Dictionary result = BaseCallLogMessageParser.Aggregate( + new[] { Dxmsg009Bare } + ); + + Assert.That(result.Count, Is.EqualTo(1)); + ParsedTypeReport report = result["Sample.BrokenThing"]; + Assert.That(report.MissingBaseFor, Is.EqualTo(new List { "OnEnable" })); + Assert.That(report.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG009" })); + } + + [Test] + public void Aggregate_Dxmsg009AccumulatesAlongsideDxmsg006() + { + // Same type, two diagnostics on different methods → one entry, two methods, two ids. + const string awake006 = + "'Sample.BrokenThing' overrides MessageAwareComponent.Awake but does not call base.Awake(); " + + "the messaging system may not function correctly on this component."; + + Dictionary result = BaseCallLogMessageParser.Aggregate( + new[] { awake006, Dxmsg009Bare } + ); + + Assert.That(result.Count, Is.EqualTo(1)); + ParsedTypeReport report = result["Sample.BrokenThing"]; + Assert.That(report.MissingBaseFor, Is.EqualTo(new List { "Awake", "OnEnable" })); + Assert.That(report.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG006", "DXMSG009" })); + } + + [Test] + public void Aggregate_Dxmsg009Dedups() + { + List lines = new() { Dxmsg009Bare, Dxmsg009Bare, Dxmsg009Bare }; + + Dictionary result = BaseCallLogMessageParser.Aggregate(lines); + + Assert.That(result.Count, Is.EqualTo(1)); + ParsedTypeReport report = result["Sample.BrokenThing"]; + Assert.That(report.MissingBaseFor, Is.EqualTo(new List { "OnEnable" })); + Assert.That(report.DiagnosticIds.Count, Is.EqualTo(1)); + } + + [Test] + public void ParseLine_BareDxmsg010_CapturesIdTypeAndMethod() + { + ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(Dxmsg010Bare); + + Assert.That(parsed, Is.Not.Null); + ParsedEntry entry = parsed!.Value; + Assert.That(entry.DiagnosticId, Is.EqualTo("DXMSG010")); + Assert.That(entry.TypeFullName, Is.EqualTo("Sample.BrokenThing")); + Assert.That(entry.MethodName, Is.EqualTo("OnEnable")); + Assert.That(entry.FilePath, Is.Empty); + Assert.That(entry.Line, Is.EqualTo(0)); + } + + [Test] + public void ParseLine_PrefixedDxmsg010_CapturesPathAndLine() + { + const string line = + "Assets/Scripts/BrokenThing.cs(11,33): warning DXMSG010: " + Dxmsg010Bare; + + ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(line); + + Assert.That(parsed, Is.Not.Null); + ParsedEntry entry = parsed!.Value; + Assert.That(entry.DiagnosticId, Is.EqualTo("DXMSG010")); + Assert.That(entry.TypeFullName, Is.EqualTo("Sample.BrokenThing")); + Assert.That(entry.MethodName, Is.EqualTo("OnEnable")); + Assert.That(entry.FilePath, Is.EqualTo("Assets/Scripts/BrokenThing.cs")); + Assert.That(entry.Line, Is.EqualTo(11)); + } + + [Test] + public void ParseLine_AnchorRejectsDxmsg010MidString() + { + // Adversarial: the analyzer's wording embedded in a Debug.Log payload must not be parsed + // as a real DXMSG010 warning. The body regex is anchored at ^ so any leading text + // disqualifies the match. + const string line = + "Custom log: see ('Sample.BrokenThing' calls base.OnEnable() but the inherited override on 'Sample.ddd' " + + "does not chain to MessageAwareComponent.OnEnable; the messaging system will not function correctly on this component.)"; + + ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(line); + + Assert.That(parsed, Is.Null); + } + + [Test] + public void Aggregate_Dxmsg010ContributesToMissingBaseFor() + { + // DXMSG010 must contribute its method name to MissingBaseFor so the inspector overlay + // surfaces it just like DXMSG006/007/009. + Dictionary result = BaseCallLogMessageParser.Aggregate( + new[] { Dxmsg010Bare } + ); + + Assert.That(result.Count, Is.EqualTo(1)); + ParsedTypeReport report = result["Sample.BrokenThing"]; + Assert.That(report.MissingBaseFor, Is.EqualTo(new List { "OnEnable" })); + Assert.That(report.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG010" })); + } + + [Test] + public void ParseLine_Dxmsg010_BrokenAncestorIsNotSurfacedOnParsedEntry() + { + // Spec 5a: the DXMSG010 regex captures the broken-ancestor name in a `broken` group, but + // the `ParsedEntry` struct does NOT expose it as a field. This test PINS the current + // limitation: future readers of the parsed entry have no way to surface the broken-ancestor + // FQN to the inspector overlay's "broken chain via {broken}" message. If the struct gains + // a BrokenAncestor field in a future change, this test should be updated to assert the + // captured value rather than the absence — but until then, this test keeps the limitation + // visible to drive a future enhancement and prevent silent regressions of the regex itself. + ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(Dxmsg010Bare); + + Assert.That(parsed, Is.Not.Null); + ParsedEntry entry = parsed!.Value; + Assert.That(entry.DiagnosticId, Is.EqualTo("DXMSG010")); + Assert.That(entry.TypeFullName, Is.EqualTo("Sample.BrokenThing")); + Assert.That(entry.MethodName, Is.EqualTo("OnEnable")); + + // ParsedEntry's public surface is exactly five members (DiagnosticId, TypeFullName, + // MethodName, FilePath, Line). Confirm the struct shape is unchanged so a future addition + // of a BrokenAncestor property is detected here as a deliberate API change. + System.Reflection.PropertyInfo[] properties = typeof(ParsedEntry).GetProperties( + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance + ); + string[] propertyNames = properties.Select(p => p.Name).OrderBy(n => n).ToArray(); + Assert.That( + propertyNames, + Is.EqualTo(new[] { "DiagnosticId", "FilePath", "Line", "MethodName", "TypeFullName" }), + "ParsedEntry must expose exactly DiagnosticId/TypeFullName/MethodName/FilePath/Line. " + + "The DXMSG010 regex captures `broken` but the struct does NOT yet surface it. " + + "If you're seeing this assertion fail because you added BrokenAncestor, update " + + "this test and add an assertion on the captured value." + ); + } + + [Test] + public void Aggregate_KeepsFirstSeenFilePathAndLine() + { + const string prefixed = + "Assets/Sample/Player.cs(12,9): warning DXMSG006: " + + "'Sample.Player' overrides MessageAwareComponent.Awake but does not call base.Awake(); " + + "the messaging system may not function correctly on this component."; + const string bareLater = + "'Sample.Player' overrides MessageAwareComponent.OnEnable but does not call base.OnEnable(); " + + "the messaging system may not function correctly on this component."; + + Dictionary result = BaseCallLogMessageParser.Aggregate( + new[] { prefixed, bareLater } + ); + + Assert.That(result.Count, Is.EqualTo(1)); + ParsedTypeReport report = result["Sample.Player"]; + Assert.That(report.FilePath, Is.EqualTo("Assets/Sample/Player.cs")); + Assert.That(report.Line, Is.EqualTo(12)); + Assert.That(report.MissingBaseFor, Is.EqualTo(new List { "Awake", "OnEnable" })); + } +} diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs.meta b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs.meta new file mode 100644 index 00000000..08e1b49b --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d8afee1eb18769f45a97b0721d78812f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs new file mode 100644 index 00000000..004541df --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs @@ -0,0 +1,714 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using DxMessaging.Editor.Analyzers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using NUnit.Framework; + +namespace WallstopStudios.DxMessaging.SourceGenerators.Tests; + +/// +/// Tests for the scanner-level classification logic that powers the inspector overlay's data +/// source. +/// +/// +/// +/// The Unity-coupled wrapper BaseCallTypeScanner lives behind #if UNITY_EDITOR and +/// cannot be loaded outside the Editor. The pure classification core +/// () is linked into this test project via +/// <Compile Include="..\..\Editor\Analyzers\BaseCallTypeScannerCore.cs" Link="..." /> +/// and tested via Roslyn-compiled in-memory fixtures (the same harness BaseCallIlInspectorTests +/// uses). +/// +/// +/// These tests pin the contracts the inspector overlay depends on: +/// +/// +/// DXMSG006 attribution when an override does not call base. +/// DXMSG007 attribution for new-shadowed lifecycle methods (and the +/// fact that DXMSG009 is conservatively classified the same way — IL alone cannot distinguish +/// the two). +/// DXMSG010 attribution at the LEAF when an intermediate ancestor's override +/// fails to call base. Includes the four-level Leaf : Middle : ddd : MAC case to prove the +/// chain walk skips intermediate types that don't declare the slot directly. +/// Opt-out paths via class-level [DxIgnoreMissingBaseCall] and via the +/// project-level ignored-types list. +/// Skipping abstract types and generic-type definitions (they aren't +/// instantiable so their override shape doesn't matter to the runtime overlay). +/// FQN normalisation (Outer+NestedOuter.Nested) so the +/// snapshot key matches what the analyzer emits. +/// Independence across types — two broken types both appear in the snapshot. +/// Healthy chains report nothing (no false positives). +/// +/// +[TestFixture] +public sealed class BaseCallTypeScannerTests +{ + // ---- Per-classification tests ----------------------------------------------------------- + + [Test] + public void Scan_OverrideWithoutBase_ReportsDxmsg006_AndAddsMethodToMissingBaseFor() + { + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class Broken : MessageAwareComponent + { + protected override void OnEnable() + { + // No base call. + } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That(snapshot, Contains.Key("Broken")); + BaseCallTypeScannerCore.ScanEntry entry = snapshot["Broken"]; + Assert.That(entry.MissingBaseFor, Is.EquivalentTo(new[] { "OnEnable" })); + Assert.That(entry.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG006" })); + } + + [Test] + public void Scan_OverrideWithBase_ReportsNothing() + { + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class Clean : MessageAwareComponent + { + protected override void OnEnable() + { + base.OnEnable(); + } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That(snapshot, Is.Empty); + } + + [Test] + public void Scan_NoModifierOnGuardedName_ReportsHidingDiagnostic() + { + // Acceptance contract: declaring a same-named lifecycle method without override or new + // (C# CS0114) compiles to the same IL shape as `new void X()`. The scanner's IL-only + // probe cannot distinguish DXMSG007 from DXMSG009; it conservatively classifies as + // DXMSG007. The compile-time analyzer is authoritative for the precise ID. This test + // pins the conservative-classification contract — if a future scanner gains semantic + // insight, the assertion below should be updated alongside the doc note. + Assembly fixture = CompileFixture( + """ + #pragma warning disable CS0114 // suppress "hides inherited member" so the fixture compiles. + using DxMessaging.Unity; + + public class ImplicitHider : MessageAwareComponent + { + protected void OnEnable() + { + // No `override`, no `new`. C# emits CS0114 — the analyzer would emit DXMSG009. + // The scanner classifies as DXMSG007 because the IL is indistinguishable. + } + } + #pragma warning restore CS0114 + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That(snapshot, Contains.Key("ImplicitHider")); + BaseCallTypeScannerCore.ScanEntry entry = snapshot["ImplicitHider"]; + Assert.That(entry.MissingBaseFor, Is.EquivalentTo(new[] { "OnEnable" })); + Assert.That( + entry.DiagnosticIds, + Is.EquivalentTo(new[] { "DXMSG007" }), + "Scanner should conservatively classify DXMSG009 as DXMSG007 (IL ambiguity)." + ); + } + + [Test] + public void Scan_ExplicitNewOnGuardedName_ReportsDxmsg007() + { + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class ExplicitHider : MessageAwareComponent + { + protected new void OnEnable() + { + // Hides via explicit `new`. + } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That(snapshot, Contains.Key("ExplicitHider")); + BaseCallTypeScannerCore.ScanEntry entry = snapshot["ExplicitHider"]; + Assert.That(entry.MissingBaseFor, Is.EquivalentTo(new[] { "OnEnable" })); + Assert.That(entry.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG007" })); + } + + [Test] + public void Scan_BrokenIntermediate_ReportsDxmsg010OnLeaf() + { + // The user's canonical `BrokenThing : ddd : MessageAwareComponent` case: ddd's override + // does not call base, BrokenThing's override does. The leaf is the type the user is + // actively editing, so DXMSG010 should land on BrokenThing — not on ddd, which gets its + // own DXMSG006 row. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class ddd : MessageAwareComponent + { + protected override void OnEnable() + { + // No base call — chain dies here. + } + } + + public class BrokenThing : ddd + { + protected override void OnEnable() + { + base.OnEnable(); + } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That(snapshot, Contains.Key("ddd")); + Assert.That(snapshot["ddd"].DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG006" })); + + Assert.That(snapshot, Contains.Key("BrokenThing")); + Assert.That( + snapshot["BrokenThing"].DiagnosticIds, + Is.EquivalentTo(new[] { "DXMSG010" }), + "DXMSG010 must land on the leaf (the type the user is editing)." + ); + Assert.That(snapshot["BrokenThing"].MissingBaseFor, Is.EquivalentTo(new[] { "OnEnable" })); + } + + [Test] + public void Scan_ChainSkippingMiddleType_ReportsDxmsg010OnLeaf() + { + // Four-level chain: BrokenThing : Middle : ddd : MessageAwareComponent. Middle does NOT + // declare OnEnable, but ddd's override is broken. BrokenThing calls base correctly. The + // chain walker's GetOverriddenMethod must walk PAST Middle (which doesn't declare the + // slot directly) to find ddd's broken override. If the walker stopped at Middle without + // finding the method on it, we would report nothing on BrokenThing — a missed DXMSG010. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class ddd : MessageAwareComponent + { + protected override void OnEnable() + { + // Broken — no base call. + } + } + + public class Middle : ddd + { + // Intentionally does not declare OnEnable. + public int _unused; + } + + public class BrokenThing : Middle + { + protected override void OnEnable() + { + base.OnEnable(); + } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That(snapshot, Contains.Key("ddd")); + Assert.That(snapshot["ddd"].DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG006" })); + + // Middle declares neither override nor new → no entry. + Assert.That(snapshot, Does.Not.ContainKey("Middle")); + + Assert.That(snapshot, Contains.Key("BrokenThing")); + Assert.That( + snapshot["BrokenThing"].DiagnosticIds, + Is.EquivalentTo(new[] { "DXMSG010" }), + "Chain walker must skip Middle and detect ddd's broken override." + ); + } + + [Test] + public void Scan_ClassLevelDxIgnoreMissingBaseCallAttribute_ExcludesFromSnapshot() + { + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + using DxMessaging.Core.Attributes; + + [DxIgnoreMissingBaseCall] + public class IgnoredBroken : MessageAwareComponent + { + protected override void OnEnable() + { + // Broken, but opted out. + } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That(snapshot, Is.Empty); + } + + [Test] + public void Scan_TypeInProjectIgnoreList_ExcludesFromSnapshot() + { + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class ProjectIgnoredBroken : MessageAwareComponent + { + protected override void OnEnable() + { + // Broken, but opted out at the project level. + } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan( + EnumerateMacSubclasses(fixture), + new[] { "ProjectIgnoredBroken" } + ); + + Assert.That(snapshot, Is.Empty); + } + + [Test] + public void Scan_AbstractType_IsSkipped() + { + // Abstract subclasses cannot exist as MonoBehaviour instances, so the inspector overlay + // never shows their HelpBox. The scanner should not include them in the snapshot even if + // they technically have a broken override. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public abstract class AbstractBroken : MessageAwareComponent + { + protected override void OnEnable() + { + // Broken — but abstract types are skipped. + } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That(snapshot, Is.Empty); + } + + [Test] + public void Scan_GenericTypeDefinition_IsSkipped() + { + // Open generic-type definitions cannot be instantiated as MonoBehaviour components. + // Closed generic instantiations would be classified separately — but the open definition + // itself is a TypeCache artifact we should not surface. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class GenericBroken : MessageAwareComponent + { + protected override void OnEnable() + { + // Broken — but the generic-type-definition is skipped. + } + } + """ + ); + + // Feed in only the generic-type-definition (no closed instantiation exists). + IEnumerable candidates = fixture.GetTypes().Where(t => t.IsGenericTypeDefinition); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(candidates, null); + + Assert.That(snapshot, Is.Empty); + } + + [Test] + public void Scan_NestedTypeFqnUsesDots_NotPlusSign() + { + // System.Type.FullName for nested types uses '+' as the separator (e.g. + // "Outer+Nested"); the analyzer emits the dotted form so the inspector overlay can + // round-trip the FQN through the JSON cache and reflect on it as a CSharp identifier. + // The scanner must normalise '+' → '.' so its snapshot key matches the analyzer. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class Outer + { + public class Nested : MessageAwareComponent + { + protected override void OnEnable() + { + // Broken to ensure the entry is produced. + } + } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That( + snapshot, + Contains.Key("Outer.Nested"), + "Nested type FQN must use '.' (analyzer form), not '+' (Reflection form)." + ); + Assert.That( + snapshot, + Does.Not.ContainKey("Outer+Nested"), + "Plus-form FQN must not appear in the snapshot." + ); + } + + [Test] + public void Scan_TwoTypesWithSameMethodIssues_BothInSnapshot() + { + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class FirstBroken : MessageAwareComponent + { + protected override void OnEnable() + { + // No base call. + } + } + + public class SecondBroken : MessageAwareComponent + { + protected override void OnDisable() + { + // No base call. + } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That(snapshot, Contains.Key("FirstBroken")); + Assert.That(snapshot, Contains.Key("SecondBroken")); + Assert.That(snapshot["FirstBroken"].MissingBaseFor, Is.EquivalentTo(new[] { "OnEnable" })); + Assert.That( + snapshot["SecondBroken"].MissingBaseFor, + Is.EquivalentTo(new[] { "OnDisable" }) + ); + } + + [Test] + public void Scan_MethodLevelDxIgnoreMissingBaseCallAttribute_ExcludesFromSnapshot() + { + // Spec 2a: the class itself is NOT marked, but a single method has the + // [DxIgnoreMissingBaseCall] attribute. The scanner's method-level check (over the five + // guarded methods) opts the entire type out from the inspector overlay — matching the + // attribute applied to a method on a non-attributed class. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + using DxMessaging.Core.Attributes; + + public class IgnoredViaMethod : MessageAwareComponent + { + [DxIgnoreMissingBaseCall] + protected override void OnEnable() + { + // Broken, but the method is opted out — this should suppress the type. + } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That(snapshot, Is.Empty); + } + + [Test] + public void Scan_TwoBrokenMethodsOnSameType_FoldedIntoSingleEntry() + { + // Spec 2b: a single type with TWO broken overrides (Awake AND OnEnable) must produce + // exactly ONE entry whose MissingBaseFor lists both methods. DiagnosticIds is the + // deduplicated union (DXMSG006 once even though both methods contribute it). + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class BrokenThing : MessageAwareComponent + { + protected override void Awake() + { + // No base call. + } + + protected override void OnEnable() + { + // No base call. + } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That(snapshot, Has.Count.EqualTo(1)); + Assert.That(snapshot, Contains.Key("BrokenThing")); + BaseCallTypeScannerCore.ScanEntry entry = snapshot["BrokenThing"]; + Assert.That( + entry.MissingBaseFor, + Is.EquivalentTo(new[] { "Awake", "OnEnable" }), + "Both broken methods must appear in MissingBaseFor on a single entry." + ); + Assert.That( + entry.DiagnosticIds, + Is.EquivalentTo(new[] { "DXMSG006" }), + "DXMSG006 must be deduped to a single id even though both methods contribute it." + ); + } + + [Test] + public void Scan_NullSettings_TreatsOptOutListAsEmpty_NoNullReferenceException() + { + // Spec 2e: passing null for ignoredTypeNames must be treated as an empty opt-out list and + // must not throw. This pins the defensive null-handling at the API boundary. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class BrokenLeaf : MessageAwareComponent + { + protected override void OnEnable() + { + // No base call. + } + } + """ + ); + + Dictionary? snapshot = null; + Assert.DoesNotThrow(() => + { + snapshot = BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + }); + Assert.That(snapshot, Is.Not.Null); + Assert.That(snapshot!, Contains.Key("BrokenLeaf")); + } + + [Test] + public void Scan_OnExternMethod_TreatedAsCleanCrossAssembly() + { + // Spec 2d: a MessageAwareComponent subclass whose override is `extern` (no IL body) must + // be treated as assume-clean. GetMethodBody() returns null for extern methods just like + // it does for cross-assembly closed-source code; the scanner's defensive bias means no + // diagnostic is emitted. + // Suppress CS0626 for the missing DllImport — we never actually call the method, we just + // need a MethodInfo whose IL body is null. + Assembly fixture = CompileFixture( + """ + #pragma warning disable CS0626 + using System.Runtime.InteropServices; + using DxMessaging.Unity; + + public class ExternLeaf : MessageAwareComponent + { + [DllImport("nonexistent")] + protected static extern void NotALifecycleMethod(); + + protected override extern void OnEnable(); + } + #pragma warning restore CS0626 + """ + ); + + // Sanity-check the precondition: the method has no body. + Type leaf = fixture.GetType("ExternLeaf")!; + MethodInfo? extern_ = leaf.GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, + Type.EmptyTypes, + null + ); + Assert.That(extern_, Is.Not.Null); + Assert.That(extern_!.GetMethodBody(), Is.Null); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + // The IL inspector returns true (assume clean) when the body is null. The scanner records + // an entry only when MissingBaseFor is non-empty — so an assume-clean override produces + // no entry. + Assert.That( + snapshot, + Does.Not.ContainKey("ExternLeaf"), + "Extern (no IL body) override must be treated as assume-clean and produce no entry." + ); + } + + [Test] + public void Scan_HealthyChain_ReportsNothing() + { + // Three-deep healthy chain: every link calls base, no DXMSG006 / DXMSG010 should fire. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class HealthyA : MessageAwareComponent + { + protected override void OnEnable() { base.OnEnable(); } + } + + public class HealthyB : HealthyA + { + protected override void OnEnable() { base.OnEnable(); } + } + + public class HealthyC : HealthyB + { + protected override void OnEnable() { base.OnEnable(); } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That(snapshot, Is.Empty); + } + + // ---- Compilation harness --------------------------------------------------------------- + + /// + /// Enumerate every concrete + abstract type in the fixture that derives (transitively) from + /// the stub MessageAwareComponent. The scanner's own filtering (abstract / + /// generic-definition skipping, MAC-itself skipping, FQN normalisation) is the contract under + /// test, so we feed in a deliberately permissive candidate set. + /// + private static IEnumerable EnumerateMacSubclasses(Assembly fixture) + { + Type mac = fixture.GetType("DxMessaging.Unity.MessageAwareComponent")!; + return fixture.GetTypes().Where(t => t != mac && mac.IsAssignableFrom(t)); + } + + private static Assembly CompileFixture(string userSource) + { + // Build a self-contained assembly that defines a MessageAwareComponent stub plus the + // user's classes on top. The stub's chain terminator FQN ("DxMessaging.Unity.MessageAware + // Component") is the literal string the Core checks for to terminate the chain walk — + // keep them in lock-step. + const string Stubs = """ +namespace UnityEngine +{ + public class MonoBehaviour { } +} + +namespace DxMessaging.Core.Attributes +{ + using System; + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false, AllowMultiple = false)] + public sealed class DxIgnoreMissingBaseCallAttribute : System.Attribute { } +} + +namespace DxMessaging.Unity +{ + using UnityEngine; + + public class MessageAwareComponent : MonoBehaviour + { + protected virtual void Awake() { } + protected virtual void OnEnable() { } + protected virtual void OnDisable() { } + protected virtual void OnDestroy() { } + protected virtual void RegisterMessageHandlers() { } + } +} +"""; + SyntaxTree stubs = CSharpSyntaxTree.ParseText(Stubs); + SyntaxTree user = CSharpSyntaxTree.ParseText(userSource); + + List references = new() + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + }; + // Ensure System.Runtime is loaded — required for MetadataReference resolution on net9.0. + Assembly runtime = Assembly.Load("System.Runtime"); + if (!string.IsNullOrEmpty(runtime.Location)) + { + references.Add(MetadataReference.CreateFromFile(runtime.Location)); + } + + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName: "BaseCallTypeScannerCoreFixture_" + Guid.NewGuid().ToString("N"), + syntaxTrees: new[] { stubs, user }, + references: references, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + using MemoryStream stream = new(); + EmitResult emit = compilation.Emit(stream); + if (!emit.Success) + { + string errors = string.Join( + "\n", + emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.ToString()) + ); + throw new InvalidOperationException( + $"Test fixture failed to compile:\n{errors}\n\nUser source:\n{userSource}" + ); + } + + stream.Seek(0, SeekOrigin.Begin); + return Assembly.Load(stream.ToArray()); + } +} diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs.meta b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs.meta new file mode 100644 index 00000000..7bb4a003 --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ed9422c0061b4b61ba996fc8328cdd9a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs new file mode 100644 index 00000000..5dfabd9e --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs @@ -0,0 +1,625 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DxMessaging.Editor.Analyzers; +using NUnit.Framework; + +namespace WallstopStudios.DxMessaging.SourceGenerators.Tests; + +/// +/// Regression coverage for the dual-source console harvester. Two layers are covered here: +/// +/// The Unity 2021 CompilerMessage parse path (fed through +/// ) — the wire format the harvester sees on +/// 2021 builds. +/// The per-assembly merge + retirement bookkeeping in +/// — the most novel slice of the dual-source design and +/// the part most likely to silently corrupt the snapshot if regressed. +/// +/// +/// +/// The harvester itself lives in the Editor assembly (which dotnet-test cannot load — it depends +/// on UnityEditor types), so we test the slices that ARE pure: feeding synthetic +/// `CompilerMessage`-shaped log strings through the parser, and exercising the aggregator +/// directly via its public static API. +/// +[TestFixture] +public sealed class CompilationMessageHarvestTests +{ + // Verbatim shape of what `CompilerMessage.message` carries on Unity 2021 for a Roslyn + // analyzer warning. The harvester's prefilter only checks for the substring "DXMSG00". + private const string Unity2021Dxmsg009 = + "Assets/Sample/BrokenThing.cs(12,21): warning DXMSG009: 'Sample.BrokenThing' declares OnEnable without 'override' or 'new'; " + + "this implicitly hides MessageAwareComponent.OnEnable (CS0114) and the messaging system will not function. " + + "Add 'override' and call base.OnEnable(), or add 'new' if the hiding is intentional."; + + private const string Unity2021Dxmsg006 = + "Assets/Sample/Player.cs(8,29): warning DXMSG006: 'Sample.Player' overrides MessageAwareComponent.Awake but does not call base.Awake(); " + + "the messaging system may not function correctly on this component."; + + private const string Unity2021Dxmsg007 = + "Assets/Sample/Player.cs(15,21): warning DXMSG007: 'Sample.Player' hides MessageAwareComponent.OnEnable with 'new'; " + + "replace with 'override' and call base.OnEnable() so the messaging system continues to function."; + + [Test] + public void Aggregate_OnUnity2021Dxmsg009Line_ProducesEntryWithFilePathAndLine() + { + Dictionary aggregated = BaseCallLogMessageParser.Aggregate( + new[] { Unity2021Dxmsg009 } + ); + + Assert.That(aggregated, Has.Count.EqualTo(1)); + Assert.That(aggregated.ContainsKey("Sample.BrokenThing"), Is.True); + ParsedTypeReport report = aggregated["Sample.BrokenThing"]; + Assert.That(report.MissingBaseFor, Is.EquivalentTo(new[] { "OnEnable" })); + Assert.That(report.DiagnosticIds, Contains.Item("DXMSG009")); + Assert.That(report.FilePath, Is.EqualTo("Assets/Sample/BrokenThing.cs")); + Assert.That(report.Line, Is.EqualTo(12)); + } + + [Test] + public void Aggregate_OnMixedDiagnosticsForSameType_DedupesMethodsAndUnionsIds() + { + // Player.cs raises both a DXMSG006 (override missing base) on Awake and a DXMSG007 + // (new hides) on OnEnable. The same type FQN appears in both, so the per-type report + // should fold both methods into one entry while keeping both diagnostic ids. + Dictionary aggregated = BaseCallLogMessageParser.Aggregate( + new[] { Unity2021Dxmsg006, Unity2021Dxmsg007 } + ); + + Assert.That(aggregated, Has.Count.EqualTo(1)); + ParsedTypeReport report = aggregated["Sample.Player"]; + Assert.That(report.MissingBaseFor, Is.EquivalentTo(new[] { "Awake", "OnEnable" })); + Assert.That(report.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG006", "DXMSG007" })); + // First-occurrence file path is stable so "Open Script" jumps to the first reported + // location — which is what the user's eye lands on first in the console. + Assert.That(report.FilePath, Is.EqualTo("Assets/Sample/Player.cs")); + Assert.That(report.Line, Is.EqualTo(8)); + } + + [Test] + public void Aggregate_DropsLinesWithoutAnyDxmsgPrefix() + { + // The harvester's hot-path filter on `OnAssemblyCompilationFinished` skips lines that + // don't contain "DXMSG00" before parsing. The parser itself must also be tolerant of + // unrelated lines (the LogEntries scan path doesn't pre-filter as aggressively). + Dictionary aggregated = BaseCallLogMessageParser.Aggregate( + new[] + { + "Assets/Foo.cs(1,1): warning CS0162: Unreachable code detected", + "[BUILD] Cooked some assets in 12.3s", + Unity2021Dxmsg009, + } + ); + + Assert.That(aggregated, Has.Count.EqualTo(1)); + Assert.That(aggregated.ContainsKey("Sample.BrokenThing"), Is.True); + } + + [Test] + public void Aggregate_OnEmptyInput_ReturnsEmptyDictionary() + { + // The harvester calls Aggregate even when an assembly produced zero matching messages + // — the empty result is then used by ApplyCompilerMessageDrain to RETIRE the previous + // attribution for that assembly. Stable empty handling is load-bearing for that flow. + Dictionary aggregated = BaseCallLogMessageParser.Aggregate( + Array.Empty() + ); + + Assert.That(aggregated, Is.Empty); + } + + [Test] + public void Aggregate_OnNullInput_ReturnsEmptyDictionary() + { + Dictionary aggregated = BaseCallLogMessageParser.Aggregate(null); + + Assert.That(aggregated, Is.Empty); + } + + // -- BaseCallReportAggregator.ApplyAssemblyReports tests + // ------------------------------------------------------- + // These exercise the merge + retirement contract directly. They're the single most novel + // slice of the dual-source design and have failed repeatedly in adversarial review; locking + // them in with deterministic dotnet-test coverage closes the gap. + + [Test] + public void ApplyAssemblyReports_NewType_AddedToBoth() + { + Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); + Dictionary mergedReports = new(StringComparer.Ordinal); + + Dictionary reports = MakeReports( + ("Sample.Player", new[] { "Awake" }, "DXMSG006", "Assets/Player.cs", 8) + ); + + BaseCallReportAggregator.ApplyAssemblyReports( + "Sample.dll", + reports, + typesByAssembly, + mergedReports + ); + + Assert.That(typesByAssembly.ContainsKey("Sample.dll"), Is.True); + Assert.That(typesByAssembly["Sample.dll"], Is.EquivalentTo(new[] { "Sample.Player" })); + Assert.That(mergedReports, Has.Count.EqualTo(1)); + Assert.That(mergedReports.ContainsKey("Sample.Player"), Is.True); + Assert.That( + mergedReports["Sample.Player"].MissingBaseFor, + Is.EquivalentTo(new[] { "Awake" }) + ); + Assert.That(mergedReports["Sample.Player"].FilePath, Is.EqualTo("Assets/Player.cs")); + Assert.That(mergedReports["Sample.Player"].Line, Is.EqualTo(8)); + } + + [Test] + public void ApplyAssemblyReports_RecompileSameAssemblyDropsRetiredTypes() + { + // Assembly A reports type X with method Awake. The user fixes the issue and recompiles — + // A's next batch is empty. X must be removed from BOTH mergedReports AND + // typesByAssembly[A] (otherwise the inspector shows a phantom HelpBox for a fixed type). + Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); + Dictionary mergedReports = new(StringComparer.Ordinal); + + BaseCallReportAggregator.ApplyAssemblyReports( + "A.dll", + MakeReports(("X", new[] { "Awake" }, "DXMSG006", "X.cs", 1)), + typesByAssembly, + mergedReports + ); + Assume.That( + mergedReports.ContainsKey("X"), + Is.True, + "Precondition: X must be in the merged map after first apply." + ); + + // Re-call without X. + BaseCallReportAggregator.ApplyAssemblyReports( + "A.dll", + new Dictionary(StringComparer.Ordinal), + typesByAssembly, + mergedReports + ); + + Assert.That(mergedReports, Is.Empty, "X must be retired from mergedReports."); + Assert.That( + typesByAssembly["A.dll"], + Is.Empty, + "A.dll's FQN set must drop X after the empty recompile." + ); + } + + [Test] + public void ApplyAssemblyReports_TwoAssembliesReportSameTypeRetainAfterOneDrops() + { + // Cross-assembly survival: A and B both report type X (e.g., partial classes split across + // assemblies, or duplicate type-name across modules). When A re-compiles without X, X + // must SURVIVE in mergedReports because B still claims it. Then when B drops X, X must + // disappear. + Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); + Dictionary mergedReports = new(StringComparer.Ordinal); + + BaseCallReportAggregator.ApplyAssemblyReports( + "A.dll", + MakeReports(("X", new[] { "Awake" }, "DXMSG006", "A/X.cs", 5)), + typesByAssembly, + mergedReports + ); + BaseCallReportAggregator.ApplyAssemblyReports( + "B.dll", + MakeReports(("X", new[] { "Awake" }, "DXMSG006", "B/X.cs", 9)), + typesByAssembly, + mergedReports + ); + Assume.That(mergedReports.ContainsKey("X"), Is.True); + + // A drops X. + BaseCallReportAggregator.ApplyAssemblyReports( + "A.dll", + new Dictionary(StringComparer.Ordinal), + typesByAssembly, + mergedReports + ); + + Assert.That( + mergedReports.ContainsKey("X"), + Is.True, + "X must survive while B still reports it." + ); + Assert.That(typesByAssembly["A.dll"], Is.Empty); + Assert.That(typesByAssembly["B.dll"], Is.EquivalentTo(new[] { "X" })); + + // Now B drops X too. + BaseCallReportAggregator.ApplyAssemblyReports( + "B.dll", + new Dictionary(StringComparer.Ordinal), + typesByAssembly, + mergedReports + ); + + Assert.That(mergedReports, Is.Empty, "X must be retired once both assemblies drop it."); + Assert.That(typesByAssembly["B.dll"], Is.Empty); + } + + [Test] + public void ApplyAssemblyReports_DifferentMethodsOnSameTypeAcrossAssemblies() + { + // A reports X.Awake; B reports X.OnEnable. The merged view must carry both methods on a + // single X entry — this is the partial-class / split-assembly case. + Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); + Dictionary mergedReports = new(StringComparer.Ordinal); + + BaseCallReportAggregator.ApplyAssemblyReports( + "A.dll", + MakeReports(("X", new[] { "Awake" }, "DXMSG006", "A/X.cs", 5)), + typesByAssembly, + mergedReports + ); + BaseCallReportAggregator.ApplyAssemblyReports( + "B.dll", + MakeReports(("X", new[] { "OnEnable" }, "DXMSG006", "B/X.cs", 9)), + typesByAssembly, + mergedReports + ); + + Assert.That(mergedReports.ContainsKey("X"), Is.True); + Assert.That( + mergedReports["X"].MissingBaseFor, + Is.EquivalentTo(new[] { "Awake", "OnEnable" }) + ); + // First-seen file path wins so the "Open Script" jump is stable. + Assert.That(mergedReports["X"].FilePath, Is.EqualTo("A/X.cs")); + Assert.That(mergedReports["X"].Line, Is.EqualTo(5)); + } + + [Test] + public void ApplyAssemblyReports_UnknownAssemblyKeyDoesNotDisturbExistingState() + { + // Sanity check: applying an empty batch for an assembly we've never seen leaves the + // merged map untouched. A common refresh path on Unity 2021 is "every assembly fires + // assemblyCompilationFinished, even ones with no warnings" — those calls must not + // accidentally zero out the snapshot. + Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); + Dictionary mergedReports = new(StringComparer.Ordinal); + + BaseCallReportAggregator.ApplyAssemblyReports( + "A.dll", + MakeReports(("X", new[] { "Awake" }, "DXMSG006", "A/X.cs", 5)), + typesByAssembly, + mergedReports + ); + + BaseCallReportAggregator.ApplyAssemblyReports( + "Empty.dll", + new Dictionary(StringComparer.Ordinal), + typesByAssembly, + mergedReports + ); + + Assert.That(mergedReports.ContainsKey("X"), Is.True); + Assert.That(typesByAssembly.ContainsKey("Empty.dll"), Is.True); + Assert.That(typesByAssembly["Empty.dll"], Is.Empty); + } + + [Test] + public void ApplyAssemblyReports_NullArguments_ThrowOrTreatNullPayloadAsRetirement() + { + Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); + Dictionary mergedReports = new(StringComparer.Ordinal); + + Assert.Throws(() => + BaseCallReportAggregator.ApplyAssemblyReports( + string.Empty, + new Dictionary(StringComparer.Ordinal), + typesByAssembly, + mergedReports + ) + ); + Assert.Throws(() => + BaseCallReportAggregator.ApplyAssemblyReports( + "A.dll", + new Dictionary(StringComparer.Ordinal), + null!, + mergedReports + ) + ); + Assert.Throws(() => + BaseCallReportAggregator.ApplyAssemblyReports( + "A.dll", + new Dictionary(StringComparer.Ordinal), + typesByAssembly, + null! + ) + ); + + // A null `latestReportsForAssembly` is the harvester's "this assembly produced zero + // matching messages" sentinel and must behave as the retirement path (same as an empty + // dict). + BaseCallReportAggregator.ApplyAssemblyReports( + "A.dll", + MakeReports(("X", new[] { "Awake" }, "DXMSG006", "A/X.cs", 1)), + typesByAssembly, + mergedReports + ); + BaseCallReportAggregator.ApplyAssemblyReports( + "A.dll", + null, + typesByAssembly, + mergedReports + ); + Assert.That(mergedReports, Is.Empty); + Assert.That(typesByAssembly["A.dll"], Is.Empty); + } + + // -- BaseCallReportAggregator.BuildSnapshot tests + // --------------------------------------------- + + [Test] + public void BuildSnapshot_LogEntriesAndCompilerMessageAgree() + { + // Same type + same method reported via both paths: one entry, dedup'd diagnostic IDs, + // method appears once. + Dictionary logEntries = MakeReports( + ("Sample.Player", new[] { "Awake" }, "DXMSG006", "Assets/Player.cs", 8) + ); + Dictionary merged = MakeReports( + ("Sample.Player", new[] { "Awake" }, "DXMSG006", "Assets/Player.cs", 8) + ); + + Dictionary snapshot = + BaseCallReportAggregator.BuildSnapshot(logEntries, merged); + + Assert.That(snapshot, Has.Count.EqualTo(1)); + BaseCallReportEntryDto entry = snapshot["Sample.Player"]; + Assert.That(entry.MissingBaseFor, Is.EquivalentTo(new[] { "Awake" })); + Assert.That(entry.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG006" })); + Assert.That(entry.FilePath, Is.EqualTo("Assets/Player.cs")); + Assert.That(entry.Line, Is.EqualTo(8)); + } + + [Test] + public void BuildSnapshot_LogEntriesOnlyVsCompilerMessageOnly() + { + // Each source independently produces a non-empty snapshot. Both halves of the dual-source + // contract must work in isolation — Unity 2021 only feeds the CompilerMessage path, + // Unity 2022+ predominantly feeds LogEntries. + Dictionary logOnly = MakeReports( + ("Sample.A", new[] { "Awake" }, "DXMSG006", "A.cs", 1) + ); + Dictionary logSnapshot = + BaseCallReportAggregator.BuildSnapshot(logOnly, mergedReports: null); + Assert.That(logSnapshot, Has.Count.EqualTo(1)); + Assert.That(logSnapshot.ContainsKey("Sample.A"), Is.True); + + Dictionary mergedOnly = MakeReports( + ("Sample.B", new[] { "OnEnable" }, "DXMSG009", "B.cs", 2) + ); + Dictionary mergedSnapshot = + BaseCallReportAggregator.BuildSnapshot(logEntriesReports: null, mergedOnly); + Assert.That(mergedSnapshot, Has.Count.EqualTo(1)); + Assert.That(mergedSnapshot.ContainsKey("Sample.B"), Is.True); + Assert.That( + mergedSnapshot["Sample.B"].DiagnosticIds, + Is.EquivalentTo(new[] { "DXMSG009" }) + ); + } + + [Test] + public void BuildSnapshot_KeepsFirstSeenFilePathLine() + { + // First seen wins. LogEntries reports first → its path/line stick even though merged + // also has data for the same type with a different path/line. + Dictionary logEntries = MakeReports( + ("X", new[] { "Awake" }, "DXMSG006", "First.cs", 3) + ); + Dictionary merged = MakeReports( + ("X", new[] { "Awake" }, "DXMSG006", "Second.cs", 99) + ); + + Dictionary snapshot = + BaseCallReportAggregator.BuildSnapshot(logEntries, merged); + + Assert.That(snapshot["X"].FilePath, Is.EqualTo("First.cs")); + Assert.That(snapshot["X"].Line, Is.EqualTo(3)); + } + + [Test] + public void BuildSnapshot_UnionsMethodsAndDiagnosticIdsAcrossSources() + { + // LogEntries says X.Awake / DXMSG006; merged says X.OnEnable / DXMSG009. The snapshot + // union must carry both methods and both diagnostic IDs on a single X entry. + Dictionary logEntries = MakeReports( + ("X", new[] { "Awake" }, "DXMSG006", "A.cs", 1) + ); + Dictionary merged = MakeReports( + ("X", new[] { "OnEnable" }, "DXMSG009", "B.cs", 2) + ); + + Dictionary snapshot = + BaseCallReportAggregator.BuildSnapshot(logEntries, merged); + + Assert.That(snapshot["X"].MissingBaseFor, Is.EquivalentTo(new[] { "Awake", "OnEnable" })); + Assert.That(snapshot["X"].DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG006", "DXMSG009" })); + } + + [Test] + public void BuildSnapshot_EmptyInputs_ReturnsEmptySnapshot() + { + Dictionary snapshot = + BaseCallReportAggregator.BuildSnapshot(null, null); + Assert.That(snapshot, Is.Empty); + + snapshot = BaseCallReportAggregator.BuildSnapshot( + new Dictionary(StringComparer.Ordinal), + new Dictionary(StringComparer.Ordinal) + ); + Assert.That(snapshot, Is.Empty); + } + + [Test] + public void ApplyAssemblyReports_ThreeAssembliesDisjointSetsRetireOneAndOverlap() + { + // Spec 3a: A reports {X, Y}, B reports {Y, Z}, C reports {W}. The merged snapshot must + // contain {W, X, Y, Z}. Then A retires X (recompiles without it). The merged snapshot must + // still contain {W, Y, Z}: Y survives because B still claims it; X disappears entirely. + Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); + Dictionary mergedReports = new(StringComparer.Ordinal); + + BaseCallReportAggregator.ApplyAssemblyReports( + "A.dll", + MakeReports( + ("X", new[] { "Awake" }, "DXMSG006", "A/X.cs", 1), + ("Y", new[] { "OnEnable" }, "DXMSG006", "A/Y.cs", 2) + ), + typesByAssembly, + mergedReports + ); + BaseCallReportAggregator.ApplyAssemblyReports( + "B.dll", + MakeReports( + ("Y", new[] { "OnEnable" }, "DXMSG006", "B/Y.cs", 3), + ("Z", new[] { "OnDisable" }, "DXMSG006", "B/Z.cs", 4) + ), + typesByAssembly, + mergedReports + ); + BaseCallReportAggregator.ApplyAssemblyReports( + "C.dll", + MakeReports(("W", new[] { "OnDestroy" }, "DXMSG006", "C/W.cs", 5)), + typesByAssembly, + mergedReports + ); + + Assert.That( + mergedReports.Keys, + Is.EquivalentTo(new[] { "W", "X", "Y", "Z" }), + "Pre-retirement snapshot must contain all four FQNs across the three assemblies." + ); + + // A retires X by recompiling and reporting only Y. + BaseCallReportAggregator.ApplyAssemblyReports( + "A.dll", + MakeReports(("Y", new[] { "OnEnable" }, "DXMSG006", "A/Y.cs", 2)), + typesByAssembly, + mergedReports + ); + + Assert.That( + mergedReports.Keys, + Is.EquivalentTo(new[] { "W", "Y", "Z" }), + "X must be retired; W/Y/Z must remain (Y survives via B's claim)." + ); + Assert.That(typesByAssembly["A.dll"], Is.EquivalentTo(new[] { "Y" })); + Assert.That(typesByAssembly["B.dll"], Is.EquivalentTo(new[] { "Y", "Z" })); + Assert.That(typesByAssembly["C.dll"], Is.EquivalentTo(new[] { "W" })); + } + + [Test] + public void Aggregate_SameAssemblyReportsSameFqnMultipleTimesInOneDrain_DedupesMethods() + { + // Spec 3b: a single drain that contains the SAME line three times for the same FQN must + // dedupe — Aggregate-then-merge always produces a single MissingBaseFor entry per method. + // This pins the parser-level dedup contract that the harvester depends on. + Dictionary aggregated = BaseCallLogMessageParser.Aggregate( + new[] { Unity2021Dxmsg009, Unity2021Dxmsg009, Unity2021Dxmsg009 } + ); + + Assert.That(aggregated, Has.Count.EqualTo(1)); + ParsedTypeReport report = aggregated["Sample.BrokenThing"]; + Assert.That( + report.MissingBaseFor, + Is.EquivalentTo(new[] { "OnEnable" }), + "Triple-reported same FQN.method must collapse to a single MissingBaseFor entry." + ); + Assert.That(report.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG009" })); + } + + [Test] + public void BuildSnapshot_DictionaryWithNullValueDoesNotCrash() + { + // Spec 3c: defensive — if either source dictionary contains a null ParsedTypeReport value + // (a defensive shape we may see if the harvester's internal state ever decays), the + // snapshot builder must not crash. The null entry is silently skipped. + Dictionary logEntries = new(StringComparer.Ordinal) + { + { "Sample.X", null! }, + }; + Dictionary merged = MakeReports( + ("Sample.Y", new[] { "OnEnable" }, "DXMSG006", "Y.cs", 1) + ); + + Dictionary? snapshot = null; + Assert.DoesNotThrow(() => + { + snapshot = BaseCallReportAggregator.BuildSnapshot(logEntries, merged); + }); + Assert.That(snapshot, Is.Not.Null); + // The null-valued Sample.X is skipped; only Sample.Y survives. + Assert.That(snapshot!.Keys, Is.EquivalentTo(new[] { "Sample.Y" })); + } + + [Test] + public void ApplyAssemblyReports_FilePathStickinessFirstSeenWinsAcrossAssemblies() + { + // Spec 3d: A reports type X with path=A.cs line=10. B then ALSO reports X with path=B.cs + // line=20. The merged snapshot must keep A.cs/10 (first-assembly-seen wins). This pins + // the cross-assembly first-seen contract — same-assembly recompile uses latest payload + // (different code path; pinned implicitly by ApplyAssemblyReports_RecompileSameAssembly...). + Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); + Dictionary mergedReports = new(StringComparer.Ordinal); + + BaseCallReportAggregator.ApplyAssemblyReports( + "A.dll", + MakeReports(("X", new[] { "Awake" }, "DXMSG006", "A.cs", 10)), + typesByAssembly, + mergedReports + ); + BaseCallReportAggregator.ApplyAssemblyReports( + "B.dll", + MakeReports(("X", new[] { "Awake" }, "DXMSG006", "B.cs", 20)), + typesByAssembly, + mergedReports + ); + + Assert.That(mergedReports, Contains.Key("X")); + Assert.That( + mergedReports["X"].FilePath, + Is.EqualTo("A.cs"), + "First-assembly-seen file path must persist when a second assembly also reports the FQN." + ); + Assert.That( + mergedReports["X"].Line, + Is.EqualTo(10), + "First-assembly-seen line must persist when a second assembly also reports the FQN." + ); + } + + private static Dictionary MakeReports( + params ( + string Fqn, + string[] Methods, + string DiagnosticId, + string FilePath, + int Line + )[] entries + ) + { + Dictionary result = new(StringComparer.Ordinal); + foreach ((string fqn, string[] methods, string id, string path, int line) in entries) + { + ParsedTypeReport report = new() + { + TypeFullName = fqn, + FilePath = path, + Line = line, + }; + foreach (string method in methods.Distinct(StringComparer.Ordinal)) + { + report.MissingBaseFor.Add(method); + } + report.DiagnosticIds.Add(id); + result[fqn] = report; + } + return result; + } +} diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs.meta b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs.meta new file mode 100644 index 00000000..0eb49a43 --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 89debb9a37ebd5b4f9e1af8c2136bbe8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTestUtilities.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTestUtilities.cs index c998da25..664e8ca6 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTestUtilities.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTestUtilities.cs @@ -1,8 +1,14 @@ using System.Collections.Immutable; +using System.Linq; using System.Reflection; +using System.Threading; +using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; using WallstopStudios.DxMessaging.SourceGenerators; +using WallstopStudios.DxMessaging.SourceGenerators.Analyzers; namespace WallstopStudios.DxMessaging.SourceGenerators.Tests; @@ -78,6 +84,112 @@ internal static ImmutableArray ParseSnippet(string userSource) return userTree.GetDiagnostics().ToImmutableArray(); } + internal static ImmutableArray RunBaseCallAnalyzer( + string userSource, + params (string path, string contents)[] additionalFiles + ) + { + return RunBaseCallAnalyzer(userSource, compilationOptions: null, additionalFiles); + } + + /// + /// Variant accepting a custom so tests can pass + /// WithSpecificDiagnosticOptions(...) to verify .editorconfig severity overrides + /// (item H in the adversarial review). + /// + internal static ImmutableArray RunBaseCallAnalyzer( + string userSource, + CSharpCompilationOptions? compilationOptions, + params (string path, string contents)[] additionalFiles + ) + { + SyntaxTree stubs = CSharpSyntaxTree.ParseText(SharedStubs, ParseOptions); + SyntaxTree userTree = CSharpSyntaxTree.ParseText(userSource, ParseOptions); + + CSharpCompilationOptions effectiveOptions = + compilationOptions ?? new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary); + + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName: "AnalyzerTests", + syntaxTrees: new[] { stubs, userTree }, + references: CoreReferences, + options: effectiveOptions + ); + + // B6. Refuse to return when the underlying compilation has errors — otherwise tests can + // silently bind nothing and pass. We exclude analyzer diagnostics here (we only want raw + // compile errors) by calling Compilation.GetDiagnostics rather than the analyzer pipeline. + ImmutableArray compileDiags = compilation.GetDiagnostics(); + ImmutableArray errors = compileDiags + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToImmutableArray(); + if (!errors.IsEmpty) + { + throw new InvalidOperationException( + "Test source did not compile cleanly:\n" + + string.Join("\n", errors.Select(d => d.ToString())) + ); + } + + ImmutableArray texts = (additionalFiles ?? Array.Empty<(string, string)>()) + .Select(t => (AdditionalText)new InMemoryAdditionalText(t.path, t.contents)) + .ToImmutableArray(); + + AnalyzerOptions analyzerOptions = new(texts); + CompilationWithAnalyzers compilationWithAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(new MessageAwareComponentBaseCallAnalyzer()), + analyzerOptions + ); + + Task> task = + compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None); + return task.GetAwaiter().GetResult(); + } + + /// + /// Lenient variant of + /// that does NOT throw on compile errors. Used by + /// where many doc fragments are not standalone-compilable (e.g. they reference types from the + /// runtime that the test compilation does not link). The base-call analyzer keys exclusively + /// off override syntax + the MessageAwareComponent base-symbol lookup, so it still + /// produces meaningful results when other parts of the snippet fail to bind. + /// + internal static ImmutableArray RunBaseCallAnalyzerLenient(string userSource) + { + SyntaxTree stubs = CSharpSyntaxTree.ParseText(SharedStubs, ParseOptions); + SyntaxTree userTree = CSharpSyntaxTree.ParseText(userSource, ParseOptions); + + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName: "DocsSnippetAnalyzer", + syntaxTrees: new[] { stubs, userTree }, + references: CoreReferences, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + CompilationWithAnalyzers compilationWithAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(new MessageAwareComponentBaseCallAnalyzer()) + ); + + Task> task = + compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None); + return task.GetAwaiter().GetResult(); + } + + /// + /// S1. Builds an wrapping the given in-memory additional files. + /// Exposed to tests that need to exercise + /// directly (e.g. verifying the cache contract across repeat calls with different cancellation tokens). + /// + internal static AnalyzerOptions BuildAnalyzerOptions( + params (string path, string contents)[] additionalFiles + ) + { + ImmutableArray texts = (additionalFiles ?? Array.Empty<(string, string)>()) + .Select(t => (AdditionalText)new InMemoryAdditionalText(t.path, t.contents)) + .ToImmutableArray(); + return new AnalyzerOptions(texts); + } + private static ImmutableArray BuildCoreReferences() { List references = new(); @@ -99,6 +211,24 @@ void AddAssembly(Assembly assembly) return references.ToImmutableArray(); } + private sealed class InMemoryAdditionalText : AdditionalText + { + private readonly SourceText _sourceText; + + public InMemoryAdditionalText(string path, string contents) + { + Path = path; + _sourceText = SourceText.From(contents ?? string.Empty); + } + + public override string Path { get; } + + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return _sourceText; + } + } + private const string SharedStubs = """ namespace DxMessaging.Core.Attributes { @@ -125,6 +255,9 @@ public sealed class DxUntargetedMessageAttribute : Attribute { } [AttributeUsage(AttributeTargets.Struct)] public sealed class DxBroadcastMessageAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false, AllowMultiple = false)] + public sealed class DxIgnoreMissingBaseCallAttribute : Attribute { } } namespace DxMessaging.Core @@ -150,6 +283,24 @@ public struct Color { public static readonly Color green = default; } + + public class MonoBehaviour { } +} + +namespace DxMessaging.Unity +{ + using UnityEngine; + + public abstract class MessageAwareComponent : MonoBehaviour + { + protected virtual bool RegisterForStringMessages => true; + + protected virtual void Awake() { } + protected virtual void OnEnable() { } + protected virtual void OnDisable() { } + protected virtual void OnDestroy() { } + protected virtual void RegisterMessageHandlers() { } + } } """; } diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs new file mode 100644 index 00000000..ba1d6086 --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs @@ -0,0 +1,2289 @@ +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using NUnit.Framework; +using WallstopStudios.DxMessaging.SourceGenerators.Analyzers; + +namespace WallstopStudios.DxMessaging.SourceGenerators.Tests; + +[TestFixture] +public sealed class MessageAwareComponentBaseCallAnalyzerTests +{ + // S2. Reference the analyzer's source-of-truth constant directly via InternalsVisibleTo — + // no more duplicated literal in the tests. Drift risk eliminated. + private static readonly string IgnoreFileName = IgnoreListReader.IgnoreFileName; + + [Test] + public void OverrideAwakeWithoutBaseEmitsDxmsg006() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + int x = 0; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + Diagnostic dxmsg006 = diagnostics.Single(d => d.Id == "DXMSG006"); + // E. Diagnostic location must point at the method identifier so the IDE squiggly + // appears under the method name (not the body, modifier list, or whole declaration). + Assert.That( + dxmsg006 + .Location.SourceTree!.GetText() + .GetSubText(dxmsg006.Location.SourceSpan) + .ToString(), + Is.EqualTo("Awake") + ); + } + + [Test] + public void OverrideAwakeWithBaseCallIsClean() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + base.Awake(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public void ExpressionBodiedAwakeWithoutBaseEmitsDxmsg006() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + private void DoStuff() { } + protected override void Awake() => DoStuff(); + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + } + + [Test] + public void ExpressionBodiedAwakeWithBaseCallIsClean() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() => base.Awake(); + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public void ConditionalBaseCallIsAcceptedAsGoodFaith() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + public bool flag; + protected override void Awake() + { + if (flag) + { + base.Awake(); + } + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + [TestCase("OnEnable")] + [TestCase("OnDisable")] + [TestCase("OnDestroy")] + [TestCase("RegisterMessageHandlers")] + public void EachOtherGuardedMethodEmitsDxmsg006WhenMissingBase(string methodName) + { + string source = $$""" +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void {{methodName}}() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + // E. Each guarded method's diagnostic squiggly must land on the method identifier. + Diagnostic dxmsg006 = diagnostics.Single(d => d.Id == "DXMSG006"); + Assert.That( + dxmsg006 + .Location.SourceTree!.GetText() + .GetSubText(dxmsg006.Location.SourceSpan) + .ToString(), + Is.EqualTo(methodName) + ); + } + + [Test] + public void RegisterMessageHandlersWithoutBaseAndStringMessagesDisabledIsLoweredToInfo() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override bool RegisterForStringMessages => false; + + protected override void RegisterMessageHandlers() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Info); + AssertSmartCaseMessageMentionsTypeAndMethod(diagnostics, "Sample.Player"); + AssertNoSiblings(diagnostics, "DXMSG006"); + } + + [Test] + public void RegisterMessageHandlersInfoSmartCaseRespectsBlockBodiedFalseGetter() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override bool RegisterForStringMessages + { + get { return false; } + } + + protected override void RegisterMessageHandlers() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Info); + AssertSmartCaseMessageMentionsTypeAndMethod(diagnostics, "Sample.Player"); + AssertNoSiblings(diagnostics, "DXMSG006"); + } + + [Test] + public void RegisterMessageHandlersInfoSmartCaseRespectsArrowGetter() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override bool RegisterForStringMessages + { + get => false; + } + + protected override void RegisterMessageHandlers() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Info); + AssertSmartCaseMessageMentionsTypeAndMethod(diagnostics, "Sample.Player"); + AssertNoSiblings(diagnostics, "DXMSG006"); + } + + [Test] + public void NewKeywordOnGuardedMethodEmitsDxmsg007() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected new void Awake() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG007", DiagnosticSeverity.Warning); + // E. DXMSG007's squiggly must also land on the method identifier. + Diagnostic dxmsg007 = diagnostics.Single(d => d.Id == "DXMSG007"); + Assert.That( + dxmsg007 + .Location.SourceTree!.GetText() + .GetSubText(dxmsg007.Location.SourceSpan) + .ToString(), + Is.EqualTo("Awake") + ); + } + + [Test] + public void TwoDeepInheritanceFlagsOnlyDescendantWithMissingBase() + { + string source = """ +namespace Sample +{ + public class A : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + base.Awake(); + } + } + + public sealed class B : A + { + protected override void Awake() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Diagnostic[] dxmsg006 = diagnostics.Where(d => d.Id == "DXMSG006").ToArray(); + Assert.That(dxmsg006, Has.Length.EqualTo(1)); + Assert.That(dxmsg006[0].GetMessage(), Does.Contain("Sample.B")); + } + + [Test] + public void IgnoreAttributeOnClassEmitsDxmsg008Only() + { + // B5. Two overrides: one clean, one dirty. DXMSG008 must fire ONCE — on the dirty one. + // Clean overrides on opted-out classes must produce zero diagnostics (no noise). + string source = """ +namespace Sample +{ + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + base.Awake(); + } + + protected override void OnEnable() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG007"), Is.Empty); + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + Assert.That( + diagnostics + .Single(d => d.Id == "DXMSG008") + .Location.SourceTree!.GetText() + .GetSubText(diagnostics.Single(d => d.Id == "DXMSG008").Location.SourceSpan) + .ToString(), + Is.EqualTo("OnEnable") + ); + } + + [Test] + public void IgnoreAttributeOnMethodEmitsDxmsg008Only() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + protected override void Awake() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG007"), Is.Empty); + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + } + + [Test] + public void ClassFullNameInIgnoreListEmitsDxmsg008Only() + { + // B5. Two overrides — clean and dirty. DXMSG008 fires ONCE on the dirty one. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + base.Awake(); + } + + protected override void OnDisable() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer( + source, + (IgnoreFileName, "# header line\n\nSample.Player\n") + ); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG007"), Is.Empty); + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + Assert.That( + diagnostics + .Single(d => d.Id == "DXMSG008") + .Location.SourceTree!.GetText() + .GetSubText(diagnostics.Single(d => d.Id == "DXMSG008").Location.SourceSpan) + .ToString(), + Is.EqualTo("OnDisable") + ); + } + + [Test] + public void PartialClassWithBaseCallInOtherPartialIsClean() + { + string source = """ +namespace Sample +{ + public partial class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + base.Awake(); + } + } + + public partial class Player + { + private void Helper() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public void MessageAwareComponentItselfIsNeverFlagged() + { + // A type with the same simple name as the base class but residing outside the + // DxMessaging.Unity namespace must be ignored: the analyzer's strict-inheritance check + // walks BaseType (not name comparisons), so this class never inherits from the real MAC. + string source = """ +namespace Sample +{ + public abstract class MessageAwareComponent + { + protected virtual void Awake() { } + } + + public sealed class Player : MessageAwareComponent + { + protected override void Awake() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public void SealedOverrideWithoutBaseEmitsDxmsg006() + { + string source = """ +namespace Sample +{ + public class A : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + base.Awake(); + } + } + + public sealed class B : A + { + protected sealed override void Awake() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Diagnostic[] dxmsg006 = diagnostics.Where(d => d.Id == "DXMSG006").ToArray(); + Assert.That(dxmsg006, Has.Length.EqualTo(1)); + Assert.That(dxmsg006[0].GetMessage(), Does.Contain("Sample.B")); + Assert.That(dxmsg006[0].Severity, Is.EqualTo(DiagnosticSeverity.Warning)); + } + + [Test] + public void HelperIndirectionFalsePositiveStillFires() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() => CallBaseAwake(); + + private void CallBaseAwake() + { + base.Awake(); + } + } +} +"""; + + // Documented false positive — analyzer is good-faith and only inspects the override body. + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + } + + [Test] + public void AsyncVoidAwakeWithoutBaseEmitsDxmsg006() + { + string source = """ +namespace Sample +{ + using System.Threading.Tasks; + + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override async void Awake() + { + await Task.Yield(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Diagnostic[] dxmsg006 = diagnostics.Where(d => d.Id == "DXMSG006").ToArray(); + Assert.That(dxmsg006, Has.Length.EqualTo(1)); + Assert.That(dxmsg006[0].Severity, Is.EqualTo(DiagnosticSeverity.Warning)); + } + + [Test] + public void GenericIntermediaryWithoutBaseCallIsFlagged() + { + string source = """ +namespace Sample +{ + public abstract class MyBase : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + base.Awake(); + } + } + + public sealed class MyConcrete : MyBase + { + protected override void Awake() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Diagnostic[] dxmsg006 = diagnostics.Where(d => d.Id == "DXMSG006").ToArray(); + Assert.That(dxmsg006, Has.Length.EqualTo(1)); + Assert.That(dxmsg006[0].GetMessage(), Does.Contain("Sample.MyConcrete")); + } + + [Test] + public void NonLiteralRegisterForStringMessagesKeepsWarningSeverity() + { + string source = """ +namespace Sample +{ + public static class SomeStaticConfig + { + public static bool Disable = false; + } + + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override bool RegisterForStringMessages => SomeStaticConfig.Disable; + + protected override void RegisterMessageHandlers() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + } + + [Test] + public void MethodWithoutOverrideOrNewIsIgnored() + { + // A new declaration named Awake that neither overrides nor hides — should not be flagged. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + public void Awake(int discriminator) + { + int x = discriminator; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public void IgnoreListReaderTreatsCommentsAndBlankLinesAsNoise() + { + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + int x = 1; + } + } +} +"""; + + // Lines with leading whitespace, commented lines and blank lines should be skipped. + const string ignoreContents = + "# This file is auto-generated; do not hand-edit\n" + + "\n" + + " Sample.Player \n" + + "# Sample.Player.Other\n"; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer( + source, + (IgnoreFileName, ignoreContents) + ); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + } + + // -- B2 ---------------------------------------------------------------------------------- + + [Test] + public void InheritedRegisterForStringMessagesOverrideTriggersSmartCaseLowering() + { + // B2. The override of RegisterForStringMessages lives on the base, NOT the most-derived + // class. The smart-case lowering must walk the inheritance chain to find it. Without the + // fix, this test would assert Warning; with the fix, Info. + string source = """ +namespace Sample +{ + public abstract class MyBase : DxMessaging.Unity.MessageAwareComponent + { + protected override bool RegisterForStringMessages => false; + } + + public sealed class MyConcrete : MyBase + { + protected override void RegisterMessageHandlers() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Info); + AssertSmartCaseMessageMentionsTypeAndMethod(diagnostics, "Sample.MyConcrete"); + AssertNoSiblings(diagnostics, "DXMSG006"); + } + + [Test] + public void MoreDerivedRegisterForStringMessagesOverrideWinsAndPreventsSmartCase() + { + // B2. Most-derived override wins. Even though the grandparent returns literal false, the + // intermediate overrides it back to literal true — so the smart-case must NOT apply. + string source = """ +namespace Sample +{ + public abstract class GrandParent : DxMessaging.Unity.MessageAwareComponent + { + protected override bool RegisterForStringMessages => false; + } + + public abstract class Parent : GrandParent + { + protected override bool RegisterForStringMessages => true; + } + + public sealed class Child : Parent + { + protected override void RegisterMessageHandlers() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + } + + // -- B3 ---------------------------------------------------------------------------------- + + [Test] + public void ConditionalReturnFalseInRegisterForStringMessagesGetterKeepsWarning() + { + // B3. Block-bodied getter with `if (...) return false; return true;` is NOT + // unconditional — smart-case must NOT apply. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + public bool specialCondition; + + protected override bool RegisterForStringMessages + { + get + { + if (specialCondition) return false; + return true; + } + } + + protected override void RegisterMessageHandlers() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + } + + // -- B4 ---------------------------------------------------------------------------------- + + [TestCase("default")] + [TestCase("false || false")] + [TestCase("!true")] + [TestCase("SomeStaticConfig.Disable")] + public void NonLiteralRegisterForStringMessagesExpressionsKeepWarningSeverity(string expression) + { + string source = $$""" +namespace Sample +{ + public static class SomeStaticConfig + { + public static bool Disable = false; + } + + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override bool RegisterForStringMessages => {{expression}}; + + protected override void RegisterMessageHandlers() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + } + + // -- B (strong, ordering) ---------------------------------------------------------------- + + [Test] + public void NonOverrideAwakeOnOptedOutClassProducesZeroDiagnostics() + { + // B (strong). A method named `Awake` with neither `override` nor `new` on an opted-out + // class must produce zero diagnostics — including DXMSG008. Before the reorder fix, + // DXMSG008 could fire here. + string source = """ +namespace Sample +{ + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + public void Awake(int discriminator) + { + int x = discriminator; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + // -- G (using alias) --------------------------------------------------------------------- + + [Test] + public void UsingAliasForMessageAwareComponentResolvesViaFullyQualifiedName() + { + // G. Confirms FQN resolution survives `using` aliases — the analyzer walks BaseType + // and compares against the symbol's display name, so aliases are transparent. + string source = """ +using MAC = DxMessaging.Unity.MessageAwareComponent; + +namespace Sample +{ + public sealed class Player : MAC + { + protected override void Awake() + { + int x = 0; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + } + + // -- H (.editorconfig severity overrides) ------------------------------------------------ + + [Test] + public void EditorConfigSuppressOnDxmsg006SilencesCanonicalCase() + { + // H.1 — descriptor-based DXMSG006 path: WithSpecificDiagnosticOptions(Suppress) yields zero. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + int x = 0; + } + } +} +"""; + + CSharpCompilationOptions options = new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary + ).WithSpecificDiagnosticOptions( + ImmutableDictionary.CreateRange( + new[] + { + new System.Collections.Generic.KeyValuePair( + "DXMSG006", + ReportDiagnostic.Suppress + ), + } + ) + ); + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer( + source, + options + ); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + } + + [Test] + public void EditorConfigSuppressOnDxmsg006AlsoSilencesSmartCaseInfoPath() + { + // H.2 — the runtime-built `Diagnostic.Create(string id, ...)` path used for the + // smart-case Info lowering must also honour editorconfig severity overrides. This is the + // rubric-flagged "real gotcha" — without proper threading the Info diagnostic would slip + // through `Suppress`. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override bool RegisterForStringMessages => false; + + protected override void RegisterMessageHandlers() + { + int x = 1; + } + } +} +"""; + + CSharpCompilationOptions options = new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary + ).WithSpecificDiagnosticOptions( + ImmutableDictionary.CreateRange( + new[] + { + new System.Collections.Generic.KeyValuePair( + "DXMSG006", + ReportDiagnostic.Suppress + ), + } + ) + ); + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer( + source, + options + ); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + } + + [Test] + public void EditorConfigPromoteOnDxmsg006ProducesError() + { + // H.3 — promotion (`Error`) must thread through the descriptor path. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + int x = 0; + } + } +} +"""; + + CSharpCompilationOptions options = new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary + ).WithSpecificDiagnosticOptions( + ImmutableDictionary.CreateRange( + new[] + { + new System.Collections.Generic.KeyValuePair( + "DXMSG006", + ReportDiagnostic.Error + ), + } + ) + ); + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer( + source, + options + ); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Error); + } + + // -- I (good-faith policy: lambda / local function) -------------------------------------- + + [Test] + public void BaseCallInsideLocalFunctionIsAcceptedAsGoodFaith() + { + // I. base.X() inside a nested local function still satisfies the good-faith textual + // search — DescendantNodes() walks lambdas and local functions. Documents the policy. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + void Local() { base.Awake(); } + Local(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + } + + // -- J (global:: prefix in ignore list) -------------------------------------------------- + + [Test] + public void GlobalPrefixedFqnInIgnoreListMatchesAfterPrefixStripping() + { + // J. Friendlier UX: a `global::` prefix on an ignore-list entry is stripped so it still + // matches the symbol's omitted-global FQN comparison. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer( + source, + (IgnoreFileName, "global::Sample.Player\n") + ); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + } + + [Test] + public void NonPrefixedFqnInIgnoreListAlsoMatches() + { + // J. Sanity: the without-prefix form continues to match. Pairs with the prefixed test. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer( + source, + (IgnoreFileName, "Sample.Player\n") + ); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + } + + // -- S1 (cache contract under repeated calls / mismatched tokens) ----------------------- + + [Test] + public void IgnoreListReaderRepeatLoadOnSameOptionsReturnsSameInstanceAndIsTokenSafe() + { + // S1. Verify the Lazy cache contract: two calls to Load(...) with the same + // AnalyzerOptions must return the same hashset instance (single-shot memoization), + // and passing a different (or even already-cancelled) CancellationToken on the + // second call must NOT crash — the factory deliberately drops the outer token via + // CancellationToken.None to avoid the cached-cancellation-exception footgun. + AnalyzerOptions options = GeneratorTestUtilities.BuildAnalyzerOptions( + (IgnoreFileName, "Sample.Player\nSample.Other\n") + ); + + ImmutableHashSet first = IgnoreListReader.Load(options, CancellationToken.None); + Assert.That(first, Has.Count.EqualTo(2)); + Assert.That(first, Does.Contain("Sample.Player")); + Assert.That(first, Does.Contain("Sample.Other")); + + // Second call uses an already-cancelled token. If the factory closure baked the + // first call's token, this would still return the cached value — fine. The footgun + // (which the fix prevents) is the inverse: a cancelled FIRST call caches an + // OperationCanceledException and re-throws it forever. We can't easily simulate + // that here without racing, so we settle for asserting the cache returns the same + // immutable hashset reference and never throws on a token mismatch. + using CancellationTokenSource cts = new(); + cts.Cancel(); + ImmutableHashSet? second = null; + Assert.DoesNotThrow(() => second = IgnoreListReader.Load(options, cts.Token)); + Assert.That(second, Is.SameAs(first)); + } + + // -- S4 (combined opt-out + literal-false RegisterForStringMessages) --------------------- + + [Test] + public void OptOutAttributePlusFalseStringMessagesSettingProducesSingleDxmsg008() + { + // S4. When a class has BOTH the [DxIgnoreMissingBaseCall] opt-out AND a literal-false + // RegisterForStringMessages override AND a missing-base RegisterMessageHandlers, the + // opt-out path wins: exactly ONE DXMSG008 fires (because the underlying check would + // have produced a DXMSG006 diagnostic at *some* severity — would-have-fired counts as + // needing suppression), and the smart-case Info-lowering path is bypassed entirely. + string source = """ +namespace Sample +{ + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override bool RegisterForStringMessages => false; + + protected override void RegisterMessageHandlers() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG007"), Is.Empty); + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + } + + // -- helpers ----------------------------------------------------------------------------- + + private static void AssertSingle( + ImmutableArray diagnostics, + string expectedId, + DiagnosticSeverity expectedSeverity + ) + { + Diagnostic[] matching = diagnostics.Where(d => d.Id == expectedId).ToArray(); + Assert.That( + matching, + Has.Length.EqualTo(1), + $"Expected exactly one {expectedId} diagnostic; got: " + + string.Join(", ", diagnostics.Select(d => d.Id + "(" + d.Severity + ")")) + ); + Assert.That( + matching[0].Severity, + Is.EqualTo(expectedSeverity), + $"Expected {expectedId} to have severity {expectedSeverity}." + ); + } + + /// + /// D. Smart-case tests must verify the formatted message threads the type display name and + /// the `RegisterMessageHandlers` method name correctly through the runtime-built diagnostic. + /// + private static void AssertSmartCaseMessageMentionsTypeAndMethod( + ImmutableArray diagnostics, + string expectedTypeDisplayName + ) + { + Diagnostic dxmsg006 = diagnostics.Single(d => d.Id == "DXMSG006"); + string message = dxmsg006.GetMessage(); + Assert.That( + message, + Does.Contain(expectedTypeDisplayName), + $"Smart-case message must include the containing type name '{expectedTypeDisplayName}'." + ); + Assert.That( + message, + Does.Contain("RegisterMessageHandlers"), + "Smart-case message must include the method name 'RegisterMessageHandlers'." + ); + } + + /// + /// F. Belt-and-braces: assert no spurious sibling diagnostics from other DXMSG ids when the + /// canonical id under test is fixed. + /// + private static void AssertNoSiblings(ImmutableArray diagnostics, string canonicalId) + { + if (canonicalId != "DXMSG007") + { + Assert.That( + diagnostics.Count(d => d.Id == "DXMSG007"), + Is.Zero, + "Did not expect any DXMSG007 diagnostics." + ); + } + if (canonicalId != "DXMSG008") + { + Assert.That( + diagnostics.Count(d => d.Id == "DXMSG008"), + Is.Zero, + "Did not expect any DXMSG008 diagnostics." + ); + } + if (canonicalId != "DXMSG006") + { + Assert.That( + diagnostics.Count(d => d.Id == "DXMSG006"), + Is.Zero, + "Did not expect any DXMSG006 diagnostics." + ); + } + if (canonicalId != "DXMSG009") + { + Assert.That( + diagnostics.Count(d => d.Id == "DXMSG009"), + Is.Zero, + "Did not expect any DXMSG009 diagnostics." + ); + } + } + + // -- DXMSG009 (implicit-hide / missing-modifier) coverage -------------------------------- + + [Test] + public void PrivateOnEnableWithoutModifierEmitsDxmsg009() + { + // The user-reported case: `private void OnEnable() {}` on a MessageAwareComponent subclass. + // C# emits CS0114; our analyzer must surface DXMSG009 so the inspector overlay also shows it. + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + private void OnEnable() { } + } +} +"""; + + 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("OnEnable")); + // S1: pin the CS0114 cross-reference into the message so a future refactor that drops the + // parenthetical doesn't silently lose the canonical compiler-warning anchor. + Assert.That(dxmsg009.GetMessage(), Does.Contain("CS0114")); + string spanText = dxmsg009 + .Location.SourceTree!.GetText() + .GetSubText(dxmsg009.Location.SourceSpan) + .ToString(); + Assert.That(spanText, Is.EqualTo("OnEnable")); + } + + [Test] + public void GenericMethodWithGuardedNameDoesNotFireDxmsg009() + { + // C# does NOT emit CS0114 for `void Awake()` because the type-parameter arity differs + // from the base; both methods coexist. DXMSG009 must not fire either — flagging it would + // be a false positive misleading the user toward an incorrect "fix". + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + private void Awake() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public void ExpressionBodiedNonOverrideOnEnableEmitsDxmsg009() + { + // Expression-bodied form of the implicit-hide pattern. The method is parameter-less, + // returns void, non-static, and has no override/new modifier — so DXMSG009 fires. + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + private void DoStuff() { } + private void OnEnable() => DoStuff(); + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG009", DiagnosticSeverity.Warning); + AssertNoSiblings(diagnostics, "DXMSG009"); + } + + [Test] + public void Dxmsg009CoexistsWithDxmsg006OnSameClass() + { + // S3: a class can have one method that genuinely overrides without base (DXMSG006) AND + // another that implicitly hides (DXMSG009). Both diagnostics must fire on the same class + // so the inspector overlay surfaces both methods in its HelpBox. + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() { /* missing base.Awake() */ } + private void OnEnable() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics.Count(d => d.Id == "DXMSG006"), Is.EqualTo(1)); + Assert.That(diagnostics.Count(d => d.Id == "DXMSG009"), Is.EqualTo(1)); + Assert.That(diagnostics.Count(d => d.Id == "DXMSG007"), Is.Zero); + Assert.That(diagnostics.Count(d => d.Id == "DXMSG008"), Is.Zero); + } + + [Test] + public void NoAccessibilityOnEnableWithoutModifierEmitsDxmsg009() + { + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + void OnEnable() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG009", DiagnosticSeverity.Warning); + AssertNoSiblings(diagnostics, "DXMSG009"); + } + + [Test] + public void ProtectedAwakeWithoutModifierEmitsDxmsg009() + { + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + protected void Awake() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG009", DiagnosticSeverity.Warning); + AssertNoSiblings(diagnostics, "DXMSG009"); + } + + [Test] + public void PublicRegisterMessageHandlersWithoutModifierEmitsDxmsg009() + { + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + public void RegisterMessageHandlers() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG009", DiagnosticSeverity.Warning); + AssertNoSiblings(diagnostics, "DXMSG009"); + } + + [TestCase("Awake")] + [TestCase("OnEnable")] + [TestCase("OnDisable")] + [TestCase("OnDestroy")] + [TestCase("RegisterMessageHandlers")] + public void EachGuardedMethodWithoutModifierEmitsDxmsg009(string methodName) + { + string source = $$""" +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + private void {{methodName}}() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG009", DiagnosticSeverity.Warning); + AssertNoSiblings(diagnostics, "DXMSG009"); + Assert.That( + diagnostics.Single(d => d.Id == "DXMSG009").GetMessage(), + Does.Contain(methodName) + ); + } + + [Test] + public void OnEnableOverloadWithParameterDoesNotFireDxmsg009() + { + // A method named OnEnable that takes a parameter is NOT a Unity lifecycle override and + // does NOT hide the base. Signature filter must keep this silent. + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + public void OnEnable(int discriminator) { int x = discriminator; } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public void StaticAwakeDoesNotFireDxmsg009() + { + // Unity ignores static lifecycle methods; the analyzer should as well. + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + private static void Awake() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public void NonVoidOnEnableDoesNotFireDxmsg009() + { + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + public int OnEnable() => 0; + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public void Dxmsg009RespectsClassLevelDxIgnoreMissingBaseCall() + { + string source = """ +namespace Sample +{ + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + private void OnEnable() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + AssertNoSiblings(diagnostics, "DXMSG008"); + // S6: pin the suppression-source string so a future change to the analyzer's argument + // passing (the literal `[DxIgnoreMissingBaseCall]` for attribute-driven opt-outs vs the + // ignore-list filename) does not silently drift. + Assert.That( + diagnostics.Single(d => d.Id == "DXMSG008").GetMessage(), + Does.Contain("[DxIgnoreMissingBaseCall]") + ); + } + + [Test] + public void Dxmsg009RespectsMethodLevelDxIgnoreMissingBaseCall() + { + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + private void OnEnable() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + AssertNoSiblings(diagnostics, "DXMSG008"); + Assert.That( + diagnostics.Single(d => d.Id == "DXMSG008").GetMessage(), + Does.Contain("[DxIgnoreMissingBaseCall]") + ); + } + + [Test] + public void Dxmsg009RespectsProjectIgnoreList() + { + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + private void OnEnable() { } + } +} +"""; + (string path, string contents)[] additionalFiles = new[] + { + ($"some/path/{IgnoreListReader.IgnoreFileName}", "Sample.BrokenThing\n"), + }; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer( + source, + additionalFiles + ); + + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + AssertNoSiblings(diagnostics, "DXMSG008"); + Assert.That( + diagnostics.Single(d => d.Id == "DXMSG008").GetMessage(), + Does.Contain(IgnoreListReader.IgnoreFileName) + ); + } + + [Test] + public void Dxmsg009DoesNotFireOnUnrelatedClass() + { + // S9: an UNRELATED MonoBehaviour subclass (NOT inheriting from MessageAwareComponent) must + // never receive DXMSG009 even when it declares same-named methods. The strict-inheritance + // walk is what gates the analyzer; using a MonoBehaviour base instead of a bare class is a + // stronger pin against future regressions where a looser "any MonoBehaviour" rule would + // over-fire. + string source = """ +namespace Sample +{ + public class Unrelated : UnityEngine.MonoBehaviour + { + private void OnEnable() { } + private void Awake() { } + private void RegisterMessageHandlers() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public void Dxmsg009SmartCaseDoesNotApply() + { + // Smart-case (literal-`false` RegisterForStringMessages → Info) is DXMSG006-only. + // Even when the same class overrides RegisterForStringMessages => false, a missing-modifier + // RegisterMessageHandlers must stay at Warning severity (DXMSG009), not Info. + string source = """ +namespace Sample +{ + public class BrokenThing : DxMessaging.Unity.MessageAwareComponent + { + protected override bool RegisterForStringMessages => false; + private void RegisterMessageHandlers() { } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG009", DiagnosticSeverity.Warning); + AssertNoSiblings(diagnostics, "DXMSG009"); + } + + [Test] + public void NestedTypeFullyQualifiedNameUsesDotSeparatorForOverlayLookup() + { + // S6 regression: System.Type.FullName renders nested types as `Outer+Nested`, but the + // analyzer's `containingType.ToDisplayString()` (which produces the FQN the harvester + // keys snapshot rows by) renders them as `Outer.Nested`. The inspector overlay normalises + // FullName to dot-form before the lookup; this test pins the analyzer's output shape so + // a future Roslyn or analyzer change that flips the format breaks LOUDLY here rather + // than silently breaking the inspector for every nested MessageAwareComponent subclass. + string source = """ +namespace Sample +{ + public sealed class Outer + { + public sealed class Nested : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + int x = 0; + } + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + Diagnostic dxmsg006 = diagnostics.Single(d => d.Id == "DXMSG006"); + // The emitted message must contain the dot-form of the nested FQN — that is the form + // the harvester ingests and keys the snapshot by. + Assert.That(dxmsg006.GetMessage(), Does.Contain("Sample.Outer.Nested")); + Assert.That(dxmsg006.GetMessage(), Does.Not.Contain("Outer+Nested")); + } + + // -- DXMSG010 (transitive broken base-call chain) ---------------------------------------- + + [Test] + public void BrokenIntermediateAncestorEmitsDxmsg010OnDescendant() + { + // The exact user-reported case. `BrokenThing.OnEnable` correctly calls base.OnEnable(), + // but the inherited override on `ddd` has an empty body — so the chain stops at `ddd` + // and never reaches `MessageAwareComponent.OnEnable`. DXMSG006 fires on `ddd`; DXMSG010 + // fires on `BrokenThing` so the user editing `BrokenThing` is told the chain is broken. + string source = """ +namespace Sample +{ + public class ddd : DxMessaging.Unity.MessageAwareComponent + { + // Field included in the user's literal report shape — exercised here for fidelity. + public int a; + protected override void OnEnable() { } + } + + public class BrokenThing : ddd + { + protected override void OnEnable() + { + base.OnEnable(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + AssertSingle(diagnostics, "DXMSG010", DiagnosticSeverity.Warning); + + Diagnostic dxmsg006 = diagnostics.Single(d => d.Id == "DXMSG006"); + Assert.That(dxmsg006.GetMessage(), Does.Contain("Sample.ddd")); + + Diagnostic dxmsg010 = diagnostics.Single(d => d.Id == "DXMSG010"); + string msg010 = dxmsg010.GetMessage(); + Assert.That(msg010, Does.Contain("Sample.BrokenThing")); + Assert.That(msg010, Does.Contain("OnEnable")); + Assert.That(msg010, Does.Contain("Sample.ddd")); + } + + [Test] + public void ThreeDeepBrokenIntermediateEmitsDxmsg010OnEveryDescendant() + { + // `ddd.OnEnable` is empty → DXMSG006 on ddd. Both `Middle` and `BrokenThing` correctly + // call base.OnEnable() but the chain dies at `ddd`. DXMSG010 must fire on BOTH descendants + // so each user editing either type sees the warning. + string source = """ +namespace Sample +{ + public class ddd : DxMessaging.Unity.MessageAwareComponent + { + protected override void OnEnable() { } + } + + public class Middle : ddd + { + protected override void OnEnable() + { + base.OnEnable(); + } + } + + public class BrokenThing : Middle + { + protected override void OnEnable() + { + base.OnEnable(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That( + diagnostics.Count(d => d.Id == "DXMSG006"), + Is.EqualTo(1), + "Exactly one DXMSG006 expected (on ddd)." + ); + Diagnostic dxmsg006 = diagnostics.Single(d => d.Id == "DXMSG006"); + Assert.That(dxmsg006.GetMessage(), Does.Contain("Sample.ddd")); + Assert.That(dxmsg006.Severity, Is.EqualTo(DiagnosticSeverity.Warning)); + + Diagnostic[] dxmsg010 = diagnostics.Where(d => d.Id == "DXMSG010").ToArray(); + Assert.That( + dxmsg010, + Has.Length.EqualTo(2), + "Exactly two DXMSG010 expected (on Middle and BrokenThing)." + ); + Assert.That(dxmsg010.All(d => d.Severity == DiagnosticSeverity.Warning), Is.True); + string[] messages = dxmsg010.Select(d => d.GetMessage()).ToArray(); + Assert.That(messages.Any(m => m.Contains("Sample.Middle")), Is.True); + Assert.That(messages.Any(m => m.Contains("Sample.BrokenThing")), Is.True); + // Both DXMSG010 messages should mention `Sample.ddd` as the first broken ancestor. + Assert.That(messages.All(m => m.Contains("Sample.ddd")), Is.True); + } + + [Test] + public void HealthyChainEmitsNoDiagnostics() + { + // Sanity: when every override correctly calls base, no diagnostics fire — the chain + // walk must not produce false positives on a clean inheritance graph. + string source = """ +namespace Sample +{ + public class ddd : DxMessaging.Unity.MessageAwareComponent + { + protected override void OnEnable() + { + base.OnEnable(); + } + } + + public class BrokenThing : ddd + { + protected override void OnEnable() + { + base.OnEnable(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public void IntermediateDoesNotOverrideAtAllIsClean() + { + // When `ddd` has no OnEnable override at all, BrokenThing.OnEnable's OverriddenMethod + // resolves directly to MessageAwareComponent.OnEnable (which is virtual + chain- + // terminating). No DXMSG010 should fire. + string source = """ +namespace Sample +{ + public class ddd : DxMessaging.Unity.MessageAwareComponent + { + public int a; + } + + public class BrokenThing : ddd + { + protected override void OnEnable() + { + base.OnEnable(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public void Dxmsg010RespectsClassLevelDxIgnoreMissingBaseCall() + { + // Class-level [DxIgnoreMissingBaseCall] on `BrokenThing` must convert the would-be + // DXMSG010 into DXMSG008. DXMSG006 on `ddd` is unaffected (different type). + string source = """ +namespace Sample +{ + public class ddd : DxMessaging.Unity.MessageAwareComponent + { + protected override void OnEnable() { } + } + + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + public class BrokenThing : ddd + { + protected override void OnEnable() + { + base.OnEnable(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics.Count(d => d.Id == "DXMSG010"), Is.Zero); + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + Assert.That( + diagnostics.Single(d => d.Id == "DXMSG006").GetMessage(), + Does.Contain("Sample.ddd") + ); + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + Diagnostic dxmsg008 = diagnostics.Single(d => d.Id == "DXMSG008"); + Assert.That(dxmsg008.GetMessage(), Does.Contain("Sample.BrokenThing")); + Assert.That(dxmsg008.GetMessage(), Does.Contain("[DxIgnoreMissingBaseCall]")); + } + + [Test] + public void Dxmsg010RespectsProjectIgnoreList() + { + // Project-wide ignore-list entry for `BrokenThing` must lower DXMSG010 to DXMSG008. + string source = """ +namespace Sample +{ + public class ddd : DxMessaging.Unity.MessageAwareComponent + { + protected override void OnEnable() { } + } + + public class BrokenThing : ddd + { + protected override void OnEnable() + { + base.OnEnable(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer( + source, + (IgnoreFileName, "Sample.BrokenThing\n") + ); + + Assert.That(diagnostics.Count(d => d.Id == "DXMSG010"), Is.Zero); + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + Assert.That( + diagnostics.Single(d => d.Id == "DXMSG006").GetMessage(), + Does.Contain("Sample.ddd") + ); + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + Diagnostic dxmsg008 = diagnostics.Single(d => d.Id == "DXMSG008"); + Assert.That(dxmsg008.GetMessage(), Does.Contain("Sample.BrokenThing")); + Assert.That(dxmsg008.GetMessage(), Does.Contain(IgnoreListReader.IgnoreFileName)); + } + + [Test] + public void Dxmsg010ChainSurvivesGenericIntermediate() + { + // The chain-walk normalizes via OriginalDefinition so a generic intermediate doesn't + // confuse the lookup. `MyBase.OnEnable` is broken; `BrokenThing : MyBase` calls + // base correctly. DXMSG006 fires on MyBase, DXMSG010 fires on BrokenThing. + string source = """ +namespace Sample +{ + public class MyBase : DxMessaging.Unity.MessageAwareComponent + { + protected override void OnEnable() { } + } + + public sealed class BrokenThing : MyBase + { + protected override void OnEnable() + { + base.OnEnable(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + Assert.That( + diagnostics.Single(d => d.Id == "DXMSG006").GetMessage(), + Does.Contain("Sample.MyBase") + ); + AssertSingle(diagnostics, "DXMSG010", DiagnosticSeverity.Warning); + Assert.That( + diagnostics.Single(d => d.Id == "DXMSG010").GetMessage(), + Does.Contain("Sample.BrokenThing") + ); + } + + [Test] + public void Dxmsg010StillFiresAtWarningEvenWhenSmartCaseLowersDxmsg006OnAncestor() + { + // The smart-case lowering (literal `RegisterForStringMessages => false`) takes DXMSG006 + // on `ddd.RegisterMessageHandlers` from Warning to Info — but the chain is GENUINELY + // broken from BrokenThing's perspective. DXMSG010 must still fire at Warning on + // BrokenThing: smart-case is a per-method per-class courtesy, descendants still need + // the chain to be unbroken. + string source = """ +namespace Sample +{ + public class ddd : DxMessaging.Unity.MessageAwareComponent + { + protected override bool RegisterForStringMessages => false; + protected override void RegisterMessageHandlers() { } + } + + public class BrokenThing : ddd + { + protected override void RegisterMessageHandlers() + { + base.RegisterMessageHandlers(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Info); + Assert.That( + diagnostics.Single(d => d.Id == "DXMSG006").GetMessage(), + Does.Contain("Sample.ddd") + ); + AssertSingle(diagnostics, "DXMSG010", DiagnosticSeverity.Warning); + Assert.That( + diagnostics.Single(d => d.Id == "DXMSG010").GetMessage(), + Does.Contain("Sample.BrokenThing") + ); + } + + // Cross-assembly assume-clean policy: when an ancestor's override has no + // DeclaringSyntaxReferences (e.g. lives in a binary-only third-party package), the analyzer + // trusts it and does not emit DXMSG010. Emitting DXMSG010 against a type the user can't + // edit would be unactionable. This branch is exercised at runtime against compiled + // dependencies; it cannot be unit-tested here because every Roslyn fixture in this + // dotnet-test project compiles all sources into a single in-memory assembly. The policy is + // documented in `docs/reference/analyzers.md` under DXMSG010 and pinned by + // `ChainReachesMessageAwareComponent`'s remarks. + + [Test] + public void Dxmsg010MessageMentionsBrokenAncestorTypeName() + { + // The DXMSG010 message must include the FQN of the broken ancestor — not a generic + // "an ancestor" placeholder — so the user knows exactly where the chain is broken. + string source = """ +namespace Sample +{ + public class ddd : DxMessaging.Unity.MessageAwareComponent + { + protected override void OnEnable() { } + } + + public sealed class BrokenThing : ddd + { + protected override void OnEnable() + { + base.OnEnable(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Diagnostic dxmsg010 = diagnostics.Single(d => d.Id == "DXMSG010"); + string message = dxmsg010.GetMessage(); + Assert.That(message, Does.Contain("Sample.ddd")); + Assert.That(message, Does.Not.Contain("an ancestor")); + Assert.That(message, Does.Not.Contain("{2}")); + } + + [Test] + public void Dxmsg010LocationIsOnDescendantMethodIdentifier() + { + // The squiggle should land on the method identifier of the type the user can edit (the + // descendant), not on the broken ancestor's identifier. Pin the source-span text. + string source = """ +namespace Sample +{ + public class ddd : DxMessaging.Unity.MessageAwareComponent + { + protected override void OnEnable() { } + } + + public sealed class BrokenThing : ddd + { + protected override void OnEnable() + { + base.OnEnable(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Diagnostic dxmsg010 = diagnostics.Single(d => d.Id == "DXMSG010"); + string spanText = dxmsg010 + .Location.SourceTree!.GetText() + .GetSubText(dxmsg010.Location.SourceSpan) + .ToString(); + Assert.That(spanText, Is.EqualTo("OnEnable")); + // Sanity: the source span for DXMSG010 must NOT point inside `ddd` — confirm by + // verifying the surrounding source contains "BrokenThing" (the descendant) within a + // small window around the span. + string fullText = dxmsg010.Location.SourceTree.GetText().ToString(); + int spanStart = dxmsg010.Location.SourceSpan.Start; + int windowStart = System.Math.Max(0, spanStart - 200); + string window = fullText.Substring(windowStart, spanStart - windowStart); + Assert.That(window, Does.Contain("BrokenThing")); + } + + // -- Adversarial-audit additions --------------------------------------------------------- + + [Test] + public void Dxmsg008AttributeAndIgnoreListBothPresentFiresOnce() + { + // Adversarial: BOTH the class-level [DxIgnoreMissingBaseCall] attribute AND the project + // ignore list claim Sample.Player. The opt-out path must coalesce — exactly ONE DXMSG008 + // is emitted for the offending method, not two competing entries (one per opt-out source). + string source = """ +namespace Sample +{ + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer( + source, + (IgnoreFileName, "Sample.Player\n") + ); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG007"), Is.Empty); + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + } + + [Test] + public void Dxmsg008MethodAndClassAttributeBothPresentFiresOnce() + { + // Adversarial: BOTH the class-level AND method-level [DxIgnoreMissingBaseCall] are set. + // Exactly ONE DXMSG008 should fire — the opt-out is binary, so duplicate sources do not + // duplicate the diagnostic. + string source = """ +namespace Sample +{ + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + protected override void Awake() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG007"), Is.Empty); + AssertSingle(diagnostics, "DXMSG008", DiagnosticSeverity.Info); + } + + [Test] + public void Dxmsg008ClassAttributeWithMixedCleanAndDirtyMethodsOnlyFiresForDirty() + { + // The class is opted out via [DxIgnoreMissingBaseCall]; one method is broken (would emit + // DXMSG006), another method is clean (calls base). DXMSG008 must fire EXACTLY ONCE — on + // the would-have-fired method only — and the clean method must not produce noise. + string source = """ +namespace Sample +{ + [DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall] + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override void Awake() + { + base.Awake(); + } + + protected override void OnEnable() + { + int x = 1; + } + + protected override void OnDisable() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG007"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG009"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG010"), Is.Empty); + // Expect a DXMSG008 for each broken method that would have fired (OnEnable, OnDisable), + // but not the clean Awake. Pin the count so a regression that fires for clean methods + // (or fires at type-granularity instead of method-granularity) breaks loudly. + Diagnostic[] dxmsg008 = diagnostics.Where(d => d.Id == "DXMSG008").ToArray(); + Assert.That( + dxmsg008, + Has.Length.EqualTo(2), + "Expected one DXMSG008 per dirty method; clean methods must not contribute." + ); + string[] spans = dxmsg008 + .Select(d => + d.Location.SourceTree!.GetText().GetSubText(d.Location.SourceSpan).ToString() + ) + .ToArray(); + Assert.That(spans, Is.EquivalentTo(new[] { "OnEnable", "OnDisable" })); + } + + [Test] + public void Dxmsg010DoesNotFireWhenAncestorHasSmartCaseAndCallsBaseCorrectly() + { + // Spec 1b (clean variant): ancestor has literal `RegisterForStringMessages => false` AND + // its RegisterMessageHandlers correctly calls base. Descendant overrides and calls base. + // The chain is genuinely clean — DXMSG010 must NOT fire (and DXMSG006 must NOT fire). + string source = """ +namespace Sample +{ + public class Ancestor : DxMessaging.Unity.MessageAwareComponent + { + protected override bool RegisterForStringMessages => false; + + protected override void RegisterMessageHandlers() + { + base.RegisterMessageHandlers(); + } + } + + public sealed class Descendant : Ancestor + { + protected override void RegisterMessageHandlers() + { + base.RegisterMessageHandlers(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + Assert.That(diagnostics.Where(d => d.Id == "DXMSG006"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "DXMSG010"), Is.Empty); + } + + [Test] + public void Dxmsg010ChainSurvivesUnusuallyShapedFourLevelChain() + { + // Spec 1c (defensive): unusually shaped chain across four levels, with the broken link at + // the deepest level, an intermediate that does NOT declare the slot, and a leaf that calls + // base. The chain walker must terminate without infinite-looping. C# does not allow + // partial-class self-references that would form a true cycle, but this is the closest + // shape we can construct: the walker must skip Middle (no declaration) and find the broken + // ddd override. + string source = """ +namespace Sample +{ + public class ddd : DxMessaging.Unity.MessageAwareComponent + { + protected override void OnEnable() { } + } + + public class Middle : ddd + { + // Intentionally does NOT declare OnEnable. + public int unused; + } + + public class Inner : Middle + { + protected override void OnEnable() + { + base.OnEnable(); + } + } + + public sealed class Leaf : Inner + { + protected override void OnEnable() + { + base.OnEnable(); + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + // ddd has DXMSG006. Inner and Leaf both have DXMSG010 — chain dies at ddd. + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + Assert.That( + diagnostics.Single(d => d.Id == "DXMSG006").GetMessage(), + Does.Contain("Sample.ddd") + ); + Diagnostic[] dxmsg010 = diagnostics.Where(d => d.Id == "DXMSG010").ToArray(); + Assert.That(dxmsg010, Has.Length.EqualTo(2)); + Assert.That( + dxmsg010.Select(d => d.GetMessage()).Any(m => m.Contains("Sample.Inner")), + Is.True + ); + Assert.That( + dxmsg010.Select(d => d.GetMessage()).Any(m => m.Contains("Sample.Leaf")), + Is.True + ); + } + + [Test] + public void TernaryReturningFalseOnRegisterForStringMessagesDoesNotApplySmartCase() + { + // Spec 1d: smart-case lowering applies ONLY for a literal `false`. A ternary expression — + // even one that always evaluates to false at runtime — must NOT lower DXMSG006 to Info. + // The analyzer's literal-shape check is syntactic; runtime evaluation is irrelevant. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + public bool flag; + protected override bool RegisterForStringMessages => flag ? false : false; + + protected override void RegisterMessageHandlers() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + } + + [Test] + public void IsFalsePatternOnRegisterForStringMessagesDoesNotApplySmartCase() + { + // Spec 1d: an `is false` pattern is not a literal-false return — smart-case must not apply. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + public bool flag; + protected override bool RegisterForStringMessages => flag is false; + + protected override void RegisterMessageHandlers() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + } + + [Test] + public void SwitchExpressionReturningFalseOnRegisterForStringMessagesDoesNotApplySmartCase() + { + // Spec 1d: a switch expression whose only arm returns literal false is still NOT a literal + // false return — smart-case must not apply. + string source = """ +namespace Sample +{ + public sealed class Player : DxMessaging.Unity.MessageAwareComponent + { + protected override bool RegisterForStringMessages => 0 switch { _ => false }; + + protected override void RegisterMessageHandlers() + { + int x = 1; + } + } +} +"""; + + ImmutableArray diagnostics = GeneratorTestUtilities.RunBaseCallAnalyzer(source); + + AssertSingle(diagnostics, "DXMSG006", DiagnosticSeverity.Warning); + } +} diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs.meta b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs.meta new file mode 100644 index 00000000..4b57e81e --- /dev/null +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5e7286a98be55974590989bb09785db3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/WallstopStudios.DxMessaging.SourceGenerators.Tests.csproj b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/WallstopStudios.DxMessaging.SourceGenerators.Tests.csproj index 44acb482..c1ae44a9 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/WallstopStudios.DxMessaging.SourceGenerators.Tests.csproj +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/WallstopStudios.DxMessaging.SourceGenerators.Tests.csproj @@ -1,20 +1,44 @@ - - - net9.0 - enable - enable - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + + net9.0 + enable + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/com.wallstop-studios.dxmessaging.sln b/com.wallstop-studios.dxmessaging.sln index ef509ae3..db9accc5 100644 --- a/com.wallstop-studios.dxmessaging.sln +++ b/com.wallstop-studios.dxmessaging.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 @@ -8,20 +9,54 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WallstopStudios.DxMessaging EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WallstopStudios.DxMessaging.SourceGenerators.Tests", "SourceGenerators\WallstopStudios.DxMessaging.SourceGenerators.Tests\WallstopStudios.DxMessaging.SourceGenerators.Tests.csproj", "{8B884BA3-4017-4F4A-9D47-96CFD3E8ED21}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WallstopStudios.DxMessaging.Analyzer", "SourceGenerators\WallstopStudios.DxMessaging.Analyzer\WallstopStudios.DxMessaging.Analyzer.csproj", "{29778D32-41E1-4D01-AF3D-5AA0BE7DC879}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {50C6E19D-8B21-919A-A9BB-1AAFAE92D548}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {50C6E19D-8B21-919A-A9BB-1AAFAE92D548}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50C6E19D-8B21-919A-A9BB-1AAFAE92D548}.Debug|x64.ActiveCfg = Debug|Any CPU + {50C6E19D-8B21-919A-A9BB-1AAFAE92D548}.Debug|x64.Build.0 = Debug|Any CPU + {50C6E19D-8B21-919A-A9BB-1AAFAE92D548}.Debug|x86.ActiveCfg = Debug|Any CPU + {50C6E19D-8B21-919A-A9BB-1AAFAE92D548}.Debug|x86.Build.0 = Debug|Any CPU {50C6E19D-8B21-919A-A9BB-1AAFAE92D548}.Release|Any CPU.ActiveCfg = Release|Any CPU {50C6E19D-8B21-919A-A9BB-1AAFAE92D548}.Release|Any CPU.Build.0 = Release|Any CPU + {50C6E19D-8B21-919A-A9BB-1AAFAE92D548}.Release|x64.ActiveCfg = Release|Any CPU + {50C6E19D-8B21-919A-A9BB-1AAFAE92D548}.Release|x64.Build.0 = Release|Any CPU + {50C6E19D-8B21-919A-A9BB-1AAFAE92D548}.Release|x86.ActiveCfg = Release|Any CPU + {50C6E19D-8B21-919A-A9BB-1AAFAE92D548}.Release|x86.Build.0 = Release|Any CPU {8B884BA3-4017-4F4A-9D47-96CFD3E8ED21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8B884BA3-4017-4F4A-9D47-96CFD3E8ED21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B884BA3-4017-4F4A-9D47-96CFD3E8ED21}.Debug|x64.ActiveCfg = Debug|Any CPU + {8B884BA3-4017-4F4A-9D47-96CFD3E8ED21}.Debug|x64.Build.0 = Debug|Any CPU + {8B884BA3-4017-4F4A-9D47-96CFD3E8ED21}.Debug|x86.ActiveCfg = Debug|Any CPU + {8B884BA3-4017-4F4A-9D47-96CFD3E8ED21}.Debug|x86.Build.0 = Debug|Any CPU {8B884BA3-4017-4F4A-9D47-96CFD3E8ED21}.Release|Any CPU.ActiveCfg = Release|Any CPU {8B884BA3-4017-4F4A-9D47-96CFD3E8ED21}.Release|Any CPU.Build.0 = Release|Any CPU + {8B884BA3-4017-4F4A-9D47-96CFD3E8ED21}.Release|x64.ActiveCfg = Release|Any CPU + {8B884BA3-4017-4F4A-9D47-96CFD3E8ED21}.Release|x64.Build.0 = Release|Any CPU + {8B884BA3-4017-4F4A-9D47-96CFD3E8ED21}.Release|x86.ActiveCfg = Release|Any CPU + {8B884BA3-4017-4F4A-9D47-96CFD3E8ED21}.Release|x86.Build.0 = Release|Any CPU + {29778D32-41E1-4D01-AF3D-5AA0BE7DC879}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {29778D32-41E1-4D01-AF3D-5AA0BE7DC879}.Debug|Any CPU.Build.0 = Debug|Any CPU + {29778D32-41E1-4D01-AF3D-5AA0BE7DC879}.Debug|x64.ActiveCfg = Debug|Any CPU + {29778D32-41E1-4D01-AF3D-5AA0BE7DC879}.Debug|x64.Build.0 = Debug|Any CPU + {29778D32-41E1-4D01-AF3D-5AA0BE7DC879}.Debug|x86.ActiveCfg = Debug|Any CPU + {29778D32-41E1-4D01-AF3D-5AA0BE7DC879}.Debug|x86.Build.0 = Debug|Any CPU + {29778D32-41E1-4D01-AF3D-5AA0BE7DC879}.Release|Any CPU.ActiveCfg = Release|Any CPU + {29778D32-41E1-4D01-AF3D-5AA0BE7DC879}.Release|Any CPU.Build.0 = Release|Any CPU + {29778D32-41E1-4D01-AF3D-5AA0BE7DC879}.Release|x64.ActiveCfg = Release|Any CPU + {29778D32-41E1-4D01-AF3D-5AA0BE7DC879}.Release|x64.Build.0 = Release|Any CPU + {29778D32-41E1-4D01-AF3D-5AA0BE7DC879}.Release|x86.ActiveCfg = Release|Any CPU + {29778D32-41E1-4D01-AF3D-5AA0BE7DC879}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -29,6 +64,7 @@ Global GlobalSection(NestedProjects) = preSolution {50C6E19D-8B21-919A-A9BB-1AAFAE92D548} = {C03C8CAA-D0B1-310A-0C7E-67BC14B87CB1} {8B884BA3-4017-4F4A-9D47-96CFD3E8ED21} = {C03C8CAA-D0B1-310A-0C7E-67BC14B87CB1} + {29778D32-41E1-4D01-AF3D-5AA0BE7DC879} = {C03C8CAA-D0B1-310A-0C7E-67BC14B87CB1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AF2EA467-188D-4BA6-806F-3B26A1A4B688} diff --git a/docs/reference/analyzers.md b/docs/reference/analyzers.md new file mode 100644 index 00000000..cc549ee2 --- /dev/null +++ b/docs/reference/analyzers.md @@ -0,0 +1,450 @@ +# Roslyn Analyzers & Diagnostics + +[← Back to Index](../getting-started/index.md) | [Troubleshooting](troubleshooting.md) | [Quick Reference](quick-reference.md) | [FAQ](faq.md) + +--- + +DxMessaging ships a Roslyn analyzer (`WallstopStudios.DxMessaging.SourceGenerators.dll`) that catches the most common authoring mistakes at compile time. This page is the canonical reference for every diagnostic the package emits. + +## Quick reference table + +| ID | Severity | Title | Source | +| ----------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------- | --------------------------------------- | +| [`DXMSG002`](#dxmsg002-multiple-message-attributes) | Error | Multiple Message Attributes | `DxMessageIdGenerator` | +| [`DXMSG003`](#dxmsg003-containing-type-must-be-partial-for-nested-generation) | Warning | Containing type must be partial for nested generation | both generators | +| [`DXMSG004`](#dxmsg004-add-partial-keyword-to-containing-type) | Info | Add 'partial' keyword to containing type | both generators | +| [`DXMSG005`](#dxmsg005-invalid-optional-default-value) | Error | Invalid optional default value | `DxAutoConstructorGenerator` | +| [`DXMSG006`](#dxmsg006-missing-base-call) | Warning | Missing `base.{method}()` call | `MessageAwareComponentBaseCallAnalyzer` | +| [`DXMSG007`](#dxmsg007-new-hides-unity-method) | Warning | Unity lifecycle method hidden with `new` | `MessageAwareComponentBaseCallAnalyzer` | +| [`DXMSG008`](#dxmsg008-opt-out-marker) | Info | Type opted out of base-call check | `MessageAwareComponentBaseCallAnalyzer` | +| [`DXMSG009`](#dxmsg009-implicit-hide-and-missing-modifier) | Warning | Method implicitly hides MessageAwareComponent lifecycle method (no `override`/`new`) | `MessageAwareComponentBaseCallAnalyzer` | +| [`DXMSG010`](#dxmsg010-broken-transitive-base-call-chain) | Warning | `base.{method}()` chains into an override that does not reach `MessageAwareComponent` | `MessageAwareComponentBaseCallAnalyzer` | + +!!! tip +All diagnostic IDs can be customised per project in `.editorconfig` — e.g. `dotnet_diagnostic.DXMSG006.severity = error` to upgrade missing base calls to a build break. + +--- + +## DXMSG002: Multiple Message Attributes + +- **Severity:** Error +- **Source:** `DxMessageIdGenerator` +- **Triggered when:** A type carries more than one of `[DxBroadcastMessage]`, `[DxTargetedMessage]`, or `[DxUntargetedMessage]`. +- **Message:** `Type '{0}' cannot have more than one Dx message attribute ([DxBroadcastMessage], [DxTargetedMessage], [DxUntargetedMessage]).` + +### Fix + +Pick exactly one message-shape attribute. A message can be Broadcast, Targeted, or Untargeted — not two at once. If you genuinely need both shapes, define two separate types. + +```csharp +// ❌ Multiple shapes on one type +[DxBroadcastMessage] +[DxTargetedMessage] +public readonly partial struct Healed { public readonly int amount; } + +// ✅ One shape per type +[DxTargetedMessage] +public readonly partial struct Healed { public readonly int amount; } +``` + +--- + +## DXMSG003: Containing type must be partial for nested generation + +- **Severity:** Warning +- **Source:** Both `DxMessageIdGenerator` and `DxAutoConstructorGenerator` +- **Triggered when:** A type that needs source generation (i.e. carries a `[DxAutoConstructor]` or any `[Dx*Message]` attribute) is nested inside one or more containing types that are not declared `partial`. +- **Message:** `Type '{0}' is nested inside non-partial container(s): {1}. Suggested fix: add the 'partial' keyword to the containing type declaration(s).` + +### Fix + +Add `partial` to every enclosing type declaration. Roslyn cannot emit additional members into a nested type unless every container is partial. + +```csharp +// ❌ Container is not partial; generation cannot continue +public sealed class GameSystems +{ + [DxUntargetedMessage] + public readonly partial struct SceneLoaded { public readonly int buildIndex; } +} + +// ✅ +public sealed partial class GameSystems +{ + [DxUntargetedMessage] + public readonly partial struct SceneLoaded { public readonly int buildIndex; } +} +``` + +--- + +## DXMSG004: Add 'partial' keyword to containing type + +- **Severity:** Info +- **Source:** Both `DxMessageIdGenerator` and `DxAutoConstructorGenerator` +- **Triggered when:** Same condition as [`DXMSG003`](#dxmsg003-containing-type-must-be-partial-for-nested-generation), but emitted as an Info-level suggestion alongside the warning so IDEs can surface it as a lightbulb action. +- **Message:** `Add 'partial' to the declaration of '{0}' to enable generation for nested type '{1}'.` + +### Fix + +Identical to [`DXMSG003`](#dxmsg003-containing-type-must-be-partial-for-nested-generation) — add `partial` to the named container. + +--- + +## DXMSG005: Invalid optional default value + +- **Severity:** Error +- **Source:** `DxAutoConstructorGenerator` +- **Triggered when:** A field marked `[DxOptionalParameter]` carries a default expression that is not a valid C# constant for the field's type (e.g. a method call, a non-constant member access, an expression of the wrong type). +- **Message:** `Field '{0}' default value expression '{1}' is not a valid optional parameter default for type '{2}'.` + +### Fix + +Replace the expression with a constant literal, an enum member, `default`, `null` (for reference types and nullable value types), or a `const` field reference. + +```csharp +// ❌ Method calls and non-constant expressions are not legal C# defaults +[DxAutoConstructor] +public readonly partial struct Damage +{ + [DxOptionalParameter(GetDefaultAmount())] public readonly int amount; +} + +// ✅ Constants only +[DxAutoConstructor] +public readonly partial struct Damage +{ + [DxOptionalParameter(0)] public readonly int amount; +} +``` + +--- + +## DXMSG006: Missing base call + +- **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.` + +### 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. | + +!!! 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. + +### Detection policy (good-faith textual match) + +The analyzer looks for any `base.(...)` invocation anywhere inside the override body — including invocations nested inside lambdas or local functions. It does **not** perform reachability or data-flow analysis. The single known false-positive shape is **helper indirection**: + +```csharp +// ❌ False positive: analyzer cannot follow the indirection and emits DXMSG006 +protected override void Awake() => CallHelper(); +private void CallHelper() => base.Awake(); // analyzer sees this in CallHelper, not Awake +``` + +If you genuinely need to delegate to a helper, suppress the warning with `[DxIgnoreMissingBaseCall]` (see [Suppression precedence](#suppression-precedence) below). + +### Smart case: `RegisterForStringMessages => false` + +When the same class overrides `RegisterForStringMessages` to literally `false`, DXMSG006 on `RegisterMessageHandlers` is **lowered to Info severity** (same diagnostic ID — still configurable via `.editorconfig`). The interpretation is **strict literal-only**: + +| Form | Lowered to Info? | +| ------------------------------------------------------------------------------------------------- | ------------------------- | +| `protected override bool RegisterForStringMessages => false;` | ✅ yes | +| `protected override bool RegisterForStringMessages { get => false; }` | ✅ yes | +| `protected override bool RegisterForStringMessages { get { return false; } }` | ✅ yes (single statement) | +| `protected override bool RegisterForStringMessages => default;` | ❌ no — stays Warning | +| `protected override bool RegisterForStringMessages => !true;` | ❌ no — stays Warning | +| `protected override bool RegisterForStringMessages => Constants.Disable;` | ❌ no — stays Warning | +| `protected override bool RegisterForStringMessages { get { if (x) return false; return true; } }` | ❌ no — stays Warning | + +The smart-case is deliberately conservative: anything that introduces a conditional, a non-literal expression, or even one extra statement is treated as ambiguous and stays at Warning severity. + +### Suppression options + +See [Suppression precedence](#suppression-precedence) for the full ordering. + +--- + +## DXMSG007: `new` hides Unity method + +- **Severity:** Warning +- **Source:** `MessageAwareComponentBaseCallAnalyzer` +- **Triggered when:** A subclass uses the `new` modifier (instead of `override`) on one of the five guarded method names. +- **Message:** `'{0}' hides MessageAwareComponent.{1} with 'new'; replace with 'override' and call base.{1}() so the messaging system continues to function.` + +### Why this is worse than DXMSG006 + +`new` doesn't override — it shadows. Unity calls the **base** lifecycle method, which still runs correctly, but if you also expect your hidden method to run (e.g. via `someComponent.OnEnable()` from a polymorphic call site) you'll get the wrong dispatch. More commonly: developers reach for `new` thinking it suppresses a CS0114 hide-warning, and the result is a silently broken component. + +### Fix + +Replace `new` with `override` and add the base call. + +```csharp +// ❌ Hides the lifecycle method; Unity still calls the base, your code never runs +public sealed class HealthComponent : MessageAwareComponent +{ + new void OnEnable() { _hud.Show(); } +} + +// ✅ Override and chain +public sealed class HealthComponent : MessageAwareComponent +{ + protected override void OnEnable() + { + base.OnEnable(); + _hud.Show(); + } +} +``` + +--- + +## DXMSG008: Opt-out marker + +- **Severity:** Info +- **Source:** `MessageAwareComponentBaseCallAnalyzer` +- **Triggered when:** A method or class is excluded from the base-call check via `[DxIgnoreMissingBaseCall]` or via the project-level ignored-types file, **and** the analyzer would otherwise have emitted `DXMSG006`, `DXMSG007`, `DXMSG009`, or `DXMSG010` for that method. +- **Message:** `'{0}' is excluded from the DxMessaging base-call check ({1}).` + +### Purpose + +DXMSG008 is purely informational. It tells you "yes, the analyzer noticed this would be a problem, but you've explicitly opted out — here's where the suppression came from". The placeholder `{1}` reports the suppression source: either the literal `[DxIgnoreMissingBaseCall]` or the file name `DxMessaging.BaseCallIgnore.txt`. + +### Quieting it + +Most users leave DXMSG008 enabled because it's a useful audit signal. To silence it for a specific project, add to `.editorconfig`: + +```ini +[*.cs] +dotnet_diagnostic.DXMSG008.severity = none +``` + +--- + +## DXMSG009: Implicit hide and missing modifier + +- **Severity:** Warning +- **Source:** `MessageAwareComponentBaseCallAnalyzer` +- **Triggered when:** A subclass of `MessageAwareComponent` declares a method whose name matches one of the five guarded lifecycle methods (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, `RegisterMessageHandlers`), with neither `override` nor `new`, AND the signature is parameter-less, returns `void`, is non-static, and is non-generic. C# treats this as implicit hiding (compiler warning [CS0114](https://learn.microsoft.com/en-us/dotnet/csharp/misc/cs0114)) — the base method never runs and the messaging system will not function. +- **Message:** `'{0}' declares {1} without 'override' or 'new'; this implicitly hides MessageAwareComponent.{1} (CS0114) and the messaging system will not function. Add 'override' and call base.{1}(), or add 'new' if the hiding is intentional.` + +### Why this exists + +DXMSG009 is the most common Unity footgun. Forgetting `override` on `private void OnEnable()` is silent at runtime — Unity calls the subclass method directly, the base implementation never gets a chance to enable the messaging token, and every registered handler stops working. C# already emits CS0114 for this, but in many Unity projects compiler warnings get ignored. DXMSG009 surfaces it to the inspector overlay and the project's analyzer report. + +### Fix + +```csharp +// ❌ Implicit hiding — DXMSG009 fires (alongside CS0114) +public class BrokenThing : MessageAwareComponent +{ + private void OnEnable() { } +} + +// ✅ Override and chain +public class FixedThing : MessageAwareComponent +{ + protected override void OnEnable() + { + base.OnEnable(); + // … your logic … + } +} +``` + +Use `new` instead of `override` only if you have a deliberate reason to disable the base implementation; in that case DXMSG007 will fire and DXMSG009 will not. The recommended fix is almost always `override` + `base.{method}()`. + +### Suppression + +DXMSG009 honors all the same suppression paths as DXMSG006 — see [Suppression precedence](#suppression-precedence) below. + +### Signature filter + +DXMSG009 fires only when the method shape matches a Unity lifecycle method: + +- Parameter-less. +- Returns `void`. +- Non-static. +- Non-generic. + +So unrelated overloads like `void OnEnable(int discriminator) {}`, unrelated static helpers, and generic same-name methods (`void Awake()`, which C# does not treat as hiding because the type-parameter arity differs from the base) all stay silent — they aren't actually hiding the base. + +### Coexistence with other diagnostics + +DXMSG009 is mutually exclusive with DXMSG006 and DXMSG007 _for the same method_ (a method either has `override`, `new`, or neither). However, a single subclass can carry **both** DXMSG009 (on one method that's missing the modifier) and DXMSG006 (on a different method that overrides without `base.X()`) — in that case the inspector overlay lists both methods in its HelpBox. + +### Editor inspector overlay + +The inspector overlay's `BaseCallTypeScanner` is an IL-reflection scanner — it reads each override's IL bytes via `MethodInfo.GetMethodBody()` and checks for the `call`/`callvirt` shape that `base.X()` compiles to. The C# compiler emits **the same IL** for `new void X()` (DXMSG007) and for a same-named declaration with the modifier missing (DXMSG009 / CS0114): both produce a non-virtual hide-by-sig method. **The IL scanner cannot distinguish DXMSG009 from DXMSG007 from IL alone**, so it conservatively records the diagnostic id as `DXMSG007` in the cached snapshot for both cases. The compile-time analyzer remains authoritative for the precise classification — when the cached `Snapshot.diagnosticIds` (or the JSON file at `Library/DxMessaging/baseCallReport.json`) shows `DXMSG007` but the analyzer console output is `DXMSG009`, **trust the analyzer**. The HelpBox itself lights up correctly either way because the overlay reads `missingBaseFor` (the method name list) for its rendering — the user-visible behaviour is identical. + +--- + +## DXMSG010: Broken transitive base-call chain + +- **Severity:** Warning +- **Source:** `MessageAwareComponentBaseCallAnalyzer` +- **Triggered when:** A class deriving from `MessageAwareComponent` correctly calls `base.{method}()` from one of the five guarded overrides, BUT the inherited override on an intermediate ancestor does **not** itself chain to `base.{method}()`. The chain is broken at the parent, so `MessageAwareComponent`'s lifecycle work never runs on this component even though the user's override looks correct in isolation. +- **Message:** `'{0}' calls base.{1}() but the inherited override on '{2}' does not chain to MessageAwareComponent.{1}; the messaging system will not function correctly on this component.` + +### Why this exists + +DXMSG006 is a per-method syntactic check: "does this override contain a textual `base.X()` call?". That check fires on the broken intermediate (e.g., a parent `ddd.OnEnable() {}`), but it cannot see across the inheritance boundary into a descendant. Without DXMSG010, the user editing the descendant only sees a clean override — no diagnostic — even though their component is silently broken. + +```csharp +// ❌ Both warnings now fire. +public class ddd : MessageAwareComponent +{ + protected override void OnEnable() { } // DXMSG006 here — chain dies here +} + +public class BrokenThing : ddd +{ + protected override void OnEnable() + { + base.OnEnable(); // DXMSG010 here — chain still broken + } +} +``` + +### Semantic difference vs DXMSG006 + +- **DXMSG006** is a per-method, per-class textual check: a single override either contains `base.X()` or it doesn't. It runs in isolation. +- **DXMSG010** is a transitive chain walk: it follows `IMethodSymbol.OverriddenMethod` from this override up the inheritance graph (normalising via `OriginalDefinition` so generic intermediates like `MyBase` don't confuse the lookup) and confirms every link calls base before terminating at `MessageAwareComponent`. If any intermediate link is broken, every descendant in the chain warns — not just the original offender. + +### Cross-assembly assume-clean caveat + +If an ancestor's override has no `DeclaringSyntaxReferences` — typically because it lives in a binary-only third-party package — the analyzer cannot inspect its body. In that case DXMSG010 trusts the ancestor and does **not** fire. Emitting the diagnostic against a type the user can't edit would be unactionable. + +### Suppression options + +DXMSG010 honours all the same suppression paths as DXMSG006 — see [Suppression precedence](#suppression-precedence) below. Class-level `[DxIgnoreMissingBaseCall]` on the descendant, a method-level attribute, or a project ignore-list entry all convert DXMSG010 into the informational DXMSG008. + +### Fix + +In order of preference: + +- **Fix the broken intermediate.** Open the parent class's override and add the missing `base.{method}()`. This is the correct fix in almost every case — every descendant in the chain becomes clean automatically. +- **Override directly from `MessageAwareComponent`.** If you control the descendant but not the intermediate, change the descendant's base type to skip the broken intermediate. +- **Suppress with `[DxIgnoreMissingBaseCall]`.** Only when the broken chain is genuinely intentional (e.g. a deliberate adapter that shouldn't participate in messaging). Document the reason in a comment alongside the attribute. + +### Known limitation + +DXMSG010 reuses the same good-faith textual check as DXMSG006 (`ContainsBaseInvocation`). If an ancestor's body literally contains `base.X()` after a `return;` (i.e. unreachable but syntactically present), the chain check considers it clean — mirroring DXMSG006's policy. Both diagnostics share a single textual policy so users get consistent results; if you genuinely need flow-aware analysis, suppress with `[DxIgnoreMissingBaseCall]` and review manually. + +--- + +## Suppression precedence + +When DxMessaging suppresses a base-call check, it consults the following sources in order. The **first** match wins: + +1. **Method-level attribute** — `[DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall]` placed directly on the override. +1. **Class-level attribute** — `[DxIgnoreMissingBaseCall]` placed on the type declaration; suppresses all guarded overrides inside that type. +1. **Project ignore list** — fully-qualified type names listed in `Assets/Editor/DxMessaging.BaseCallIgnore.txt` (one per line). Manage entries via **Project Settings → DxMessaging → Base-Call Check → Ignored Types**. +1. **`.editorconfig` rule** — `dotnet_diagnostic.DXMSG006.severity = none` (or `DXMSG007.severity = none`, `DXMSG009.severity = none`, `DXMSG010.severity = none`) disables the diagnostic project-wide. + +```csharp +// Method-level — only this override is exempt +public sealed class FlashyComponent : MessageAwareComponent +{ + [DxIgnoreMissingBaseCall] + protected override void Awake() => CallHelperThatChainsToBase(); +} + +// Class-level — every guarded override on this type is exempt +[DxIgnoreMissingBaseCall] +public sealed class LegacyAdapter : MessageAwareComponent { /* ... */ } +``` + +!!! warning +Suppressing the diagnostic does not change the runtime behaviour: if your override genuinely never reaches `base.Awake()`, the messaging system on that component will still be dead. The suppression only silences the analyzer. + +--- + +## Inspector integration + +The Inspector overlay's data source is the **`BaseCallTypeScanner`** — a deterministic IL-reflection scanner that walks every loaded `MessageAwareComponent` subclass via `UnityEditor.TypeCache.GetTypesDerivedFrom()` and inspects each override's IL body for the base-call shape (`call`/`callvirt` to a parent's same-named method). The scanner runs on every `AssemblyReloadEvents.afterAssemblyReload` and on every `CompilationPipeline.assemblyCompilationFinished` burst (debounced via `EditorApplication.delayCall`). + +**Why IL reflection?** The previous console-scrape harvester read warnings from `UnityEditor.LogEntries` and from per-assembly `CompilerMessage[]` payloads. Both stores are downstream of Unity's decision to actually surface analyzer warnings — and on Unity 2021 with Bee/csc cache hits (which happen on most domain reloads after the first), Unity skips that surface entirely. The scrape returned nothing, even though the analyzer ran successfully on the original compile. The result was an intermittent "missing warnings" bug: warnings would appear after a fresh compile and then disappear after a domain reload, with no user-visible cause. IL reflection over loaded types is deterministic — the assemblies are in the AppDomain, the methods have IL bodies, the same scan produces the same result on every reload regardless of compile-pipeline state. + +**Cross-assembly assume-clean.** Ancestors whose IL is unavailable (`MethodInfo.GetMethodBody()` returns null — abstract methods, P/Invoke, IL2CPP-stripped bodies, closed-source third-party libraries) are trusted. Emitting an unactionable warning against code the user can't edit would be hostile, and the compile-time analyzer remains the authoritative source for CI builds. + +**OpCodes-table walker.** The scanner's IL walker decodes every CIL instruction by looking up its `OpCode` in the static tables built from `System.Reflection.Emit.OpCodes` reflection (single-byte and two-byte 0xFE-prefix forms) and steps the operand-size that the opcode declares (`OpCode.OperandType`). Misalignment past multi-byte-operand opcodes (`switch` jump tables, `ldstr` 4-byte tokens, 8-byte literal constants, etc.) is therefore impossible — the walker either consumes every byte correctly or stops at the first unrecognised opcode and returns the assume-clean default. Phantom DXMSG006 from a misread `0x28` inside a wider operand is no longer a failure mode. The compile-time analyzer remains authoritative for CI; if you hit a phantom warning that the analyzer doesn't agree with, please open an issue. + +**DXMSG009 classified as DXMSG007 in the cache.** The scanner's IL-only probe cannot distinguish DXMSG007 (`new` modifier) from DXMSG009 (missing `override` / CS0114) — Roslyn emits the same IL for both. The cached snapshot conservatively classifies both as `DXMSG007`. The compile-time analyzer remains authoritative for the precise classification — see the [DXMSG009: Editor inspector overlay subsection](#editor-inspector-overlay) above. + +**Legacy console-scrape bridge (opt-in).** A toggle at **Project Settings → DxMessaging → Also Scrape Console (Legacy)** (`DxMessagingSettings.UseConsoleBridge`) re-enables the old data sources (`UnityEditor.LogEntries` reflection + `CompilationPipeline.assemblyCompilationFinished` `CompilerMessage[]`) and unions them INTO the IL scanner's snapshot — never overrides it. Default off. Enable only if you want the union of both data sources, e.g. to surface a regression in the IL byte-walker that is correctly captured by the compile-time analyzer's console output. + +The unified per-FQN snapshot is persisted to `Library/DxMessaging/baseCallReport.json` so the overlay has data to render before the first post-load rescan completes; it is rewritten on every successful rescan. A manual `Tools → DxMessaging → Rescan Base-Call Warnings` menu is available for force-rescan. + +The overlay itself uses two complementary editor-injection paths, each with its own entry point in `MessageAwareComponentInspectorOverlay`: + +- **`Editor.finishedDefaultHeaderGUI`** (entry point: `RenderForHeaderHook`) — the cross-version path that fires after Unity draws the default component header. Reliable on Unity 2022+. Because this hook runs _post-body_, gating the render on `EventType.Repaint` is safe — the inspector's Layout pass for the editor has already settled. +- **Fallback `[CustomEditor(typeof(MessageAwareComponent), editorForChildClasses: true, isFallback: true)]`** (entry point: `RenderInsideOnInspectorGUI`) — needed on Unity 2021, where `finishedDefaultHeaderGUI` does not always fire for `MonoBehaviour` subclasses without a registered custom editor. The `isFallback: true` flag means a user-defined `[CustomEditor]` for the same component type still wins precedence; the fallback only renders when no other editor is registered. + +**Layout/Repaint balance.** Inside `Editor.OnInspectorGUI`, Unity invokes the editor twice per frame: once with `Event.current.type == EventType.Layout` (control registration) and once with `EventType.Repaint` (drawing). Both passes must emit _identical_ sequences of `EditorGUILayout.*` calls — short-circuiting `OnInspectorGUI` on event type corrupts the inspector window's layout cache and breaks adjacent components. The `RenderInsideOnInspectorGUI` entry point therefore performs all "should we render?" gating up-front (before any `EditorGUILayout` call) and never gates on `EventType`. Cross-path dedupe with the header hook is handled by an unconditional skip inside `DrawHeader` when the editor instance is our fallback editor — so the two paths never both render for the same target on the same frame, regardless of Unity version. The fallback editor also walks `SerializedObject` directly and skips `m_Script` rather than calling `DrawDefaultInspector()`, which would otherwise duplicate the script row that Unity already draws in the header. + +Components that emit DXMSG006, DXMSG007, DXMSG009, or DXMSG010 show a HelpBox at the top of their Inspector with three actions: + +- **Open Script** — jumps to the offending override in your IDE of choice. +- **Ignore this type** — appends the type's fully-qualified name to `Assets/Editor/DxMessaging.BaseCallIgnore.txt`. +- **Stop ignoring** — appears instead of "Ignore this type" when the type is already in the ignore list; removes it. + +The HelpBox respects the per-project master toggle in **Project Settings → DxMessaging → Base-Call Check Enabled**. When this toggle is off, the Inspector overlay is silenced; the underlying DXMSG006/DXMSG007/DXMSG009 compile-time warnings still emit unless you suppress them via `.editorconfig` (e.g. `dotnet_diagnostic.DXMSG006.severity = none`). + +A snapshot of the latest harvest is also persisted to `Library/DxMessaging/baseCallReport.json` so the overlay has data to show on first open before the post-load rescan completes. + +**Eager-load and "cached from previous session" indicator.** The harvester's static constructor synchronously calls `LoadFromDisk` BEFORE scheduling the first scan via `EditorApplication.delayCall`. The on-disk cache populates `SnapshotInternal` immediately, so the inspector overlay can render warnings the moment the user clicks into a `MessageAwareComponent` — even before the first post-reload scan has had a chance to run. Until that first scan completes (typically within a few `EditorApplication.update` ticks after assembly reload), the harvester's `IsFreshThisSession` flag stays `false` and the overlay annotates each warning with a `(cached from previous session — refreshing…)` suffix so the user understands the data may be stale. Once the first `RescanNow` post-startup writes a new snapshot and raises `ReportUpdated`, `RepaintAllInspectors` fires and the suffix disappears for the rest of the session. This eliminates the perceived flakiness where the warning sometimes appeared and sometimes didn't, depending on how fast the user clicked into the inspector after a domain reload. + +--- + +## `csc.rsp` wiring + +`Editor/SetupCscRsp.cs` automatically writes (and keeps in sync) the lines that hand the analyzer/source-generator DLLs and the ignore-list to the C# compiler: + +```text +-a:"Packages/com.wallstop-studios.dxmessaging/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll" +-a:"Packages/com.wallstop-studios.dxmessaging/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll" +-additionalfile:"Assets/Editor/DxMessaging.BaseCallIgnore.txt" +``` + +When the package is consumed via Unity's Package Manager cache rather than embedded under `Packages/`, the analyzer paths instead resolve under `Library/PackageCache/com.wallstop-studios.dxmessaging/Editor/Analyzers/...`. The `-additionalfile:` line is only emitted when the ignore-list sidecar physically exists. + +Manual edits to `csc.rsp` are rarely necessary; the setup helper detects existing lines and only appends what's missing. + +--- + +## Unity 2021 setup notes + +DxMessaging ships **two** Roslyn DLLs because Unity 2021's analyzer loader has a hard requirement that Unity 2022+ does not: + +- `WallstopStudios.DxMessaging.Analyzer.dll` — the base-call analyzer (DXMSG006/007/008/009/010). Pinned to **Roslyn 3.8.0**. Unity 2021 silently rejects analyzer DLLs built against Roslyn 4.x; Microsoft's `Microsoft.Unity.Analyzers` package pins 3.8.0 for the same reason. +- `WallstopStudios.DxMessaging.SourceGenerators.dll` — the source generators (DXMSG002/003/004/005). Stays at **Roslyn 4.2.0** because the generators use `IIncrementalGenerator`, which was introduced in Roslyn 4.0. Unity 2021 loads source generators through a different code path that tolerates the 4.x dependency. + +Both DLLs are tagged `RoslynAnalyzer` and registered in `csc.rsp`. They live side-by-side in `Editor/Analyzers/`. + +If you are upgrading from a prior version and DXMSG warnings stop appearing on Unity 2021: + +1. Delete the package's `Library/ScriptAssemblies` folder so Unity's compiler cache re-evaluates the analyzer DLL hashes — Unity 2021 caches "rejected analyzer" decisions per-DLL-hash. +1. Reimport the package's `Editor/Analyzers/` folder (right-click → Reimport). +1. Force a clean rebuild via `Tools → DxMessaging → Rescan Base-Call Warnings` after the next compile finishes. + +The Inspector overlay also has a Unity 2021 fallback: a `[CustomEditor(typeof(MessageAwareComponent), editorForChildClasses: true, isFallback: true)]` is registered alongside the cross-version `Editor.finishedDefaultHeaderGUI` hook. User-defined `[CustomEditor]`s for the same component type still win precedence over the fallback — see the [Inspector integration](#inspector-integration) section above. + +--- + +## See also + +- [Troubleshooting](troubleshooting.md) — runtime symptoms and how they map to diagnostics. +- [Inheritance Contract: `MessageAwareComponent`](../../README.md#inheritance-contract-messageawarecomponent) — the README's top-level write-up of the rule the analyzer enforces. +- [Unity Integration](../guides/unity-integration.md) — broader Unity-side guidance for inheritance and lifecycle. +- [Quick Reference](quick-reference.md) — concise listing of all diagnostic IDs. diff --git a/docs/reference/analyzers.md.meta b/docs/reference/analyzers.md.meta new file mode 100644 index 00000000..afc25b54 --- /dev/null +++ b/docs/reference/analyzers.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 17a30e22832e2524186b14c119ae60f7 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/llms.txt b/llms.txt index f0759aca..2e32536d 100644 --- a/llms.txt +++ b/llms.txt @@ -286,5 +286,5 @@ Copyright (c) 2017-2026 Wallstop Studios --- -**Last Updated:** 2026-03-17 +**Last Updated:** 2026-04-28 **Generated by:** scripts/update-llms-txt.js using package.json v2.2.0 and .llm/skills metadata diff --git a/package.json b/package.json index 157bc242..59907c6e 100644 --- a/package.json +++ b/package.json @@ -55,24 +55,26 @@ "SourceGenerators.meta" ], "scripts": { - "test": "jest", - "test:scripts": "jest", - "test:llms-txt": "jest --runTestsByPath scripts/__tests__/update-llms-txt.test.js", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", + "test": "node scripts/run-managed-jest.js", + "test:scripts": "node scripts/run-managed-jest.js", + "test:llms-txt": "node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/update-llms-txt.test.js", + "test:watch": "node scripts/run-managed-jest.js --watch", + "test:coverage": "node scripts/run-managed-jest.js --coverage", "format:md": "prettier --write \"**/*.{md,markdown}\"", "format:md:check": "prettier --check \"**/*.{md,markdown}\"", "format:json": "prettier --write \"**/*.{json,asmdef,asmref}\"", "format:json:check": "prettier --check \"**/*.{json,asmdef,asmref}\"", "format:yaml": "prettier --write \"**/*.{yml,yaml}\"", "format:yaml:check": "prettier --check \"**/*.{yml,yaml}\"", - "check:yaml": "prettier --check \"**/*.{yml,yaml}\" && yamllint -c .yamllint.yaml .", + "check:yaml": "prettier --check \"**/*.{yml,yaml}\" && pre-commit run yamllint --all-files", "lint:markdown": "markdownlint-cli2 \"**/*.md\" \"**/*.markdown\"", "update:llms-txt": "node scripts/update-llms-txt.js", "check:llms-txt": "node scripts/update-llms-txt.js --check", "validate:llms-txt": "npm run test:llms-txt && npm run check:llms-txt", "validate:npm-meta": "node scripts/validate-npm-meta.js --check", - "validate:vscode-settings": "node scripts/validate-vscode-settings.js" + "validate:pre-commit-tooling": "node scripts/validate-pre-commit-tooling.js", + "validate:vscode-settings": "node scripts/validate-vscode-settings.js", + "preflight:pre-commit": "npm run validate:pre-commit-tooling && npm run check:yaml && node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/validate-pre-commit-tooling.test.js scripts/__tests__/run-managed-jest.test.js" }, "devDependencies": { "jest": "^30.3.0", diff --git a/scripts/__tests__/fix-md029-md051.test.js b/scripts/__tests__/fix-md029-md051.test.js new file mode 100644 index 00000000..631dcb11 --- /dev/null +++ b/scripts/__tests__/fix-md029-md051.test.js @@ -0,0 +1,156 @@ +"use strict"; + +const { + convertHeadingToHtmlFragment, + processMarkdownContent, +} = require("../fix-md029-md051.js"); + +describe("convertHeadingToHtmlFragment", () => { + test("matches markdownlint slug behavior for punctuation and slash", () => { + const fragment = convertHeadingToHtmlFragment( + "DXMSG009: Implicit hide / missing modifier" + ); + + expect(fragment).toBe("#dxmsg009-implicit-hide--missing-modifier"); + }); +}); + +describe("processMarkdownContent", () => { + test("normalizes ordered lists to one-style", () => { + const input = [ + "# Header", + "", + "1. First", + "2. Second", + "3. Third", + "", + ].join("\n"); + + const result = processMarkdownContent(input); + + expect(result.changed).toBe(true); + expect(result.content).toContain("1. First"); + expect(result.content).toContain("1. Second"); + expect(result.content).toContain("1. Third"); + }); + + test("fixes local fragment links using heading fragments", () => { + const input = [ + "# Diagnostics", + "", + "## DXMSG009: Implicit hide / missing modifier", + "", + "See [DXMSG009](#dxmsg009-implicit-hide-missing-modifier).", + "", + ].join("\n"); + + const result = processMarkdownContent(input); + + expect(result.changed).toBe(true); + expect(result.content).toContain( + "[DXMSG009](#dxmsg009-implicit-hide--missing-modifier)" + ); + }); + + test("does not rewrite content inside fenced code blocks", () => { + const input = [ + "# Header", + "", + "```markdown", + "2. Keep this list number", + "See [Link](#header)", + "```", + "", + "2. Rewrite this one", + "", + ].join("\n"); + + const result = processMarkdownContent(input); + + expect(result.content).toContain("2. Keep this list number"); + expect(result.content).toContain("See [Link](#header)"); + expect(result.content).toContain("1. Rewrite this one"); + }); + + test("keeps GitHub line fragments untouched", () => { + const input = [ + "# Header", + "", + "[Line link](#L20)", + "", + ].join("\n"); + + const result = processMarkdownContent(input); + + expect(result.changed).toBe(false); + expect(result.content).toContain("[Line link](#L20)"); + }); + + test("leaves valid duplicate-heading fragments unchanged", () => { + const input = [ + "## Configuration", + "", + "## Configuration", + "", + "See [First](#configuration) and [Second](#configuration-1).", + "", + ].join("\n"); + + const result = processMarkdownContent(input); + + expect(result.changed).toBe(false); + expect(result.content).toContain("[First](#configuration)"); + expect(result.content).toContain("[Second](#configuration-1)"); + }); + + test("supports custom heading anchors", () => { + const input = [ + "## Inspector section {#editor-inspector-overlay}", + "", + "Jump to [section](#editor-inspector-overlay).", + "", + ].join("\n"); + + const result = processMarkdownContent(input); + + expect(result.changed).toBe(false); + expect(result.content).toContain("[section](#editor-inspector-overlay)"); + }); + + test("fixes definition-style fragment links", () => { + const input = [ + "## DXMSG009: Implicit hide / missing modifier", + "", + "[dxmsg9]: #dxmsg009-implicit-hide-missing-modifier", + "Use [DXMSG009][dxmsg9].", + "", + ].join("\n"); + + const result = processMarkdownContent(input); + + expect(result.changed).toBe(true); + expect(result.content).toContain( + "[dxmsg9]: #dxmsg009-implicit-hide--missing-modifier" + ); + }); + + test("does not modify blockquote-wrapped fenced code blocks", () => { + const input = [ + "# Header", + "", + "> ```markdown", + "> 2. Keep this list number", + "> [Link](#header)", + "> ```", + "", + "2. Rewrite this one", + "", + ].join("\n"); + + const result = processMarkdownContent(input); + + expect(result.content).toContain("> 2. Keep this list number"); + expect(result.content).toContain("> [Link](#header)"); + expect(result.content).toContain("1. Rewrite this one"); + }); +}); diff --git a/scripts/__tests__/fix-md029-md051.test.js.meta b/scripts/__tests__/fix-md029-md051.test.js.meta new file mode 100644 index 00000000..24cb80e0 --- /dev/null +++ b/scripts/__tests__/fix-md029-md051.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 72d7c6a56d084f20a866f7e293d93571 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/run-managed-jest.test.js b/scripts/__tests__/run-managed-jest.test.js new file mode 100644 index 00000000..82be79ed --- /dev/null +++ b/scripts/__tests__/run-managed-jest.test.js @@ -0,0 +1,247 @@ +/** + * @fileoverview Tests for run-managed-jest.js. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const childProcess = require("child_process"); +const { + REPO_ROOT, + LOCAL_JEST_BIN, + FALLBACK_JEST_SPEC, + getPinnedFallbackJestSpec, + toShellCommand, + parseNpmMajorVersion, + resolveLocalModule, + isCommandUnavailable, + hasHealthyLocalJestInstall, + runManagedJest, +} = require("../run-managed-jest.js"); + +describe("run-managed-jest", () => { + let existsSyncSpy; + let spawnSyncSpy; + + beforeEach(() => { + existsSyncSpy = jest.spyOn(fs, "existsSync"); + spawnSyncSpy = jest.spyOn(childProcess, "spawnSync"); + }); + + afterEach(() => { + existsSyncSpy.mockRestore(); + spawnSyncSpy.mockRestore(); + }); + + test("parseNpmMajorVersion parses valid versions", () => { + expect(parseNpmMajorVersion("11.11.0\n")).toBe(11); + expect(parseNpmMajorVersion("v10.9.3")).toBe(10); + expect(parseNpmMajorVersion("not-a-version")).toBeNull(); + expect(parseNpmMajorVersion(null)).toBeNull(); + }); + + test("parseNpmMajorVersion rejects malformed version strings", () => { + expect(parseNpmMajorVersion("v")).toBeNull(); + expect(parseNpmMajorVersion("")).toBeNull(); + expect(parseNpmMajorVersion("abc.1.2")).toBeNull(); + expect(parseNpmMajorVersion({})).toBeNull(); + }); + + test("getPinnedFallbackJestSpec uses lockfile version when available", () => { + const readFileSyncFn = jest.fn(() => + JSON.stringify({ + packages: { + "node_modules/jest": { + version: "30.3.1", + }, + }, + }) + ); + + expect(getPinnedFallbackJestSpec(readFileSyncFn)).toBe("jest@30.3.1"); + }); + + test("getPinnedFallbackJestSpec falls back to static version when lockfile is invalid", () => { + const readFileSyncFn = jest.fn(() => "not-json"); + expect(getPinnedFallbackJestSpec(readFileSyncFn)).toBe(FALLBACK_JEST_SPEC); + }); + + test("toShellCommand applies platform-specific npm command suffixes", () => { + expect(toShellCommand("npm", "linux")).toBe("npm"); + expect(toShellCommand("npm", "darwin")).toBe("npm"); + expect(toShellCommand("npm", "win32")).toBe("npm.cmd"); + }); + + test("isCommandUnavailable handles common command-not-found scenarios", () => { + expect(isCommandUnavailable(null)).toBe(true); + expect(isCommandUnavailable({ status: 127, error: null })).toBe(true); + expect(isCommandUnavailable({ status: null, error: { code: "ENOENT" } })).toBe(true); + expect(isCommandUnavailable({ status: null, error: { code: "EACCES" } })).toBe(true); + expect(isCommandUnavailable({ status: 1, error: null })).toBe(false); + }); + + test("hasHealthyLocalJestInstall returns false when local jest binary is missing", () => { + const result = hasHealthyLocalJestInstall(() => path.join(REPO_ROOT, "node_modules", "jest-circus", "build", "runner.js"), () => false); + expect(result).toBe(false); + }); + + test("hasHealthyLocalJestInstall returns false when jest-circus runner cannot be resolved", () => { + const result = hasHealthyLocalJestInstall(() => null, () => true); + expect(result).toBe(false); + }); + + test("hasHealthyLocalJestInstall rejects runner paths outside local node_modules", () => { + const externalRunnerPath = path.join(path.dirname(REPO_ROOT), "external-cache", "jest-circus", "build", "runner.js"); + const result = hasHealthyLocalJestInstall(() => externalRunnerPath, () => true); + expect(result).toBe(false); + }); + + test("hasHealthyLocalJestInstall accepts runner paths inside local node_modules", () => { + const localRunnerPath = path.join(REPO_ROOT, "node_modules", "jest-circus", "build", "runner.js"); + const result = hasHealthyLocalJestInstall(() => localRunnerPath, () => true); + expect(result).toBe(true); + }); + + test("resolveLocalModule resolves local jest-circus runner from repository dependencies", () => { + const resolvedPath = resolveLocalModule("jest-circus/runner"); + expect(typeof resolvedPath).toBe("string"); + expect(resolvedPath).toContain(path.join("node_modules", "jest-circus")); + }); + + test("runManagedJest uses local jest when installed", () => { + existsSyncSpy.mockReturnValue(true); + spawnSyncSpy.mockReturnValue({ status: 0 }); + + const result = runManagedJest(["--version"]); + + expect(result).toEqual({ status: 0, error: null }); + expect(spawnSyncSpy).toHaveBeenCalledWith( + process.execPath, + [LOCAL_JEST_BIN, "--version"], + expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + ); + }); + + test("runManagedJest uses npm exec fallback when local jest is missing and npm>=7", () => { + existsSyncSpy.mockReturnValue(false); + const pinnedFallbackJestSpec = getPinnedFallbackJestSpec(); + spawnSyncSpy + .mockReturnValueOnce({ status: 0, stdout: "11.11.0\n", stderr: "" }) + .mockReturnValueOnce({ status: 0 }); + + const result = runManagedJest(["--runTestsByPath", "scripts/__tests__/alpha.test.js"]); + + expect(result).toEqual({ status: 0, error: null }); + expect(spawnSyncSpy).toHaveBeenNthCalledWith( + 1, + toShellCommand("npm"), + ["--version"], + expect.objectContaining({ cwd: REPO_ROOT, encoding: "utf8" }) + ); + expect(spawnSyncSpy).toHaveBeenNthCalledWith( + 2, + toShellCommand("npm"), + [ + "exec", + "--yes", + `--package=${pinnedFallbackJestSpec}`, + "--", + "jest", + "--runTestsByPath", + "scripts/__tests__/alpha.test.js", + ], + expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + ); + }); + + test("runManagedJest uses npm exec fallback when local jest install is unhealthy", () => { + existsSyncSpy.mockReturnValue(true); + const pinnedFallbackJestSpec = getPinnedFallbackJestSpec(); + spawnSyncSpy + .mockReturnValueOnce({ status: 0, stdout: "11.11.0\n", stderr: "" }) + .mockReturnValueOnce({ status: 0 }); + const fallbackWarningSpy = jest.fn(); + + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => false, + printLocalJestFallbackWarningFn: fallbackWarningSpy, + }); + + expect(result).toEqual({ status: 0, error: null }); + expect(fallbackWarningSpy).toHaveBeenCalledTimes(1); + expect(spawnSyncSpy).toHaveBeenNthCalledWith( + 1, + toShellCommand("npm"), + ["--version"], + expect.objectContaining({ cwd: REPO_ROOT, encoding: "utf8" }) + ); + expect(spawnSyncSpy).toHaveBeenNthCalledWith( + 2, + toShellCommand("npm"), + ["exec", "--yes", `--package=${pinnedFallbackJestSpec}`, "--", "jest", "--version"], + expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + ); + }); + + test("runManagedJest uses npx fallback when npm major version is older than 7", () => { + existsSyncSpy.mockReturnValue(false); + const pinnedFallbackJestSpec = getPinnedFallbackJestSpec(); + spawnSyncSpy + .mockReturnValueOnce({ status: 0, stdout: "6.14.18\n", stderr: "" }) + .mockReturnValueOnce({ status: 0 }); + + const result = runManagedJest(["--version"]); + + expect(result).toEqual({ status: 0, error: null }); + expect(spawnSyncSpy).toHaveBeenNthCalledWith( + 2, + toShellCommand("npx"), + ["--yes", `--package=${pinnedFallbackJestSpec}`, "jest", "--version"], + expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + ); + }); + + test("runManagedJest uses npx fallback when npm major version cannot be determined", () => { + existsSyncSpy.mockReturnValue(false); + const pinnedFallbackJestSpec = getPinnedFallbackJestSpec(); + spawnSyncSpy + .mockReturnValueOnce({ status: 1, stdout: "", stderr: "npm unavailable" }) + .mockReturnValueOnce({ status: 0 }); + + const result = runManagedJest(["--version"]); + + expect(result).toEqual({ status: 0, error: null }); + expect(spawnSyncSpy).toHaveBeenNthCalledWith( + 2, + toShellCommand("npx"), + ["--yes", `--package=${pinnedFallbackJestSpec}`, "jest", "--version"], + expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + ); + }); + + test("runManagedJest falls back to npx when npm exec command is unavailable", () => { + existsSyncSpy.mockReturnValue(false); + const pinnedFallbackJestSpec = getPinnedFallbackJestSpec(); + spawnSyncSpy + .mockReturnValueOnce({ status: 0, stdout: "11.11.0\n", stderr: "" }) + .mockReturnValueOnce({ status: null, error: { code: "EACCES", message: "npm denied" } }) + .mockReturnValueOnce({ status: 0 }); + + const result = runManagedJest(["--version"]); + + expect(result).toEqual({ status: 0, error: null }); + expect(spawnSyncSpy).toHaveBeenNthCalledWith( + 2, + toShellCommand("npm"), + ["exec", "--yes", `--package=${pinnedFallbackJestSpec}`, "--", "jest", "--version"], + expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + ); + expect(spawnSyncSpy).toHaveBeenNthCalledWith( + 3, + toShellCommand("npx"), + ["--yes", `--package=${pinnedFallbackJestSpec}`, "jest", "--version"], + expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + ); + }); +}); \ No newline at end of file diff --git a/scripts/__tests__/validate-pre-commit-tooling.test.js b/scripts/__tests__/validate-pre-commit-tooling.test.js new file mode 100644 index 00000000..34f71d72 --- /dev/null +++ b/scripts/__tests__/validate-pre-commit-tooling.test.js @@ -0,0 +1,196 @@ +/** + * @fileoverview Tests for validate-pre-commit-tooling.js. + */ + +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + parseHookEntries, + parseHookIds, + hasNpxInstallPolicy, + hasManagedJestInvocation, + validateYamllintPolicy, + validateConfigContent, + validateConfigFile, +} = require("../validate-pre-commit-tooling.js"); + +describe("validate-pre-commit-tooling", () => { + test("parseHookEntries reads folded and inline entry styles", () => { + const content = [ + "repos:", + " - repo: local", + " hooks:", + " - id: alpha", + " entry: node scripts/alpha.js", + " - id: beta", + " entry: >-", + " npx --yes jest --runTestsByPath scripts/__tests__/beta.test.js", + " scripts/__tests__/gamma.test.js", + ].join("\n"); + + const hooks = parseHookEntries(content); + + expect(hooks).toHaveLength(2); + expect(hooks[0]).toEqual( + expect.objectContaining({ + id: "alpha", + entry: "node scripts/alpha.js", + }) + ); + expect(hooks[1]).toEqual( + expect.objectContaining({ + id: "beta", + entry: "npx --yes jest --runTestsByPath scripts/__tests__/beta.test.js scripts/__tests__/gamma.test.js", + }) + ); + }); + + test("parseHookIds captures hook ids across repos", () => { + const content = [ + "repos:", + " - repo: https://github.com/adrienverge/yamllint", + " rev: v1.38.0", + " hooks:", + " - id: yamllint", + " - repo: local", + " hooks:", + " - id: alpha", + " entry: node scripts/alpha.js", + ].join("\n"); + + const ids = parseHookIds(content); + + expect(ids).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "yamllint" }), + expect.objectContaining({ id: "alpha" }), + ]) + ); + }); + + test("hasNpxInstallPolicy rejects npx without explicit policy", () => { + const okWithYes = hasNpxInstallPolicy("npx --yes jest --runTestsByPath foo.test.js"); + const okWithNo = hasNpxInstallPolicy("npx --no jest --runTestsByPath foo.test.js"); + const bad = hasNpxInstallPolicy("npx jest --runTestsByPath foo.test.js"); + + expect(okWithYes).toBe(true); + expect(okWithNo).toBe(true); + expect(bad).toBe(false); + }); + + test("hasManagedJestInvocation detects unmanaged bare jest command", () => { + expect(hasManagedJestInvocation("jest --runTestsByPath foo.test.js")).toBe(false); + expect(hasManagedJestInvocation("node scripts/run-managed-jest.js --runTestsByPath foo.test.js")).toBe(true); + expect(hasManagedJestInvocation("script-tests", "npm run test:scripts")).toBe(false); + expect(hasManagedJestInvocation("script-tests", "node scripts/run-managed-jest.js")).toBe(true); + }); + + test("validateConfigContent reports missing npx policy and unmanaged jest", () => { + const content = [ + "repos:", + " - repo: https://github.com/adrienverge/yamllint", + " rev: v1.38.0", + " hooks:", + " - id: yamllint", + " args: [-c, .yamllint.yaml]", + " - repo: local", + " hooks:", + " - id: bad-npx", + " entry: npx jest --runTestsByPath scripts/__tests__/a.test.js", + " - id: bad-jest", + " entry: jest --runTestsByPath scripts/__tests__/b.test.js", + " - id: good", + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/c.test.js", + ].join("\n"); + + const violations = validateConfigContent(content); + + expect(violations).toHaveLength(3); + expect(violations.filter((violation) => violation.hookId === "bad-npx")).toHaveLength(2); + expect(violations.filter((violation) => violation.hookId === "bad-jest")).toHaveLength(1); + }); + + test("validateYamllintPolicy reports missing yamllint hook", () => { + const content = [ + "repos:", + " - repo: local", + " hooks:", + " - id: alpha", + " entry: node scripts/alpha.js", + ].join("\n"); + + const violations = validateYamllintPolicy(content); + + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("Missing required yamllint hook"); + }); + + test("validateYamllintPolicy rejects conditional skip pattern", () => { + const content = [ + "repos:", + " - repo: local", + " hooks:", + " - id: yamllint", + " entry: bash -c 'if command -v yamllint >/dev/null 2>&1; then yamllint -c .yamllint.yaml \"$@\"; else echo \"yamllint not installed; skipping\"; fi' --", + ].join("\n"); + + const violations = validateYamllintPolicy(content); + + expect(violations.length).toBeGreaterThanOrEqual(1); + expect( + violations.some((violation) => + violation.message.includes("must not be conditionally skipped") + ) + ).toBe(true); + }); + + test("validateConfigFile passes for repository pre-commit config", () => { + const repoConfigPath = path.resolve(__dirname, "../../.pre-commit-config.yaml"); + const configContent = fs.readFileSync(repoConfigPath, "utf8"); + const hooks = parseHookEntries(configContent); + const violations = validateConfigFile(repoConfigPath); + + expect(hooks.length).toBeGreaterThan(0); + expect(violations).toHaveLength(0); + }); + + test("package preflight script includes YAML validation gate", () => { + const packageJsonPath = path.resolve(__dirname, "../../package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + + expect(packageJson.scripts["check:yaml"]).toContain( + "pre-commit run yamllint --all-files" + ); + expect(packageJson.scripts["preflight:pre-commit"]).toContain( + "npm run check:yaml" + ); + }); + + test("validateConfigFile handles CRLF and lone CR line endings", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pre-commit-tooling-")); + const filePath = path.join(tempDir, ".pre-commit-config.yaml"); + + try { + const content = [ + "repos:", + " - repo: local", + " hooks:", + " - id: bad", + " entry: npx jest --runTestsByPath scripts/__tests__/a.test.js", + ].join("\r"); + + fs.writeFileSync(filePath, content, "utf8"); + const violations = validateConfigFile(filePath); + + expect(violations).toHaveLength(3); + expect(violations.filter((violation) => violation.hookId === "bad")).toHaveLength(2); + expect(violations.some((violation) => violation.hookId === "yamllint")).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/__tests__/verify-managed-jest-fallback.test.js b/scripts/__tests__/verify-managed-jest-fallback.test.js new file mode 100644 index 00000000..49a2f7ba --- /dev/null +++ b/scripts/__tests__/verify-managed-jest-fallback.test.js @@ -0,0 +1,66 @@ +/** + * @fileoverview Tests for verify-managed-jest-fallback.js. + */ + +"use strict"; + +const { + resolveManagedRunnerPath, + removeManagedRunner, + verifyManagedJestFallback, +} = require("../verify-managed-jest-fallback.js"); + +describe("verify-managed-jest-fallback", () => { + test("resolveManagedRunnerPath uses provided resolver", () => { + const moduleResolver = jest.fn(() => "/tmp/jest-circus/runner.js"); + + const runnerPath = resolveManagedRunnerPath(moduleResolver); + + expect(runnerPath).toBe("/tmp/jest-circus/runner.js"); + expect(moduleResolver).toHaveBeenCalledWith("jest-circus/runner"); + }); + + test("removeManagedRunner throws when runner does not exist before deletion", () => { + const existsSyncFn = jest.fn(() => false); + const logFn = jest.fn(); + + expect(() => + removeManagedRunner("/tmp/missing-runner.js", { existsSyncFn, logFn }) + ).toThrow("Runner path does not exist before deletion."); + }); + + test("removeManagedRunner removes existing runner", () => { + const existsSyncFn = jest + .fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + const rmSyncFn = jest.fn(); + const logFn = jest.fn(); + + removeManagedRunner("/tmp/runner.js", { existsSyncFn, rmSyncFn, logFn }); + + expect(logFn).toHaveBeenCalledWith("Deleting managed Jest runner: /tmp/runner.js"); + expect(rmSyncFn).toHaveBeenCalledWith("/tmp/runner.js"); + expect(existsSyncFn).toHaveBeenCalledTimes(2); + }); + + test("verifyManagedJestFallback resolves and removes runner", () => { + const moduleResolver = jest.fn(() => "/tmp/runner.js"); + const existsSyncFn = jest + .fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + const rmSyncFn = jest.fn(); + const logFn = jest.fn(); + + verifyManagedJestFallback({ + moduleResolver, + existsSyncFn, + rmSyncFn, + logFn, + }); + + expect(moduleResolver).toHaveBeenCalledWith("jest-circus/runner"); + expect(rmSyncFn).toHaveBeenCalledWith("/tmp/runner.js"); + }); +}); diff --git a/scripts/fix-md029-md051.js b/scripts/fix-md029-md051.js new file mode 100644 index 00000000..519f6d69 --- /dev/null +++ b/scripts/fix-md029-md051.js @@ -0,0 +1,228 @@ +#!/usr/bin/env node +// Safe auto-fix for markdownlint MD029 (ordered list prefix) and MD051 (link fragments). +// - Normalizes ordered list prefixes to 1. outside fenced code blocks. +// - Fixes local fragment links (#...) to match GitHub/markdownlint heading fragments. + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const headingAnchorRe = /\{(#[a-z\d]+(?:[-_][a-z\d]+)*)\}/gu; +const lineFragmentRe = /^#(?:L\d+(?:C\d+)?-L\d+(?:C\d+)?|L\d+)$/u; +const fencedCodeRe = /^(?:>\s*)*(```|~~~)/u; +const headingRe = /^#{1,6}\s+(.+)$/u; +const orderedListRe = /^(\s*(?:>\s*)*)\d+\.(\s+)/u; +const inlineLinkFragmentRe = /\]\((#[^) \t]+)([^)]*)\)/gu; +const definitionFragmentRe = /^(\s*\[[^\]]+\]:\s*)(#[^\s]+)(.*)$/u; + +function safeDecodeURIComponent(value) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function convertHeadingToHtmlFragment(headingText) { + const withoutTrailingHashes = headingText.replace(/\s+#+\s*$/u, "").trim(); + headingAnchorRe.lastIndex = 0; + const withoutCustomAnchors = withoutTrailingHashes.replace(headingAnchorRe, "").trim(); + headingAnchorRe.lastIndex = 0; + + if (withoutCustomAnchors.length === 0) { + return "#"; + } + + const slug = withoutCustomAnchors + .toLowerCase() + .replace(/[^\p{Letter}\p{Mark}\p{Number}\p{Connector_Punctuation}\- ]/gu, "") + .replace(/ /gu, "-"); + + return `#${encodeURIComponent(slug)}`; +} + +function simplifyFragment(fragment) { + const withoutHash = fragment.startsWith("#") ? fragment.slice(1) : fragment; + const decoded = safeDecodeURIComponent(withoutHash); + + const simplified = decoded + .toLowerCase() + .replace(/[^\p{Letter}\p{Mark}\p{Number}\p{Connector_Punctuation}\- ]/gu, "") + .replace(/ /gu, "-") + .replace(/-+/gu, "-"); + + return `#${simplified}`; +} + +function collectDocumentFragments(lines) { + const fragments = new Set(["#top"]); + const counts = new Map(); + + let inFence = false; + for (const rawLine of lines) { + const trimmed = rawLine.trim(); + + if (fencedCodeRe.test(trimmed)) { + inFence = !inFence; + continue; + } + + if (inFence) { + continue; + } + + const headingMatch = trimmed.match(headingRe); + if (!headingMatch) { + continue; + } + + const headingText = headingMatch[1]; + const fragment = convertHeadingToHtmlFragment(headingText); + + if (fragment !== "#") { + const count = counts.get(fragment) || 0; + if (count > 0) { + fragments.add(`${fragment}-${count}`); + } + fragments.add(fragment); + counts.set(fragment, count + 1); + } + + headingAnchorRe.lastIndex = 0; + let anchorMatch = null; + while ((anchorMatch = headingAnchorRe.exec(headingText)) !== null) { + fragments.add(anchorMatch[1]); + } + headingAnchorRe.lastIndex = 0; + } + + return fragments; +} + +function buildAliasMap(fragments) { + const aliasMap = new Map(); + for (const fragment of fragments) { + const key = simplifyFragment(fragment); + if (!aliasMap.has(key)) { + aliasMap.set(key, new Set()); + } + aliasMap.get(key).add(fragment); + } + return aliasMap; +} + +function resolveFragment(fragment, fragments, aliasMap) { + if (fragments.has(fragment) || lineFragmentRe.test(fragment)) { + return fragment; + } + + const key = simplifyFragment(fragment); + const candidates = aliasMap.get(key); + if (!candidates || candidates.size !== 1) { + return fragment; + } + + return Array.from(candidates)[0]; +} + +function processMarkdownContent(content) { + const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + const fragments = collectDocumentFragments(lines); + const aliasMap = buildAliasMap(fragments); + + let inFence = false; + let changed = false; + + for (let i = 0; i < lines.length; i++) { + const originalLine = lines[i]; + const trimmed = originalLine.trim(); + + if (fencedCodeRe.test(trimmed)) { + inFence = !inFence; + continue; + } + + if (inFence) { + continue; + } + + let nextLine = originalLine.replace( + orderedListRe, + (match, leadingSpace, trailingSpace) => `${leadingSpace}1.${trailingSpace}` + ); + if (nextLine !== originalLine) { + changed = true; + } + + nextLine = nextLine.replace(inlineLinkFragmentRe, (match, fragment, suffix) => { + const resolved = resolveFragment(fragment, fragments, aliasMap); + if (resolved === fragment) { + return match; + } + changed = true; + return `](${resolved}${suffix})`; + }); + + const definitionMatch = nextLine.match(definitionFragmentRe); + if (definitionMatch) { + const resolved = resolveFragment(definitionMatch[2], fragments, aliasMap); + if (resolved !== definitionMatch[2]) { + nextLine = `${definitionMatch[1]}${resolved}${definitionMatch[3]}`; + changed = true; + } + } + + lines[i] = nextLine; + } + + return { + content: lines.join("\n"), + changed, + }; +} + +function main() { + const files = process.argv.slice(2); + if (files.length === 0) { + process.exit(0); + } + + for (const relPath of files) { + const absolutePath = path.resolve(process.cwd(), relPath); + + if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) { + continue; + } + + let source; + try { + source = fs.readFileSync(absolutePath, "utf8"); + } catch (error) { + console.error(`Skipping ${relPath}: ${error.message}`); + continue; + } + + const result = processMarkdownContent(source); + if (!result.changed) { + continue; + } + + fs.writeFileSync(absolutePath, result.content); + console.log(`MD029/MD051 fixed: ${path.relative(process.cwd(), absolutePath)}`); + } +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { + collectDocumentFragments, + convertHeadingToHtmlFragment, + processMarkdownContent, + resolveFragment, + simplifyFragment, + }; +} + +if (require.main === module) { + main(); +} diff --git a/scripts/fix-md029-md051.js.meta b/scripts/fix-md029-md051.js.meta new file mode 100644 index 00000000..c1a7b972 --- /dev/null +++ b/scripts/fix-md029-md051.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c3b3f56d63e3470f9b9d36e83f8f0451 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/run-managed-jest.js b/scripts/run-managed-jest.js new file mode 100644 index 00000000..abf88af9 --- /dev/null +++ b/scripts/run-managed-jest.js @@ -0,0 +1,253 @@ +#!/usr/bin/env node +/** + * run-managed-jest.js + * + * Runs Jest in a robust, non-interactive way for hooks and local automation: + * 1) Prefer local devDependency (node_modules/jest/bin/jest.js). + * 2) If local Jest is missing, provision a pinned fallback via npm exec. + * 3) If npm is too old for npm exec (or unavailable), fall back to npx. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const childProcess = require("child_process"); +const { createRequire } = require("module"); + +const REPO_ROOT = path.join(__dirname, ".."); +const REPO_NODE_MODULES = path.join(REPO_ROOT, "node_modules"); +const PACKAGE_LOCK_PATH = path.join(REPO_ROOT, "package-lock.json"); +const LOCAL_JEST_BIN = path.join(REPO_ROOT, "node_modules", "jest", "bin", "jest.js"); +const FALLBACK_JEST_SPEC = "jest@30.3.0"; +const REPO_REQUIRE = createRequire(path.join(REPO_ROOT, "package.json")); + +function toShellCommand(command, platform = process.platform) { + return platform === "win32" ? `${command}.cmd` : command; +} + +function parseNpmMajorVersion(versionText) { + if (typeof versionText !== "string") { + return null; + } + + const match = /^v?(\d+)/.exec(versionText.trim()); + if (!match) { + return null; + } + + return Number(match[1]); +} + +function getNpmMajorVersion() { + const result = childProcess.spawnSync(toShellCommand("npm"), ["--version"], { + cwd: REPO_ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error || result.status !== 0) { + return null; + } + + return parseNpmMajorVersion(result.stdout); +} + +function runCommand(command, args) { + const result = childProcess.spawnSync(command, args, { + cwd: REPO_ROOT, + stdio: "inherit", + }); + + return { + status: result.status, + error: result.error || null, + }; +} + +function runLocalJest(args) { + return runCommand(process.execPath, [LOCAL_JEST_BIN, ...args]); +} + +function getPinnedFallbackJestSpec( + readFileSyncFn = fs.readFileSync, + fallbackSpec = FALLBACK_JEST_SPEC +) { + try { + const packageLock = JSON.parse(readFileSyncFn(PACKAGE_LOCK_PATH, "utf8")); + const lockedVersion = packageLock && packageLock.packages && packageLock.packages["node_modules/jest"] && packageLock.packages["node_modules/jest"].version; + if (typeof lockedVersion === "string" && /^\d+\.\d+\.\d+$/.test(lockedVersion)) { + return `jest@${lockedVersion}`; + } + } catch { + // Fall through to static fallback when lockfile is unavailable or malformed. + } + + return fallbackSpec; +} + +function runNpmExecJest(args) { + const jestSpec = getPinnedFallbackJestSpec(); + return runCommand(toShellCommand("npm"), [ + "exec", + "--yes", + `--package=${jestSpec}`, + "--", + "jest", + ...args, + ]); +} + +function runNpxJest(args) { + const jestSpec = getPinnedFallbackJestSpec(); + return runCommand(toShellCommand("npx"), [ + "--yes", + `--package=${jestSpec}`, + "jest", + ...args, + ]); +} + +function isCommandUnavailable(result) { + if (!result) { + return true; + } + + if (result.error && ["ENOENT", "EACCES"].includes(result.error.code)) { + return true; + } + + return result.status === 127; +} + +function normalizeForPathComparison(targetPath) { + let resolved = path.resolve(targetPath); + try { + resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved); + } catch { + // Keep resolved path when target is unavailable; callers handle existence separately. + } + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function isPathInsideDirectory(filePath, directoryPath) { + const normalizedFilePath = normalizeForPathComparison(filePath); + const normalizedDirectoryPath = normalizeForPathComparison(directoryPath); + const relativePath = path.relative(normalizedDirectoryPath, normalizedFilePath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +function resolveLocalModule(moduleSpecifier) { + try { + return REPO_REQUIRE.resolve(moduleSpecifier); + } catch { + return null; + } +} + +function hasHealthyLocalJestInstall( + moduleResolver = resolveLocalModule, + existsSyncFn = fs.existsSync +) { + if (!existsSyncFn(LOCAL_JEST_BIN)) { + return false; + } + + const circusRunnerPath = moduleResolver("jest-circus/runner"); + if (!circusRunnerPath) { + return false; + } + + if (!existsSyncFn(circusRunnerPath)) { + return false; + } + + return isPathInsideDirectory(circusRunnerPath, REPO_NODE_MODULES); +} + +function printLocalJestFallbackWarning() { + console.warn( + "⚠️ Local Jest install appears incomplete; falling back to pinned npm exec Jest." + ); + if (process.platform === "win32") { + console.warn("Windows tip: run npm install/npm ci in the same shell used by git hooks."); + } +} + +function runManagedJest(args, options = {}) { + const { + hasHealthyLocalJestInstallFn = hasHealthyLocalJestInstall, + getNpmMajorVersionFn = getNpmMajorVersion, + printLocalJestFallbackWarningFn = printLocalJestFallbackWarning, + } = options; + + if (hasHealthyLocalJestInstallFn()) { + return runLocalJest(args); + } + + if (fs.existsSync(LOCAL_JEST_BIN)) { + printLocalJestFallbackWarningFn(); + } + + const npmMajor = getNpmMajorVersionFn(); + + if (npmMajor === null || npmMajor < 7) { + return runNpxJest(args); + } + + const npmExecResult = runNpmExecJest(args); + if (isCommandUnavailable(npmExecResult)) { + return runNpxJest(args); + } + + return npmExecResult; +} + +function printManagedJestLaunchError(error) { + const detail = error && error.message ? ` (${error.message})` : ""; + console.error(`❌ Failed to launch managed Jest${detail}.`); + console.error("Ensure Node.js/npm are available in this shell, or run npm install."); + if (process.platform === "win32") { + console.error("Windows tip: if you use nvm/fnm, open PowerShell or Git Bash with Node initialized and verify npm --version."); + } +} + +function main() { + const result = runManagedJest(process.argv.slice(2)); + + if (result.error) { + printManagedJestLaunchError(result.error); + process.exit(1); + } + + const status = typeof result.status === "number" ? result.status : 1; + process.exit(status); +} + +module.exports = { + REPO_ROOT, + REPO_NODE_MODULES, + PACKAGE_LOCK_PATH, + LOCAL_JEST_BIN, + FALLBACK_JEST_SPEC, + normalizeForPathComparison, + isPathInsideDirectory, + resolveLocalModule, + hasHealthyLocalJestInstall, + printLocalJestFallbackWarning, + toShellCommand, + parseNpmMajorVersion, + getNpmMajorVersion, + getPinnedFallbackJestSpec, + runCommand, + runLocalJest, + runNpmExecJest, + runNpxJest, + isCommandUnavailable, + runManagedJest, + printManagedJestLaunchError, +}; + +if (require.main === module) { + main(); +} \ No newline at end of file diff --git a/scripts/validate-pre-commit-tooling.js b/scripts/validate-pre-commit-tooling.js new file mode 100644 index 00000000..57d9907d --- /dev/null +++ b/scripts/validate-pre-commit-tooling.js @@ -0,0 +1,296 @@ +#!/usr/bin/env node +/** + * validate-pre-commit-tooling.js + * + * Enforces non-interactive Node tooling rules for local hooks: + * - npx calls must explicitly set install policy via --yes/-y or --no. + * - Jest-related hooks must use scripts/run-managed-jest.js for deterministic execution. + * - yamllint must be configured as a non-optional hook (no conditional skip wrappers). + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { normalizeToLf } = require("./lib/quote-parser"); + +const PRE_COMMIT_CONFIG_PATH = path.join(__dirname, "..", ".pre-commit-config.yaml"); + +class Violation { + constructor(hookId, line, message, entry) { + this.hookId = hookId; + this.line = line; + this.message = message; + this.entry = entry; + } + + toString() { + return `${this.hookId} (line ${this.line}): ${this.message}\n entry: ${this.entry}`; + } +} + +function getIndent(line) { + return line.length - line.trimStart().length; +} + +function parseHookEntries(content) { + const normalized = normalizeToLf(content); + const lines = normalized.split("\n"); + const entries = []; + + let currentHookId = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const idMatch = /^(\s*)-\s+id:\s*([^\s#]+)\s*$/.exec(line); + if (idMatch) { + currentHookId = idMatch[2].trim(); + continue; + } + + if (!currentHookId) { + continue; + } + + const entryMatch = /^(\s*)entry:\s*(.*)$/.exec(line); + if (!entryMatch) { + continue; + } + + const entryIndent = entryMatch[1].length; + const entryValue = entryMatch[2].trim(); + let command; + + if ([">", ">-", "|", "|-"] .includes(entryValue)) { + const blockLines = []; + let j = i + 1; + while (j < lines.length) { + const nextLine = lines[j]; + const nextLineIndent = getIndent(nextLine); + + if (nextLine.trim().length > 0 && nextLineIndent <= entryIndent) { + break; + } + + if (nextLine.trim().length > 0) { + blockLines.push(nextLine.trim()); + } + + j++; + } + command = blockLines.join(" ").replace(/\s+/g, " ").trim(); + } else { + command = entryValue; + } + + entries.push({ id: currentHookId, line: i + 1, entry: command }); + } + + return entries; +} + +function parseHookIds(content) { + const lines = normalizeToLf(content).split("\n"); + const ids = []; + + for (let i = 0; i < lines.length; i++) { + const idMatch = /^\s*-\s+id:\s*([^\s#]+)\s*$/.exec(lines[i]); + if (idMatch) { + ids.push({ id: idMatch[1].trim(), line: i + 1 }); + } + } + + return ids; +} + +function tokenizeCommand(entry) { + const tokens = entry.match(/"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\S+/g) || []; + return tokens.map((token) => token.replace(/^['"]|['"]$/g, "")); +} + +function hasNpxInstallPolicy(entry) { + const tokens = tokenizeCommand(entry); + let foundNpx = false; + + for (let i = 0; i < tokens.length; i++) { + if (tokens[i] !== "npx") { + continue; + } + + foundNpx = true; + let hasPolicy = false; + + for (let j = i + 1; j < tokens.length; j++) { + const token = tokens[j]; + + if (token === "--yes" || token === "-y" || token === "--no") { + hasPolicy = true; + break; + } + + if (token === "--") { + break; + } + + if (!token.startsWith("-")) { + break; + } + } + + if (!hasPolicy) { + return false; + } + } + + if (foundNpx) { + return true; + } + + // Fallback for quoted shell fragments that contain npx but were tokenized as a single token. + // This check is intentionally lexical and does not attempt to evaluate shell expansion. + if (/\bnpx\b/.test(entry)) { + return /\b(--yes|-y|--no)\b/.test(entry); + } + + return true; +} + +function usesManagedJestWrapper(entry) { + return /\bnode\b\s+scripts\/run-managed-jest\.js\b/.test(entry); +} + +function isJestRelatedHook(hookId, entry) { + return ( + usesManagedJestWrapper(entry) || + /\bjest\b/.test(entry) || + /script-(?:parser-)?tests/.test(hookId) + ); +} + +function hasManagedJestInvocation(hookIdOrEntry, maybeEntry) { + const hookId = maybeEntry === undefined ? "" : hookIdOrEntry; + const entry = maybeEntry === undefined ? hookIdOrEntry : maybeEntry; + + if (!isJestRelatedHook(hookId, entry)) { + return true; + } + + return usesManagedJestWrapper(entry); +} + +function validateHookEntries(entries) { + const violations = []; + + for (const hook of entries) { + if (/\bnpx\b/.test(hook.entry) && !hasNpxInstallPolicy(hook.entry)) { + violations.push( + new Violation( + hook.id, + hook.line, + "npx entry must explicitly set install policy with --yes/-y or --no.", + hook.entry + ) + ); + } + + if (!hasManagedJestInvocation(hook.id, hook.entry)) { + violations.push( + new Violation( + hook.id, + hook.line, + "Jest-related hooks must invoke node scripts/run-managed-jest.js.", + hook.entry + ) + ); + } + } + + return violations; +} + +function validateYamllintPolicy(content) { + const violations = []; + const normalized = normalizeToLf(content); + const lines = normalized.split("\n"); + const hookIds = parseHookIds(content); + const yamllintHook = hookIds.find((hook) => hook.id === "yamllint"); + + if (!yamllintHook) { + violations.push( + new Violation( + "yamllint", + 1, + "Missing required yamllint hook. Configure a non-optional yamllint hook in .pre-commit-config.yaml.", + "(missing hook)" + ) + ); + } + + const forbiddenPatterns = [ + /yamllint not installed; skipping/i, + /command\s+-v\s+yamllint/i, + ]; + + for (const pattern of forbiddenPatterns) { + const lineIndex = lines.findIndex((line) => pattern.test(line)); + if (lineIndex !== -1) { + violations.push( + new Violation( + "yamllint", + lineIndex + 1, + "yamllint hook must not be conditionally skipped; use a deterministic managed hook.", + lines[lineIndex].trim() + ) + ); + } + } + + return violations; +} + +function validateConfigContent(content) { + const hooks = parseHookEntries(content); + return [...validateHookEntries(hooks), ...validateYamllintPolicy(content)]; +} + +function validateConfigFile(filePath = PRE_COMMIT_CONFIG_PATH) { + const content = fs.readFileSync(filePath, "utf8"); + return validateConfigContent(content); +} + +function main() { + const violations = validateConfigFile(PRE_COMMIT_CONFIG_PATH); + + if (violations.length === 0) { + console.log("✅ Pre-commit Node tooling validation passed."); + process.exit(0); + } + + console.error(`❌ Found ${violations.length} pre-commit tooling violation(s):`); + for (const violation of violations) { + console.error(`\n- ${violation.toString()}`); + } + + process.exit(1); +} + +module.exports = { + PRE_COMMIT_CONFIG_PATH, + Violation, + getIndent, + parseHookEntries, + parseHookIds, + tokenizeCommand, + hasNpxInstallPolicy, + usesManagedJestWrapper, + isJestRelatedHook, + hasManagedJestInvocation, + validateHookEntries, + validateYamllintPolicy, + validateConfigContent, + validateConfigFile, +}; + +if (require.main === module) { + main(); +} diff --git a/scripts/verify-managed-jest-fallback.js b/scripts/verify-managed-jest-fallback.js new file mode 100644 index 00000000..175ce26d --- /dev/null +++ b/scripts/verify-managed-jest-fallback.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); + +function resolveManagedRunnerPath(moduleResolver = require.resolve) { + return moduleResolver("jest-circus/runner"); +} + +function removeManagedRunner( + runnerPath, + { + existsSyncFn = fs.existsSync, + rmSyncFn = fs.rmSync, + logFn = console.log, + } = {} +) { + logFn(`Deleting managed Jest runner: ${runnerPath}`); + + if (!existsSyncFn(runnerPath)) { + throw new Error("Runner path does not exist before deletion."); + } + + rmSyncFn(runnerPath); + + if (existsSyncFn(runnerPath)) { + throw new Error("Runner path still exists after deletion."); + } +} + +function verifyManagedJestFallback(options = {}) { + const { + moduleResolver = require.resolve, + existsSyncFn, + rmSyncFn, + logFn, + } = options; + + const runnerPath = resolveManagedRunnerPath(moduleResolver); + removeManagedRunner(runnerPath, { + existsSyncFn, + rmSyncFn, + logFn, + }); +} + +function main() { + verifyManagedJestFallback(); +} + +module.exports = { + resolveManagedRunnerPath, + removeManagedRunner, + verifyManagedJestFallback, +}; + +if (require.main === module) { + main(); +} From 43fb85474fbce9f88eb678c043b2b379367c8ab9 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Tue, 28 Apr 2026 16:10:43 -0700 Subject: [PATCH 02/12] GitHook fixes --- .cspell.json | 28 +++- .github/workflows/format-on-demand.yml | 12 +- .github/workflows/validate-npm-meta.yml | 19 ++- .llm/context.md | 8 + .pre-commit-config.yaml | 17 +- CONTRIBUTING.md | 4 +- package.json | 17 +- ...tect-shell-redirection-antipattern.test.js | 154 ++++++++++++++++++ ...shell-redirection-antipattern.test.js.meta | 7 + .../__tests__/generate-skills-index.test.js | 82 ++++++++++ .../pre-commit-hook-stage-policy.test.js | 123 ++++++++++++++ .../pre-commit-hook-stage-policy.test.js.meta | 7 + scripts/__tests__/prettier-version.test.js | 133 +++++++++++++++ .../__tests__/run-managed-jest.test.js.meta | 7 + .../__tests__/run-managed-prettier.test.js | 93 +++++++++++ scripts/__tests__/shell-command.test.js | 74 +++++++++ scripts/__tests__/validate-npm-meta.test.js | 146 ++++++++++++++++- .../validate-pre-commit-tooling.test.js | 75 ++++++++- .../validate-pre-commit-tooling.test.js.meta | 7 + .../verify-managed-jest-fallback.test.js.meta | 7 + scripts/generate-skills-index.js | 17 +- scripts/lib/prettier-version.js | 78 +++++++++ scripts/lib/shell-command.js | 100 ++++++++++++ scripts/lib/shell-command.js.meta | 7 + scripts/run-managed-jest.js | 23 ++- scripts/run-managed-jest.js.meta | 7 + scripts/run-managed-prettier.js | 105 ++++++++++++ scripts/validate-npm-meta.js | 111 +++++++++---- scripts/validate-pre-commit-tooling.js | 70 +++++++- scripts/validate-pre-commit-tooling.js.meta | 7 + scripts/verify-managed-jest-fallback.js.meta | 7 + 31 files changed, 1472 insertions(+), 80 deletions(-) create mode 100644 scripts/__tests__/detect-shell-redirection-antipattern.test.js create mode 100644 scripts/__tests__/detect-shell-redirection-antipattern.test.js.meta create mode 100644 scripts/__tests__/pre-commit-hook-stage-policy.test.js create mode 100644 scripts/__tests__/pre-commit-hook-stage-policy.test.js.meta create mode 100644 scripts/__tests__/prettier-version.test.js create mode 100644 scripts/__tests__/run-managed-jest.test.js.meta create mode 100644 scripts/__tests__/run-managed-prettier.test.js create mode 100644 scripts/__tests__/shell-command.test.js create mode 100644 scripts/__tests__/validate-pre-commit-tooling.test.js.meta create mode 100644 scripts/__tests__/verify-managed-jest-fallback.test.js.meta create mode 100644 scripts/lib/prettier-version.js create mode 100644 scripts/lib/shell-command.js create mode 100644 scripts/lib/shell-command.js.meta create mode 100644 scripts/run-managed-jest.js.meta create mode 100644 scripts/run-managed-prettier.js create mode 100644 scripts/validate-pre-commit-tooling.js.meta create mode 100644 scripts/verify-managed-jest-fallback.js.meta diff --git a/.cspell.json b/.cspell.json index ace5d85b..e664aa14 100644 --- a/.cspell.json +++ b/.cspell.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", "version": "0.2", - "language": "en", + "language": "en,en-GB", "dictionaries": ["csharp", "typescript", "powershell", "npm", "dotnet", "bash", "markdown"], "enableGlobDot": true, "minWordLength": 4, @@ -213,7 +213,33 @@ "llmstxt", "llms", "LLMS", + "callvirt", + "Callvirt", + "customised", + "dedup", + "dedup'd", + "Dedups", "desync", + "fqns", + "Indirected", + "initialised", + "ldsfld", + "Ldsfld", + "ldstr", + "Ldstr", + "materialised", + "misaligning", + "normalise", + "Normalise", + "normalises", + "ordinally", + "recognised", + "recompiles", + "reentrancy", + "serialisable", + "unconfigured", + "unrecognised", + "Unrecognised", "unmatch" ], "ignoreRegExpList": [ diff --git a/.github/workflows/format-on-demand.yml b/.github/workflows/format-on-demand.yml index f348f4ba..c155ceb8 100644 --- a/.github/workflows/format-on-demand.yml +++ b/.github/workflows/format-on-demand.yml @@ -70,9 +70,9 @@ jobs: run: dotnet tool restore - name: Apply Prettier fixes run: | - npx --yes prettier@3.8.1 --write "**/*.{md,markdown}" - npx --yes prettier@3.8.1 --write "**/*.{json,asmdef,asmref}" - npx --yes prettier@3.8.1 --write "**/*.{yml,yaml}" + node scripts/run-managed-prettier.js --write "**/*.{md,markdown}" + node scripts/run-managed-prettier.js --write "**/*.{json,asmdef,asmref}" + node scripts/run-managed-prettier.js --write "**/*.{yml,yaml}" - name: Apply markdownlint fixes run: | npx --yes markdownlint-cli2@0.20.0 "**/*.md" "**/*.markdown" --fix @@ -220,9 +220,9 @@ jobs: run: dotnet tool restore - name: Apply Prettier fixes run: | - npx --yes prettier@3.8.1 --write "**/*.{md,markdown}" - npx --yes prettier@3.8.1 --write "**/*.{json,asmdef,asmref}" - npx --yes prettier@3.8.1 --write "**/*.{yml,yaml}" + node scripts/run-managed-prettier.js --write "**/*.{md,markdown}" + node scripts/run-managed-prettier.js --write "**/*.{json,asmdef,asmref}" + node scripts/run-managed-prettier.js --write "**/*.{yml,yaml}" - name: Apply markdownlint fixes run: | npx --yes markdownlint-cli2@0.20.0 "**/*.md" "**/*.markdown" --fix diff --git a/.github/workflows/validate-npm-meta.yml b/.github/workflows/validate-npm-meta.yml index cc4a452c..dba0b9e8 100644 --- a/.github/workflows/validate-npm-meta.yml +++ b/.github/workflows/validate-npm-meta.yml @@ -13,6 +13,7 @@ on: - "package.json" - ".npmignore" - "scripts/validate-npm-meta.js" + - "scripts/__tests__/validate-npm-meta.test.js" push: branches: - main @@ -28,6 +29,7 @@ on: - "package.json" - ".npmignore" - "scripts/validate-npm-meta.js" + - "scripts/__tests__/validate-npm-meta.test.js" workflow_dispatch: concurrency: @@ -39,8 +41,14 @@ permissions: jobs: validate-npm-meta: - name: Validate NPM Meta Files - runs-on: ubuntu-latest + name: Validate NPM Meta Files (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: - name: Checkout @@ -56,12 +64,7 @@ jobs: cache-dependency-path: package.json - name: Install dependencies - run: | - if [ -f package-lock.json ]; then - npm ci - else - npm i --no-audit --no-fund - fi + run: npm i --no-audit --no-fund - name: Validate NPM package meta files run: npm run validate:npm-meta diff --git a/.llm/context.md b/.llm/context.md index ff238a6e..109f4afa 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -26,6 +26,8 @@ This file is intentionally concise. It contains only critical, high-signal guida - Never commit repository settings that auto-approve chat-invoked terminal commands. - Ensure fenced markdown examples are closed and do not swallow real sections (for example `## See Also`). - Run file-scoped validation during editing; do not treat git hooks as the first signal of quality issues. +- 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. - When editing `.pre-commit-config.yaml`, `scripts/*` hook tooling, or `.github/workflows/*.yml`, run `npm run preflight:pre-commit` before finishing. ## Build and Test Commands @@ -35,7 +37,10 @@ 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` +- Check hook-managed Prettier targets: `npm run check:prettier:hooks` - Validate YAML formatting and lint policy: `npm run check:yaml` +- Validate npm package meta integrity: `npm run validate:npm-meta` +- File-scoped spellcheck: `npx --yes cspell@9 --no-progress --no-summary ` - Note: Prettier does not auto-wrap long YAML lines; yamllint enforces the 200-character limit. - Auto-fix markdown fragments/lists: `node scripts/fix-md029-md051.js ` - Lint markdown: `npx markdownlint-cli2 ` @@ -58,6 +63,9 @@ This file is intentionally concise. It contains only critical, high-signal guida - Keep JS and PowerShell behavior synchronized when dual implementations exist. - Add tests for parser changes and malformed input edge cases. - 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 `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. +- 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. - 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 and yamllint issues are caught before commit. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 82418872..d4ebcea9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,10 +44,10 @@ repos: hooks: - id: prettier name: Prettier (Markdown, JSON, asmdef, asmref, YAML) - entry: npx --yes prettier@3.8.1 --write + entry: node scripts/run-managed-prettier.js --write language: system files: '(?i)\.(md|markdown|json|asmdef|asmref|ya?ml)$' - description: Use pinned Prettier version to match CI. + description: Use managed Prettier wrapper for cross-platform version parity. - repo: local hooks: @@ -187,7 +187,9 @@ repos: entry: node scripts/validate-npm-meta.js --check language: system pass_filenames: false + files: '^(Editor/|Runtime/|Samples~/|SourceGenerators/|.*\.meta$|.*\.md$|package\.json$|\.npmignore$|scripts/validate-npm-meta\.js|scripts/__tests__/validate-npm-meta\.test\.js)$' stages: + - pre-commit - pre-push description: Validate that npm package includes all .meta files correctly. @@ -228,23 +230,29 @@ repos: entry: >- node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/validate-lychee-config.test.js scripts/__tests__/generate-skills-index.test.js + scripts/__tests__/prettier-version.test.js + scripts/__tests__/run-managed-prettier.test.js scripts/__tests__/validate-skills-required-fields.test.js scripts/__tests__/validate-workflows.test.js scripts/__tests__/check-eol.test.js scripts/__tests__/quote-parser.test.js + scripts/__tests__/shell-command.test.js scripts/__tests__/validate-skills-llm-policy.test.js scripts/__tests__/update-llms-txt.test.js scripts/__tests__/fix-md029-md051.test.js scripts/__tests__/validate-vscode-settings.test.js scripts/__tests__/validate-pre-commit-tooling.test.js + scripts/__tests__/validate-npm-meta.test.js + scripts/__tests__/detect-shell-redirection-antipattern.test.js + scripts/__tests__/pre-commit-hook-stage-policy.test.js scripts/__tests__/run-managed-jest.test.js scripts/__tests__/verify-managed-jest-fallback.test.js language: system pass_filenames: false - files: '^(\.gitattributes|CONTRIBUTING\.md|\.vscode/settings\.json|\.github/workflows/(llm-policy-check|pre-commit-tooling-check)\.yml|scripts/check-eol\.ps1|scripts/(check-eol|fix-eol|fix-md029-md051|validate-lychee-config|validate-skills|generate-skills-index|validate-workflows|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|run-managed-jest|verify-managed-jest-fallback)\.js|scripts/lib/(quote-parser|eol-policy)\.js|scripts/__tests__/(check-eol|fix-md029-md051|validate-lychee-config|validate-skills-required-fields|validate-skills-llm-policy|generate-skills-index|validate-workflows|quote-parser|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|run-managed-jest|verify-managed-jest-fallback)\.test\.js)$' + files: '^(\.gitattributes|CONTRIBUTING\.md|\.vscode/settings\.json|\.github/workflows/(llm-policy-check|pre-commit-tooling-check)\.yml|scripts/check-eol\.ps1|scripts/(check-eol|fix-eol|fix-md029-md051|validate-lychee-config|validate-skills|generate-skills-index|validate-workflows|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-npm-meta|run-managed-jest|run-managed-prettier|verify-managed-jest-fallback)\.js|scripts/lib/(quote-parser|eol-policy|shell-command|prettier-version)\.js|scripts/__tests__/(check-eol|fix-md029-md051|validate-lychee-config|validate-skills-required-fields|validate-skills-llm-policy|generate-skills-index|prettier-version|run-managed-prettier|validate-workflows|quote-parser|shell-command|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-npm-meta|detect-shell-redirection-antipattern|pre-commit-hook-stage-policy|run-managed-jest|verify-managed-jest-fallback)\.test\.js)$' stages: - pre-commit - description: Fail fast on parser and llms generator regressions (quote handling, frontmatter/TOML parsing, newline normalization) before push. + description: Fail fast on parser, npm-meta, and shell-safety regressions (quote handling, frontmatter/TOML parsing, newline normalization) before push. - repo: local hooks: @@ -276,5 +284,6 @@ repos: files: '(?i)\.(md|cs|json|ya?ml|ps1|js)$' pass_filenames: true stages: + - pre-commit - pre-push description: Check spelling in changed files before push. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 740abd69..bae2f9f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,8 @@ Run these steps in order: `jest` does not need to be installed globally. Hooks and scripts route through `scripts/run-managed-jest.js` so they can use local devDependencies first, then a managed fallback when needed. +Prettier hooks and npm scripts route through `scripts/run-managed-prettier.js` so format checks and writes use the same resolved Prettier version across local shells and CI. + Windows note: if you use `nvm` or `fnm`, run commits from a shell where Node is initialized (PowerShell or Git Bash) and verify `npm --version` before running hooks. If you edit `.github/workflows/*.yml`, run `npm run preflight:pre-commit` in that same shell before `git commit`. @@ -50,7 +52,7 @@ Handy commands: - Lint markdown (all files): `pre-commit run markdownlint --all-files` - Lint markdown (manual): `npx markdownlint-cli2@0.20.0 "**/*.md" --fix` - Format JSON/.asmdef (all files): `pre-commit run prettier --all-files` -- Format JSON/.asmdef (manual): `npx prettier@3.8.1 --write "**/*.{json,asmdef}"` +- Format JSON/.asmdef (manual): `node scripts/run-managed-prettier.js --write "**/*.{json,asmdef}"` - Format YAML (all files): `pre-commit run prettier-yaml --all-files` - Check YAML formatting + lint: `npm run check:yaml` - Run yamllint hook directly: `pre-commit run yamllint --all-files` diff --git a/package.json b/package.json index 59907c6e..6d46ab7b 100644 --- a/package.json +++ b/package.json @@ -60,13 +60,14 @@ "test:llms-txt": "node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/update-llms-txt.test.js", "test:watch": "node scripts/run-managed-jest.js --watch", "test:coverage": "node scripts/run-managed-jest.js --coverage", - "format:md": "prettier --write \"**/*.{md,markdown}\"", - "format:md:check": "prettier --check \"**/*.{md,markdown}\"", - "format:json": "prettier --write \"**/*.{json,asmdef,asmref}\"", - "format:json:check": "prettier --check \"**/*.{json,asmdef,asmref}\"", - "format:yaml": "prettier --write \"**/*.{yml,yaml}\"", - "format:yaml:check": "prettier --check \"**/*.{yml,yaml}\"", - "check:yaml": "prettier --check \"**/*.{yml,yaml}\" && pre-commit run yamllint --all-files", + "format:md": "node scripts/run-managed-prettier.js --write \"**/*.{md,markdown}\"", + "format:md:check": "node scripts/run-managed-prettier.js --check \"**/*.{md,markdown}\"", + "format:json": "node scripts/run-managed-prettier.js --write \"**/*.{json,asmdef,asmref}\"", + "format:json:check": "node scripts/run-managed-prettier.js --check \"**/*.{json,asmdef,asmref}\"", + "format:yaml": "node scripts/run-managed-prettier.js --write \"**/*.{yml,yaml}\"", + "format:yaml:check": "node scripts/run-managed-prettier.js --check \"**/*.{yml,yaml}\"", + "check:prettier:hooks": "node scripts/run-managed-prettier.js --check \"**/*.{md,markdown,json,asmdef,asmref,yml,yaml}\"", + "check:yaml": "npm run format:yaml:check && pre-commit run yamllint --all-files", "lint:markdown": "markdownlint-cli2 \"**/*.md\" \"**/*.markdown\"", "update:llms-txt": "node scripts/update-llms-txt.js", "check:llms-txt": "node scripts/update-llms-txt.js --check", @@ -74,7 +75,7 @@ "validate:npm-meta": "node scripts/validate-npm-meta.js --check", "validate:pre-commit-tooling": "node scripts/validate-pre-commit-tooling.js", "validate:vscode-settings": "node scripts/validate-vscode-settings.js", - "preflight:pre-commit": "npm run validate:pre-commit-tooling && npm run check:yaml && node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/validate-pre-commit-tooling.test.js scripts/__tests__/run-managed-jest.test.js" + "preflight:pre-commit": "npm run validate:pre-commit-tooling && npm run check:prettier:hooks && npm run check:yaml && node scripts/generate-skills-index.js --check && npm run validate:npm-meta && node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/validate-pre-commit-tooling.test.js scripts/__tests__/run-managed-jest.test.js scripts/__tests__/run-managed-prettier.test.js scripts/__tests__/prettier-version.test.js scripts/__tests__/validate-npm-meta.test.js scripts/__tests__/generate-skills-index.test.js scripts/__tests__/shell-command.test.js scripts/__tests__/detect-shell-redirection-antipattern.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js" }, "devDependencies": { "jest": "^30.3.0", diff --git a/scripts/__tests__/detect-shell-redirection-antipattern.test.js b/scripts/__tests__/detect-shell-redirection-antipattern.test.js new file mode 100644 index 00000000..207cc993 --- /dev/null +++ b/scripts/__tests__/detect-shell-redirection-antipattern.test.js @@ -0,0 +1,154 @@ +/** + * @fileoverview Guards against shell-redirection anti-patterns in production scripts. + * + * Shell redirection in command strings (for example `> /dev/null 2>&1`) is not + * cross-platform and can fail on Windows hooks. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { normalizeToLf } = require("../lib/quote-parser"); + +function collectProductionScriptFiles(directoryPath) { + const entries = fs.readdirSync(directoryPath, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const absolutePath = path.join(directoryPath, entry.name); + + if (entry.isDirectory()) { + if (entry.name === "__tests__") { + continue; + } + + files.push(...collectProductionScriptFiles(absolutePath)); + continue; + } + + if (!entry.isFile()) { + continue; + } + + if (!entry.name.endsWith(".js")) { + continue; + } + + files.push(absolutePath); + } + + return files; +} + +function findShellRedirectionViolations(filePath) { + const content = normalizeToLf(fs.readFileSync(filePath, "utf8")); + const violations = []; + + const commandLiteralPattern = + /\b(?:execSync|spawnSync|execFileSync)\s*\(\s*(["'`])((?:\\.|(?!\1)[\s\S])*?)\1/g; + const shellRedirectionPattern = /(?:^|\s)(?:>>?|<)\s|2>&1/; + + let match = commandLiteralPattern.exec(content); + while (match !== null) { + const commandLiteral = match[2]; + if (shellRedirectionPattern.test(commandLiteral)) { + const line = content.slice(0, match.index).split("\n").length; + const source = match[0].split("\n")[0].trim(); + + violations.push({ + line, + source, + }); + } + + match = commandLiteralPattern.exec(content); + } + + return violations; +} + +function findNonPortableNpmCommandViolations(filePath) { + const content = normalizeToLf(fs.readFileSync(filePath, "utf8")); + const violations = []; + + const directNpmPattern = + /\b(?:spawnSync|execFileSync|execSync)\s*\(\s*(["'`])(npm|npx)\1/g; + const legacyShellWrapperPattern = + /\b(?:spawnSync|execFileSync|execSync)\s*\(\s*toShellCommand\s*\(/g; + + let match = directNpmPattern.exec(content); + while (match !== null) { + const line = content.slice(0, match.index).split("\n").length; + const source = match[0].split("\n")[0].trim(); + + violations.push({ + line, + source, + reason: "direct-npm-command", + }); + + match = directNpmPattern.exec(content); + } + + match = legacyShellWrapperPattern.exec(content); + while (match !== null) { + const line = content.slice(0, match.index).split("\n").length; + const source = match[0].split("\n")[0].trim(); + + violations.push({ + line, + source, + reason: "legacy-shell-wrapper", + }); + + match = legacyShellWrapperPattern.exec(content); + } + + return violations; +} + +describe("detect-shell-redirection-antipattern", () => { + test("production scripts avoid shell redirection in child_process command strings", () => { + const scriptsRoot = path.resolve(__dirname, ".."); + const scriptFiles = collectProductionScriptFiles(scriptsRoot); + const failures = []; + + for (const filePath of scriptFiles) { + const violations = [ + ...findShellRedirectionViolations(filePath).map((violation) => ({ + ...violation, + reason: "shell-redirection", + })), + ...findNonPortableNpmCommandViolations(filePath), + ]; + if (violations.length === 0) { + continue; + } + + failures.push({ + filePath: path.relative(path.resolve(__dirname, "../.."), filePath), + violations, + }); + } + + if (failures.length > 0) { + const details = failures + .map((failure) => { + const lines = failure.violations + .map( + (violation) => + ` line ${violation.line} [${violation.reason}]: ${violation.source}` + ) + .join("\n"); + return ` ${failure.filePath}\n${lines}`; + }) + .join("\n"); + + throw new Error( + `Found shell command portability violations in production scripts:\n${details}\n` + + "Use child_process argument arrays with stdio options and call spawnPlatformCommandSync() for npm/npx invocations." + ); + } + }); +}); diff --git a/scripts/__tests__/detect-shell-redirection-antipattern.test.js.meta b/scripts/__tests__/detect-shell-redirection-antipattern.test.js.meta new file mode 100644 index 00000000..3060a624 --- /dev/null +++ b/scripts/__tests__/detect-shell-redirection-antipattern.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2b175f61a73bd6f4c981e894cff3306d +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/generate-skills-index.test.js b/scripts/__tests__/generate-skills-index.test.js index 0d8f28a7..4f14bec9 100644 --- a/scripts/__tests__/generate-skills-index.test.js +++ b/scripts/__tests__/generate-skills-index.test.js @@ -10,13 +10,17 @@ "use strict"; +const childProcess = require("child_process"); + const { applyBrandCapitalization, categoryToTitle, + formatWithPrettier, parseFrontmatter, BRAND_NAMES, } = require('../generate-skills-index.js'); const { normalizeToLf } = require('../lib/quote-parser'); +const { toShellCommand } = require('../lib/shell-command'); describe("generate-skills-index", () => { describe("BRAND_NAMES mapping", () => { @@ -374,6 +378,84 @@ describe("generate-skills-index", () => { }); }); + describe("formatWithPrettier", () => { + test("invokes prettier via platform-aware shell command helper", () => { + const spawnSyncMock = jest.fn(() => ({ + status: 0, + stdout: "formatted output", + stderr: "", + })); + + const result = formatWithPrettier("raw input", spawnSyncMock); + + expect(result).toBe("formatted output"); + expect(spawnSyncMock).toHaveBeenCalledTimes(1); + const [command, args, options] = spawnSyncMock.mock.calls[0]; + expect(command).toBe("npx"); + expect(args[0]).toBe("--yes"); + expect(args[1].startsWith("--package=prettier@")).toBe(true); + expect(args[2]).toBe("prettier"); + expect(args).toContain("--stdin-filepath"); + expect(options).toEqual( + expect.objectContaining({ + input: "raw input", + encoding: "utf8", + cwd: expect.any(String), + }) + ); + }); + + test("throws when prettier execution fails", () => { + const spawnSyncMock = jest.fn(() => ({ + status: 1, + stdout: "", + stderr: "boom", + })); + + expect(() => formatWithPrettier("raw", spawnSyncMock)).toThrow("Prettier failed: boom"); + }); + + test("rethrows child process launch errors", () => { + const launchError = new Error("spawn failed"); + const spawnSyncMock = jest.fn(() => ({ + error: launchError, + status: null, + stdout: "", + stderr: "", + })); + + expect(() => formatWithPrettier("raw", spawnSyncMock)).toThrow("spawn failed"); + }); + + test("default invocation resolves npx via platform-aware spawn helper", () => { + const spawnSyncSpy = jest + .spyOn(childProcess, "spawnSync") + .mockReturnValue({ status: 0, stdout: "formatted output", stderr: "" }); + + const result = formatWithPrettier("raw input"); + + const expectedOptions = { + input: "raw input", + encoding: "utf8", + cwd: expect.any(String), + }; + + if (process.platform === "win32") { + expectedOptions.shell = true; + expectedOptions.windowsHide = true; + } + + expect(result).toBe("formatted output"); + expect(spawnSyncSpy).toHaveBeenCalledWith( + toShellCommand("npx"), + expect.arrayContaining(["--yes", "prettier", "--stdin-filepath"]), + expect.objectContaining(expectedOptions) + ); + + spawnSyncSpy.mockRestore(); + }); + }); + describe("normalizeToLf", () => { describe("line ending conversions", () => { test.each([ diff --git a/scripts/__tests__/pre-commit-hook-stage-policy.test.js b/scripts/__tests__/pre-commit-hook-stage-policy.test.js new file mode 100644 index 00000000..3eaf162b --- /dev/null +++ b/scripts/__tests__/pre-commit-hook-stage-policy.test.js @@ -0,0 +1,123 @@ +/** + * @fileoverview Validates required hook stage and coverage policies in .pre-commit-config.yaml. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { normalizeToLf } = require("../lib/quote-parser"); + +function getIndent(line) { + return line.length - line.trimStart().length; +} + +function findHookBlock(lines, hookId) { + let startIndex = -1; + let hookIndent = -1; + + for (let i = 0; i < lines.length; i++) { + const idMatch = /^(\s*)-\s+id:\s*([^\s#]+)\s*$/.exec(lines[i]); + if (!idMatch || idMatch[2].trim() !== hookId) { + continue; + } + + startIndex = i; + hookIndent = idMatch[1].length; + break; + } + + if (startIndex === -1) { + return null; + } + + let endIndex = lines.length; + for (let i = startIndex + 1; i < lines.length; i++) { + const idMatch = /^(\s*)-\s+id:\s*([^\s#]+)\s*$/.exec(lines[i]); + if (!idMatch) { + continue; + } + + if (idMatch[1].length === hookIndent) { + endIndex = i; + break; + } + } + + return { + startLine: startIndex + 1, + lines: lines.slice(startIndex, endIndex), + }; +} + +function extractStagesFromHookBlock(hookBlock) { + if (!hookBlock) { + return []; + } + + const stages = []; + + for (let i = 0; i < hookBlock.lines.length; i++) { + const stagesMatch = /^(\s*)stages:\s*$/.exec(hookBlock.lines[i]); + if (!stagesMatch) { + continue; + } + + const stagesIndent = stagesMatch[1].length; + + for (let j = i + 1; j < hookBlock.lines.length; j++) { + const line = hookBlock.lines[j]; + if (!line.trim()) { + continue; + } + + const indent = getIndent(line); + if (indent <= stagesIndent) { + break; + } + + const stageMatch = /^\s*-\s*([^\s#]+)\s*$/.exec(line); + if (stageMatch) { + stages.push(stageMatch[1].trim()); + } + } + + break; + } + + return stages; +} + +describe("pre-commit hook stage policy", () => { + const configPath = path.resolve(__dirname, "../../.pre-commit-config.yaml"); + const configContent = normalizeToLf(fs.readFileSync(configPath, "utf8")); + const configLines = configContent.split("\n"); + + test("cspell hook runs at pre-commit and pre-push", () => { + const cspellBlock = findHookBlock(configLines, "cspell"); + expect(cspellBlock).not.toBeNull(); + + const stages = extractStagesFromHookBlock(cspellBlock); + expect(stages).toEqual(expect.arrayContaining(["pre-commit", "pre-push"])); + }); + + test("validate-npm-meta hook runs at pre-commit and pre-push", () => { + const npmMetaBlock = findHookBlock(configLines, "validate-npm-meta"); + expect(npmMetaBlock).not.toBeNull(); + + const stages = extractStagesFromHookBlock(npmMetaBlock); + expect(stages).toEqual(expect.arrayContaining(["pre-commit", "pre-push"])); + }); + + test("script-parser-tests includes npm-meta and shell-safety regressions", () => { + const parserTestsBlock = findHookBlock(configLines, "script-parser-tests"); + expect(parserTestsBlock).not.toBeNull(); + + const blockText = parserTestsBlock.lines.join("\n"); + expect(blockText).toContain("scripts/__tests__/validate-npm-meta.test.js"); + expect(blockText).toContain("scripts/__tests__/run-managed-prettier.test.js"); + expect(blockText).toContain("scripts/__tests__/prettier-version.test.js"); + expect(blockText).toContain("scripts/__tests__/shell-command.test.js"); + expect(blockText).toContain("scripts/__tests__/detect-shell-redirection-antipattern.test.js"); + }); +}); diff --git a/scripts/__tests__/pre-commit-hook-stage-policy.test.js.meta b/scripts/__tests__/pre-commit-hook-stage-policy.test.js.meta new file mode 100644 index 00000000..fa8b0168 --- /dev/null +++ b/scripts/__tests__/pre-commit-hook-stage-policy.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5410853d7274f1b42b461741e4bc78fe +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/prettier-version.test.js b/scripts/__tests__/prettier-version.test.js new file mode 100644 index 00000000..6e1f312c --- /dev/null +++ b/scripts/__tests__/prettier-version.test.js @@ -0,0 +1,133 @@ +/** + * @fileoverview Tests for scripts/lib/prettier-version.js. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const { + FALLBACK_PRETTIER_SPEC, + normalizePinnedVersion, + getConfiguredPrettierSpec, + getPinnedFallbackPrettierSpec, + getPinnedPrettierSpec, +} = require("../lib/prettier-version"); + +function createReadFileSyncStub({ packageJson, packageLock } = {}) { + return (filePath) => { + if (filePath.endsWith("package.json")) { + if (packageJson === undefined) { + throw new Error("package.json unavailable"); + } + return packageJson; + } + + if (filePath.endsWith("package-lock.json")) { + if (packageLock === undefined) { + throw new Error("package-lock.json unavailable"); + } + return packageLock; + } + + throw new Error(`Unexpected path: ${filePath}`); + }; +} + +describe("prettier-version", () => { + test("normalizePinnedVersion strips supported semver prefixes", () => { + expect(normalizePinnedVersion("^3.8.3")).toBe("3.8.3"); + expect(normalizePinnedVersion("~3.8.3")).toBe("3.8.3"); + expect(normalizePinnedVersion("3.8.3")).toBe("3.8.3"); + }); + + test("normalizePinnedVersion rejects non-semver strings", () => { + expect(normalizePinnedVersion("latest")).toBeNull(); + expect(normalizePinnedVersion("^3.8")).toBeNull(); + expect(normalizePinnedVersion(null)).toBeNull(); + }); + + test("getConfiguredPrettierSpec reads package.json devDependency", () => { + const readFileSyncFn = createReadFileSyncStub({ + packageJson: JSON.stringify({ + devDependencies: { + prettier: "^3.8.3", + }, + }), + }); + + expect(getConfiguredPrettierSpec(readFileSyncFn)).toBe("prettier@3.8.3"); + }); + + test("getConfiguredPrettierSpec returns null when prettier is missing", () => { + const readFileSyncFn = createReadFileSyncStub({ + packageJson: JSON.stringify({ devDependencies: {} }), + }); + + expect(getConfiguredPrettierSpec(readFileSyncFn)).toBeNull(); + }); + + test("getPinnedFallbackPrettierSpec prefers lockfile version", () => { + const readFileSyncFn = createReadFileSyncStub({ + packageLock: JSON.stringify({ + packages: { + "node_modules/prettier": { + version: "3.8.4", + }, + }, + }), + }); + + expect(getPinnedFallbackPrettierSpec(readFileSyncFn)).toBe("prettier@3.8.4"); + }); + + test("getPinnedFallbackPrettierSpec uses fallback when lockfile is invalid", () => { + const readFileSyncFn = createReadFileSyncStub({ + packageLock: "not-json", + }); + + expect(getPinnedFallbackPrettierSpec(readFileSyncFn)).toBe(FALLBACK_PRETTIER_SPEC); + }); + + test("getPinnedPrettierSpec uses lockfile when available", () => { + const readFileSyncFn = createReadFileSyncStub({ + packageJson: JSON.stringify({ + devDependencies: { + prettier: "^3.8.3", + }, + }), + packageLock: JSON.stringify({ + packages: { + "node_modules/prettier": { + version: "3.9.0", + }, + }, + }), + }); + + expect(getPinnedPrettierSpec(readFileSyncFn)).toBe("prettier@3.9.0"); + }); + + test("getPinnedPrettierSpec falls back to configured package version", () => { + const readFileSyncFn = createReadFileSyncStub({ + packageJson: JSON.stringify({ + devDependencies: { + prettier: "~3.8.3", + }, + }), + packageLock: "not-json", + }); + + expect(getPinnedPrettierSpec(readFileSyncFn)).toBe("prettier@3.8.3"); + }); + + test("getPinnedPrettierSpec matches repository package.json configuration", () => { + const packageJsonPath = path.resolve(__dirname, "../../package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + const configuredVersion = normalizePinnedVersion(packageJson.devDependencies.prettier); + + expect(configuredVersion).toBeTruthy(); + expect(getPinnedPrettierSpec()).toBe(`prettier@${configuredVersion}`); + }); +}); diff --git a/scripts/__tests__/run-managed-jest.test.js.meta b/scripts/__tests__/run-managed-jest.test.js.meta new file mode 100644 index 00000000..89ef062a --- /dev/null +++ b/scripts/__tests__/run-managed-jest.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8d996b3dcd484f04ea45ec8ea038685b +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/run-managed-prettier.test.js b/scripts/__tests__/run-managed-prettier.test.js new file mode 100644 index 00000000..6e015483 --- /dev/null +++ b/scripts/__tests__/run-managed-prettier.test.js @@ -0,0 +1,93 @@ +/** + * @fileoverview Tests for run-managed-prettier.js. + */ + +"use strict"; + +const childProcess = require("child_process"); +const { toShellCommand } = require("../lib/shell-command"); +const { + REPO_ROOT, + runCommand, + runNpxPrettier, + runManagedPrettier, +} = require("../run-managed-prettier"); + +describe("run-managed-prettier", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("runManagedPrettier prefers local prettier when available", () => { + const runLocalPrettierFn = jest.fn(() => ({ status: 0, error: null })); + const runNpxPrettierFn = jest.fn(() => ({ status: 1, error: null })); + + const result = runManagedPrettier(["--check", "README.md"], { + existsSyncFn: () => true, + runLocalPrettierFn, + runNpxPrettierFn, + }); + + expect(result).toEqual({ status: 0, error: null }); + expect(runLocalPrettierFn).toHaveBeenCalledWith(["--check", "README.md"]); + expect(runNpxPrettierFn).not.toHaveBeenCalled(); + }); + + test("runManagedPrettier falls back to npx when local prettier is missing", () => { + const runLocalPrettierFn = jest.fn(() => ({ status: 0, error: null })); + const runNpxPrettierFn = jest.fn(() => ({ status: 0, error: null })); + + const result = runManagedPrettier(["--write", "README.md"], { + existsSyncFn: () => false, + runLocalPrettierFn, + runNpxPrettierFn, + }); + + expect(result).toEqual({ status: 0, error: null }); + expect(runLocalPrettierFn).not.toHaveBeenCalled(); + expect(runNpxPrettierFn).toHaveBeenCalledWith(["--write", "README.md"]); + }); + + test("runNpxPrettier invokes npx with pinned package spec", () => { + const spawnSyncSpy = jest + .spyOn(childProcess, "spawnSync") + .mockReturnValue({ status: 0, error: null }); + + const result = runNpxPrettier(["--check", "README.md"], "prettier@3.8.3"); + + const expectedOptions = { cwd: REPO_ROOT, stdio: "inherit" }; + if (process.platform === "win32") { + expectedOptions.shell = true; + expectedOptions.windowsHide = true; + } + + expect(result).toEqual({ status: 0, error: null }); + expect(spawnSyncSpy).toHaveBeenCalledWith( + toShellCommand("npx"), + [ + "--yes", + "--package=prettier@3.8.3", + "prettier", + "--check", + "README.md", + ], + expect.objectContaining(expectedOptions) + ); + }); + + test("runCommand delegates non-shell-shim commands to child_process.spawnSync", () => { + const spawnSyncSpy = jest + .spyOn(childProcess, "spawnSync") + .mockReturnValue({ status: 0, error: null }); + + const result = runCommand(process.execPath, ["tool.js"]); + + expect(result).toEqual({ status: 0, error: null }); + expect(spawnSyncSpy).toHaveBeenCalledWith( + process.execPath, + ["tool.js"], + expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + ); + spawnSyncSpy.mockRestore(); + }); +}); diff --git a/scripts/__tests__/shell-command.test.js b/scripts/__tests__/shell-command.test.js new file mode 100644 index 00000000..cdcb1c19 --- /dev/null +++ b/scripts/__tests__/shell-command.test.js @@ -0,0 +1,74 @@ +/** + * @fileoverview Tests for scripts/lib/shell-command.js. + */ + +"use strict"; + +const { + toShellCommand, + isShellShimCommand, + resolveSpawnCommand, + resolveSpawnOptions, + spawnPlatformCommandSync, +} = require("../lib/shell-command"); + +describe("shell-command", () => { + test("toShellCommand returns .cmd wrappers on win32", () => { + expect(toShellCommand("npm", "win32")).toBe("npm.cmd"); + expect(toShellCommand("npx", "win32")).toBe("npx.cmd"); + expect(toShellCommand("npm", "linux")).toBe("npm"); + }); + + test("isShellShimCommand identifies npm/npx", () => { + expect(isShellShimCommand("npm")).toBe(true); + expect(isShellShimCommand("npx")).toBe(true); + expect(isShellShimCommand("git")).toBe(false); + }); + + test("resolveSpawnCommand maps npm shim commands on win32", () => { + expect(resolveSpawnCommand("npm", "win32")).toBe("npm.cmd"); + expect(resolveSpawnCommand("npx", "win32")).toBe("npx.cmd"); + expect(resolveSpawnCommand("git", "win32")).toBe("git"); + expect(resolveSpawnCommand("npm", "linux")).toBe("npm"); + }); + + test("resolveSpawnOptions enforces shell mode for npm/npx on win32", () => { + const options = resolveSpawnOptions("npm", { cwd: "C:/repo", shell: false }, "win32"); + + expect(options).toEqual( + expect.objectContaining({ + cwd: "C:/repo", + shell: true, + windowsHide: true, + }) + ); + }); + + test("resolveSpawnOptions leaves non-shim commands unchanged", () => { + const options = resolveSpawnOptions("git", { cwd: "/repo", stdio: "pipe" }, "win32"); + + expect(options).toEqual( + expect.objectContaining({ + cwd: "/repo", + stdio: "pipe", + }) + ); + expect(options.shell).toBeUndefined(); + }); + + test("spawnPlatformCommandSync delegates with resolved command/options", () => { + const spawnSyncMock = jest.fn(() => ({ status: 0, stdout: "", stderr: "" })); + + spawnPlatformCommandSync("npm", ["--version"], { cwd: "C:/repo" }, spawnSyncMock, "win32"); + + expect(spawnSyncMock).toHaveBeenCalledWith( + "npm.cmd", + ["--version"], + expect.objectContaining({ + cwd: "C:/repo", + shell: true, + windowsHide: true, + }) + ); + }); +}); diff --git a/scripts/__tests__/validate-npm-meta.test.js b/scripts/__tests__/validate-npm-meta.test.js index c65a97f0..9990837c 100644 --- a/scripts/__tests__/validate-npm-meta.test.js +++ b/scripts/__tests__/validate-npm-meta.test.js @@ -10,13 +10,22 @@ "use strict"; +const childProcess = require("child_process"); +const { toShellCommand } = require("../lib/shell-command"); + const { + getPackageFiles, + parseNpmPackJsonOutput, parseTarListingOutput, validateMetaFilesHaveTargets, validateFilesHaveMetaFiles, -} = require('../validate-npm-meta.js'); +} = require("../validate-npm-meta.js"); describe("validate-npm-meta", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + describe("parseTarListingOutput", () => { test("parses package paths with LF line endings", () => { const tarOutput = [ @@ -41,6 +50,126 @@ describe("validate-npm-meta", () => { }); }); + describe("parseNpmPackJsonOutput", () => { + test("parses npm pack --json files with object entries", () => { + const packOutput = JSON.stringify([ + { + files: [ + { path: "Runtime/File.cs" }, + { path: "Runtime/File.cs.meta" }, + ], + }, + ]); + + const files = parseNpmPackJsonOutput(packOutput); + expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); + }); + + test("parses npm pack --json files with string entries", () => { + const packOutput = JSON.stringify([ + { + files: ["Runtime/File.cs", "Runtime/File.cs.meta"], + }, + ]); + + const files = parseNpmPackJsonOutput(packOutput); + expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); + }); + + test("parses npm pack JSON output with CRLF and surrounding whitespace", () => { + const packOutput = + "\r\n" + + JSON.stringify([ + { + files: [ + { path: "Runtime/File.cs" }, + { path: "Runtime/File.cs.meta" }, + ], + }, + ]) + + "\r\n"; + + const files = parseNpmPackJsonOutput(packOutput); + expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); + }); + + test("throws when npm pack output is not valid JSON", () => { + expect(() => parseNpmPackJsonOutput("not-json")).toThrow( + "Unable to parse npm pack --json output" + ); + }); + + test("throws when npm pack output does not include files", () => { + const packOutput = JSON.stringify([ + { + name: "com.wallstop-studios.dxmessaging", + }, + ]); + + expect(() => parseNpmPackJsonOutput(packOutput)).toThrow( + "did not include a files list" + ); + }); + }); + + describe("getPackageFiles", () => { + test("uses cross-platform npm pack invocation and returns file list", () => { + const spawnSyncSpy = jest.spyOn(childProcess, "spawnSync").mockReturnValue({ + status: 0, + stdout: JSON.stringify([ + { + files: [ + { path: "Runtime/File.cs" }, + { path: "Runtime/File.cs.meta" }, + ], + }, + ]), + stderr: "", + }); + + const files = getPackageFiles(); + + expect(files).toEqual(["Runtime/File.cs", "Runtime/File.cs.meta"]); + expect(spawnSyncSpy).toHaveBeenCalledWith( + toShellCommand("npm"), + ["pack", "--json", "--dry-run"], + expect.objectContaining({ + cwd: expect.any(String), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }) + ); + }); + + test("uses npm.cmd command name on win32", () => { + expect(toShellCommand("npm", "win32")).toBe("npm.cmd"); + expect(toShellCommand("npx", "win32")).toBe("npx.cmd"); + }); + + test("throws when npm pack exits with non-zero status", () => { + jest.spyOn(childProcess, "spawnSync").mockReturnValue({ + status: 1, + stdout: "", + stderr: "simulated failure", + }); + + expect(() => getPackageFiles()).toThrow( + "npm pack --json --dry-run failed with exit code 1" + ); + }); + + test("throws when npm process spawn fails", () => { + jest.spyOn(childProcess, "spawnSync").mockReturnValue({ + error: new Error("spawn failed"), + status: null, + stdout: "", + stderr: "", + }); + + expect(() => getPackageFiles()).toThrow("spawn failed"); + }); + }); + describe("validateMetaFilesHaveTargets", () => { test("should pass when all .meta files have corresponding files", () => { const files = [ @@ -166,6 +295,21 @@ describe("validate-npm-meta", () => { expect(result.errors).toHaveLength(0); }); + test("should allow .github, .git, and node_modules paths without .meta", () => { + const files = [ + ".github/workflows/build.yml", + ".git/HEAD", + "node_modules/some-package/index.js", + "Runtime/Core/MessageHandler.cs", + "Runtime/Core/MessageHandler.cs.meta", + ]; + + const result = validateFilesHaveMetaFiles(files); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + test("should detect multiple missing .meta files", () => { const files = [ "Runtime/Core/File1.cs", diff --git a/scripts/__tests__/validate-pre-commit-tooling.test.js b/scripts/__tests__/validate-pre-commit-tooling.test.js index 34f71d72..b1dbfd83 100644 --- a/scripts/__tests__/validate-pre-commit-tooling.test.js +++ b/scripts/__tests__/validate-pre-commit-tooling.test.js @@ -13,7 +13,9 @@ const { parseHookIds, hasNpxInstallPolicy, hasManagedJestInvocation, + hasManagedPrettierInvocation, validateYamllintPolicy, + validatePrettierVersionResolution, validateConfigContent, validateConfigFile, } = require("../validate-pre-commit-tooling.js"); @@ -89,6 +91,12 @@ describe("validate-pre-commit-tooling", () => { expect(hasManagedJestInvocation("script-tests", "node scripts/run-managed-jest.js")).toBe(true); }); + test("hasManagedPrettierInvocation requires managed prettier wrapper for prettier hook", () => { + expect(hasManagedPrettierInvocation("prettier", "npx --yes prettier@3.8.3 --write")).toBe(false); + expect(hasManagedPrettierInvocation("prettier", "node scripts/run-managed-prettier.js --write")).toBe(true); + expect(hasManagedPrettierInvocation("other-hook", "npx --yes prettier@3.8.3 --write")).toBe(true); + }); + test("validateConfigContent reports missing npx policy and unmanaged jest", () => { const content = [ "repos:", @@ -148,6 +156,37 @@ describe("validate-pre-commit-tooling", () => { ).toBe(true); }); + test("validatePrettierVersionResolution passes when configured and resolved specs match", () => { + const violations = validatePrettierVersionResolution( + () => "prettier@3.8.3", + () => "prettier@3.8.3" + ); + + expect(violations).toHaveLength(0); + }); + + test("validatePrettierVersionResolution reports mismatch between configured and resolved specs", () => { + const violations = validatePrettierVersionResolution( + () => "prettier@3.8.3", + () => "prettier@3.9.0" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("prettier-version"); + expect(violations[0].message).toContain("must match package.json"); + }); + + test("validatePrettierVersionResolution reports missing configured spec", () => { + const violations = validatePrettierVersionResolution( + () => null, + () => "prettier@3.8.3" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("prettier-version"); + expect(violations[0].message).toContain("Missing pinned prettier version"); + }); + test("validateConfigFile passes for repository pre-commit config", () => { const repoConfigPath = path.resolve(__dirname, "../../.pre-commit-config.yaml"); const configContent = fs.readFileSync(repoConfigPath, "utf8"); @@ -158,16 +197,44 @@ describe("validate-pre-commit-tooling", () => { expect(violations).toHaveLength(0); }); - test("package preflight script includes YAML validation gate", () => { + test("validateConfigContent reports unmanaged prettier hook", () => { + const content = [ + "repos:", + " - repo: https://github.com/adrienverge/yamllint", + " rev: v1.38.0", + " hooks:", + " - id: yamllint", + " args: [-c, .yamllint.yaml]", + " - repo: local", + " hooks:", + " - id: prettier", + " entry: npx --yes prettier@3.8.3 --write", + ].join("\n"); + + const violations = validateConfigContent(content); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("prettier"); + expect(violations[0].message).toContain("run-managed-prettier.js"); + }); + + test("package preflight script includes YAML, runtime, and portability gates", () => { const packageJsonPath = path.resolve(__dirname, "../../package.json"); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + const preflightScript = packageJson.scripts["preflight:pre-commit"]; + expect(packageJson.scripts["check:prettier:hooks"]).toContain( + "node scripts/run-managed-prettier.js --check" + ); + expect(preflightScript).toContain("npm run check:prettier:hooks"); expect(packageJson.scripts["check:yaml"]).toContain( "pre-commit run yamllint --all-files" ); - expect(packageJson.scripts["preflight:pre-commit"]).toContain( - "npm run check:yaml" - ); + expect(preflightScript).toContain("npm run check:yaml"); + expect(preflightScript).toContain("node scripts/generate-skills-index.js --check"); + expect(preflightScript).toContain("npm run validate:npm-meta"); + expect(preflightScript).toContain("scripts/__tests__/generate-skills-index.test.js"); + expect(preflightScript).toContain("scripts/__tests__/shell-command.test.js"); }); test("validateConfigFile handles CRLF and lone CR line endings", () => { diff --git a/scripts/__tests__/validate-pre-commit-tooling.test.js.meta b/scripts/__tests__/validate-pre-commit-tooling.test.js.meta new file mode 100644 index 00000000..3b94b4ee --- /dev/null +++ b/scripts/__tests__/validate-pre-commit-tooling.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 72ea7c6a2c906f44ba521725349fd155 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/verify-managed-jest-fallback.test.js.meta b/scripts/__tests__/verify-managed-jest-fallback.test.js.meta new file mode 100644 index 00000000..027c1264 --- /dev/null +++ b/scripts/__tests__/verify-managed-jest-fallback.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8db7c6d8d6ff3234da31448319c534a5 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/generate-skills-index.js b/scripts/generate-skills-index.js index 9e1d4cfc..caba70c7 100644 --- a/scripts/generate-skills-index.js +++ b/scripts/generate-skills-index.js @@ -24,18 +24,18 @@ const fs = require("fs"); const path = require("path"); -const { spawnSync } = require("child_process"); const { stripMatchingBoundaryQuotes, normalizeToLf, } = require("./lib/quote-parser"); +const { spawnPlatformCommandSync } = require("./lib/shell-command"); +const { getPinnedPrettierSpec } = require("./lib/prettier-version"); const SKILLS_DIR = path.join(__dirname, "..", ".llm", "skills"); const INDEX_PATH = path.join(SKILLS_DIR, "index.md"); const EXCLUDED_FILES = ["index.md", "specification.md"]; const EXCLUDED_DIRS = ["templates"]; const REPO_ROOT = path.join(__dirname, ".."); -const PRETTIER_VERSION = "3.8.1"; /** * Brand name capitalization mapping. @@ -123,19 +123,17 @@ function getLatestSkillDate(skills) { return new Date().toISOString().split("T")[0]; } -function formatWithPrettier(content) { +function formatWithPrettier(content, spawnSyncImpl = spawnPlatformCommandSync) { + const prettierSpec = getPinnedPrettierSpec(); const prettierArgs = [ "--yes", - `prettier@${PRETTIER_VERSION}`, + `--package=${prettierSpec}`, + "prettier", "--stdin-filepath", INDEX_PATH, ]; - const isWindows = process.platform === "win32"; - const command = isWindows ? "cmd.exe" : "npx"; - const args = isWindows ? ["/d", "/s", "/c", "npx", ...prettierArgs] : prettierArgs; - - const result = spawnSync(command, args, { + const result = spawnSyncImpl("npx", prettierArgs, { cwd: REPO_ROOT, input: content, encoding: "utf8", @@ -532,6 +530,7 @@ if (typeof module !== 'undefined' && module.exports) { module.exports = { applyBrandCapitalization, categoryToTitle, + formatWithPrettier, parseFrontmatter, BRAND_NAMES, }; diff --git a/scripts/lib/prettier-version.js b/scripts/lib/prettier-version.js new file mode 100644 index 00000000..10f8f4e6 --- /dev/null +++ b/scripts/lib/prettier-version.js @@ -0,0 +1,78 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const REPO_ROOT = path.join(__dirname, "..", ".."); +const PACKAGE_JSON_PATH = path.join(REPO_ROOT, "package.json"); +const PACKAGE_LOCK_PATH = path.join(REPO_ROOT, "package-lock.json"); +const FALLBACK_PRETTIER_SPEC = "prettier@3.8.3"; + +function normalizePinnedVersion(version) { + if (typeof version !== "string") { + return null; + } + + const trimmedVersion = version.trim().replace(/^[~^]/, ""); + if (!/^\d+\.\d+\.\d+$/.test(trimmedVersion)) { + return null; + } + + return trimmedVersion; +} + +function getConfiguredPrettierSpec(readFileSyncFn = fs.readFileSync) { + try { + const packageJson = JSON.parse(readFileSyncFn(PACKAGE_JSON_PATH, "utf8")); + const configuredVersion = normalizePinnedVersion( + packageJson && packageJson.devDependencies && packageJson.devDependencies.prettier + ); + + if (configuredVersion) { + return `prettier@${configuredVersion}`; + } + } catch { + // Fall through to runtime fallback. + } + + return null; +} + +function getPinnedFallbackPrettierSpec( + readFileSyncFn = fs.readFileSync, + fallbackSpec = FALLBACK_PRETTIER_SPEC +) { + try { + const packageLock = JSON.parse(readFileSyncFn(PACKAGE_LOCK_PATH, "utf8")); + const lockfileVersion = normalizePinnedVersion( + packageLock && + packageLock.packages && + packageLock.packages["node_modules/prettier"] && + packageLock.packages["node_modules/prettier"].version + ); + + if (lockfileVersion) { + return `prettier@${lockfileVersion}`; + } + } catch { + // Fall through to static fallback when lockfile is unavailable or malformed. + } + + return fallbackSpec; +} + +function getPinnedPrettierSpec(readFileSyncFn = fs.readFileSync) { + const configuredSpec = getConfiguredPrettierSpec(readFileSyncFn); + return getPinnedFallbackPrettierSpec(readFileSyncFn, configuredSpec || FALLBACK_PRETTIER_SPEC); +} + +module.exports = { + REPO_ROOT, + PACKAGE_JSON_PATH, + PACKAGE_LOCK_PATH, + FALLBACK_PRETTIER_SPEC, + normalizePinnedVersion, + getConfiguredPrettierSpec, + getPinnedFallbackPrettierSpec, + getPinnedPrettierSpec, +}; diff --git a/scripts/lib/shell-command.js b/scripts/lib/shell-command.js new file mode 100644 index 00000000..4b8bac9a --- /dev/null +++ b/scripts/lib/shell-command.js @@ -0,0 +1,100 @@ +"use strict"; + +const childProcess = require("child_process"); + +/** + * Convert a command name to the platform-specific executable name. + * npm/npx on Windows are exposed as .cmd shims. + * + * This helper only adjusts the command token. Use spawnPlatformCommandSync() + * for child_process execution so Windows shell requirements are applied. + * @deprecated For child_process calls, use spawnPlatformCommandSync(). + * + * @param {string} command - Base command name (for example "npm") + * @param {string} platform - Process platform string + * @returns {string} Command adjusted for platform execution + */ +function toShellCommand(command, platform = process.platform) { + return platform === "win32" ? `${command}.cmd` : command; +} + +/** + * Determine whether a command uses Windows shell shims. + * + * @param {string} command - Base command name + * @returns {boolean} True when command is npm/npx + */ +function isShellShimCommand(command) { + return command === "npm" || command === "npx"; +} + +/** + * Resolve a platform-aware command token for spawn-style execution. + * + * @param {string} command - Base command name + * @param {string} platform - Process platform string + * @returns {string} Resolved command name + */ +function resolveSpawnCommand(command, platform = process.platform) { + if (platform === "win32" && isShellShimCommand(command)) { + return toShellCommand(command, platform); + } + + return command; +} + +/** + * Resolve platform-aware spawn options. + * + * Windows npm/npx shims are batch files and must run with shell enabled. + * Keep this logic centralized so callers cannot forget it. + * + * @param {string} command - Base command name + * @param {object} options - Existing spawn options + * @param {string} platform - Process platform string + * @returns {object} Resolved spawn options + */ +function resolveSpawnOptions(command, options = {}, platform = process.platform) { + const resolvedOptions = { ...options }; + + if (platform === "win32" && isShellShimCommand(command)) { + resolvedOptions.shell = true; + + if (resolvedOptions.windowsHide === undefined) { + resolvedOptions.windowsHide = true; + } + } + + return resolvedOptions; +} + +/** + * Spawn a platform-aware child process. + * + * @param {string} command - Base command name + * @param {string[]} args - Command arguments + * @param {object} options - spawnSync options + * @param {Function} spawnSyncImpl - Optional spawnSync implementation for tests + * @param {string} platform - Process platform string + * @returns {object} spawnSync result object + */ +function spawnPlatformCommandSync( + command, + args = [], + options = {}, + spawnSyncImpl = childProcess.spawnSync, + platform = process.platform +) { + const resolvedCommand = resolveSpawnCommand(command, platform); + const resolvedOptions = resolveSpawnOptions(command, options, platform); + + return spawnSyncImpl(resolvedCommand, args, resolvedOptions); +} + +module.exports = { + toShellCommand, + isShellShimCommand, + resolveSpawnCommand, + resolveSpawnOptions, + spawnPlatformCommandSync, +}; diff --git a/scripts/lib/shell-command.js.meta b/scripts/lib/shell-command.js.meta new file mode 100644 index 00000000..6df4174c --- /dev/null +++ b/scripts/lib/shell-command.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4c846d99c9b151b478841056b352c285 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/run-managed-jest.js b/scripts/run-managed-jest.js index abf88af9..06a81889 100644 --- a/scripts/run-managed-jest.js +++ b/scripts/run-managed-jest.js @@ -14,6 +14,11 @@ const fs = require("fs"); const path = require("path"); const childProcess = require("child_process"); const { createRequire } = require("module"); +const { + toShellCommand, + isShellShimCommand, + spawnPlatformCommandSync, +} = require("./lib/shell-command"); const REPO_ROOT = path.join(__dirname, ".."); const REPO_NODE_MODULES = path.join(REPO_ROOT, "node_modules"); @@ -22,10 +27,6 @@ const LOCAL_JEST_BIN = path.join(REPO_ROOT, "node_modules", "jest", "bin", "jest const FALLBACK_JEST_SPEC = "jest@30.3.0"; const REPO_REQUIRE = createRequire(path.join(REPO_ROOT, "package.json")); -function toShellCommand(command, platform = process.platform) { - return platform === "win32" ? `${command}.cmd` : command; -} - function parseNpmMajorVersion(versionText) { if (typeof versionText !== "string") { return null; @@ -40,7 +41,7 @@ function parseNpmMajorVersion(versionText) { } function getNpmMajorVersion() { - const result = childProcess.spawnSync(toShellCommand("npm"), ["--version"], { + const result = spawnPlatformCommandSync("npm", ["--version"], { cwd: REPO_ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], @@ -54,7 +55,11 @@ function getNpmMajorVersion() { } function runCommand(command, args) { - const result = childProcess.spawnSync(command, args, { + const spawnSyncImpl = isShellShimCommand(command) + ? spawnPlatformCommandSync + : childProcess.spawnSync; + + const result = spawnSyncImpl(command, args, { cwd: REPO_ROOT, stdio: "inherit", }); @@ -88,7 +93,7 @@ function getPinnedFallbackJestSpec( function runNpmExecJest(args) { const jestSpec = getPinnedFallbackJestSpec(); - return runCommand(toShellCommand("npm"), [ + return runCommand("npm", [ "exec", "--yes", `--package=${jestSpec}`, @@ -100,7 +105,7 @@ function runNpmExecJest(args) { function runNpxJest(args) { const jestSpec = getPinnedFallbackJestSpec(); - return runCommand(toShellCommand("npx"), [ + return runCommand("npx", [ "--yes", `--package=${jestSpec}`, "jest", @@ -243,6 +248,8 @@ module.exports = { runLocalJest, runNpmExecJest, runNpxJest, + isShellShimCommand, + spawnPlatformCommandSync, isCommandUnavailable, runManagedJest, printManagedJestLaunchError, diff --git a/scripts/run-managed-jest.js.meta b/scripts/run-managed-jest.js.meta new file mode 100644 index 00000000..dd1455fd --- /dev/null +++ b/scripts/run-managed-jest.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 454a7b4cc398d714b8cd5f29734b7335 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/run-managed-prettier.js b/scripts/run-managed-prettier.js new file mode 100644 index 00000000..e13795f8 --- /dev/null +++ b/scripts/run-managed-prettier.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * run-managed-prettier.js + * + * Runs Prettier in a robust, non-interactive way for hooks and local automation: + * 1) Prefer local devDependency (node_modules/prettier/bin/prettier.cjs). + * 2) If local Prettier is missing, provision a pinned fallback via npx. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const childProcess = require("child_process"); +const { isShellShimCommand, spawnPlatformCommandSync } = require("./lib/shell-command"); +const { getPinnedPrettierSpec } = require("./lib/prettier-version"); + +const REPO_ROOT = path.join(__dirname, ".."); +const LOCAL_PRETTIER_BIN = path.join( + REPO_ROOT, + "node_modules", + "prettier", + "bin", + "prettier.cjs" +); + +function runCommand(command, args, options = {}) { + const spawnSyncImpl = isShellShimCommand(command) + ? spawnPlatformCommandSync + : childProcess.spawnSync; + + const result = spawnSyncImpl(command, args, { + cwd: REPO_ROOT, + stdio: "inherit", + ...options, + }); + + return { + status: result.status, + error: result.error || null, + }; +} + +function runLocalPrettier(args) { + return runCommand(process.execPath, [LOCAL_PRETTIER_BIN, ...args]); +} + +function runNpxPrettier(args, prettierSpec = getPinnedPrettierSpec()) { + return runCommand("npx", [ + "--yes", + `--package=${prettierSpec}`, + "prettier", + ...args, + ]); +} + +function runManagedPrettier(args, options = {}) { + const { + existsSyncFn = fs.existsSync, + runLocalPrettierFn = runLocalPrettier, + runNpxPrettierFn = runNpxPrettier, + } = options; + + if (existsSyncFn(LOCAL_PRETTIER_BIN)) { + return runLocalPrettierFn(args); + } + + return runNpxPrettierFn(args); +} + +function printManagedPrettierLaunchError(error) { + const detail = error && error.message ? ` (${error.message})` : ""; + console.error(`Failed to launch managed Prettier${detail}.`); + console.error("Ensure Node.js/npm are available in this shell, or run npm install."); + if (process.platform === "win32") { + console.error( + "Windows tip: if you use nvm/fnm, open PowerShell or Git Bash with Node initialized and verify npm --version." + ); + } +} + +function main() { + const result = runManagedPrettier(process.argv.slice(2)); + + if (result.error) { + printManagedPrettierLaunchError(result.error); + process.exit(1); + } + + process.exit(typeof result.status === "number" ? result.status : 1); +} + +module.exports = { + REPO_ROOT, + LOCAL_PRETTIER_BIN, + runCommand, + runLocalPrettier, + runNpxPrettier, + runManagedPrettier, + printManagedPrettierLaunchError, +}; + +if (require.main === module) { + main(); +} diff --git a/scripts/validate-npm-meta.js b/scripts/validate-npm-meta.js index 6968a7ce..c1a8cfbf 100644 --- a/scripts/validate-npm-meta.js +++ b/scripts/validate-npm-meta.js @@ -16,10 +16,9 @@ "use strict"; -const { execSync } = require("child_process"); -const fs = require("fs"); const path = require("path"); const { normalizeToLf } = require("./lib/quote-parser"); +const { spawnPlatformCommandSync } = require("./lib/shell-command"); /** * Parse tar listing output into package-relative file paths. @@ -36,46 +35,100 @@ function parseTarListingOutput(tarOutput) { } /** - * Get list of files that would be included in the npm package - * @returns {string[]} Array of file paths relative to package root + * Parse `npm pack --json --dry-run` output and return package-relative file paths. + * + * @param {string} packOutput - Raw JSON output from npm pack + * @returns {string[]} Package-relative file list */ -function getPackageFiles() { - const repoRoot = path.resolve(__dirname, ".."); +function parseNpmPackJsonOutput(packOutput) { + const trimmedOutput = normalizeToLf(packOutput || "").trim(); + if (!trimmedOutput) { + throw new Error("npm pack produced no output"); + } + + let parsedOutput; try { - // Create a temporary tarball - console.log("Creating package tarball..."); - execSync("npm pack > /dev/null 2>&1", { - encoding: "utf8", - cwd: repoRoot, - }); + parsedOutput = JSON.parse(trimmedOutput); + } catch (error) { + throw new Error(`Unable to parse npm pack --json output: ${error.message}`); + } - // Find the tarball file - const tarballs = fs - .readdirSync(repoRoot) - .filter((f) => f.endsWith(".tgz") || f.endsWith(".tar.gz")); + if ( + !Array.isArray(parsedOutput) || + parsedOutput.length === 0 || + parsedOutput[0] === null || + typeof parsedOutput[0] !== "object" + ) { + throw new Error("npm pack --json output did not contain package metadata"); + } - if (tarballs.length === 0) { - throw new Error("No tarball file found after npm pack"); - } + const packageInfo = parsedOutput[0]; + if (!Array.isArray(packageInfo.files)) { + throw new Error("npm pack --json output did not include a files list"); + } + + const files = packageInfo.files + .map((entry) => { + if (typeof entry === "string") { + return entry; + } + + if (entry && typeof entry.path === "string") { + return entry.path; + } + + return ""; + }) + .filter((entry) => entry.length > 0); - const tarballPath = path.join(repoRoot, tarballs[0]); + if (files.length === 0) { + throw new Error("npm pack --json output contained an empty files list"); + } + + return files; +} + +/** + * Get list of files that would be included in the npm package. + * + * Uses npm's JSON dry-run output so the check is shell-safe and cross-platform. + * + * @returns {string[]} Array of file paths relative to package root + */ +function getPackageFiles() { + const repoRoot = path.resolve(__dirname, ".."); - // Extract file list from tarball - const tarOutput = execSync(`tar -tzf "${tarballPath}"`, { + try { + console.log("Computing package file list via npm pack --json --dry-run..."); + const packResult = spawnPlatformCommandSync("npm", ["pack", "--json", "--dry-run"], { encoding: "utf8", cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], }); - // Parse file list, removing the "package/" prefix and empty lines - const files = parseTarListingOutput(tarOutput); + if (packResult.error) { + throw packResult.error; + } - // Clean up tarball - fs.unlinkSync(tarballPath); + if (packResult.status !== 0) { + const stderr = normalizeToLf(packResult.stderr || "").trim(); + throw new Error( + `npm pack --json --dry-run failed with exit code ${packResult.status}${stderr ? `: ${stderr}` : ""}` + ); + } - return files; + return parseNpmPackJsonOutput(packResult.stdout || ""); } catch (error) { - console.error("Error creating or reading npm package:", error.message); + if (error && error.code === "ENOENT") { + console.error( + "Error creating or reading npm package:", + `${error.message}\n` + + "npm was not found in this hook shell. Verify npm --version in the same shell used for git commits." + ); + } else { + console.error("Error creating or reading npm package:", error.message); + } throw error; } } @@ -121,7 +174,6 @@ function validateMetaFilesHaveTargets(files) { */ function validateFilesHaveMetaFiles(files) { const errors = []; - const fileSet = new Set(files); const metaFiles = new Set(files.filter((f) => f.endsWith(".meta"))); // Files that don't need .meta files (non-Unity assets) @@ -234,6 +286,7 @@ if (require.main === module) { // Export for testing module.exports = { getPackageFiles, + parseNpmPackJsonOutput, parseTarListingOutput, validateMetaFilesHaveTargets, validateFilesHaveMetaFiles, diff --git a/scripts/validate-pre-commit-tooling.js b/scripts/validate-pre-commit-tooling.js index 57d9907d..c7508add 100644 --- a/scripts/validate-pre-commit-tooling.js +++ b/scripts/validate-pre-commit-tooling.js @@ -13,6 +13,10 @@ const fs = require("fs"); const path = require("path"); const { normalizeToLf } = require("./lib/quote-parser"); +const { + getConfiguredPrettierSpec, + getPinnedPrettierSpec, +} = require("./lib/prettier-version"); const PRE_COMMIT_CONFIG_PATH = path.join(__dirname, "..", ".pre-commit-config.yaml"); @@ -159,6 +163,10 @@ function usesManagedJestWrapper(entry) { return /\bnode\b\s+scripts\/run-managed-jest\.js\b/.test(entry); } +function usesManagedPrettierWrapper(entry) { + return /\bnode\b\s+scripts\/run-managed-prettier\.js\b/.test(entry); +} + function isJestRelatedHook(hookId, entry) { return ( usesManagedJestWrapper(entry) || @@ -178,6 +186,14 @@ function hasManagedJestInvocation(hookIdOrEntry, maybeEntry) { return usesManagedJestWrapper(entry); } +function hasManagedPrettierInvocation(hookId, entry) { + if (hookId !== "prettier") { + return true; + } + + return usesManagedPrettierWrapper(entry); +} + function validateHookEntries(entries) { const violations = []; @@ -203,6 +219,17 @@ function validateHookEntries(entries) { ) ); } + + if (!hasManagedPrettierInvocation(hook.id, hook.entry)) { + violations.push( + new Violation( + hook.id, + hook.line, + "Prettier hook must invoke node scripts/run-managed-prettier.js.", + hook.entry + ) + ); + } } return violations; @@ -248,9 +275,47 @@ function validateYamllintPolicy(content) { return violations; } +function validatePrettierVersionResolution( + getConfiguredPrettierSpecFn = getConfiguredPrettierSpec, + getPinnedPrettierSpecFn = getPinnedPrettierSpec +) { + const violations = []; + + const configuredSpec = getConfiguredPrettierSpecFn(); + if (!configuredSpec) { + violations.push( + new Violation( + "prettier-version", + 1, + "Missing pinned prettier version in package.json devDependencies.", + "(missing package.json devDependencies.prettier)" + ) + ); + return violations; + } + + const resolvedSpec = getPinnedPrettierSpecFn(); + if (resolvedSpec !== configuredSpec) { + violations.push( + new Violation( + "prettier-version", + 1, + `Resolved managed Prettier spec (${resolvedSpec}) must match package.json (${configuredSpec}).`, + "scripts/lib/prettier-version.js" + ) + ); + } + + return violations; +} + function validateConfigContent(content) { const hooks = parseHookEntries(content); - return [...validateHookEntries(hooks), ...validateYamllintPolicy(content)]; + return [ + ...validateHookEntries(hooks), + ...validateYamllintPolicy(content), + ...validatePrettierVersionResolution(), + ]; } function validateConfigFile(filePath = PRE_COMMIT_CONFIG_PATH) { @@ -283,10 +348,13 @@ module.exports = { tokenizeCommand, hasNpxInstallPolicy, usesManagedJestWrapper, + usesManagedPrettierWrapper, isJestRelatedHook, hasManagedJestInvocation, + hasManagedPrettierInvocation, validateHookEntries, validateYamllintPolicy, + validatePrettierVersionResolution, validateConfigContent, validateConfigFile, }; diff --git a/scripts/validate-pre-commit-tooling.js.meta b/scripts/validate-pre-commit-tooling.js.meta new file mode 100644 index 00000000..a7d3bf8c --- /dev/null +++ b/scripts/validate-pre-commit-tooling.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6110c9eede9c9384a8bc58eddf785390 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/verify-managed-jest-fallback.js.meta b/scripts/verify-managed-jest-fallback.js.meta new file mode 100644 index 00000000..0008ea98 --- /dev/null +++ b/scripts/verify-managed-jest-fallback.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6d92d7e4515f3944f86e11dd62ec540b +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 44e6fb488bfded85b7a09dd2bcaad8783dbff9e8 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 29 Apr 2026 10:15:03 -0700 Subject: [PATCH 03/12] Remove underscored tests --- .gitignore | 7 +- .llm/context.md | 11 +- .llm/skills/index.md | 4 +- .../test-coverage-organization-assertions.md | 2 + .../test-coverage-unity-anti-patterns.md | 10 + .pre-commit-config.yaml | 13 +- CHANGELOG.md | 3 + Editor/Analyzers/BaseCallIlInspector.cs | 1 + Editor/Analyzers/BaseCallLogMessageParser.cs | 8 +- Editor/Analyzers/BaseCallReportAggregator.cs | 8 +- Editor/Analyzers/BaseCallTypeScannerCore.cs | 20 +- .../WallstopStudios.DxMessaging.Analyzer.dll | Bin 22016 -> 22016 bytes ...opStudios.DxMessaging.SourceGenerators.dll | Bin 33280 -> 33280 bytes .../MessageAwareComponentInspectorOverlay.cs | 4 +- .../BaseCallIlInspectorTests.cs | 42 +- .../BaseCallLogMessageParserTests.cs | 62 +-- .../BaseCallTypeScannerTests.cs | 34 +- .../CompilationMessageHarvestTests.cs | 40 +- llms.txt | 6 +- package.json | 3 +- .../fix-csharp-underscore-methods.test.js | 323 +++++++++++++++ ...fix-csharp-underscore-methods.test.js.meta | 3 +- .../__tests__/generate-skills-index.test.js | 60 ++- .../pre-commit-hook-stage-policy.test.js | 15 + .../__tests__/prettier-version.test.js.meta | 3 +- .../run-managed-prettier.test.js.meta | 7 + scripts/__tests__/shell-command.test.js.meta | 7 + .../validate-pre-commit-tooling.test.js | 238 ++++++++++- scripts/fix-csharp-underscore-methods.js | 371 ++++++++++++++++++ scripts/fix-csharp-underscore-methods.js.meta | 7 + scripts/generate-skills-index.js | 1 + scripts/lib/prettier-version.js.meta | 7 + scripts/run-managed-prettier.js.meta | 7 + scripts/update-llms-txt.js | 4 +- scripts/validate-pre-commit-tooling.js | 169 +++++++- 35 files changed, 1359 insertions(+), 141 deletions(-) create mode 100644 scripts/__tests__/fix-csharp-underscore-methods.test.js rename SourceGenerators/WallstopStudios.DxMessaging.Analyzer/bin.meta => scripts/__tests__/fix-csharp-underscore-methods.test.js.meta (67%) rename SourceGenerators/WallstopStudios.DxMessaging.Analyzer/obj.meta => scripts/__tests__/prettier-version.test.js.meta (67%) create mode 100644 scripts/__tests__/run-managed-prettier.test.js.meta create mode 100644 scripts/__tests__/shell-command.test.js.meta create mode 100644 scripts/fix-csharp-underscore-methods.js create mode 100644 scripts/fix-csharp-underscore-methods.js.meta create mode 100644 scripts/lib/prettier-version.js.meta create mode 100644 scripts/run-managed-prettier.js.meta diff --git a/.gitignore b/.gitignore index e655ff90..9bd2afc8 100644 --- a/.gitignore +++ b/.gitignore @@ -350,4 +350,9 @@ pre-commit.md* pre-commit.txt* pre-push.md* pre-push.txt* -pr-description.md* \ No newline at end of file +pr-description.md* + +SourceGenerators/WallstopStudios.DxMessaging.Analyzer/bin.meta +SourceGenerators/WallstopStudios.DxMessaging.Analyzer/obj.meta +Temp +Temp.meta \ No newline at end of file diff --git a/.llm/context.md b/.llm/context.md index 109f4afa..7c3774a7 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -28,7 +28,7 @@ This file is intentionally concise. It contains only critical, high-signal guida - Run file-scoped validation during editing; do not treat git hooks as the first signal of quality issues. - 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. -- When editing `.pre-commit-config.yaml`, `scripts/*` hook tooling, or `.github/workflows/*.yml`, run `npm run preflight:pre-commit` before finishing. +- 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. ## Build and Test Commands @@ -37,9 +37,13 @@ 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` +- 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` - Validate npm package meta integrity: `npm run validate:npm-meta` +- Check C# method naming (no underscores): `node scripts/fix-csharp-underscore-methods.js --check --all` +- Auto-fix C# method naming on selected files: `node scripts/fix-csharp-underscore-methods.js ` - File-scoped spellcheck: `npx --yes cspell@9 --no-progress --no-summary ` - Note: Prettier does not auto-wrap long YAML lines; yamllint enforces the 200-character limit. - Auto-fix markdown fragments/lists: `node scripts/fix-md029-md051.js ` @@ -53,6 +57,7 @@ This file is intentionally concise. It contains only critical, high-signal guida - Use explicit types where practical; avoid unnecessary `var`. - Keep braces explicit. - Avoid regions. +- Use PascalCase for all method names with no underscores (including test methods); this is auto-enforced by the `fix-csharp-underscore-methods` pre-commit hook. - Keep test names descriptive and readable. - Keep public API changes intentional and backward-compatible unless planned otherwise. @@ -62,12 +67,14 @@ This file is intentionally concise. It contains only critical, high-signal guida - Normalize multiline text handling before line-based parsing. - Keep JS and PowerShell behavior synchronized when dual implementations exist. - Add tests for parser changes and malformed input edge cases. +- For path-exclusion logic in script CLIs, apply exclusion patterns only to repository-local paths and add paired tests for outside-repo explicit file args plus repo-internal excluded directories. - 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 `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. - 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. - 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 and yamllint issues are caught before commit. +- 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. ## Line Ending Policy diff --git a/.llm/skills/index.md b/.llm/skills/index.md index 535d73a4..cd0a883e 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -187,11 +187,11 @@ | [Test Failure Investigation Procedure](./testing/test-failure-investigation-procedure.md) | ✅ 217 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, investigation | | [Test Failure Root Causes and Anti-Patterns](./testing/test-failure-investigation-root-causes.md) | ✅ 187 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, root-cause-analysis | | [Test Invalid Skill](./testing/test-invalid-skill.md) | 📝 31 | 🔴 Expert | ✅ Stable | ●●●○○ | testing, fixtures | -| [Test Organization and Assertions](./testing/test-coverage-organization-assertions.md) | ✅ 172 | 🟢 Basic | ✅ Stable | ○○○○○ | testing, assertions | +| [Test Organization and Assertions](./testing/test-coverage-organization-assertions.md) | ✅ 174 | 🟢 Basic | ✅ Stable | ○○○○○ | testing, assertions | | [Test Production Code Directly](./testing/test-production-code.md) | ✅ 146 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, anti-patterns | | [Test Production Code Directly Part 1](./testing/test-production-code-part-1.md) | ✅ 205 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | | [Test Production Code Directly Part 2](./testing/test-production-code-part-2.md) | 📝 66 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Unity Test Considerations and Anti-Patterns](./testing/test-coverage-unity-anti-patterns.md) | ✅ 260 | 🟢 Basic | ✅ Stable | ○○○○○ | testing, unity | +| [Unity Test Considerations and Anti-Patterns](./testing/test-coverage-unity-anti-patterns.md) | ⚠️ 270 | 🟢 Basic | ✅ Stable | ○○○○○ | testing, unity | --- diff --git a/.llm/skills/testing/test-coverage-organization-assertions.md b/.llm/skills/testing/test-coverage-organization-assertions.md index 8723f5d2..2ef49a0a 100644 --- a/.llm/skills/testing/test-coverage-organization-assertions.md +++ b/.llm/skills/testing/test-coverage-organization-assertions.md @@ -94,6 +94,8 @@ public void EmitUntargetedMessageInvokesAllRegisteredHandlers() { } public void Test_Emit_Works() { } ``` +Test method names must use PascalCase without underscores. The pre-commit hook `fix-csharp-underscore-methods` auto-fixes underscored method names when possible. + ### Test Class Organization ```csharp diff --git a/.llm/skills/testing/test-coverage-unity-anti-patterns.md b/.llm/skills/testing/test-coverage-unity-anti-patterns.md index 0484565f..68a7ab99 100644 --- a/.llm/skills/testing/test-coverage-unity-anti-patterns.md +++ b/.llm/skills/testing/test-coverage-unity-anti-patterns.md @@ -187,6 +187,16 @@ public void Message_Bus_Should_Handle_Null_Input() { } public void MessageBusHandlesNullInput() { } ``` +This rule is enforced by the pre-commit hook `fix-csharp-underscore-methods`, which auto-converts underscored method names to PascalCase before commit. + +Local validation helpers: + +- `node scripts/fix-csharp-underscore-methods.js --check ` +- `node scripts/fix-csharp-underscore-methods.js --check --all` +- `node scripts/fix-csharp-underscore-methods.js ` + +Note: `git commit --no-verify` bypasses pre-commit hooks, so avoid using it for regular development flows. + ### Don't Use Regions ```csharp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d4ebcea9..4d931b97 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,16 @@ repos: - post-checkout - post-rewrite description: Install the .NET tools listed at .config/dotnet-tools.json. + - id: fix-csharp-underscore-methods + name: Auto-fix C# method names (remove underscores) + entry: >- + bash -c 'node scripts/fix-csharp-underscore-methods.js "$@" && git add "$@"' -- + language: system + types: + - c# + stages: + - pre-commit + description: Auto-fix C# method names by removing underscores and fail if restaging fails. - id: csharpier name: Run CSharpier on C# files entry: dotnet tool run csharpier format @@ -240,6 +250,7 @@ repos: scripts/__tests__/validate-skills-llm-policy.test.js scripts/__tests__/update-llms-txt.test.js scripts/__tests__/fix-md029-md051.test.js + scripts/__tests__/fix-csharp-underscore-methods.test.js scripts/__tests__/validate-vscode-settings.test.js scripts/__tests__/validate-pre-commit-tooling.test.js scripts/__tests__/validate-npm-meta.test.js @@ -249,7 +260,7 @@ repos: scripts/__tests__/verify-managed-jest-fallback.test.js language: system pass_filenames: false - files: '^(\.gitattributes|CONTRIBUTING\.md|\.vscode/settings\.json|\.github/workflows/(llm-policy-check|pre-commit-tooling-check)\.yml|scripts/check-eol\.ps1|scripts/(check-eol|fix-eol|fix-md029-md051|validate-lychee-config|validate-skills|generate-skills-index|validate-workflows|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-npm-meta|run-managed-jest|run-managed-prettier|verify-managed-jest-fallback)\.js|scripts/lib/(quote-parser|eol-policy|shell-command|prettier-version)\.js|scripts/__tests__/(check-eol|fix-md029-md051|validate-lychee-config|validate-skills-required-fields|validate-skills-llm-policy|generate-skills-index|prettier-version|run-managed-prettier|validate-workflows|quote-parser|shell-command|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-npm-meta|detect-shell-redirection-antipattern|pre-commit-hook-stage-policy|run-managed-jest|verify-managed-jest-fallback)\.test\.js)$' + files: '^(\.gitattributes|CONTRIBUTING\.md|\.vscode/settings\.json|\.github/workflows/(llm-policy-check|pre-commit-tooling-check)\.yml|scripts/check-eol\.ps1|scripts/(check-eol|fix-eol|fix-md029-md051|fix-csharp-underscore-methods|validate-lychee-config|validate-skills|generate-skills-index|validate-workflows|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-npm-meta|run-managed-jest|run-managed-prettier|verify-managed-jest-fallback)\.js|scripts/lib/(quote-parser|eol-policy|shell-command|prettier-version)\.js|scripts/__tests__/(check-eol|fix-md029-md051|fix-csharp-underscore-methods|validate-lychee-config|validate-skills-required-fields|validate-skills-llm-policy|generate-skills-index|prettier-version|run-managed-prettier|validate-workflows|quote-parser|shell-command|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-npm-meta|detect-shell-redirection-antipattern|pre-commit-hook-stage-policy|run-managed-jest|verify-managed-jest-fallback)\.test\.js)$' stages: - pre-commit description: Fail fast on parser, npm-meta, and shell-safety regressions (quote handling, frontmatter/TOML parsing, newline normalization) before push. diff --git a/CHANGELOG.md b/CHANGELOG.md index 94126240..517b5edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added GitHub Actions workflows for automatic validation and updates of llms.txt - Added documentation about AI agent integration in README - Inspector overlay now shows yesterday's analyzer 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 as `(cached from previous session — refreshing…)` until the first scan refreshes it. Eliminates the perceived flakiness where the warning sometimes appeared and sometimes didn't, depending on how fast the user clicked into the inspector after a domain reload. +- Added `scripts/fix-csharp-underscore-methods.js` to auto-convert underscored C# method names to PascalCase. +- Added pre-commit hook `fix-csharp-underscore-methods` to auto-fix and re-stage changed C# files before commit. +- Added script tests and hook-policy checks that enforce no-underscore method naming in C# test code. ## [2.2.0] diff --git a/Editor/Analyzers/BaseCallIlInspector.cs b/Editor/Analyzers/BaseCallIlInspector.cs index 2d7f14c4..d0b7d5db 100644 --- a/Editor/Analyzers/BaseCallIlInspector.cs +++ b/Editor/Analyzers/BaseCallIlInspector.cs @@ -114,6 +114,7 @@ public static bool MethodIlContainsBaseCall(MethodInfo method, string methodName // Abstract / extern / runtime-implemented / IL2CPP-stripped — cannot inspect. return true; } + byte[] il = body.GetILAsByteArray(); if (il == null || il.Length == 0) { diff --git a/Editor/Analyzers/BaseCallLogMessageParser.cs b/Editor/Analyzers/BaseCallLogMessageParser.cs index 58ee989b..25a9017d 100644 --- a/Editor/Analyzers/BaseCallLogMessageParser.cs +++ b/Editor/Analyzers/BaseCallLogMessageParser.cs @@ -7,7 +7,6 @@ namespace DxMessaging.Editor.Analyzers using System; using System.Collections.Generic; using System.Globalization; - using System.Linq; using System.Text.RegularExpressions; /// @@ -59,7 +58,7 @@ public sealed class ParsedTypeReport { public string TypeFullName { get; set; } - public HashSet MissingBaseFor { get; } = new(StringComparer.Ordinal); + public SortedSet MissingBaseFor { get; } = new(StringComparer.Ordinal); public HashSet DiagnosticIds { get; } = new(StringComparer.Ordinal); @@ -277,10 +276,7 @@ public static Dictionary Aggregate(IEnumerable report.DiagnosticIds.Add(entry.DiagnosticId); - if ( - !string.IsNullOrEmpty(entry.MethodName) - && !report.MissingBaseFor.Contains(entry.MethodName, StringComparer.Ordinal) - ) + if (!string.IsNullOrEmpty(entry.MethodName)) { report.MissingBaseFor.Add(entry.MethodName); } diff --git a/Editor/Analyzers/BaseCallReportAggregator.cs b/Editor/Analyzers/BaseCallReportAggregator.cs index 4bac9e47..d551430a 100644 --- a/Editor/Analyzers/BaseCallReportAggregator.cs +++ b/Editor/Analyzers/BaseCallReportAggregator.cs @@ -25,10 +25,10 @@ public sealed class BaseCallReportEntryDto public string TypeName; /// Method names whose overrides are missing the corresponding base.*() call. - public List MissingBaseFor = new(); + public readonly SortedSet MissingBaseFor = new(StringComparer.Ordinal); /// Diagnostic IDs that contributed to this entry (e.g., DXMSG006/007/008/009). - public HashSet DiagnosticIds = new(StringComparer.Ordinal); + public readonly HashSet DiagnosticIds = new(StringComparer.Ordinal); /// Source file path (best-effort) for "Open Script" actions in the inspector overlay. public string FilePath; @@ -275,7 +275,7 @@ ParsedTypeReport report foreach (string method in report.MissingBaseFor) { - if (!string.IsNullOrEmpty(method) && !existing.MissingBaseFor.Contains(method)) + if (!string.IsNullOrEmpty(method)) { // Dedupe across the dual-source merge: LogEntries and CompilerMessage may // both surface the same `.` pair on Unity 2022+, where both @@ -287,7 +287,7 @@ ParsedTypeReport report foreach (string id in report.DiagnosticIds) { - if (!string.IsNullOrEmpty(id) && !existing.DiagnosticIds.Contains(id)) + if (!string.IsNullOrEmpty(id)) { // Mirror MissingBaseFor's dedup. Even though DiagnosticIds is a HashSet today, // an explicit Contains check keeps the merge contract stable against future diff --git a/Editor/Analyzers/BaseCallTypeScannerCore.cs b/Editor/Analyzers/BaseCallTypeScannerCore.cs index ce2207cd..5bff4e1f 100644 --- a/Editor/Analyzers/BaseCallTypeScannerCore.cs +++ b/Editor/Analyzers/BaseCallTypeScannerCore.cs @@ -65,10 +65,10 @@ public sealed class ScanEntry public string TypeName; /// Method names whose overrides are missing the corresponding base.*() call. - public List MissingBaseFor = new(); + public SortedSet MissingBaseFor = new(StringComparer.Ordinal); /// Diagnostic IDs that contributed to this entry (DXMSG006 / DXMSG007 / DXMSG010). - public List DiagnosticIds = new(); + public HashSet DiagnosticIds = new(StringComparer.Ordinal); } /// @@ -201,8 +201,8 @@ private static ScanEntry ScanOne(Type concrete, string fullName) ScanEntry entry = new() { TypeName = fullName, - MissingBaseFor = new List(), - DiagnosticIds = new List(), + MissingBaseFor = new SortedSet(StringComparer.Ordinal), + DiagnosticIds = new HashSet(StringComparer.Ordinal), }; foreach (string methodName in GuardedMethodNames) @@ -228,7 +228,7 @@ private static void ClassifyMethod(Type concrete, string methodName, ScanEntry e // Type does not declare this method at all — nothing to flag at this level. return; } - if (!declared.ReturnType.Equals(typeof(void))) + if (declared.ReturnType != typeof(void)) { return; } @@ -377,14 +377,8 @@ private static MethodInfo GetOverriddenMethod(MethodInfo derivedOverride) private static void AddIfMissing(ScanEntry entry, string methodName, string diagnosticId) { - if (!entry.MissingBaseFor.Contains(methodName)) - { - entry.MissingBaseFor.Add(methodName); - } - if (!entry.DiagnosticIds.Contains(diagnosticId)) - { - entry.DiagnosticIds.Add(diagnosticId); - } + entry.MissingBaseFor.Add(methodName); + entry.DiagnosticIds.Add(diagnosticId); } } } diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll index c392a2d1f70b7663c77f8438705def517f1945fc..8a0b4092dba46beea6e2681ce255b1821d8759f1 100644 GIT binary patch delta 255 zcmZoz!`QHfaY6@+z?%cRHuhL)F*;3-cTwK_N^6pcKtbFxn+}OX?mNG%dRL|7|8{ea z{UsJgr_J#$Cs{R2jMI`VOij#9(vp%>Ez>M4Qj^TgEs_mPjFXI#jLj{L&CHW65|b<^ z2Y5BGJiRkv@8m090Ro?F1g1?nYvI4;&-TNC=F)Q~n|L2kfC}=PKm~!SZMTVRD&St2 z`|)RYSL;Uaeq%J*#QT5(R8UqDDhO2Vuw(5vm#KGe zrsUT3pWH0s^Muvklp&eHlpzsF8ZcNe7&4?XqyYIAK-!GK90*exjDfP23~3A|Kxhn9 ekp`481*$dzlO_yFKv^T8xH(WPakG2SPG$f|QBH>d diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll index c907536ebad62a6ed0ee7aff2a00158078af9cae..24bc7bdedbfa42d44e12919b91789f740d20b2d2 100644 GIT binary patch delta 237 zcmZo@VQOe$n$W>wV0Ylz#vYMWffdJIcJhDpbYE3|ip6%R5%=Z|sr7;yCdO$=7N#cV zCTU5@sg`LL7O6>Q<`&5YCdNrdNyg@u#%AWp7Kurglf%pRvfME=e>z#BB0!*5Mdbgv z-zOYr=Gk}pAM!6+pmi+T6ceQM&$z*e-j2{hBSsG z1`7sL1``HzAO`W08B!T6fh-FkOa=1IfHD?9Q3Ig7F_3Qr#Ku4|OCSl7O9sj&0>vyh JPp`{l1^_rgP2m6l delta 237 zcmZo@VQOe$n$W?rWxxNyjXff%0w3!ix_&9lb?vCo*}Wj6@y+H9sr7;yrpcy>$p#jN zsVSxwsb=Qpsm6(xX(lGdhG}M|21cd^CP|h?hUNx|lf%pRvdnkgHFdH?MS#Gjcb~WX zZAx+KsTUWP-l4}nxuW8N0#vZc2Pz0sZ7L@G!@JRl>&}LoTQ_G^K49@TWk_Z)Wk>{) z1`HMqh773;DL}pjkTzp52f|baW1y@hLmGn#5E=tjqyc42fvSzbqzOY3P}T@2ZVnVn K+&sN5lNkVLWJ~-2 diff --git a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs index acea5dfe..8410e5fe 100644 --- a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs +++ b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs @@ -104,7 +104,7 @@ private static void DrawHeader(Editor editor) /// inspector's layout pass for this editor has already completed. Safe to gate on /// here — we are not inside an OnInspectorGUI body. /// - private static void RenderForHeaderHook(UnityEngine.Object target) + private static void RenderForHeaderHook(Object target) { if (target == null) { @@ -147,7 +147,7 @@ private static void RenderForHeaderHook(UnityEngine.Object target) /// Cross-path dedupe with the header-hook path is handled inside /// , which unconditionally skips when the editor is our fallback. /// - internal static void RenderInsideOnInspectorGUI(UnityEngine.Object target) + internal static void RenderInsideOnInspectorGUI(Object target) { if (target is not MessageAwareComponent messageAwareComponent) { diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs index 8e86d89f..31410b98 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallIlInspectorTests.cs @@ -35,7 +35,7 @@ public sealed class BaseCallIlInspectorTests // ---- BaseCallIlInspector unit tests --------------------------------------------------- [Test] - public void IlInspector_OnNullMethod_ReturnsTrueAssumeClean() + public void IlInspectorOnNullMethodReturnsTrueAssumeClean() { // Defensive default biases away from phantom warnings: when we can't reason, assume the // method is fine. @@ -43,17 +43,17 @@ public void IlInspector_OnNullMethod_ReturnsTrueAssumeClean() } [Test] - public void IlInspector_OnEmptyMethodName_ReturnsTrueAssumeClean() + public void IlInspectorOnEmptyMethodNameReturnsTrueAssumeClean() { MethodInfo method = typeof(BaseCallTypeScannerTests).GetMethod( - nameof(IlInspector_OnEmptyMethodName_ReturnsTrueAssumeClean), + nameof(IlInspectorOnEmptyMethodNameReturnsTrueAssumeClean), BindingFlags.Public | BindingFlags.Instance )!; Assert.That(BaseCallIlInspector.MethodIlContainsBaseCall(method, string.Empty), Is.True); } [Test] - public void IlInspector_OnAbstractMethod_ReturnsTrueAssumeClean() + public void IlInspectorOnAbstractMethodReturnsTrueAssumeClean() { // Abstract methods have no IL body — GetMethodBody() returns null. The inspector must // treat this as assume-clean (cross-assembly third-party code paths exhibit the same @@ -72,7 +72,7 @@ public void IlInspector_OnAbstractMethod_ReturnsTrueAssumeClean() // ---- End-to-end via Roslyn-compiled assemblies ---------------------------------------- [Test] - public void E2E_LeafCallsBaseCorrectly_ScannerReportsClean() + public void E2ELeafCallsBaseCorrectlyScannerReportsClean() { Assembly fixture = CompileFixture( """ @@ -101,7 +101,7 @@ protected override void OnEnable() } [Test] - public void E2E_LeafMissingBaseCall_ScannerDetectsDxmsg006() + public void E2ELeafMissingBaseCallScannerDetectsDxmsg006() { Assembly fixture = CompileFixture( """ @@ -130,7 +130,7 @@ protected override void OnEnable() } [Test] - public void E2E_LeafCallsUnrelatedSiblingMethod_NotMistakenForBaseCall() + public void E2ELeafCallsUnrelatedSiblingMethodNotMistakenForBaseCall() { // The leaf calls SOMETHING — but it's a method on a sibling class, not the parent's // OnEnable. The IsAssignableFrom check inside the inspector ensures we only count calls @@ -167,7 +167,7 @@ protected override void OnEnable() } [Test] - public void E2E_LeafCallsBaseAwakeButCheckingForOnEnable_DoesNotMatch() + public void E2ELeafCallsBaseAwakeButCheckingForOnEnableDoesNotMatch() { // The leaf overrides Awake correctly but does not declare OnEnable. We're asking about // "does this Awake body call base.OnEnable()" — which is a meaningless question, but the @@ -202,7 +202,7 @@ protected override void Awake() } [Test] - public void E2E_AllFiveGuardedMethodsCalledCorrectly() + public void E2EAllFiveGuardedMethodsCalledCorrectly() { Assembly fixture = CompileFixture( """ @@ -247,7 +247,7 @@ string name in new[] } [Test] - public void E2E_BrokenIntermediateChain_DescendantBaseCallStillDetectedAtLeaf() + public void E2EBrokenIntermediateChainDescendantBaseCallStillDetectedAtLeaf() { // The leaf calls base.OnEnable() correctly — IL inspection of the leaf must report TRUE. // The DXMSG010 detection (the intermediate's broken chain) is the SCANNER's job, not the @@ -306,7 +306,7 @@ protected override void OnEnable() } [Test] - public void E2E_Callvirt_StillDetectedAsBaseCall() + public void E2ECallvirtStillDetectedAsBaseCall() { // C# emits `call` for non-virtual base method invocation, and `callvirt` for virtual ones // in some configurations. We accept both opcodes — covered by Roslyn's standard emission @@ -338,7 +338,7 @@ protected override void OnDestroy() } [Test] - public void E2E_DeepChain_LeafBaseCallDetected() + public void E2EDeepChainLeafBaseCallDetected() { // Three-deep chain, each link calls base. The IL inspector at the leaf only inspects the // leaf's body — it must report TRUE because the leaf's IL contains a base.OnEnable() call. @@ -380,7 +380,7 @@ public class C : B } [Test] - public void E2E_LeafCallsBaseConditionally_StillDetected() + public void E2ELeafCallsBaseConditionallyStillDetected() { // base.X() inside an `if` is still visible to the IL walker. The walker doesn't check // reachability — even an unreachable base call counts as "calls base". This matches the @@ -416,7 +416,7 @@ protected override void OnEnable() } [Test] - public void E2E_MultipleSeparateBaseCalls_StillDetectedAsCallsBase() + public void E2EMultipleSeparateBaseCallsStillDetectedAsCallsBase() { // Multiple invocations of base methods (e.g. base.OnEnable() called twice for some // reason) — the inspector returns true on the first match and short-circuits. @@ -448,7 +448,7 @@ protected override void OnEnable() } [Test] - public void E2E_LeafWithSwitchInstruction_BeforeBaseCall_StillDetectsBaseCall() + public void E2ELeafWithSwitchInstructionBeforeBaseCallStillDetectsBaseCall() { // S2: regression guard for the OpCodes-table walker. The body emits a `switch` instruction // (variable-length jump table: 4-byte case count + N×4-byte targets) BEFORE the base @@ -567,7 +567,7 @@ private abstract class AbstractFixture // ---- Adversarial-audit additions ------------------------------------------------------- [Test] - public void E2E_LdstrBeforeBaseCall_StillDetectsBaseCall() + public void E2ELdstrBeforeBaseCallStillDetectsBaseCall() { // Spec 4b: an `ldstr` opcode (0x72) carries a 4-byte metadata-token operand. If the // walker stepped 1 byte instead of 4, it would land inside the operand bytes — and one @@ -608,7 +608,7 @@ protected override void OnEnable() } [Test] - public void E2E_GenericMethodContext_ResolutionWorks() + public void E2EGenericMethodContextResolutionWorks() { // Spec 4c: an IL body that resolves a base method on a generic ancestor. The IL inspector // must pass the method's generic-arg context (declaring-type generic args + method generic @@ -653,7 +653,7 @@ protected override void OnEnable() } [Test] - public void E2E_UnrelatedClassCallingSameNamedStaticMethod_RejectedByIsAssignableFromGuard() + public void E2EUnrelatedClassCallingSameNamedStaticMethodRejectedByIsAssignableFromGuard() { // Spec 4e: the leaf calls a same-named method on a CONCRETE UNRELATED class (not via a // static-helper alias, but via the class type directly). The IsAssignableFrom guard inside @@ -695,7 +695,7 @@ protected override void OnEnable() } [Test] - public void E2E_SecondInstanceMethodNamedSameAsBase_OnUnrelatedInstance_AlsoRejected() + public void E2ESecondInstanceMethodNamedSameAsBaseOnUnrelatedInstanceAlsoRejected() { // Spec 4e (reinforced): the leaf calls `OnEnable` on a field of an unrelated REFERENCE // type — IsAssignableFrom must still reject. The reference type is not an ancestor of the @@ -737,7 +737,7 @@ protected override void OnEnable() } [Test] - public void E2E_VolatilePrefix_TwoByteOpcodeWalkerHandled() + public void E2EVolatilePrefixTwoByteOpcodeWalkerHandled() { // Spec 4a: a method body containing the two-byte 0xFE 0x13 (volatile.) prefix BEFORE // an instruction. The OpCodes-table walker has a separate two-byte branch that must @@ -792,7 +792,7 @@ public void E2E_VolatilePrefix_TwoByteOpcodeWalkerHandled() } [Test] - public void E2E_ResolveMethodInvalidToken_WalkerSwallowsAndContinues() + public void E2EResolveMethodInvalidTokenWalkerSwallowsAndContinues() { // Spec 4d: synthesize a method whose IL contains a `call` opcode (0x28) followed by a // metadata token that does NOT bind in the runtime context (a clearly-invalid token like diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs index ea7f1aee..e1bbf7bf 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallLogMessageParserTests.cs @@ -30,7 +30,7 @@ public sealed class BaseCallLogMessageParserTests + "does not chain to MessageAwareComponent.OnEnable; the messaging system will not function correctly on this component."; [Test] - public void ParseLine_BareDxmsg006_CapturesIdTypeAndMethod() + public void ParseLineBareDxmsg006CapturesIdTypeAndMethod() { ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(Dxmsg006Bare); @@ -44,7 +44,7 @@ public void ParseLine_BareDxmsg006_CapturesIdTypeAndMethod() } [Test] - public void ParseLine_BareDxmsg007_CapturesIdTypeAndMethod() + public void ParseLineBareDxmsg007CapturesIdTypeAndMethod() { ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(Dxmsg007Bare); @@ -56,7 +56,7 @@ public void ParseLine_BareDxmsg007_CapturesIdTypeAndMethod() } [Test] - public void ParseLine_BareDxmsg008_CapturesIdAndTypeWithEmptyMethod() + public void ParseLineBareDxmsg008CapturesIdAndTypeWithEmptyMethod() { ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(Dxmsg008Bare); @@ -68,7 +68,7 @@ public void ParseLine_BareDxmsg008_CapturesIdAndTypeWithEmptyMethod() } [Test] - public void ParseLine_PrefixedDxmsg006_PopulatesPathAndLine() + public void ParseLinePrefixedDxmsg006PopulatesPathAndLine() { const string line = "Assets/Sample/Player.cs(12,9): warning DXMSG006: " + Dxmsg006Bare; @@ -84,7 +84,7 @@ public void ParseLine_PrefixedDxmsg006_PopulatesPathAndLine() } [Test] - public void ParseLine_PrefixedDxmsg007_PopulatesPathAndLine() + public void ParseLinePrefixedDxmsg007PopulatesPathAndLine() { const string line = "Assets/Sample/Player.cs(34,5): warning DXMSG007: " + Dxmsg007Bare; @@ -98,7 +98,7 @@ public void ParseLine_PrefixedDxmsg007_PopulatesPathAndLine() } [Test] - public void ParseLine_PrefixedDxmsg008_PopulatesPathAndLine() + public void ParseLinePrefixedDxmsg008PopulatesPathAndLine() { const string line = "Assets/Sample/Player.cs(7,5): info DXMSG008: " + Dxmsg008Bare; @@ -113,13 +113,13 @@ public void ParseLine_PrefixedDxmsg008_PopulatesPathAndLine() } [Test] - public void ParseLine_NullInput_ReturnsNull() + public void ParseLineNullInputReturnsNull() { Assert.That(BaseCallLogMessageParser.ParseLine(null!), Is.Null); } [Test] - public void ParseLine_EmptyOrWhitespaceInput_ReturnsNull() + public void ParseLineEmptyOrWhitespaceInputReturnsNull() { Assert.That(BaseCallLogMessageParser.ParseLine(string.Empty), Is.Null); Assert.That(BaseCallLogMessageParser.ParseLine(" "), Is.Null); @@ -127,7 +127,7 @@ public void ParseLine_EmptyOrWhitespaceInput_ReturnsNull() } [Test] - public void ParseLine_UnrelatedCompilerWarning_ReturnsNull() + public void ParseLineUnrelatedCompilerWarningReturnsNull() { Assert.That( BaseCallLogMessageParser.ParseLine( @@ -138,7 +138,7 @@ public void ParseLine_UnrelatedCompilerWarning_ReturnsNull() } [Test] - public void ParseLine_DebugLogStyleText_ReturnsNull() + public void ParseLineDebugLogStyleTextReturnsNull() { Assert.That( BaseCallLogMessageParser.ParseLine( @@ -149,7 +149,7 @@ public void ParseLine_DebugLogStyleText_ReturnsNull() } [Test] - public void ParseLine_DiagnosticIdInIsolationOrCommentForm_ReturnsNull() + public void ParseLineDiagnosticIdInIsolationOrCommentFormReturnsNull() { // A bare token mention in a comment / random log line must NOT match. Assert.That(BaseCallLogMessageParser.ParseLine("DXMSG006"), Is.Null); @@ -164,7 +164,7 @@ public void ParseLine_DiagnosticIdInIsolationOrCommentForm_ReturnsNull() } [Test] - public void ParseLine_AnchorRejectsAnalyzerWordingMidString() + public void ParseLineAnchorRejectsAnalyzerWordingMidString() { // S7: body regexes anchor to ^ so a Debug.Log payload that happens to embed the // analyzer's wording mid-string is NOT surfaced as a real DXMSG006/007/008. @@ -191,7 +191,7 @@ public void ParseLine_AnchorRejectsAnalyzerWordingMidString() } [Test] - public void Aggregate_DedupesSameTypeAndMethod() + public void AggregateDedupesSameTypeAndMethod() { List lines = new() { Dxmsg006Bare, Dxmsg006Bare, Dxmsg006Bare }; @@ -204,7 +204,7 @@ public void Aggregate_DedupesSameTypeAndMethod() } [Test] - public void Aggregate_MergesDifferentMethodsOnSameType() + public void AggregateMergesDifferentMethodsOnSameType() { const string awakeLine = "'Sample.Player' overrides MessageAwareComponent.Awake but does not call base.Awake(); " @@ -223,7 +223,7 @@ public void Aggregate_MergesDifferentMethodsOnSameType() } [Test] - public void Aggregate_SeparatesDifferentTypes() + public void AggregateSeparatesDifferentTypes() { const string a = "'Sample.PlayerA' overrides MessageAwareComponent.Awake but does not call base.Awake(); " @@ -242,7 +242,7 @@ public void Aggregate_SeparatesDifferentTypes() } [Test] - public void Aggregate_AccumulatesIdsAcrossDxmsg006And007() + public void AggregateAccumulatesIdsAcrossDxmsg006And007() { const string awake006 = "'Sample.Player' overrides MessageAwareComponent.Awake but does not call base.Awake(); " @@ -262,7 +262,7 @@ public void Aggregate_AccumulatesIdsAcrossDxmsg006And007() } [Test] - public void Aggregate_Dxmsg008_ContributesIdButNoMethod() + public void AggregateDxmsg008ContributesIdButNoMethod() { Dictionary result = BaseCallLogMessageParser.Aggregate( new[] { Dxmsg008Bare } @@ -275,7 +275,7 @@ public void Aggregate_Dxmsg008_ContributesIdButNoMethod() } [Test] - public void Aggregate_Empty_ReturnsEmptyDictionary() + public void AggregateEmptyReturnsEmptyDictionary() { Dictionary result = BaseCallLogMessageParser.Aggregate( System.Array.Empty() @@ -285,7 +285,7 @@ public void Aggregate_Empty_ReturnsEmptyDictionary() } [Test] - public void Aggregate_OrderIndependent_For008ThenVs006Then008() + public void AggregateOrderIndependentFor008ThenVs006Then008() { const string awake006 = "'Sample.Player' overrides MessageAwareComponent.Awake but does not call base.Awake(); " @@ -310,7 +310,7 @@ public void Aggregate_OrderIndependent_For008ThenVs006Then008() } [Test] - public void ParseLine_BareDxmsg009_CapturesIdTypeAndMethod() + public void ParseLineBareDxmsg009CapturesIdTypeAndMethod() { ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(Dxmsg009Bare); @@ -324,7 +324,7 @@ public void ParseLine_BareDxmsg009_CapturesIdTypeAndMethod() } [Test] - public void ParseLine_PrefixedDxmsg009_CapturesPathAndLine() + public void ParseLinePrefixedDxmsg009CapturesPathAndLine() { const string line = "Assets/Scripts/BrokenThing.cs(7,22): warning DXMSG009: " + Dxmsg009Bare; @@ -341,7 +341,7 @@ public void ParseLine_PrefixedDxmsg009_CapturesPathAndLine() } [Test] - public void ParseLine_AnchorRejectsDxmsg009MidString() + public void ParseLineAnchorRejectsDxmsg009MidString() { // Adversarial: the analyzer's wording embedded in a Debug.Log payload must not be parsed // as a real DXMSG009 warning. @@ -354,7 +354,7 @@ public void ParseLine_AnchorRejectsDxmsg009MidString() } [Test] - public void Aggregate_Dxmsg009ContributesToMissingBaseFor() + public void AggregateDxmsg009ContributesToMissingBaseFor() { Dictionary result = BaseCallLogMessageParser.Aggregate( new[] { Dxmsg009Bare } @@ -367,7 +367,7 @@ public void Aggregate_Dxmsg009ContributesToMissingBaseFor() } [Test] - public void Aggregate_Dxmsg009AccumulatesAlongsideDxmsg006() + public void AggregateDxmsg009AccumulatesAlongsideDxmsg006() { // Same type, two diagnostics on different methods → one entry, two methods, two ids. const string awake006 = @@ -385,7 +385,7 @@ public void Aggregate_Dxmsg009AccumulatesAlongsideDxmsg006() } [Test] - public void Aggregate_Dxmsg009Dedups() + public void AggregateDxmsg009Dedups() { List lines = new() { Dxmsg009Bare, Dxmsg009Bare, Dxmsg009Bare }; @@ -398,7 +398,7 @@ public void Aggregate_Dxmsg009Dedups() } [Test] - public void ParseLine_BareDxmsg010_CapturesIdTypeAndMethod() + public void ParseLineBareDxmsg010CapturesIdTypeAndMethod() { ParsedEntry? parsed = BaseCallLogMessageParser.ParseLine(Dxmsg010Bare); @@ -412,7 +412,7 @@ public void ParseLine_BareDxmsg010_CapturesIdTypeAndMethod() } [Test] - public void ParseLine_PrefixedDxmsg010_CapturesPathAndLine() + public void ParseLinePrefixedDxmsg010CapturesPathAndLine() { const string line = "Assets/Scripts/BrokenThing.cs(11,33): warning DXMSG010: " + Dxmsg010Bare; @@ -429,7 +429,7 @@ public void ParseLine_PrefixedDxmsg010_CapturesPathAndLine() } [Test] - public void ParseLine_AnchorRejectsDxmsg010MidString() + public void ParseLineAnchorRejectsDxmsg010MidString() { // Adversarial: the analyzer's wording embedded in a Debug.Log payload must not be parsed // as a real DXMSG010 warning. The body regex is anchored at ^ so any leading text @@ -444,7 +444,7 @@ public void ParseLine_AnchorRejectsDxmsg010MidString() } [Test] - public void Aggregate_Dxmsg010ContributesToMissingBaseFor() + public void AggregateDxmsg010ContributesToMissingBaseFor() { // DXMSG010 must contribute its method name to MissingBaseFor so the inspector overlay // surfaces it just like DXMSG006/007/009. @@ -459,7 +459,7 @@ public void Aggregate_Dxmsg010ContributesToMissingBaseFor() } [Test] - public void ParseLine_Dxmsg010_BrokenAncestorIsNotSurfacedOnParsedEntry() + public void ParseLineDxmsg010BrokenAncestorIsNotSurfacedOnParsedEntry() { // Spec 5a: the DXMSG010 regex captures the broken-ancestor name in a `broken` group, but // the `ParsedEntry` struct does NOT expose it as a field. This test PINS the current @@ -494,7 +494,7 @@ public void ParseLine_Dxmsg010_BrokenAncestorIsNotSurfacedOnParsedEntry() } [Test] - public void Aggregate_KeepsFirstSeenFilePathAndLine() + public void AggregateKeepsFirstSeenFilePathAndLine() { const string prefixed = "Assets/Sample/Player.cs(12,9): warning DXMSG006: " diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs index 004541df..630c9926 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs @@ -51,7 +51,7 @@ public sealed class BaseCallTypeScannerTests // ---- Per-classification tests ----------------------------------------------------------- [Test] - public void Scan_OverrideWithoutBase_ReportsDxmsg006_AndAddsMethodToMissingBaseFor() + public void ScanOverrideWithoutBaseReportsDxmsg006AndAddsMethodToMissingBaseFor() { Assembly fixture = CompileFixture( """ @@ -77,7 +77,7 @@ protected override void OnEnable() } [Test] - public void Scan_OverrideWithBase_ReportsNothing() + public void ScanOverrideWithBaseReportsNothing() { Assembly fixture = CompileFixture( """ @@ -100,7 +100,7 @@ protected override void OnEnable() } [Test] - public void Scan_NoModifierOnGuardedName_ReportsHidingDiagnostic() + public void ScanNoModifierOnGuardedNameReportsHidingDiagnostic() { // Acceptance contract: declaring a same-named lifecycle method without override or new // (C# CS0114) compiles to the same IL shape as `new void X()`. The scanner's IL-only @@ -139,7 +139,7 @@ protected void OnEnable() } [Test] - public void Scan_ExplicitNewOnGuardedName_ReportsDxmsg007() + public void ScanExplicitNewOnGuardedNameReportsDxmsg007() { Assembly fixture = CompileFixture( """ @@ -165,7 +165,7 @@ public class ExplicitHider : MessageAwareComponent } [Test] - public void Scan_BrokenIntermediate_ReportsDxmsg010OnLeaf() + public void ScanBrokenIntermediateReportsDxmsg010OnLeaf() { // The user's canonical `BrokenThing : ddd : MessageAwareComponent` case: ddd's override // does not call base, BrokenThing's override does. The leaf is the type the user is @@ -209,7 +209,7 @@ protected override void OnEnable() } [Test] - public void Scan_ChainSkippingMiddleType_ReportsDxmsg010OnLeaf() + public void ScanChainSkippingMiddleTypeReportsDxmsg010OnLeaf() { // Four-level chain: BrokenThing : Middle : ddd : MessageAwareComponent. Middle does NOT // declare OnEnable, but ddd's override is broken. BrokenThing calls base correctly. The @@ -262,7 +262,7 @@ protected override void OnEnable() } [Test] - public void Scan_ClassLevelDxIgnoreMissingBaseCallAttribute_ExcludesFromSnapshot() + public void ScanClassLevelDxIgnoreMissingBaseCallAttributeExcludesFromSnapshot() { Assembly fixture = CompileFixture( """ @@ -287,7 +287,7 @@ protected override void OnEnable() } [Test] - public void Scan_TypeInProjectIgnoreList_ExcludesFromSnapshot() + public void ScanTypeInProjectIgnoreListExcludesFromSnapshot() { Assembly fixture = CompileFixture( """ @@ -313,7 +313,7 @@ protected override void OnEnable() } [Test] - public void Scan_AbstractType_IsSkipped() + public void ScanAbstractTypeIsSkipped() { // Abstract subclasses cannot exist as MonoBehaviour instances, so the inspector overlay // never shows their HelpBox. The scanner should not include them in the snapshot even if @@ -339,7 +339,7 @@ protected override void OnEnable() } [Test] - public void Scan_GenericTypeDefinition_IsSkipped() + public void ScanGenericTypeDefinitionIsSkipped() { // Open generic-type definitions cannot be instantiated as MonoBehaviour components. // Closed generic instantiations would be classified separately — but the open definition @@ -368,7 +368,7 @@ protected override void OnEnable() } [Test] - public void Scan_NestedTypeFqnUsesDots_NotPlusSign() + public void ScanNestedTypeFqnUsesDotsNotPlusSign() { // System.Type.FullName for nested types uses '+' as the separator (e.g. // "Outer+Nested"); the analyzer emits the dotted form so the inspector overlay can @@ -407,7 +407,7 @@ protected override void OnEnable() } [Test] - public void Scan_TwoTypesWithSameMethodIssues_BothInSnapshot() + public void ScanTwoTypesWithSameMethodIssuesBothInSnapshot() { Assembly fixture = CompileFixture( """ @@ -444,7 +444,7 @@ protected override void OnDisable() } [Test] - public void Scan_MethodLevelDxIgnoreMissingBaseCallAttribute_ExcludesFromSnapshot() + public void ScanMethodLevelDxIgnoreMissingBaseCallAttributeExcludesFromSnapshot() { // Spec 2a: the class itself is NOT marked, but a single method has the // [DxIgnoreMissingBaseCall] attribute. The scanner's method-level check (over the five @@ -473,7 +473,7 @@ protected override void OnEnable() } [Test] - public void Scan_TwoBrokenMethodsOnSameType_FoldedIntoSingleEntry() + public void ScanTwoBrokenMethodsOnSameTypeFoldedIntoSingleEntry() { // Spec 2b: a single type with TWO broken overrides (Awake AND OnEnable) must produce // exactly ONE entry whose MissingBaseFor lists both methods. DiagnosticIds is the @@ -516,7 +516,7 @@ protected override void OnEnable() } [Test] - public void Scan_NullSettings_TreatsOptOutListAsEmpty_NoNullReferenceException() + public void ScanNullSettingsTreatsOptOutListAsEmptyNoNullReferenceException() { // Spec 2e: passing null for ignoredTypeNames must be treated as an empty opt-out list and // must not throw. This pins the defensive null-handling at the API boundary. @@ -544,7 +544,7 @@ protected override void OnEnable() } [Test] - public void Scan_OnExternMethod_TreatedAsCleanCrossAssembly() + public void ScanOnExternMethodTreatedAsCleanCrossAssembly() { // Spec 2d: a MessageAwareComponent subclass whose override is `extern` (no IL body) must // be treated as assume-clean. GetMethodBody() returns null for extern methods just like @@ -595,7 +595,7 @@ public class ExternLeaf : MessageAwareComponent } [Test] - public void Scan_HealthyChain_ReportsNothing() + public void ScanHealthyChainReportsNothing() { // Three-deep healthy chain: every link calls base, no DXMSG006 / DXMSG010 should fire. Assembly fixture = CompileFixture( diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs index 5dfabd9e..5c263557 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs @@ -42,7 +42,7 @@ public sealed class CompilationMessageHarvestTests + "replace with 'override' and call base.OnEnable() so the messaging system continues to function."; [Test] - public void Aggregate_OnUnity2021Dxmsg009Line_ProducesEntryWithFilePathAndLine() + public void AggregateOnUnity2021Dxmsg009LineProducesEntryWithFilePathAndLine() { Dictionary aggregated = BaseCallLogMessageParser.Aggregate( new[] { Unity2021Dxmsg009 } @@ -58,7 +58,7 @@ public void Aggregate_OnUnity2021Dxmsg009Line_ProducesEntryWithFilePathAndLine() } [Test] - public void Aggregate_OnMixedDiagnosticsForSameType_DedupesMethodsAndUnionsIds() + public void AggregateOnMixedDiagnosticsForSameTypeDedupesMethodsAndUnionsIds() { // Player.cs raises both a DXMSG006 (override missing base) on Awake and a DXMSG007 // (new hides) on OnEnable. The same type FQN appears in both, so the per-type report @@ -78,7 +78,7 @@ public void Aggregate_OnMixedDiagnosticsForSameType_DedupesMethodsAndUnionsIds() } [Test] - public void Aggregate_DropsLinesWithoutAnyDxmsgPrefix() + public void AggregateDropsLinesWithoutAnyDxmsgPrefix() { // The harvester's hot-path filter on `OnAssemblyCompilationFinished` skips lines that // don't contain "DXMSG00" before parsing. The parser itself must also be tolerant of @@ -97,7 +97,7 @@ public void Aggregate_DropsLinesWithoutAnyDxmsgPrefix() } [Test] - public void Aggregate_OnEmptyInput_ReturnsEmptyDictionary() + public void AggregateOnEmptyInputReturnsEmptyDictionary() { // The harvester calls Aggregate even when an assembly produced zero matching messages // — the empty result is then used by ApplyCompilerMessageDrain to RETIRE the previous @@ -110,7 +110,7 @@ public void Aggregate_OnEmptyInput_ReturnsEmptyDictionary() } [Test] - public void Aggregate_OnNullInput_ReturnsEmptyDictionary() + public void AggregateOnNullInputReturnsEmptyDictionary() { Dictionary aggregated = BaseCallLogMessageParser.Aggregate(null); @@ -124,7 +124,7 @@ public void Aggregate_OnNullInput_ReturnsEmptyDictionary() // them in with deterministic dotnet-test coverage closes the gap. [Test] - public void ApplyAssemblyReports_NewType_AddedToBoth() + public void ApplyAssemblyReportsNewTypeAddedToBoth() { Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); Dictionary mergedReports = new(StringComparer.Ordinal); @@ -153,7 +153,7 @@ public void ApplyAssemblyReports_NewType_AddedToBoth() } [Test] - public void ApplyAssemblyReports_RecompileSameAssemblyDropsRetiredTypes() + public void ApplyAssemblyReportsRecompileSameAssemblyDropsRetiredTypes() { // Assembly A reports type X with method Awake. The user fixes the issue and recompiles — // A's next batch is empty. X must be removed from BOTH mergedReports AND @@ -190,7 +190,7 @@ public void ApplyAssemblyReports_RecompileSameAssemblyDropsRetiredTypes() } [Test] - public void ApplyAssemblyReports_TwoAssembliesReportSameTypeRetainAfterOneDrops() + public void ApplyAssemblyReportsTwoAssembliesReportSameTypeRetainAfterOneDrops() { // Cross-assembly survival: A and B both report type X (e.g., partial classes split across // assemblies, or duplicate type-name across modules). When A re-compiles without X, X @@ -242,7 +242,7 @@ public void ApplyAssemblyReports_TwoAssembliesReportSameTypeRetainAfterOneDrops( } [Test] - public void ApplyAssemblyReports_DifferentMethodsOnSameTypeAcrossAssemblies() + public void ApplyAssemblyReportsDifferentMethodsOnSameTypeAcrossAssemblies() { // A reports X.Awake; B reports X.OnEnable. The merged view must carry both methods on a // single X entry — this is the partial-class / split-assembly case. @@ -273,7 +273,7 @@ public void ApplyAssemblyReports_DifferentMethodsOnSameTypeAcrossAssemblies() } [Test] - public void ApplyAssemblyReports_UnknownAssemblyKeyDoesNotDisturbExistingState() + public void ApplyAssemblyReportsUnknownAssemblyKeyDoesNotDisturbExistingState() { // Sanity check: applying an empty batch for an assembly we've never seen leaves the // merged map untouched. A common refresh path on Unity 2021 is "every assembly fires @@ -302,7 +302,7 @@ public void ApplyAssemblyReports_UnknownAssemblyKeyDoesNotDisturbExistingState() } [Test] - public void ApplyAssemblyReports_NullArguments_ThrowOrTreatNullPayloadAsRetirement() + public void ApplyAssemblyReportsNullArgumentsThrowOrTreatNullPayloadAsRetirement() { Dictionary> typesByAssembly = new(StringComparer.OrdinalIgnoreCase); Dictionary mergedReports = new(StringComparer.Ordinal); @@ -355,7 +355,7 @@ public void ApplyAssemblyReports_NullArguments_ThrowOrTreatNullPayloadAsRetireme // --------------------------------------------- [Test] - public void BuildSnapshot_LogEntriesAndCompilerMessageAgree() + public void BuildSnapshotLogEntriesAndCompilerMessageAgree() { // Same type + same method reported via both paths: one entry, dedup'd diagnostic IDs, // method appears once. @@ -378,7 +378,7 @@ public void BuildSnapshot_LogEntriesAndCompilerMessageAgree() } [Test] - public void BuildSnapshot_LogEntriesOnlyVsCompilerMessageOnly() + public void BuildSnapshotLogEntriesOnlyVsCompilerMessageOnly() { // Each source independently produces a non-empty snapshot. Both halves of the dual-source // contract must work in isolation — Unity 2021 only feeds the CompilerMessage path, @@ -405,7 +405,7 @@ public void BuildSnapshot_LogEntriesOnlyVsCompilerMessageOnly() } [Test] - public void BuildSnapshot_KeepsFirstSeenFilePathLine() + public void BuildSnapshotKeepsFirstSeenFilePathLine() { // First seen wins. LogEntries reports first → its path/line stick even though merged // also has data for the same type with a different path/line. @@ -424,7 +424,7 @@ public void BuildSnapshot_KeepsFirstSeenFilePathLine() } [Test] - public void BuildSnapshot_UnionsMethodsAndDiagnosticIdsAcrossSources() + public void BuildSnapshotUnionsMethodsAndDiagnosticIdsAcrossSources() { // LogEntries says X.Awake / DXMSG006; merged says X.OnEnable / DXMSG009. The snapshot // union must carry both methods and both diagnostic IDs on a single X entry. @@ -443,7 +443,7 @@ public void BuildSnapshot_UnionsMethodsAndDiagnosticIdsAcrossSources() } [Test] - public void BuildSnapshot_EmptyInputs_ReturnsEmptySnapshot() + public void BuildSnapshotEmptyInputsReturnsEmptySnapshot() { Dictionary snapshot = BaseCallReportAggregator.BuildSnapshot(null, null); @@ -457,7 +457,7 @@ public void BuildSnapshot_EmptyInputs_ReturnsEmptySnapshot() } [Test] - public void ApplyAssemblyReports_ThreeAssembliesDisjointSetsRetireOneAndOverlap() + public void ApplyAssemblyReportsThreeAssembliesDisjointSetsRetireOneAndOverlap() { // Spec 3a: A reports {X, Y}, B reports {Y, Z}, C reports {W}. The merged snapshot must // contain {W, X, Y, Z}. Then A retires X (recompiles without it). The merged snapshot must @@ -515,7 +515,7 @@ public void ApplyAssemblyReports_ThreeAssembliesDisjointSetsRetireOneAndOverlap( } [Test] - public void Aggregate_SameAssemblyReportsSameFqnMultipleTimesInOneDrain_DedupesMethods() + public void AggregateSameAssemblyReportsSameFqnMultipleTimesInOneDrainDedupesMethods() { // Spec 3b: a single drain that contains the SAME line three times for the same FQN must // dedupe — Aggregate-then-merge always produces a single MissingBaseFor entry per method. @@ -535,7 +535,7 @@ public void Aggregate_SameAssemblyReportsSameFqnMultipleTimesInOneDrain_DedupesM } [Test] - public void BuildSnapshot_DictionaryWithNullValueDoesNotCrash() + public void BuildSnapshotDictionaryWithNullValueDoesNotCrash() { // Spec 3c: defensive — if either source dictionary contains a null ParsedTypeReport value // (a defensive shape we may see if the harvester's internal state ever decays), the @@ -559,7 +559,7 @@ public void BuildSnapshot_DictionaryWithNullValueDoesNotCrash() } [Test] - public void ApplyAssemblyReports_FilePathStickinessFirstSeenWinsAcrossAssemblies() + public void ApplyAssemblyReportsFilePathStickinessFirstSeenWinsAcrossAssemblies() { // Spec 3d: A reports type X with path=A.cs line=10. B then ALSO reports X with path=B.cs // line=20. The merged snapshot must keep A.cs/10 (first-assembly-seen wins). This pins diff --git a/llms.txt b/llms.txt index 2e32536d..0133a24f 100644 --- a/llms.txt +++ b/llms.txt @@ -16,7 +16,7 @@ DxMessaging is a high-performance messaging library for Unity (v2021.3+) that re - **Language:** C# (.NET Standard 2.0) - **Platform:** Unity 2021.3+ - **Package Manager:** OpenUPM, npm, Unity Package Manager -- **Tests:** NUnit + Unity Test Framework +- **Tests:** NUnit + Unity Test Framework (PascalCase method names, no underscores) - **Documentation:** MkDocs Material ## Key Features @@ -199,7 +199,7 @@ npx cspell "**/*" - **Code Style:** 4-space indent, explicit types (no `var`), PascalCase for public APIs - **Line Endings:** LF by default, CRLF for C#/.NET project files -- **Tests:** NUnit + Unity Test Framework, no underscores in test names +- **Tests:** NUnit + Unity Test Framework, no underscores in method names (enforced by pre-commit fixer) - **Documentation:** MkDocs Material, lazy numbering for ordered lists - **Commits:** Imperative mood, reference issues/PRs @@ -286,5 +286,5 @@ Copyright (c) 2017-2026 Wallstop Studios --- -**Last Updated:** 2026-04-28 +**Last Updated:** 2026-04-29 **Generated by:** scripts/update-llms-txt.js using package.json v2.2.0 and .llm/skills metadata diff --git a/package.json b/package.json index 6d46ab7b..512fde41 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "format:json:check": "node scripts/run-managed-prettier.js --check \"**/*.{json,asmdef,asmref}\"", "format:yaml": "node scripts/run-managed-prettier.js --write \"**/*.{yml,yaml}\"", "format:yaml:check": "node scripts/run-managed-prettier.js --check \"**/*.{yml,yaml}\"", + "check:package-json-format": "node scripts/run-managed-prettier.js --check package.json", "check:prettier:hooks": "node scripts/run-managed-prettier.js --check \"**/*.{md,markdown,json,asmdef,asmref,yml,yaml}\"", "check:yaml": "npm run format:yaml:check && pre-commit run yamllint --all-files", "lint:markdown": "markdownlint-cli2 \"**/*.md\" \"**/*.markdown\"", @@ -75,7 +76,7 @@ "validate:npm-meta": "node scripts/validate-npm-meta.js --check", "validate:pre-commit-tooling": "node scripts/validate-pre-commit-tooling.js", "validate:vscode-settings": "node scripts/validate-vscode-settings.js", - "preflight:pre-commit": "npm run validate:pre-commit-tooling && npm run check:prettier:hooks && npm run check:yaml && node scripts/generate-skills-index.js --check && npm run validate:npm-meta && node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/validate-pre-commit-tooling.test.js scripts/__tests__/run-managed-jest.test.js scripts/__tests__/run-managed-prettier.test.js scripts/__tests__/prettier-version.test.js scripts/__tests__/validate-npm-meta.test.js scripts/__tests__/generate-skills-index.test.js scripts/__tests__/shell-command.test.js scripts/__tests__/detect-shell-redirection-antipattern.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js" + "preflight:pre-commit": "npm run check:package-json-format && npm run check:prettier:hooks && npm run validate:pre-commit-tooling && npm run check:yaml && node scripts/generate-skills-index.js --check && npm run validate:npm-meta && pre-commit run script-parser-tests --all-files" }, "devDependencies": { "jest": "^30.3.0", diff --git a/scripts/__tests__/fix-csharp-underscore-methods.test.js b/scripts/__tests__/fix-csharp-underscore-methods.test.js new file mode 100644 index 00000000..d28c0cd5 --- /dev/null +++ b/scripts/__tests__/fix-csharp-underscore-methods.test.js @@ -0,0 +1,323 @@ +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const childProcess = require("child_process"); + +const { + convertMethodNameToPascalCase, + collectMethodRenames, + applyMethodRenames, +} = require("../fix-csharp-underscore-methods.js"); + +const FIXER_SCRIPT_PATH = path.resolve(__dirname, "../fix-csharp-underscore-methods.js"); +const OUTSIDE_REPO_EXCLUDED_SEGMENTS = [ + ".git", + "node_modules", + "Library", + "Obj", + "Temp", + ".vs", + ".venv", + ".artifacts", + "site", +]; + +describe("fix-csharp-underscore-methods", () => { + test("convertMethodNameToPascalCase removes underscores while preserving segment casing", () => { + expect(convertMethodNameToPascalCase("Parse_Line_Bare")).toBe("ParseLineBare"); + expect(convertMethodNameToPascalCase("E2E_Leaf_Calls_Base")).toBe("E2ELeafCallsBase"); + expect(convertMethodNameToPascalCase("__parse__line__")).toBe("ParseLine"); + expect(convertMethodNameToPascalCase("Parse__Line")).toBe("ParseLine"); + }); + + test("collectMethodRenames finds method declarations and skips op_ names", () => { + const source = [ + "public sealed class NamingTests", + "{", + " [Test]", + " public void Parse_Line_Bare() { }", + "", + " private static IEnumerable Edge_Case_Test_Data() => Array.Empty();", + "", + " public void op_Custom_Method() { }", + "}", + ].join("\n"); + + const renames = collectMethodRenames(source); + + expect(renames.get("Parse_Line_Bare")).toBe("ParseLineBare"); + expect(renames.get("Edge_Case_Test_Data")).toBe("EdgeCaseTestData"); + expect(renames.has("op_Custom_Method")).toBe(false); + }); + + test("applyMethodRenames updates declarations and nameof references", () => { + const source = [ + "public sealed class NamingTests", + "{", + " [Test]", + " public void Parse_Line_Bare()", + " {", + " string methodName = nameof(Parse_Line_Bare);", + " Parse_Line_Bare();", + " }", + "}", + ].join("\n"); + + const renames = new Map([["Parse_Line_Bare", "ParseLineBare"]]); + const result = applyMethodRenames(source, renames); + + expect(result.renameCount).toBe(1); + expect(result.updatedContent).toContain("public void ParseLineBare()"); + expect(result.updatedContent).toContain("nameof(ParseLineBare)"); + expect(result.updatedContent).toContain("ParseLineBare();"); + }); + + test("applyMethodRenames counts unique renamed identifiers, not total occurrences", () => { + const source = [ + "public sealed class NamingTests", + "{", + " public void Method_Name()", + " {", + " Method_Name();", + " Method_Name();", + " }", + "}", + ].join("\n"); + + const renames = new Map([["Method_Name", "MethodName"]]); + const result = applyMethodRenames(source, renames); + + expect(result.renameCount).toBe(1); + expect(result.updatedContent).toContain("MethodName();"); + expect(result.updatedContent).not.toContain("Method_Name"); + }); + + test("--check exits non-zero when a fix is required", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-check-")); + const filePath = path.join(tempDir, "NeedsFix.cs"); + + try { + fs.writeFileSync( + filePath, + [ + "public sealed class NeedsFix", + "{", + " public void Parse_Line_Bare() { }", + "}", + ].join("\n"), + "utf8" + ); + + const result = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, "--check", filePath], + { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8", + } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Found C# methods with underscores"); + expect(result.stderr).toContain("NeedsFix.cs"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("CLI rewrites file content in place", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-fix-")); + const filePath = path.join(tempDir, "FixMe.cs"); + + try { + fs.writeFileSync( + filePath, + [ + "public sealed class FixMe", + "{", + " public void Parse_Line_Bare() { }", + "}", + ].join("\n"), + "utf8" + ); + + const result = childProcess.spawnSync(process.execPath, [FIXER_SCRIPT_PATH, filePath], { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + + const updated = fs.readFileSync(filePath, "utf8"); + expect(updated).toContain("ParseLineBare"); + expect(updated).not.toContain("Parse_Line_Bare"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test.each(OUTSIDE_REPO_EXCLUDED_SEGMENTS)( + "--check processes explicitly passed files in outside-repo %s segment", + (excludedSegment) => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "dxmsg-csharp-underscore-outside-temp-check-") + ); + const outsideSegmentDir = path.join(tempDir, excludedSegment); + const filePath = path.join(outsideSegmentDir, "OutsideSegmentNeedsFix.cs"); + + try { + fs.mkdirSync(outsideSegmentDir, { recursive: true }); + fs.writeFileSync( + filePath, + [ + "public sealed class OutsideSegmentNeedsFix", + "{", + " public void Parse_Line_Bare() { }", + "}", + ].join("\n"), + "utf8" + ); + + const result = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, "--check", filePath], + { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8", + } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Found C# methods with underscores"); + expect(result.stderr).toContain("OutsideSegmentNeedsFix.cs"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + ); + + test.each(OUTSIDE_REPO_EXCLUDED_SEGMENTS)( + "CLI rewrites explicitly passed files in outside-repo %s segment", + (excludedSegment) => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "dxmsg-csharp-underscore-outside-temp-rewrite-") + ); + const outsideSegmentDir = path.join(tempDir, excludedSegment); + const filePath = path.join(outsideSegmentDir, "OutsideSegmentRewrite.cs"); + + try { + fs.mkdirSync(outsideSegmentDir, { recursive: true }); + fs.writeFileSync( + filePath, + [ + "public sealed class OutsideSegmentRewrite", + "{", + " public void Parse_Line_Bare() { }", + "}", + ].join("\n"), + "utf8" + ); + + const result = childProcess.spawnSync(process.execPath, [FIXER_SCRIPT_PATH, filePath], { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + + const updated = fs.readFileSync(filePath, "utf8"); + expect(updated).toContain("ParseLineBare"); + expect(updated).not.toContain("Parse_Line_Bare"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + ); + + test("--check processes explicitly passed relative paths outside the repo", () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "dxmsg-csharp-underscore-outside-relative-check-") + ); + const filePath = path.join(tempDir, "OutsideRelativeNeedsFix.cs"); + + try { + fs.writeFileSync( + filePath, + [ + "public sealed class OutsideRelativeNeedsFix", + "{", + " public void Parse_Line_Bare() { }", + "}", + ].join("\n"), + "utf8" + ); + + const repoRoot = path.resolve(__dirname, "../.."); + const relativeOutsidePath = path.relative(repoRoot, filePath); + const result = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, "--check", relativeOutsidePath], + { + cwd: repoRoot, + encoding: "utf8", + } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Found C# methods with underscores"); + expect(result.stderr).toContain("OutsideRelativeNeedsFix.cs"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("explicitly passed files in repo Temp remain skipped", () => { + const tempDir = path.resolve(__dirname, "../../Temp/dxmsg-csharp-underscore-test"); + const filePath = path.join(tempDir, "RepoTempSkip.cs"); + + try { + fs.mkdirSync(tempDir, { recursive: true }); + fs.writeFileSync( + filePath, + [ + "public sealed class RepoTempSkip", + "{", + " public void Parse_Line_Bare() { }", + "}", + ].join("\n"), + "utf8" + ); + + const checkResult = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, "--check", filePath], + { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8", + } + ); + + expect(checkResult.status).toBe(0); + expect(checkResult.stdout).toContain("No C# files to process."); + + const rewriteResult = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, filePath], + { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8", + } + ); + + expect(rewriteResult.status).toBe(0); + + const contentAfterRewrite = fs.readFileSync(filePath, "utf8"); + expect(contentAfterRewrite).toContain("Parse_Line_Bare"); + expect(contentAfterRewrite).not.toContain("ParseLineBare"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/bin.meta b/scripts/__tests__/fix-csharp-underscore-methods.test.js.meta similarity index 67% rename from SourceGenerators/WallstopStudios.DxMessaging.Analyzer/bin.meta rename to scripts/__tests__/fix-csharp-underscore-methods.test.js.meta index 6b0acb2e..43c2f0fd 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/bin.meta +++ b/scripts/__tests__/fix-csharp-underscore-methods.test.js.meta @@ -1,6 +1,5 @@ fileFormatVersion: 2 -guid: d7945a1325a18234d84313f159ea1aae -folderAsset: yes +guid: 0fe4784c0c0df39459926204b2e6d033 DefaultImporter: externalObjects: {} userData: diff --git a/scripts/__tests__/generate-skills-index.test.js b/scripts/__tests__/generate-skills-index.test.js index 4f14bec9..8b4118cf 100644 --- a/scripts/__tests__/generate-skills-index.test.js +++ b/scripts/__tests__/generate-skills-index.test.js @@ -20,7 +20,7 @@ const { BRAND_NAMES, } = require('../generate-skills-index.js'); const { normalizeToLf } = require('../lib/quote-parser'); -const { toShellCommand } = require('../lib/shell-command'); +const { toShellCommand, spawnPlatformCommandSync } = require('../lib/shell-command'); describe("generate-skills-index", () => { describe("BRAND_NAMES mapping", () => { @@ -379,7 +379,7 @@ describe("generate-skills-index", () => { }); describe("formatWithPrettier", () => { - test("invokes prettier via platform-aware shell command helper", () => { + test("passes base command to provided spawn implementation", () => { const spawnSyncMock = jest.fn(() => ({ status: 0, stdout: "formatted output", @@ -405,6 +405,62 @@ describe("generate-skills-index", () => { ); }); + test("delegates platform resolution to spawn helper when wrapper enforces win32", () => { + const spawnSyncMock = jest.fn(() => ({ + status: 0, + stdout: "formatted output", + stderr: "", + })); + + const win32Wrapper = (command, args, options) => + spawnPlatformCommandSync(command, args, options, spawnSyncMock, "win32"); + + const result = formatWithPrettier("raw input", win32Wrapper); + + expect(result).toBe("formatted output"); + expect(spawnSyncMock).toHaveBeenCalledTimes(1); + expect(spawnSyncMock).toHaveBeenCalledWith( + "npx.cmd", + expect.arrayContaining(["--yes", "prettier", "--stdin-filepath"]), + expect.objectContaining({ + input: "raw input", + encoding: "utf8", + cwd: expect.any(String), + shell: true, + windowsHide: true, + }) + ); + }); + + test("delegates platform resolution to spawn helper when wrapper enforces non-win32", () => { + const spawnSyncMock = jest.fn(() => ({ + status: 0, + stdout: "formatted output", + stderr: "", + })); + + const linuxWrapper = (command, args, options) => + spawnPlatformCommandSync(command, args, options, spawnSyncMock, "linux"); + + const result = formatWithPrettier("raw input", linuxWrapper); + + expect(result).toBe("formatted output"); + expect(spawnSyncMock).toHaveBeenCalledTimes(1); + + const [command, args, options] = spawnSyncMock.mock.calls[0]; + expect(command).toBe("npx"); + expect(args).toEqual(expect.arrayContaining(["--yes", "prettier", "--stdin-filepath"])); + expect(options).toEqual( + expect.objectContaining({ + input: "raw input", + encoding: "utf8", + cwd: expect.any(String), + }) + ); + expect(options.shell).toBeUndefined(); + expect(options.windowsHide).toBeUndefined(); + }); + test("throws when prettier execution fails", () => { const spawnSyncMock = jest.fn(() => ({ status: 1, diff --git a/scripts/__tests__/pre-commit-hook-stage-policy.test.js b/scripts/__tests__/pre-commit-hook-stage-policy.test.js index 3eaf162b..b60dc47f 100644 --- a/scripts/__tests__/pre-commit-hook-stage-policy.test.js +++ b/scripts/__tests__/pre-commit-hook-stage-policy.test.js @@ -109,6 +109,20 @@ describe("pre-commit hook stage policy", () => { expect(stages).toEqual(expect.arrayContaining(["pre-commit", "pre-push"])); }); + test("fix-csharp-underscore-methods hook runs at pre-commit", () => { + const fixerBlock = findHookBlock(configLines, "fix-csharp-underscore-methods"); + expect(fixerBlock).not.toBeNull(); + + const stages = extractStagesFromHookBlock(fixerBlock); + expect(stages).toEqual(expect.arrayContaining(["pre-commit"])); + + const blockText = fixerBlock.lines.join("\n"); + expect(blockText).toContain("scripts/fix-csharp-underscore-methods.js"); + expect(blockText).toContain("git add \"$@\""); + expect(blockText).not.toContain("|| true"); + expect(blockText).not.toContain("|| echo"); + }); + test("script-parser-tests includes npm-meta and shell-safety regressions", () => { const parserTestsBlock = findHookBlock(configLines, "script-parser-tests"); expect(parserTestsBlock).not.toBeNull(); @@ -119,5 +133,6 @@ describe("pre-commit hook stage policy", () => { expect(blockText).toContain("scripts/__tests__/prettier-version.test.js"); expect(blockText).toContain("scripts/__tests__/shell-command.test.js"); expect(blockText).toContain("scripts/__tests__/detect-shell-redirection-antipattern.test.js"); + expect(blockText).toContain("scripts/__tests__/fix-csharp-underscore-methods.test.js"); }); }); diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/obj.meta b/scripts/__tests__/prettier-version.test.js.meta similarity index 67% rename from SourceGenerators/WallstopStudios.DxMessaging.Analyzer/obj.meta rename to scripts/__tests__/prettier-version.test.js.meta index a849d54d..fd82decc 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/obj.meta +++ b/scripts/__tests__/prettier-version.test.js.meta @@ -1,6 +1,5 @@ fileFormatVersion: 2 -guid: 3e4ef19dc810e5c44a96ae5831e1e393 -folderAsset: yes +guid: 18c325d9690f6ac499491840c9b680a7 DefaultImporter: externalObjects: {} userData: diff --git a/scripts/__tests__/run-managed-prettier.test.js.meta b/scripts/__tests__/run-managed-prettier.test.js.meta new file mode 100644 index 00000000..6faaaf4e --- /dev/null +++ b/scripts/__tests__/run-managed-prettier.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 12fb843a01e6904449d0c37b841fda3a +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/shell-command.test.js.meta b/scripts/__tests__/shell-command.test.js.meta new file mode 100644 index 00000000..fc47da3f --- /dev/null +++ b/scripts/__tests__/shell-command.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ad6d93d622d52834b9464dde144eca68 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/validate-pre-commit-tooling.test.js b/scripts/__tests__/validate-pre-commit-tooling.test.js index b1dbfd83..bb370fa3 100644 --- a/scripts/__tests__/validate-pre-commit-tooling.test.js +++ b/scripts/__tests__/validate-pre-commit-tooling.test.js @@ -11,11 +11,17 @@ const path = require("path"); const { parseHookEntries, parseHookIds, + hasRequiredParserPrecheckCommand, + hasRequiredPackageJsonFormatCommand, hasNpxInstallPolicy, hasManagedJestInvocation, hasManagedPrettierInvocation, validateYamllintPolicy, validatePrettierVersionResolution, + validatePreflightScriptPolicy, + REQUIRED_PRECHECK_PARSER_COMMAND, + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + REQUIRED_PARSER_SUITE_HOOK_ID, validateConfigContent, validateConfigFile, } = require("../validate-pre-commit-tooling.js"); @@ -84,6 +90,38 @@ describe("validate-pre-commit-tooling", () => { expect(bad).toBe(false); }); + test("hasRequiredParserPrecheckCommand detects parser command as chained step", () => { + const script = [ + "npm run validate:pre-commit-tooling", + "npm run check:prettier:hooks", + REQUIRED_PRECHECK_PARSER_COMMAND, + ].join(" && "); + + expect(hasRequiredParserPrecheckCommand(script)).toBe(true); + }); + + test("hasRequiredParserPrecheckCommand rejects substring-only matches", () => { + const script = "npm run validate:pre-commit-tooling && echo pre-commit run script-parser-tests --all-files"; + + expect(hasRequiredParserPrecheckCommand(script)).toBe(false); + }); + + test("hasRequiredPackageJsonFormatCommand detects package.json format precheck step", () => { + const script = [ + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + "npm run check:prettier:hooks", + REQUIRED_PRECHECK_PARSER_COMMAND, + ].join(" && "); + + expect(hasRequiredPackageJsonFormatCommand(script)).toBe(true); + }); + + test("hasRequiredPackageJsonFormatCommand rejects substring-only matches", () => { + const script = "npm run validate:pre-commit-tooling && echo npm run check:package-json-format"; + + expect(hasRequiredPackageJsonFormatCommand(script)).toBe(false); + }); + test("hasManagedJestInvocation detects unmanaged bare jest command", () => { expect(hasManagedJestInvocation("jest --runTestsByPath foo.test.js")).toBe(false); expect(hasManagedJestInvocation("node scripts/run-managed-jest.js --runTestsByPath foo.test.js")).toBe(true); @@ -115,7 +153,33 @@ describe("validate-pre-commit-tooling", () => { " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/c.test.js", ].join("\n"); - const violations = validateConfigContent(content); + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, + }, + }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + ].join("\n"); + } + + return ""; + }); + + const violations = validateConfigContent(content, { + readFileSyncImpl: readFileSyncMock, + packageJsonPath: "/tmp/package.json", + preCommitConfigPath: "/tmp/pre-commit.yaml", + }); expect(violations).toHaveLength(3); expect(violations.filter((violation) => violation.hookId === "bad-npx")).toHaveLength(2); @@ -211,7 +275,33 @@ describe("validate-pre-commit-tooling", () => { " entry: npx --yes prettier@3.8.3 --write", ].join("\n"); - const violations = validateConfigContent(content); + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, + }, + }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + ].join("\n"); + } + + return ""; + }); + + const violations = validateConfigContent(content, { + readFileSyncImpl: readFileSyncMock, + packageJsonPath: "/tmp/package.json", + preCommitConfigPath: "/tmp/pre-commit.yaml", + }); expect(violations).toHaveLength(1); expect(violations[0].hookId).toBe("prettier"); @@ -226,6 +316,7 @@ describe("validate-pre-commit-tooling", () => { expect(packageJson.scripts["check:prettier:hooks"]).toContain( "node scripts/run-managed-prettier.js --check" ); + expect(preflightScript).toContain(REQUIRED_PACKAGE_JSON_FORMAT_COMMAND); expect(preflightScript).toContain("npm run check:prettier:hooks"); expect(packageJson.scripts["check:yaml"]).toContain( "pre-commit run yamllint --all-files" @@ -233,8 +324,144 @@ describe("validate-pre-commit-tooling", () => { expect(preflightScript).toContain("npm run check:yaml"); expect(preflightScript).toContain("node scripts/generate-skills-index.js --check"); expect(preflightScript).toContain("npm run validate:npm-meta"); - expect(preflightScript).toContain("scripts/__tests__/generate-skills-index.test.js"); - expect(preflightScript).toContain("scripts/__tests__/shell-command.test.js"); + expect(preflightScript).toContain(REQUIRED_PRECHECK_PARSER_COMMAND); + expect(preflightScript).not.toContain("node scripts/run-managed-jest.js --runTestsByPath"); + }); + + test("validatePreflightScriptPolicy passes when parser precheck command exists", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, + }, + }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + ].join("\n"); + } + + return ""; + }); + + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(0); + expect(readFileSyncMock).toHaveBeenCalledWith("/tmp/package.json", "utf8"); + expect(readFileSyncMock).toHaveBeenCalledWith("/tmp/pre-commit.yaml", "utf8"); + }); + + test("validatePreflightScriptPolicy reports missing parser precheck command", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling`, + }, + }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + ].join("\n"); + } + + return ""; + }); + + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_PRECHECK_PARSER_COMMAND); + }); + + test("validatePreflightScriptPolicy reports missing package.json format precheck command", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `npm run validate:pre-commit-tooling && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, + }, + }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + ].join("\n"); + } + + return ""; + }); + + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_PACKAGE_JSON_FORMAT_COMMAND); + }); + + test("validatePreflightScriptPolicy reports missing parser suite hook", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, + }, + }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + " - id: alpha", + " entry: node scripts/alpha.js", + ].join("\n"); + } + + return ""; + }); + + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_PARSER_SUITE_HOOK_ID); }); test("validateConfigFile handles CRLF and lone CR line endings", () => { @@ -253,9 +480,10 @@ describe("validate-pre-commit-tooling", () => { fs.writeFileSync(filePath, content, "utf8"); const violations = validateConfigFile(filePath); - expect(violations).toHaveLength(3); + expect(violations).toHaveLength(4); expect(violations.filter((violation) => violation.hookId === "bad")).toHaveLength(2); expect(violations.some((violation) => violation.hookId === "yamllint")).toBe(true); + expect(violations.some((violation) => violation.hookId === "preflight-script")).toBe(true); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } diff --git a/scripts/fix-csharp-underscore-methods.js b/scripts/fix-csharp-underscore-methods.js new file mode 100644 index 00000000..44ed80ed --- /dev/null +++ b/scripts/fix-csharp-underscore-methods.js @@ -0,0 +1,371 @@ +#!/usr/bin/env node +/* + Fix C# method names containing underscores by converting them to PascalCase. + - Converts names like Parse_Line_Bare -> ParseLineBare. + - Skips names that start with op_ (operator overload metadata names). + - By default, processes staged C# files; accepts explicit file paths. +*/ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const METHOD_DECLARATION_PATTERN = + /^\s*(?:(?:\[[^\]\r\n]+\]\s*)*)(?:(?:public|private|protected|internal)\s+)?(?:(?:static|virtual|override|abstract|sealed|async|new|extern|partial|unsafe|readonly)\s+)*(?:[\w<>\[\],.?]+\s+)+(?[A-Za-z_]\w*_[A-Za-z0-9_]+)\s*(?:<[^>\r\n]+>\s*)?\(/gm; + +const EXCLUDED_DIRECTORY_PATTERNS = [ + /(^|[\\/])\.git([\\/]|$)/i, + /(^|[\\/])node_modules([\\/]|$)/i, + /(^|[\\/])Library([\\/]|$)/, + /(^|[\\/])(Obj|obj)([\\/]|$)/, + /(^|[\\/])Temp([\\/]|$)/i, + /(^|[\\/])\.vs([\\/]|$)/, + /(^|[\\/])\.venv([\\/]|$)/, + /(^|[\\/])\.artifacts([\\/]|$)/, + /(^|[\\/])site([\\/]|$)/, +]; + +function normalizeToLf(value) { + return String(value).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +function getGitRepoRoot() { + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + cwd: process.cwd(), + encoding: "utf8", + }); + + if (result.status === 0 && result.stdout) { + return result.stdout.trim(); + } + + if (result.status !== 0) { + console.error( + "Warning: unable to determine git repository root; defaulting to current working directory." + ); + } + + return process.cwd(); +} + +function parseArgs(argv) { + const fileArgs = []; + let checkOnly = false; + let allFiles = false; + + for (const arg of argv) { + if (arg === "--check") { + checkOnly = true; + continue; + } + + if (arg === "--all") { + allFiles = true; + continue; + } + + fileArgs.push(arg); + } + + return { checkOnly, allFiles, fileArgs }; +} + +function isExcludedPath(fullPath) { + return EXCLUDED_DIRECTORY_PATTERNS.some((pattern) => pattern.test(fullPath)); +} + +function isPathInsideRoot(rootDir, fullPath) { + const normalizedRootDir = path.resolve(rootDir); + const normalizedFullPath = path.resolve(fullPath); + const relativePath = path.relative(normalizedRootDir, normalizedFullPath); + + if (relativePath === "") { + return true; + } + + // On Windows, different drive letters can yield an absolute relative path. + return !relativePath.startsWith("..") && !path.isAbsolute(relativePath); +} + +// INTERNAL ONLY: rootDir is expected to be the repository root. +function walkCsharpFiles(rootDir, files = []) { + let entries; + + try { + entries = fs.readdirSync(rootDir, { withFileTypes: true }); + } catch { + return files; + } + + for (const entry of entries) { + const fullPath = path.join(rootDir, entry.name); + + if (isExcludedPath(fullPath)) { + continue; + } + + if (entry.isDirectory()) { + walkCsharpFiles(fullPath, files); + continue; + } + + if (entry.isFile() && fullPath.endsWith(".cs") && !fullPath.endsWith(".meta")) { + files.push(fullPath); + } + } + + return files; +} + +function resolveExplicitFiles(repoRoot, fileArgs) { + const resolved = []; + const seen = new Set(); + + for (const rawArg of fileArgs) { + if (!rawArg.endsWith(".cs")) { + continue; + } + + const candidatePath = path.resolve(repoRoot, rawArg); + + if (!fs.existsSync(candidatePath)) { + continue; + } + + // Apply excluded-directory patterns only for repo-local paths. + if (isPathInsideRoot(repoRoot, candidatePath) && isExcludedPath(candidatePath)) { + continue; + } + + const stats = fs.statSync(candidatePath); + if (!stats.isFile() || candidatePath.endsWith(".meta")) { + continue; + } + + if (!seen.has(candidatePath)) { + seen.add(candidatePath); + resolved.push(candidatePath); + } + } + + return resolved; +} + +function getStagedCsharpFiles(repoRoot) { + const result = spawnSync( + "git", + ["diff", "--cached", "--name-only", "--diff-filter=ACMR", "--", "*.cs"], + { + cwd: repoRoot, + encoding: "utf8", + } + ); + + if (result.status !== 0) { + console.error( + "Warning: unable to read staged C# files from git; no files were processed." + ); + return []; + } + + if (!result.stdout) { + return []; + } + + const files = []; + const seen = new Set(); + + for (const relativePath of normalizeToLf(result.stdout) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean)) { + const fullPath = path.resolve(repoRoot, relativePath); + + if (!fullPath.endsWith(".cs") || fullPath.endsWith(".meta")) { + continue; + } + + if (!fs.existsSync(fullPath)) { + continue; + } + + if (isExcludedPath(fullPath)) { + continue; + } + + if (!seen.has(fullPath)) { + seen.add(fullPath); + files.push(fullPath); + } + } + + return files; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function convertMethodNameToPascalCase(methodName) { + return methodName + .split("_") + .filter((segment) => segment.length > 0) + .map((segment) => { + if (segment.length === 1) { + return segment.toUpperCase(); + } + + return `${segment[0].toUpperCase()}${segment.slice(1)}`; + }) + .join(""); +} + +function collectMethodRenames(content) { + const methodRenames = new Map(); + let match; + + while ((match = METHOD_DECLARATION_PATTERN.exec(content)) !== null) { + const methodName = match.groups ? match.groups.name : ""; + + if (!methodName || methodName.startsWith("op_")) { + continue; + } + + const newName = convertMethodNameToPascalCase(methodName); + if (!newName || newName === methodName) { + continue; + } + + methodRenames.set(methodName, newName); + } + + return methodRenames; +} + +function applyMethodRenames(content, methodRenames) { + let updatedContent = content; + let renameCount = 0; + + for (const [oldName, newName] of methodRenames.entries()) { + const namePattern = new RegExp( + `(? 0) { + return resolveExplicitFiles(repoRoot, parsedArgs.fileArgs); + } + + if (parsedArgs.allFiles) { + return walkCsharpFiles(repoRoot); + } + + return getStagedCsharpFiles(repoRoot); +} + +function main() { + const repoRoot = getGitRepoRoot(); + const parsedArgs = parseArgs(process.argv.slice(2)); + const files = resolveTargetFiles(repoRoot, parsedArgs); + + if (files.length === 0) { + console.log("No C# files to process."); + return 0; + } + + const changedFiles = []; + let totalRenamed = 0; + + for (const filePath of files) { + const result = processFile(filePath, parsedArgs.checkOnly); + + if (!result.changed) { + continue; + } + + changedFiles.push(path.relative(repoRoot, filePath)); + totalRenamed += result.renameCount; + } + + if (parsedArgs.checkOnly) { + if (changedFiles.length > 0) { + console.error("Found C# methods with underscores. Run fixer to update these files:"); + for (const relativePath of changedFiles) { + console.error(`- ${relativePath}`); + } + console.error( + "Method names must use PascalCase without underscores (for example: ParseLineInput)." + ); + return 1; + } + + console.log("No C# method naming fixes required."); + return 0; + } + + if (changedFiles.length > 0) { + console.log( + `Updated ${changedFiles.length} file(s); renamed ${totalRenamed} method identifier(s).` + ); + for (const relativePath of changedFiles) { + console.log(`Fixed: ${relativePath}`); + } + } else { + console.log("No C# method naming fixes required."); + } + + return 0; +} + +module.exports = { + METHOD_DECLARATION_PATTERN, + convertMethodNameToPascalCase, + collectMethodRenames, + applyMethodRenames, + processFile, + parseArgs, + resolveTargetFiles, +}; + +if (require.main === module) { + process.exit(main()); +} diff --git a/scripts/fix-csharp-underscore-methods.js.meta b/scripts/fix-csharp-underscore-methods.js.meta new file mode 100644 index 00000000..6b32fe31 --- /dev/null +++ b/scripts/fix-csharp-underscore-methods.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 87b0110e983e26248913b12d1530c3aa +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/generate-skills-index.js b/scripts/generate-skills-index.js index caba70c7..2a2c7b8d 100644 --- a/scripts/generate-skills-index.js +++ b/scripts/generate-skills-index.js @@ -133,6 +133,7 @@ function formatWithPrettier(content, spawnSyncImpl = spawnPlatformCommandSync) { INDEX_PATH, ]; + // Keep the base command token here; platform-specific shim resolution belongs in spawnPlatformCommandSync. const result = spawnSyncImpl("npx", prettierArgs, { cwd: REPO_ROOT, input: content, diff --git a/scripts/lib/prettier-version.js.meta b/scripts/lib/prettier-version.js.meta new file mode 100644 index 00000000..710b8fae --- /dev/null +++ b/scripts/lib/prettier-version.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ad24d338159eaf146b9f9fe1212e1ce4 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/run-managed-prettier.js.meta b/scripts/run-managed-prettier.js.meta new file mode 100644 index 00000000..94781039 --- /dev/null +++ b/scripts/run-managed-prettier.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8ec0e164de183b24ab99f36710f31dbc +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/update-llms-txt.js b/scripts/update-llms-txt.js index 7008a7f2..32471f72 100755 --- a/scripts/update-llms-txt.js +++ b/scripts/update-llms-txt.js @@ -173,7 +173,7 @@ DxMessaging is a high-performance messaging library for Unity (v2021.3+) that re - **Language:** C# (.NET Standard 2.0) - **Platform:** Unity 2021.3+ - **Package Manager:** OpenUPM, npm, Unity Package Manager -- **Tests:** NUnit + Unity Test Framework +- **Tests:** NUnit + Unity Test Framework (PascalCase method names, no underscores) - **Documentation:** MkDocs Material ## Key Features @@ -356,7 +356,7 @@ npx cspell "**/*" - **Code Style:** 4-space indent, explicit types (no \`var\`), PascalCase for public APIs - **Line Endings:** LF by default, CRLF for C#/.NET project files -- **Tests:** NUnit + Unity Test Framework, no underscores in test names +- **Tests:** NUnit + Unity Test Framework, no underscores in method names (enforced by pre-commit fixer) - **Documentation:** MkDocs Material, lazy numbering for ordered lists - **Commits:** Imperative mood, reference issues/PRs diff --git a/scripts/validate-pre-commit-tooling.js b/scripts/validate-pre-commit-tooling.js index c7508add..bd8ae779 100644 --- a/scripts/validate-pre-commit-tooling.js +++ b/scripts/validate-pre-commit-tooling.js @@ -19,6 +19,12 @@ const { } = require("./lib/prettier-version"); const PRE_COMMIT_CONFIG_PATH = path.join(__dirname, "..", ".pre-commit-config.yaml"); +const PACKAGE_JSON_PATH = path.join(__dirname, "..", "package.json"); +const REQUIRED_PRECHECK_PARSER_COMMAND = + "pre-commit run script-parser-tests --all-files"; +const REQUIRED_PACKAGE_JSON_FORMAT_COMMAND = + "npm run check:package-json-format"; +const REQUIRED_PARSER_SUITE_HOOK_ID = "script-parser-tests"; class Violation { constructor(hookId, line, message, entry) { @@ -112,6 +118,35 @@ function tokenizeCommand(entry) { return tokens.map((token) => token.replace(/^['"]|['"]$/g, "")); } +function escapeRegexLiteral(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function hasRequiredPreflightCommand(preflightScript, requiredCommand) { + if (typeof preflightScript !== "string" || preflightScript.trim().length === 0) { + return false; + } + + const normalizedScript = preflightScript.replace(/\s+/g, " ").trim(); + const normalizedRequired = requiredCommand.replace(/\s+/g, " ").trim(); + const commandRegex = new RegExp( + `(?:^|&&\\s*)${escapeRegexLiteral(normalizedRequired)}(?:\\s*&&|$)` + ); + + return commandRegex.test(normalizedScript); +} + +function hasRequiredParserPrecheckCommand(preflightScript) { + return hasRequiredPreflightCommand(preflightScript, REQUIRED_PRECHECK_PARSER_COMMAND); +} + +function hasRequiredPackageJsonFormatCommand(preflightScript) { + return hasRequiredPreflightCommand( + preflightScript, + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND + ); +} + function hasNpxInstallPolicy(entry) { const tokens = tokenizeCommand(entry); let foundNpx = false; @@ -309,18 +344,135 @@ function validatePrettierVersionResolution( return violations; } -function validateConfigContent(content) { +function validatePreflightScriptPolicy( + readFileSyncImpl = fs.readFileSync, + packageJsonPath = PACKAGE_JSON_PATH, + preCommitConfigPath = PRE_COMMIT_CONFIG_PATH +) { + const violations = []; + let packageJson; + let preCommitConfig; + + try { + packageJson = JSON.parse(readFileSyncImpl(packageJsonPath, "utf8")); + } catch (error) { + violations.push( + new Violation( + "preflight-script", + 1, + "Unable to parse package.json while validating preflight script policy.", + error.message + ) + ); + return violations; + } + + const preflightScript = packageJson?.scripts?.["preflight:pre-commit"]; + if (typeof preflightScript !== "string" || preflightScript.trim().length === 0) { + violations.push( + new Violation( + "preflight-script", + 1, + "Missing package.json scripts.preflight:pre-commit command.", + "package.json" + ) + ); + return violations; + } + + if (!hasRequiredPackageJsonFormatCommand(preflightScript)) { + violations.push( + new Violation( + "preflight-script", + 1, + `preflight:pre-commit must include '${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND}' so package.json formatting drift is caught before hooks.`, + preflightScript + ) + ); + } + + if (!hasRequiredParserPrecheckCommand(preflightScript)) { + violations.push( + new Violation( + "preflight-script", + 1, + `preflight:pre-commit must include '${REQUIRED_PRECHECK_PARSER_COMMAND}' to match hook parser coverage.`, + preflightScript + ) + ); + } + + try { + preCommitConfig = readFileSyncImpl(preCommitConfigPath, "utf8"); + } catch (error) { + violations.push( + new Violation( + "preflight-script", + 1, + "Unable to read .pre-commit-config.yaml while validating preflight parser coverage.", + error.message + ) + ); + return violations; + } + + const hasParserSuiteHook = parseHookIds(preCommitConfig).some( + (hook) => hook.id === REQUIRED_PARSER_SUITE_HOOK_ID + ); + if (!hasParserSuiteHook) { + violations.push( + new Violation( + "preflight-script", + 1, + `Missing required '${REQUIRED_PARSER_SUITE_HOOK_ID}' hook in .pre-commit-config.yaml.`, + ".pre-commit-config.yaml" + ) + ); + } + + return violations; +} + +function validateConfigContent( + content, + { + readFileSyncImpl = fs.readFileSync, + packageJsonPath = PACKAGE_JSON_PATH, + preCommitConfigPath = PRE_COMMIT_CONFIG_PATH, + } = {} +) { const hooks = parseHookEntries(content); return [ + ...validatePreflightScriptPolicy( + readFileSyncImpl, + packageJsonPath, + preCommitConfigPath + ), ...validateHookEntries(hooks), ...validateYamllintPolicy(content), ...validatePrettierVersionResolution(), ]; } -function validateConfigFile(filePath = PRE_COMMIT_CONFIG_PATH) { - const content = fs.readFileSync(filePath, "utf8"); - return validateConfigContent(content); +function validateConfigFile( + filePath = PRE_COMMIT_CONFIG_PATH, + readFileSyncImpl = fs.readFileSync +) { + const content = readFileSyncImpl(filePath, "utf8"); + const resolvedFilePath = path.resolve(filePath); + + const readFileSyncWithCachedConfig = (targetPath, encoding) => { + if (path.resolve(targetPath) === resolvedFilePath) { + return content; + } + + return readFileSyncImpl(targetPath, encoding); + }; + + return validateConfigContent(content, { + readFileSyncImpl: readFileSyncWithCachedConfig, + preCommitConfigPath: filePath, + }); } function main() { @@ -346,6 +498,10 @@ module.exports = { parseHookEntries, parseHookIds, tokenizeCommand, + escapeRegexLiteral, + hasRequiredPreflightCommand, + hasRequiredParserPrecheckCommand, + hasRequiredPackageJsonFormatCommand, hasNpxInstallPolicy, usesManagedJestWrapper, usesManagedPrettierWrapper, @@ -355,6 +511,11 @@ module.exports = { validateHookEntries, validateYamllintPolicy, validatePrettierVersionResolution, + validatePreflightScriptPolicy, + PACKAGE_JSON_PATH, + REQUIRED_PRECHECK_PARSER_COMMAND, + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + REQUIRED_PARSER_SUITE_HOOK_ID, validateConfigContent, validateConfigFile, }; From b55b3d9f56bd6db1e25721b9d468845cc7cd439c Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 29 Apr 2026 10:55:14 -0700 Subject: [PATCH 04/12] PR feedback --- .github/workflows/deploy-docs.yml | 18 ++++++++- .../workflows/pre-commit-tooling-check.yml | 37 +++++++++++++++++-- .github/workflows/validate-docs.yml | 17 ++++++++- docs/reference/analyzers.md | 4 +- mkdocs.yml | 1 + 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 39328165..43de94e1 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -77,7 +77,23 @@ jobs: echo "✅ mkdocs.yml syntax validation passed" - name: Build documentation - run: mkdocs build --strict --site-dir _site + run: | + set -o pipefail + mkdocs build --strict --site-dir _site 2>&1 | tee build.log + + - name: Emit mkdocs diagnostics on failure + if: failure() + run: | + if [ -f build.log ]; then + echo "::group::MkDocs warnings and errors" + grep -nEi "warning|error|not found|aborted" build.log || true + echo "::endgroup::" + echo "::group::Last 120 lines of build.log" + tail -n 120 build.log || true + echo "::endgroup::" + else + echo "::warning::build.log was not generated." + fi - name: Validate build output run: | diff --git a/.github/workflows/pre-commit-tooling-check.yml b/.github/workflows/pre-commit-tooling-check.yml index b964c22d..22e84434 100644 --- a/.github/workflows/pre-commit-tooling-check.yml +++ b/.github/workflows/pre-commit-tooling-check.yml @@ -60,22 +60,53 @@ jobs: persist-credentials: false - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" + - name: Diagnose Node dependency files + shell: bash + run: | + set -euo pipefail + echo "Workspace: $PWD" + for file in package.json package-lock.json; do + if [ -f "$file" ]; then + echo "Found: $file" + else + echo "Missing: $file" + fi + done + + if [ ! -f package.json ]; then + echo "::error::package.json is required for this workflow." + exit 1 + fi + + if [ ! -f package-lock.json ]; then + echo "::warning::package-lock.json is missing. npm ci will fail with a clear error in the next step." + fi + - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: "20" cache: npm - cache-dependency-path: package-lock.json + # Use package.json as a stable cache key input. package-lock.json path + # resolution has been flaky on hosted runners in this matrix job. + cache-dependency-path: package.json - name: Install pre-commit run: python -m pip install pre-commit - name: Install dependencies - run: npm ci + shell: bash + run: | + set -euo pipefail + if [ ! -f package-lock.json ]; then + echo "::error::package-lock.json is required for npm ci in this workflow." + exit 1 + fi + npm ci - name: Validate pre-commit Node tooling policy run: pre-commit run validate-pre-commit-tooling --all-files diff --git a/.github/workflows/validate-docs.yml b/.github/workflows/validate-docs.yml index b28ae432..835245ff 100644 --- a/.github/workflows/validate-docs.yml +++ b/.github/workflows/validate-docs.yml @@ -122,6 +122,7 @@ jobs: - name: Build documentation (strict mode) run: | echo "Building documentation in strict mode..." + set -o pipefail mkdocs build --strict --site-dir _site 2>&1 | tee build.log # Check for warnings in the build log @@ -133,6 +134,20 @@ jobs: echo "" echo "✅ Documentation build completed successfully" + - name: Emit mkdocs diagnostics on failure + if: failure() + run: | + if [ -f build.log ]; then + echo "::group::MkDocs warnings and errors" + grep -nEi "warning|error|not found|aborted" build.log || true + echo "::endgroup::" + echo "::group::Last 120 lines of build.log" + tail -n 120 build.log || true + echo "::endgroup::" + else + echo "::warning::build.log was not generated." + fi + - name: Validate build output run: | echo "Validating build output..." @@ -172,7 +187,7 @@ jobs: LINK_COUNT=$(grep -roc 'href="[^"]*"' _site --include='*.html' | awk -F: '{sum+=$NF} END {print sum}' || echo "0") # For internal links, pipe through awk to count instead of grep -c which exits 1 on no match # shellcheck disable=SC2126 # wc -l intentional: grep -c exits 1 on no match, breaking CI - INTERNAL_LINKS=$(grep -roh 'href="[^"]*"' _site --include='*.html' 2>/dev/null | grep -v 'href="https\?://\|href="mailto:' | wc -l || echo "0") + INTERNAL_LINKS=$(grep -roh 'href="[^"]*"' _site --include='*.html' | grep -v 'href="https\?://\|href="mailto:' | wc -l || echo "0") echo "📊 Found $LINK_COUNT total links ($INTERNAL_LINKS internal)" echo "✅ Link structure scan completed" diff --git a/docs/reference/analyzers.md b/docs/reference/analyzers.md index cc549ee2..81b12240 100644 --- a/docs/reference/analyzers.md +++ b/docs/reference/analyzers.md @@ -1,6 +1,6 @@ # Roslyn Analyzers & Diagnostics -[← Back to Index](../getting-started/index.md) | [Troubleshooting](troubleshooting.md) | [Quick Reference](quick-reference.md) | [FAQ](faq.md) +[← Back to Reference](reference.md) | [Troubleshooting](troubleshooting.md) | [Quick Reference](quick-reference.md) | [FAQ](faq.md) --- @@ -445,6 +445,6 @@ The Inspector overlay also has a Unity 2021 fallback: a `[CustomEditor(typeof(Me ## See also - [Troubleshooting](troubleshooting.md) — runtime symptoms and how they map to diagnostics. -- [Inheritance Contract: `MessageAwareComponent`](../../README.md#inheritance-contract-messageawarecomponent) — the README's top-level write-up of the rule the analyzer enforces. +- [Inheritance and base calls](../guides/unity-integration.md#important-inheritance-and-base-calls) — the inheritance contract this analyzer enforces. - [Unity Integration](../guides/unity-integration.md) — broader Unity-side guidance for inheritance and lifecycle. - [Quick Reference](quick-reference.md) — concise listing of all diagnostic IDs. diff --git a/mkdocs.yml b/mkdocs.yml index 3ff15a4b..ba7399fc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -197,4 +197,5 @@ nav: - FAQ: reference/faq.md - Glossary: reference/glossary.md - Troubleshooting: reference/troubleshooting.md + - Analyzers: reference/analyzers.md - Compatibility: reference/compatibility.md From 0961e29854d7630a953d1d02ca408f7e11b42c99 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 29 Apr 2026 14:03:34 -0700 Subject: [PATCH 05/12] PR feedback --- .github/workflows/json-format-check.yml | 2 - .github/workflows/llm-policy-check.yml | 2 - .github/workflows/markdown-json.yml | 2 - .../workflows/pre-commit-tooling-check.yml | 25 +- .github/workflows/prettier-autofix.yml | 2 - .github/workflows/script-tests.yml | 2 - .github/workflows/yaml-format-lint.yml | 2 - .llm/context.md | 5 + package.json | 3 +- .../fix-csharp-underscore-methods.test.js | 234 +++++++++++--- .../validate-pre-commit-tooling.test.js | 173 +++++++++- scripts/__tests__/validate-workflows.test.js | 299 +++++++++++++++++- scripts/fix-csharp-underscore-methods.js | 104 +++++- scripts/validate-pre-commit-tooling.js | 73 ++++- scripts/validate-workflows.js | 289 ++++++++++++++++- 15 files changed, 1126 insertions(+), 91 deletions(-) diff --git a/.github/workflows/json-format-check.yml b/.github/workflows/json-format-check.yml index 68aa2944..8332be82 100644 --- a/.github/workflows/json-format-check.yml +++ b/.github/workflows/json-format-check.yml @@ -8,7 +8,6 @@ on: - "**/*.asmref" - ".prettierrc*" - "package.json" - - "package-lock.json" push: branches: - main @@ -19,7 +18,6 @@ on: - "**/*.asmref" - ".prettierrc*" - "package.json" - - "package-lock.json" workflow_dispatch: concurrency: diff --git a/.github/workflows/llm-policy-check.yml b/.github/workflows/llm-policy-check.yml index d2241e78..d97739a8 100644 --- a/.github/workflows/llm-policy-check.yml +++ b/.github/workflows/llm-policy-check.yml @@ -4,7 +4,6 @@ on: pull_request: paths: - ".llm/**" - - ".vscode/settings.json" - "scripts/validate-skills.js" - "scripts/validate-vscode-settings.js" - "scripts/generate-skills-index.js" @@ -16,7 +15,6 @@ on: - master paths: - ".llm/**" - - ".vscode/settings.json" - "scripts/validate-skills.js" - "scripts/validate-vscode-settings.js" - "scripts/generate-skills-index.js" diff --git a/.github/workflows/markdown-json.yml b/.github/workflows/markdown-json.yml index 09b32856..f692f0d2 100644 --- a/.github/workflows/markdown-json.yml +++ b/.github/workflows/markdown-json.yml @@ -14,7 +14,6 @@ on: - ".prettierrc*" - ".markdownlint*" - "package.json" - - "package-lock.json" pull_request: paths: - "**/*.md" @@ -25,7 +24,6 @@ on: - ".prettierrc*" - ".markdownlint*" - "package.json" - - "package-lock.json" workflow_dispatch: permissions: diff --git a/.github/workflows/pre-commit-tooling-check.yml b/.github/workflows/pre-commit-tooling-check.yml index 22e84434..08922e6a 100644 --- a/.github/workflows/pre-commit-tooling-check.yml +++ b/.github/workflows/pre-commit-tooling-check.yml @@ -5,7 +5,6 @@ on: paths: - ".pre-commit-config.yaml" - "package.json" - - "package-lock.json" - "scripts/**/*.js" - "scripts/__tests__/**/*.js" - "CONTRIBUTING.md" @@ -21,7 +20,6 @@ on: paths: - ".pre-commit-config.yaml" - "package.json" - - "package-lock.json" - "scripts/**/*.js" - "scripts/__tests__/**/*.js" - "CONTRIBUTING.md" @@ -72,6 +70,7 @@ jobs: for file in package.json package-lock.json; do if [ -f "$file" ]; then echo "Found: $file" + ls -l "$file" else echo "Missing: $file" fi @@ -83,7 +82,9 @@ jobs: fi if [ ! -f package-lock.json ]; then - echo "::warning::package-lock.json is missing. npm ci will fail with a clear error in the next step." + echo "::notice::package-lock.json not found; this workflow will use npm install fallback." + else + echo "::notice::package-lock.json found; this workflow will use npm ci." fi - name: Setup Node.js @@ -95,6 +96,14 @@ jobs: # resolution has been flaky on hosted runners in this matrix job. cache-dependency-path: package.json + - name: Diagnose Node runtime + shell: bash + run: | + set -euo pipefail + echo "Node: $(node --version)" + echo "npm: $(npm --version)" + echo "npm cache: $(npm config get cache)" + - name: Install pre-commit run: python -m pip install pre-commit @@ -102,11 +111,13 @@ jobs: shell: bash run: | set -euo pipefail - if [ ! -f package-lock.json ]; then - echo "::error::package-lock.json is required for npm ci in this workflow." - exit 1 + if [ -f package-lock.json ]; then + echo "Install mode: npm ci (lockfile present)" + npm ci + else + echo "Install mode: npm install --no-audit --no-fund (lockfile absent)" + npm i --no-audit --no-fund fi - npm ci - name: Validate pre-commit Node tooling policy run: pre-commit run validate-pre-commit-tooling --all-files diff --git a/.github/workflows/prettier-autofix.yml b/.github/workflows/prettier-autofix.yml index cc15b698..e3ee66b3 100644 --- a/.github/workflows/prettier-autofix.yml +++ b/.github/workflows/prettier-autofix.yml @@ -12,7 +12,6 @@ on: - ".prettierrc*" - ".markdownlint*" - "package.json" - - "package-lock.json" - "scripts/check-eol.ps1" pull_request_target: paths: @@ -25,7 +24,6 @@ on: - ".prettierrc*" - ".markdownlint*" - "package.json" - - "package-lock.json" - "scripts/check-eol.ps1" workflow_dispatch: diff --git a/.github/workflows/script-tests.yml b/.github/workflows/script-tests.yml index 03e9980c..13955d0f 100644 --- a/.github/workflows/script-tests.yml +++ b/.github/workflows/script-tests.yml @@ -4,7 +4,6 @@ on: pull_request: paths: - package.json - - package-lock.json - scripts/**/*.js - scripts/__tests__/**/*.js push: @@ -13,7 +12,6 @@ on: - master paths: - package.json - - package-lock.json - scripts/**/*.js - scripts/__tests__/**/*.js workflow_dispatch: diff --git a/.github/workflows/yaml-format-lint.yml b/.github/workflows/yaml-format-lint.yml index 5490134b..6f1705e2 100644 --- a/.github/workflows/yaml-format-lint.yml +++ b/.github/workflows/yaml-format-lint.yml @@ -9,7 +9,6 @@ on: - ".yamllint.yaml" - ".prettierrc*" - "package.json" - - "package-lock.json" push: branches: - main @@ -21,7 +20,6 @@ on: - ".yamllint.yaml" - ".prettierrc*" - "package.json" - - "package-lock.json" workflow_dispatch: concurrency: diff --git a/.llm/context.md b/.llm/context.md index 7c3774a7..f70a31e9 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -45,6 +45,7 @@ This file is intentionally concise. It contains only critical, high-signal guida - Check C# method naming (no underscores): `node scripts/fix-csharp-underscore-methods.js --check --all` - Auto-fix C# method naming on selected files: `node scripts/fix-csharp-underscore-methods.js ` - 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. - Auto-fix markdown fragments/lists: `node scripts/fix-md029-md051.js ` - Lint markdown: `npx markdownlint-cli2 ` @@ -68,13 +69,17 @@ This file is intentionally concise. It contains only critical, high-signal guida - Keep JS and PowerShell behavior synchronized when dual implementations exist. - Add tests for parser changes and malformed input edge cases. - For path-exclusion logic in script CLIs, apply exclusion patterns only to repository-local paths and add paired tests for outside-repo explicit file args plus repo-internal excluded directories. +- 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 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 `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. - 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. - 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. +- For command alternation regexes, avoid optional-suffix shorthands that split words into partial tokens; prefer explicit alternation forms like `(?:install|i)` to keep patterns readable and spellcheck-safe. +- For temporary test directory/file labels, prefer full descriptive words (for example `carriage-return-arguments`) over opaque abbreviations to reduce avoidable cspell failures. ## Line Ending Policy diff --git a/package.json b/package.json index 512fde41..8699bf73 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "format:yaml:check": "node scripts/run-managed-prettier.js --check \"**/*.{yml,yaml}\"", "check:package-json-format": "node scripts/run-managed-prettier.js --check package.json", "check:prettier:hooks": "node scripts/run-managed-prettier.js --check \"**/*.{md,markdown,json,asmdef,asmref,yml,yaml}\"", + "check:cspell:scripts": "npx --yes cspell@9 --no-progress --no-summary \"scripts/**/*.js\"", "check:yaml": "npm run format:yaml:check && pre-commit run yamllint --all-files", "lint:markdown": "markdownlint-cli2 \"**/*.md\" \"**/*.markdown\"", "update:llms-txt": "node scripts/update-llms-txt.js", @@ -76,7 +77,7 @@ "validate:npm-meta": "node scripts/validate-npm-meta.js --check", "validate:pre-commit-tooling": "node scripts/validate-pre-commit-tooling.js", "validate:vscode-settings": "node scripts/validate-vscode-settings.js", - "preflight:pre-commit": "npm run check:package-json-format && npm run check:prettier:hooks && npm run validate:pre-commit-tooling && npm run check:yaml && node scripts/generate-skills-index.js --check && npm run validate:npm-meta && pre-commit run script-parser-tests --all-files" + "preflight:pre-commit": "npm run check:package-json-format && npm run check:prettier:hooks && npm run validate:pre-commit-tooling && npm run check:cspell:scripts && npm run check:yaml && node scripts/generate-skills-index.js --check && npm run validate:npm-meta && pre-commit run script-parser-tests --all-files" }, "devDependencies": { "jest": "^30.3.0", diff --git a/scripts/__tests__/fix-csharp-underscore-methods.test.js b/scripts/__tests__/fix-csharp-underscore-methods.test.js index d28c0cd5..18b19163 100644 --- a/scripts/__tests__/fix-csharp-underscore-methods.test.js +++ b/scripts/__tests__/fix-csharp-underscore-methods.test.js @@ -6,6 +6,10 @@ const path = require("path"); const childProcess = require("child_process"); const { + isCsharpSourceFile, + normalizeExplicitPathArg, + toWindowsAbsolutePathFromPosixDrivePath, + resolveCandidatePath, convertMethodNameToPascalCase, collectMethodRenames, applyMethodRenames, @@ -25,6 +29,38 @@ const OUTSIDE_REPO_EXCLUDED_SEGMENTS = [ ]; describe("fix-csharp-underscore-methods", () => { + test("isCsharpSourceFile supports case-insensitive .cs and rejects .meta", () => { + expect(isCsharpSourceFile("Runtime/FixMe.cs")).toBe(true); + expect(isCsharpSourceFile("Runtime/FixMe.CS")).toBe(true); + expect(isCsharpSourceFile("Runtime/FixMe.cs.meta")).toBe(false); + expect(isCsharpSourceFile("Runtime/FixMe.txt")).toBe(false); + }); + + test("normalizeExplicitPathArg trims quotes/whitespace and carriage returns", () => { + expect(normalizeExplicitPathArg(' "C:/Temp/FixMe.cs"\r ')).toBe("C:/Temp/FixMe.cs"); + expect(normalizeExplicitPathArg(" 'C:/Temp/FixMe.cs'\r\r")).toBe("C:/Temp/FixMe.cs"); + expect(normalizeExplicitPathArg("\r")).toBe(""); + }); + + test("toWindowsAbsolutePathFromPosixDrivePath converts Git-Bash style paths", () => { + expect(toWindowsAbsolutePathFromPosixDrivePath("/c/Users/dev/FixMe.cs")).toBe( + "C:\\Users\\dev\\FixMe.cs" + ); + expect(toWindowsAbsolutePathFromPosixDrivePath("/z/tmp/project/File.CS")).toBe( + "Z:\\tmp\\project\\File.CS" + ); + expect(toWindowsAbsolutePathFromPosixDrivePath("/tmp/file.cs")).toBe(""); + }); + + test("resolveCandidatePath falls back to converted win32 path when direct resolve misses", () => { + const result = resolveCandidatePath("C:\\repo", "/c/Users/dev/FixMe.CS", { + platform: "win32", + existsSync: (candidatePath) => candidatePath === "C:\\Users\\dev\\FixMe.CS", + }); + + expect(result).toBe("C:\\Users\\dev\\FixMe.CS"); + }); + test("convertMethodNameToPascalCase removes underscores while preserving segment casing", () => { expect(convertMethodNameToPascalCase("Parse_Line_Bare")).toBe("ParseLineBare"); expect(convertMethodNameToPascalCase("E2E_Leaf_Calls_Base")).toBe("E2ELeafCallsBase"); @@ -52,6 +88,21 @@ describe("fix-csharp-underscore-methods", () => { expect(renames.has("op_Custom_Method")).toBe(false); }); + test("collectMethodRenames handles underscore return types and generic signatures", () => { + const source = [ + "public sealed class SignatureTests", + "{", + " private Custom_Type Parse_Line_Bare() => default;", + " private System.Collections.Generic.Dictionary Build_Map_Data() => default;", + "}", + ].join("\n"); + + const renames = collectMethodRenames(source); + + expect(renames.get("Parse_Line_Bare")).toBe("ParseLineBare"); + expect(renames.get("Build_Map_Data")).toBe("BuildMapData"); + }); + test("applyMethodRenames updates declarations and nameof references", () => { const source = [ "public sealed class NamingTests", @@ -158,6 +209,100 @@ describe("fix-csharp-underscore-methods", () => { } }); + test("--check handles explicit file args that include trailing carriage returns", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-carriage-return-arguments-")); + const filePath = path.join(tempDir, "NeedsFix.cs"); + + try { + fs.writeFileSync( + filePath, + [ + "public sealed class NeedsFix", + "{", + " public void Parse_Line_Bare() { }", + "}", + ].join("\n"), + "utf8" + ); + + const result = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, "--check", `${filePath}\r`], + { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8", + } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("NeedsFix.cs"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("CLI rewrites uppercase .CS files", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-upper-ext-")); + const filePath = path.join(tempDir, "FixMe.CS"); + + try { + fs.writeFileSync( + filePath, + [ + "public sealed class FixMe", + "{", + " public void Parse_Line_Bare() { }", + "}", + ].join("\n"), + "utf8" + ); + + const result = childProcess.spawnSync(process.execPath, [FIXER_SCRIPT_PATH, filePath], { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + const updated = fs.readFileSync(filePath, "utf8"); + expect(updated).toContain("ParseLineBare"); + expect(updated).not.toContain("Parse_Line_Bare"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("CLI rewrites CRLF content without losing line ending style", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-crlf-")); + const filePath = path.join(tempDir, "CrlfFix.cs"); + + try { + fs.writeFileSync( + filePath, + [ + "public sealed class CrlfFix", + "{", + " public void Parse_Line_Bare() { }", + "}", + ].join("\r\n"), + "utf8" + ); + + const result = childProcess.spawnSync(process.execPath, [FIXER_SCRIPT_PATH, filePath], { + cwd: path.resolve(__dirname, "../.."), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + + const updated = fs.readFileSync(filePath, "utf8"); + expect(updated).toContain("ParseLineBare"); + expect(updated).toContain("\r\n"); + expect(updated).not.toContain("Parse_Line_Bare"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + test.each(OUTSIDE_REPO_EXCLUDED_SEGMENTS)( "--check processes explicitly passed files in outside-repo %s segment", (excludedSegment) => { @@ -273,51 +418,60 @@ describe("fix-csharp-underscore-methods", () => { } }); - test("explicitly passed files in repo Temp remain skipped", () => { - const tempDir = path.resolve(__dirname, "../../Temp/dxmsg-csharp-underscore-test"); - const filePath = path.join(tempDir, "RepoTempSkip.cs"); - - try { - fs.mkdirSync(tempDir, { recursive: true }); - fs.writeFileSync( - filePath, - [ - "public sealed class RepoTempSkip", - "{", - " public void Parse_Line_Bare() { }", - "}", - ].join("\n"), - "utf8" + test.each(OUTSIDE_REPO_EXCLUDED_SEGMENTS)( + "explicitly passed files in repo-internal %s segment remain skipped", + (excludedSegment) => { + const repoRoot = path.resolve(__dirname, "../.."); + const repoInternalExcludedRoot = path.join( + repoRoot, + "Tests", + "dxmsg-csharp-underscore-repo-excluded" ); + const excludedDir = path.join(repoInternalExcludedRoot, excludedSegment, "nested"); + const filePath = path.join(excludedDir, "RepoExcludedSkip.cs"); - const checkResult = childProcess.spawnSync( - process.execPath, - [FIXER_SCRIPT_PATH, "--check", filePath], - { - cwd: path.resolve(__dirname, "../.."), - encoding: "utf8", - } - ); + try { + fs.mkdirSync(excludedDir, { recursive: true }); + fs.writeFileSync( + filePath, + [ + "public sealed class RepoExcludedSkip", + "{", + " public void Parse_Line_Bare() { }", + "}", + ].join("\n"), + "utf8" + ); - expect(checkResult.status).toBe(0); - expect(checkResult.stdout).toContain("No C# files to process."); + const checkResult = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, "--check", filePath], + { + cwd: repoRoot, + encoding: "utf8", + } + ); - const rewriteResult = childProcess.spawnSync( - process.execPath, - [FIXER_SCRIPT_PATH, filePath], - { - cwd: path.resolve(__dirname, "../.."), - encoding: "utf8", - } - ); + expect(checkResult.status).toBe(0); + expect(checkResult.stdout).toContain("No C# files to process."); - expect(rewriteResult.status).toBe(0); + const rewriteResult = childProcess.spawnSync( + process.execPath, + [FIXER_SCRIPT_PATH, filePath], + { + cwd: repoRoot, + encoding: "utf8", + } + ); - const contentAfterRewrite = fs.readFileSync(filePath, "utf8"); - expect(contentAfterRewrite).toContain("Parse_Line_Bare"); - expect(contentAfterRewrite).not.toContain("ParseLineBare"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + expect(rewriteResult.status).toBe(0); + + const contentAfterRewrite = fs.readFileSync(filePath, "utf8"); + expect(contentAfterRewrite).toContain("Parse_Line_Bare"); + expect(contentAfterRewrite).not.toContain("ParseLineBare"); + } finally { + fs.rmSync(repoInternalExcludedRoot, { recursive: true, force: true }); + } } - }); + ); }); diff --git a/scripts/__tests__/validate-pre-commit-tooling.test.js b/scripts/__tests__/validate-pre-commit-tooling.test.js index bb370fa3..ca19f2d4 100644 --- a/scripts/__tests__/validate-pre-commit-tooling.test.js +++ b/scripts/__tests__/validate-pre-commit-tooling.test.js @@ -13,6 +13,8 @@ const { parseHookIds, hasRequiredParserPrecheckCommand, hasRequiredPackageJsonFormatCommand, + hasRequiredScriptsCspellCommand, + hasRequiredParserSuiteTestPaths, hasNpxInstallPolicy, hasManagedJestInvocation, hasManagedPrettierInvocation, @@ -21,7 +23,9 @@ const { validatePreflightScriptPolicy, REQUIRED_PRECHECK_PARSER_COMMAND, REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + REQUIRED_SCRIPTS_CSPELL_COMMAND, REQUIRED_PARSER_SUITE_HOOK_ID, + REQUIRED_PARSER_SUITE_TEST_PATHS, validateConfigContent, validateConfigFile, } = require("../validate-pre-commit-tooling.js"); @@ -57,6 +61,38 @@ describe("validate-pre-commit-tooling", () => { ); }); + test("parseHookEntries handles consecutive folded entries", () => { + const content = [ + "repos:", + " - repo: local", + " hooks:", + " - id: alpha", + " entry: >-", + " node scripts/run-managed-jest.js --runTestsByPath", + " scripts/__tests__/alpha.test.js", + " - id: beta", + " entry: >-", + " node scripts/run-managed-jest.js --runTestsByPath", + " scripts/__tests__/beta.test.js", + ].join("\n"); + + const hooks = parseHookEntries(content); + + expect(hooks).toHaveLength(2); + expect(hooks[0]).toEqual( + expect.objectContaining({ + id: "alpha", + entry: "node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/alpha.test.js", + }) + ); + expect(hooks[1]).toEqual( + expect.objectContaining({ + id: "beta", + entry: "node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/beta.test.js", + }) + ); + }); + test("parseHookIds captures hook ids across repos", () => { const content = [ "repos:", @@ -122,6 +158,48 @@ describe("validate-pre-commit-tooling", () => { expect(hasRequiredPackageJsonFormatCommand(script)).toBe(false); }); + test("hasRequiredScriptsCspellCommand detects script cspell command as chained step", () => { + const script = [ + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + REQUIRED_SCRIPTS_CSPELL_COMMAND, + REQUIRED_PRECHECK_PARSER_COMMAND, + ].join(" && "); + + expect(hasRequiredScriptsCspellCommand(script)).toBe(true); + }); + + test("hasRequiredScriptsCspellCommand rejects substring-only matches", () => { + const script = "npm run validate:pre-commit-tooling && echo npm run check:cspell:scripts"; + + expect(hasRequiredScriptsCspellCommand(script)).toBe(false); + }); + + test("hasRequiredParserSuiteTestPaths detects required parser regression test path", () => { + const content = [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: >-", + " node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + ` ${REQUIRED_PARSER_SUITE_TEST_PATHS[0]}`, + ].join("\n"); + + expect(hasRequiredParserSuiteTestPaths(content)).toBe(true); + }); + + test("hasRequiredParserSuiteTestPaths rejects missing required parser regression test path", () => { + const content = [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + ].join("\n"); + + expect(hasRequiredParserSuiteTestPaths(content)).toBe(false); + }); + test("hasManagedJestInvocation detects unmanaged bare jest command", () => { expect(hasManagedJestInvocation("jest --runTestsByPath foo.test.js")).toBe(false); expect(hasManagedJestInvocation("node scripts/run-managed-jest.js --runTestsByPath foo.test.js")).toBe(true); @@ -157,7 +235,7 @@ describe("validate-pre-commit-tooling", () => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, }, }); } @@ -168,7 +246,7 @@ describe("validate-pre-commit-tooling", () => { " - repo: local", " hooks:", ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js", ].join("\n"); } @@ -179,6 +257,8 @@ describe("validate-pre-commit-tooling", () => { readFileSyncImpl: readFileSyncMock, packageJsonPath: "/tmp/package.json", preCommitConfigPath: "/tmp/pre-commit.yaml", + getConfiguredPrettierSpecFn: () => "prettier@3.8.3", + getPinnedPrettierSpecFn: () => "prettier@3.8.3", }); expect(violations).toHaveLength(3); @@ -279,7 +359,7 @@ describe("validate-pre-commit-tooling", () => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, }, }); } @@ -290,7 +370,7 @@ describe("validate-pre-commit-tooling", () => { " - repo: local", " hooks:", ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js", ].join("\n"); } @@ -301,6 +381,8 @@ describe("validate-pre-commit-tooling", () => { readFileSyncImpl: readFileSyncMock, packageJsonPath: "/tmp/package.json", preCommitConfigPath: "/tmp/pre-commit.yaml", + getConfiguredPrettierSpecFn: () => "prettier@3.8.3", + getPinnedPrettierSpecFn: () => "prettier@3.8.3", }); expect(violations).toHaveLength(1); @@ -318,6 +400,7 @@ describe("validate-pre-commit-tooling", () => { ); expect(preflightScript).toContain(REQUIRED_PACKAGE_JSON_FORMAT_COMMAND); expect(preflightScript).toContain("npm run check:prettier:hooks"); + expect(preflightScript).toContain(REQUIRED_SCRIPTS_CSPELL_COMMAND); expect(packageJson.scripts["check:yaml"]).toContain( "pre-commit run yamllint --all-files" ); @@ -333,7 +416,7 @@ describe("validate-pre-commit-tooling", () => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, }, }); } @@ -344,7 +427,7 @@ describe("validate-pre-commit-tooling", () => { " - repo: local", " hooks:", ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js", ].join("\n"); } @@ -367,7 +450,7 @@ describe("validate-pre-commit-tooling", () => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling`, + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND}`, }, }); } @@ -378,7 +461,7 @@ describe("validate-pre-commit-tooling", () => { " - repo: local", " hooks:", ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js", ].join("\n"); } @@ -401,7 +484,7 @@ describe("validate-pre-commit-tooling", () => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `npm run validate:pre-commit-tooling && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, + "preflight:pre-commit": `npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, }, }); } @@ -412,7 +495,7 @@ describe("validate-pre-commit-tooling", () => { " - repo: local", " hooks:", ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js", ].join("\n"); } @@ -430,7 +513,7 @@ describe("validate-pre-commit-tooling", () => { expect(violations[0].message).toContain(REQUIRED_PACKAGE_JSON_FORMAT_COMMAND); }); - test("validatePreflightScriptPolicy reports missing parser suite hook", () => { + test("validatePreflightScriptPolicy reports missing scripts cspell precheck command", () => { const readFileSyncMock = jest.fn((filePath) => { if (filePath === "/tmp/package.json") { return JSON.stringify({ @@ -440,6 +523,40 @@ describe("validate-pre-commit-tooling", () => { }); } + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js", + ].join("\n"); + } + + return ""; + }); + + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_SCRIPTS_CSPELL_COMMAND); + }); + + test("validatePreflightScriptPolicy reports missing parser suite hook", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, + }, + }); + } + if (filePath === "/tmp/pre-commit.yaml") { return [ "repos:", @@ -464,6 +581,40 @@ describe("validate-pre-commit-tooling", () => { expect(violations[0].message).toContain(REQUIRED_PARSER_SUITE_HOOK_ID); }); + test("validatePreflightScriptPolicy reports missing required parser regression test path", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, + }, + }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + ].join("\n"); + } + + return ""; + }); + + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_PARSER_SUITE_TEST_PATHS[0]); + }); + test("validateConfigFile handles CRLF and lone CR line endings", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pre-commit-tooling-")); const filePath = path.join(tempDir, ".pre-commit-config.yaml"); diff --git a/scripts/__tests__/validate-workflows.test.js b/scripts/__tests__/validate-workflows.test.js index 05f23478..68347f68 100644 --- a/scripts/__tests__/validate-workflows.test.js +++ b/scripts/__tests__/validate-workflows.test.js @@ -14,6 +14,10 @@ const path = require("path"); const { isForbiddenRenormalizePattern, hasExistenceCheck, + extractWorkflowPathEntries, + findIgnoredPathViolations, + extractRunBlocks, + findLockfileInstallViolations, validateWorkflow, } = require('../validate-workflows.js'); @@ -66,6 +70,17 @@ describe("isForbiddenRenormalizePattern", () => { expect(isForbiddenRenormalizePattern(line)).toBe(false); }); + test("variable-based pattern with uppercase variable name", () => { + const line = 'git add --renormalize -- "*.${FILE_EXT}" "**/*.${FILE_EXT}"'; + expect(isForbiddenRenormalizePattern(line)).toBe(false); + }); + + test("ignores non-command extension text on same line", () => { + const line = + 'echo "extensions: *.json" && git add --renormalize -- "*.md" "**/*.md"'; + expect(isForbiddenRenormalizePattern(line)).toBe(false); + }); + test("single specific file", () => { const line = "git add --renormalize -- '.config/dotnet-tools.json'"; @@ -139,14 +154,198 @@ describe("hasExistenceCheck", () => { "", "", "", + "", + "", + "", + "", + "", + "", "git add --renormalize -- '*.md' '**/*.md'", ]; - // Index 7, lookback 5 won't reach index 0 - expect(hasExistenceCheck(lines, 7)).toBe(false); + // Index 13, lookback 10 won't reach index 0 + expect(hasExistenceCheck(lines, 13)).toBe(false); }); }); }); +describe("extractWorkflowPathEntries", () => { + test("collects entries under paths blocks", () => { + const lines = [ + "on:", + " pull_request:", + " paths:", + " - \"package.json\"", + " - \"package-lock.json\"", + " workflow_dispatch:", + " inputs:", + " target:", + " description: Target", + ]; + + const entries = extractWorkflowPathEntries(lines); + expect(entries).toEqual([ + { line: 4, path: "package.json" }, + { line: 5, path: "package-lock.json" }, + ]); + }); +}); + +describe("findIgnoredPathViolations", () => { + const isIgnoredPathMock = (_repoRoot, candidatePath) => + candidatePath === "package-lock.json"; + + test("reports ignored literal path entries", () => { + const lines = [ + "on:", + " push:", + " paths:", + " - package-lock.json", + " - scripts/**/*.js", + ]; + + const violations = findIgnoredPathViolations( + "test.yml", + lines, + "/tmp", + isIgnoredPathMock + ); + + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("ignored by git"); + expect(violations[0].line).toBe(4); + }); + + test.each([ + "scripts/**/*.js", + "**/*.yml", + "${{ github.event.pull_request.head.ref }}", + "!docs/**", + ])("ignores non-literal path pattern '%s'", (pathPattern) => { + const lines = [ + "on:", + " push:", + " paths:", + ` - ${pathPattern}`, + ]; + + const violations = findIgnoredPathViolations( + "test.yml", + lines, + "/tmp", + isIgnoredPathMock + ); + + expect(violations).toHaveLength(0); + }); +}); + +describe("run block lockfile policy", () => { + test("extractRunBlocks handles folded and inline run definitions", () => { + const lines = [ + "steps:", + " - name: Install dependencies", + " run: |", + " npm ci", + " - name: Inline", + " run: npm run test:scripts", + ]; + + const blocks = extractRunBlocks(lines); + expect(blocks).toHaveLength(2); + expect(blocks[0]).toEqual( + expect.objectContaining({ startLine: 3, text: "npm ci" }) + ); + expect(blocks[1]).toEqual( + expect.objectContaining({ startLine: 6, text: "npm run test:scripts" }) + ); + }); + + test.each([ + { + name: "flags hard failure when lockfile is missing", + lines: [ + "steps:", + " - run: |", + " if [ ! -f package-lock.json ]; then", + " exit 1", + " fi", + " npm ci", + ], + expectedViolations: 1, + }, + { + name: "flags unguarded npm ci when lockfile is ignored", + lines: [ + "steps:", + " - run: npm ci", + ], + expectedViolations: 1, + }, + { + name: "allows npm ci with fallback install", + lines: [ + "steps:", + " - run: |", + " if [ -f package-lock.json ]; then", + " npm ci", + " else", + " npm i --no-audit --no-fund", + " fi", + ], + expectedViolations: 0, + }, + { + name: "allows npm ci with fallback full install command", + lines: [ + "steps:", + " - run: |", + " if [ -f package-lock.json ]; then", + " npm ci", + " else", + " npm install --no-audit --no-fund", + " fi", + ], + expectedViolations: 0, + }, + { + name: "allows npm ci with shell-or fallback", + lines: [ + "steps:", + " - run: npm ci || npm i --no-audit --no-fund", + ], + expectedViolations: 0, + }, + ])("findLockfileInstallViolations: $name", ({ lines, expectedViolations }) => { + const violations = findLockfileInstallViolations( + "test.yml", + lines, + true + ); + + expect(violations).toHaveLength(expectedViolations); + }); + + test("does not enforce lockfile fallback policy when package-lock is not ignored", () => { + const lines = [ + "steps:", + " - run: npm ci", + ]; + + const violations = findLockfileInstallViolations("test.yml", lines, false); + expect(violations).toHaveLength(0); + }); + + test("source avoids optional-suffix shorthand that trips cspell", () => { + const source = fs.readFileSync( + path.resolve(__dirname, "../validate-workflows.js"), + "utf8" + ); + const optionalSuffixShorthand = `i(?:${"n" + "stall"})?`; + + expect(source).not.toContain(optionalSuffixShorthand); + }); +}); + describe("Real workflow patterns", () => { describe("should correctly handle actual workflow content", () => { test("correct per-extension loop pattern", () => { @@ -215,3 +414,99 @@ describe("validateWorkflow newline handling", () => { } }); }); + +describe("validateWorkflow policy integration", () => { + test("reports ignored path filters and unsafe lockfile install policy", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "validate-workflows-policy-")); + try { + const workflowPath = path.join(tempDir, "policy-test.yml"); + const workflowContent = [ + "name: Policy Test", + "on:", + " pull_request:", + " paths:", + " - package-lock.json", + "jobs:", + " test:", + " runs-on: ubuntu-latest", + " steps:", + " - name: Install dependencies", + " run: |", + " if [ ! -f package-lock.json ]; then", + " exit 1", + " fi", + " npm ci", + ].join("\n"); + + fs.writeFileSync(workflowPath, workflowContent, "utf8"); + const isIgnoredPathMock = (_repoRoot, candidatePath) => + candidatePath === "package-lock.json"; + const violations = validateWorkflow(workflowPath, { + repoRoot: tempDir, + isIgnoredPathFn: isIgnoredPathMock, + }); + + const errorMessages = violations + .filter((violation) => violation.severity === "error") + .map((violation) => violation.message); + + expect( + errorMessages.some((message) => message.includes("ignored by git")) + ).toBe(true); + expect( + errorMessages.some((message) => + message.includes("must not fail when the lockfile is absent") + ) + ).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("surfaces ignore policy evaluation failures as validation errors", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "validate-workflows-policy-error-")); + try { + const workflowPath = path.join(tempDir, "policy-error-test.yml"); + fs.writeFileSync( + workflowPath, + [ + "name: Policy Error Test", + "on:", + " pull_request:", + " paths:", + " - package-lock.json", + ].join("\n"), + "utf8" + ); + + const violations = validateWorkflow(workflowPath, { + repoRoot: tempDir, + isIgnoredPathFn: () => { + throw new Error("mock git failure"); + }, + }); + + expect( + violations.some((violation) => + violation.message.includes("Workflow validation failed while evaluating ignore policy") + ) + ).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("current repository workflows pass with no validation errors", () => { + const workflowsDir = path.resolve(__dirname, "../../.github/workflows"); + const workflowFiles = fs + .readdirSync(workflowsDir) + .filter((fileName) => fileName.endsWith(".yml") || fileName.endsWith(".yaml")); + + for (const workflowFile of workflowFiles) { + const workflowPath = path.join(workflowsDir, workflowFile); + const violations = validateWorkflow(workflowPath); + const errors = violations.filter((violation) => violation.severity === "error"); + expect(errors).toHaveLength(0); + } + }); +}); diff --git a/scripts/fix-csharp-underscore-methods.js b/scripts/fix-csharp-underscore-methods.js index 44ed80ed..8fb0b480 100644 --- a/scripts/fix-csharp-underscore-methods.js +++ b/scripts/fix-csharp-underscore-methods.js @@ -15,6 +15,10 @@ const { spawnSync } = require("child_process"); const METHOD_DECLARATION_PATTERN = /^\s*(?:(?:\[[^\]\r\n]+\]\s*)*)(?:(?:public|private|protected|internal)\s+)?(?:(?:static|virtual|override|abstract|sealed|async|new|extern|partial|unsafe|readonly)\s+)*(?:[\w<>\[\],.?]+\s+)+(?[A-Za-z_]\w*_[A-Za-z0-9_]+)\s*(?:<[^>\r\n]+>\s*)?\(/gm; +const CSHARP_SOURCE_FILE_PATTERN = /\.cs$/i; +const META_FILE_PATTERN = /\.meta$/i; +const WINDOWS_POSIX_DRIVE_PATH_PATTERN = /^\/([A-Za-z])\/(.+)$/; + const EXCLUDED_DIRECTORY_PATTERNS = [ /(^|[\\/])\.git([\\/]|$)/i, /(^|[\\/])node_modules([\\/]|$)/i, @@ -31,6 +35,83 @@ function normalizeToLf(value) { return String(value).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } +function isCsharpSourceFile(filePath) { + if (typeof filePath !== "string" || filePath.trim().length === 0) { + return false; + } + + return CSHARP_SOURCE_FILE_PATTERN.test(filePath) && !META_FILE_PATTERN.test(filePath); +} + +function stripOptionalWrappingQuotes(value) { + if (value.length < 2) { + return value; + } + + const firstChar = value[0]; + const lastChar = value[value.length - 1]; + if ((firstChar === '"' && lastChar === '"') || (firstChar === "'" && lastChar === "'")) { + return value.slice(1, -1); + } + + return value; +} + +function normalizeExplicitPathArg(rawArg) { + if (typeof rawArg !== "string") { + return ""; + } + + const withoutTrailingCarriageReturns = rawArg.replace(/\r+$/g, ""); + const trimmed = withoutTrailingCarriageReturns.trim(); + if (trimmed.length === 0) { + return ""; + } + + return stripOptionalWrappingQuotes(trimmed).replace(/\r+$/g, "").trim(); +} + +function toWindowsAbsolutePathFromPosixDrivePath(value) { + const match = WINDOWS_POSIX_DRIVE_PATH_PATTERN.exec(value); + if (!match) { + return ""; + } + + const driveLetter = match[1].toUpperCase(); + const segments = match[2].replace(/\//g, "\\"); + return `${driveLetter}:\\${segments}`; +} + +function resolveCandidatePath( + repoRoot, + rawArg, + { platform = process.platform, existsSync = fs.existsSync } = {} +) { + const normalizedArg = normalizeExplicitPathArg(rawArg); + if (normalizedArg.length === 0) { + return ""; + } + + const pathResolver = platform === "win32" ? path.win32 : path; + const directCandidatePath = pathResolver.resolve(repoRoot, normalizedArg); + + if (existsSync(directCandidatePath) || platform !== "win32") { + return directCandidatePath; + } + + const windowsAbsolutePath = toWindowsAbsolutePathFromPosixDrivePath(normalizedArg); + if (!windowsAbsolutePath) { + return directCandidatePath; + } + + const convertedCandidatePath = pathResolver.resolve(windowsAbsolutePath); + if (existsSync(convertedCandidatePath)) { + return convertedCandidatePath; + } + + return directCandidatePath; +} + function getGitRepoRoot() { const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: process.cwd(), @@ -111,7 +192,7 @@ function walkCsharpFiles(rootDir, files = []) { continue; } - if (entry.isFile() && fullPath.endsWith(".cs") && !fullPath.endsWith(".meta")) { + if (entry.isFile() && isCsharpSourceFile(fullPath)) { files.push(fullPath); } } @@ -124,11 +205,15 @@ function resolveExplicitFiles(repoRoot, fileArgs) { const seen = new Set(); for (const rawArg of fileArgs) { - if (!rawArg.endsWith(".cs")) { + const normalizedArg = normalizeExplicitPathArg(rawArg); + if (!isCsharpSourceFile(normalizedArg)) { continue; } - const candidatePath = path.resolve(repoRoot, rawArg); + const candidatePath = resolveCandidatePath(repoRoot, normalizedArg); + if (candidatePath.length === 0) { + continue; + } if (!fs.existsSync(candidatePath)) { continue; @@ -140,7 +225,7 @@ function resolveExplicitFiles(repoRoot, fileArgs) { } const stats = fs.statSync(candidatePath); - if (!stats.isFile() || candidatePath.endsWith(".meta")) { + if (!stats.isFile() || !isCsharpSourceFile(candidatePath)) { continue; } @@ -183,7 +268,7 @@ function getStagedCsharpFiles(repoRoot) { .filter(Boolean)) { const fullPath = path.resolve(repoRoot, relativePath); - if (!fullPath.endsWith(".cs") || fullPath.endsWith(".meta")) { + if (!isCsharpSourceFile(fullPath)) { continue; } @@ -226,6 +311,8 @@ function collectMethodRenames(content) { const methodRenames = new Map(); let match; + METHOD_DECLARATION_PATTERN.lastIndex = 0; + while ((match = METHOD_DECLARATION_PATTERN.exec(content)) !== null) { const methodName = match.groups ? match.groups.name : ""; @@ -358,6 +445,13 @@ function main() { module.exports = { METHOD_DECLARATION_PATTERN, + CSHARP_SOURCE_FILE_PATTERN, + META_FILE_PATTERN, + WINDOWS_POSIX_DRIVE_PATH_PATTERN, + isCsharpSourceFile, + normalizeExplicitPathArg, + toWindowsAbsolutePathFromPosixDrivePath, + resolveCandidatePath, convertMethodNameToPascalCase, collectMethodRenames, applyMethodRenames, diff --git a/scripts/validate-pre-commit-tooling.js b/scripts/validate-pre-commit-tooling.js index bd8ae779..40f7b1e7 100644 --- a/scripts/validate-pre-commit-tooling.js +++ b/scripts/validate-pre-commit-tooling.js @@ -24,7 +24,12 @@ const REQUIRED_PRECHECK_PARSER_COMMAND = "pre-commit run script-parser-tests --all-files"; const REQUIRED_PACKAGE_JSON_FORMAT_COMMAND = "npm run check:package-json-format"; +const REQUIRED_SCRIPTS_CSPELL_COMMAND = + "npm run check:cspell:scripts"; const REQUIRED_PARSER_SUITE_HOOK_ID = "script-parser-tests"; +const REQUIRED_PARSER_SUITE_TEST_PATHS = [ + "scripts/__tests__/fix-csharp-underscore-methods.test.js", +]; class Violation { constructor(hookId, line, message, entry) { @@ -71,7 +76,7 @@ function parseHookEntries(content) { const entryValue = entryMatch[2].trim(); let command; - if ([">", ">-", "|", "|-"] .includes(entryValue)) { + if ([">", ">-", "|", "|-"].includes(entryValue)) { const blockLines = []; let j = i + 1; while (j < lines.length) { @@ -88,6 +93,9 @@ function parseHookEntries(content) { j++; } + + // Skip block lines that were consumed by this folded/literal entry. + i = j - 1; command = blockLines.join(" ").replace(/\s+/g, " ").trim(); } else { command = entryValue; @@ -147,6 +155,31 @@ function hasRequiredPackageJsonFormatCommand(preflightScript) { ); } +function hasRequiredScriptsCspellCommand(preflightScript) { + return hasRequiredPreflightCommand( + preflightScript, + REQUIRED_SCRIPTS_CSPELL_COMMAND + ); +} + +function hasRequiredParserSuiteTestPaths( + preCommitConfigContent, + requiredTestPaths = REQUIRED_PARSER_SUITE_TEST_PATHS +) { + const parserSuiteHook = parseHookEntries(preCommitConfigContent).find( + (hook) => hook.id === REQUIRED_PARSER_SUITE_HOOK_ID + ); + + if (!parserSuiteHook) { + return false; + } + + const parserSuiteTokens = new Set(tokenizeCommand(parserSuiteHook.entry)); + return requiredTestPaths.every((requiredTestPath) => + parserSuiteTokens.has(requiredTestPath) + ); +} + function hasNpxInstallPolicy(entry) { const tokens = tokenizeCommand(entry); let foundNpx = false; @@ -391,6 +424,17 @@ function validatePreflightScriptPolicy( ); } + if (!hasRequiredScriptsCspellCommand(preflightScript)) { + violations.push( + new Violation( + "preflight-script", + 1, + `preflight:pre-commit must include '${REQUIRED_SCRIPTS_CSPELL_COMMAND}' so script spelling regressions are caught before hooks.`, + preflightScript + ) + ); + } + if (!hasRequiredParserPrecheckCommand(preflightScript)) { violations.push( new Violation( @@ -430,6 +474,22 @@ function validatePreflightScriptPolicy( ); } + if ( + hasParserSuiteHook && + !hasRequiredParserSuiteTestPaths(preCommitConfig, REQUIRED_PARSER_SUITE_TEST_PATHS) + ) { + violations.push( + new Violation( + "preflight-script", + 1, + `The '${REQUIRED_PARSER_SUITE_HOOK_ID}' hook entry must include required regression test path(s): ${REQUIRED_PARSER_SUITE_TEST_PATHS.join( + ", " + )}.`, + REQUIRED_PARSER_SUITE_HOOK_ID + ) + ); + } + return violations; } @@ -439,6 +499,8 @@ function validateConfigContent( readFileSyncImpl = fs.readFileSync, packageJsonPath = PACKAGE_JSON_PATH, preCommitConfigPath = PRE_COMMIT_CONFIG_PATH, + getConfiguredPrettierSpecFn = getConfiguredPrettierSpec, + getPinnedPrettierSpecFn = getPinnedPrettierSpec, } = {} ) { const hooks = parseHookEntries(content); @@ -450,7 +512,10 @@ function validateConfigContent( ), ...validateHookEntries(hooks), ...validateYamllintPolicy(content), - ...validatePrettierVersionResolution(), + ...validatePrettierVersionResolution( + getConfiguredPrettierSpecFn, + getPinnedPrettierSpecFn + ), ]; } @@ -502,6 +567,7 @@ module.exports = { hasRequiredPreflightCommand, hasRequiredParserPrecheckCommand, hasRequiredPackageJsonFormatCommand, + hasRequiredScriptsCspellCommand, hasNpxInstallPolicy, usesManagedJestWrapper, usesManagedPrettierWrapper, @@ -515,7 +581,10 @@ module.exports = { PACKAGE_JSON_PATH, REQUIRED_PRECHECK_PARSER_COMMAND, REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + REQUIRED_SCRIPTS_CSPELL_COMMAND, REQUIRED_PARSER_SUITE_HOOK_ID, + REQUIRED_PARSER_SUITE_TEST_PATHS, + hasRequiredParserSuiteTestPaths, validateConfigContent, validateConfigFile, }; diff --git a/scripts/validate-workflows.js b/scripts/validate-workflows.js index 09efc212..b0fc16c2 100644 --- a/scripts/validate-workflows.js +++ b/scripts/validate-workflows.js @@ -23,10 +23,12 @@ "use strict"; +const { execFileSync } = require("child_process"); const fs = require("fs"); const path = require("path"); const { normalizeToLf } = require("./lib/quote-parser"); +const REPO_ROOT = path.join(__dirname, ".."); const WORKFLOWS_DIR = path.join(__dirname, "..", ".github", "workflows"); /** @@ -47,6 +49,14 @@ class Violation { } } +function getIndent(line) { + return line.length - line.trimStart().length; +} + +function usesVariableExtensionPattern(line) { + return /\*\.\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/.test(line); +} + /** * Checks if a line contains a problematic single-line multi-pattern renormalize command. * These commands fail with exit code 128 if any pattern matches no files. @@ -67,20 +77,26 @@ function isForbiddenRenormalizePattern(line) { } // Skip lines that use shell variable expansion (part of a loop) - if (trimmed.includes("$ext") || trimmed.includes("${ext}")) { + if (usesVariableExtensionPattern(trimmed)) { return false; } + const commandMatch = + /git add --renormalize\s+--\s+(.+?)(?:\s*(?:&&|\|\||;|\|)\s*.+)?$/.exec( + trimmed + ); + const renormalizeArgs = commandMatch ? commandMatch[1] : trimmed; + // Skip lines that target a single specific file (e.g., '.config/dotnet-tools.json') // These are safe because the file definitely exists or the step would have failed earlier - const singleFilePattern = /git add --renormalize\s+--\s+'[^'*?]+'/; - if (singleFilePattern.test(trimmed)) { + const singleFilePattern = /^["']?[^"'*?\s]+["']?$/; + if (singleFilePattern.test(renormalizeArgs)) { return false; } // Count distinct file extension patterns (*.ext or **/*.ext) // Use a Set to count unique extensions - const extensionPatterns = trimmed.match(/\*\.(\w+)/g) || []; + const extensionPatterns = renormalizeArgs.match(/\*\.(\w+)/g) || []; const uniqueExtensions = new Set( extensionPatterns.map((p) => p.replace("*.", "")) ); @@ -101,7 +117,7 @@ function hasExistenceCheck(lines, lineIndex) { // Look backwards for an existence check pattern // Pattern: if git ls-files "*.ext" | grep -q .; then // or: if git ls-files "*.$ext" | grep -q .; then - const lookbackLines = 5; + const lookbackLines = 10; const startIndex = Math.max(0, lineIndex - lookbackLines); for (let i = lineIndex - 1; i >= startIndex; i--) { @@ -126,14 +142,235 @@ function hasExistenceCheck(lines, lineIndex) { return false; } +function isGitIgnoredPath(repoRoot, relativePath, execFileSyncImpl = execFileSync) { + if (typeof relativePath !== "string" || relativePath.trim().length === 0) { + return false; + } + + try { + execFileSyncImpl( + "git", + ["check-ignore", "--quiet", "--no-index", relativePath], + { + cwd: repoRoot, + stdio: "ignore", + } + ); + return true; + } catch (error) { + if (error && typeof error.status === "number" && error.status === 1) { + return false; + } + + if (error && error.code === "ENOENT") { + return false; + } + + const message = error && error.message ? error.message : String(error); + throw new Error( + `Unable to evaluate git ignore status for '${relativePath}': ${message}` + ); + } +} + +function extractWorkflowPathEntries(lines) { + const entries = []; + let inPathsBlock = false; + let pathsIndent = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + const indent = getIndent(line); + + if (!inPathsBlock && /^\s*paths:\s*$/.test(line)) { + inPathsBlock = true; + pathsIndent = indent; + continue; + } + + if (!inPathsBlock) { + continue; + } + + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue; + } + + if (indent <= pathsIndent && !/^\s*-\s+/.test(line)) { + inPathsBlock = false; + pathsIndent = -1; + + if (/^\s*paths:\s*$/.test(line)) { + inPathsBlock = true; + pathsIndent = indent; + } + continue; + } + + const pathEntry = /^\s*-\s*["']?([^"'#]+)["']?\s*(?:#.*)?$/.exec(line); + if (pathEntry) { + entries.push({ + line: i + 1, + path: pathEntry[1].trim(), + }); + } + } + + return entries; +} + +function isLiteralPath(pathValue) { + return !/[\*\?\[\]\{\}]|\$\{\{/.test(pathValue) && !pathValue.startsWith("!"); +} + +function findIgnoredPathViolations( + relativePath, + lines, + repoRoot = REPO_ROOT, + isIgnoredPathFn = isGitIgnoredPath +) { + const violations = []; + const entries = extractWorkflowPathEntries(lines); + + for (const entry of entries) { + if (!isLiteralPath(entry.path)) { + continue; + } + + if (!isIgnoredPathFn(repoRoot, entry.path)) { + continue; + } + + violations.push( + new Violation( + relativePath, + entry.line, + entry.path, + `Workflow trigger path '${entry.path}' is ignored by git and cannot trigger this workflow. Remove it from paths filters or update ignore policy.`, + "error" + ) + ); + } + + return violations; +} + +function extractRunBlocks(lines) { + const blocks = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const blockRunMatch = /^(\s*)(?:-\s+)?run:\s*[>|][+-]?\s*$/.exec(line); + + if (blockRunMatch) { + const baseIndent = blockRunMatch[1].length; + const blockLines = []; + let j = i + 1; + + while (j < lines.length) { + const nextLine = lines[j]; + const trimmed = nextLine.trim(); + const nextIndent = getIndent(nextLine); + + if (trimmed.length > 0 && nextIndent <= baseIndent) { + break; + } + + blockLines.push(nextLine.trim()); + j++; + } + + blocks.push({ + startLine: i + 1, + text: blockLines.join("\n").trim(), + }); + + i = j - 1; + continue; + } + + const inlineRunMatch = /^\s*(?:-\s+)?run:\s*(.+?)\s*$/.exec(line); + if (inlineRunMatch) { + blocks.push({ + startLine: i + 1, + text: inlineRunMatch[1].trim(), + }); + } + } + + return blocks; +} + +function findLockfileInstallViolations(relativePath, lines, packageLockIgnored) { + const violations = []; + + if (!packageLockIgnored) { + return violations; + } + + const runBlocks = extractRunBlocks(lines); + + for (const block of runBlocks) { + if (!/(^|\n|;|&&)\s*npm\s+ci\b/m.test(block.text)) { + continue; + } + + const hasLockfileCheck = + /\[\s*-f\s+package-lock\.json\s*\]/.test(block.text) || + /\btest\s+-f\s+package-lock\.json\b/.test(block.text); + const hasAnyIfElseFallback = + /\bif\b[\s\S]*?\bnpm\s+ci\b[\s\S]*?\belse\b[\s\S]*?\bnpm\s+(?:install|i)\b/.test(block.text); + const hasElseFallbackInstall = + /\belse\b[\s\S]*?\bnpm\s+(?:install|i)\b/.test(block.text); + const hasOrFallbackInstall = + /\bnpm\s+ci\b\s*\|\|\s*\bnpm\s+(?:install|i)\b/.test(block.text); + const hasMissingLockfileHardFail = + /\[\s*!\s+-f\s+package-lock\.json\s*\][\s\S]*?\bexit\s+1\b/.test(block.text); + + if (hasOrFallbackInstall) { + continue; + } + + if (hasMissingLockfileHardFail) { + violations.push( + new Violation( + relativePath, + block.startLine, + "npm ci", + "Repository ignores package-lock.json, so workflows must not fail when the lockfile is absent. Use npm ci/npm install fallback.", + "error" + ) + ); + continue; + } + + if ((!hasLockfileCheck && !hasAnyIfElseFallback) || !hasElseFallbackInstall) { + violations.push( + new Violation( + relativePath, + block.startLine, + "npm ci", + "Repository ignores package-lock.json, so npm ci blocks must include a lockfile presence check and npm install fallback.", + "error" + ) + ); + } + } + + return violations; +} + /** * Validates a single workflow file. * * @param {string} filePath - Absolute path to the workflow file * @returns {Violation[]} Array of violations found */ -function validateWorkflow(filePath) { +function validateWorkflow(filePath, options = {}) { const violations = []; + const repoRoot = options.repoRoot || REPO_ROOT; + const isIgnoredPathFn = options.isIgnoredPathFn || isGitIgnoredPath; const relativePath = path.relative( path.join(__dirname, ".."), filePath @@ -171,8 +408,7 @@ function validateWorkflow(filePath) { if ( line.includes("git add") && line.includes("--renormalize") && - !line.includes("$ext") && - !line.includes("${ext}") && + !usesVariableExtensionPattern(line) && line.includes("*.") ) { if (!hasExistenceCheck(lines, index)) { @@ -189,6 +425,32 @@ function validateWorkflow(filePath) { } }); + try { + violations.push( + ...findIgnoredPathViolations( + relativePath, + lines, + repoRoot, + isIgnoredPathFn + ) + ); + + const packageLockIgnored = isIgnoredPathFn(repoRoot, "package-lock.json"); + violations.push( + ...findLockfileInstallViolations(relativePath, lines, packageLockIgnored) + ); + } catch (error) { + violations.push( + new Violation( + relativePath, + 0, + "git check-ignore", + `Workflow validation failed while evaluating ignore policy: ${error.message}`, + "error" + ) + ); + } + return violations; } @@ -196,7 +458,7 @@ function validateWorkflow(filePath) { * Main entry point. */ function main() { - console.log("Validating workflow files for git add --renormalize patterns...\n"); + console.log("Validating workflow files for policy and reliability patterns...\n"); if (!fs.existsSync(WORKFLOWS_DIR)) { console.log(`Workflows directory not found: ${WORKFLOWS_DIR}`); @@ -235,7 +497,7 @@ function main() { if (allViolations.length === 0) { console.log("✅ All workflow files passed validation.\n"); - console.log("No forbidden git add --renormalize patterns detected."); + console.log("No workflow policy violations detected."); process.exit(0); } @@ -256,7 +518,7 @@ function main() { if (errors.length > 0) { console.log("\nValidation FAILED. Please fix the errors above."); console.log( - "\nSee .llm/skills/github-actions/git-renormalize-patterns.md for the required pattern." + "\nSee .llm/skills/github-actions/git-renormalize-patterns.md for renormalize guidance." ); process.exit(1); } @@ -270,6 +532,11 @@ if (typeof module !== 'undefined' && module.exports) { module.exports = { isForbiddenRenormalizePattern, hasExistenceCheck, + isGitIgnoredPath, + extractWorkflowPathEntries, + findIgnoredPathViolations, + extractRunBlocks, + findLockfileInstallViolations, validateWorkflow, Violation, }; From a5a31763971a68c38e6436f45da2e7fe50f62529 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 29 Apr 2026 15:15:46 -0700 Subject: [PATCH 06/12] PR feedback --- .../workflows/pre-commit-tooling-check.yml | 31 ++++ scripts/__tests__/run-managed-jest.test.js | 173 ++++++++++++++++-- scripts/run-managed-jest.js | 150 ++++++++++++++- 3 files changed, 330 insertions(+), 24 deletions(-) diff --git a/.github/workflows/pre-commit-tooling-check.yml b/.github/workflows/pre-commit-tooling-check.yml index 08922e6a..125e2061 100644 --- a/.github/workflows/pre-commit-tooling-check.yml +++ b/.github/workflows/pre-commit-tooling-check.yml @@ -128,9 +128,40 @@ jobs: - name: Run parser script tests hook run: pre-commit run script-parser-tests --all-files + - name: Diagnose managed Jest fallback environment + shell: bash + run: | + set -euo pipefail + echo "Node: $(node --version)" + echo "npm: $(npm --version)" + node <<'NODE' + const fs = require("fs"); + const runnerPath = "node_modules/jest-circus/build/runner.js"; + console.log("runner exists before deletion:", fs.existsSync(runnerPath)); + try { + console.log("resolved runner path:", require.resolve("jest-circus/runner")); + } catch (error) { + console.log(`resolved runner path: unavailable (${error.message})`); + } + NODE + - name: Remove managed Jest runner to force fallback path run: node scripts/verify-managed-jest-fallback.js + - name: Confirm managed Jest runner was removed + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const fs = require("fs"); + const runnerPath = "node_modules/jest-circus/build/runner.js"; + const exists = fs.existsSync(runnerPath); + console.log("runner exists after deletion:", exists); + if (exists) { + process.exit(1); + } + NODE + - name: Verify managed Jest fallback test run: >- node scripts/run-managed-jest.js --runTestsByPath diff --git a/scripts/__tests__/run-managed-jest.test.js b/scripts/__tests__/run-managed-jest.test.js index 82be79ed..91edf335 100644 --- a/scripts/__tests__/run-managed-jest.test.js +++ b/scripts/__tests__/run-managed-jest.test.js @@ -11,12 +11,16 @@ const { REPO_ROOT, LOCAL_JEST_BIN, FALLBACK_JEST_SPEC, + ISOLATED_JEST_CACHE_ROOT, getPinnedFallbackJestSpec, + getIsolatedJestPaths, + prepareIsolatedFallbackJest, toShellCommand, parseNpmMajorVersion, resolveLocalModule, isCommandUnavailable, hasHealthyLocalJestInstall, + runIsolatedFallbackJest, runManagedJest, } = require("../run-managed-jest.js"); @@ -34,18 +38,17 @@ describe("run-managed-jest", () => { spawnSyncSpy.mockRestore(); }); - test("parseNpmMajorVersion parses valid versions", () => { - expect(parseNpmMajorVersion("11.11.0\n")).toBe(11); - expect(parseNpmMajorVersion("v10.9.3")).toBe(10); - expect(parseNpmMajorVersion("not-a-version")).toBeNull(); - expect(parseNpmMajorVersion(null)).toBeNull(); - }); - - test("parseNpmMajorVersion rejects malformed version strings", () => { - expect(parseNpmMajorVersion("v")).toBeNull(); - expect(parseNpmMajorVersion("")).toBeNull(); - expect(parseNpmMajorVersion("abc.1.2")).toBeNull(); - expect(parseNpmMajorVersion({})).toBeNull(); + test.each([ + { input: "11.11.0\n", expected: 11 }, + { input: "v10.9.3", expected: 10 }, + { input: "not-a-version", expected: null }, + { input: null, expected: null }, + { input: "v", expected: null }, + { input: "", expected: null }, + { input: "abc.1.2", expected: null }, + { input: {}, expected: null }, + ])("parseNpmMajorVersion($input) -> $expected", ({ input, expected }) => { + expect(parseNpmMajorVersion(input)).toBe(expected); }); test("getPinnedFallbackJestSpec uses lockfile version when available", () => { @@ -73,12 +76,128 @@ describe("run-managed-jest", () => { expect(toShellCommand("npm", "win32")).toBe("npm.cmd"); }); - test("isCommandUnavailable handles common command-not-found scenarios", () => { - expect(isCommandUnavailable(null)).toBe(true); - expect(isCommandUnavailable({ status: 127, error: null })).toBe(true); - expect(isCommandUnavailable({ status: null, error: { code: "ENOENT" } })).toBe(true); - expect(isCommandUnavailable({ status: null, error: { code: "EACCES" } })).toBe(true); - expect(isCommandUnavailable({ status: 1, error: null })).toBe(false); + test.each([ + { value: null, expected: true }, + { value: { status: 127, error: null }, expected: true }, + { value: { status: null, error: { code: "ENOENT" } }, expected: true }, + { value: { status: null, error: { code: "EACCES" } }, expected: true }, + { value: { status: 1, error: null }, expected: false }, + ])("isCommandUnavailable(%j) -> $expected", ({ value, expected }) => { + expect(isCommandUnavailable(value)).toBe(expected); + }); + + test("prepareIsolatedFallbackJest reuses cached isolated binary when available", () => { + const jestSpec = "jest@30.3.0"; + const { jestBinPath } = getIsolatedJestPaths(jestSpec); + const existsSyncFn = jest.fn((targetPath) => targetPath === jestBinPath); + const runCommandFn = jest.fn(); + + const result = prepareIsolatedFallbackJest(jestSpec, { + existsSyncFn, + runCommandFn, + }); + + expect(result).toEqual({ jestBinPath, cacheHit: true }); + expect(runCommandFn).not.toHaveBeenCalled(); + }); + + test("prepareIsolatedFallbackJest installs isolated fallback when cache is missing", () => { + const jestSpec = "jest@30.3.0"; + const { installDir, packageJsonPath, jestBinPath } = getIsolatedJestPaths(jestSpec); + const existingPaths = new Set(); + + const existsSyncFn = jest.fn((targetPath) => existingPaths.has(targetPath)); + const mkdirSyncFn = jest.fn(); + const writeFileSyncFn = jest.fn((targetPath) => { + existingPaths.add(targetPath); + }); + const runCommandFn = jest.fn((_command, _args, options) => { + expect(options).toEqual(expect.objectContaining({ cwd: installDir })); + existingPaths.add(jestBinPath); + return { status: 0, error: null }; + }); + + const result = prepareIsolatedFallbackJest(jestSpec, { + existsSyncFn, + mkdirSyncFn, + writeFileSyncFn, + runCommandFn, + }); + + expect(result).toEqual({ jestBinPath, cacheHit: false }); + expect(mkdirSyncFn).toHaveBeenCalledWith(installDir, { recursive: true }); + expect(writeFileSyncFn).toHaveBeenCalledWith( + packageJsonPath, + expect.stringContaining("dxmessaging-managed-jest-fallback-cache"), + "utf8" + ); + expect(runCommandFn).toHaveBeenCalledWith( + "npm", + [ + "install", + "--no-audit", + "--no-fund", + "--no-package-lock", + "--no-save", + jestSpec, + ], + expect.objectContaining({ cwd: installDir }) + ); + }); + + test("prepareIsolatedFallbackJest reports unavailable isolated fallback when install fails", () => { + const warnFn = jest.fn(); + const result = prepareIsolatedFallbackJest("jest@30.3.0", { + existsSyncFn: () => false, + mkdirSyncFn: jest.fn(), + writeFileSyncFn: jest.fn(), + runCommandFn: () => ({ status: 1, error: null }), + warnFn, + }); + + expect(result).toEqual({ jestBinPath: null, cacheHit: false }); + expect( + warnFn.mock.calls.some((call) => call[0].includes("install failed")) + ).toBe(true); + }); + + test("runIsolatedFallbackJest executes isolated binary when prepared", () => { + const runCommandFn = jest.fn(() => ({ status: 0, error: null })); + const printIsolatedFallbackSelectionFn = jest.fn(); + const result = runIsolatedFallbackJest(["--version"], { + getPinnedFallbackJestSpecFn: () => "jest@30.3.0", + prepareIsolatedFallbackJestFn: () => ({ + jestBinPath: path.join(ISOLATED_JEST_CACHE_ROOT, "jest_30.3.0", "node_modules", "jest", "bin", "jest.js"), + cacheHit: true, + }), + runCommandFn, + printIsolatedFallbackSelectionFn, + }); + + expect(result).toEqual({ status: 0, error: null }); + expect(printIsolatedFallbackSelectionFn).toHaveBeenCalledTimes(1); + expect(runCommandFn).toHaveBeenCalledWith( + process.execPath, + [ + path.join(ISOLATED_JEST_CACHE_ROOT, "jest_30.3.0", "node_modules", "jest", "bin", "jest.js"), + "--version", + ] + ); + }); + + test("runIsolatedFallbackJest returns null when isolated fallback cannot be prepared", () => { + const runCommandFn = jest.fn(); + const result = runIsolatedFallbackJest(["--version"], { + getPinnedFallbackJestSpecFn: () => "jest@30.3.0", + prepareIsolatedFallbackJestFn: () => ({ + jestBinPath: null, + cacheHit: false, + }), + runCommandFn, + }); + + expect(result).toBeNull(); + expect(runCommandFn).not.toHaveBeenCalled(); }); test("hasHealthyLocalJestInstall returns false when local jest binary is missing", () => { @@ -166,6 +285,7 @@ describe("run-managed-jest", () => { const result = runManagedJest(["--version"], { hasHealthyLocalJestInstallFn: () => false, printLocalJestFallbackWarningFn: fallbackWarningSpy, + runIsolatedFallbackJestFn: () => null, }); expect(result).toEqual({ status: 0, error: null }); @@ -184,6 +304,23 @@ describe("run-managed-jest", () => { ); }); + test("runManagedJest uses isolated fallback when local install is unhealthy", () => { + existsSyncSpy.mockReturnValue(true); + const isolatedResult = { status: 0, error: null }; + const runIsolatedFallbackJestFn = jest.fn(() => isolatedResult); + const runNpmExecJestFn = jest.fn(); + + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => false, + runIsolatedFallbackJestFn, + runNpmExecJestFn, + }); + + expect(result).toEqual(isolatedResult); + expect(runIsolatedFallbackJestFn).toHaveBeenCalledWith(["--version"]); + expect(runNpmExecJestFn).not.toHaveBeenCalled(); + }); + test("runManagedJest uses npx fallback when npm major version is older than 7", () => { existsSyncSpy.mockReturnValue(false); const pinnedFallbackJestSpec = getPinnedFallbackJestSpec(); diff --git a/scripts/run-managed-jest.js b/scripts/run-managed-jest.js index 06a81889..ba452585 100644 --- a/scripts/run-managed-jest.js +++ b/scripts/run-managed-jest.js @@ -11,6 +11,7 @@ "use strict"; const fs = require("fs"); +const os = require("os"); const path = require("path"); const childProcess = require("child_process"); const { createRequire } = require("module"); @@ -25,6 +26,7 @@ const REPO_NODE_MODULES = path.join(REPO_ROOT, "node_modules"); const PACKAGE_LOCK_PATH = path.join(REPO_ROOT, "package-lock.json"); const LOCAL_JEST_BIN = path.join(REPO_ROOT, "node_modules", "jest", "bin", "jest.js"); const FALLBACK_JEST_SPEC = "jest@30.3.0"; +const ISOLATED_JEST_CACHE_ROOT = path.join(os.tmpdir(), "dxmessaging-managed-jest"); const REPO_REQUIRE = createRequire(path.join(REPO_ROOT, "package.json")); function parseNpmMajorVersion(versionText) { @@ -54,7 +56,7 @@ function getNpmMajorVersion() { return parseNpmMajorVersion(result.stdout); } -function runCommand(command, args) { +function runCommand(command, args, spawnOptions = {}) { const spawnSyncImpl = isShellShimCommand(command) ? spawnPlatformCommandSync : childProcess.spawnSync; @@ -62,6 +64,7 @@ function runCommand(command, args) { const result = spawnSyncImpl(command, args, { cwd: REPO_ROOT, stdio: "inherit", + ...spawnOptions, }); return { @@ -113,6 +116,122 @@ function runNpxJest(args) { ]); } +function sanitizeCacheKey(value) { + return String(value).replace(/[^a-zA-Z0-9._-]+/g, "_"); +} + +function getIsolatedJestPaths(jestSpec) { + const cacheKey = sanitizeCacheKey(jestSpec); + const installDir = path.join(ISOLATED_JEST_CACHE_ROOT, cacheKey); + return { + installDir, + packageJsonPath: path.join(installDir, "package.json"), + jestBinPath: path.join(installDir, "node_modules", "jest", "bin", "jest.js"), + }; +} + +function writeIsolatedJestCacheManifest( + packageJsonPath, + writeFileSyncFn = fs.writeFileSync +) { + const manifest = { + name: "dxmessaging-managed-jest-fallback-cache", + private: true, + }; + writeFileSyncFn(packageJsonPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +} + +function prepareIsolatedFallbackJest( + jestSpec = getPinnedFallbackJestSpec(), + { + existsSyncFn = fs.existsSync, + mkdirSyncFn = fs.mkdirSync, + writeFileSyncFn = fs.writeFileSync, + runCommandFn = runCommand, + warnFn = console.warn, + } = {} +) { + const { installDir, packageJsonPath, jestBinPath } = getIsolatedJestPaths(jestSpec); + + if (existsSyncFn(jestBinPath)) { + return { + jestBinPath, + cacheHit: true, + }; + } + + mkdirSyncFn(installDir, { recursive: true }); + + if (!existsSyncFn(packageJsonPath)) { + writeIsolatedJestCacheManifest(packageJsonPath, writeFileSyncFn); + } + + warnFn(`⚠️ Installing isolated fallback Jest (${jestSpec}).`); + const installResult = runCommandFn( + "npm", + [ + "install", + "--no-audit", + "--no-fund", + "--no-package-lock", + "--no-save", + jestSpec, + ], + { + cwd: installDir, + } + ); + + if (installResult.error || installResult.status !== 0) { + const detail = installResult.error && installResult.error.message + ? installResult.error.message + : `status=${installResult.status}`; + warnFn(`⚠️ Isolated fallback Jest install failed (${detail}).`); + return { + jestBinPath: null, + cacheHit: false, + }; + } + + if (!existsSyncFn(jestBinPath)) { + warnFn(`⚠️ Isolated fallback Jest binary missing after install: ${jestBinPath}`); + return { + jestBinPath: null, + cacheHit: false, + }; + } + + return { + jestBinPath, + cacheHit: false, + }; +} + +function printIsolatedFallbackSelection(jestBinPath, cacheHit) { + const cacheLabel = cacheHit ? "cache hit" : "fresh install"; + console.warn(`⚠️ Using isolated fallback Jest (${cacheLabel}): ${jestBinPath}`); +} + +function runIsolatedFallbackJest( + args, + { + getPinnedFallbackJestSpecFn = getPinnedFallbackJestSpec, + prepareIsolatedFallbackJestFn = prepareIsolatedFallbackJest, + runCommandFn = runCommand, + printIsolatedFallbackSelectionFn = printIsolatedFallbackSelection, + } = {} +) { + const jestSpec = getPinnedFallbackJestSpecFn(); + const prepared = prepareIsolatedFallbackJestFn(jestSpec); + + if (!prepared || !prepared.jestBinPath) { + return null; + } + + printIsolatedFallbackSelectionFn(prepared.jestBinPath, prepared.cacheHit); + return runCommandFn(process.execPath, [prepared.jestBinPath, ...args]); +} + function isCommandUnavailable(result) { if (!result) { return true; @@ -172,7 +291,7 @@ function hasHealthyLocalJestInstall( function printLocalJestFallbackWarning() { console.warn( - "⚠️ Local Jest install appears incomplete; falling back to pinned npm exec Jest." + "⚠️ Local Jest install appears incomplete; falling back to managed Jest." ); if (process.platform === "win32") { console.warn("Windows tip: run npm install/npm ci in the same shell used by git hooks."); @@ -184,25 +303,37 @@ function runManagedJest(args, options = {}) { hasHealthyLocalJestInstallFn = hasHealthyLocalJestInstall, getNpmMajorVersionFn = getNpmMajorVersion, printLocalJestFallbackWarningFn = printLocalJestFallbackWarning, + runIsolatedFallbackJestFn = runIsolatedFallbackJest, + runNpmExecJestFn = runNpmExecJest, + runNpxJestFn = runNpxJest, } = options; if (hasHealthyLocalJestInstallFn()) { return runLocalJest(args); } - if (fs.existsSync(LOCAL_JEST_BIN)) { + const hasLocalJestBinary = fs.existsSync(LOCAL_JEST_BIN); + + if (hasLocalJestBinary) { printLocalJestFallbackWarningFn(); + + const isolatedFallbackResult = runIsolatedFallbackJestFn(args); + if (isolatedFallbackResult) { + return isolatedFallbackResult; + } + + console.warn("⚠️ Isolated fallback Jest was unavailable; trying npm exec/npx fallback."); } const npmMajor = getNpmMajorVersionFn(); if (npmMajor === null || npmMajor < 7) { - return runNpxJest(args); + return runNpxJestFn(args); } - const npmExecResult = runNpmExecJest(args); + const npmExecResult = runNpmExecJestFn(args); if (isCommandUnavailable(npmExecResult)) { - return runNpxJest(args); + return runNpxJestFn(args); } return npmExecResult; @@ -235,11 +366,18 @@ module.exports = { PACKAGE_LOCK_PATH, LOCAL_JEST_BIN, FALLBACK_JEST_SPEC, + ISOLATED_JEST_CACHE_ROOT, normalizeForPathComparison, isPathInsideDirectory, resolveLocalModule, hasHealthyLocalJestInstall, printLocalJestFallbackWarning, + sanitizeCacheKey, + getIsolatedJestPaths, + writeIsolatedJestCacheManifest, + prepareIsolatedFallbackJest, + printIsolatedFallbackSelection, + runIsolatedFallbackJest, toShellCommand, parseNpmMajorVersion, getNpmMajorVersion, From 666aa8ec0c9ef0bcf2a3e42370e40782f77e0d5c Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 29 Apr 2026 15:44:32 -0700 Subject: [PATCH 07/12] PR feedback --- .../workflows/pre-commit-tooling-check.yml | 57 +++++- scripts/__tests__/run-managed-jest.test.js | 178 ++++++++++++++++-- scripts/run-managed-jest.js | 108 ++++++++++- 3 files changed, 326 insertions(+), 17 deletions(-) diff --git a/.github/workflows/pre-commit-tooling-check.yml b/.github/workflows/pre-commit-tooling-check.yml index 125e2061..6b48a016 100644 --- a/.github/workflows/pre-commit-tooling-check.yml +++ b/.github/workflows/pre-commit-tooling-check.yml @@ -162,8 +162,63 @@ jobs: } NODE + - name: Diagnose isolated Jest fallback resolution inputs + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const fs = require("fs"); + const { + getPinnedFallbackJestSpec, + getIsolatedJestPaths, + } = require("./scripts/run-managed-jest.js"); + + const spec = getPinnedFallbackJestSpec(); + const isolatedPaths = getIsolatedJestPaths(spec); + + console.log("fallback spec:", spec); + console.log("cwd:", process.cwd()); + console.log("NODE_PATH:", process.env.NODE_PATH || ""); + console.log("isolated install dir:", isolatedPaths.installDir); + console.log( + "isolated jest bin:", + isolatedPaths.jestBinPath, + "exists:", + fs.existsSync(isolatedPaths.jestBinPath) + ); + console.log( + "isolated jest runner:", + isolatedPaths.jestRunnerPath, + "exists:", + fs.existsSync(isolatedPaths.jestRunnerPath) + ); + + try { + console.log("repo resolve jest-circus/runner:", require.resolve("jest-circus/runner")); + } catch (error) { + console.log(`repo resolve jest-circus/runner: unavailable (${error.message})`); + } + NODE + + - name: Assert isolated Jest fallback path is active + shell: bash + run: | + set -euo pipefail + output_file="$(mktemp)" + node scripts/run-managed-jest.js --version 2>&1 | tee "$output_file" + + if ! grep -Fq "Using isolated fallback Jest" "$output_file"; then + echo "::error::Expected isolated fallback Jest to be used after removing local runner." + exit 1 + fi + + if ! grep -Fq "Injected isolated Jest test runner" "$output_file"; then + echo "::error::Expected isolated fallback test runner injection diagnostics." + exit 1 + fi + - name: Verify managed Jest fallback test run: >- node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/run-managed-jest.test.js --testNamePattern - "runManagedJest uses npm exec fallback when local jest install is unhealthy" + "runIsolatedFallbackJest injects isolated testRunner when caller did not provide one" diff --git a/scripts/__tests__/run-managed-jest.test.js b/scripts/__tests__/run-managed-jest.test.js index 91edf335..8a14c4d0 100644 --- a/scripts/__tests__/run-managed-jest.test.js +++ b/scripts/__tests__/run-managed-jest.test.js @@ -14,6 +14,8 @@ const { ISOLATED_JEST_CACHE_ROOT, getPinnedFallbackJestSpec, getIsolatedJestPaths, + hasCliOption, + buildNodePathEnv, prepareIsolatedFallbackJest, toShellCommand, parseNpmMajorVersion, @@ -51,6 +53,35 @@ describe("run-managed-jest", () => { expect(parseNpmMajorVersion(input)).toBe(expected); }); + test.each([ + { args: ["--version"], option: "--testRunner", expected: false }, + { args: ["--testRunner", "custom-runner.js"], option: "--testRunner", expected: true }, + { args: ["--testRunner=custom-runner.js"], option: "--testRunner", expected: true }, + { args: ["--watch"], option: "watch", expected: true }, + { args: ["--watchAll"], option: "watch", expected: false }, + ])("hasCliOption($args, $option) -> $expected", ({ args, option, expected }) => { + expect(hasCliOption(args, option)).toBe(expected); + }); + + test.each([ + { + isolatedNodeModulesPath: path.join("/tmp", "isolated", "node_modules"), + baseEnv: {}, + expectedNodePath: path.join("/tmp", "isolated", "node_modules"), + }, + { + isolatedNodeModulesPath: path.join("/tmp", "isolated", "node_modules"), + baseEnv: { NODE_PATH: path.join("/tmp", "existing", "node_modules") }, + expectedNodePath: [ + path.join("/tmp", "isolated", "node_modules"), + path.join("/tmp", "existing", "node_modules"), + ].join(path.delimiter), + }, + ])("buildNodePathEnv prepends isolated node_modules", ({ isolatedNodeModulesPath, baseEnv, expectedNodePath }) => { + const result = buildNodePathEnv(isolatedNodeModulesPath, baseEnv); + expect(result.NODE_PATH).toBe(expectedNodePath); + }); + test("getPinnedFallbackJestSpec uses lockfile version when available", () => { const readFileSyncFn = jest.fn(() => JSON.stringify({ @@ -88,8 +119,10 @@ describe("run-managed-jest", () => { test("prepareIsolatedFallbackJest reuses cached isolated binary when available", () => { const jestSpec = "jest@30.3.0"; - const { jestBinPath } = getIsolatedJestPaths(jestSpec); - const existsSyncFn = jest.fn((targetPath) => targetPath === jestBinPath); + const { jestBinPath, jestRunnerPath } = getIsolatedJestPaths(jestSpec); + const existsSyncFn = jest.fn( + (targetPath) => targetPath === jestBinPath || targetPath === jestRunnerPath + ); const runCommandFn = jest.fn(); const result = prepareIsolatedFallbackJest(jestSpec, { @@ -97,13 +130,38 @@ describe("run-managed-jest", () => { runCommandFn, }); - expect(result).toEqual({ jestBinPath, cacheHit: true }); + expect(result).toEqual({ jestBinPath, jestRunnerPath, cacheHit: true }); expect(runCommandFn).not.toHaveBeenCalled(); }); + test("prepareIsolatedFallbackJest reinstalls when cached runner is missing", () => { + const jestSpec = "jest@30.3.0"; + const { installDir, jestBinPath, jestRunnerPath } = getIsolatedJestPaths(jestSpec); + const existingPaths = new Set([jestBinPath]); + + const existsSyncFn = jest.fn((targetPath) => existingPaths.has(targetPath)); + const runCommandFn = jest.fn((_command, _args, options) => { + expect(options).toEqual(expect.objectContaining({ cwd: installDir })); + existingPaths.add(jestBinPath); + existingPaths.add(jestRunnerPath); + return { status: 0, error: null }; + }); + + const result = prepareIsolatedFallbackJest(jestSpec, { + existsSyncFn, + mkdirSyncFn: jest.fn(), + writeFileSyncFn: jest.fn(), + runCommandFn, + warnFn: jest.fn(), + }); + + expect(result).toEqual({ jestBinPath, jestRunnerPath, cacheHit: false }); + expect(runCommandFn).toHaveBeenCalledTimes(1); + }); + test("prepareIsolatedFallbackJest installs isolated fallback when cache is missing", () => { const jestSpec = "jest@30.3.0"; - const { installDir, packageJsonPath, jestBinPath } = getIsolatedJestPaths(jestSpec); + const { installDir, packageJsonPath, jestBinPath, jestRunnerPath } = getIsolatedJestPaths(jestSpec); const existingPaths = new Set(); const existsSyncFn = jest.fn((targetPath) => existingPaths.has(targetPath)); @@ -114,6 +172,7 @@ describe("run-managed-jest", () => { const runCommandFn = jest.fn((_command, _args, options) => { expect(options).toEqual(expect.objectContaining({ cwd: installDir })); existingPaths.add(jestBinPath); + existingPaths.add(jestRunnerPath); return { status: 0, error: null }; }); @@ -124,7 +183,7 @@ describe("run-managed-jest", () => { runCommandFn, }); - expect(result).toEqual({ jestBinPath, cacheHit: false }); + expect(result).toEqual({ jestBinPath, jestRunnerPath, cacheHit: false }); expect(mkdirSyncFn).toHaveBeenCalledWith(installDir, { recursive: true }); expect(writeFileSyncFn).toHaveBeenCalledWith( packageJsonPath, @@ -155,33 +214,106 @@ describe("run-managed-jest", () => { warnFn, }); - expect(result).toEqual({ jestBinPath: null, cacheHit: false }); + expect(result).toEqual({ jestBinPath: null, jestRunnerPath: null, cacheHit: false }); expect( warnFn.mock.calls.some((call) => call[0].includes("install failed")) ).toBe(true); }); - test("runIsolatedFallbackJest executes isolated binary when prepared", () => { + test("runIsolatedFallbackJest injects isolated testRunner when caller did not provide one", () => { + const jestSpec = "jest@30.3.0"; + const { jestBinPath, jestRunnerPath } = getIsolatedJestPaths(jestSpec); const runCommandFn = jest.fn(() => ({ status: 0, error: null })); const printIsolatedFallbackSelectionFn = jest.fn(); const result = runIsolatedFallbackJest(["--version"], { - getPinnedFallbackJestSpecFn: () => "jest@30.3.0", + getPinnedFallbackJestSpecFn: () => jestSpec, prepareIsolatedFallbackJestFn: () => ({ - jestBinPath: path.join(ISOLATED_JEST_CACHE_ROOT, "jest_30.3.0", "node_modules", "jest", "bin", "jest.js"), + jestBinPath, + jestRunnerPath, cacheHit: true, }), runCommandFn, printIsolatedFallbackSelectionFn, + existsSyncFn: () => true, }); expect(result).toEqual({ status: 0, error: null }); expect(printIsolatedFallbackSelectionFn).toHaveBeenCalledTimes(1); + expect(printIsolatedFallbackSelectionFn).toHaveBeenCalledWith( + jestBinPath, + true, + expect.objectContaining({ + testRunnerPath: jestRunnerPath, + testRunnerInjected: true, + callerProvidedTestRunner: false, + nodePathOverride: expect.stringContaining( + path.join(ISOLATED_JEST_CACHE_ROOT, "jest_30.3.0", "node_modules") + ), + }) + ); + expect(runCommandFn).toHaveBeenCalledWith( + process.execPath, + [ + jestBinPath, + "--testRunner", + jestRunnerPath, + "--version", + ], + expect.objectContaining({ + env: expect.objectContaining({ + NODE_PATH: expect.stringContaining( + path.join(ISOLATED_JEST_CACHE_ROOT, "jest_30.3.0", "node_modules") + ), + }), + }) + ); + }); + + test("runIsolatedFallbackJest preserves caller-provided --testRunner", () => { + const jestSpec = "jest@30.3.0"; + const { jestBinPath, jestRunnerPath } = getIsolatedJestPaths(jestSpec); + const runCommandFn = jest.fn(() => ({ status: 0, error: null })); + const printIsolatedFallbackSelectionFn = jest.fn(); + + runIsolatedFallbackJest(["--testRunner", "custom-runner.js", "--version"], { + getPinnedFallbackJestSpecFn: () => jestSpec, + prepareIsolatedFallbackJestFn: () => ({ + jestBinPath, + jestRunnerPath, + cacheHit: false, + }), + runCommandFn, + printIsolatedFallbackSelectionFn, + existsSyncFn: () => true, + }); + expect(runCommandFn).toHaveBeenCalledWith( process.execPath, [ - path.join(ISOLATED_JEST_CACHE_ROOT, "jest_30.3.0", "node_modules", "jest", "bin", "jest.js"), + jestBinPath, + "--testRunner", + "custom-runner.js", "--version", - ] + ], + expect.objectContaining({ + env: expect.objectContaining({ + NODE_PATH: expect.stringContaining( + path.join(ISOLATED_JEST_CACHE_ROOT, "jest_30.3.0", "node_modules") + ), + }), + }) + ); + expect(printIsolatedFallbackSelectionFn).toHaveBeenCalledWith( + jestBinPath, + false, + expect.objectContaining({ + testRunnerPath: null, + testRunnerInjected: false, + callerProvidedTestRunner: true, + nodePathOverride: expect.stringContaining( + path.join(ISOLATED_JEST_CACHE_ROOT, "jest_30.3.0", "node_modules") + ), + }) ); }); @@ -200,6 +332,30 @@ describe("run-managed-jest", () => { expect(runCommandFn).not.toHaveBeenCalled(); }); + test("runIsolatedFallbackJest returns null when runner injection is required but unavailable", () => { + const jestSpec = "jest@30.3.0"; + const runCommandFn = jest.fn(); + const warnFn = jest.fn(); + + const result = runIsolatedFallbackJest(["--version"], { + getPinnedFallbackJestSpecFn: () => jestSpec, + prepareIsolatedFallbackJestFn: () => ({ + jestBinPath: path.join(ISOLATED_JEST_CACHE_ROOT, "jest_30.3.0", "node_modules", "jest", "bin", "jest.js"), + jestRunnerPath: null, + cacheHit: false, + }), + runCommandFn, + existsSyncFn: () => false, + warnFn, + }); + + expect(result).toBeNull(); + expect(runCommandFn).not.toHaveBeenCalled(); + expect( + warnFn.mock.calls.some((call) => call[0].includes("runner unavailable")) + ).toBe(true); + }); + test("hasHealthyLocalJestInstall returns false when local jest binary is missing", () => { const result = hasHealthyLocalJestInstall(() => path.join(REPO_ROOT, "node_modules", "jest-circus", "build", "runner.js"), () => false); expect(result).toBe(false); diff --git a/scripts/run-managed-jest.js b/scripts/run-managed-jest.js index ba452585..81b4f8bf 100644 --- a/scripts/run-managed-jest.js +++ b/scripts/run-managed-jest.js @@ -127,6 +127,29 @@ function getIsolatedJestPaths(jestSpec) { installDir, packageJsonPath: path.join(installDir, "package.json"), jestBinPath: path.join(installDir, "node_modules", "jest", "bin", "jest.js"), + jestRunnerPath: path.join(installDir, "node_modules", "jest-circus", "build", "runner.js"), + }; +} + +function hasCliOption(args, optionName) { + const normalizedOption = optionName.startsWith("--") + ? optionName + : `--${optionName}`; + + return args.some((arg) => + arg === normalizedOption || arg.startsWith(`${normalizedOption}=`) + ); +} + +function buildNodePathEnv(isolatedNodeModulesPath, baseEnv = process.env) { + const existingNodePath = baseEnv.NODE_PATH; + const nextNodePath = existingNodePath + ? `${isolatedNodeModulesPath}${path.delimiter}${existingNodePath}` + : isolatedNodeModulesPath; + + return { + ...baseEnv, + NODE_PATH: nextNodePath, }; } @@ -151,15 +174,23 @@ function prepareIsolatedFallbackJest( warnFn = console.warn, } = {} ) { - const { installDir, packageJsonPath, jestBinPath } = getIsolatedJestPaths(jestSpec); + const { installDir, packageJsonPath, jestBinPath, jestRunnerPath } = getIsolatedJestPaths(jestSpec); + + const hasCachedJestBin = existsSyncFn(jestBinPath); + const hasCachedJestRunner = existsSyncFn(jestRunnerPath); - if (existsSyncFn(jestBinPath)) { + if (hasCachedJestBin && hasCachedJestRunner) { return { jestBinPath, + jestRunnerPath, cacheHit: true, }; } + if (hasCachedJestBin && !hasCachedJestRunner) { + warnFn(`⚠️ Isolated fallback cache is missing Jest runner; reinstalling fallback: ${jestRunnerPath}`); + } + mkdirSyncFn(installDir, { recursive: true }); if (!existsSyncFn(packageJsonPath)) { @@ -189,6 +220,7 @@ function prepareIsolatedFallbackJest( warnFn(`⚠️ Isolated fallback Jest install failed (${detail}).`); return { jestBinPath: null, + jestRunnerPath: null, cacheHit: false, }; } @@ -197,19 +229,51 @@ function prepareIsolatedFallbackJest( warnFn(`⚠️ Isolated fallback Jest binary missing after install: ${jestBinPath}`); return { jestBinPath: null, + jestRunnerPath: null, + cacheHit: false, + }; + } + + if (!existsSyncFn(jestRunnerPath)) { + warnFn(`⚠️ Isolated fallback Jest runner missing after install: ${jestRunnerPath}`); + return { + jestBinPath: null, + jestRunnerPath: null, cacheHit: false, }; } return { jestBinPath, + jestRunnerPath, cacheHit: false, }; } -function printIsolatedFallbackSelection(jestBinPath, cacheHit) { +function printIsolatedFallbackSelection( + jestBinPath, + cacheHit, + { + testRunnerPath = null, + testRunnerInjected = false, + callerProvidedTestRunner = false, + nodePathOverride = null, + } = {} +) { const cacheLabel = cacheHit ? "cache hit" : "fresh install"; console.warn(`⚠️ Using isolated fallback Jest (${cacheLabel}): ${jestBinPath}`); + + if (testRunnerInjected && testRunnerPath) { + console.warn(`⚠️ Injected isolated Jest test runner: ${testRunnerPath}`); + } + + if (callerProvidedTestRunner) { + console.warn("⚠️ Caller provided --testRunner; managed runner did not override it."); + } + + if (nodePathOverride) { + console.warn(`⚠️ Injected NODE_PATH for isolated fallback: ${nodePathOverride}`); + } } function runIsolatedFallbackJest( @@ -219,6 +283,9 @@ function runIsolatedFallbackJest( prepareIsolatedFallbackJestFn = prepareIsolatedFallbackJest, runCommandFn = runCommand, printIsolatedFallbackSelectionFn = printIsolatedFallbackSelection, + existsSyncFn = fs.existsSync, + hasCliOptionFn = hasCliOption, + warnFn = console.warn, } = {} ) { const jestSpec = getPinnedFallbackJestSpecFn(); @@ -228,8 +295,37 @@ function runIsolatedFallbackJest( return null; } - printIsolatedFallbackSelectionFn(prepared.jestBinPath, prepared.cacheHit); - return runCommandFn(process.execPath, [prepared.jestBinPath, ...args]); + const invocationArgs = [prepared.jestBinPath]; + const callerProvidedTestRunner = hasCliOptionFn(args, "--testRunner"); + + let injectedRunnerPath = null; + if (!callerProvidedTestRunner) { + if (!prepared.jestRunnerPath || !existsSyncFn(prepared.jestRunnerPath)) { + warnFn(`⚠️ Isolated fallback Jest runner unavailable at expected path: ${prepared.jestRunnerPath}`); + return null; + } + + invocationArgs.push("--testRunner", prepared.jestRunnerPath); + injectedRunnerPath = prepared.jestRunnerPath; + } + + invocationArgs.push(...args); + + const isolatedNodeModulesPath = path.dirname( + path.dirname(path.dirname(prepared.jestBinPath)) + ); + const isolatedNodePathEnv = buildNodePathEnv(isolatedNodeModulesPath); + + printIsolatedFallbackSelectionFn(prepared.jestBinPath, prepared.cacheHit, { + testRunnerPath: injectedRunnerPath, + testRunnerInjected: Boolean(injectedRunnerPath), + callerProvidedTestRunner, + nodePathOverride: isolatedNodePathEnv.NODE_PATH, + }); + + return runCommandFn(process.execPath, invocationArgs, { + env: isolatedNodePathEnv, + }); } function isCommandUnavailable(result) { @@ -374,6 +470,8 @@ module.exports = { printLocalJestFallbackWarning, sanitizeCacheKey, getIsolatedJestPaths, + hasCliOption, + buildNodePathEnv, writeIsolatedJestCacheManifest, prepareIsolatedFallbackJest, printIsolatedFallbackSelection, From 18cf59f8a603e4c227fabbdd15f6c58e30fed523 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 29 Apr 2026 15:45:02 -0700 Subject: [PATCH 08/12] Add meta file --- Tests/dxmsg-csharp-underscore-repo-excluded.meta | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Tests/dxmsg-csharp-underscore-repo-excluded.meta diff --git a/Tests/dxmsg-csharp-underscore-repo-excluded.meta b/Tests/dxmsg-csharp-underscore-repo-excluded.meta new file mode 100644 index 00000000..7be230d2 --- /dev/null +++ b/Tests/dxmsg-csharp-underscore-repo-excluded.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c80da12a27ca09b4e840b0592fd8001a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 83a0ce11f2953aa951cb76ac6930db01636e4e4e Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 29 Apr 2026 20:02:08 -0700 Subject: [PATCH 09/12] PR feedback --- .../workflows/pre-commit-tooling-check.yml | 2 +- .github/workflows/validate-npm-meta.yml | 7 +- .llm/context.md | 2 + Editor/Analyzers/BaseCallLogMessageParser.cs | 9 +- Editor/Analyzers/BaseCallReportAggregator.cs | 14 ++- Editor/Analyzers/BaseCallTypeScannerCore.cs | 16 ++- .../WallstopStudios.DxMessaging.Analyzer.dll | Bin 22016 -> 23040 bytes ...opStudios.DxMessaging.SourceGenerators.dll | Bin 33280 -> 34816 bytes .../MessageAwareComponentFallbackEditor.cs | 1 + .../MessageAwareComponentInspectorOverlay.cs | 1 + .../BaseCallTypeScannerTests.cs | 27 +++++ com.wallstop-studios.dxmessaging.sln | 1 - .../fix-csharp-underscore-methods.test.js | 34 +++++++ scripts/__tests__/validate-workflows.test.js | 94 ++++++++++++++++++ .../verify-managed-jest-fallback.test.js | 73 ++++++++++++-- scripts/fix-csharp-underscore-methods.js | 9 +- scripts/validate-workflows.js | 79 ++++++++++++--- scripts/verify-managed-jest-fallback.js | 64 +++++++++++- 18 files changed, 391 insertions(+), 42 deletions(-) diff --git a/.github/workflows/pre-commit-tooling-check.yml b/.github/workflows/pre-commit-tooling-check.yml index 6b48a016..e9e57ff4 100644 --- a/.github/workflows/pre-commit-tooling-check.yml +++ b/.github/workflows/pre-commit-tooling-check.yml @@ -146,7 +146,7 @@ jobs: NODE - name: Remove managed Jest runner to force fallback path - run: node scripts/verify-managed-jest-fallback.js + run: node scripts/verify-managed-jest-fallback.js --force-delete-managed-runner - name: Confirm managed Jest runner was removed shell: bash diff --git a/.github/workflows/validate-npm-meta.yml b/.github/workflows/validate-npm-meta.yml index dba0b9e8..950e55b2 100644 --- a/.github/workflows/validate-npm-meta.yml +++ b/.github/workflows/validate-npm-meta.yml @@ -64,7 +64,12 @@ jobs: cache-dependency-path: package.json - name: Install dependencies - run: npm i --no-audit --no-fund + run: | + if [ -f package-lock.json ]; then + npm ci + else + npm i --no-audit --no-fund + fi - name: Validate NPM package meta files run: npm run validate:npm-meta diff --git a/.llm/context.md b/.llm/context.md index f70a31e9..ed13bcdf 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -78,6 +78,8 @@ This file is intentionally concise. It contains only critical, high-signal guida - 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. - 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. +- For destructive test harness scripts (for example deleting files under `node_modules`), require explicit CLI opt-in flags and validate target paths defensively before mutation. +- In workflows where `package-lock.json` is gitignored, dependency install blocks must be lockfile-aware (`npm ci` when lockfile exists, `npm i --no-audit --no-fund` fallback when absent); bare install-only blocks should be treated as policy violations. - For command alternation regexes, avoid optional-suffix shorthands that split words into partial tokens; prefer explicit alternation forms like `(?:install|i)` to keep patterns readable and spellcheck-safe. - For temporary test directory/file labels, prefer full descriptive words (for example `carriage-return-arguments`) over opaque abbreviations to reduce avoidable cspell failures. diff --git a/Editor/Analyzers/BaseCallLogMessageParser.cs b/Editor/Analyzers/BaseCallLogMessageParser.cs index 25a9017d..12447793 100644 --- a/Editor/Analyzers/BaseCallLogMessageParser.cs +++ b/Editor/Analyzers/BaseCallLogMessageParser.cs @@ -49,8 +49,8 @@ int line /// Per-type aggregate produced by . /// /// - /// is deduplicated and ordered by first-occurrence (insertion order - /// of the underlying ). uses ordinal comparison. + /// is deduplicated and stored in deterministic ordinal-sorted order + /// via . uses ordinal comparison. /// / hold the FIRST seen non-empty values so the /// inspector overlay's "Open Script" jump remains stable across repeated parses. /// @@ -249,8 +249,9 @@ out line /// /// Aggregates many log lines into a per-type report keyed by FQN. Deduplicates methods and - /// diagnostic ids ordinally; preserves first-occurrence for - /// / . + /// diagnostic ids ordinally; method names are stored in deterministic ordinal-sorted order. + /// Preserves first-occurrence for / + /// . /// public static Dictionary Aggregate(IEnumerable logLines) { diff --git a/Editor/Analyzers/BaseCallReportAggregator.cs b/Editor/Analyzers/BaseCallReportAggregator.cs index d551430a..1f63b158 100644 --- a/Editor/Analyzers/BaseCallReportAggregator.cs +++ b/Editor/Analyzers/BaseCallReportAggregator.cs @@ -122,7 +122,8 @@ Dictionary mergedReports // payload for each (assembly, FQN) pair. // // Per-FQN merge semantics: - // - Method list: union, deduplicated ordinally, first-seen order preserved. + // - Method list: union, deduplicated ordinally, stored in deterministic + // ordinal-sorted order. // - Diagnostic IDs: union via HashSet. // - File path / line: first non-empty wins (stable across recompiles). Dictionary rebuilt = new(StringComparer.Ordinal); @@ -279,8 +280,8 @@ ParsedTypeReport report { // Dedupe across the dual-source merge: LogEntries and CompilerMessage may // both surface the same `.` pair on Unity 2022+, where both - // pipes are wired. Keeping MissingBaseFor a List (rather than a - // HashSet) preserves first-seen order for stable HelpBox output. + // pipes are wired. MissingBaseFor is a SortedSet, so duplicates are + // removed and HelpBox output remains stable via deterministic ordinal sorting. existing.MissingBaseFor.Add(method); } } @@ -289,11 +290,8 @@ ParsedTypeReport report { if (!string.IsNullOrEmpty(id)) { - // Mirror MissingBaseFor's dedup. Even though DiagnosticIds is a HashSet today, - // an explicit Contains check keeps the merge contract stable against future - // shape changes (List would silently start producing duplicate ids - // without this guard). The dual-source merge — LogEntries + CompilerMessage on - // Unity 2022+ — is the path that exercises this branch in practice. + // HashSet dedupes repeated IDs surfaced by the dual-source merge + // (LogEntries + CompilerMessage on Unity 2022+). existing.DiagnosticIds.Add(id); } } diff --git a/Editor/Analyzers/BaseCallTypeScannerCore.cs b/Editor/Analyzers/BaseCallTypeScannerCore.cs index 5bff4e1f..8d43ba62 100644 --- a/Editor/Analyzers/BaseCallTypeScannerCore.cs +++ b/Editor/Analyzers/BaseCallTypeScannerCore.cs @@ -54,6 +54,9 @@ public static class BaseCallTypeScannerCore private const string IgnoreAttributeFullName = "DxMessaging.Core.Attributes.DxIgnoreMissingBaseCallAttribute"; + private const string MessageAwareComponentFullName = + "DxMessaging.Unity.MessageAwareComponent"; + /// /// Result row produced by . Mirrors the Unity-facing /// BaseCallReportEntry shape but uses pure BCL collections so the helper is @@ -125,12 +128,18 @@ IEnumerable ignoredTypeNames { continue; } + if ( + string.Equals(fullName, MessageAwareComponentFullName, StringComparison.Ordinal) + ) + { + continue; + } // FullName for nested types uses '+'; the analyzer (and the inspector overlay's // lookup) emits the dotted form. Normalise here so the scanner-produced snapshot // is keyed identically to the analyzer's identifiers. fullName = fullName.Replace('+', '.'); - bool optedOutByAttribute = TypeOrAncestorHasIgnoreAttribute(concrete); + bool optedOutByAttribute = TypeOrGuardedMethodHasIgnoreAttribute(concrete); bool optedOutByList = projectIgnore.Contains(fullName); ScanEntry entry = ScanOne(concrete, fullName); @@ -155,10 +164,11 @@ IEnumerable ignoredTypeNames return result; } - private static bool TypeOrAncestorHasIgnoreAttribute(Type type) + private static bool TypeOrGuardedMethodHasIgnoreAttribute(Type type) { // [DxIgnoreMissingBaseCall] applies with Inherited=false (matches the analyzer's - // attribute declaration), so we only walk the type itself plus its declared methods. + // attribute declaration), so we inspect only the type itself plus its declared + // guarded lifecycle methods. foreach (object attr in type.GetCustomAttributes(inherit: false)) { if (attr.GetType().FullName == IgnoreAttributeFullName) diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll index 8a0b4092dba46beea6e2681ce255b1821d8759f1..1d3e95c3d3ec557524c8b46853cd12a3c915834e 100644 GIT binary patch delta 7846 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 DHtb!6 delta 7045 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 diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll index 24bc7bdedbfa42d44e12919b91789f740d20b2d2..c2ddae6874b400d12bd0ce66b45841eb47f7d815 100644 GIT binary patch literal 34816 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 z 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 VtL6WBqWc%Uty2HX{Qrps{x1#=*6RQO diff --git a/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs b/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs index 6dfb00d6..7ab42ea1 100644 --- a/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs +++ b/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs @@ -1,6 +1,7 @@ namespace DxMessaging.Editor.CustomEditors { #if UNITY_EDITOR + using DxMessaging.Unity; using Unity; using UnityEditor; diff --git a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs index 8410e5fe..b07fd9fb 100644 --- a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs +++ b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs @@ -5,6 +5,7 @@ namespace DxMessaging.Editor.CustomEditors using System.Linq; using DxMessaging.Editor.Analyzers; using DxMessaging.Editor.Settings; + using DxMessaging.Unity; using Unity; using UnityEditor; using UnityEditorInternal; diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs index 630c9926..4a802f45 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs @@ -367,6 +367,33 @@ protected override void OnEnable() Assert.That(snapshot, Is.Empty); } + [Test] + public void ScanSkipsMessageAwareComponentWhenCandidateListIncludesBaseType() + { + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + + public class BrokenLeaf : MessageAwareComponent + { + protected override void OnEnable() + { + // No base call. + } + } + """ + ); + + Type messageAwareComponent = fixture.GetType("DxMessaging.Unity.MessageAwareComponent")!; + Type brokenLeaf = fixture.GetType("BrokenLeaf")!; + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(new[] { messageAwareComponent, brokenLeaf }, null); + + Assert.That(snapshot, Does.Not.ContainKey("DxMessaging.Unity.MessageAwareComponent")); + Assert.That(snapshot, Contains.Key("BrokenLeaf")); + } + [Test] public void ScanNestedTypeFqnUsesDotsNotPlusSign() { diff --git a/com.wallstop-studios.dxmessaging.sln b/com.wallstop-studios.dxmessaging.sln index db9accc5..b8d67615 100644 --- a/com.wallstop-studios.dxmessaging.sln +++ b/com.wallstop-studios.dxmessaging.sln @@ -1,4 +1,3 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 diff --git a/scripts/__tests__/fix-csharp-underscore-methods.test.js b/scripts/__tests__/fix-csharp-underscore-methods.test.js index 18b19163..db301677 100644 --- a/scripts/__tests__/fix-csharp-underscore-methods.test.js +++ b/scripts/__tests__/fix-csharp-underscore-methods.test.js @@ -145,6 +145,40 @@ describe("fix-csharp-underscore-methods", () => { expect(result.updatedContent).not.toContain("Method_Name"); }); + test("applyMethodRenames updates identifiers at the start of content", () => { + const source = [ + "Method_Name();", + "nameof(Method_Name);", + ].join("\n"); + + const renames = new Map([["Method_Name", "MethodName"]]); + const result = applyMethodRenames(source, renames); + + expect(result.renameCount).toBe(1); + expect(result.updatedContent).toContain("MethodName();"); + expect(result.updatedContent).toContain("nameof(MethodName);"); + }); + + test("applyMethodRenames does not rename identifier substrings", () => { + const source = [ + "public sealed class NamingTests", + "{", + " public void Method_Name()", + " {", + " Method_Name();", + " Method_Name_Helper();", + " }", + "}", + ].join("\n"); + + const renames = new Map([["Method_Name", "MethodName"]]); + const result = applyMethodRenames(source, renames); + + expect(result.renameCount).toBe(1); + expect(result.updatedContent).toContain("MethodName();"); + expect(result.updatedContent).toContain("Method_Name_Helper();"); + }); + test("--check exits non-zero when a fix is required", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-csharp-underscore-check-")); const filePath = path.join(tempDir, "NeedsFix.cs"); diff --git a/scripts/__tests__/validate-workflows.test.js b/scripts/__tests__/validate-workflows.test.js index 68347f68..b5326879 100644 --- a/scripts/__tests__/validate-workflows.test.js +++ b/scripts/__tests__/validate-workflows.test.js @@ -14,6 +14,7 @@ const path = require("path"); const { isForbiddenRenormalizePattern, hasExistenceCheck, + isGitIgnoredPath, extractWorkflowPathEntries, findIgnoredPathViolations, extractRunBlocks, @@ -239,6 +240,78 @@ describe("findIgnoredPathViolations", () => { }); }); +describe("isGitIgnoredPath", () => { + test("uses git check-ignore with --no-index and -- separator", () => { + const execFileSyncMock = jest.fn(); + + const ignored = isGitIgnoredPath( + "/repo", + "package-lock.json", + execFileSyncMock + ); + + expect(ignored).toBe(true); + expect(execFileSyncMock).toHaveBeenCalledWith( + "git", + ["check-ignore", "--quiet", "--no-index", "--", "package-lock.json"], + expect.objectContaining({ cwd: "/repo" }) + ); + }); + + test("falls back when git does not support --no-index", () => { + const unsupportedNoIndexError = new Error("unknown option"); + unsupportedNoIndexError.status = 129; + unsupportedNoIndexError.stderr = "error: unknown option `no-index`"; + + const execFileSyncMock = jest + .fn() + .mockImplementationOnce(() => { + throw unsupportedNoIndexError; + }) + .mockImplementationOnce(() => {}); + + const ignored = isGitIgnoredPath( + "/repo", + "package-lock.json", + execFileSyncMock + ); + + expect(ignored).toBe(true); + expect(execFileSyncMock).toHaveBeenNthCalledWith( + 2, + "git", + ["check-ignore", "--quiet", "--", "package-lock.json"], + expect.objectContaining({ cwd: "/repo" }) + ); + }); + + test("returns false when fallback check-ignore reports not ignored", () => { + const unsupportedNoIndexError = new Error("unknown option"); + unsupportedNoIndexError.status = 129; + unsupportedNoIndexError.stderr = "error: unknown option `no-index`"; + + const notIgnoredError = new Error("not ignored"); + notIgnoredError.status = 1; + + const execFileSyncMock = jest + .fn() + .mockImplementationOnce(() => { + throw unsupportedNoIndexError; + }) + .mockImplementationOnce(() => { + throw notIgnoredError; + }); + + const ignored = isGitIgnoredPath( + "/repo", + "package-lock.json", + execFileSyncMock + ); + + expect(ignored).toBe(false); + }); +}); + describe("run block lockfile policy", () => { test("extractRunBlocks handles folded and inline run definitions", () => { const lines = [ @@ -315,6 +388,27 @@ describe("run block lockfile policy", () => { ], expectedViolations: 0, }, + { + name: "flags npm install-only blocks", + lines: [ + "steps:", + " - run: npm i --no-audit --no-fund", + ], + expectedViolations: 1, + }, + { + name: "flags fallback with wrong lockfile guard", + lines: [ + "steps:", + " - run: |", + " if [ -f npm-shrinkwrap.json ]; then", + " npm ci", + " else", + " npm i --no-audit --no-fund", + " fi", + ], + expectedViolations: 1, + }, ])("findLockfileInstallViolations: $name", ({ lines, expectedViolations }) => { const violations = findLockfileInstallViolations( "test.yml", diff --git a/scripts/__tests__/verify-managed-jest-fallback.test.js b/scripts/__tests__/verify-managed-jest-fallback.test.js index 49a2f7ba..f2f25bae 100644 --- a/scripts/__tests__/verify-managed-jest-fallback.test.js +++ b/scripts/__tests__/verify-managed-jest-fallback.test.js @@ -5,9 +5,14 @@ "use strict"; const { + FORCE_DELETE_FLAG, resolveManagedRunnerPath, + isManagedRunnerPathSafe, + assertManagedRunnerPathSafe, + hasForcedDeletionOptIn, removeManagedRunner, verifyManagedJestFallback, + main, } = require("../verify-managed-jest-fallback.js"); describe("verify-managed-jest-fallback", () => { @@ -20,15 +25,49 @@ describe("verify-managed-jest-fallback", () => { expect(moduleResolver).toHaveBeenCalledWith("jest-circus/runner"); }); + test("isManagedRunnerPathSafe accepts expected managed runner paths", () => { + expect( + isManagedRunnerPathSafe( + "/repo/node_modules/jest-circus/build/runner.js" + ) + ).toBe(true); + }); + + test("assertManagedRunnerPathSafe rejects unexpected deletion targets", () => { + expect(() => assertManagedRunnerPathSafe("/tmp/runner.js")).toThrow( + "Refusing to delete unexpected runner path" + ); + }); + + test("hasForcedDeletionOptIn requires explicit flag", () => { + expect(hasForcedDeletionOptIn([])).toBe(false); + expect(hasForcedDeletionOptIn([FORCE_DELETE_FLAG])).toBe(true); + }); + test("removeManagedRunner throws when runner does not exist before deletion", () => { const existsSyncFn = jest.fn(() => false); const logFn = jest.fn(); expect(() => - removeManagedRunner("/tmp/missing-runner.js", { existsSyncFn, logFn }) + removeManagedRunner( + "/repo/node_modules/jest-circus/build/runner.js", + { existsSyncFn, logFn } + ) ).toThrow("Runner path does not exist before deletion."); }); + test("removeManagedRunner throws for unsafe path even if file exists", () => { + const existsSyncFn = jest.fn(() => true); + + expect(() => + removeManagedRunner("/tmp/runner.js", { + existsSyncFn, + rmSyncFn: jest.fn(), + logFn: jest.fn(), + }) + ).toThrow("Refusing to delete unexpected runner path"); + }); + test("removeManagedRunner removes existing runner", () => { const existsSyncFn = jest .fn() @@ -36,16 +75,21 @@ describe("verify-managed-jest-fallback", () => { .mockReturnValueOnce(false); const rmSyncFn = jest.fn(); const logFn = jest.fn(); + const runnerPath = "/repo/node_modules/jest-circus/build/runner.js"; - removeManagedRunner("/tmp/runner.js", { existsSyncFn, rmSyncFn, logFn }); + removeManagedRunner(runnerPath, { existsSyncFn, rmSyncFn, logFn }); - expect(logFn).toHaveBeenCalledWith("Deleting managed Jest runner: /tmp/runner.js"); - expect(rmSyncFn).toHaveBeenCalledWith("/tmp/runner.js"); + expect(logFn).toHaveBeenCalledWith( + "Deleting managed Jest runner: /repo/node_modules/jest-circus/build/runner.js" + ); + expect(rmSyncFn).toHaveBeenCalledWith(runnerPath); expect(existsSyncFn).toHaveBeenCalledTimes(2); }); test("verifyManagedJestFallback resolves and removes runner", () => { - const moduleResolver = jest.fn(() => "/tmp/runner.js"); + const moduleResolver = jest.fn( + () => "/repo/node_modules/jest-circus/build/runner.js" + ); const existsSyncFn = jest .fn() .mockReturnValueOnce(true) @@ -53,7 +97,7 @@ describe("verify-managed-jest-fallback", () => { const rmSyncFn = jest.fn(); const logFn = jest.fn(); - verifyManagedJestFallback({ + const runnerPath = verifyManagedJestFallback({ moduleResolver, existsSyncFn, rmSyncFn, @@ -61,6 +105,21 @@ describe("verify-managed-jest-fallback", () => { }); expect(moduleResolver).toHaveBeenCalledWith("jest-circus/runner"); - expect(rmSyncFn).toHaveBeenCalledWith("/tmp/runner.js"); + expect(runnerPath).toBe("/repo/node_modules/jest-circus/build/runner.js"); + expect(rmSyncFn).toHaveBeenCalledWith( + "/repo/node_modules/jest-circus/build/runner.js" + ); + }); + + test("main requires explicit force flag", () => { + expect(() => main([])).toThrow("Refusing to delete managed Jest runner"); + }); + + test("main invokes verifier when force flag is present", () => { + const verifyManagedJestFallbackFn = jest.fn(); + + main([FORCE_DELETE_FLAG], { verifyManagedJestFallbackFn }); + + expect(verifyManagedJestFallbackFn).toHaveBeenCalledTimes(1); }); }); diff --git a/scripts/fix-csharp-underscore-methods.js b/scripts/fix-csharp-underscore-methods.js index 8fb0b480..22553810 100644 --- a/scripts/fix-csharp-underscore-methods.js +++ b/scripts/fix-csharp-underscore-methods.js @@ -336,12 +336,17 @@ function applyMethodRenames(content, methodRenames) { let renameCount = 0; for (const [oldName, newName] of methodRenames.entries()) { + // Capture the non-identifier prefix so we can preserve it in the replacement without + // relying on lookbehind, which is unavailable on older Node runtimes. const namePattern = new RegExp( - `(? `${prefix}${newName}` + ); if (candidateContent !== updatedContent) { updatedContent = candidateContent; renameCount += 1; diff --git a/scripts/validate-workflows.js b/scripts/validate-workflows.js index b0fc16c2..c2604514 100644 --- a/scripts/validate-workflows.js +++ b/scripts/validate-workflows.js @@ -147,15 +147,29 @@ function isGitIgnoredPath(repoRoot, relativePath, execFileSyncImpl = execFileSyn return false; } - try { - execFileSyncImpl( - "git", - ["check-ignore", "--quiet", "--no-index", relativePath], - { - cwd: repoRoot, - stdio: "ignore", - } + const runCheckIgnore = (args) => + execFileSyncImpl("git", args, { + cwd: repoRoot, + stdio: ["ignore", "ignore", "pipe"], + }); + + const isUnsupportedNoIndex = (error) => { + const stderr = + error && error.stderr + ? String(error.stderr) + : ""; + const message = error && error.message ? String(error.message) : ""; + const combined = `${message}\n${stderr}`; + + return ( + (error && typeof error.status === "number" && error.status === 129) + || /unknown option|unknown switch/i.test(combined) + || /check-ignore/i.test(combined) && /no-index/i.test(combined) ); + }; + + try { + runCheckIgnore(["check-ignore", "--quiet", "--no-index", "--", relativePath]); return true; } catch (error) { if (error && typeof error.status === "number" && error.status === 1) { @@ -166,6 +180,33 @@ function isGitIgnoredPath(repoRoot, relativePath, execFileSyncImpl = execFileSyn return false; } + if (isUnsupportedNoIndex(error)) { + try { + runCheckIgnore(["check-ignore", "--quiet", "--", relativePath]); + return true; + } catch (fallbackError) { + if ( + fallbackError + && typeof fallbackError.status === "number" + && fallbackError.status === 1 + ) { + return false; + } + + if (fallbackError && fallbackError.code === "ENOENT") { + return false; + } + + const fallbackMessage = + fallbackError && fallbackError.message + ? fallbackError.message + : String(fallbackError); + throw new Error( + `Unable to evaluate git ignore status for '${relativePath}' after falling back from --no-index: ${fallbackMessage}` + ); + } + } + const message = error && error.message ? error.message : String(error); throw new Error( `Unable to evaluate git ignore status for '${relativePath}': ${message}` @@ -312,7 +353,23 @@ function findLockfileInstallViolations(relativePath, lines, packageLockIgnored) const runBlocks = extractRunBlocks(lines); for (const block of runBlocks) { - if (!/(^|\n|;|&&)\s*npm\s+ci\b/m.test(block.text)) { + const hasNpmCi = /(^|\n|;|&&)\s*npm\s+ci\b/m.test(block.text); + const hasNpmInstall = /(^|\n|;|&&)\s*npm\s+(?:install|i)\b/m.test(block.text); + + if (hasNpmInstall && !hasNpmCi) { + violations.push( + new Violation( + relativePath, + block.startLine, + "npm install", + "Repository ignores package-lock.json, so dependency install blocks must be lockfile-aware. Use npm ci when package-lock.json exists and npm install fallback when it does not.", + "error" + ) + ); + continue; + } + + if (!hasNpmCi) { continue; } @@ -321,8 +378,6 @@ function findLockfileInstallViolations(relativePath, lines, packageLockIgnored) /\btest\s+-f\s+package-lock\.json\b/.test(block.text); const hasAnyIfElseFallback = /\bif\b[\s\S]*?\bnpm\s+ci\b[\s\S]*?\belse\b[\s\S]*?\bnpm\s+(?:install|i)\b/.test(block.text); - const hasElseFallbackInstall = - /\belse\b[\s\S]*?\bnpm\s+(?:install|i)\b/.test(block.text); const hasOrFallbackInstall = /\bnpm\s+ci\b\s*\|\|\s*\bnpm\s+(?:install|i)\b/.test(block.text); const hasMissingLockfileHardFail = @@ -345,7 +400,7 @@ function findLockfileInstallViolations(relativePath, lines, packageLockIgnored) continue; } - if ((!hasLockfileCheck && !hasAnyIfElseFallback) || !hasElseFallbackInstall) { + if (!hasLockfileCheck || !hasAnyIfElseFallback) { violations.push( new Violation( relativePath, diff --git a/scripts/verify-managed-jest-fallback.js b/scripts/verify-managed-jest-fallback.js index 175ce26d..49091df1 100644 --- a/scripts/verify-managed-jest-fallback.js +++ b/scripts/verify-managed-jest-fallback.js @@ -2,19 +2,54 @@ "use strict"; const fs = require("fs"); +const path = require("path"); + +const FORCE_DELETE_FLAG = "--force-delete-managed-runner"; function resolveManagedRunnerPath(moduleResolver = require.resolve) { return moduleResolver("jest-circus/runner"); } +function isManagedRunnerPathSafe(runnerPath, { pathModule = path } = {}) { + if (typeof runnerPath !== "string" || runnerPath.trim().length === 0) { + return false; + } + + const normalizedPath = pathModule + .resolve(runnerPath) + .replace(/\\/g, "/") + .toLowerCase(); + + return ( + normalizedPath.includes("/node_modules/") + && normalizedPath.includes("/jest-circus/") + && normalizedPath.endsWith("/runner.js") + ); +} + +function assertManagedRunnerPathSafe(runnerPath, { pathModule = path } = {}) { + if (!isManagedRunnerPathSafe(runnerPath, { pathModule })) { + throw new Error( + `Refusing to delete unexpected runner path outside managed jest-circus target: ${runnerPath}` + ); + } +} + +function hasForcedDeletionOptIn(argv = process.argv.slice(2)) { + return Array.isArray(argv) && argv.includes(FORCE_DELETE_FLAG); +} + function removeManagedRunner( runnerPath, { existsSyncFn = fs.existsSync, rmSyncFn = fs.rmSync, logFn = console.log, + pathModule = path, } = {} ) { + assertManagedRunnerPathSafe(runnerPath, { pathModule }); + logFn(`Deleting managed Jest runner: ${runnerPath}`); if (!existsSyncFn(runnerPath)) { @@ -34,6 +69,7 @@ function verifyManagedJestFallback(options = {}) { existsSyncFn, rmSyncFn, logFn, + pathModule, } = options; const runnerPath = resolveManagedRunnerPath(moduleResolver); @@ -41,19 +77,41 @@ function verifyManagedJestFallback(options = {}) { existsSyncFn, rmSyncFn, logFn, + pathModule, }); + + return runnerPath; } -function main() { - verifyManagedJestFallback(); +function main( + argv = process.argv.slice(2), + { verifyManagedJestFallbackFn = verifyManagedJestFallback } = {} +) { + if (!hasForcedDeletionOptIn(argv)) { + throw new Error( + `Refusing to delete managed Jest runner without explicit opt-in. Re-run with ${FORCE_DELETE_FLAG}.` + ); + } + + verifyManagedJestFallbackFn(); } module.exports = { + FORCE_DELETE_FLAG, resolveManagedRunnerPath, + isManagedRunnerPathSafe, + assertManagedRunnerPathSafe, + hasForcedDeletionOptIn, removeManagedRunner, verifyManagedJestFallback, + main, }; if (require.main === module) { - main(); + try { + main(); + } catch (error) { + console.error(error.message || error); + process.exit(1); + } } From 23081db01f23bdee99100e61f79a02fb1ca4df1d Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Wed, 29 Apr 2026 20:33:03 -0700 Subject: [PATCH 10/12] PR feedback --- .github/workflows/validate-npm-meta.yml | 19 + scripts/__tests__/validate-workflows.test.js | 222 +++++++++ scripts/validate-workflows.js | 467 +++++++++++++++++++ 3 files changed, 708 insertions(+) diff --git a/.github/workflows/validate-npm-meta.yml b/.github/workflows/validate-npm-meta.yml index 950e55b2..86f6080f 100644 --- a/.github/workflows/validate-npm-meta.yml +++ b/.github/workflows/validate-npm-meta.yml @@ -50,6 +50,11 @@ jobs: - windows-latest runs-on: ${{ matrix.os }} timeout-minutes: 10 + defaults: + run: + # This job uses POSIX shell conditionals in run blocks; keep shell + # explicit so windows-latest does not default to PowerShell. + shell: bash steps: - name: Checkout uses: actions/checkout@v6 @@ -63,11 +68,25 @@ jobs: cache: 'npm' cache-dependency-path: package.json + - name: Diagnose shell and lockfile policy + run: | + set -euo pipefail + echo "Runner OS: ${{ runner.os }}" + echo "Bash version: ${BASH_VERSION:-unknown}" + if [ -f package-lock.json ]; then + echo "Lockfile status: package-lock.json present (install mode: npm ci)" + else + echo "Lockfile status: package-lock.json absent (install mode: npm i --no-audit --no-fund)" + fi + - name: Install dependencies run: | + set -euo pipefail if [ -f package-lock.json ]; then + echo "Install mode: npm ci" npm ci else + echo "Install mode: npm i --no-audit --no-fund" npm i --no-audit --no-fund fi diff --git a/scripts/__tests__/validate-workflows.test.js b/scripts/__tests__/validate-workflows.test.js index b5326879..61c331e0 100644 --- a/scripts/__tests__/validate-workflows.test.js +++ b/scripts/__tests__/validate-workflows.test.js @@ -19,6 +19,8 @@ const { findIgnoredPathViolations, extractRunBlocks, findLockfileInstallViolations, + detectBashSyntaxPattern, + findWindowsBashPortabilityViolations, validateWorkflow, } = require('../validate-workflows.js'); @@ -440,6 +442,226 @@ describe("run block lockfile policy", () => { }); }); +describe("windows matrix bash shell portability policy", () => { + test.each([ + { + name: "detects if bracket conditionals", + runText: "if [ -f package-lock.json ]; then\n npm ci\nfi", + expected: "if/elif [ ... ] conditional", + }, + { + name: "detects elif bracket conditionals", + runText: "if [ -f package-lock.json ]; then\n npm ci\nelif [ -f npm-shrinkwrap.json ]; then\n npm ci\nfi", + expected: "if/elif [ ... ] conditional", + }, + { + name: "detects for-in loops", + runText: "for ext in md json; do\n echo \"$ext\"\ndone", + expected: "for ... in loop", + }, + { + name: "detects while loops", + runText: "while [ -f package-lock.json ]; do\n break\ndone", + expected: "while [ ... ] loop", + }, + { + name: "detects until loops", + runText: "until [ -f package-lock.json ]; do\n break\ndone", + expected: "until [ ... ] loop", + }, + { + name: "detects set shell options", + runText: "set -euo pipefail\nnpm ci", + expected: "set -e/-o shell option", + }, + { + name: "detects test builtins", + runText: "test -f package-lock.json && npm ci", + expected: "test -f/-d shell check", + }, + { + name: "detects logical chaining operators", + runText: "npm ci && npm run validate:npm-meta", + expected: "logical chaining operator (&&/||)", + }, + { + name: "ignores commented bash snippets", + runText: "# if [ -f package-lock.json ]; then\nnpm ci", + expected: null, + }, + { + name: "ignores plain npm command without chaining", + runText: "npm ci", + expected: null, + }, + ])("detectBashSyntaxPattern: $name", ({ runText, expected }) => { + expect(detectBashSyntaxPattern(runText)).toBe(expected); + }); + + test.each([ + { + name: "flags bash syntax in windows matrix job without shell override", + lines: [ + "name: test", + "jobs:", + " validate:", + " runs-on: ${{ matrix.os }}", + " strategy:", + " matrix:", + " os:", + " - ubuntu-latest", + " - windows-latest", + " steps:", + " - name: Install", + " run: |", + " if [ -f package-lock.json ]; then", + " npm ci", + " else", + " npm i --no-audit --no-fund", + " fi", + ], + expectedViolationCount: 1, + }, + { + name: "flags shell chaining operators in windows matrix job without shell override", + lines: [ + "name: test", + "jobs:", + " validate:", + " runs-on: ${{ matrix.os }}", + " strategy:", + " matrix:", + " os:", + " - ubuntu-latest", + " - windows-latest", + " steps:", + " - name: Install", + " run: npm ci && npm run validate:npm-meta", + ], + expectedViolationCount: 1, + }, + { + name: "allows step-level shell override", + lines: [ + "name: test", + "jobs:", + " validate:", + " runs-on: ${{ matrix.os }}", + " strategy:", + " matrix:", + " os:", + " - ubuntu-latest", + " - windows-latest", + " steps:", + " - name: Install", + " shell: bash", + " run: |", + " if [ -f package-lock.json ]; then", + " npm ci", + " else", + " npm i --no-audit --no-fund", + " fi", + ], + expectedViolationCount: 0, + }, + { + name: "allows job defaults.run.shell override", + lines: [ + "name: test", + "jobs:", + " validate:", + " runs-on: ${{ matrix.os }}", + " strategy:", + " matrix:", + " os:", + " - ubuntu-latest", + " - windows-latest", + " defaults:", + " run:", + " shell: bash", + " steps:", + " - name: Install", + " run: |", + " if [ -f package-lock.json ]; then", + " npm ci", + " else", + " npm i --no-audit --no-fund", + " fi", + ], + expectedViolationCount: 0, + }, + { + name: "allows workflow defaults.run.shell override", + lines: [ + "name: test", + "defaults:", + " run:", + " shell: bash", + "jobs:", + " validate:", + " runs-on: ${{ matrix.os }}", + " strategy:", + " matrix:", + " os:", + " - ubuntu-latest", + " - windows-latest", + " steps:", + " - name: Install", + " run: |", + " if [ -f package-lock.json ]; then", + " npm ci", + " else", + " npm i --no-audit --no-fund", + " fi", + ], + expectedViolationCount: 0, + }, + { + name: "does not enforce bash-shell policy for ubuntu-only jobs", + lines: [ + "name: test", + "jobs:", + " validate:", + " runs-on: ubuntu-latest", + " steps:", + " - name: Install", + " run: |", + " if [ -f package-lock.json ]; then", + " npm ci", + " else", + " npm i --no-audit --no-fund", + " fi", + ], + expectedViolationCount: 0, + }, + { + name: "does not flag non-bash run blocks in windows jobs", + lines: [ + "name: test", + "jobs:", + " validate:", + " runs-on: ${{ matrix.os }}", + " strategy:", + " matrix:", + " os:", + " - ubuntu-latest", + " - windows-latest", + " steps:", + " - name: Install", + " run: npm ci", + ], + expectedViolationCount: 0, + }, + ])("findWindowsBashPortabilityViolations: $name", ({ lines, expectedViolationCount }) => { + const violations = findWindowsBashPortabilityViolations( + "test.yml", + lines + ); + + expect(violations).toHaveLength(expectedViolationCount); + }); +}); + describe("Real workflow patterns", () => { describe("should correctly handle actual workflow content", () => { test("correct per-extension loop pattern", () => { diff --git a/scripts/validate-workflows.js b/scripts/validate-workflows.js index c2604514..0a4f3251 100644 --- a/scripts/validate-workflows.js +++ b/scripts/validate-workflows.js @@ -5,6 +5,7 @@ * Validates GitHub Actions workflow files for problematic patterns, specifically: * - Single-line multi-pattern `git add --renormalize` commands (FORBIDDEN) * - `git add --renormalize` commands without existence checks + * - Bash syntax in Windows-targeting jobs without Bash-compatible shell overrides * * @usage * node scripts/validate-workflows.js @@ -416,6 +417,461 @@ function findLockfileInstallViolations(relativePath, lines, packageLockIgnored) return violations; } +function extractJobs(lines) { + const jobs = []; + let inJobsBlock = false; + let jobsIndent = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + const indent = getIndent(line); + + if (!inJobsBlock && /^\s*jobs:\s*$/.test(line)) { + inJobsBlock = true; + jobsIndent = indent; + continue; + } + + if (!inJobsBlock) { + continue; + } + + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue; + } + + if (indent <= jobsIndent) { + break; + } + + const jobHeader = /^\s*([A-Za-z0-9_-]+):\s*$/.exec(line); + if (!jobHeader || indent !== jobsIndent + 2) { + continue; + } + + let endLine = lines.length - 1; + for (let j = i + 1; j < lines.length; j++) { + const nextLine = lines[j]; + const nextTrimmed = nextLine.trim(); + const nextIndent = getIndent(nextLine); + + if (nextTrimmed.length === 0 || nextTrimmed.startsWith("#")) { + continue; + } + + if (nextIndent <= jobsIndent) { + endLine = j - 1; + break; + } + + if ( + nextIndent === jobsIndent + 2 + && /^\s*[A-Za-z0-9_-]+:\s*$/.test(nextLine) + ) { + endLine = j - 1; + break; + } + } + + jobs.push({ + id: jobHeader[1], + startLine: i + 1, + endLine: endLine + 1, + indent, + }); + + i = endLine; + } + + return jobs; +} + +function extractDefaultRunShellFromBlock(lines, startIndex, endIndex, defaultsIndent) { + let runIndent = -1; + + for (let i = startIndex + 1; i <= endIndex && i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + const indent = getIndent(line); + + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue; + } + + if (indent <= defaultsIndent) { + break; + } + + if (runIndent === -1 && /^\s*run:\s*$/.test(line) && indent === defaultsIndent + 2) { + runIndent = indent; + continue; + } + + if (runIndent !== -1) { + if (indent <= runIndent) { + break; + } + + const shellMatch = /^\s*shell:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/.exec(line); + if (shellMatch && indent === runIndent + 2) { + return shellMatch[1].toLowerCase(); + } + } + } + + return null; +} + +function extractWorkflowDefaultsShell(lines) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!/^\s*defaults:\s*$/.test(line) || getIndent(line) !== 0) { + continue; + } + + return extractDefaultRunShellFromBlock(lines, i, lines.length - 1, 0); + } + + return null; +} + +function extractJobDefaultsShell(lines, job) { + const startIndex = job.startLine - 1; + const endIndex = job.endLine - 1; + + for (let i = startIndex + 1; i <= endIndex; i++) { + const line = lines[i]; + const trimmed = line.trim(); + const indent = getIndent(line); + + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue; + } + + if (indent <= job.indent) { + break; + } + + if (!/^\s*defaults:\s*$/.test(line) || indent !== job.indent + 2) { + continue; + } + + return extractDefaultRunShellFromBlock(lines, i, endIndex, indent); + } + + return null; +} + +function jobTargetsWindows(lines, job) { + const startIndex = job.startLine - 1; + const endIndex = job.endLine - 1; + let runsOnValue = null; + + for (let i = startIndex + 1; i <= endIndex; i++) { + const line = lines[i]; + const trimmed = line.trim(); + const indent = getIndent(line); + + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue; + } + + if (indent <= job.indent) { + break; + } + + if (indent !== job.indent + 2) { + continue; + } + + const runsOnMatch = /^\s*runs-on:\s*(.+?)\s*$/.exec(line); + if (!runsOnMatch) { + continue; + } + + runsOnValue = runsOnMatch[1].trim(); + break; + } + + if (!runsOnValue) { + return false; + } + + if (/\bwindows(?:-[a-z0-9]+)?\b/i.test(runsOnValue)) { + return true; + } + + if (!/matrix\./i.test(runsOnValue)) { + return false; + } + + let inMatrixBlock = false; + let matrixIndent = -1; + + for (let i = startIndex + 1; i <= endIndex; i++) { + const line = lines[i]; + const trimmed = line.trim(); + const indent = getIndent(line); + + if (!inMatrixBlock && /^\s*matrix:\s*$/.test(line)) { + inMatrixBlock = true; + matrixIndent = indent; + continue; + } + + if (!inMatrixBlock) { + continue; + } + + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue; + } + + if (indent <= matrixIndent) { + inMatrixBlock = false; + matrixIndent = -1; + continue; + } + + if (/\bwindows(?:-[a-z0-9]+)?\b/i.test(line)) { + return true; + } + } + + return false; +} + +function extractStepRun(lines, stepStartIndex, stepEndIndex) { + for (let i = stepStartIndex; i <= stepEndIndex; i++) { + const line = lines[i]; + const blockRunMatch = /^(\s*)(?:-\s+)?run:\s*[>|][+-]?\s*$/.exec(line); + + if (blockRunMatch) { + const baseIndent = blockRunMatch[1].length; + const blockLines = []; + let j = i + 1; + + while (j <= stepEndIndex) { + const nextLine = lines[j]; + const trimmed = nextLine.trim(); + const nextIndent = getIndent(nextLine); + + if (trimmed.length > 0 && nextIndent <= baseIndent) { + break; + } + + blockLines.push(nextLine.trim()); + j++; + } + + return { + line: i + 1, + text: blockLines.join("\n").trim(), + }; + } + + const inlineRunMatch = /^\s*(?:-\s+)?run:\s*(.+?)\s*$/.exec(line); + if (inlineRunMatch) { + return { + line: i + 1, + text: inlineRunMatch[1].trim(), + }; + } + } + + return null; +} + +function extractStepShell(lines, stepStartIndex, stepEndIndex) { + for (let i = stepStartIndex; i <= stepEndIndex; i++) { + const line = lines[i]; + const shellMatch = /^\s*shell:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/.exec(line); + + if (shellMatch) { + return shellMatch[1].toLowerCase(); + } + } + + return null; +} + +function extractJobSteps(lines, job) { + const steps = []; + const startIndex = job.startLine - 1; + const endIndex = job.endLine - 1; + let stepsStartIndex = -1; + let stepsIndent = -1; + + for (let i = startIndex + 1; i <= endIndex; i++) { + const line = lines[i]; + if (/^\s*steps:\s*$/.test(line) && getIndent(line) === job.indent + 2) { + stepsStartIndex = i; + stepsIndent = getIndent(line); + break; + } + } + + if (stepsStartIndex === -1) { + return steps; + } + + let i = stepsStartIndex + 1; + while (i <= endIndex) { + const line = lines[i]; + const trimmed = line.trim(); + const indent = getIndent(line); + + if (trimmed.length === 0 || trimmed.startsWith("#")) { + i++; + continue; + } + + if (indent <= stepsIndent) { + break; + } + + if (!(indent === stepsIndent + 2 && /^\s*-\s+/.test(line))) { + i++; + continue; + } + + const stepStartIndex = i; + let stepEndIndex = endIndex; + + for (let j = i + 1; j <= endIndex; j++) { + const nextLine = lines[j]; + const nextTrimmed = nextLine.trim(); + const nextIndent = getIndent(nextLine); + + if (nextTrimmed.length === 0 || nextTrimmed.startsWith("#")) { + continue; + } + + if (nextIndent <= stepsIndent) { + stepEndIndex = j - 1; + break; + } + + if (nextIndent === stepsIndent + 2 && /^\s*-\s+/.test(nextLine)) { + stepEndIndex = j - 1; + break; + } + } + + const run = extractStepRun(lines, stepStartIndex, stepEndIndex); + steps.push({ + shell: extractStepShell(lines, stepStartIndex, stepEndIndex), + run, + }); + + i = stepEndIndex + 1; + } + + return steps; +} + +const BASH_SYNTAX_PATTERNS = [ + { + label: "if/elif [ ... ] conditional", + regex: /^(?:if|elif)\s+\[\[?/, + }, + { + label: "for ... in loop", + regex: /^for\s+[A-Za-z_][A-Za-z0-9_]*\s+in\b/, + }, + { + label: "while [ ... ] loop", + regex: /^while\s+\[\[?/, + }, + { + label: "until [ ... ] loop", + regex: /^until\s+\[\[?/, + }, + { + label: "set -e/-o shell option", + regex: /^set\s+-[A-Za-z]/, + }, + { + label: "test -f/-d shell check", + regex: /^test\s+-[A-Za-z]/, + }, + { + label: "logical chaining operator (&&/||)", + regex: /&&|\|\|/, + }, +]; + +function detectBashSyntaxPattern(runText) { + if (typeof runText !== "string" || runText.trim().length === 0) { + return null; + } + + const runLines = runText.split("\n"); + + for (const rawLine of runLines) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith("#")) { + continue; + } + + for (const pattern of BASH_SYNTAX_PATTERNS) { + if (pattern.regex.test(line)) { + return pattern.label; + } + } + } + + return null; +} + +function isBashCompatibleShell(shell) { + return shell === "bash" || shell === "sh"; +} + +function findWindowsBashPortabilityViolations(relativePath, lines) { + const violations = []; + const jobs = extractJobs(lines); + const workflowDefaultsShell = extractWorkflowDefaultsShell(lines); + + for (const job of jobs) { + if (!jobTargetsWindows(lines, job)) { + continue; + } + + const jobDefaultsShell = extractJobDefaultsShell(lines, job); + const steps = extractJobSteps(lines, job); + + for (const step of steps) { + if (!step.run || typeof step.run.text !== "string") { + continue; + } + + const bashPattern = detectBashSyntaxPattern(step.run.text); + if (!bashPattern) { + continue; + } + + const effectiveShell = step.shell || jobDefaultsShell || workflowDefaultsShell; + if (isBashCompatibleShell(effectiveShell)) { + continue; + } + + violations.push( + new Violation( + relativePath, + step.run.line, + bashPattern, + `Windows-targeting workflow job '${job.id}' uses Bash syntax (${bashPattern}) without a Bash-compatible shell. Add 'shell: bash' to the step or set 'defaults.run.shell: bash' at job/workflow scope.`, + "error" + ) + ); + } + } + + return violations; +} + /** * Validates a single workflow file. * @@ -494,6 +950,10 @@ function validateWorkflow(filePath, options = {}) { violations.push( ...findLockfileInstallViolations(relativePath, lines, packageLockIgnored) ); + + violations.push( + ...findWindowsBashPortabilityViolations(relativePath, lines) + ); } catch (error) { violations.push( new Violation( @@ -592,6 +1052,13 @@ if (typeof module !== 'undefined' && module.exports) { findIgnoredPathViolations, extractRunBlocks, findLockfileInstallViolations, + extractJobs, + extractWorkflowDefaultsShell, + extractJobDefaultsShell, + jobTargetsWindows, + extractJobSteps, + detectBashSyntaxPattern, + findWindowsBashPortabilityViolations, validateWorkflow, Violation, }; From 4828bff1e93090a7f68d3c816fcc534b267df7d2 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Thu, 30 Apr 2026 17:25:43 -0700 Subject: [PATCH 11/12] Test fixes, documentation rework --- .cspell.json | 7 +- .github/workflows/changelog-policy-check.yml | 89 ++ .github/workflows/docs-lint.yml | 67 + .../workflows/pre-commit-tooling-check.yml | 3 + .llm/context.md | 13 +- .llm/skills/documentation/ascii-only-docs.md | 172 +++ .../code-samples-must-compile.md | 138 ++ .../external-url-fragment-validation.md | 24 +- .../link-quality-guidelines-part-1.md | 2 +- .../documentation/link-quality-guidelines.md | 2 +- .../markdown-compatibility-part-1.md | 26 +- .../markdown-compatibility-part-2.md | 28 +- .../documentation/markdown-compatibility.md | 14 +- .../documentation/mermaid-theming-part-1.md | 2 +- .llm/skills/documentation/mermaid-theming.md | 12 +- .../skills/documentation/mkdocs-navigation.md | 12 +- .../skills/documentation/skill-file-sizing.md | 40 +- .../git-renormalize-patterns.md | 4 +- .../workflow-consistency-part-1.md | 10 +- .../github-actions/workflow-consistency.md | 4 +- .llm/skills/index.md | 303 ++-- .../npm-package-configuration-part-1.md | 2 +- .../packaging/npm-package-configuration.md | 2 +- .../performance/aggressive-inlining-part-1.md | 10 +- .../performance/aggressive-inlining-part-2.md | 20 +- .../performance/array-pooling-part-1.md | 26 +- .llm/skills/performance/array-pooling.md | 6 +- .../performance/cache-eviction-policies.md | 2 +- .../performance/collection-pooling-part-1.md | 12 +- .../performance/object-pooling-part-1.md | 28 +- .../readonly-struct-cached-hash-part-1.md | 24 +- .../performance/serializable-dictionary.md | 38 +- .../performance/stringbuilder-pooling.md | 34 +- .../yield-instruction-pooling-part-1.md | 28 +- .../scripting/cross-platform-compatibility.md | 4 +- .../powershell-best-practices-part-1.md | 18 +- .../powershell-best-practices-part-2.md | 6 +- .llm/skills/scripting/shell-best-practices.md | 22 +- .../solid/fluent-builder-pattern-part-1.md | 2 +- .../skills/solid/iequatable-implementation.md | 2 +- .llm/skills/specification.md | 176 +-- .llm/skills/templates/skill-template.md | 2 +- .../testing/git-workflow-robustness-part-1.md | 16 +- .../testing/inspector-overlay-invariants.md | 151 ++ .llm/skills/testing/script-test-coverage.md | 10 +- .../testing/test-base-class-cleanup-part-1.md | 24 +- .llm/skills/testing/test-categories.md | 2 +- .markdownlint-cli2.jsonc | 4 +- .pre-commit-config.yaml | 41 +- AGENTS.md | 5 + CHANGELOG.md | 17 +- CLAUDE.md | 5 + CONTRIBUTING.md | 11 +- Editor/Analyzers/BaseCallIlInspector.cs | 8 +- Editor/Analyzers/BaseCallReportAggregator.cs | 6 +- Editor/Analyzers/BaseCallTypeScanner.cs | 16 +- Editor/Analyzers/BaseCallTypeScannerCore.cs | 65 +- .../Analyzers/DxMessagingConsoleHarvester.cs | 24 +- .../WallstopStudios.DxMessaging.Analyzer.dll | Bin 23040 -> 23040 bytes ...opStudios.DxMessaging.SourceGenerators.dll | Bin 34816 -> 34816 bytes .../MessageAwareComponentFallbackEditor.cs | 89 +- .../MessageAwareComponentInspectorOverlay.cs | 5 +- .../CustomEditors/MessagingComponentEditor.cs | 2 +- .../Settings/DxMessagingBaseCallIgnoreSync.cs | 2 +- Editor/Settings/DxMessagingSettings.cs | 10 +- .../Settings/DxMessagingSettingsProvider.cs | 83 +- .../MessagingComponentEditorHarness.cs | 2 +- README.md | 288 ++-- Runtime/Core/DataStructure/CyclicBuffer.cs | 2 +- Runtime/Core/IMessage.cs | 6 +- Runtime/Core/MessageBus/IMessageBus.cs | 6 +- Samples~/DI/README.md | 8 +- Samples~/Mini Combat/README.md | 65 +- Samples~/Mini Combat/Walkthrough.md | 14 +- Samples~/UI Buttons + Inspector/README.md | 37 +- .../Analyzers/IgnoreListReader.cs | 4 +- .../MessageAwareComponentBaseCallAnalyzer.cs | 10 +- .../BaseCallTypeScannerTests.cs | 61 +- .../CompilationMessageHarvestTests.cs | 6 +- .../DocsSnippetCompilationTests.cs | 419 +++++- .../GeneratorTestUtilities.cs | 82 +- ...essageAwareComponentFallbackEditorTests.cs | 312 ++++ ...eAwareComponentFallbackEditorTests.cs.meta | 3 + .../BenchmarkHarnessRobustnessTests.cs | 398 ++++++ .../BenchmarkHarnessRobustnessTests.cs.meta | 11 + Tests/Runtime/Benchmarks/BenchmarkTestBase.cs | 94 +- .../Benchmarks/ComparisonPerformanceTests.cs | 80 +- Tests/Runtime/Benchmarks/PerformanceTests.cs | 126 +- Tests/Runtime/Core/MessagingTestBase.cs | 57 +- ...MessagingTestBaseCleanupRobustnessTests.cs | 246 ++++ ...gingTestBaseCleanupRobustnessTests.cs.meta | 11 + .../Core/TestAttributeContractTests.cs | 140 ++ .../Core/TestAttributeContractTests.cs.meta | 11 + docs/advanced/emit-shorthands.md | 34 +- docs/advanced/message-bus-providers.md | 20 +- docs/advanced/registration-builders.md | 12 +- docs/advanced/runtime-configuration.md | 18 +- docs/advanced/string-messages.md | 6 +- docs/architecture/comparisons.md | 1102 +++++++------- docs/architecture/design-and-architecture.md | 44 +- docs/architecture/performance.md | 22 +- docs/concepts/index.md | 20 +- docs/concepts/interceptors-and-ordering.md | 86 +- docs/concepts/listening-patterns.md | 32 +- docs/concepts/mental-model.md | 32 +- docs/concepts/message-types.md | 44 +- docs/concepts/targeting-and-context.md | 82 +- docs/examples/end-to-end-scene-transitions.md | 10 +- docs/examples/end-to-end.md | 6 +- docs/getting-started/install.md | 2 +- docs/getting-started/visual-guide.md | 22 +- docs/guides/advanced.md | 30 +- docs/guides/diagnostics.md | 30 +- docs/guides/inspector-overlay.md | 286 ++++ docs/guides/inspector-overlay.md.meta | 7 + docs/guides/migration-guide.md | 56 +- docs/guides/patterns.md | 152 +- docs/guides/unity-integration.md | 19 +- docs/images/inspector-overlay.meta | 8 + docs/images/inspector-overlay/README.md | 187 +++ docs/images/inspector-overlay/README.md.meta | 7 + .../inspector-overlay/dxmsg006-overlay.png | Bin 0 -> 18255 bytes .../dxmsg006-overlay.png.meta | 7 + .../inspector-overlay/dxmsg007-overlay.png | Bin 0 -> 17055 bytes .../dxmsg007-overlay.png.meta | 7 + .../inspector-overlay/dxmsg009-overlay.png | Bin 0 -> 17657 bytes .../dxmsg009-overlay.png.meta | 7 + .../inspector-overlay/dxmsg010-overlay.png | Bin 0 -> 17125 bytes .../dxmsg010-overlay.png.meta | 7 + .../inspector-overlay/inspector-actions.png | Bin 0 -> 17679 bytes .../inspector-actions.png.meta | 7 + .../inspector-overlay/inspector-ignored.png | Bin 0 -> 12681 bytes .../inspector-ignored.png.meta | 7 + .../project-settings-panel.png | Bin 0 -> 60794 bytes .../project-settings-panel.png.meta | 7 + .../inspector-overlay/tools-menu-rescan.png | Bin 0 -> 39232 bytes .../tools-menu-rescan.png.meta | 7 + .../worked-example-after.png | Bin 0 -> 7019 bytes .../worked-example-after.png.meta | 7 + .../worked-example-before.png | Bin 0 -> 17285 bytes .../worked-example-before.png.meta | 7 + docs/index.md | 20 +- docs/integrations/index.md | 22 +- docs/integrations/reflex.md | 12 +- docs/integrations/vcontainer.md | 10 +- docs/integrations/zenject.md | 10 +- docs/reference/analyzers.md | 152 +- docs/reference/compatibility.md | 24 +- docs/reference/faq.md | 26 +- docs/reference/glossary.md | 22 +- docs/reference/helpers.md | 112 +- docs/reference/quick-reference.md | 30 +- docs/reference/reference.md | 4 +- docs/reference/troubleshooting.md | 24 +- llms.txt | 8 +- mkdocs.yml | 8 + package.json | 4 +- .../pre-commit-hook-stage-policy.test.js | 17 +- scripts/__tests__/validate-changelog.test.js | 395 ++++++ .../__tests__/validate-changelog.test.js.meta | 7 + .../validate-doc-code-patterns.test.js | 206 +++ .../validate-doc-code-patterns.test.js.meta | 7 + scripts/__tests__/validate-docs-ascii.test.js | 233 +++ .../validate-docs-ascii.test.js.meta | 7 + .../validate-pre-commit-tooling.test.js | 1263 +++++++++-------- scripts/__tests__/validate-workflows.test.js | 79 ++ scripts/generate-skills-index.js | 42 +- scripts/normalize-docs-ascii.js | 575 ++++++++ scripts/normalize-docs-ascii.js.meta | 7 + scripts/update-llms-txt.js | 8 +- scripts/validate-changelog.js | 882 ++++++++++++ scripts/validate-changelog.js.meta | 7 + scripts/validate-doc-code-patterns.js | 338 +++++ scripts/validate-doc-code-patterns.js.meta | 7 + scripts/validate-docs-ascii.js | 377 +++++ scripts/validate-docs-ascii.js.meta | 7 + scripts/validate-pre-commit-tooling.js | 913 ++++++------ scripts/validate-workflows.js | 15 +- 178 files changed, 9640 insertions(+), 3217 deletions(-) create mode 100644 .github/workflows/changelog-policy-check.yml create mode 100644 .github/workflows/docs-lint.yml create mode 100644 .llm/skills/documentation/ascii-only-docs.md create mode 100644 .llm/skills/documentation/code-samples-must-compile.md create mode 100644 .llm/skills/testing/inspector-overlay-invariants.md create mode 100644 Tests/Editor/MessageAwareComponentFallbackEditorTests.cs create mode 100644 Tests/Editor/MessageAwareComponentFallbackEditorTests.cs.meta create mode 100644 Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs create mode 100644 Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs.meta create mode 100644 Tests/Runtime/Core/MessagingTestBaseCleanupRobustnessTests.cs create mode 100644 Tests/Runtime/Core/MessagingTestBaseCleanupRobustnessTests.cs.meta create mode 100644 Tests/Runtime/Core/TestAttributeContractTests.cs create mode 100644 Tests/Runtime/Core/TestAttributeContractTests.cs.meta create mode 100644 docs/guides/inspector-overlay.md create mode 100644 docs/guides/inspector-overlay.md.meta create mode 100644 docs/images/inspector-overlay.meta create mode 100644 docs/images/inspector-overlay/README.md create mode 100644 docs/images/inspector-overlay/README.md.meta create mode 100644 docs/images/inspector-overlay/dxmsg006-overlay.png create mode 100644 docs/images/inspector-overlay/dxmsg006-overlay.png.meta create mode 100644 docs/images/inspector-overlay/dxmsg007-overlay.png create mode 100644 docs/images/inspector-overlay/dxmsg007-overlay.png.meta create mode 100644 docs/images/inspector-overlay/dxmsg009-overlay.png create mode 100644 docs/images/inspector-overlay/dxmsg009-overlay.png.meta create mode 100644 docs/images/inspector-overlay/dxmsg010-overlay.png create mode 100644 docs/images/inspector-overlay/dxmsg010-overlay.png.meta create mode 100644 docs/images/inspector-overlay/inspector-actions.png create mode 100644 docs/images/inspector-overlay/inspector-actions.png.meta create mode 100644 docs/images/inspector-overlay/inspector-ignored.png create mode 100644 docs/images/inspector-overlay/inspector-ignored.png.meta create mode 100644 docs/images/inspector-overlay/project-settings-panel.png create mode 100644 docs/images/inspector-overlay/project-settings-panel.png.meta create mode 100644 docs/images/inspector-overlay/tools-menu-rescan.png create mode 100644 docs/images/inspector-overlay/tools-menu-rescan.png.meta create mode 100644 docs/images/inspector-overlay/worked-example-after.png create mode 100644 docs/images/inspector-overlay/worked-example-after.png.meta create mode 100644 docs/images/inspector-overlay/worked-example-before.png create mode 100644 docs/images/inspector-overlay/worked-example-before.png.meta create mode 100644 scripts/__tests__/validate-changelog.test.js create mode 100644 scripts/__tests__/validate-changelog.test.js.meta create mode 100644 scripts/__tests__/validate-doc-code-patterns.test.js create mode 100644 scripts/__tests__/validate-doc-code-patterns.test.js.meta create mode 100644 scripts/__tests__/validate-docs-ascii.test.js create mode 100644 scripts/__tests__/validate-docs-ascii.test.js.meta create mode 100644 scripts/normalize-docs-ascii.js create mode 100644 scripts/normalize-docs-ascii.js.meta create mode 100644 scripts/validate-changelog.js create mode 100644 scripts/validate-changelog.js.meta create mode 100644 scripts/validate-doc-code-patterns.js create mode 100644 scripts/validate-doc-code-patterns.js.meta create mode 100644 scripts/validate-docs-ascii.js create mode 100644 scripts/validate-docs-ascii.js.meta diff --git a/.cspell.json b/.cspell.json index e664aa14..23ffdf55 100644 --- a/.cspell.json +++ b/.cspell.json @@ -69,6 +69,7 @@ "methodimpl", "iequatable", "IEquatable", + "IMGUI", "inlinable", "inlines", "customisation", @@ -97,6 +98,7 @@ "pygments", "kwds", "arithmatex", + "apos", "linenums", "inlinehilite", "cairosvg", @@ -173,6 +175,7 @@ "Slru", "RAII", "raii", + "rvalue", "Configurator", "Initializable", "Hipple", @@ -204,6 +207,7 @@ "Fira", "APFS", "NTFS", + "ENOENT", "nocasematch", "émoji", "directx", @@ -240,7 +244,8 @@ "unconfigured", "unrecognised", "Unrecognised", - "unmatch" + "unmatch", + "unstubbed" ], "ignoreRegExpList": [ "/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g", diff --git a/.github/workflows/changelog-policy-check.yml b/.github/workflows/changelog-policy-check.yml new file mode 100644 index 00000000..40529e32 --- /dev/null +++ b/.github/workflows/changelog-policy-check.yml @@ -0,0 +1,89 @@ +name: Changelog Policy Check + +on: + pull_request: + paths: + - "CHANGELOG.md" + - "package.json" + - "Runtime/**" + - "Editor/**" + - "!Editor/Analyzers/**" + - "!Editor/Testing/**" + - "SourceGenerators/**" + - "Samples~/**" + - "scripts/validate-changelog.js" + - "scripts/validate-pre-commit-tooling.js" + - "scripts/__tests__/validate-changelog.test.js" + - "scripts/__tests__/validate-pre-commit-tooling.test.js" + - ".pre-commit-config.yaml" + - ".llm/context.md" + - ".llm/skills/documentation/changelog-*.md" + - ".github/workflows/changelog-policy-check.yml" + push: + branches: + - main + - master + paths: + - "CHANGELOG.md" + - "package.json" + - "Runtime/**" + - "Editor/**" + - "!Editor/Analyzers/**" + - "!Editor/Testing/**" + - "SourceGenerators/**" + - "Samples~/**" + - "scripts/validate-changelog.js" + - "scripts/validate-pre-commit-tooling.js" + - "scripts/__tests__/validate-changelog.test.js" + - "scripts/__tests__/validate-pre-commit-tooling.test.js" + - ".pre-commit-config.yaml" + - ".llm/context.md" + - ".llm/skills/documentation/changelog-*.md" + - ".github/workflows/changelog-policy-check.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changelog-policy: + name: Validate changelog policy + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: npm + cache-dependency-path: package.json + + - name: Install dependencies + shell: bash + run: | + set -euo pipefail + if [ -f package-lock.json ]; then + npm ci + else + npm i --no-audit --no-fund + fi + + - name: Run changelog validator tests + run: >- + node scripts/run-managed-jest.js --runTestsByPath + scripts/__tests__/validate-changelog.test.js + + - name: Validate changelog policy with coverage checks + run: node scripts/validate-changelog.js --check-coverage diff --git a/.github/workflows/docs-lint.yml b/.github/workflows/docs-lint.yml new file mode 100644 index 00000000..7604e989 --- /dev/null +++ b/.github/workflows/docs-lint.yml @@ -0,0 +1,67 @@ +name: Docs Lint + +on: + pull_request: + paths: + - "**/*.md" + - "**/*.cs" + - "scripts/validate-docs-ascii.js" + - "scripts/validate-doc-code-patterns.js" + push: + branches: + - main + - master + paths: + - "**/*.md" + - "**/*.cs" + - "scripts/validate-docs-ascii.js" + - "scripts/validate-doc-code-patterns.js" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate-docs-ascii: + name: Validate docs are ASCII-only + 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-ascii + run: node scripts/validate-docs-ascii.js + + validate-doc-code-patterns: + name: Validate doc code samples + 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-doc-code-patterns + run: node scripts/validate-doc-code-patterns.js diff --git a/.github/workflows/pre-commit-tooling-check.yml b/.github/workflows/pre-commit-tooling-check.yml index e9e57ff4..219be04f 100644 --- a/.github/workflows/pre-commit-tooling-check.yml +++ b/.github/workflows/pre-commit-tooling-check.yml @@ -125,6 +125,9 @@ jobs: - name: Validate YAML formatting and lint policy run: pre-commit run yamllint --all-files + - name: Validate changelog policy hook + run: pre-commit run validate-changelog-policy --all-files + - name: Run parser script tests hook run: pre-commit run script-parser-tests --all-files diff --git a/.llm/context.md b/.llm/context.md index ed13bcdf..8be2e4fe 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -42,6 +42,7 @@ This file is intentionally concise. It contains only critical, high-signal guida - Check hook-managed Prettier targets: `npm run check:prettier:hooks` - Validate YAML formatting and lint policy: `npm run check:yaml` - Validate npm package meta integrity: `npm run validate:npm-meta` +- Validate changelog structure plus changed-file coverage: `npm run validate:changelog:coverage` - Check C# method naming (no underscores): `node scripts/fix-csharp-underscore-methods.js --check --all` - Auto-fix C# method naming on selected files: `node scripts/fix-csharp-underscore-methods.js ` - File-scoped spellcheck: `npx --yes cspell@9 --no-progress --no-summary ` @@ -59,6 +60,7 @@ This file is intentionally concise. It contains only critical, high-signal guida - Keep braces explicit. - Avoid regions. - Use PascalCase for all method names with no underscores (including test methods); this is auto-enforced by the `fix-csharp-underscore-methods` pre-commit hook. +- For base-call analyzer suppression parity, method-level `[DxIgnoreMissingBaseCall]` suppresses only the annotated guarded method; class-level attribute or project ignore list suppresses the entire type. - Keep test names descriptive and readable. - Keep public API changes intentional and backward-compatible unless planned otherwise. @@ -70,12 +72,15 @@ This file is intentionally concise. It contains only critical, high-signal guida - Add tests for parser changes and malformed input edge cases. - For path-exclusion logic in script CLIs, apply exclusion patterns only to repository-local paths and add paired tests for outside-repo explicit file args plus repo-internal excluded directories. - 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 `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. +- 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. - For destructive test harness scripts (for example deleting files under `node_modules`), require explicit CLI opt-in flags and validate target paths defensively before mutation. @@ -100,10 +105,14 @@ This file is intentionally concise. It contains only critical, high-signal guida - Update relevant docs after user-visible behavior changes. - Keep examples accurate and aligned with real usage. -- Update `CHANGELOG.md` for user-facing changes. +- Update `CHANGELOG.md` only for user-facing DxMessaging changes, not developer-only tooling/process updates. +- For `## [Unreleased]` entries, mutate existing bullets as behavior evolves; do not stack separate `Added` then `Fixed` bullets for the same unreleased change. +- When likely user-visible files change (`Runtime/`, `SourceGenerators/`, `Samples~/`, and user-facing `Editor/` code), ensure `CHANGELOG.md` is updated in the same change and run `npm run validate:changelog:coverage`. - 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. ## Skills to Prefer @@ -124,5 +133,7 @@ Use the index above and then select the most relevant skill pages. Frequently us - [Skill File Sizing Guidelines](./skills/documentation/skill-file-sizing.md) - [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) - [Cross-Platform Script Compatibility](./skills/scripting/cross-platform-compatibility.md) - [Test Failure Investigation and Zero-Flaky Policy](./skills/testing/test-failure-investigation.md) diff --git a/.llm/skills/documentation/ascii-only-docs.md b/.llm/skills/documentation/ascii-only-docs.md new file mode 100644 index 00000000..9db7e08c --- /dev/null +++ b/.llm/skills/documentation/ascii-only-docs.md @@ -0,0 +1,172 @@ +--- +title: "ASCII-Only Documentation Policy" +id: "ascii-only-docs" +category: "documentation" +version: "1.0.0" +created: "2026-04-30" +updated: "2026-04-30" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "docs/" + - path: "README.md" + - path: "Runtime/" + - path: "Editor/" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "documentation" + - "ascii" + - "linting" + - "policy" + - "tooling" + +complexity: + level: "basic" + reasoning: "Mechanical character-class enforcement with a small allow list" + +impact: + performance: + rating: "none" + details: "Documentation only" + maintainability: + rating: "high" + details: "ASCII-only docs guarantee consistent grep/terminal/rg workflows and reduce LLM tokenization noise" + testability: + rating: "low" + details: "Lint script and pre-commit hook fully cover the policy" + +prerequisites: + - "Awareness of the project's ASCII normalization tooling" + +dependencies: + packages: [] + skills: + - "markdown-compatibility" + - "documentation-style-guide" + +applies_to: + languages: + - "Markdown" + - "C#" + frameworks: + - "MkDocs" + - "GitHub" + +aliases: + - "ASCII docs" + - "Pure ASCII policy" + - "No em-dash policy" + +related: + - "markdown-compatibility" + - "documentation-code-samples" + - "code-samples-must-compile" + +status: "stable" +--- + +# ASCII-Only Documentation Policy + +> **One-line summary**: All `.md` files and `///` XML doc comments must contain pure ASCII characters; real emojis are allowed only in callout positions and capped at 5 per file. + +## Overview + +The DxMessaging documentation surface (Markdown files, XML doc comments inside C# sources, and the generated `llms.txt`) is held to a strict ASCII-only standard. Real Unicode emojis (codepoint range `U+1F300` and above) are permitted only when used inside a markdown blockquote/admonition (a line beginning with `>`), with a soft per-file cap of five. + +## Rationale + +This rule did not start as a stylistic preference. It was adopted after a documentation cleanup pass uncovered roughly 5,350 non-ASCII characters scattered across 33+ files. Those characters - em-dashes, curly quotes, ellipses, bullets, arrows, geometric/dingbat glyphs, no-break spaces, mathematical operators, and box-drawing diagram characters - were: + +- **Breaking grep/terminal/rg workflows.** A user searching for `--` (a real ASCII separator) would not find paragraphs that used the visually identical `-` (em-dash, `U+2014`). +- **Producing inconsistent rendering across viewers.** Curly quotes rendered as boxes in some terminals; box-drawing diagrams collapsed to garbage on platforms with non-standard fonts. +- **Increasing LLM tokenization cost without information gain.** Each non-ASCII glyph cost more tokens than its ASCII equivalent and added entropy that interfered with downstream processing. + +The user explicitly required ASCII-only docs going forward and asked that the rule be enforced mechanically so it is never re-litigated. + +## Allowed Characters + +| Class | Codepoints | Notes | +| ------------------------------ | -------------------------------------- | ----------------------------------------------- | +| Printable ASCII | `U+0020` - `U+007E` | The full standard set | +| ASCII whitespace | `\t \n \r` | Indentation and newlines | +| Variation selectors | `U+FE0E`, `U+FE0F` | Allowed everywhere (ignored by readers) | +| Real emojis in callout context | `U+1F300+` on lines beginning with `>` | Cap of 5 per file (warning only) | +| BOM | `U+FEFF` | Tolerated only as the first character of a file | + +## Banned Characters + +The lint flags any character not in the allow list. The most common offenders are: + +- Em-dash `-` and en-dash `-` (`U+2014`, `U+2013`) +- Curly double quotes `"` `"` (`U+201C`, `U+201D`) +- Curly single quotes `'` `'` (`U+2018`, `U+2019`) +- Ellipsis `...` (`U+2026`) +- Bullet `-` (`U+2022`) +- Arrows `->` `<-` `<->` `=>` `<=` (`U+2192`, `U+2190`, `U+2194`, `U+21D2`, `U+21D0`) +- Geometric/dingbat range `U+2300` - `U+27BF` (`Yes`, `No`, `Warning`, `->`, `-`, etc.) +- Box-drawing characters `+ -- |` (`U+2500` - `U+257F`) +- No-break space (`U+00A0`) +- Mathematical operators (`<=`, `>=`, `!=`, `x`, `+/-`) + +Outside the curated callout-emoji exception, **any** non-ASCII character is a violation. + +## Substitution Table + +When rewriting content, apply these substitutions. The full implementation lives in `scripts/normalize-docs-ascii.js`. + +| Original | Codepoint | Replacement | Notes | +| ------------------ | ------------------- | -------------- | --------------------------------------------- | +| Em-dash | `U+2014` | `--` | Spaces around | +| En-dash | `U+2013` | `-` | Except numeric ranges | +| Curly double quote | `U+201C`,`U+201D` | `"` | Always safe | +| Curly single quote | `U+2018`,`U+2019` | `'` | Always safe | +| Ellipsis | `U+2026` | `...` | Three ASCII dots | +| Bullet | `U+2022` | `-` | Outside fenced blocks only | +| Right arrow | `U+2192`,`U+21D2` | `to` or `->` | Word form in prose; symbol form in menus/code | +| Left arrow | `U+2190`,`U+21D0` | `from` or `<-` | Same context rule | +| Both-ways arrow | `U+2194` | `<->` | Outside fenced blocks | +| Checkmark | `U+2713`,`U+2705` | `Yes` / `[x]` | Tables: words; lists: checkboxes | +| Cross | `U+2717`,`U+274C` | `No` / `[ ]` | Same as above | +| Warning | `U+26A0` | `Warning:` | Used as a callout prefix | +| No-break space | `U+00A0` | regular space | | +| Less-or-equal | `U+2264` | `<=` | | +| Greater-or-equal | `U+2265` | `>=` | | +| Not-equal | `U+2260` | `!=` | | +| Multiplication | `U+00D7` | `x` | | +| Plus-minus | `U+00B1` | `+/-` | | +| Box-drawing | `U+2500` - `U+257F` | manual rewrite | Use ASCII trees (`+ --`) or Mermaid diagrams | + +## Allowed Exceptions + +- **Callout-position emojis.** A real emoji at the start of an admonition line (`> 1F4DD Note`) is allowed. The validator counts these against a soft cap of 5 per file. +- **Skill emoji-shortcode example data.** `.llm/skills/documentation/markdown-compatibility-part-1.md` and `markdown-compatibility-part-2.md` are exempt from emoji and codepoint scanning entirely; they document the project's emoji-shortcode conventions and need to display the source forms. + +## Enforcement + +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. + +## How to Fix Violations + +```bash +# Apply auto-substitutions (idempotent). +node scripts/normalize-docs-ascii.js + +# Confirm clean. +node scripts/validate-docs-ascii.js +``` + +For arrows, review the diff carefully: `to` / `from` reads better in prose than `->` / `<-`, but a menu path like `Tools > Wallstop Studios > DxMessaging` should keep the `>` form. The normalizer already attempts this distinction; hand-tune ambiguous cases. + +For box-drawing characters, the normalizer flags but does not auto-fix. Rewrite as either an ASCII tree or a Mermaid diagram (preferred for non-tree shapes). + +## See Also + +- [Markdown Compatibility Guidelines](./markdown-compatibility.md) +- [Documentation Style Guide](./documentation-style-guide.md) +- [Code Samples Must Compile](./code-samples-must-compile.md) diff --git a/.llm/skills/documentation/code-samples-must-compile.md b/.llm/skills/documentation/code-samples-must-compile.md new file mode 100644 index 00000000..00bf1a49 --- /dev/null +++ b/.llm/skills/documentation/code-samples-must-compile.md @@ -0,0 +1,138 @@ +--- +title: "Code Samples Must Compile" +id: "code-samples-must-compile" +category: "documentation" +version: "1.0.0" +created: "2026-04-30" +updated: "2026-04-30" + +source: + repository: "wallstop/DxMessaging" + files: + - path: "docs/" + - path: "Runtime/" + - path: "Editor/" + - path: "SourceGenerators/" + url: "https://github.com/wallstop/DxMessaging" + +tags: + - "documentation" + - "code-samples" + - "compilation" + - "linting" + - "anti-patterns" + - "tooling" + +complexity: + level: "basic" + reasoning: "Pattern catalog enforced by lint plus Roslyn compilation harness" + +impact: + performance: + rating: "none" + details: "Documentation only" + maintainability: + rating: "high" + details: "Compiling samples eliminate the entire copy/paste-broken-doc support burden" + testability: + rating: "high" + details: "Roslyn-backed test asserts every fenced block, table-cell inline span, and XML doc block compiles" + +prerequisites: + - "Familiarity with C# extension method semantics" + - "Familiarity with the [Dx*Message] / [DxAutoConstructor] API surface" + +dependencies: + packages: [] + skills: + - "documentation-code-samples" + +applies_to: + languages: + - "C#" + - "Markdown" + frameworks: + - "Unity" + - ".NET" + +aliases: + - "Compiling samples" + - "Doc snippet compilation" + +related: + - "documentation-code-samples" + - "documentation-xml-docs" + - "ascii-only-docs" + +status: "stable" +--- + +# Code Samples Must Compile + +> **One-line summary**: Every C# code sample in every doc - inline backticks, fenced blocks, and XML doc `` blocks - must compile. The pattern lint is the canonical defense for the struct-rvalue-Emit bug class (samples like `new X().Emit()` that won't compile); the Roslyn harness provides supplementary semantic checks for the rest. + +## Overview + +DxMessaging documentation is held to a "samples-compile" bar. The bar is enforced both proactively (a pattern lint catches known-broken samples before they merge) and as a compile-time safety net (a Roslyn-backed NUnit test compiles every extracted snippet against a stub harness). + +## Specific Gotcha (the trigger for this skill) + +The `Emit` shorthands are extension methods on **`this ref TMessage`** where `TMessage : struct, I*Message`. A `new X(...)` expression is an rvalue and not addressable, so the form `new X(...).Emit(...)` does not compile. The compiler emits `CS1612` ("cannot modify the return value of ... because it is not a variable") or `CS1510` ("a ref or out value must be an assignable variable") depending on context. + +```csharp +new SceneLoaded(1).Emit(); // Forbidden - does not compile. + +// Correct - assign to a local first. +var msg = new SceneLoaded(1); +msg.Emit(); +``` + +This pattern slipped past the original snippet-compile harness because the offending samples lived in markdown table cells (inline backticks, not fenced blocks). The pattern lint and the table-cell extraction in the Roslyn harness both now cover this surface. + +## Pattern Catalog + +Add new entries to this catalog as new broken-sample classes are discovered. Each entry corresponds to a rule in `scripts/validate-doc-code-patterns.js` (the `BANNED_PATTERNS` array). + +### `struct-emit-temporary` + +- **Regex:** `(?:(?...` 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`). + +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. + +## How to Fix Violations + +1. Run `node scripts/validate-doc-code-patterns.js` locally to see the file:line:column report. +1. For each hit, follow the rule's `fix` suggestion. The `struct-emit-temporary` rule's fix is "assign to local first." +1. Re-run the validator until clean. +1. Run `dotnet test` in `SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/` to confirm the Roslyn harness still passes. + +When changing a snippet that the Roslyn test was previously skipping (via `ShouldSkipSnippet`), prefer making the snippet standalone-compilable over extending the skip heuristic. If the snippet truly is partial (showing only a method body or a usage pattern), document the rationale in the surrounding prose. + +## How to Add a New Pattern + +1. Identify the broken-sample class. Confirm it cannot be caught by the existing Roslyn harness (often because the broken pattern is in a context the harness skips, or its compile error is in `IgnoredSnippetDiagnosticIds`). +1. Add an entry to `BANNED_PATTERNS` in `scripts/validate-doc-code-patterns.js` with a unique `id`, the regex, the `why`, and the `fix`. +1. Run `node scripts/validate-doc-code-patterns.js` to catch any existing instances. +1. Add the pattern to the catalog above. +1. If the pattern's diagnostic ID is reliably caught by Roslyn, consider removing it from `IgnoredSnippetDiagnosticIds` so the harness becomes the canonical enforcement. + +## See Also + +- [Documentation Code Samples](./documentation-code-samples.md) +- [XML Documentation Standards](./documentation-xml-docs.md) +- [ASCII-Only Documentation Policy](./ascii-only-docs.md) diff --git a/.llm/skills/documentation/external-url-fragment-validation.md b/.llm/skills/documentation/external-url-fragment-validation.md index a72ebf08..2cb109c4 100644 --- a/.llm/skills/documentation/external-url-fragment-validation.md +++ b/.llm/skills/documentation/external-url-fragment-validation.md @@ -91,7 +91,7 @@ Fragment links can break silently or cause CI failures: 1. **Navigate to the exact URL**: Open `https://example.com/page#fragment` in a browser 1. **Verify scroll position**: Confirm the page scrolls to the expected section -1. **Inspect the heading ID**: Right-click the heading → Inspect → Check the `id` attribute +1. **Inspect the heading ID**: Right-click the heading -> Inspect -> Check the `id` attribute 1. **Test with link checker**: Run `lychee --include-fragments "URL"` locally ### Fragment ID Discovery @@ -100,14 +100,14 @@ Different sites generate fragment IDs differently: ```bash # GitHub generates IDs from heading text (lowercase, hyphens for spaces) -## Getting Started → #getting-started +## Getting Started -> #getting-started # Some sites use custom IDs -

Getting Started

→ #quick-start +

Getting Started

-> #quick-start # Duplicate headings get suffixes -## Links → #links -## Links → #links-1 (second occurrence) +## Links -> #links +## Links -> #links-1 (second occurrence) ``` ### Best Practices for Fragment URLs @@ -130,13 +130,13 @@ See [here](https://www.markdownguide.org/basic-syntax/). ### Common Fragment Patterns by Site -| Site | ID Generation Pattern | -| --------------- | -------------------------------------------------------- | -| GitHub | Lowercase, spaces → hyphens, special chars removed | -| Unity Docs | Custom IDs, often different from heading text | -| Microsoft Learn | Lowercase, spaces → hyphens, may include section numbers | -| MDN Web Docs | Lowercase, underscores for spaces | -| Stack Overflow | Numeric IDs for answers, text for sections | +| Site | ID Generation Pattern | +| --------------- | --------------------------------------------------------- | +| GitHub | Lowercase, spaces -> hyphens, special chars removed | +| Unity Docs | Custom IDs, often different from heading text | +| Microsoft Learn | Lowercase, spaces -> hyphens, may include section numbers | +| MDN Web Docs | Lowercase, underscores for spaces | +| Stack Overflow | Numeric IDs for answers, text for sections | ### Automated Validation diff --git a/.llm/skills/documentation/link-quality-guidelines-part-1.md b/.llm/skills/documentation/link-quality-guidelines-part-1.md index d0718c27..001d5596 100644 --- a/.llm/skills/documentation/link-quality-guidelines-part-1.md +++ b/.llm/skills/documentation/link-quality-guidelines-part-1.md @@ -130,7 +130,7 @@ https://docs.unity3d.com/2021.3/Documentation/Manual/PageName.html | URL returns 200 | Open in browser, verify no 404/redirect chain | | Content matches expectation | Confirm the page contains the referenced information | | URL is HTTPS | Avoid HTTP links; use HTTPS for security | -| No URL shorteners | Use full URLs, not bit.ly or similar | +| URL shorteners | Use full URLs, not bit.ly or similar | | Versioned when possible | Prefer `/v1.2.3/` over `/latest/` for stability | #### High-Risk External Domains diff --git a/.llm/skills/documentation/link-quality-guidelines.md b/.llm/skills/documentation/link-quality-guidelines.md index c391ce54..6c49d02e 100644 --- a/.llm/skills/documentation/link-quality-guidelines.md +++ b/.llm/skills/documentation/link-quality-guidelines.md @@ -84,7 +84,7 @@ status: "stable" ## Overview -Links in documentation serve two purposes: navigation and context. Poor link quality—whether through cryptic text, incorrect URLs, or broken references—damages user trust and wastes developer time investigating CI failures. +Links in documentation serve two purposes: navigation and context. Poor link quality -- whether through cryptic text, incorrect URLs, or broken references -- damages user trust and wastes developer time investigating CI failures. This skill covers: diff --git a/.llm/skills/documentation/markdown-compatibility-part-1.md b/.llm/skills/documentation/markdown-compatibility-part-1.md index 8f264ed3..4aed1f81 100644 --- a/.llm/skills/documentation/markdown-compatibility-part-1.md +++ b/.llm/skills/documentation/markdown-compatibility-part-1.md @@ -28,7 +28,7 @@ Continuation material extracted from `markdown-compatibility.md` to keep .llm fi Admonitions (callout boxes) are a common MkDocs extension that use `!!!` syntax. -#### ❌ Forbidden: MkDocs Admonitions +#### No Forbidden: MkDocs Admonitions ```markdown !!! note "Important" @@ -47,12 +47,12 @@ This tip syntax is not standard markdown. Admonitions without titles are also forbidden. ``` -#### ✅ Correct: Blockquotes with Emoji +#### Yes Correct: Blockquotes with Emoji ```markdown > ℹ️ **Note**: This is a note that renders everywhere. -> ⚠️ **Caution**: This warning displays correctly on all platforms. +> **Caution**: This warning displays correctly on all platforms. > 🚨 **Critical**: This danger callout works universally. @@ -66,12 +66,12 @@ Admonitions without titles are also forbidden. | Type | Emoji | Example | | -------- | ----- | ------------------------ | | Note | ℹ️ | `> ℹ️ **Note**: ...` | -| Warning | ⚠️ | `> ⚠️ **Warning**: ...` | +| Warning | | `> **Warning**: ...` | | Danger | 🚨 | `> 🚨 **Danger**: ...` | | Tip | 💡 | `> 💡 **Tip**: ...` | | Info | 📝 | `> 📝 **Info**: ...` | -| Success | ✅ | `> ✅ **Success**: ...` | -| Error | ❌ | `> ❌ **Error**: ...` | +| Success | Yes | `> Yes **Success**: ...` | +| Error | No | `> No **Error**: ...` | | Example | 📌 | `> 📌 **Example**: ...` | | See Also | 🔗 | `> 🔗 **See Also**: ...` | @@ -81,7 +81,7 @@ Admonitions without titles are also forbidden. MkDocs supports collapsible sections with `???` syntax. -#### ❌ Forbidden: MkDocs Collapsibles +#### No Forbidden: MkDocs Collapsibles ```markdown ??? note "Click to expand" @@ -92,7 +92,7 @@ It appears as broken syntax elsewhere. The plus makes it expanded initially. ``` -#### ✅ Correct: Use Details/Summary HTML +#### Yes Correct: Use Details/Summary HTML ```markdown
@@ -119,7 +119,7 @@ This content is always visible, which is often clearer for users. MkDocs Material provides tabbed content with `===` syntax. -#### ❌ Forbidden: MkDocs Tabs +#### No Forbidden: MkDocs Tabs ```markdown === "Python" @@ -138,7 +138,7 @@ MkDocs Material provides tabbed content with `===` syntax. ` ``` -#### ✅ Correct: Use Headers +#### Yes Correct: Use Headers ````markdown ### Python @@ -168,7 +168,7 @@ For installation instructions or platform-specific content, headers provide clea MkDocs Material supports styled buttons via attribute syntax. -#### ❌ Forbidden: Button Attributes +#### No Forbidden: Button Attributes ```markdown [Get Started](getting-started.md){ .md-button } @@ -178,7 +178,7 @@ MkDocs Material supports styled buttons via attribute syntax. [:fontawesome-brands-github: View on GitHub](https://github.com/example){ .md-button } ``` -#### ✅ Correct: Standard Links +#### Yes Correct: Standard Links ```markdown [Get Started](getting-started.md) @@ -191,7 +191,7 @@ MkDocs Material supports styled buttons via attribute syntax. If you need visual emphasis, use bold or place links prominently: ```markdown -**[Get Started →](getting-started.md)** +**[Get Started ->](getting-started.md)** ``` --- diff --git a/.llm/skills/documentation/markdown-compatibility-part-2.md b/.llm/skills/documentation/markdown-compatibility-part-2.md index 63b6c3df..cc5c0376 100644 --- a/.llm/skills/documentation/markdown-compatibility-part-2.md +++ b/.llm/skills/documentation/markdown-compatibility-part-2.md @@ -27,13 +27,13 @@ Continuation material extracted from `markdown-compatibility.md` to keep .llm fi | Feature | MkDocs Syntax | Standard Alternative | | ----------- | --------------------------- | ---------------------------------- | | Note | `!!! note` | `> ℹ️ **Note**: ...` | -| Warning | `!!! warning` | `> ⚠️ **Warning**: ...` | +| Warning | `!!! warning` | `> **Warning**: ...` | | Danger | `!!! danger` | `> 🚨 **Danger**: ...` | | Tip | `!!! tip` | `> 💡 **Tip**: ...` | | Collapsible | `??? note` | `
...` | | Tabs | `=== "Tab"` | `### Tab` headers | | Button | `[text](url){ .md-button }` | `[text](url)` or `**[text](url)**` | -| Emoji | `:emoji:` | Unicode emoji: ⚠️ 🚀 ✅ | +| Emoji | `:emoji:` | Unicode emoji: 🚀 Yes | --- @@ -86,7 +86,7 @@ grep -rn --include='*.md' "%%{init.*theme" docs/ MkDocs uses `:emoji_name:` shortcode syntax from the `pymdownx.emoji` extension. -#### ❌ Forbidden: Emoji Shortcodes +#### Forbidden: Emoji Shortcodes ```markdown :warning: This is a warning. @@ -100,7 +100,7 @@ MkDocs uses `:emoji_name:` shortcode syntax from the `pymdownx.emoji` extension. :material-code-braces: Code example. ``` -> ⚠️ **Note**: Material for MkDocs provides icon shortcodes that must also be avoided: +> **Note**: Material for MkDocs provides icon shortcodes that must also be avoided: > > - `:material-*:` patterns (e.g., `:material-code-braces:`, `:material-check:`) > - `:octicons-*:` patterns (e.g., `:octicons-git-branch-16:`) @@ -109,14 +109,14 @@ MkDocs uses `:emoji_name:` shortcode syntax from the `pymdownx.emoji` extension. > > These render as literal text in standard markdown viewers. -#### ✅ Correct: Unicode Emoji Directly +#### Correct: Unicode Emoji Directly ```markdown -⚠️ This is a warning. +This is a warning. 🚀 Fast performance! -✅ Test passed. +Test passed. GitHub integration (use text, not icon). @@ -127,10 +127,10 @@ Code example (describe with words). | Shortcode | Unicode | Copy-paste | | ---------------------- | ------- | ---------- | -| `:warning:` | ⚠️ | ⚠️ | +| `:warning:` | | | | `:rocket:` | 🚀 | 🚀 | -| `:white_check_mark:` | ✅ | ✅ | -| `:x:` | ❌ | ❌ | +| `:white_check_mark:` | Yes | Yes | +| `:x:` | No | No | | `:bulb:` | 💡 | 💡 | | `:information_source:` | ℹ️ | ℹ️ | | `:fire:` | 🔥 | 🔥 | @@ -142,7 +142,7 @@ Code example (describe with words). ### 6. Other MkDocs-Specific Syntax -#### ❌ Forbidden: Annotations +#### Forbidden: Annotations ```markdown Some code (1) @@ -151,13 +151,13 @@ Some code (1) 1. This is an annotation that appears on hover. ``` -#### ❌ Forbidden: Keys Extension +#### Forbidden: Keys Extension ```markdown Press ++ctrl+alt+del++ to restart. ``` -#### ❌ Forbidden: Critic Markup +#### Forbidden: Critic Markup ```markdown {--deleted text--} @@ -165,7 +165,7 @@ Press ++ctrl+alt+del++ to restart. {~~old~>new~~} ``` -#### ✅ Correct: Use Plain Descriptions +#### Correct: Use Plain Descriptions ```markdown Some code diff --git a/.llm/skills/documentation/markdown-compatibility.md b/.llm/skills/documentation/markdown-compatibility.md index 1b8a8813..0a3fb016 100644 --- a/.llm/skills/documentation/markdown-compatibility.md +++ b/.llm/skills/documentation/markdown-compatibility.md @@ -90,7 +90,7 @@ MkDocs and its Material theme provide powerful extensions like admonitions, tabs This will not render correctly on GitHub. ``` -On GitHub, this displays as literal text: `!!! warning "Caution"` followed by an indented paragraph—not as a styled warning box. +On GitHub, this displays as literal text: `!!! warning "Caution"` followed by an indented paragraph -- not as a styled warning box. ## Solution @@ -116,12 +116,12 @@ For deeper nesting, keep increasing: outer uses 5, middle uses 4, inner uses 3. ### Common Mistakes -| Mistake | Problem | Fix | -| ------------------- | ------------------------------------ | ------------------------------ | -| Same backtick count | Inner fence closes outer prematurely | More backticks on outer | -| Spaces in fence | ` ``` ` may not parse | No spaces in backtick sequence | -| Mismatched closing | Opening with 4, closing with 3 | Count must match exactly | -| Missing closing | Following headings render as code | Always close the outer fence | +| Mistake | Problem | Fix | +| ------------------- | ------------------------------------ | ---------------------------- | +| Same backtick count | Inner fence closes outer prematurely | More backticks on outer | +| Spaces in fence | ` ``` ` may not parse | spaces in backtick sequence | +| Mismatched closing | Opening with 4, closing with 3 | Count must match exactly | +| Missing closing | Following headings render as code | Always close the outer fence | ### Guardrail: Keep Real Sections Outside Fenced Examples diff --git a/.llm/skills/documentation/mermaid-theming-part-1.md b/.llm/skills/documentation/mermaid-theming-part-1.md index d5b104a9..669599ee 100644 --- a/.llm/skills/documentation/mermaid-theming-part-1.md +++ b/.llm/skills/documentation/mermaid-theming-part-1.md @@ -122,7 +122,7 @@ The regex uses four flags: `g` (global) replaces all occurrences, `i` (case-inse ### Using Init Directives in docs/ ````markdown - + ```mermaid %%{init: {'theme': 'dark'}}%% diff --git a/.llm/skills/documentation/mermaid-theming.md b/.llm/skills/documentation/mermaid-theming.md index 80ceb513..bc5309ee 100644 --- a/.llm/skills/documentation/mermaid-theming.md +++ b/.llm/skills/documentation/mermaid-theming.md @@ -77,7 +77,7 @@ This project uses Mermaid diagrams for visualizing architecture and message flow ## Critical: Never Hardcode Dark Themes -> **⚠️ NEVER use `%%{init: {'theme': 'dark'}}%%` in ANY markdown file** - not in `docs/`, not in `README.md`, nowhere. +> **NEVER use `%%{init: {'theme': 'dark'}}%%` in ANY markdown file** - not in `docs/`, not in `README.md`, nowhere. ### Why This Matters @@ -93,7 +93,7 @@ GitHub and VS Code now respect `prefers-color-scheme` automatically for Mermaid **Omit init directives entirely.** Let the renderer (GitHub, VS Code, MkDocs) choose the appropriate theme based on user preferences. ````markdown - + ```mermaid flowchart TD @@ -103,7 +103,7 @@ flowchart TD ```` ````markdown - + ```mermaid %%{init: {'theme': 'dark'}}%% @@ -157,7 +157,7 @@ This ensures diagrams always match the user's preferred theme. ## Solution -### ✅ Correct: docs/ Files (MkDocs) +### Correct: docs/ Files (MkDocs) ````markdown ```mermaid @@ -169,7 +169,7 @@ flowchart TD No init directive needed. The global configuration handles theming automatically. -### ✅ Correct: README.md (GitHub/VS Code) +### Correct: README.md (GitHub/VS Code) ````markdown ```mermaid @@ -181,7 +181,7 @@ flowchart TD No init directive needed. GitHub and VS Code automatically detect user theme preferences. -### ❌ Forbidden: Any File with Hardcoded Theme Directive +### Forbidden: Any File with Hardcoded Theme Directive ````markdown ```mermaid diff --git a/.llm/skills/documentation/mkdocs-navigation.md b/.llm/skills/documentation/mkdocs-navigation.md index 11e8c6b5..bf2c81a6 100644 --- a/.llm/skills/documentation/mkdocs-navigation.md +++ b/.llm/skills/documentation/mkdocs-navigation.md @@ -74,7 +74,7 @@ status: "stable" ## Overview -The `mkdocs.yml` file contains a `nav` section that defines the documentation site's navigation structure. When documentation files are added to the `docs/` directory but not added to the `nav` section, they become "orphaned"—the files exist but are not discoverable through the sidebar navigation. +The `mkdocs.yml` file contains a `nav` section that defines the documentation site's navigation structure. When documentation files are added to the `docs/` directory but not added to the `nav` section, they become "orphaned" -- the files exist but are not discoverable through the sidebar navigation. This skill ensures that navigation stays synchronized with the actual documentation files. @@ -115,7 +115,7 @@ nav: When a section has an `index.md` file, list it **without a title** to make the section header itself clickable: ```yaml -# ✅ CORRECT: Section header is clickable, links to index.md +# CORRECT: Section header is clickable, links to index.md nav: - Getting Started: - getting-started/index.md # No title = clickable header @@ -124,7 +124,7 @@ nav: ``` ```yaml -# ❌ WRONG: Section header is not clickable, index appears as separate item +# WRONG: Section header is not clickable, index appears as separate item nav: - Getting Started: - Overview: getting-started/index.md # Title makes it a separate item @@ -210,7 +210,7 @@ done ## Anti-Patterns -### ❌ Adding Files Without Nav Update +### Adding Files Without Nav Update ```bash # Create new documentation @@ -221,7 +221,7 @@ git commit -m "Add new feature docs" # WRONG: mkdocs.yml not updated **Why it's wrong**: The page exists but users cannot navigate to it. -### ❌ Incorrect Index Page Format +### Incorrect Index Page Format ```yaml # WRONG: Index has a title, section header not clickable @@ -233,7 +233,7 @@ nav: **Why it's wrong**: Users must click "Concepts Overview" to see the overview instead of clicking "Concepts" directly. -### ❌ Random Page Order +### Random Page Order ```yaml # WRONG: Advanced topic before basics diff --git a/.llm/skills/documentation/skill-file-sizing.md b/.llm/skills/documentation/skill-file-sizing.md index d03b1ff6..8a3fc3ef 100644 --- a/.llm/skills/documentation/skill-file-sizing.md +++ b/.llm/skills/documentation/skill-file-sizing.md @@ -92,12 +92,12 @@ Conversely, files that are too short may lack: Apply strict line count limits with graduated enforcement: -| Range | Status | Action | -| ------------- | ---------- | ------------------------------------------ | -| < 120 lines | 📝 Short | Consider adding more examples or detail | -| 120-260 lines | ✅ Ideal | Target range for skill files | -| 261-300 lines | ⚠️ Warning | Consider splitting into focused sub-skills | -| > 300 lines | ❌ Error | Must split; blocks CI/pre-commit | +| Range | Status | Action | +| ------------- | ------- | ------------------------------------------ | +| < 120 lines | Short | Consider adding more examples or detail | +| 120-260 lines | Ideal | Target range for skill files | +| 261-300 lines | Warning | Consider splitting into focused sub-skills | +| > 300 lines | Error | Must split; blocks CI/pre-commit | ### Implementation @@ -143,17 +143,17 @@ If `object-pooling.md` exceeds 350 lines: ```text .llm/skills/performance/ -└── object-pooling.md (450 lines) ++-- object-pooling.md (450 lines) ``` **After** (split by variation): ```text .llm/skills/performance/ -├── object-pooling.md (180 lines - core concept) -├── array-pooling.md (200 lines - array-specific) -├── collection-pooling.md (220 lines - collections) -└── stringbuilder-pooling.md (190 lines - StringBuilder) ++-- object-pooling.md (180 lines - core concept) ++-- array-pooling.md (200 lines - array-specific) ++-- collection-pooling.md (220 lines - collections) ++-- stringbuilder-pooling.md (190 lines - StringBuilder) ``` ### Example 2: Organizing Related Skills @@ -170,7 +170,7 @@ Group related skills under a common category with cross-references: ## Anti-Patterns -### ❌ Kitchen Sink Skills +### Kitchen Sink Skills ```markdown # Everything About Performance @@ -181,7 +181,7 @@ memory alignment, SIMD, async patterns, threading... **Why it's wrong**: Covers too many unrelated concepts. Split into focused skills. -### ❌ Overly Terse Skills +### Overly Terse Skills ```markdown # Object Pooling @@ -195,7 +195,7 @@ Done. **Why it's wrong**: Lacks examples, context, anti-patterns, and edge cases. -### ❌ Excessive Code Duplication +### Excessive Code Duplication Including the same example code in multiple variations within one file. Extract to a referenced utility or create separate skill files. @@ -208,8 +208,8 @@ Validation is automated: node scripts/validate-skills.js # Check output for size warnings/errors -# ⚠️ size: File has 380 lines (ideal: 200-350) -# ❌ size: File has 520 lines (max: 500) +# size: File has 380 lines (ideal: 200-350) +# size: File has 520 lines (max: 500) ``` ## When to Split a Skill @@ -226,10 +226,10 @@ Consider splitting when: ```text .llm/skills/{category}/ -├── {main-concept}.md # Core pattern (200-350 lines) -├── {concept}-{variation1}.md # Specific variation -├── {concept}-{variation2}.md # Another variation -└── {concept}-advanced.md # Advanced usage ++-- {main-concept}.md # Core pattern (200-350 lines) ++-- {concept}-{variation1}.md # Specific variation ++-- {concept}-{variation2}.md # Another variation ++-- {concept}-advanced.md # Advanced usage ``` Cross-reference using the `related` frontmatter field and `## See Also` sections. diff --git a/.llm/skills/github-actions/git-renormalize-patterns.md b/.llm/skills/github-actions/git-renormalize-patterns.md index 59885a7e..72b6fa16 100644 --- a/.llm/skills/github-actions/git-renormalize-patterns.md +++ b/.llm/skills/github-actions/git-renormalize-patterns.md @@ -128,7 +128,7 @@ git add --renormalize -- "*.yaml" # FAILS: pathspec did not match any files Use `yml` for non-dotfile YAML files. **Generalized rule**: Exclude any extension when ALL tracked files of that extension are -dotfiles. Verify with: `git ls-files "*.$ext" "**/*.$ext"` — if all results start with `.`, +dotfiles. Verify with: `git ls-files "*.$ext" "**/*.$ext"` -- if all results start with `.`, exclude that extension. ### `file_pattern` in `git-auto-commit-action` @@ -146,7 +146,7 @@ file_pattern: "**/*.md **/*.json **/*.yml" | ---------------------------- | --------- | | All patterns match files | 0 | | Any pattern matches no files | 128 | -| No `.gitattributes` rules | 0 | +| `.gitattributes` rules | 0 | ## Best Practices diff --git a/.llm/skills/github-actions/workflow-consistency-part-1.md b/.llm/skills/github-actions/workflow-consistency-part-1.md index a4e6cbe4..7b990e9c 100644 --- a/.llm/skills/github-actions/workflow-consistency-part-1.md +++ b/.llm/skills/github-actions/workflow-consistency-part-1.md @@ -166,13 +166,13 @@ jobs: | Missing `persist-credentials: false` | Git credentials persist unnecessarily | Add to checkout step | | Missing `timeout-minutes` | Jobs can run indefinitely | Add timeout to every job | | Single quotes for strings | Inconsistent with Prettier | Use double quotes | -| Wrong property order | Hard to review, fails formatting | Use: name → on → concurrency → permissions → jobs | +| Wrong property order | Hard to review, fails formatting | Use: name > on > concurrency > permissions > jobs | ## Validation Checklist Before committing a workflow, verify: -- [ ] Properties ordered: `name` → `on` → `concurrency` → `permissions` → `jobs` +- [ ] Properties ordered: `name` to `on` to `concurrency` to `permissions` to `jobs` - [ ] Concurrency group defined with `cancel-in-progress: true` - [ ] Explicit `permissions` block with minimal required permissions - [ ] Every job has `timeout-minutes` @@ -183,11 +183,11 @@ Before committing a workflow, verify: ## See Also -- [Git Renormalize Pattern Validation](./git-renormalize-patterns.md) — ensuring pathspec patterns +- [Git Renormalize Pattern Validation](./git-renormalize-patterns.md) -- ensuring pathspec patterns match actual repository files to prevent CI failures -- [Cross-Platform Compatibility](../scripting/cross-platform-compatibility.md) — handling platform +- [Cross-Platform Compatibility](../scripting/cross-platform-compatibility.md) -- handling platform differences in CI scripts -- [Shell Best Practices](../scripting/shell-best-practices.md) — patterns for shell commands in +- [Shell Best Practices](../scripting/shell-best-practices.md) -- patterns for shell commands in workflow steps ## Related Links diff --git a/.llm/skills/github-actions/workflow-consistency.md b/.llm/skills/github-actions/workflow-consistency.md index d148b891..89d9ef50 100644 --- a/.llm/skills/github-actions/workflow-consistency.md +++ b/.llm/skills/github-actions/workflow-consistency.md @@ -81,7 +81,7 @@ are consistent, secure, and maintainable. Apply these requirements to every workflow file: -1. Use consistent property ordering: `name` → `on` → `concurrency` → `permissions` → `jobs` +1. Use consistent property ordering: `name` -> `on` -> `concurrency` -> `permissions` -> `jobs` 1. Always include a concurrency group with `cancel-in-progress: true` 1. Declare explicit minimal permissions 1. Set `timeout-minutes` on every job @@ -144,7 +144,7 @@ permissions: pull-requests: write ``` -**Never omit permissions**—implicit permissions are overly broad. +**Never omit permissions** -- implicit permissions are overly broad. ### 3. Job Timeout diff --git a/.llm/skills/index.md b/.llm/skills/index.md index cd0a883e..62769f95 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -1,6 +1,6 @@ # Skills Index -> **Auto-generated** on 2026-03-17. Do not edit manually. +> **Auto-generated** on 2026-04-30. Do not edit manually. > Run `node scripts/generate-skills-index.js` to regenerate. --- @@ -9,189 +9,192 @@ | Metric | Value | | ------------ | ----- | -| Total Skills | 132 | +| Total Skills | 135 | | Categories | 7 | --- ## Table of Contents -- [Documentation](#documentation) (24) +- [Documentation](#documentation) (26) - [GitHub Actions](#github-actions) (5) - [Packaging](#packaging) (2) - [Performance](#performance) (40) - [Scripting](#scripting) (15) - [Solid](#solid) (15) -- [Testing](#testing) (31) +- [Testing](#testing) (32) --- ## Documentation -| Skill | Lines | Complexity | Status | Performance | Tags | -| ----------------------------------------------------------------------------------------------------- | ------ | --------------- | --------- | ----------- | ---------------------------- | -| [Changelog Entry Writing and Anti-Patterns](./documentation/changelog-entry-writing.md) | ⚠️ 277 | 🟢 Basic | ✅ Stable | ○○○○○ | changelog, release-notes | -| [Changelog Entry Writing and Anti-Patterns Part 1](./documentation/changelog-entry-writing-part-1.md) | 📝 56 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Changelog Management](./documentation/changelog-management.md) | ✅ 229 | 🟢 Basic | ✅ Stable | ○○○○○ | changelog, documentation | -| [Changelog Release Workflow](./documentation/changelog-release-workflow.md) | ✅ 250 | 🟢 Basic | ✅ Stable | ○○○○○ | changelog, release-workflow | -| [Documentation Code Samples](./documentation/documentation-code-samples.md) | ✅ 213 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, code-samples | -| [Documentation Code Samples Part 1](./documentation/documentation-code-samples-part-1.md) | 📝 82 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Documentation Style Guide](./documentation/documentation-style-guide.md) | ✅ 204 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, style | -| [Documentation Update Workflow](./documentation/documentation-update-workflow.md) | ✅ 155 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, workflow | -| [Documentation Updates and Maintenance](./documentation/documentation-updates.md) | ✅ 149 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, code-comments | -| [External URL Fragment Validation](./documentation/external-url-fragment-validation.md) | ✅ 182 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, links | -| [GitHub Actions Version Consistency](./documentation/github-actions-version-consistency.md) | ✅ 204 | 🟢 Basic | ✅ Stable | ○○○○○ | github-actions, ci-cd | -| [Link Quality and External URL Management](./documentation/link-quality-guidelines.md) | ✅ 120 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, links | -| [Link Quality and External URL Management Part 1](./documentation/link-quality-guidelines-part-1.md) | ✅ 196 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Link Quality and External URL Management Part 2](./documentation/link-quality-guidelines-part-2.md) | 📝 64 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Markdown Compatibility Guidelines](./documentation/markdown-compatibility.md) | ✅ 136 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, markdown | -| [Markdown Compatibility Guidelines Part 1](./documentation/markdown-compatibility-part-1.md) | ✅ 202 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Markdown Compatibility Guidelines Part 2](./documentation/markdown-compatibility-part-2.md) | ✅ 210 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Mermaid Diagram Theming](./documentation/mermaid-theming.md) | ✅ 199 | 🟡 Intermediate | ✅ Stable | ○○○○○ | documentation, mermaid | -| [Mermaid Diagram Theming Part 1](./documentation/mermaid-theming-part-1.md) | ✅ 160 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [MkDocs Navigation Management](./documentation/mkdocs-navigation.md) | ✅ 252 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, mkdocs | -| [MkDocs Navigation Management Part 1](./documentation/mkdocs-navigation-part-1.md) | 📝 71 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Skill File Sizing Guidelines](./documentation/skill-file-sizing.md) | ✅ 256 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, skills | -| [Skill File Sizing Guidelines Part 1](./documentation/skill-file-sizing-part-1.md) | 📝 34 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [XML Documentation Standards](./documentation/documentation-xml-docs.md) | ✅ 191 | 🟢 Basic | ✅ Stable | ○○○○○ | documentation, xml-docs | +| Skill | Lines | Complexity | Status | Performance | Tags | +| ----------------------------------------------------------------------------------------------------- | ---------- | -------------- | -------- | ------------ | ---------------------------- | +| [ASCII-Only Documentation Policy](./documentation/ascii-only-docs.md) | [ok] 173 | [basic] | [stable] | [risk: none] | documentation, ascii | +| [Changelog Entry Writing and Anti-Patterns](./documentation/changelog-entry-writing.md) | [warn] 277 | [basic] | [stable] | [risk: none] | changelog, release-notes | +| [Changelog Entry Writing and Anti-Patterns Part 1](./documentation/changelog-entry-writing-part-1.md) | [draft] 56 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Changelog Management](./documentation/changelog-management.md) | [ok] 229 | [basic] | [stable] | [risk: none] | changelog, documentation | +| [Changelog Release Workflow](./documentation/changelog-release-workflow.md) | [ok] 250 | [basic] | [stable] | [risk: none] | changelog, release-workflow | +| [Code Samples Must Compile](./documentation/code-samples-must-compile.md) | [ok] 139 | [basic] | [stable] | [risk: none] | documentation, code-samples | +| [Documentation Code Samples](./documentation/documentation-code-samples.md) | [ok] 213 | [basic] | [stable] | [risk: none] | documentation, code-samples | +| [Documentation Code Samples Part 1](./documentation/documentation-code-samples-part-1.md) | [draft] 82 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Documentation Style Guide](./documentation/documentation-style-guide.md) | [ok] 204 | [basic] | [stable] | [risk: none] | documentation, style | +| [Documentation Update Workflow](./documentation/documentation-update-workflow.md) | [ok] 155 | [basic] | [stable] | [risk: none] | documentation, workflow | +| [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 | +| [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 | +| [Markdown Compatibility Guidelines](./documentation/markdown-compatibility.md) | [ok] 136 | [basic] | [stable] | [risk: none] | documentation, markdown | +| [Markdown Compatibility Guidelines Part 1](./documentation/markdown-compatibility-part-1.md) | [ok] 202 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Markdown Compatibility Guidelines Part 2](./documentation/markdown-compatibility-part-2.md) | [ok] 210 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Mermaid Diagram Theming](./documentation/mermaid-theming.md) | [ok] 199 | [intermediate] | [stable] | [risk: none] | documentation, mermaid | +| [Mermaid Diagram Theming Part 1](./documentation/mermaid-theming-part-1.md) | [ok] 160 | [intermediate] | [stable] | [risk: low] | migration, split | +| [MkDocs Navigation Management](./documentation/mkdocs-navigation.md) | [ok] 252 | [basic] | [stable] | [risk: none] | documentation, mkdocs | +| [MkDocs Navigation Management Part 1](./documentation/mkdocs-navigation-part-1.md) | [draft] 71 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Skill File Sizing Guidelines](./documentation/skill-file-sizing.md) | [ok] 256 | [basic] | [stable] | [risk: none] | documentation, skills | +| [Skill File Sizing Guidelines Part 1](./documentation/skill-file-sizing-part-1.md) | [draft] 34 | [intermediate] | [stable] | [risk: low] | migration, split | +| [XML Documentation Standards](./documentation/documentation-xml-docs.md) | [ok] 191 | [basic] | [stable] | [risk: none] | documentation, xml-docs | ## GitHub Actions -| Skill | Lines | Complexity | Status | Performance | Tags | -| ------------------------------------------------------------------------------------------------------ | ------ | --------------- | --------- | ----------- | --------------------- | -| [Git Renormalize Pattern Validation](./github-actions/git-renormalize-patterns.md) | ✅ 232 | 🟡 Intermediate | ✅ Stable | ●○○○○ | github-actions, git | -| [GitHub Actions Workflow Consistency](./github-actions/workflow-consistency.md) | ✅ 183 | 🟡 Intermediate | ✅ Stable | ●●○○○ | github-actions, ci-cd | -| [GitHub Actions Workflow Consistency Part 1](./github-actions/workflow-consistency-part-1.md) | ✅ 196 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Lychee Link Checker Configuration Management](./github-actions/lychee-configuration.md) | ✅ 252 | 🟡 Intermediate | ✅ Stable | ●○○○○ | github-actions, ci-cd | -| [Lychee Link Checker Configuration Management Part 1](./github-actions/lychee-configuration-part-1.md) | 📝 72 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | +| Skill | Lines | Complexity | Status | Performance | Tags | +| ------------------------------------------------------------------------------------------------------ | ---------- | -------------- | -------- | -------------- | --------------------- | +| [Git Renormalize Pattern Validation](./github-actions/git-renormalize-patterns.md) | [ok] 232 | [intermediate] | [stable] | [risk: low] | github-actions, git | +| [GitHub Actions Workflow Consistency](./github-actions/workflow-consistency.md) | [ok] 183 | [intermediate] | [stable] | [risk: medium] | github-actions, ci-cd | +| [GitHub Actions Workflow Consistency Part 1](./github-actions/workflow-consistency-part-1.md) | [ok] 196 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Lychee Link Checker Configuration Management](./github-actions/lychee-configuration.md) | [ok] 252 | [intermediate] | [stable] | [risk: low] | github-actions, ci-cd | +| [Lychee Link Checker Configuration Management Part 1](./github-actions/lychee-configuration-part-1.md) | [draft] 72 | [intermediate] | [stable] | [risk: low] | migration, split | ## Packaging -| Skill | Lines | Complexity | Status | Performance | Tags | -| ----------------------------------------------------------------------------------- | ------ | --------------- | --------- | ----------- | ---------------- | -| [npm Package Configuration](./packaging/npm-package-configuration.md) | ✅ 221 | 🟡 Intermediate | ✅ Stable | ●○○○○ | npm, packaging | -| [npm Package Configuration Part 1](./packaging/npm-package-configuration-part-1.md) | 📝 110 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | +| Skill | Lines | Complexity | Status | Performance | Tags | +| ----------------------------------------------------------------------------------- | ----------- | -------------- | -------- | ----------- | ---------------- | +| [npm Package Configuration](./packaging/npm-package-configuration.md) | [ok] 221 | [intermediate] | [stable] | [risk: low] | npm, packaging | +| [npm Package Configuration Part 1](./packaging/npm-package-configuration-part-1.md) | [draft] 110 | [intermediate] | [stable] | [risk: low] | migration, split | ## Performance -| Skill | Lines | Complexity | Status | Performance | Tags | -| ------------------------------------------------------------------------------------------------------------------ | ------ | --------------- | --------- | ----------- | --------------------- | -| [AggressiveInlining for Hot Path Optimization](./performance/aggressive-inlining.md) | 📝 109 | 🟡 Intermediate | ✅ Stable | ●●○○○ | performance, inlining | -| [AggressiveInlining for Hot Path Optimization Part 1](./performance/aggressive-inlining-part-1.md) | ✅ 171 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [AggressiveInlining for Hot Path Optimization Part 2](./performance/aggressive-inlining-part-2.md) | ✅ 131 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [AggressiveInlining Performance Notes](./performance/aggressive-inlining-performance-notes.md) | 📝 95 | 🟡 Intermediate | ✅ Stable | ●●○○○ | performance, inlining | -| [Array Pooling Usage Examples](./performance/array-pooling-usage-examples.md) | ✅ 146 | 🟡 Intermediate | ✅ Stable | ●●●○○ | memory, allocation | -| [Array Pooling with ArrayPool and Custom Pools](./performance/array-pooling.md) | ✅ 121 | 🟡 Intermediate | ✅ Stable | ●●●●● | memory, allocation | -| [Array Pooling with ArrayPool and Custom Pools Part 1](./performance/array-pooling-part-1.md) | ✅ 205 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Array Pooling with ArrayPool and Custom Pools Part 2](./performance/array-pooling-part-2.md) | 📝 58 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Auto-Load Singleton Attribute](./performance/singleton-autoload.md) | 📝 112 | 🟡 Intermediate | ✅ Stable | ●○○○○ | unity, singleton | -| [Cache Builder Configuration](./performance/cache-eviction-builder.md) | ✅ 204 | 🟡 Intermediate | ✅ Stable | ●●●○○ | caching, builder | -| [Cache Eviction Implementation](./performance/cache-eviction-implementation.md) | 📝 91 | 🟠 Advanced | ✅ Stable | ●●●○○ | caching, eviction | -| [Cache Eviction Implementation Part 1](./performance/cache-eviction-implementation-part-1.md) | ✅ 245 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Cache Eviction Implementation Part 2](./performance/cache-eviction-implementation-part-2.md) | 📝 43 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Collection Pooling with RAII Pattern](./performance/collection-pooling.md) | 📝 119 | 🟡 Intermediate | ✅ Stable | ●●●○○ | memory, allocation | -| [Collection Pooling with RAII Pattern Part 1](./performance/collection-pooling-part-1.md) | ✅ 206 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Collection Pooling with RAII Pattern Part 2](./performance/collection-pooling-part-2.md) | 📝 57 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [High-Performance Cache with Eviction Policies](./performance/cache-eviction-policies.md) | ✅ 177 | 🟠 Advanced | ✅ Stable | ●●●○○ | caching, memory | -| [Object Pooling Anti-Patterns](./performance/object-pooling-anti-patterns.md) | ✅ 145 | 🟡 Intermediate | ✅ Stable | ●●●○○ | memory, allocation | -| [Object Pooling for Zero-Allocation Messaging](./performance/object-pooling.md) | ✅ 124 | 🟡 Intermediate | ✅ Stable | ●●●○○ | memory, allocation | -| [Object Pooling for Zero-Allocation Messaging Part 1](./performance/object-pooling-part-1.md) | ✅ 191 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Object Pooling for Zero-Allocation Messaging Part 2](./performance/object-pooling-part-2.md) | 📝 73 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Object Pooling Usage Examples](./performance/object-pooling-usage-examples.md) | 📝 115 | 🟡 Intermediate | ✅ Stable | ●●●○○ | memory, allocation | -| [Object Pooling Variations](./performance/object-pooling-variations.md) | ✅ 148 | 🟡 Intermediate | ✅ Stable | ●●●○○ | memory, allocation | -| [Readonly Struct Cached Hash Performance Notes](./performance/readonly-struct-cached-hash-performance-notes.md) | 📝 92 | 🟡 Intermediate | ✅ Stable | ●●●○○ | performance, struct | -| [Readonly Struct with Cached Hash for Dictionary Keys](./performance/readonly-struct-cached-hash.md) | ✅ 128 | 🟡 Intermediate | ✅ Stable | ●●●○○ | performance, struct | -| [Readonly Struct with Cached Hash for Dictionary Keys Part 1](./performance/readonly-struct-cached-hash-part-1.md) | ✅ 171 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Readonly Struct with Cached Hash for Dictionary Keys Part 2](./performance/readonly-struct-cached-hash-part-2.md) | 📝 107 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Runtime Singleton Pattern](./performance/singleton-runtime.md) | ✅ 188 | 🟡 Intermediate | ✅ Stable | ●○○○○ | unity, singleton | -| [RuntimeSingleton and ScriptableObject Singleton Patterns](./performance/singleton-patterns.md) | ✅ 166 | 🟡 Intermediate | ✅ Stable | ●●○○○ | unity, singleton | -| [ScriptableObject Singleton Pattern](./performance/singleton-scriptableobject.md) | ✅ 178 | 🟡 Intermediate | ✅ Stable | ●○○○○ | unity, singleton | -| [Serializable Dictionary for Unity Inspector](./performance/serializable-dictionary.md) | ✅ 241 | 🟡 Intermediate | ✅ Stable | ●○○○○ | unity, serialization | -| [Serializable Dictionary for Unity Inspector Part 1](./performance/serializable-dictionary-part-1.md) | 📝 73 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Serializable Dictionary Property Drawer](./performance/serializable-dictionary-property-drawer.md) | ✅ 148 | 🟡 Intermediate | ✅ Stable | ●●○○○ | unity, serialization | -| [Serializable Dictionary Usage Examples](./performance/serializable-dictionary-usage-examples.md) | ✅ 136 | 🟡 Intermediate | ✅ Stable | ●●○○○ | unity, serialization | -| [Singleton Usage Examples](./performance/singleton-usage-examples.md) | ✅ 140 | 🟡 Intermediate | ✅ Stable | ●○○○○ | unity, singleton | -| [StringBuilder Pooling for Zero-Allocation String Building](./performance/stringbuilder-pooling.md) | ✅ 199 | 🟢 Basic | ✅ Stable | ●●●○○ | memory, allocation | -| [StringBuilder Pooling for Zero-Allocation String Building Part 1](./performance/stringbuilder-pooling-part-1.md) | ✅ 153 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [WaitForSeconds and Yield Instruction Pooling](./performance/yield-instruction-pooling.md) | 📝 104 | 🟢 Basic | ✅ Stable | ●●●○○ | performance, unity | -| [WaitForSeconds and Yield Instruction Pooling Part 1](./performance/yield-instruction-pooling-part-1.md) | ✅ 164 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [WaitForSeconds and Yield Instruction Pooling Part 2](./performance/yield-instruction-pooling-part-2.md) | ✅ 148 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | +| Skill | Lines | Complexity | Status | Performance | Tags | +| ------------------------------------------------------------------------------------------------------------------ | ----------- | -------------- | -------- | ---------------- | --------------------- | +| [AggressiveInlining for Hot Path Optimization](./performance/aggressive-inlining.md) | [draft] 109 | [intermediate] | [stable] | [risk: medium] | performance, inlining | +| [AggressiveInlining for Hot Path Optimization Part 1](./performance/aggressive-inlining-part-1.md) | [ok] 171 | [intermediate] | [stable] | [risk: low] | migration, split | +| [AggressiveInlining for Hot Path Optimization Part 2](./performance/aggressive-inlining-part-2.md) | [ok] 131 | [intermediate] | [stable] | [risk: low] | migration, split | +| [AggressiveInlining Performance Notes](./performance/aggressive-inlining-performance-notes.md) | [draft] 95 | [intermediate] | [stable] | [risk: medium] | performance, inlining | +| [Array Pooling Usage Examples](./performance/array-pooling-usage-examples.md) | [ok] 146 | [intermediate] | [stable] | [risk: high] | memory, allocation | +| [Array Pooling with ArrayPool and Custom Pools](./performance/array-pooling.md) | [ok] 121 | [intermediate] | [stable] | [risk: critical] | memory, allocation | +| [Array Pooling with ArrayPool and Custom Pools Part 1](./performance/array-pooling-part-1.md) | [ok] 205 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Array Pooling with ArrayPool and Custom Pools Part 2](./performance/array-pooling-part-2.md) | [draft] 58 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Auto-Load Singleton Attribute](./performance/singleton-autoload.md) | [draft] 112 | [intermediate] | [stable] | [risk: low] | unity, singleton | +| [Cache Builder Configuration](./performance/cache-eviction-builder.md) | [ok] 204 | [intermediate] | [stable] | [risk: high] | caching, builder | +| [Cache Eviction Implementation](./performance/cache-eviction-implementation.md) | [draft] 91 | [advanced] | [stable] | [risk: high] | caching, eviction | +| [Cache Eviction Implementation Part 1](./performance/cache-eviction-implementation-part-1.md) | [ok] 245 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Cache Eviction Implementation Part 2](./performance/cache-eviction-implementation-part-2.md) | [draft] 43 | [intermediate] | [stable] | [risk: low] | migration, split | +| [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 | +| [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 | +| [Object Pooling for Zero-Allocation Messaging Part 1](./performance/object-pooling-part-1.md) | [ok] 191 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Object Pooling for Zero-Allocation Messaging Part 2](./performance/object-pooling-part-2.md) | [draft] 73 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Object Pooling Usage Examples](./performance/object-pooling-usage-examples.md) | [draft] 115 | [intermediate] | [stable] | [risk: high] | memory, allocation | +| [Object Pooling Variations](./performance/object-pooling-variations.md) | [ok] 148 | [intermediate] | [stable] | [risk: high] | memory, allocation | +| [Readonly Struct Cached Hash Performance Notes](./performance/readonly-struct-cached-hash-performance-notes.md) | [draft] 92 | [intermediate] | [stable] | [risk: high] | performance, struct | +| [Readonly Struct with Cached Hash for Dictionary Keys](./performance/readonly-struct-cached-hash.md) | [ok] 128 | [intermediate] | [stable] | [risk: high] | performance, struct | +| [Readonly Struct with Cached Hash for Dictionary Keys Part 1](./performance/readonly-struct-cached-hash-part-1.md) | [ok] 171 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Readonly Struct with Cached Hash for Dictionary Keys Part 2](./performance/readonly-struct-cached-hash-part-2.md) | [draft] 107 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Runtime Singleton Pattern](./performance/singleton-runtime.md) | [ok] 188 | [intermediate] | [stable] | [risk: low] | unity, singleton | +| [RuntimeSingleton and ScriptableObject Singleton Patterns](./performance/singleton-patterns.md) | [ok] 166 | [intermediate] | [stable] | [risk: medium] | unity, singleton | +| [ScriptableObject Singleton Pattern](./performance/singleton-scriptableobject.md) | [ok] 178 | [intermediate] | [stable] | [risk: low] | unity, singleton | +| [Serializable Dictionary for Unity Inspector](./performance/serializable-dictionary.md) | [ok] 241 | [intermediate] | [stable] | [risk: low] | unity, serialization | +| [Serializable Dictionary for Unity Inspector Part 1](./performance/serializable-dictionary-part-1.md) | [draft] 73 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Serializable Dictionary Property Drawer](./performance/serializable-dictionary-property-drawer.md) | [ok] 148 | [intermediate] | [stable] | [risk: medium] | unity, serialization | +| [Serializable Dictionary Usage Examples](./performance/serializable-dictionary-usage-examples.md) | [ok] 136 | [intermediate] | [stable] | [risk: medium] | unity, serialization | +| [Singleton Usage Examples](./performance/singleton-usage-examples.md) | [ok] 140 | [intermediate] | [stable] | [risk: low] | unity, singleton | +| [StringBuilder Pooling for Zero-Allocation String Building](./performance/stringbuilder-pooling.md) | [ok] 199 | [basic] | [stable] | [risk: high] | memory, allocation | +| [StringBuilder Pooling for Zero-Allocation String Building Part 1](./performance/stringbuilder-pooling-part-1.md) | [ok] 153 | [intermediate] | [stable] | [risk: low] | migration, split | +| [WaitForSeconds and Yield Instruction Pooling](./performance/yield-instruction-pooling.md) | [draft] 104 | [basic] | [stable] | [risk: high] | performance, unity | +| [WaitForSeconds and Yield Instruction Pooling Part 1](./performance/yield-instruction-pooling-part-1.md) | [ok] 164 | [intermediate] | [stable] | [risk: low] | migration, split | +| [WaitForSeconds and Yield Instruction Pooling Part 2](./performance/yield-instruction-pooling-part-2.md) | [ok] 148 | [intermediate] | [stable] | [risk: low] | migration, split | ## Scripting -| Skill | Lines | Complexity | Status | Performance | Tags | -| -------------------------------------------------------------------------------------------------------- | ------ | --------------- | --------- | ----------- | -------------------------------- | -| [Cross-Platform Script Compatibility](./scripting/cross-platform-compatibility.md) | ✅ 225 | 🟡 Intermediate | ✅ Stable | ○○○○○ | cross-platform, case-sensitivity | -| [JavaScript Code Quality Practices](./scripting/javascript-code-quality.md) | ✅ 159 | 🟡 Intermediate | ✅ Stable | ○○○○○ | javascript, code-quality | -| [JavaScript Code Quality Practices Part 1](./scripting/javascript-code-quality-part-1.md) | ✅ 178 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [JavaScript Code Quality Practices Part 2](./scripting/javascript-code-quality-part-2.md) | ✅ 142 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [PowerShell Scripting Best Practices](./scripting/powershell-best-practices.md) | 📝 105 | 🟡 Intermediate | ✅ Stable | ○○○○○ | powershell, scripting | -| [PowerShell Scripting Best Practices Part 1](./scripting/powershell-best-practices-part-1.md) | ✅ 204 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [PowerShell Scripting Best Practices Part 2](./scripting/powershell-best-practices-part-2.md) | ✅ 139 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Regex Pattern Documentation](./scripting/regex-documentation.md) | ✅ 150 | 🟡 Intermediate | ✅ Stable | ○○○○○ | regex, documentation | -| [Regex Pattern Documentation Part 1](./scripting/regex-documentation-part-1.md) | ✅ 193 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Regex Pattern Documentation Part 2](./scripting/regex-documentation-part-2.md) | ✅ 180 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Shell Scripting Best Practices](./scripting/shell-best-practices.md) | ✅ 218 | 🟡 Intermediate | ✅ Stable | ○○○○○ | shell, bash | -| [Shell Scripting Best Practices Part 1](./scripting/shell-best-practices-part-1.md) | ✅ 201 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Validation Patterns and Duplicate Warning Prevention](./scripting/validation-patterns.md) | ✅ 240 | 🟡 Intermediate | ✅ Stable | ○○○○○ | validation, javascript | -| [Validation Patterns and Duplicate Warning Prevention Part 1](./scripting/validation-patterns-part-1.md) | ✅ 204 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Validation Patterns and Duplicate Warning Prevention Part 2](./scripting/validation-patterns-part-2.md) | 📝 116 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | +| Skill | Lines | Complexity | Status | Performance | Tags | +| -------------------------------------------------------------------------------------------------------- | ----------- | -------------- | -------- | ------------ | -------------------------------- | +| [Cross-Platform Script Compatibility](./scripting/cross-platform-compatibility.md) | [ok] 225 | [intermediate] | [stable] | [risk: none] | cross-platform, case-sensitivity | +| [JavaScript Code Quality Practices](./scripting/javascript-code-quality.md) | [ok] 159 | [intermediate] | [stable] | [risk: none] | javascript, code-quality | +| [JavaScript Code Quality Practices Part 1](./scripting/javascript-code-quality-part-1.md) | [ok] 178 | [intermediate] | [stable] | [risk: low] | migration, split | +| [JavaScript Code Quality Practices Part 2](./scripting/javascript-code-quality-part-2.md) | [ok] 142 | [intermediate] | [stable] | [risk: low] | migration, split | +| [PowerShell Scripting Best Practices](./scripting/powershell-best-practices.md) | [draft] 105 | [intermediate] | [stable] | [risk: none] | powershell, scripting | +| [PowerShell Scripting Best Practices Part 1](./scripting/powershell-best-practices-part-1.md) | [ok] 204 | [intermediate] | [stable] | [risk: low] | migration, split | +| [PowerShell Scripting Best Practices Part 2](./scripting/powershell-best-practices-part-2.md) | [ok] 139 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Regex Pattern Documentation](./scripting/regex-documentation.md) | [ok] 150 | [intermediate] | [stable] | [risk: none] | regex, documentation | +| [Regex Pattern Documentation Part 1](./scripting/regex-documentation-part-1.md) | [ok] 193 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Regex Pattern Documentation Part 2](./scripting/regex-documentation-part-2.md) | [ok] 180 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Shell Scripting Best Practices](./scripting/shell-best-practices.md) | [ok] 218 | [intermediate] | [stable] | [risk: none] | shell, bash | +| [Shell Scripting Best Practices Part 1](./scripting/shell-best-practices-part-1.md) | [ok] 201 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Validation Patterns and Duplicate Warning Prevention](./scripting/validation-patterns.md) | [ok] 240 | [intermediate] | [stable] | [risk: none] | validation, javascript | +| [Validation Patterns and Duplicate Warning Prevention Part 1](./scripting/validation-patterns-part-1.md) | [ok] 204 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Validation Patterns and Duplicate Warning Prevention Part 2](./scripting/validation-patterns-part-2.md) | [draft] 116 | [intermediate] | [stable] | [risk: low] | migration, split | ## Solid -| Skill | Lines | Complexity | Status | Performance | Tags | -| -------------------------------------------------------------------------------------------------- | ------ | --------------- | --------- | ----------- | ----------------------- | -| [Collection Extension Methods with Performance Documentation](./solid/collection-extensions.md) | ✅ 145 | 🟡 Intermediate | ✅ Stable | ●●●○○ | solid, extensions | -| [Collection Extensions: Accessors](./solid/collection-extensions-accessors.md) | ✅ 230 | 🟡 Intermediate | ✅ Stable | ●●●○○ | collections, extensions | -| [Collection Extensions: Shuffle](./solid/collection-extensions-shuffle.md) | ✅ 145 | 🟢 Basic | ✅ Stable | ●●○○○ | collections, shuffle | -| [Collection Extensions: Type Specialization](./solid/collection-extensions-type-specialization.md) | ✅ 169 | 🟡 Intermediate | ✅ Stable | ●●●○○ | collections, extensions | -| [Fluent Builder Pattern with Struct Builders](./solid/fluent-builder-pattern.md) | 📝 112 | 🟡 Intermediate | ✅ Stable | ●●○○○ | solid, patterns | -| [Fluent Builder Pattern with Struct Builders Part 1](./solid/fluent-builder-pattern-part-1.md) | ✅ 203 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Fluent Builder Pattern with Struct Builders Part 2](./solid/fluent-builder-pattern-part-2.md) | 📝 76 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Fluent Builder Templates and Factories](./solid/fluent-builder-pattern-templates.md) | 📝 118 | 🟡 Intermediate | ✅ Stable | ●●○○○ | solid, patterns | -| [Fluent Builder Usage Examples](./solid/fluent-builder-pattern-usage-examples.md) | ✅ 129 | 🟡 Intermediate | ✅ Stable | ●●○○○ | solid, patterns | -| [IEquatable Implementation for Value Types](./solid/iequatable-implementation.md) | ✅ 240 | 🟡 Intermediate | ✅ Stable | ●●●○○ | solid, performance | -| [IEquatable Implementation Variants](./solid/iequatable-implementation-variants.md) | ✅ 202 | 🟡 Intermediate | ✅ Stable | ●●○○○ | solid, performance | -| [IEquatable Usage Examples](./solid/iequatable-implementation-usage.md) | 📝 112 | 🟡 Intermediate | ✅ Stable | ●●○○○ | solid, performance | -| [Try-Pattern API Usage Examples](./solid/try-pattern-apis-usage.md) | ✅ 137 | 🟢 Basic | ✅ Stable | ●●○○○ | solid, defensive | -| [Try-Pattern API Variants](./solid/try-pattern-apis-variants.md) | ✅ 234 | 🟢 Basic | ✅ Stable | ●●○○○ | solid, defensive | -| [Try-Pattern APIs for Defensive Programming](./solid/try-pattern-apis.md) | ✅ 221 | 🟢 Basic | ✅ Stable | ●●●○○ | solid, defensive | +| Skill | Lines | Complexity | Status | Performance | Tags | +| -------------------------------------------------------------------------------------------------- | ----------- | -------------- | -------- | -------------- | ----------------------- | +| [Collection Extension Methods with Performance Documentation](./solid/collection-extensions.md) | [ok] 145 | [intermediate] | [stable] | [risk: high] | solid, extensions | +| [Collection Extensions: Accessors](./solid/collection-extensions-accessors.md) | [ok] 230 | [intermediate] | [stable] | [risk: high] | collections, extensions | +| [Collection Extensions: Shuffle](./solid/collection-extensions-shuffle.md) | [ok] 145 | [basic] | [stable] | [risk: medium] | collections, shuffle | +| [Collection Extensions: Type Specialization](./solid/collection-extensions-type-specialization.md) | [ok] 169 | [intermediate] | [stable] | [risk: high] | collections, extensions | +| [Fluent Builder Pattern with Struct Builders](./solid/fluent-builder-pattern.md) | [draft] 112 | [intermediate] | [stable] | [risk: medium] | solid, patterns | +| [Fluent Builder Pattern with Struct Builders Part 1](./solid/fluent-builder-pattern-part-1.md) | [ok] 203 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Fluent Builder Pattern with Struct Builders Part 2](./solid/fluent-builder-pattern-part-2.md) | [draft] 76 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Fluent Builder Templates and Factories](./solid/fluent-builder-pattern-templates.md) | [draft] 118 | [intermediate] | [stable] | [risk: medium] | solid, patterns | +| [Fluent Builder Usage Examples](./solid/fluent-builder-pattern-usage-examples.md) | [ok] 129 | [intermediate] | [stable] | [risk: medium] | solid, patterns | +| [IEquatable Implementation for Value Types](./solid/iequatable-implementation.md) | [ok] 240 | [intermediate] | [stable] | [risk: high] | solid, performance | +| [IEquatable Implementation Variants](./solid/iequatable-implementation-variants.md) | [ok] 202 | [intermediate] | [stable] | [risk: medium] | solid, performance | +| [IEquatable Usage Examples](./solid/iequatable-implementation-usage.md) | [draft] 112 | [intermediate] | [stable] | [risk: medium] | solid, performance | +| [Try-Pattern API Usage Examples](./solid/try-pattern-apis-usage.md) | [ok] 137 | [basic] | [stable] | [risk: medium] | solid, defensive | +| [Try-Pattern API Variants](./solid/try-pattern-apis-variants.md) | [ok] 234 | [basic] | [stable] | [risk: medium] | solid, defensive | +| [Try-Pattern APIs for Defensive Programming](./solid/try-pattern-apis.md) | [ok] 221 | [basic] | [stable] | [risk: high] | solid, defensive | ## Testing -| Skill | Lines | Complexity | Status | Performance | Tags | -| ----------------------------------------------------------------------------------------------------- | ------ | --------------- | --------- | ----------- | ---------------------------- | -| [Comprehensive Test Coverage Requirements](./testing/comprehensive-test-coverage.md) | ✅ 142 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, coverage | -| [Data-Driven Coverage Patterns](./testing/test-coverage-data-driven.md) | ✅ 173 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, data-driven | -| [Data-Driven Test Sources](./testing/data-driven-tests-sources.md) | ✅ 256 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, parameterized | -| [Data-Driven Test Usage Patterns](./testing/data-driven-tests-usage.md) | 📝 108 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, parameterized | -| [Data-Driven Tests with TestCaseSource](./testing/data-driven-tests.md) | ✅ 198 | 🟡 Intermediate | ✅ Stable | ●○○○○ | testing, parameterized | -| [Git and Parser Robustness in CI/CD](./testing/git-workflow-robustness.md) | ✅ 214 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, git | -| [Git and Parser Robustness in CI/CD Part 1](./testing/git-workflow-robustness-part-1.md) | ✅ 188 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Script Test Coverage Requirements](./testing/script-test-coverage.md) | ✅ 260 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, scripts | -| [Shared Fixtures: Generic Base](./testing/shared-test-fixtures-generic-base.md) | ✅ 186 | 🟠 Advanced | ✅ Stable | ●●●○○ | testing, fixtures | -| [Shared Fixtures: Reference Counting](./testing/shared-test-fixtures-reference-counting.md) | ✅ 253 | 🟠 Advanced | ✅ Stable | ●●●○○ | testing, fixtures | -| [Shared Test Fixtures with Reference Counting](./testing/shared-test-fixtures.md) | ✅ 166 | 🟠 Advanced | ✅ Stable | ●●●○○ | testing, fixtures | -| [Test Base Class Cleanup Usage](./testing/test-base-class-cleanup-usage.md) | ✅ 219 | 🟡 Intermediate | ✅ Stable | ●○○○○ | testing, cleanup | -| [Test Base Class with Automatic Resource Cleanup](./testing/test-base-class-cleanup.md) | ✅ 125 | 🟡 Intermediate | ✅ Stable | ●○○○○ | testing, cleanup | -| [Test Base Class with Automatic Resource Cleanup Part 1](./testing/test-base-class-cleanup-part-1.md) | ✅ 232 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Test Categories for Selective Execution](./testing/test-categories.md) | ✅ 253 | 🟢 Basic | ✅ Stable | ●●●○○ | testing, organization | -| [Test Categories for Selective Execution Part 1](./testing/test-categories-part-1.md) | 📝 67 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Test Category Execution](./testing/test-categories-execution.md) | ✅ 143 | 🟢 Basic | ✅ Stable | ○○○○○ | testing, organization | -| [Test Code Quality and Accuracy](./testing/test-code-quality.md) | ✅ 244 | 🟡 Intermediate | ✅ Stable | ●●○○○ | testing, documentation | -| [Test Coverage Scenario Categories](./testing/test-coverage-scenario-categories.md) | ✅ 224 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, coverage | -| [Test Diagnostics and Investigation Patterns](./testing/test-diagnostics.md) | ✅ 248 | 🟡 Intermediate | ✅ Stable | ●○○○○ | testing, diagnostics | -| [Test Diagnostics Patterns](./testing/test-diagnostics-patterns.md) | ✅ 190 | 🟡 Intermediate | ✅ Stable | ●○○○○ | testing, diagnostics | -| [Test Diagnostics Usage](./testing/test-diagnostics-usage.md) | ✅ 197 | 🟡 Intermediate | ✅ Stable | ●○○○○ | testing, diagnostics | -| [Test Failure Investigation and Zero-Flaky Policy](./testing/test-failure-investigation.md) | ✅ 120 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, investigation | -| [Test Failure Investigation Procedure](./testing/test-failure-investigation-procedure.md) | ✅ 217 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, investigation | -| [Test Failure Root Causes and Anti-Patterns](./testing/test-failure-investigation-root-causes.md) | ✅ 187 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, root-cause-analysis | -| [Test Invalid Skill](./testing/test-invalid-skill.md) | 📝 31 | 🔴 Expert | ✅ Stable | ●●●○○ | testing, fixtures | -| [Test Organization and Assertions](./testing/test-coverage-organization-assertions.md) | ✅ 174 | 🟢 Basic | ✅ Stable | ○○○○○ | testing, assertions | -| [Test Production Code Directly](./testing/test-production-code.md) | ✅ 146 | 🟡 Intermediate | ✅ Stable | ○○○○○ | testing, anti-patterns | -| [Test Production Code Directly Part 1](./testing/test-production-code-part-1.md) | ✅ 205 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Test Production Code Directly Part 2](./testing/test-production-code-part-2.md) | 📝 66 | 🟡 Intermediate | ✅ Stable | ●○○○○ | migration, split | -| [Unity Test Considerations and Anti-Patterns](./testing/test-coverage-unity-anti-patterns.md) | ⚠️ 270 | 🟢 Basic | ✅ Stable | ○○○○○ | testing, unity | +| Skill | Lines | Complexity | Status | Performance | Tags | +| ----------------------------------------------------------------------------------------------------- | ----------- | -------------- | -------- | -------------- | ---------------------------- | +| [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 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 | +| [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 | +| [Shared Test Fixtures with Reference Counting](./testing/shared-test-fixtures.md) | [ok] 166 | [advanced] | [stable] | [risk: high] | testing, fixtures | +| [Test Base Class Cleanup Usage](./testing/test-base-class-cleanup-usage.md) | [ok] 219 | [intermediate] | [stable] | [risk: low] | testing, cleanup | +| [Test Base Class with Automatic Resource Cleanup](./testing/test-base-class-cleanup.md) | [ok] 125 | [intermediate] | [stable] | [risk: low] | testing, cleanup | +| [Test Base Class with Automatic Resource Cleanup Part 1](./testing/test-base-class-cleanup-part-1.md) | [ok] 232 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Test Categories for Selective Execution](./testing/test-categories.md) | [ok] 253 | [basic] | [stable] | [risk: high] | testing, organization | +| [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 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 | +| [Test Diagnostics Usage](./testing/test-diagnostics-usage.md) | [ok] 197 | [intermediate] | [stable] | [risk: low] | testing, diagnostics | +| [Test Failure Investigation and Zero-Flaky Policy](./testing/test-failure-investigation.md) | [ok] 120 | [intermediate] | [stable] | [risk: none] | testing, investigation | +| [Test Failure Investigation Procedure](./testing/test-failure-investigation-procedure.md) | [ok] 217 | [intermediate] | [stable] | [risk: none] | testing, investigation | +| [Test Failure Root Causes and Anti-Patterns](./testing/test-failure-investigation-root-causes.md) | [ok] 187 | [intermediate] | [stable] | [risk: none] | testing, root-cause-analysis | +| [Test Invalid Skill](./testing/test-invalid-skill.md) | [draft] 31 | [expert] | [stable] | [risk: high] | testing, fixtures | +| [Test Organization and Assertions](./testing/test-coverage-organization-assertions.md) | [ok] 174 | [basic] | [stable] | [risk: none] | testing, assertions | +| [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 | +| [Unity Test Considerations and Anti-Patterns](./testing/test-coverage-unity-anti-patterns.md) | [warn] 270 | [basic] | [stable] | [risk: none] | testing, unity | --- diff --git a/.llm/skills/packaging/npm-package-configuration-part-1.md b/.llm/skills/packaging/npm-package-configuration-part-1.md index 23f14c6f..afb3c818 100644 --- a/.llm/skills/packaging/npm-package-configuration-part-1.md +++ b/.llm/skills/packaging/npm-package-configuration-part-1.md @@ -93,7 +93,7 @@ scripts.meta ### Key principles -1. Use "files" as a pure allowlist—no negated patterns +1. Use "files" as a pure allowlist -- no negated patterns 1. Use specific patterns for complex structures (e.g., `SourceGenerators/Foo/*.cs`) 1. Use `.npmignore` for subdirectory exclusions and defense-in-depth 1. For Unity: include `.meta` files for included items, exclude for excluded items diff --git a/.llm/skills/packaging/npm-package-configuration.md b/.llm/skills/packaging/npm-package-configuration.md index f19f8138..8a498e99 100644 --- a/.llm/skills/packaging/npm-package-configuration.md +++ b/.llm/skills/packaging/npm-package-configuration.md @@ -40,7 +40,7 @@ npm uses two complementary mechanisms: 1. **`.npmignore`**: An **exclusion list** for files within allowed directories ```text -All Repository Files → "files" Allowlist → .npmignore Exclusions → Final Package +All Repository Files -> "files" Allowlist -> .npmignore Exclusions -> Final Package ``` **Key insight:** Items not matched by "files" are already excluded. However, `.npmignore` remains valuable for excluding subdirectories within included paths, handling complex patterns, and providing defense-in-depth. diff --git a/.llm/skills/performance/aggressive-inlining-part-1.md b/.llm/skills/performance/aggressive-inlining-part-1.md index d50f382b..c0104614 100644 --- a/.llm/skills/performance/aggressive-inlining-part-1.md +++ b/.llm/skills/performance/aggressive-inlining-part-1.md @@ -26,11 +26,11 @@ Continuation material extracted from `aggressive-inlining.md` to keep .llm files ```text Without Inlining: With Inlining: -───────────────── ──────────────── -call GetValue() ─┐ sum += _value; - push this │ ~3-5 cycles (directly inline) - push return │ - pop result ─┘ ~1 cycle +----------------- ---------------- +call GetValue() -+ sum += _value; + push this | ~3-5 cycles (directly inline) + push return | + pop result -+ ~1 cycle ``` ### Implementation diff --git a/.llm/skills/performance/aggressive-inlining-part-2.md b/.llm/skills/performance/aggressive-inlining-part-2.md index 5190d972..d3716e3e 100644 --- a/.llm/skills/performance/aggressive-inlining-part-2.md +++ b/.llm/skills/performance/aggressive-inlining-part-2.md @@ -27,26 +27,26 @@ Continuation material extracted from `aggressive-inlining.md` to keep .llm files ### Good Candidates for AggressiveInlining ```csharp -// ✅ Property getters (trivial) +// Yes Property getters (trivial) [MethodImpl(MethodImplOptions.AggressiveInlining)] public int Count => _count; -// ✅ Simple arithmetic/logic +// Yes Simple arithmetic/logic [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int Clamp(int value, int min, int max) { return value < min ? min : (value > max ? max : value); } -// ✅ Hash/equality operations +// Yes Hash/equality operations [MethodImpl(MethodImplOptions.AggressiveInlining)] public override int GetHashCode() => _cachedHash; -// ✅ Type checks +// Yes Type checks [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsValid => _data != null; -// ✅ Forwarding calls +// Yes Forwarding calls [MethodImpl(MethodImplOptions.AggressiveInlining)] public T Get(int index) => _array[index]; ``` @@ -54,7 +54,7 @@ public T Get(int index) => _array[index]; ### Poor Candidates ```csharp -// ❌ Large method bodies +// No Large method bodies [MethodImpl(MethodImplOptions.AggressiveInlining)] public void ProcessData() { @@ -62,11 +62,11 @@ public void ProcessData() // Inlining this everywhere bloats code size } -// ❌ Virtual methods (can't inline) +// No Virtual methods (can't inline) [MethodImpl(MethodImplOptions.AggressiveInlining)] // Ignored public virtual void Update() { } -// ❌ Methods with try-catch +// No Methods with try-catch [MethodImpl(MethodImplOptions.AggressiveInlining)] public void DoSomething() { @@ -74,11 +74,11 @@ public void DoSomething() catch { /* ... */ } } -// ❌ Recursive methods +// No Recursive methods [MethodImpl(MethodImplOptions.AggressiveInlining)] public int Factorial(int n) => n <= 1 ? 1 : n * Factorial(n - 1); -// ❌ Cold paths (rarely called) +// No Cold paths (rarely called) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void HandleError() { /* called 0.01% of time */ } ``` diff --git a/.llm/skills/performance/array-pooling-part-1.md b/.llm/skills/performance/array-pooling-part-1.md index f7a9760e..d1ee2bc5 100644 --- a/.llm/skills/performance/array-pooling-part-1.md +++ b/.llm/skills/performance/array-pooling-part-1.md @@ -25,21 +25,21 @@ Continuation material extracted from `array-pooling.md` to keep .llm files withi ### Core Concept ```text -┌─────────────────────────────────────────────────────────────┐ -│ Array Pool Hierarchy │ -├─────────────────────────────────────────────────────────────┤ -│ WallstopArrayPool │ Exact size, cleared, safe │ -│ WallstopFastArrayPool │ Exact size, not cleared, fast │ -│ SystemArrayPool │ May be larger, wraps Shared │ -└─────────────────────────────────────────────────────────────┘ ++-------------------------------------------------------------+ +| Array Pool Hierarchy | ++-------------------------------------------------------------+ +| WallstopArrayPool | Exact size, cleared, safe | +| WallstopFastArrayPool | Exact size, not cleared, fast | +| SystemArrayPool | May be larger, wraps Shared | ++-------------------------------------------------------------+ Usage: -┌──────────────────────────────────────────────────────────────┐ -│ using PooledArray lease = Pool.Get(size, out T[] arr); │ -│ // arr.Length == size (Wallstop) or >= size (System) │ -│ // Use arr... │ -│ // Dispose returns to pool │ -└──────────────────────────────────────────────────────────────┘ ++--------------------------------------------------------------+ +| using PooledArray lease = Pool.Get(size, out T[] arr); | +| // arr.Length == size (Wallstop) or >= size (System) | +| // Use arr... | +| // Dispose returns to pool | ++--------------------------------------------------------------+ ``` ### Implementation diff --git a/.llm/skills/performance/array-pooling.md b/.llm/skills/performance/array-pooling.md index 007f1dff..8bead08d 100644 --- a/.llm/skills/performance/array-pooling.md +++ b/.llm/skills/performance/array-pooling.md @@ -86,9 +86,9 @@ Three pool types serve different needs: | Pool Type | Exact Size | Clears Data | Best For | | -------------------------- | ---------- | ----------- | ----------------------------- | -| `WallstopArrayPool` | ✅ Yes | ✅ Yes | Security-sensitive, exact-fit | -| `WallstopFastArrayPool` | ✅ Yes | ❌ No | Unmanaged types, max speed | -| `SystemArrayPool` | ❌ No | ❌ No | Variable sizes, standard .NET | +| `WallstopArrayPool` | Yes | Yes | Security-sensitive, exact-fit | +| `WallstopFastArrayPool` | Yes | No | Unmanaged types, max speed | +| `SystemArrayPool` | No | No | Variable sizes, standard .NET | ## Problem Statement diff --git a/.llm/skills/performance/cache-eviction-policies.md b/.llm/skills/performance/cache-eviction-policies.md index 4bd8f808..53dbfc14 100644 --- a/.llm/skills/performance/cache-eviction-policies.md +++ b/.llm/skills/performance/cache-eviction-policies.md @@ -141,7 +141,7 @@ if (!cache.TryGet(playerId, out GameData data)) | LFU | Frequency matters | Evicts least frequently accessed | | SLRU | Hot/cold data | Protects frequently accessed items | | FIFO | Time-based freshness | Evicts oldest regardless of access | -| Random | Simple, uniform | No tracking overhead | +| Random | Simple, uniform | tracking overhead | ## Best Practices diff --git a/.llm/skills/performance/collection-pooling-part-1.md b/.llm/skills/performance/collection-pooling-part-1.md index d6db0bd0..eac366e7 100644 --- a/.llm/skills/performance/collection-pooling-part-1.md +++ b/.llm/skills/performance/collection-pooling-part-1.md @@ -25,12 +25,12 @@ Continuation material extracted from `collection-pooling.md` to keep .llm files ### Core Concept ```text -┌─────────────────────────────────────────────────────────────┐ -│ using (PooledResource> lease = Pool.Get(out list)) │ -│ { │ -│ // Use list... │ -│ } // <-- Dispose() called: list.Clear(), return to pool │ -└─────────────────────────────────────────────────────────────┘ ++-------------------------------------------------------------+ +| using (PooledResource> lease = Pool.Get(out list)) | +| { | +| // Use list... | +| } // <-- Dispose() called: list.Clear(), return to pool | ++-------------------------------------------------------------+ ``` ### Implementation diff --git a/.llm/skills/performance/object-pooling-part-1.md b/.llm/skills/performance/object-pooling-part-1.md index 8d687d01..b75fe5c6 100644 --- a/.llm/skills/performance/object-pooling-part-1.md +++ b/.llm/skills/performance/object-pooling-part-1.md @@ -27,20 +27,20 @@ Continuation material extracted from `object-pooling.md` to keep .llm files with Instead of `new`, acquire objects from a pool. Instead of letting them become garbage, return them to the pool: ```text -┌─────────────────────────────────────────────────────────┐ -│ Object Pool │ -│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ -│ │ Msg │ │ Msg │ │ Msg │ │ Msg │ │ Msg │ ... (idle) │ -│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │ -└────────────────────┬────────────────────────────────────┘ - │ - ┌───────────┴───────────┐ - │ Rent() │ Return() - ▼ │ - ┌─────────┐ │ - │ Active │──────────────────┘ - │ Message │ - └─────────┘ ++---------------------------------------------------------+ +| Object Pool | +| +-----+ +-----+ +-----+ +-----+ +-----+ | +| | Msg | | Msg | | Msg | | Msg | | Msg | ... (idle) | +| +-----+ +-----+ +-----+ +-----+ +-----+ | ++--------------------+------------------------------------+ + | + +-----------+-----------+ + | Rent() | Return() + v | + +---------+ | + | Active |------------------+ + | Message | + +---------+ ``` ### Implementation diff --git a/.llm/skills/performance/readonly-struct-cached-hash-part-1.md b/.llm/skills/performance/readonly-struct-cached-hash-part-1.md index 2ec5a49f..124fe9c5 100644 --- a/.llm/skills/performance/readonly-struct-cached-hash-part-1.md +++ b/.llm/skills/performance/readonly-struct-cached-hash-part-1.md @@ -25,18 +25,18 @@ Continuation material extracted from `readonly-struct-cached-hash.md` to keep .l ### Core Concept ```text -┌──────────────────────────────────────────────────────────────┐ -│ FastVector2Int (readonly struct) │ -├──────────────────────────────────────────────────────────────┤ -│ readonly int x = 5 │ -│ readonly int y = 10 │ -│ readonly int _hash = 0x7A3B2C1D ← Computed ONCE in ctor │ -├──────────────────────────────────────────────────────────────┤ -│ GetHashCode() → return _hash ← O(1), no computation │ -│ Equals(other) → if (_hash != other._hash) return false; │ -│ return x == other.x && y == other.y; │ -│ ↑ Early-out on hash mismatch │ -└──────────────────────────────────────────────────────────────┘ ++--------------------------------------------------------------+ +| FastVector2Int (readonly struct) | ++--------------------------------------------------------------+ +| readonly int x = 5 | +| readonly int y = 10 | +| readonly int _hash = 0x7A3B2C1D <- Computed ONCE in ctor | ++--------------------------------------------------------------+ +| GetHashCode() -> return _hash <- O(1), no computation | +| Equals(other) -> if (_hash != other._hash) return false; | +| return x == other.x && y == other.y; | +| ^ Early-out on hash mismatch | ++--------------------------------------------------------------+ ``` ### Implementation diff --git a/.llm/skills/performance/serializable-dictionary.md b/.llm/skills/performance/serializable-dictionary.md index bf2bf80e..ad298fca 100644 --- a/.llm/skills/performance/serializable-dictionary.md +++ b/.llm/skills/performance/serializable-dictionary.md @@ -94,25 +94,25 @@ private Dictionary itemCounts; // Never serialized! ### Core Concept ```text -┌─────────────────────────────────────────────────────────────┐ -│ SerializableDictionary │ -├─────────────────────────────────────────────────────────────┤ -│ [SerializeField] List keys ← Unity serializes │ -│ [SerializeField] List values ← Unity serializes │ -│ │ -│ Dictionary dictionary ← Runtime access │ -├─────────────────────────────────────────────────────────────┤ -│ OnBeforeSerialize(): │ -│ keys.Clear(); values.Clear(); │ -│ foreach(kvp in dictionary): │ -│ keys.Add(kvp.Key); │ -│ values.Add(kvp.Value); │ -│ │ -│ OnAfterDeserialize(): │ -│ dictionary.Clear(); │ -│ for(i = 0; i < keys.Count; i++): │ -│ dictionary[keys[i]] = values[i]; │ -└─────────────────────────────────────────────────────────────┘ ++-------------------------------------------------------------+ +| SerializableDictionary | ++-------------------------------------------------------------+ +| [SerializeField] List keys <- Unity serializes | +| [SerializeField] List values <- Unity serializes | +| | +| Dictionary dictionary <- Runtime access | ++-------------------------------------------------------------+ +| OnBeforeSerialize(): | +| keys.Clear(); values.Clear(); | +| foreach(kvp in dictionary): | +| keys.Add(kvp.Key); | +| values.Add(kvp.Value); | +| | +| OnAfterDeserialize(): | +| dictionary.Clear(); | +| for(i = 0; i < keys.Count; i++): | +| dictionary[keys[i]] = values[i]; | ++-------------------------------------------------------------+ ``` ### Implementation diff --git a/.llm/skills/performance/stringbuilder-pooling.md b/.llm/skills/performance/stringbuilder-pooling.md index 7fc4af76..337f9bac 100644 --- a/.llm/skills/performance/stringbuilder-pooling.md +++ b/.llm/skills/performance/stringbuilder-pooling.md @@ -28,7 +28,7 @@ complexity: impact: performance: rating: "high" - details: "Eliminates O(n²) string allocations in concatenation loops" + details: "Eliminates O(n^2) string allocations in concatenation loops" maintainability: rating: "high" details: "Cleaner than manual StringBuilder management" @@ -71,12 +71,12 @@ status: "stable" ## Overview -String concatenation with `+` or `+=` creates new string objects for each operation, causing O(n²) allocations for n concatenations. StringBuilder avoids this, but creating a new StringBuilder each time still allocates. Pooling StringBuilders eliminates even that allocation. +String concatenation with `+` or `+=` creates new string objects for each operation, causing O(n^2) allocations for n concatenations. StringBuilder avoids this, but creating a new StringBuilder each time still allocates. Pooling StringBuilders eliminates even that allocation. ## Problem Statement ```csharp -// BAD: O(n²) allocations +// BAD: O(n^2) allocations public string BuildItemList(List items) { string result = ""; @@ -111,20 +111,20 @@ public string BuildItemList(List items) Pool StringBuilders and clear them on return: ```text -┌────────────────────────────────────────┐ -│ StringBuilder Pool │ -│ ┌──────────┐ ┌──────────┐ ┌────────┐ │ -│ │ Cap:256 │ │ Cap:512 │ │Cap:1024│ │ -│ └──────────┘ └──────────┘ └────────┘ │ -└────────────────┬───────────────────────┘ - │ Get(capacity_hint) - ▼ - ┌───────────┐ - │ Use & sb │ sb.Clear() + Return - │ .Append() │────────────────────┐ - └───────────┘ │ - │ sb.ToString() │ - ▼ ▼ ++----------------------------------------+ +| StringBuilder Pool | +| +----------+ +----------+ +--------+ | +| | Cap:256 | | Cap:512 | |Cap:1024| | +| +----------+ +----------+ +--------+ | ++----------------+-----------------------+ + | Get(capacity_hint) + v + +-----------+ + | Use & sb | sb.Clear() + Return + | .Append() |--------------------+ + +-----------+ | + | sb.ToString() | + v v "result" Pool ``` diff --git a/.llm/skills/performance/yield-instruction-pooling-part-1.md b/.llm/skills/performance/yield-instruction-pooling-part-1.md index e1bfafc0..59af630a 100644 --- a/.llm/skills/performance/yield-instruction-pooling-part-1.md +++ b/.llm/skills/performance/yield-instruction-pooling-part-1.md @@ -25,20 +25,20 @@ Continuation material extracted from `yield-instruction-pooling.md` to keep .llm ### Core Concept ```text -┌─────────────────────────────────────────────────────────────────┐ -│ WaitForSeconds Cache │ -├─────────────────────────────────────────────────────────────────┤ -│ Key (quantized seconds) │ Value (WaitForSeconds instance) │ -│ ─────────────────────────────────────────────────────────── │ -│ 0.0f │ WaitForSeconds(0.0f) │ -│ 0.1f │ WaitForSeconds(0.1f) │ -│ 0.5f │ WaitForSeconds(0.5f) │ -│ 1.0f │ WaitForSeconds(1.0f) │ -│ 2.0f │ WaitForSeconds(2.0f) │ -│ ... │ ... │ -└─────────────────────────────────────────────────────────────────┘ - -GetWaitForSeconds(1.5f) → Quantize to 1.5f → Return cached instance ++-----------------------------------------------------------------+ +| WaitForSeconds Cache | ++-----------------------------------------------------------------+ +| Key (quantized seconds) | Value (WaitForSeconds instance) | +| ----------------------- | ----------------------------------- | +| 0.0f | WaitForSeconds(0.0f) | +| 0.1f | WaitForSeconds(0.1f) | +| 0.5f | WaitForSeconds(0.5f) | +| 1.0f | WaitForSeconds(1.0f) | +| 2.0f | WaitForSeconds(2.0f) | +| ... | ... | ++-----------------------------------------------------------------+ + +GetWaitForSeconds(1.5f) -> Quantize to 1.5f -> Return cached instance ``` ### Implementation diff --git a/.llm/skills/scripting/cross-platform-compatibility.md b/.llm/skills/scripting/cross-platform-compatibility.md index 7f68d61f..884e48d7 100644 --- a/.llm/skills/scripting/cross-platform-compatibility.md +++ b/.llm/skills/scripting/cross-platform-compatibility.md @@ -83,7 +83,7 @@ status: "stable" ## Overview Scripts that work locally on Windows or macOS often fail in CI/CD environments running Linux. -The most common cause is **filename case sensitivity**—Linux filesystems are case-sensitive while +The most common cause is **filename case sensitivity** -- Linux filesystems are case-sensitive while Windows and macOS are case-insensitive by default. This skill documents patterns to prevent these issues and ensure all scripts have proper test coverage. @@ -174,7 +174,7 @@ done | Platform | Filesystem | Case-Sensitive | CI Environment | | -------------- | ---------- | -------------- | -------------- | | Linux | ext4, XFS | Yes | GitHub Actions | -| macOS | APFS | No (default) | Local dev | +| macOS | APFS | (default) | Local dev | | Windows | NTFS | No | Local dev | | WSL | ext4 | Yes | Local dev | | Docker (Linux) | ext4 | Yes | Local CI | diff --git a/.llm/skills/scripting/powershell-best-practices-part-1.md b/.llm/skills/scripting/powershell-best-practices-part-1.md index 7534f455..1cd1edcc 100644 --- a/.llm/skills/scripting/powershell-best-practices-part-1.md +++ b/.llm/skills/scripting/powershell-best-practices-part-1.md @@ -80,9 +80,9 @@ $pattern = '' # Correct: use non-greedy for comments This distinction applies when: -1. **Matching XML/SVG/HTML tag attributes**: `[^>]*` is safe—closing `>` is outside quotes +1. **Matching XML/SVG/HTML tag attributes**: `[^>]*` is safe -- closing `>` is outside quotes 1. **The file is controlled/validated**: Project-maintained assets with known format -1. **Matching comments or CDATA**: Use `.*?` instead—these sections allow `>` +1. **Matching comments or CDATA**: Use `.*?` instead -- these sections allow `>` ### Structural Completeness in XML/SVG Replacements @@ -98,14 +98,14 @@ $pattern = '.*?' $replacement = 'New Content' # Input: Old -# Result: New Content ← Works by accident! +# Result: New Content <- Works by accident! # But what if there's whitespace or nested elements? # Input: # Old # # Result: New Content -# ← Broken indentation, fragile! +# <- Broken indentation, fragile! ``` The pattern relies on `` being immediately after ``, which is an undocumented assumption. @@ -176,7 +176,7 @@ $json = @" ""name"": ""value"" } "@ -# Output: {"name": "value"} ← Wrong! +# Output: {"name": "value"} <- Wrong! # CORRECT: Use single quotes naturally $json = @" @@ -184,19 +184,19 @@ $json = @" "name": "value" } "@ -# Output: {"name": "value"} ← Correct! +# Output: {"name": "value"} <- Correct! ``` ### Single-Quote Here-Strings (@'...'@) -In `@'...'@` here-strings, no escaping is possible—everything is literal. +In `@'...'@` here-strings, no escaping is possible -- everything is literal. ### Here-String Syntax Rules | Type | Variable Expansion | Quote Escaping Required | Use Case | | --------- | ------------------ | ----------------------- | ------------------- | -| `@"..."@` | Yes | No (use single `"`) | Dynamic content | -| `@'...'@` | No | No escaping possible | Literal/static text | +| `@"..."@` | Yes | (use single `"`) | Dynamic content | +| `@'...'@` | No | escaping possible | Literal/static text | ## See Also diff --git a/.llm/skills/scripting/powershell-best-practices-part-2.md b/.llm/skills/scripting/powershell-best-practices-part-2.md index f493ee21..ef06b54b 100644 --- a/.llm/skills/scripting/powershell-best-practices-part-2.md +++ b/.llm/skills/scripting/powershell-best-practices-part-2.md @@ -53,7 +53,7 @@ pre-commit hooks. | "runs before each commit is created" | Executes prior to commit creation | pre-commit hooks | | "runs as a pre-commit hook" | Explicitly names the hook type | pre-commit hooks | | "runs after each commit" | Executes after commit is finalized | post-commit hooks | -| "runs on every commit" | Ambiguous—avoid this phrasing | Neither | +| "runs on every commit" | Ambiguous -- avoid this phrasing | Neither | | "runs when commits are pushed" | Executes during push operation | pre-push hooks | ## .NET File Encoding Behaviors @@ -77,10 +77,10 @@ $utf8NoBom = New-Object System.Text.UTF8Encoding($false) | Method | Default Encoding | BOM | | ---------------------------------------- | ---------------- | ------- | -| `[System.IO.File]::WriteAllText()` | UTF-8 | No BOM | +| `[System.IO.File]::WriteAllText()` | UTF-8 | BOM | | `Set-Content` (PS 5.1) | System default | Varies | | `Set-Content -Encoding UTF8` (PS 5.1) | UTF-8 | Has BOM | -| `Set-Content -Encoding utf8NoBOM` (PS 7) | UTF-8 | No BOM | +| `Set-Content -Encoding utf8NoBOM` (PS 7) | UTF-8 | BOM | | `Out-File` (PS 5.1) | UTF-16 LE | Has BOM | ### Anti-Pattern: "Fixing" Correct Code diff --git a/.llm/skills/scripting/shell-best-practices.md b/.llm/skills/scripting/shell-best-practices.md index e9d16903..a24ed4e8 100644 --- a/.llm/skills/scripting/shell-best-practices.md +++ b/.llm/skills/scripting/shell-best-practices.md @@ -125,11 +125,11 @@ npm install ### Error Handling Patterns -- **`cmd || true`** — Silently ignore failure. Use for truly optional operations. -- **`cmd || echo "..."`** — Log failure but continue. Use for optional with diagnostic output. -- **`cmd || exit 1`** — Explicit fatal (redundant with `-e`). Use for self-documenting intent. -- **`if cmd; then ... fi`** — Conditional execution. Use for different paths on success/fail. -- **`cmd || { ...; }`** — Multi-statement error handling. Use for complex recovery logic. +- **`cmd || true`** -- Silently ignore failure. Use for truly optional operations. +- **`cmd || echo "..."`** -- Log failure but continue. Use for optional with diagnostic output. +- **`cmd || exit 1`** -- Explicit fatal (redundant with `-e`). Use for self-documenting intent. +- **`if cmd; then ... fi`** -- Conditional execution. Use for different paths on success/fail. +- **`cmd || { ...; }`** -- Multi-statement error handling. Use for complex recovery logic. ### Validation Checklist for `set -e` Scripts @@ -144,12 +144,12 @@ Before merging shell scripts that use `set -e`: These commands may fail in ways that aren't obvious: -- **`grep`** — Fails with exit code 1 when no matches found. Use `grep ... || true` or `|| echo 0`. -- **`diff`** — Fails with exit code 1 when files differ. Use `diff ... || true` for comparison. -- **`git diff`** — Sometimes exits 1 when no changes. Check explicitly if needed. -- **`rm file`** — Fails if file doesn't exist. Use `rm -f file` or `rm file || true`. -- **`cd dir`** — Fails if directory doesn't exist. Check first or use `|| exit 1`. -- **`read var`** — Fails with exit code 1 at EOF. Handle in loop condition. +- **`grep`** -- Fails with exit code 1 when no matches found. Use `grep ... || true` or `|| echo 0`. +- **`diff`** -- Fails with exit code 1 when files differ. Use `diff ... || true` for comparison. +- **`git diff`** -- Sometimes exits 1 when no changes. Check explicitly if needed. +- **`rm file`** -- Fails if file doesn't exist. Use `rm -f file` or `rm file || true`. +- **`cd dir`** -- Fails if directory doesn't exist. Check first or use `|| exit 1`. +- **`read var`** -- Fails with exit code 1 at EOF. Handle in loop condition. ## Case-Sensitive File Paths diff --git a/.llm/skills/solid/fluent-builder-pattern-part-1.md b/.llm/skills/solid/fluent-builder-pattern-part-1.md index a6751bc0..e2502fc1 100644 --- a/.llm/skills/solid/fluent-builder-pattern-part-1.md +++ b/.llm/skills/solid/fluent-builder-pattern-part-1.md @@ -134,7 +134,7 @@ namespace WallstopStudios.UnityHelpers.Core.Cache /// /// Adds random jitter to expiration times to prevent thundering herd. /// - /// Jitter factor (0.0-1.0). E.g., 0.1 = ±10% variation. + /// Jitter factor (0.0-1.0). E.g., 0.1 = +/-10% variation. public CacheBuilder WithExpirationJitter(float factor) { if (factor < 0f || factor > 1f) diff --git a/.llm/skills/solid/iequatable-implementation.md b/.llm/skills/solid/iequatable-implementation.md index 1688d6d7..f7945ecb 100644 --- a/.llm/skills/solid/iequatable-implementation.md +++ b/.llm/skills/solid/iequatable-implementation.md @@ -200,7 +200,7 @@ namespace WallstopStudios.UnityHelpers.Core.DataStructure | Without IEquatable | With IEquatable | | ------------------ | --------------- | | 4MB allocations | 0 allocations | -| GC pauses | No GC pressure | +| GC pauses | GC pressure | ## Best Practices diff --git a/.llm/skills/specification.md b/.llm/skills/specification.md index 1df81205..16caa4b5 100644 --- a/.llm/skills/specification.md +++ b/.llm/skills/specification.md @@ -8,66 +8,66 @@ This document defines the structure, schema, and tooling for storing code patter ```text .llm/ -├── skills/ -│ ├── specification.md # This file - the spec -│ ├── index.md # Auto-generated index of all skills -│ ├── templates/ # Skill templates -│ │ └── skill-template.md -│ │ -│ ├── performance/ # Performance optimization patterns -│ │ ├── object-pooling.md -│ │ ├── cache-strategies.md -│ │ └── allocation-reduction.md -│ │ -│ ├── testing/ # Testing patterns and practices -│ │ ├── unity-test-patterns.md -│ │ ├── mock-strategies.md -│ │ └── assertion-patterns.md -│ │ -│ ├── solid/ # SOLID principles implementations -│ │ ├── dependency-injection.md -│ │ ├── interface-segregation.md -│ │ └── single-responsibility.md -│ │ -│ ├── messaging/ # Messaging and event patterns -│ │ ├── pub-sub-patterns.md -│ │ ├── message-routing.md -│ │ └── broadcast-strategies.md -│ │ -│ ├── unity/ # Unity-specific patterns -│ │ ├── lifecycle-management.md -│ │ ├── component-patterns.md -│ │ └── editor-extensions.md -│ │ -│ ├── concurrency/ # Threading and async patterns -│ │ ├── thread-safety.md -│ │ ├── lock-free-patterns.md -│ │ └── async-patterns.md -│ │ -│ ├── architecture/ # Architectural patterns -│ │ ├── service-locator.md -│ │ ├── factory-patterns.md -│ │ └── repository-pattern.md -│ │ -│ ├── error-handling/ # Error handling strategies -│ │ ├── exception-patterns.md -│ │ ├── result-types.md -│ │ └── defensive-coding.md -│ │ -│ ├── code-generation/ # Source generation patterns -│ │ ├── roslyn-analyzers.md -│ │ ├── source-generators.md -│ │ └── emit-patterns.md -│ │ -│ ├── scripting/ # Shell and script patterns -│ │ ├── powershell-best-practices.md -│ │ └── shell-patterns.md -│ │ -│ ├── github-actions/ # GitHub Actions workflow patterns -│ │ └── workflow-consistency.md -│ │ -│ └── documentation/ # Documentation and code comments -│ └── documentation-updates.md ++-- skills/ +| +-- specification.md # This file - the spec +| +-- index.md # Auto-generated index of all skills +| +-- templates/ # Skill templates +| | +-- skill-template.md +| | +| +-- performance/ # Performance optimization patterns +| | +-- object-pooling.md +| | +-- cache-strategies.md +| | +-- allocation-reduction.md +| | +| +-- testing/ # Testing patterns and practices +| | +-- unity-test-patterns.md +| | +-- mock-strategies.md +| | +-- assertion-patterns.md +| | +| +-- solid/ # SOLID principles implementations +| | +-- dependency-injection.md +| | +-- interface-segregation.md +| | +-- single-responsibility.md +| | +| +-- messaging/ # Messaging and event patterns +| | +-- pub-sub-patterns.md +| | +-- message-routing.md +| | +-- broadcast-strategies.md +| | +| +-- unity/ # Unity-specific patterns +| | +-- lifecycle-management.md +| | +-- component-patterns.md +| | +-- editor-extensions.md +| | +| +-- concurrency/ # Threading and async patterns +| | +-- thread-safety.md +| | +-- lock-free-patterns.md +| | +-- async-patterns.md +| | +| +-- architecture/ # Architectural patterns +| | +-- service-locator.md +| | +-- factory-patterns.md +| | +-- repository-pattern.md +| | +| +-- error-handling/ # Error handling strategies +| | +-- exception-patterns.md +| | +-- result-types.md +| | +-- defensive-coding.md +| | +| +-- code-generation/ # Source generation patterns +| | +-- roslyn-analyzers.md +| | +-- source-generators.md +| | +-- emit-patterns.md +| | +| +-- scripting/ # Shell and script patterns +| | +-- powershell-best-practices.md +| | +-- shell-patterns.md +| | +| +-- github-actions/ # GitHub Actions workflow patterns +| | +-- workflow-consistency.md +| | +| +-- documentation/ # Documentation and code comments +| +-- documentation-updates.md ``` --- @@ -158,34 +158,34 @@ status: "draft|review|stable|deprecated" | Field | Type | Required | Description | | ------------------------------- | ------ | -------- | -------------------------------------------------------------------- | -| `title` | string | ✅ | Human-readable title for the skill | -| `id` | string | ✅ | Unique kebab-case identifier (must match filename without extension) | -| `category` | enum | ✅ | Primary category (must match parent folder name) | -| `version` | semver | ✅ | Semantic version of this skill document | -| `created` | date | ✅ | ISO 8601 date when skill was first documented | -| `updated` | date | ✅ | ISO 8601 date when skill was last modified | -| `source.repository` | string | ✅ | Source repository in `owner/repo` format | -| `source.files` | array | ✅ | Array of source file references | -| `source.files[].path` | string | ✅ | Relative path within source repository | -| `source.files[].lines` | string | ❌ | Line range in format `start-end` | -| `source.files[].commit` | string | ❌ | Git commit SHA for version pinning | -| `source.url` | string | ❌ | Direct URL to repository or file | -| `tags` | array | ✅ | Array of descriptive tags for discovery | -| `complexity.level` | enum | ✅ | One of: basic, intermediate, advanced, expert | -| `complexity.reasoning` | string | ❌ | Explanation of complexity rating | -| `impact.performance.rating` | enum | ✅ | Performance impact rating | -| `impact.performance.details` | string | ❌ | Performance impact explanation | -| `impact.maintainability.rating` | enum | ✅ | Maintainability impact rating | -| `impact.testability.rating` | enum | ✅ | Testability impact rating | -| `prerequisites` | array | ❌ | Required knowledge or skills | -| `dependencies.packages` | array | ❌ | Required NuGet/npm packages | -| `dependencies.skills` | array | ❌ | Related skill IDs that should be learned first | -| `applies_to.languages` | array | ✅ | Programming languages this applies to | -| `applies_to.frameworks` | array | ❌ | Frameworks this applies to | -| `applies_to.versions` | object | ❌ | Version constraints | -| `aliases` | array | ❌ | Alternative names for search | -| `related` | array | ❌ | IDs of related skills | -| `status` | enum | ✅ | Document status: draft, review, stable, deprecated | +| `title` | string | Yes | Human-readable title for the skill | +| `id` | string | Yes | Unique kebab-case identifier (must match filename without extension) | +| `category` | enum | Yes | Primary category (must match parent folder name) | +| `version` | semver | Yes | Semantic version of this skill document | +| `created` | date | Yes | ISO 8601 date when skill was first documented | +| `updated` | date | Yes | ISO 8601 date when skill was last modified | +| `source.repository` | string | Yes | Source repository in `owner/repo` format | +| `source.files` | array | Yes | Array of source file references | +| `source.files[].path` | string | Yes | Relative path within source repository | +| `source.files[].lines` | string | No | Line range in format `start-end` | +| `source.files[].commit` | string | No | Git commit SHA for version pinning | +| `source.url` | string | No | Direct URL to repository or file | +| `tags` | array | Yes | Array of descriptive tags for discovery | +| `complexity.level` | enum | Yes | One of: basic, intermediate, advanced, expert | +| `complexity.reasoning` | string | No | Explanation of complexity rating | +| `impact.performance.rating` | enum | Yes | Performance impact rating | +| `impact.performance.details` | string | No | Performance impact explanation | +| `impact.maintainability.rating` | enum | Yes | Maintainability impact rating | +| `impact.testability.rating` | enum | Yes | Testability impact rating | +| `prerequisites` | array | No | Required knowledge or skills | +| `dependencies.packages` | array | No | Required NuGet/npm packages | +| `dependencies.skills` | array | No | Related skill IDs that should be learned first | +| `applies_to.languages` | array | Yes | Programming languages this applies to | +| `applies_to.frameworks` | array | No | Frameworks this applies to | +| `applies_to.versions` | object | No | Version constraints | +| `aliases` | array | No | Alternative names for search | +| `related` | array | No | IDs of related skills | +| `status` | enum | Yes | Document status: draft, review, stable, deprecated | --- diff --git a/.llm/skills/templates/skill-template.md b/.llm/skills/templates/skill-template.md index e21cecac..6249909f 100644 --- a/.llm/skills/templates/skill-template.md +++ b/.llm/skills/templates/skill-template.md @@ -108,7 +108,7 @@ Explain the fundamental idea. ## Anti-Patterns -### ❌ Don't Do This +### Don't Do This ```csharp // Bad example diff --git a/.llm/skills/testing/git-workflow-robustness-part-1.md b/.llm/skills/testing/git-workflow-robustness-part-1.md index 5039f48f..b101bc83 100644 --- a/.llm/skills/testing/git-workflow-robustness-part-1.md +++ b/.llm/skills/testing/git-workflow-robustness-part-1.md @@ -35,11 +35,11 @@ CommonMark defines specific rules for code spans with multiple backticks: #### Examples ````markdown -`code` → code -`code` → code -`` `code` `` → `code` -```a`b`` → a`b (opened with ``, closed with ``) -`` a ` b `` → a` b +`code` -> code +`code` -> code +`` `code` `` -> `code` +```a`b`` -> a`b (opened with ``, closed with ``) +`` a ` b `` to a` b ```` ### Parsing Algorithm @@ -77,7 +77,7 @@ When testing parsers, cover these categories: | Boundary | Code at start/end, only code, adjacent spans | | Nested | ``` `` `nested` `` ```, unequal counts | | Whitespace | Leading/trailing spaces, only spaces, newlines | -| Unicode | `café`, emoji, zero-width characters | +| Unicode | accented chars (U+00E9), emoji, zero-width | ### Data-Driven Test Patterns @@ -100,7 +100,7 @@ describe("inline code parsing", () => { ] ]; - test.each(testCases)("%s → %j (%s)", (input, expected, _desc) => { + test.each(testCases)("%s to %j (%s)", (input, expected, _desc) => { expect(parseInlineCode(input)).toEqual(expected); }); }); @@ -111,7 +111,7 @@ describe("inline code parsing", () => { For parser robustness, use property-based tests with libraries like `fast-check`: - **Never throws**: Parser should handle any input without throwing -- **Content preservation**: Total output length ≤ input length (accounting for delimiters) +- **Content preservation**: Total output length <= input length (accounting for delimiters) - **Idempotence**: Re-parsing output yields same structure ## CI/CD Integration Patterns diff --git a/.llm/skills/testing/inspector-overlay-invariants.md b/.llm/skills/testing/inspector-overlay-invariants.md new file mode 100644 index 00000000..d002386d --- /dev/null +++ b/.llm/skills/testing/inspector-overlay-invariants.md @@ -0,0 +1,151 @@ +--- +title: "Inspector Overlay Invariants for MessageAwareComponent" +id: "inspector-overlay-invariants" +category: "testing" +version: "1.0.0" +created: "2026-04-30" +updated: "2026-04-30" + +source: + repository: "wallstop-studios/com.wallstop-studios.dxmessaging" + files: + - path: "Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs" + - path: "Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs" + - path: "Tests/Editor/MessageAwareComponentFallbackEditorTests.cs" + url: "https://github.com/wallstop-studios/com.wallstop-studios.dxmessaging" + +tags: + - "testing" + - "editor" + - "inspector" + - "custom-editor" + - "unity" + - "regression" + +complexity: + level: "intermediate" + reasoning: "Requires understanding of Unity's IMGUI Layout/Repaint cycle and CustomEditor selection rules." + +impact: + performance: + rating: "low" + details: "Invariants are about correctness in the editor; no runtime cost." + maintainability: + rating: "high" + details: "Three concrete invariants prevent silent regressions in the inspector overlay." + testability: + rating: "high" + details: "Each invariant has an explicit guard test or documented source comment." + +prerequisites: + - "Familiarity with Unity's CustomEditor attribute and IMGUI." + - "Familiarity with Editor.finishedDefaultHeaderGUI." + +dependencies: + packages: [] + skills: [] + +applies_to: + languages: + - "C#" + frameworks: + - "Unity" + versions: + unity: ">=2021.3" + +aliases: + - "Fallback editor invariants" + - "Inspector overlay rules" + +related: + - "test-base-class-cleanup" + - "test-failure-investigation" + +status: "stable" +--- + +# Inspector Overlay Invariants for MessageAwareComponent + +> **One-line summary**: Three invariants keep `MessageAwareComponentFallbackEditor` and `MessageAwareComponentInspectorOverlay` from corrupting Unity's inspector layout cache or producing visible regressions like an empty vertical gap below the component header. + +## Overview + +The DxMessaging inspector overlay is split across two cooperating pieces: + +- `MessageAwareComponentFallbackEditor` -- a primary (non-fallback) `[CustomEditor]` registered for `MessageAwareComponent` and every subclass via `editorForChildClasses: true`. A user-defined `[CustomEditor]` for a specific subclass still wins precedence; this editor handles the rest. +- `MessageAwareComponentInspectorOverlay` -- a static class that hooks `Editor.finishedDefaultHeaderGUI` (the header path) and is also called from inside the fallback editor's `OnInspectorGUI` (the body path). + +Three invariants must hold simultaneously. Breaking any one of them causes a visible bug or layout corruption. + +## Invariant 1: register as PRIMARY (`isFallback = false`) and draw the default inspector body + +`MessageAwareComponentFallbackEditor` must be a primary (non-fallback) `[CustomEditor]` whose `OnInspectorGUI` body matches Unity's `GenericInspector` exactly: + +```csharp +[CustomEditor(typeof(MessageAwareComponent), true)] +[CanEditMultipleObjects] +public sealed class MessageAwareComponentFallbackEditor : Editor +{ + public override void OnInspectorGUI() + { + MessageAwareComponentInspectorOverlay.RenderInsideOnInspectorGUI(target); + DrawDefaultInspector(); + } +} +``` + +**Why primary, not fallback**: With `isFallback = true`, Unity prefers `GenericInspector` whenever it can -- and on Unity 2021, `Editor.finishedDefaultHeaderGUI` does not reliably fire for `MonoBehaviour` subclasses that have no registered `[CustomEditor]`. The combination causes the missing-base-call HelpBox to vanish entirely on Unity 2021 (and on any Unity version where the header hook is not reliable for the inspected type). Registering as a primary editor guarantees the warning surfaces on every supported Unity version because we render the HelpBox directly from `OnInspectorGUI`. + +**Why `DrawDefaultInspector()` and not a manual `SerializedObject` walk that skips `m_Script`**: Unity's `GenericInspector` (and `DrawDefaultInspector`) draws a disabled "Script" row beneath the component header for every `MonoBehaviour`. Skipping `m_Script` in a custom body -- under the (incorrect) assumption that Unity already draws the script reference in the title bar -- leaves the row blank and produces a visible empty vertical gap below the header for subclasses with no `[SerializeField]` fields. Calling `DrawDefaultInspector()` instead makes the body byte-for-byte identical to `GenericInspector`, eliminating the gap. + +**Why `editorForChildClasses: true`**: The warning HelpBox must surface for every `MessageAwareComponent` subclass, not only the abstract base. Unity's selection still prefers a more-specific user-defined `[CustomEditor(typeof(MySubclass))]` over our `editorForChildClasses` registration, so user editors continue to win precedence; the header-hook overlay surfaces the warning above the user's editor in that case. + +**Earlier rejected approaches and why they failed**: + +- `isFallback = true` (via reflection on `m_IsFallback`): the field is named `isFallback` (no `m_` prefix) and is public, so reflection on the `m_IsFallback` name returned null and emitted a runtime warning on every domain reload. Even after fixing the field-name bug and assigning the public field directly, `isFallback = true` regressed the warning surface as described above. +- Static-constructor reflection mutation on a single attribute instance: `Type.GetCustomAttributes` constructs a _fresh_ attribute instance on every call (the .NET runtime caches the metadata, not the instances). The static ctor mutated one instance, and Unity's `CustomEditorAttributes.Rebuild()` later asked for its own copy and saw an attribute with the default value because nothing had run the mutation on that copy. +- Subclass `CustomEditor` attribute that sets `isFallback` in its own constructor: structurally valid (`Type.GetCustomAttributes(false)` returns subclass instances too), but inherits the same regression as the direct-assignment approach above -- `isFallback = true` is the wrong design for this editor. + +**Regression test**: + +- `Tests/Editor/MessageAwareComponentFallbackEditorTests.cs::FallbackEditorMustRegisterAsPrimaryNonFallbackEditorForChildClasses` -- asserts `customEditor.isFallback == false` and `editorForChildClasses == true`. If a future contributor sets `isFallback = true` (regressing to a broken state), the test fails immediately with the regression context in its message. + +## Invariant 2: `BuildAndRenderOverlay` emits ZERO `EditorGUILayout` calls when `shape == 0` + +`MessageAwareComponentInspectorOverlay.BuildAndRenderOverlay` performs all gating up-front. When `shape == 0` (nothing to render), it must `return false` before any `EditorGUILayout.*` call. + +**Why**: When called from inside `OnInspectorGUI`, Unity invokes the editor twice per frame (`EventType.Layout` then `EventType.Repaint`). Both passes must emit identical control counts; a single stray `EditorGUILayout.LabelField` on only one pass corrupts Unity's layout cache and prevents adjacent components from rendering. + +The current source enforces this: see the comment in `Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs` that begins with `"Render nothing" branch`. + +**Regression coverage**: `Tests/Editor/MessageAwareComponentFallbackEditorTests.cs::FallbackEditorBodyDoesNotEmitVisibleHelpBoxWhenOverlayDisabled` (no-throw assertion when overlay is gated off). + +## Invariant 3: `RenderInsideOnInspectorGUI` is event-type-agnostic + +`MessageAwareComponentInspectorOverlay.RenderInsideOnInspectorGUI` (called from `OnInspectorGUI`) must NOT gate on `Event.current.type` and must NOT latch per-Repaint. Cross-path dedupe with the header hook is handled inside `DrawHeader` by an unconditional skip when `editor is MessageAwareComponentFallbackEditor`. + +**Why**: Inside an editor body, Unity runs both Layout and Repaint passes. Latching or event-type gating would produce different control counts on each pass and corrupt the inspector layout cache for the entire window. + +The current source enforces this: see the XML doc comment on `MessageAwareComponentInspectorOverlay` ("Layout/Repaint control-count invariant") and the dedicated comment block on `RenderInsideOnInspectorGUI`. + +## Practical Notes + +- Tests that exercise the fallback editor should set `DxMessagingSettings.GetOrCreateSettings()._baseCallCheckEnabled = false` in setup (and restore in teardown). This forces `BuildAndRenderOverlay` to early-return via the gating phase, so previous-session report data cannot make `shape != 0`. +- Subclasses used in tests must be top-level `internal` types (not nested private), because Unity cannot serialize private nested `MonoBehaviour` types through domain reload. Mark them `[AddComponentMenu("")]` to keep them out of the picker. +- `CustomEditor.isFallback` is a public field -- read it directly (no reflection). `CustomEditor.m_EditorForChildClasses` is `internal` with no public getter, so reflection is required to read that one. + +## When to Revisit + +The invariants above depend on Unity-internal behavior. Revisit if any of these change: + +- Unity renames or removes the public `CustomEditor.isFallback` field. The regression test reads it directly, so a rename would surface as a compile error in the test (immediate, build-time signal). +- Unity renames `CustomEditor.m_EditorForChildClasses`. The reflection-based read in the test will return null and the test will fail loudly with the guidance message embedded in the assertion ("Unity may have renamed the field; update this test"). +- Unity changes its `MonoBehaviour` inspector body so that the disabled "Script" row is no longer drawn by `DrawDefaultInspector()`. In that case the empty-row would no longer occupy the slot, and an empty subclass might briefly show a gap until we re-render an equivalent placeholder. Update `OnInspectorGUI` to match the new default behavior. +- Unity changes the `Editor.finishedDefaultHeaderGUI` semantics on Unity 2021 so the hook reliably fires for unregistered MonoBehaviours. At that point `isFallback = true` becomes a viable alternative architecture and would let `GenericInspector` handle our subclasses while the header hook still surfaces the warning. Re-evaluate the trade-off at that time. +- `MessageAwareComponentInspectorOverlay.BuildAndRenderOverlay` is restructured so that gating decisions intermix with `EditorGUILayout` calls. Re-establish the up-front gating phase before any layout call. + +## See Also + +- `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/script-test-coverage.md b/.llm/skills/testing/script-test-coverage.md index b53eb80d..79e87021 100644 --- a/.llm/skills/testing/script-test-coverage.md +++ b/.llm/skills/testing/script-test-coverage.md @@ -92,11 +92,11 @@ JavaScript using Jest, even when testing PowerShell script logic. ```text scripts/ -├── sync-banner-version.ps1 # PowerShell script -├── fix-eol.js # JavaScript script -└── __tests__/ - ├── sync-banner-version.test.js # Tests for PowerShell logic - └── fix-eol.test.js # Tests for JavaScript logic ++-- sync-banner-version.ps1 # PowerShell script ++-- fix-eol.js # JavaScript script ++-- __tests__/ + +-- sync-banner-version.test.js # Tests for PowerShell logic + +-- fix-eol.test.js # Tests for JavaScript logic ``` ### Naming Convention diff --git a/.llm/skills/testing/test-base-class-cleanup-part-1.md b/.llm/skills/testing/test-base-class-cleanup-part-1.md index 38d11eaa..55b3e22a 100644 --- a/.llm/skills/testing/test-base-class-cleanup-part-1.md +++ b/.llm/skills/testing/test-base-class-cleanup-part-1.md @@ -25,18 +25,18 @@ Continuation material extracted from `test-base-class-cleanup.md` to keep .llm f ### Core Concept ```text -┌─────────────────────────────────────────────────────────────────┐ -│ CommonTestBase │ -├─────────────────────────────────────────────────────────────────┤ -│ List _trackedObjects │ -│ List _trackedDisposables │ -├─────────────────────────────────────────────────────────────────┤ -│ Track(T obj) → adds to list, returns obj │ -│ TrackDisposable(T d) → adds to list, returns d │ -├─────────────────────────────────────────────────────────────────┤ -│ [TearDown] → Destroys all tracked objects │ -│ [UnityTearDown] → Yields for async destruction │ -└─────────────────────────────────────────────────────────────────┘ ++-----------------------------------------------------------------+ +| CommonTestBase | ++-----------------------------------------------------------------+ +| List _trackedObjects | +| List _trackedDisposables | ++-----------------------------------------------------------------+ +| Track(T obj) -> adds to list, returns obj | +| TrackDisposable(T d) -> adds to list, returns d | ++-----------------------------------------------------------------+ +| [TearDown] -> Destroys all tracked objects | +| [UnityTearDown] -> Yields for async destruction | ++-----------------------------------------------------------------+ ``` ### Implementation diff --git a/.llm/skills/testing/test-categories.md b/.llm/skills/testing/test-categories.md index f0457d56..8bc8abf6 100644 --- a/.llm/skills/testing/test-categories.md +++ b/.llm/skills/testing/test-categories.md @@ -234,7 +234,7 @@ public sealed class StringExtensionTests | Category | Max Time | Description | | ----------- | -------- | --------------------------- | -| Fast | <100ms | No I/O, no Unity lifecycle | +| Fast | <100ms | I/O, no Unity lifecycle | | Slow | <5s | Unity objects, moderate I/O | | Integration | <30s | External resources, network | diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 80335bc6..c230c372 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -11,7 +11,9 @@ "**/obj/**", "**/Temp/**", "**/Samples~/**", - "**/.github/**" + "**/.github/**", + "**/.venv/**", + "**/site/**" ] } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4d931b97..587511c3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,7 @@ repos: - id: fix-csharp-underscore-methods name: Auto-fix C# method names (remove underscores) entry: >- - bash -c 'node scripts/fix-csharp-underscore-methods.js "$@" && git add "$@"' -- + bash -c 'node scripts/fix-csharp-underscore-methods.js "$@" && { git diff --quiet -- "$@" || git add "$@"; }' -- language: system types: - c# @@ -54,6 +54,7 @@ repos: hooks: - id: prettier name: Prettier (Markdown, JSON, asmdef, asmref, YAML) + entry: node scripts/run-managed-prettier.js --write language: system files: '(?i)\.(md|markdown|json|asmdef|asmref|ya?ml)$' @@ -85,6 +86,27 @@ repos: language: system files: '(?i)\.(md|markdown)$' + - repo: local + hooks: + - id: validate-docs-ascii + name: Validate docs are ASCII-only + entry: node scripts/validate-docs-ascii.js + language: system + files: '\.(md|cs)$' + pass_filenames: false + stages: + - pre-commit + description: Enforce ASCII-only policy across .md files and /// XML doc comments. See .llm/skills/documentation/ascii-only-docs.md. + - id: validate-doc-code-patterns + name: Validate doc code samples don't use banned patterns + entry: node scripts/validate-doc-code-patterns.js + language: system + files: '\.(md|cs)$' + pass_filenames: false + 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. + - repo: https://github.com/adrienverge/yamllint rev: v1.38.0 hooks: @@ -203,6 +225,20 @@ repos: - pre-push description: Validate that npm package includes all .meta files correctly. + - repo: local + hooks: + - id: validate-changelog-policy + name: Validate changelog policy + entry: node scripts/validate-changelog.js --check-coverage + language: system + pass_filenames: false + files: '^(CHANGELOG\.md|Runtime/|SourceGenerators/|Samples~/|Editor/)' + exclude: "^Editor/(Analyzers|Testing)/" + stages: + - pre-commit + - pre-push + description: Enforce changelog structure and require changelog updates for likely user-visible changes. + - repo: local hooks: - id: validate-vscode-settings @@ -251,6 +287,7 @@ repos: scripts/__tests__/update-llms-txt.test.js scripts/__tests__/fix-md029-md051.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js + scripts/__tests__/validate-changelog.test.js scripts/__tests__/validate-vscode-settings.test.js scripts/__tests__/validate-pre-commit-tooling.test.js scripts/__tests__/validate-npm-meta.test.js @@ -260,7 +297,7 @@ repos: scripts/__tests__/verify-managed-jest-fallback.test.js language: system pass_filenames: false - files: '^(\.gitattributes|CONTRIBUTING\.md|\.vscode/settings\.json|\.github/workflows/(llm-policy-check|pre-commit-tooling-check)\.yml|scripts/check-eol\.ps1|scripts/(check-eol|fix-eol|fix-md029-md051|fix-csharp-underscore-methods|validate-lychee-config|validate-skills|generate-skills-index|validate-workflows|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-npm-meta|run-managed-jest|run-managed-prettier|verify-managed-jest-fallback)\.js|scripts/lib/(quote-parser|eol-policy|shell-command|prettier-version)\.js|scripts/__tests__/(check-eol|fix-md029-md051|fix-csharp-underscore-methods|validate-lychee-config|validate-skills-required-fields|validate-skills-llm-policy|generate-skills-index|prettier-version|run-managed-prettier|validate-workflows|quote-parser|shell-command|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-npm-meta|detect-shell-redirection-antipattern|pre-commit-hook-stage-policy|run-managed-jest|verify-managed-jest-fallback)\.test\.js)$' + files: '^(\.gitattributes|CONTRIBUTING\.md|\.vscode/settings\.json|\.github/workflows/(llm-policy-check|pre-commit-tooling-check)\.yml|scripts/check-eol\.ps1|scripts/(check-eol|fix-eol|fix-md029-md051|fix-csharp-underscore-methods|validate-lychee-config|validate-skills|generate-skills-index|validate-workflows|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-npm-meta|validate-changelog|run-managed-jest|run-managed-prettier|verify-managed-jest-fallback)\.js|scripts/lib/(quote-parser|eol-policy|shell-command|prettier-version)\.js|scripts/__tests__/(check-eol|fix-md029-md051|fix-csharp-underscore-methods|validate-lychee-config|validate-skills-required-fields|validate-skills-llm-policy|generate-skills-index|prettier-version|run-managed-prettier|validate-workflows|validate-changelog|quote-parser|shell-command|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-npm-meta|detect-shell-redirection-antipattern|pre-commit-hook-stage-policy|run-managed-jest|verify-managed-jest-fallback)\.test\.js)$' stages: - pre-commit description: Fail fast on parser, npm-meta, and shell-safety regressions (quote handling, frontmatter/TOML parsing, newline normalization) before push. diff --git a/AGENTS.md b/AGENTS.md index 3e51c91a..41b41830 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,8 @@ # Repository Guidelines See the [AI Agent Guidelines](./.llm/context.md) for all AI agent guidelines. + +Two project-wide rules: + +- Documentation must be pure ASCII (see [.llm/skills/documentation/ascii-only-docs.md](./.llm/skills/documentation/ascii-only-docs.md)). +- Code samples must compile (see [.llm/skills/documentation/code-samples-must-compile.md](./.llm/skills/documentation/code-samples-must-compile.md)). diff --git a/CHANGELOG.md b/CHANGELOG.md index 517b5edc..4f043935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,20 @@ 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. +- 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. +- New `docs/reference/analyzers.md` reference page documenting every `DXMSG###` diagnostic the package emits, with severity, source generator/analyzer, trigger conditions, message text, and code samples for each. Added to the Reference section of the documentation site navigation. - Added `llms.txt` file following [llmstxt.org](https://llmstxt.org/) standard for improved AI agent integration - Added automation script `scripts/update-llms-txt.js` to keep `llms.txt` up-to-date -- Added npm scripts `update:llms-txt` and `check:llms-txt` for managing llms.txt -- Added GitHub Actions workflows for automatic validation and updates of llms.txt - Added documentation about AI agent integration in README -- Inspector overlay now shows yesterday's analyzer 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 as `(cached from previous session — refreshing…)` until the first scan refreshes it. Eliminates the perceived flakiness where the warning sometimes appeared and sometimes didn't, depending on how fast the user clicked into the inspector after a domain reload. -- Added `scripts/fix-csharp-underscore-methods.js` to auto-convert underscored C# method names to PascalCase. -- Added pre-commit hook `fix-csharp-underscore-methods` to auto-fix and re-stage changed C# files before commit. -- Added script tests and hook-policy checks that enforce no-underscore method naming in C# test code. + +### Fixed + +- Fixed `scripts/validate-workflows.js` to fail loudly when `git` is unavailable during ignore-policy checks instead of silently bypassing validation. +- Fixed `scripts/validate-workflows.js` violation path reporting to compute file paths relative to the provided `repoRoot` override. +- Removed unused `using Unity;` imports from editor custom-editor and harness files to avoid unnecessary namespace dependencies. ## [2.2.0] diff --git a/CLAUDE.md b/CLAUDE.md index 462f5895..930e7aa3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,8 @@ # Claude Configuration See the [AI Agent Guidelines](./.llm/context.md) for all AI agent guidelines. + +Two project-wide rules: + +- Documentation must be pure ASCII (see [.llm/skills/documentation/ascii-only-docs.md](./.llm/skills/documentation/ascii-only-docs.md)). +- Code samples must compile (see [.llm/skills/documentation/code-samples-must-compile.md](./.llm/skills/documentation/code-samples-must-compile.md)). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bae2f9f8..2a69bc22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ node scripts/fix-eol.js This directly converts files in your working directory to the correct line endings. Add `-v` for verbose output showing each file fixed. -> **Note:** You may see references to `git add --renormalize`, but that command only updates the git index (staging area)—it does **not** modify your working tree files. Use `fix-eol.js` to actually fix files on disk. +> **Note:** You may see references to `git add --renormalize`, but that command only updates the git index (staging area) -- it does **not** modify your working tree files. Use `fix-eol.js` to actually fix files on disk. ## VS Code Security Policy @@ -69,6 +69,15 @@ If `npm run check:yaml` reports a YAML line-length failure: - Run pre-commit Node preflight: `npm run preflight:pre-commit` - Validate NPM package: `npm run validate:npm-meta` +## Documentation Style and Code Samples + +Two strict rules apply to all documentation (Markdown files and `///` XML doc comments) and to every C# code sample: + +1. **ASCII-only.** Pure ASCII is required. Real Unicode emojis are allowed only on callout lines (lines starting with `>`), capped at five per file. See [.llm/skills/documentation/ascii-only-docs.md](./.llm/skills/documentation/ascii-only-docs.md). Run `node scripts/validate-docs-ascii.js` (or `node scripts/normalize-docs-ascii.js` to auto-fix). +1. **Code samples must compile.** Every C# snippet - inline backticks, fenced blocks, table cells, and XML `` blocks - must compile against the snippet harness. See [.llm/skills/documentation/code-samples-must-compile.md](./.llm/skills/documentation/code-samples-must-compile.md). Run `node scripts/validate-doc-code-patterns.js` and the `DocsSnippetCompilationTests` suite under `SourceGenerators/`. + +Both rules are enforced by pre-commit hooks (`validate-docs-ascii`, `validate-doc-code-patterns`) and the `.github/workflows/docs-lint.yml` CI job. + ## NPM Package Validation Unity requires `.meta` files for every asset to maintain consistent GUIDs across installations. The `validate:npm-meta` script ensures: diff --git a/Editor/Analyzers/BaseCallIlInspector.cs b/Editor/Analyzers/BaseCallIlInspector.cs index d0b7d5db..7fbe2515 100644 --- a/Editor/Analyzers/BaseCallIlInspector.cs +++ b/Editor/Analyzers/BaseCallIlInspector.cs @@ -19,7 +19,7 @@ namespace DxMessaging.Editor.Analyzers /// Why does this exist? The console-scrape harvester is non-deterministic across Unity /// 2021 cache hits (Unity skips routing analyzer warnings to LogEntries / /// CompilerMessage[] on incremental compiles where Bee/csc reused a cached output). - /// IL reflection over the loaded assemblies in the AppDomain is deterministic — the bytes do + /// IL reflection over the loaded assemblies in the AppDomain is deterministic -- the bytes do /// not depend on Unity's compile-pipeline state. uses this /// helper to classify every loaded MessageAwareComponent subclass on every domain /// reload. @@ -30,7 +30,7 @@ namespace DxMessaging.Editor.Analyzers /// (single-byte and two-byte 0xFE-prefix forms) and steps the operand-size that the opcode /// declares (). Misalignment past multi-byte-operand opcodes /// (switch jump tables, ldstr 4-byte tokens, 8-byte literal constants, etc.) is - /// therefore impossible — the walker either consumes every byte correctly or stops at the + /// therefore impossible -- the walker either consumes every byte correctly or stops at the /// first unrecognised opcode. Phantom DXMSG006 from a misread 0x28 inside a wider /// operand is no longer a failure mode. /// @@ -38,7 +38,7 @@ namespace DxMessaging.Editor.Analyzers /// Defensive bias. When we cannot reason at all (null method, empty name, inaccessible /// IL body, GetMethodBody() returns null on abstract / P/Invoke / IL2CPP-stripped /// targets, or any reflection exception), the inspector returns true - /// ("assume clean — calls base") so the scanner never invents a phantom warning. The + /// ("assume clean -- calls base") so the scanner never invents a phantom warning. The /// compile-time analyzer is the authoritative source for CI builds (DXMSG006/007/009/010 via /// full Roslyn semantic-model precision); the IL scanner exists only to make the editor /// overlay light up at edit-time, where a missed warning is far worse than a phantom one. @@ -94,7 +94,7 @@ FieldInfo field in typeof(OpCodes).GetFields( /// The expected base method name (e.g. "OnEnable"). /// /// true if the IL contains a base-call shape, OR the IL was inaccessible (safe - /// default — assume clean). false only when IL was readable AND no call/callvirt + /// default -- assume clean). false only when IL was readable AND no call/callvirt /// targeting a parent same-named method was found. /// public static bool MethodIlContainsBaseCall(MethodInfo method, string methodName) diff --git a/Editor/Analyzers/BaseCallReportAggregator.cs b/Editor/Analyzers/BaseCallReportAggregator.cs index 1f63b158..e5cfb104 100644 --- a/Editor/Analyzers/BaseCallReportAggregator.cs +++ b/Editor/Analyzers/BaseCallReportAggregator.cs @@ -16,7 +16,7 @@ namespace DxMessaging.Editor.Analyzers /// /// The harvester wraps every DTO in a (with the lowercase /// Unity-serialisable field names) before the snapshot crosses into Editor code. Keep this - /// shape in lock-step with that wrapper — fields added here must also flow through to the + /// shape in lock-step with that wrapper -- fields added here must also flow through to the /// Unity-facing entry or the inspector overlay won't see them. /// public sealed class BaseCallReportEntryDto @@ -50,7 +50,7 @@ public sealed class BaseCallReportEntryDto /// /// typesByAssembly: which FQNs each compiled assembly has reported. /// When an assembly recompiles WITHOUT reporting a previously-seen FQN, that FQN is retired - /// — the user fixed the offending base call. + /// -- the user fixed the offending base call. /// mergedReports: the per-FQN union of every assembly's latest /// report. The final snapshot merges this with whatever the LogEntries scan yielded. /// @@ -71,7 +71,7 @@ public static class BaseCallReportAggregator /// Stable identifier for the source assembly (typically the /// assembly path Unity passes via CompilationPipeline.assemblyCompilationFinished). /// Reports parsed from this assembly's most recent - /// compilation. May be empty — that case is the retirement path (every FQN this assembly + /// compilation. May be empty -- that case is the retirement path (every FQN this assembly /// previously reported is dropped). /// Per-assembly FQN bookkeeping. Mutated in place. /// Per-FQN union across every assembly. Mutated in place; diff --git a/Editor/Analyzers/BaseCallTypeScanner.cs b/Editor/Analyzers/BaseCallTypeScanner.cs index 7ae876c1..4c21bfd7 100644 --- a/Editor/Analyzers/BaseCallTypeScanner.cs +++ b/Editor/Analyzers/BaseCallTypeScanner.cs @@ -17,8 +17,8 @@ namespace DxMessaging.Editor.Analyzers /// /// Why IL reflection? Unity's CompilationPipeline.assemblyCompilationFinished and /// the LogEntries console store are both downstream of Unity's decision to actually - /// surface analyzer warnings. On Bee/csc cache hits — which happen on most domain reloads - /// after the first — Unity skips that surface entirely, so the scrape returns nothing even + /// surface analyzer warnings. On Bee/csc cache hits -- which happen on most domain reloads + /// after the first -- Unity skips that surface entirely, so the scrape returns nothing even /// though the analyzer ran successfully on the first compile. By contrast, IL reflection over /// loaded types is deterministic: the assemblies are in the AppDomain, the methods have IL /// bodies, the same scan produces the same result on every reload regardless of whether the @@ -27,16 +27,16 @@ namespace DxMessaging.Editor.Analyzers /// /// What it detects: /// - /// DXMSG006 — overrides one of the five guarded methods but the IL body + /// DXMSG006 -- overrides one of the five guarded methods but the IL body /// lacks a call/callvirt to the parent's same-named method. - /// DXMSG007 — declares the method with the new modifier (IL: name + /// DXMSG007 -- declares the method with the new modifier (IL: name /// shadows a base virtual but the descendant method itself is not in an override slot). - /// DXMSG009 — declares the method without override or new — same IL shape + /// DXMSG009 -- declares the method without override or new -- same IL shape /// as DXMSG007 (both compile to a non-virtual hide-by-sig method). The scanner cannot /// distinguish the two perfectly from IL alone, so it conservatively classifies this case as /// DXMSG007. The compile-time analyzer is authoritative for the precise ID classification; /// the scanner's job is just to make sure the inspector overlay lights up. - /// DXMSG010 — overrides correctly (calls base) but an intermediate + /// DXMSG010 -- overrides correctly (calls base) but an intermediate /// ancestor's override in the chain does NOT call base. Walks parent-by-parent and re-runs the /// IL check at every link until the chain terminates at /// or hits a broken link. @@ -44,7 +44,7 @@ namespace DxMessaging.Editor.Analyzers /// /// /// Cross-assembly assume-clean: ancestors whose IL is unavailable - /// ( returns null — e.g., abstract or + /// ( returns null -- e.g., abstract or /// extern methods) are trusted. Emitting an unactionable warning against a closed-source /// third-party library would be hostile. /// @@ -65,7 +65,7 @@ internal static class BaseCallTypeScanner /// /// /// Types opted out via [DxIgnoreMissingBaseCall] or via the project's ignored-types - /// list are intentionally NOT included in the returned dictionary — the overlay reads the + /// list are intentionally NOT included in the returned dictionary -- the overlay reads the /// project ignore list directly to render its "Stop ignoring" HelpBox, and the snapshot /// semantics here match the bridge path (DXMSG008-equivalent rows were never present in /// the snapshot's missingBaseFor either). diff --git a/Editor/Analyzers/BaseCallTypeScannerCore.cs b/Editor/Analyzers/BaseCallTypeScannerCore.cs index 8d43ba62..49ddc60c 100644 --- a/Editor/Analyzers/BaseCallTypeScannerCore.cs +++ b/Editor/Analyzers/BaseCallTypeScannerCore.cs @@ -30,7 +30,7 @@ namespace DxMessaging.Editor.Analyzers /// /// Diagnostic IDs produced match what the inspector overlay reads from the Unity-facing entry: /// DXMSG006 (override missing base call), DXMSG007 (hides via new; also - /// covers DXMSG009 since IL alone can't distinguish the two — see remarks on + /// covers DXMSG009 since IL alone can't distinguish the two -- see remarks on /// ), and DXMSG010 (override calls base but a chain /// link does not). /// @@ -77,11 +77,13 @@ public sealed class ScanEntry /// /// Classify every type and return a per-FQN snapshot keyed /// by fully-qualified type name (dot-form for nested types). Types opted out via - /// [DxIgnoreMissingBaseCall] or via are - /// intentionally NOT included in the returned dictionary — the inspector overlay reads + /// class-level [DxIgnoreMissingBaseCall] or via + /// are + /// intentionally NOT included in the returned dictionary -- the inspector overlay reads /// the project ignore list directly to render its "Stop ignoring" HelpBox, and the /// snapshot semantics here match the bridge path (DXMSG008-equivalent rows were never - /// present in the snapshot's missingBaseFor either). + /// present in the snapshot's missingBaseFor either). Method-level + /// [DxIgnoreMissingBaseCall] is applied per guarded method only. /// /// /// Strict subclasses of MessageAwareComponent. Abstract types and generic-type @@ -139,15 +141,9 @@ IEnumerable ignoredTypeNames // is keyed identically to the analyzer's identifiers. fullName = fullName.Replace('+', '.'); - bool optedOutByAttribute = TypeOrGuardedMethodHasIgnoreAttribute(concrete); + bool optedOutByAttribute = TypeHasIgnoreAttribute(concrete); bool optedOutByList = projectIgnore.Contains(fullName); - ScanEntry entry = ScanOne(concrete, fullName); - if (entry == null || entry.MissingBaseFor.Count == 0) - { - continue; - } - if (optedOutByAttribute || optedOutByList) { // Suppression makes the entry an audit-marker (DXMSG008-equivalent). The @@ -158,17 +154,24 @@ IEnumerable ignoredTypeNames continue; } + HashSet methodLevelIgnore = GetGuardedMethodsWithIgnoreAttribute(concrete); + + ScanEntry entry = ScanOne(concrete, fullName, methodLevelIgnore); + if (entry == null || entry.MissingBaseFor.Count == 0) + { + continue; + } + result[fullName] = entry; } return result; } - private static bool TypeOrGuardedMethodHasIgnoreAttribute(Type type) + private static bool TypeHasIgnoreAttribute(Type type) { // [DxIgnoreMissingBaseCall] applies with Inherited=false (matches the analyzer's - // attribute declaration), so we inspect only the type itself plus its declared - // guarded lifecycle methods. + // attribute declaration), so we inspect only the type itself. foreach (object attr in type.GetCustomAttributes(inherit: false)) { if (attr.GetType().FullName == IgnoreAttributeFullName) @@ -176,9 +179,12 @@ private static bool TypeOrGuardedMethodHasIgnoreAttribute(Type type) return true; } } - // Method-level: any of the five guarded methods marked with the attribute also opts - // the entire type out from the inspector overlay (the analyzer applies the attribute - // per-method, but the overlay tracks types — opt out at the granularity we render). + return false; + } + + private static HashSet GetGuardedMethodsWithIgnoreAttribute(Type type) + { + HashSet ignoredMethods = new(StringComparer.Ordinal); foreach (string methodName in GuardedMethodNames) { MethodInfo m = type.GetMethod( @@ -199,14 +205,19 @@ private static bool TypeOrGuardedMethodHasIgnoreAttribute(Type type) { if (attr.GetType().FullName == IgnoreAttributeFullName) { - return true; + ignoredMethods.Add(methodName); + break; } } } - return false; + return ignoredMethods; } - private static ScanEntry ScanOne(Type concrete, string fullName) + private static ScanEntry ScanOne( + Type concrete, + string fullName, + HashSet methodLevelIgnore + ) { ScanEntry entry = new() { @@ -217,14 +228,24 @@ private static ScanEntry ScanOne(Type concrete, string fullName) foreach (string methodName in GuardedMethodNames) { - ClassifyMethod(concrete, methodName, entry); + ClassifyMethod(concrete, methodName, entry, methodLevelIgnore); } return entry; } - private static void ClassifyMethod(Type concrete, string methodName, ScanEntry entry) + private static void ClassifyMethod( + Type concrete, + string methodName, + ScanEntry entry, + HashSet methodLevelIgnore + ) { + if (methodLevelIgnore.Contains(methodName)) + { + return; + } + // Walk the type chain: first the leaf (concrete), then ancestors via BaseType until we // leave the MessageAwareComponent inheritance subtree. For the leaf we determine which // of DXMSG006/007/009 fires (if any). If the leaf overrides correctly, we walk diff --git a/Editor/Analyzers/DxMessagingConsoleHarvester.cs b/Editor/Analyzers/DxMessagingConsoleHarvester.cs index 0ba394ef..d63def0f 100644 --- a/Editor/Analyzers/DxMessagingConsoleHarvester.cs +++ b/Editor/Analyzers/DxMessagingConsoleHarvester.cs @@ -33,7 +33,7 @@ public sealed class BaseCallReportEntry /// /// Note: the IL-reflection scanner classifies DXMSG009 as DXMSG007 because the two are /// indistinguishable at the IL level. The compile-time analyzer remains authoritative for - /// the precise ID classification — see the analyzer reference docs and the inspector + /// the precise ID classification -- see the analyzer reference docs and the inspector /// integration section of docs/reference/analyzers.md. DXMSG008 (audit-marker for /// opted-out types) is intentionally NOT included here: opted-out types are excluded from /// the snapshot so the overlay's "Stop ignoring" path can reason about them via the @@ -58,7 +58,7 @@ internal sealed class BaseCallReportFile /// /// Builds the per-FQN snapshot consumed by the inspector overlay from a deterministic IL - /// reflection scanner () — and, optionally, a legacy + /// reflection scanner () -- and, optionally, a legacy /// console-scrape bridge for users who want the union of both data sources. /// /// @@ -66,7 +66,7 @@ internal sealed class BaseCallReportFile /// Primary source (always-on): . Walks loaded /// MessageAwareComponent subclasses via Unity's TypeCache and inspects each /// override's IL body for the base-call shape. Deterministic across Unity 2021 cache hits, - /// incremental compiles, and arbitrary domain-reload sequences — the only inputs are the + /// incremental compiles, and arbitrary domain-reload sequences -- the only inputs are the /// loaded assemblies in the AppDomain, which do not depend on Unity's compile-pipeline /// state. Runs on every and on every /// CompilationPipeline.assemblyCompilationFinished burst (debounced via @@ -84,12 +84,12 @@ internal sealed class BaseCallReportFile /// /// /// The inspector overlay reads its snapshot from the unified per-FQN map populated here on - /// every rescan. Use the menu Tools → DxMessaging → Rescan Base-Call Warnings for a + /// every rescan. Use the menu Tools > DxMessaging > Rescan Base-Call Warnings for a /// manual force-rescan. /// /// /// stays true as long as the static constructor itself does - /// not throw — the IL scanner is always wired, so the overlay never falls back to its + /// not throw -- the IL scanner is always wired, so the overlay never falls back to its /// degraded "harvester unavailable" HelpBox in normal operation. /// continues to report whether the legacy reflection layer is bindable, for diagnostics only. /// @@ -202,7 +202,7 @@ private static readonly Dictionary< /// /// Direct read of the latest console-derived report by FQN. Returns true if an /// entry exists for the given fully-qualified type name. The - /// reference points at the live snapshot row — callers must not mutate it. + /// reference points at the live snapshot row -- callers must not mutate it. /// /// /// All mutation happens on the main thread inside ; the inspector @@ -225,7 +225,7 @@ public static bool TryGetEntry(string fullyQualifiedTypeName, out BaseCallReport /// /// /// Each access returns a fresh dictionary copy. Prefer in hot - /// paths (the inspector overlay) — this property exists for callers that need to enumerate + /// paths (the inspector overlay) -- this property exists for callers that need to enumerate /// the full snapshot. /// public static IReadOnlyDictionary Snapshot => @@ -239,7 +239,7 @@ public static bool TryGetEntry(string fullyQualifiedTypeName, out BaseCallReport /// unconditionally on every supported Unity version, so this property is effectively /// always true in normal operation; it only flips to false when the static /// constructor itself throws (a hard initialization failure). The LogEntries reflection - /// layer is the optional source — see for that flag. + /// layer is the optional source -- see for that flag. /// The inspector overlay reads this property to decide whether to render its degraded /// HelpBox, so the contract here is "should the overlay attempt to render at all". /// @@ -248,7 +248,7 @@ public static bool TryGetEntry(string fullyQualifiedTypeName, out BaseCallReport /// /// true when the legacy UnityEditor.LogEntries reflection layer resolved /// successfully on this Unity version. The harvester does not require this to be true to - /// function — Unity 2021's analyzer warnings flow through the CompilerMessage feed + /// function -- Unity 2021's analyzer warnings flow through the CompilerMessage feed /// instead. Exposed primarily for diagnostics / tests. /// public static bool LogEntriesAvailable => !_logEntriesDisabled; @@ -260,8 +260,8 @@ public static bool TryGetEntry(string fullyQualifiedTypeName, out BaseCallReport /// /// /// The inspector overlay reads this to annotate its HelpBox: when false AND a - /// warning is being shown, the overlay appends a "(cached from previous session — - /// refreshing…)" suffix so the user knows the data is from yesterday's scan and a fresh + /// warning is being shown, the overlay appends a "(cached from previous session -- + /// refreshing...)" suffix so the user knows the data is from yesterday's scan and a fresh /// one is in flight. The flag is set inside and never reset, so /// the suffix disappears as soon as the first post-reload scan lands and stays gone for /// the rest of the session. @@ -368,7 +368,7 @@ _startGettingEntries is not null /// so a re-enable repopulates the snapshot /// without waiting for the next polled tick. /// - [MenuItem("Tools/DxMessaging/Rescan Base-Call Warnings")] + [MenuItem("Tools/Wallstop Studios/DxMessaging/Rescan Base-Call Warnings")] public static void RescanNow() { if (!IsAvailable) diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll index 1d3e95c3d3ec557524c8b46853cd12a3c915834e..fdaba6c0f1b4d551a3e515f75ba03997c45b2da9 100644 GIT binary patch delta 247 zcmZqJ!q~8daY6@6MoHM?jXe@h0vD9Fzn*Y;uXm^LO>S>Vt(47MoTRw*jEoH|3{#Q} z4AYE^lTuPsEiDZV3{uSu)66Xs4UE!~43iU0Qql}l7#PeM8H^@>(A1r*=@-u8Rw~;! zxy3I)!0gHL(5k>EjnfGKw?Qieku@J0-+&L+yKZn1IwBNMG}F0BcRG8phz;1 RX985624tsfb`RUh3;;hkO6LFo delta 247 zcmZqJ!q~8daY6^ndd|{=8+#<21Oo27-nQoN2LIOm=NSbqGiq$!;v~hbXK0a}W@?#c zk!WUMoNAJ6WMrO}n3R-~VrZIXmTYQ~YHW~}nqq2X%)nsI$Y3=2gQo6eO}}uKX-|$W zo800TAnrVN%q(t;t8!HmIx!I&YH!2~F71cc^5@kE9sAWQ*5L!inupqgYL YZ2?qe3{;&4q*H)=BL?Hm?qNHb0q71>j{pDw diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll index c2ddae6874b400d12bd0ce66b45841eb47f7d815..c7e4c5ca7bc8df9b3b0960f1490a4a5a750acc0f 100644 GIT binary patch delta 236 zcmZpez|=5-X+j4}Lc^Nb8+%eR1>Q+XzZ6{d-+#@*CsTj@@yghgWhJCxWNctzn37~* zm}X?0l#-fiX=!L+kZNX_W^S2iV3d|*n4D;ml4h7PIjQCt%bLF-CnoFE1_(Th=NC3@ z`R2A~n?;vh!u=*P@7&90!STGne zq%b4_S%wU03?Q*2AU_odErHMwC~g2`n}KD`fg*`Oz7bGm5>O-=$TI<|P6M)2HcxNK GWCj4&4p6}W delta 236 zcmZpez|=5-X+j5!=DeJyjXf!u0*Y~yPw|-Fbf3GgP{8e7uFa+_DNFso0^}Pp L7;m25lF1AJPI^Yr diff --git a/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs b/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs index 7ab42ea1..52deb0f7 100644 --- a/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs +++ b/Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs @@ -2,43 +2,64 @@ namespace DxMessaging.Editor.CustomEditors { #if UNITY_EDITOR using DxMessaging.Unity; - using Unity; using UnityEditor; /// - /// Fallback CustomEditor that renders the DxMessaging warning HelpBox above the default - /// inspector for any subclass. Registered with - /// isFallback: true so a user's own [CustomEditor] still wins precedence — this - /// only kicks in when no other editor is registered for the type. + /// Primary CustomEditor for every subclass. Renders the + /// DxMessaging warning HelpBox above an inspector body that is byte-for-byte identical to + /// Unity's default GenericInspector (achieved via + /// ). /// /// - /// This composes the overlay path; the two - /// paths cover different Unity inspector code paths. Notably, Unity 2021 does not reliably - /// fire for - /// subclasses that have no [CustomEditor] registered — the fallback editor is what - /// makes the HelpBox appear in that environment. To avoid double-rendering when the header - /// hook ALSO fires for our editor instance (Unity 2022+), - /// unconditionally skips the header path - /// for instances. + /// We register as a non-fallback (primary) editor with + /// editorForChildClasses: true. Several alternatives were tried and rejected: + /// + /// + /// + /// isFallback = true: Unity selects this editor only when no other matches. + /// In practice that meant Unity's GenericInspector handled every + /// subclass and the warning HelpBox vanished entirely + /// (Unity 2021's hook did not reliably fire + /// for those types). This regressed the analyzer warning surface. + /// + /// + /// Manual iteration that skips m_Script: the + /// rationale was to avoid a "duplicate Script row," but Unity does NOT draw m_Script + /// in the component header -- draws the same + /// disabled "Script" row that GenericInspector draws. Skipping it produced a visible + /// vertical gap below the header for empty subclasses, because the row Unity reserves for + /// the script reference was left blank. + /// + /// /// /// - /// We deliberately do NOT call : it re-emits the - /// m_Script field that Unity has already drawn in the inspector titlebar/header, - /// producing a duplicate "Script" row that visually breaks the inspector and offsets the - /// layout cache. Instead we walk the manually and skip - /// m_Script — the canonical "default inspector minus the script field" pattern. + /// The current design is the simple one: be the primary editor, prepend the overlay's + /// HelpBox via , + /// then call . The body therefore matches + /// GenericInspector exactly: no missing Script row, no extra vertical gap. To avoid + /// double-rendering when the header hook ALSO fires for our editor instance (Unity 2022+), + /// unconditionally skips the header path + /// for instances. /// /// /// - /// We also do NOT short-circuit on event type. Unity invokes + /// We do NOT short-circuit on event type. Unity invokes /// editors twice per frame (Layout + Repaint), and both passes MUST emit identical control /// counts, otherwise the inspector window's layout cache is corrupted and adjacent /// components fail to render. See /// for the /// matching invariant on the overlay side. /// + /// + /// + /// User-defined custom editors for specific subclasses + /// still win precedence: a [CustomEditor(typeof(MySpecificSubclass))] is more + /// specific than our editorForChildClasses registration, so Unity selects the user's + /// editor for that subclass. The header-hook overlay still surfaces the warning above the + /// user's editor in that case. + /// /// - [CustomEditor(typeof(MessageAwareComponent), editorForChildClasses: true)] + [CustomEditor(typeof(MessageAwareComponent), true)] [CanEditMultipleObjects] public sealed class MessageAwareComponentFallbackEditor : Editor { @@ -49,29 +70,11 @@ public override void OnInspectorGUI() // control counts, so we can call it unconditionally here. MessageAwareComponentInspectorOverlay.RenderInsideOnInspectorGUI(target); - serializedObject.Update(); - SerializedProperty iter = serializedObject.GetIterator(); - if (iter.NextVisible(enterChildren: true)) - { - do - { - // Skip the script reference — Unity's inspector window already draws it in - // the component header. Re-drawing it here causes a duplicate "Script" row - // that visually breaks the inspector and offsets the layout cache. - if ( - string.Equals( - iter.propertyPath, - "m_Script", - System.StringComparison.Ordinal - ) - ) - { - continue; - } - EditorGUILayout.PropertyField(iter, includeChildren: true); - } while (iter.NextVisible(enterChildren: false)); - } - serializedObject.ApplyModifiedProperties(); + // Match Unity's GenericInspector exactly — including the disabled "Script" row that + // every MonoBehaviour inspector shows. This is intentional: skipping the script row + // creates a visible empty gap below the header for subclasses with no + // [SerializeField] fields. + DrawDefaultInspector(); } } #endif diff --git a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs index b07fd9fb..5bd9e5f3 100644 --- a/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs +++ b/Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs @@ -6,7 +6,6 @@ namespace DxMessaging.Editor.CustomEditors using DxMessaging.Editor.Analyzers; using DxMessaging.Editor.Settings; using DxMessaging.Unity; - using Unity; using UnityEditor; using UnityEditorInternal; using UnityEngine; @@ -34,7 +33,7 @@ namespace DxMessaging.Editor.CustomEditors /// /// /// (registered to ) is - /// post-body and Unity has already settled layout for the inspector by the time it fires — + /// post-body and Unity has already settled layout for the inspector by the time it fires -- /// gating on EventType.Repaint there is safe. /// /// @@ -103,7 +102,7 @@ private static void DrawHeader(Editor editor) /// /// Header-hook entry point. Fires after Unity's default header has been drawn, so the /// inspector's layout pass for this editor has already completed. Safe to gate on - /// here — we are not inside an OnInspectorGUI body. + /// here -- we are not inside an OnInspectorGUI body. /// private static void RenderForHeaderHook(Object target) { diff --git a/Editor/CustomEditors/MessagingComponentEditor.cs b/Editor/CustomEditors/MessagingComponentEditor.cs index 6978fc2a..13ff44bf 100644 --- a/Editor/CustomEditors/MessagingComponentEditor.cs +++ b/Editor/CustomEditors/MessagingComponentEditor.cs @@ -8,7 +8,7 @@ namespace DxMessaging.Editor.CustomEditors using Core.MessageBus; using Core.Messages; using DxMessaging.Editor.Testing; - using Unity; + using DxMessaging.Unity; using UnityEditor; using UnityEngine; using Object = UnityEngine.Object; diff --git a/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs b/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs index 0d38ce12..cbf175da 100644 --- a/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs +++ b/Editor/Settings/DxMessagingBaseCallIgnoreSync.cs @@ -37,7 +37,7 @@ public static class DxMessagingBaseCallIgnoreSync /// FilesDiffer-style policy used elsewhere in this Editor assembly to avoid /// AssetDatabase churn during domain reload. /// - /// The settings asset. May be null — no-op in that case. + /// The settings asset. May be null -- no-op in that case. /// /// When called while Unity is mid-compile or mid-asset-import (e.g., from a /// ScriptableObject.OnValidate that fires during a domain reload), the actual diff --git a/Editor/Settings/DxMessagingSettings.cs b/Editor/Settings/DxMessagingSettings.cs index 45fe54cc..3897a9c8 100644 --- a/Editor/Settings/DxMessagingSettings.cs +++ b/Editor/Settings/DxMessagingSettings.cs @@ -78,9 +78,9 @@ public bool SuppressDomainReloadWarning /// S3: toggling from false back to true pokes /// on the next editor /// tick so the snapshot repopulates without waiting for the user to clear/re-emit warnings - /// or to manually invoke Tools/DxMessaging/Rescan Base-Call Warnings. The round-trip - /// is intentionally indirect (delayCall → RescanNow) to keep this property setter cheap and - /// safe to invoke from any editor context — including OnValidate, where AssetDatabase may + /// or to manually invoke Tools/Wallstop Studios/DxMessaging/Rescan Base-Call Warnings. The round-trip + /// is intentionally indirect (delayCall > RescanNow) to keep this property setter cheap and + /// safe to invoke from any editor context -- including OnValidate, where AssetDatabase may /// be transitional. /// public bool BaseCallCheckEnabled @@ -116,14 +116,14 @@ public bool BaseCallCheckEnabled /// /// Default false. The IL-reflection scanner /// () is the deterministic, - /// always-on primary source — it walks every loaded MessageAwareComponent subclass + /// always-on primary source -- it walks every loaded MessageAwareComponent subclass /// and inspects each override's IL body for the base-call shape, which is reliable across /// Unity 2021 cache hits, incremental compiles, and arbitrary domain-reload sequences. /// /// /// The legacy bridge predates the IL scanner and was the source of the intermittent /// "missing warnings" bug on Unity 2021. Enable it ONLY if you want the union of both - /// data sources — for example, to surface a regression in the IL byte-walker that is + /// data sources -- for example, to surface a regression in the IL byte-walker that is /// already correctly captured by the compile-time analyzer's console output. /// /// diff --git a/Editor/Settings/DxMessagingSettingsProvider.cs b/Editor/Settings/DxMessagingSettingsProvider.cs index b2706112..6e88ac48 100644 --- a/Editor/Settings/DxMessagingSettingsProvider.cs +++ b/Editor/Settings/DxMessagingSettingsProvider.cs @@ -10,7 +10,7 @@ namespace DxMessaging.Editor.Settings /// Project Settings provider for DxMessaging configuration. /// /// - /// Exposes toggles for global diagnostics mode and message buffer size under Project Settings → DxMessaging. + /// Exposes toggles for global diagnostics mode and message buffer size under Project Settings > Wallstop Studios > DxMessaging. /// public sealed class DxMessagingSettingsProvider : SettingsProvider { @@ -41,53 +41,70 @@ UnityEngine.UIElements.VisualElement rootElement /// Search text provided by the Project Settings window. public override void OnGUI(string searchContext) { - SerializedProperty targetsProp = _messagingSettings.FindProperty( - nameof(DxMessagingSettings._diagnosticsTargets) - ); - DiagnosticsTarget currentTargets = (DiagnosticsTarget)targetsProp.enumValueFlag; - DiagnosticsTarget updatedTargets = (DiagnosticsTarget) - EditorGUILayout.EnumFlagsField( + float previousLabelWidth = EditorGUIUtility.labelWidth; + EditorGUIUtility.labelWidth = 240f; + try + { + SerializedProperty targetsProp = _messagingSettings.FindProperty( + nameof(DxMessagingSettings._diagnosticsTargets) + ); + DiagnosticsTarget currentTargets = (DiagnosticsTarget)targetsProp.enumValueFlag; + DiagnosticsTarget updatedTargets = (DiagnosticsTarget) + EditorGUILayout.EnumFlagsField( + new GUIContent( + "Diagnostics Targets", + "Select where global diagnostics should be enabled by default. Combine flags for multiple targets." + ), + currentTargets + ); + if (updatedTargets != currentTargets) + { + targetsProp.enumValueFlag = (int)updatedTargets; + } + EditorGUILayout.PropertyField( + _messagingSettings.FindProperty(nameof(DxMessagingSettings._messageBufferSize)), new GUIContent( - "Diagnostics Targets", - "Select where global diagnostics should be enabled by default. Combine flags for multiple targets." + "Message Buffer Size", + "Number of emissions kept per bus/token when diagnostics mode is active." + ) + ); + EditorGUILayout.PropertyField( + _messagingSettings.FindProperty( + nameof(DxMessagingSettings._suppressDomainReloadWarning) ), - currentTargets + new GUIContent( + "Suppress Domain Reload Warning", + "Disable the warning shown when Enter Play Mode Options skips domain reload; DxMessaging still resets its statics." + ) ); - if (updatedTargets != currentTargets) + + _messagingSettings.ApplyModifiedProperties(); + } + finally { - targetsProp.enumValueFlag = (int)updatedTargets; + EditorGUIUtility.labelWidth = previousLabelWidth; } - EditorGUILayout.PropertyField( - _messagingSettings.FindProperty(nameof(DxMessagingSettings._messageBufferSize)), - new GUIContent( - "Message Buffer Size", - "Number of emissions kept per bus/token when diagnostics mode is active." - ) - ); - EditorGUILayout.PropertyField( - _messagingSettings.FindProperty( - nameof(DxMessagingSettings._suppressDomainReloadWarning) - ), - new GUIContent( - "Suppress Domain Reload Warning", - "Disable the warning shown when Enter Play Mode Options skips domain reload; DxMessaging still resets its statics." - ) - ); - - _messagingSettings.ApplyModifiedProperties(); } - [SettingsProvider] /// /// Factory used by Unity to register the DxMessaging project settings page. /// /// Configured settings provider instance. + [SettingsProvider] public static SettingsProvider CreateDxMessagingSettingsProvider() { - DxMessagingSettingsProvider provider = new("Project/DxMessaging") + DxMessagingSettingsProvider provider = new("Project/Wallstop Studios/DxMessaging") { keywords = new HashSet( - new[] { "DxMessaging", "Diagnostics", "MessageBus", "Targets" } + new[] + { + "DxMessaging", + "Diagnostics", + "MessageBus", + "Targets", + "Wallstop", + "Wallstop Studios", + } ), }; diff --git a/Editor/Testing/MessagingComponentEditorHarness.cs b/Editor/Testing/MessagingComponentEditorHarness.cs index 17accc98..68ce1538 100644 --- a/Editor/Testing/MessagingComponentEditorHarness.cs +++ b/Editor/Testing/MessagingComponentEditorHarness.cs @@ -7,7 +7,7 @@ namespace DxMessaging.Editor.Testing using Core; using Core.Diagnostics; using Core.MessageBus; - using Unity; + using DxMessaging.Unity; using UnityEngine; /// diff --git a/README.md b/README.md index fecd8405..518ec77e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Full Documentation + Full Documentation

@@ -33,7 +33,7 @@ Need install instructions? Try [OpenUPM](https://openupm.com/packages/com.wallst - [30-Second Elevator Pitch](#30-second-elevator-pitch) - [Mental Model: How to Think About DxMessaging](#mental-model-how-to-think-about-dxmessaging) - [Quick Start (5 Minutes)](#quick-start-5-minutes) -- [Dependency Injection (DI) Compatible](#-dependency-injection-di-compatible) +- [Dependency Injection (DI) Compatible](#dependency-injection-di-compatible) - [Is DxMessaging Right for You?](#is-dxmessaging-right-for-you) - [Why DxMessaging](#why-dxmessaging) - [Key Features](#key-features) @@ -86,7 +86,7 @@ DxMessaging is built around one principle: **it gets out of your way**. You have data. You need to pass it around. That's the problem. DxMessaging provides fast, simple primitives as building blocks. You model changes as message types with optional context, using game primitives (GameObjects, components) as that context. -**You don't build your game INTO the messaging system.** It's opt-in and optional—a tool you reach for when it helps. +**You don't build your game INTO the messaging system.** It's opt-in and optional -- a tool you reach for when it helps. ### The Three Message Types: Real-World Analogies @@ -94,11 +94,11 @@ You have data. You need to pass it around. That's the problem. DxMessaging provi Each message type maps to a real-world communication pattern: -#### 1. Untargeted = PA System 📢 +#### 1. Untargeted = PA System ```mermaid flowchart LR - S[Someone] -->|announces| PA[📢 PA System] + S[Someone] -->|announces| PA[PA System] PA --> L1[Listener A] PA --> L2[Listener B] PA --> L3[Listener C] @@ -108,11 +108,11 @@ Announcements with no specific recipient. Everyone who cares can hear it. **Examples:** "The game is paused", "Settings changed", "Scene finished loading" -#### 2. Targeted = Addressed Letter 📬 +#### 2. Targeted = Addressed Letter ```mermaid flowchart LR - S[Sender] -->|"To: Player"| Letter[📬 Message Bus] + S[Sender] -->|"To: Player"| Letter[Message Bus] Letter --> Player[Player receives] Other1[Enemy A] -.->|ignores| Letter Other2[Enemy B] -.->|ignores| Letter @@ -122,18 +122,18 @@ Commands to a specific recipient. Only that entity receives them. **Examples:** "Player, heal for 10 HP", "Door #7, open", "This enemy, take 25 damage" -#### 3. Broadcast = Radio Station 📻 +#### 3. Broadcast = Radio Station ```mermaid flowchart LR - Source[Enemy] -->|"I took damage!"| Radio[📻 Message Bus] + Source[Enemy] -->|"I took damage!"| Radio[Message Bus] Radio --> L1[Damage Numbers UI] Radio --> L2[Achievement Tracker] Radio --> L3[Analytics] Radio --> L4[Combat Log] ``` -Facts emitted by a specific source. No intended recipient—just an origin. Anyone can tune in. +Facts emitted by a specific source. No intended recipient -- just an origin. Anyone can tune in. **Examples:** "This enemy took 25 damage", "The player picked up item X", "This chest opened" @@ -155,24 +155,24 @@ flowchart TD | Question | Untargeted | Targeted | Broadcast | | ----------------------------------- | :--------: | :------: | :-------: | -| Has a specific sender that matters? | ❌ | ❌ | ✅ | -| Has a specific recipient? | ❌ | ✅ | ❌ | -| Is it a command? | ❌ | ✅ | ❌ | -| Is it an observable fact? | Maybe | ❌ | ✅ | -| Is it a global announcement? | ✅ | ❌ | ❌ | +| Has a specific sender that matters? | No | No | Yes | +| Has a specific recipient? | No | Yes | No | +| Is it a command? | No | Yes | No | +| Is it an observable fact? | Maybe | No | Yes | +| Is it a global announcement? | Yes | No | No | -> ⚠️ **Common Mistakes:** +> **Common Mistakes:** > -> - **Forgetting to enable the token** — Messages won't be received. Use `MessageAwareComponent` (auto-enables) or call `Token.Enable()` manually. -> - **Targeting Component when you meant GameObject** — These are distinct registration paths. Component-targeted messages won't reach GameObject-level handlers. -> - **Using Broadcast when you need Targeted** — Broadcasts have no recipient, just an origin. Use Targeted when commanding a specific entity. -> - **Missing `[Dx*Message]` attribute** — The source generator won't process the struct without the marker attribute. +> - **Forgetting to enable the token** -- Messages won't be received. Use `MessageAwareComponent` (auto-enables) or call `Token.Enable()` manually. +> - **Targeting Component when you meant GameObject** -- These are distinct registration paths. Component-targeted messages won't reach GameObject-level handlers. +> - **Using Broadcast when you need Targeted** -- Broadcasts have no recipient, just an origin. Use Targeted when commanding a specific entity. +> - **Missing `[Dx*Message]` attribute** -- The source generator won't process the struct without the marker attribute. > > 📖 See [Troubleshooting](docs/reference/troubleshooting.md) for solutions to these and other issues. -📖 **Want more depth?** See the full [Mental Model documentation](docs/concepts/mental-model.md) for detailed examples, lifecycle patterns, and edge cases. +**Want more depth?** See the full [Mental Model documentation](docs/concepts/mental-model.md) for detailed examples, lifecycle patterns, and edge cases. -📖 **Ready to code?** Jump to [Quick Start](#quick-start-5-minutes) to send your first message! +**Ready to code?** Jump to [Quick Start](#quick-start-5-minutes) to send your first message! --- @@ -242,7 +242,7 @@ No manual unsubscribe needed. Subscriptions are type-safe and lifecycle-managed. --- -## 🔧 Dependency Injection (DI) Compatible +## Dependency Injection (DI) Compatible **Using Zenject, VContainer, or Reflex?** DxMessaging is fully DI-compatible out of the box! @@ -268,26 +268,26 @@ public class PlayerService : IInitializable, IDisposable ### Why use DI + Messaging? -- **DI for construction** — Inject services, repositories, managers via constructors -- **Messaging for events** — Reactive, decoupled communication for gameplay events -- **Combined approach** — Clean architecture with testable, isolated buses +- **DI for construction** -- Inject services, repositories, managers via constructors +- **Messaging for events** -- Reactive, decoupled communication for gameplay events +- **Combined approach** -- Clean architecture with testable, isolated buses #### Automatic integration for -- ✅ **Zenject/Extenject** — Full-featured DI with extensive Unity support -- ✅ **VContainer** — Lightweight, high-performance DI with scoped lifetimes -- ✅ **Reflex** — Minimal API, high-performance dependency injection +- [x] **Zenject/Extenject** -- Full-featured DI with extensive Unity support +- [x] **VContainer** -- Lightweight, high-performance DI with scoped lifetimes +- [x] **Reflex** -- Minimal API, high-performance dependency injection ##### Get started -- [Zenject Integration Guide](docs/integrations/zenject.md) — Complete setup with examples -- [VContainer Integration Guide](docs/integrations/vcontainer.md) — Scoped buses for scene isolation -- [Reflex Integration Guide](docs/integrations/reflex.md) — Minimal, lightweight patterns +- [Zenject Integration Guide](docs/integrations/zenject.md) -- Complete setup with examples +- [VContainer Integration Guide](docs/integrations/vcontainer.md) -- Scoped buses for scene isolation +- [Reflex Integration Guide](docs/integrations/reflex.md) -- Minimal, lightweight patterns ##### Core DI concepts -- [Runtime Configuration](docs/advanced/runtime-configuration.md) — Setting message buses at runtime, re-binding registrations -- [Message Bus Providers](docs/advanced/message-bus-providers.md) — Provider system for design-time and runtime bus configuration +- [Runtime Configuration](docs/advanced/runtime-configuration.md) -- Setting message buses at runtime, re-binding registrations +- [Message Bus Providers](docs/advanced/message-bus-providers.md) -- Provider system for design-time and runtime bus configuration **Not using DI?** No problem. DxMessaging works standalone with zero dependencies. @@ -295,7 +295,7 @@ public class PlayerService : IInitializable, IDisposable ## Is DxMessaging Right for You? -### ✅ Use DxMessaging When +### Use DxMessaging When - **You have cross-system communication** - UI needs to react to gameplay, achievements track events, analytics observe everything - **You're building for scale** - 10+ systems that need to communicate, or growing from prototype to production @@ -303,9 +303,9 @@ public class PlayerService : IInitializable, IDisposable - **You value observability** - Need to debug "what fired when?" or track message flow - **Teams/long-term maintenance** - Multiple developers, or you'll maintain this code for years - **You want decoupling** - When UI classes need references to many game systems -- **You're using DI frameworks** - Compatible with Zenject/VContainer/Reflex (see [DI Compatible](#-dependency-injection-di-compatible)) +- **You're using DI frameworks** - Compatible with Zenject/VContainer/Reflex (see [DI Compatible](#dependency-injection-di-compatible)) -### ❌ Don't Use DxMessaging When +### Don't Use DxMessaging When - **Tiny prototypes/game jams** - If your game is <1000 lines and will be done in a week, C# events are fine - **Simple, local communication** - A single button calling a single method? Just use UnityEvents or direct references @@ -313,7 +313,7 @@ public class PlayerService : IInitializable, IDisposable - **Team is unfamiliar** - Learning curve exists; if the team isn't on board, it won't be used correctly - **You need synchronous return values** - DxMessaging is fire-and-forget; if you need bidirectional request/response, consider other patterns -### ⚠️ Maybe Use DxMessaging (Start Small) +### Maybe Use DxMessaging (Start Small) - **Existing large codebase** - Migrate incrementally: start with new features, refactor old code gradually (see [Migration Guide](docs/guides/migration-guide.md)) - **Small team learning** - Try it for one system (e.g., achievements) before going all-in @@ -332,8 +332,8 @@ flowchart TD Q2 -->|YES| Q3 Q3{Do you need observable, decoupled,
lifecycle-safe messaging?} - Q3 -->|YES| A3["✅ Use DxMessaging"] - Q3 -->|NO| A4["❌ Keep it simple"] + Q3 -->|YES| A3[" Use DxMessaging"] + 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. @@ -353,7 +353,7 @@ public class GameUI : MonoBehaviour { void OnEnable() { GameManager.Instance.OnScoreChanged += UpdateScore; } - // Oops, forgot OnDisable... leak! 💀 + // Oops, forgot OnDisable... leak! } ``` @@ -443,13 +443,13 @@ Open any `MessageAwareComponent` in the Inspector: ```text Message History (last 50): -[12:34:56] HealthChanged (amount: 25) → Priority: 0 -[12:34:55] ItemAdded (id: 42, count: 1) → Priority: 5 -[12:34:54] WaveStarted (wave: 3) → Priority: 0 +[12:34:56] HealthChanged (amount: 25) -> Priority: 0 +[12:34:55] ItemAdded (id: 42, count: 1) -> Priority: 5 +[12:34:54] WaveStarted (wave: 3) -> Priority: 0 Active Registrations: -✓ HealthChanged (5 handlers) -✓ ItemAdded (2 handlers) + HealthChanged (5 handlers) + ItemAdded (2 handlers) ``` **See exactly what fired, when, and who handled it.** No guesswork. @@ -482,13 +482,13 @@ heal.EmitComponentTargeted(playerComponent); #### What you get -- ✅ **Automatic cleanup** - tokens clean up when components are destroyed -- ✅ **Zero coupling** - no SerializeFields, no GetComponent, no direct references -- ✅ **Full visibility** - see message flow in Inspector with timestamps and payloads -- ✅ **Predictable order** - priority-based execution (no more mystery race conditions) -- ✅ **Type-safe** - compile-time guarantees, refactor with confidence -- ✅ **Intercept & validate** - enforce rules before handlers run (clamp damage, block invalid input) -- ✅ **Extension points everywhere** - interceptors, handlers, post-processors with priorities +- [x] **Automatic cleanup** - tokens clean up when components are destroyed +- [x] **Zero coupling** - no SerializeFields, no GetComponent, no direct references +- [x] **Full visibility** - see message flow in Inspector with timestamps and payloads +- [x] **Predictable order** - priority-based execution (no more mystery race conditions) +- [x] **Type-safe** - compile-time guarantees, refactor with confidence +- [x] **Intercept & validate** - enforce rules before handlers run (clamp damage, block invalid input) +- [x] **Extension points everywhere** - interceptors, handlers, post-processors with priorities ## Key Features @@ -517,17 +517,17 @@ Most event systems force you into one pattern. DxMessaging gives you the right t // Untargeted: "Everyone, listen up!" (global announcements) [DxUntargetedMessage] public struct GamePaused { } -// ↳ Perfect for: settings, scene transitions, global state +// -> Perfect for: settings, scene transitions, global state // Targeted: "Hey Player, do this!" (commands to specific entities) [DxTargetedMessage] public struct Heal { public int amount; } -// ↳ Perfect for: UI actions, direct commands, player input +// -> Perfect for: UI actions, direct commands, player input // Broadcast: "I took damage!" (events others can observe) [DxBroadcastMessage] public struct TookDamage { public int amount; } -// ↳ Perfect for: achievements, analytics, UI updates from entities +// -> Perfect for: achievements, analytics, UI updates from entities ``` **Why this matters:** You're not forcing everything through one generic "Event" pattern. Each message type has clear semantics. @@ -606,7 +606,7 @@ void OnDamage(ref TookDamage msg) { ### Built-in Inspector Diagnostics -**The problem with normal events:** "Which event fired? When? Who handled it? In what order?" = 🤷 +**The problem with normal events:** "Which event fired? When? Who handled it? In what order?" = unknown **DxMessaging solution:** Click any `MessageAwareComponent` in the Inspector: @@ -623,16 +623,16 @@ void OnDamage(ref TookDamage msg) { ##### Active Registrations -- ✓ HealthChanged (priority: 0, called: 847 times) -- ✓ ItemAdded (priority: 5, called: 23 times) -- ✓ TookDamage (priority: 10, called: 1,203 times) +- [x] HealthChanged (priority: 0, called: 847 times) +- [x] ItemAdded (priority: 5, called: 23 times) +- [x] TookDamage (priority: 10, called: 1,203 times) #### Real-world debugging scenarios -- "Did my message fire?" → Check history, see timestamp -- "Why didn't my handler run?" → Check registrations, see if it's active -- "What's firing too often?" → Sort by call count -- "What's the execution order?" → Sort by priority +- "Did my message fire?" -> Check history, see timestamp +- "Why didn't my handler run?" -> Check registrations, see if it's active +- "What's firing too often?" -> Sort by call count +- "What's the execution order?" -> Sort by priority **No more:** Setting 50 breakpoints and stepping through code for 30 minutes. @@ -670,74 +670,74 @@ public void TestAchievementSystem() { ## Documentation -- **[📖 Documentation Site](https://wallstop.github.io/DxMessaging/)** - Full searchable documentation -- **[📚 Wiki](https://github.com/wallstop/DxMessaging/wiki)** - Quick reference wiki -- **[📋 Changelog](CHANGELOG.md)** - Version history +- **[Documentation Site](https://wallstop.github.io/DxMessaging/)** - Full searchable documentation +- **[Wiki](https://github.com/wallstop/DxMessaging/wiki)** - Quick reference wiki +- **[Changelog](CHANGELOG.md)** - Version history -### 🎓 Learn +### Learn - **New here?** Start with [Getting Started Guide](docs/getting-started/getting-started.md) (10 min read) - **Want patterns?** See [Common Patterns](docs/guides/patterns.md) - **Deep dive?** Read [Design & Architecture](docs/architecture/design-and-architecture.md) -### 📚 Core Concepts +### Core Concepts -- [Overview](docs/getting-started/overview.md) — What and why -- [Quick Start](docs/getting-started/quick-start.md) — First message in 5 minutes -- [Message Types](docs/concepts/message-types.md) — When to use Untargeted/Targeted/Broadcast -- [Interceptors & Ordering](docs/concepts/interceptors-and-ordering.md) — Control execution flow -- [Listening Patterns](docs/concepts/listening-patterns.md) — All the ways to receive messages +- [Overview](docs/getting-started/overview.md) -- What and why +- [Quick Start](docs/getting-started/quick-start.md) -- First message in 5 minutes +- [Message Types](docs/concepts/message-types.md) -- When to use Untargeted/Targeted/Broadcast +- [Interceptors & Ordering](docs/concepts/interceptors-and-ordering.md) -- Control execution flow +- [Listening Patterns](docs/concepts/listening-patterns.md) -- All the ways to receive messages -### 🔧 Unity Integration +### Unity Integration -- [Unity Integration](docs/guides/unity-integration.md) — MessagingComponent deep dive -- [Targeting & Context](docs/concepts/targeting-and-context.md) — GameObject vs Component -- [Diagnostics](docs/guides/diagnostics.md) — Inspector tools and debugging +- [Unity Integration](docs/guides/unity-integration.md) -- MessagingComponent deep dive +- [Targeting & Context](docs/concepts/targeting-and-context.md) -- GameObject vs Component +- [Diagnostics](docs/guides/diagnostics.md) -- Inspector tools and debugging Important: Inheritance with MessageAwareComponent - If you override lifecycle or registration hooks, call the base method. -- Use `base.RegisterMessageHandlers()` to keep default string‑message registrations. +- Use `base.RegisterMessageHandlers()` to keep default string-message registrations. - Use `base.OnEnable()` / `base.OnDisable()` to preserve token enable/disable. - If you need to opt out of string demos, override `RegisterForStringMessages => false` instead of skipping the base call. -- Don’t hide Unity methods with `new` (e.g., `new void OnEnable()`); always `override` and call `base.*`. +- Don't hide Unity methods with `new` (e.g., `new void OnEnable()`); always `override` and call `base.*`. -### 🧩 DI Framework Integrations +### DI Framework Integrations DxMessaging works standalone (zero dependencies) or with any major DI framework. For detailed setup guides and code examples: -- **[Zenject Integration Guide](docs/integrations/zenject.md)** — Full-featured DI with extensive Unity support -- **[VContainer Integration Guide](docs/integrations/vcontainer.md)** — Lightweight DI with scoped lifetimes for scene isolation -- **[Reflex Integration Guide](docs/integrations/reflex.md)** — Minimal API, high-performance DI +- **[Zenject Integration Guide](docs/integrations/zenject.md)** -- Full-featured DI with extensive Unity support +- **[VContainer Integration Guide](docs/integrations/vcontainer.md)** -- Lightweight DI with scoped lifetimes for scene isolation +- **[Reflex Integration Guide](docs/integrations/reflex.md)** -- Minimal API, high-performance DI #### Core DI concepts -- **[Runtime Configuration](docs/advanced/runtime-configuration.md)** — Setting and overriding message buses at runtime, re-binding registrations -- **[Message Bus Providers](docs/advanced/message-bus-providers.md)** — Provider system and MessageBusProviderHandle for flexible bus configuration +- **[Runtime Configuration](docs/advanced/runtime-configuration.md)** -- Setting and overriding message buses at runtime, re-binding registrations +- **[Message Bus Providers](docs/advanced/message-bus-providers.md)** -- Provider system and MessageBusProviderHandle for flexible bus configuration Each guide includes: -- ✅ Complete setup instructions with installers -- ✅ Multiple usage patterns (plain classes, MonoBehaviours, direct injection) -- ✅ Testing examples with isolated buses -- ✅ Advanced patterns (pooling, scene scopes, signal bridges) +- [x] Complete setup instructions with installers +- [x] Multiple usage patterns (plain classes, MonoBehaviours, direct injection) +- [x] Testing examples with isolated buses +- [x] Advanced patterns (pooling, scene scopes, signal bridges) -See the [🔧 DI Compatible section](#-dependency-injection-di-compatible) above for a quick overview. +See the [DI Compatible section](#dependency-injection-di-compatible) above for a quick overview. -### 🆚 Comparisons +### Comparisons -- [Compare with Other Unity Messaging Frameworks](docs/architecture/comparisons.md) — In-depth comparison with UniRx, MessagePipe, Zenject Signals, C# events, UnityEvents, and more -- [Scriptable Object Architecture (SOA) Compatibility](docs/guides/patterns.md#14-compatibility-with-scriptable-object-architecture-soa) — Migration patterns and interoperability with SOA +- [Compare with Other Unity Messaging Frameworks](docs/architecture/comparisons.md) -- In-depth comparison with UniRx, MessagePipe, Zenject Signals, C# events, UnityEvents, and more +- [Scriptable Object Architecture (SOA) Compatibility](docs/guides/patterns.md#14-compatibility-with-scriptable-object-architecture-soa) -- Migration patterns and interoperability with SOA #### Quick Framework Comparison -| Framework | Best For | Key Strength | Unity Support | Learning Curve | -| ------------------- | --------------------------------- | -------------------------------- | ------------------ | -------------- | -| **DxMessaging** | Unity pub/sub with lifecycle mgmt | Inspector debugging + control | ✅ Built for Unity | ⭐⭐⭐ | -| **UniRx** | Complex event stream transforms | Reactive operators (LINQ) | ✅ Built for Unity | ⭐⭐ | -| **MessagePipe** | High-performance DI messaging | Highest throughput (97M ops/sec) | ✅ Built for Unity | ⭐⭐⭐⭐ | -| **Zenject Signals** | DI-integrated messaging | Zenject ecosystem | ✅ Built for Unity | ⭐⭐ | -| **C# Events** | Simple, local communication | Minimal overhead | ✅ Native C# | ⭐⭐⭐⭐⭐ | +| Framework | Best For | Key Strength | Unity Support | Learning Curve | +| ------------------- | --------------------------------- | -------------------------------- | --------------- | -------------- | +| **DxMessaging** | Unity pub/sub with lifecycle mgmt | Inspector debugging + control | Built for Unity | Moderate | +| **UniRx** | Complex event stream transforms | Reactive operators (LINQ) | Built for Unity | Easy | +| **MessagePipe** | High-performance DI messaging | Highest throughput (97M ops/sec) | Built for Unity | Steep | +| **Zenject Signals** | DI-integrated messaging | Zenject ecosystem | Built for Unity | Easy | +| **C# Events** | Simple, local communication | Minimal overhead | Native C# | Steepest | ##### Choose DxMessaging when you want @@ -761,17 +761,17 @@ See [full comparison](docs/architecture/comparisons.md) for detailed analysis wi > - How to use both systems together (SOs for configs, DxMessaging for events) > - When to keep using ScriptableObjects (immutable design data) -### 📖 Reference +### Reference -- [Install Guide](docs/getting-started/install.md) — All install options (OpenUPM, Git URL, scoped registry, tarball) -- [Glossary](docs/reference/glossary.md) — All terms explained in plain English -- [Quick Reference](docs/reference/quick-reference.md) — Cheat sheet -- [API Reference](docs/reference/reference.md) — Complete API -- [Helpers](docs/reference/helpers.md) — Source generators and utilities -- [FAQ](docs/reference/faq.md) — Common questions +- [Install Guide](docs/getting-started/install.md) -- All install options (OpenUPM, Git URL, scoped registry, tarball) +- [Glossary](docs/reference/glossary.md) -- All terms explained in plain English +- [Quick Reference](docs/reference/quick-reference.md) -- Cheat sheet +- [API Reference](docs/reference/reference.md) -- Complete API +- [Helpers](docs/reference/helpers.md) -- Source generators and utilities +- [FAQ](docs/reference/faq.md) -- Common questions - [Troubleshooting](docs/reference/troubleshooting.md) -### 📦 Full Documentation +### Full Documentation Browse all docs: [Documentation Hub](docs/getting-started/index.md) @@ -835,41 +835,41 @@ For OS-specific benchmark tables generated by PlayMode tests, see [Performance B ### Comparison with Unity Messaging Frameworks -| Feature | DxMessaging | UniRx | MessagePipe | Zenject Signals | -| ------------------------ | ------------------ | ------------------ | ------------------ | ------------------ | -| **Unity Compatibility** | ✅ Built for Unity | ✅ Built for Unity | ✅ Built for Unity | ✅ Built for Unity | -| **Decoupling** | ✅ Full | ✅ Full | ✅ Full | ✅ Full | -| **Lifecycle Safety** | ✅ Auto | ⚠️ Manual | ⚠️ Manual | ⚠️ DI-managed | -| **Execution Order** | ✅ Priority | ❌ None | ❌ None | ❌ None | -| **Type Safety** | ✅ Strong | ✅ Strong | ✅ Strong | ✅ Strong | -| **Inspector Debug** | ✅ Built-in | ❌ No | ❌ No | ❌ No | -| **GameObject Targeting** | ✅ Yes | ❌ No | ❌ No | ❌ No | -| **Global Observers** | ✅ Yes | ❌ No | ❌ No | ❌ No | -| **Interceptors** | ✅ Pipeline | ❌ No | ⚠️ Filters | ❌ No | -| **Post-Processing** | ✅ Dedicated | ❌ No | ⚠️ Filters | ❌ No | -| **Stream Operators** | ❌ No | ✅ Extensive | ❌ No | ⚠️ With UniRx | -| **Performance** | ✅ Good (10-17M) | ✅ Good (18M) | ✅ High (97M) | ⚠️ Moderate (2.5M) | -| **Dependencies** | ✅ None | ⚠️ UniTask | ✅ None | ⚠️ Zenject | +| Feature | DxMessaging | UniRx | MessagePipe | Zenject Signals | +| ------------------------ | --------------- | --------------- | --------------- | --------------- | +| **Unity Compatibility** | Built for Unity | Built for Unity | Built for Unity | Built for Unity | +| **Decoupling** | Full | Full | Full | Full | +| **Lifecycle Safety** | Auto | Manual | Manual | DI-managed | +| **Execution Order** | Priority | None | None | None | +| **Type Safety** | Strong | Strong | Strong | Strong | +| **Inspector Debug** | Built-in | No | No | No | +| **GameObject Targeting** | Yes | No | No | No | +| **Global Observers** | Yes | No | No | No | +| **Interceptors** | Pipeline | No | Filters | No | +| **Post-Processing** | Dedicated | No | Filters | No | +| **Stream Operators** | No | Extensive | No | With UniRx | +| **Performance** | Good (10-17M) | Good (18M) | High (97M) | Moderate (2.5M) | +| **Dependencies** | None | UniTask | None | Zenject | ### Comparison with Traditional Approaches -| Feature | DxMessaging | C# Events | UnityEvents | Static Event Bus | -| ---------------------- | ------------- | ------------ | ------------- | ---------------- | -| **Decoupling** | ✅ Full | ❌ Tight | ⚠️ Hidden | ✅ Yes | -| **Lifecycle Safety** | ✅ Auto | ❌ Manual | ⚠️ Unity-only | ❌ Manual | -| **Execution Order** | ✅ Priority | ❌ Undefined | ❌ Undefined | ❌ Undefined | -| **Type Safety** | ✅ Strong | ✅ Strong | ⚠️ Weak | ⚠️ Weak | -| **Context (Who/What)** | ✅ Rich | ❌ None | ❌ None | ❌ None | -| **Interception** | ✅ Yes | ❌ No | ❌ No | ❌ No | -| **Observability** | ✅ Built-in | ❌ No | ❌ No | ❌ No | -| **Performance** | ✅ Zero-alloc | ✅ Good | ⚠️ Boxing | ✅ Good | +| Feature | DxMessaging | C# Events | UnityEvents | Static Event Bus | +| ---------------------- | ----------- | --------- | ----------- | ---------------- | +| **Decoupling** | Full | Tight | Hidden | Yes | +| **Lifecycle Safety** | Auto | Manual | Unity-only | Manual | +| **Execution Order** | Priority | Undefined | Undefined | Undefined | +| **Type Safety** | Strong | Strong | Weak | Weak | +| **Context (Who/What)** | Rich | None | None | None | +| **Interception** | Yes | No | No | No | +| **Observability** | Built-in | No | No | No | +| **Performance** | Zero-alloc | Good | Boxing | Good | ## Samples Import samples from Package Manager: -- **[Mini Combat](Samples~/Mini%20Combat/README.md)** — Simple combat with Heal/Damage messages -- **[UI Buttons + Inspector](Samples~/UI%20Buttons%20%2B%20Inspector/README.md)** — Interactive diagnostics demo +- **[Mini Combat](Samples~/Mini%20Combat/README.md)** -- Simple combat with Heal/Damage messages +- **[UI Buttons + Inspector](Samples~/UI%20Buttons%20%2B%20Inspector/README.md)** -- Interactive diagnostics demo ## Requirements @@ -893,20 +893,20 @@ Created and maintained by [wallstop studios](https://wallstopstudios.com) ## Links -- 📦 [Package on GitHub](https://github.com/wallstop/DxMessaging) -- 🐛 [Report Issues](https://github.com/wallstop/DxMessaging/issues) -- 📖 [Documentation Site](https://wallstop.github.io/DxMessaging/) -- 📚 [Wiki](https://github.com/wallstop/DxMessaging/wiki) +- [Package on GitHub](https://github.com/wallstop/DxMessaging) +- [Report Issues](https://github.com/wallstop/DxMessaging/issues) +- [Documentation Site](https://wallstop.github.io/DxMessaging/) +- [Wiki](https://github.com/wallstop/DxMessaging/wiki) ## 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. -### 🤖 For AI Agents +### 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 +- **[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 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 2d58bfcd..85c160d5 100644 --- a/Runtime/Core/DataStructure/CyclicBuffer.cs +++ b/Runtime/Core/DataStructure/CyclicBuffer.cs @@ -70,7 +70,7 @@ public void Dispose() { } /// Maximum number of elements retained in the buffer. public int Capacity { get; private set; } - /// Current number of elements stored (≤ ). + /// Current number of elements stored (<= ). public int Count { get; private set; } private readonly List _buffer; diff --git a/Runtime/Core/IMessage.cs b/Runtime/Core/IMessage.cs index dd38bde9..d131c616 100644 --- a/Runtime/Core/IMessage.cs +++ b/Runtime/Core/IMessage.cs @@ -8,9 +8,9 @@ namespace DxMessaging.Core /// /// DxMessaging models three primary categories of messages: /// - /// — global notifications (for example: settings changed). - /// — commands or events directed at a specific target. - /// — events emitted by a specific source and consumable by any listener. + /// -- global notifications (for example: settings changed). + /// -- commands or events directed at a specific target. + /// -- events emitted by a specific source and consumable by any listener. /// /// /// Implementors typically use the generic variants (for example, IUntargetedMessage<T>) on struct messages diff --git a/Runtime/Core/MessageBus/IMessageBus.cs b/Runtime/Core/MessageBus/IMessageBus.cs index b86d9dcf..e3910b59 100644 --- a/Runtime/Core/MessageBus/IMessageBus.cs +++ b/Runtime/Core/MessageBus/IMessageBus.cs @@ -22,9 +22,9 @@ namespace DxMessaging.Core.MessageBus /// /// Message categories: /// - /// — Global notifications. - /// — Directed at a specific . - /// — Emitted from a source for any listener. + /// -- Global notifications. + /// -- Directed at a specific . + /// -- Emitted from a source for any listener. /// /// /// diff --git a/Samples~/DI/README.md b/Samples~/DI/README.md index 559f5f41..f77d6e0b 100644 --- a/Samples~/DI/README.md +++ b/Samples~/DI/README.md @@ -5,7 +5,7 @@ These snippets illustrate how to consume `IMessageRegistrationBuilder` inside co ## Setup 1. Install the relevant container package (Zenject/Extenject, VContainer, or Reflex) into your Unity project. -1. Enable the matching scripting define symbol in **Project Settings › Player › Scripting Define Symbols**: +1. Enable the matching scripting define symbol in **Project Settings > Player > Scripting Define Symbols**: - `ZENJECT_PRESENT` - `VCONTAINER_PRESENT` - `REFLEX_PRESENT` @@ -22,9 +22,9 @@ Each sample shows: - Zenject sample installer: [SampleInstaller.cs](./Zenject/SampleInstaller.cs) - VContainer sample lifetime scope: [SampleLifetimeScope.cs](./VContainer/SampleLifetimeScope.cs) - Reflex sample installer: [SampleInstaller.cs](./Reflex/SampleInstaller.cs) -- Current global message bus provider asset: [GlobalMessageBusProvider.asset](./Providers/GlobalMessageBusProvider.asset) — ScriptableObject that resolves whichever bus is currently configured as global. -- Initial global message bus provider asset: [InitialGlobalMessageBusProvider.asset](./Providers/InitialGlobalMessageBusProvider.asset) — ScriptableObject that always returns the original startup global bus, ignoring later overrides. -- Prefab setup: [MessagingInstallerSample.prefab](./Prefabs/MessagingInstallerSample.prefab) — ready-to-use hierarchy with `MessagingComponentInstaller` configuring a child `MessagingComponent` using the provider asset. Drop it into a scene to see provider-driven wiring without writing setup code. +- Current global message bus provider asset: [GlobalMessageBusProvider.asset](./Providers/GlobalMessageBusProvider.asset) -- ScriptableObject that resolves whichever bus is currently configured as global. +- Initial global message bus provider asset: [InitialGlobalMessageBusProvider.asset](./Providers/InitialGlobalMessageBusProvider.asset) -- ScriptableObject that always returns the original startup global bus, ignoring later overrides. +- Prefab setup: [MessagingInstallerSample.prefab](./Prefabs/MessagingInstallerSample.prefab) -- ready-to-use hierarchy with `MessagingComponentInstaller` configuring a child `MessagingComponent` using the provider asset. Drop it into a scene to see provider-driven wiring without writing setup code. ## Walkthrough diff --git a/Samples~/Mini Combat/README.md b/Samples~/Mini Combat/README.md index 794e07ee..10829c81 100644 --- a/Samples~/Mini Combat/README.md +++ b/Samples~/Mini Combat/README.md @@ -39,7 +39,8 @@ public class Player { public class Player : MessageAwareComponent { void Heal(int amount) { health += amount; - new PlayerHealed(amount).EmitBroadcast(this); + var healed = new PlayerHealed(amount); + healed.EmitBroadcast(this); // Done! UI, audio, analytics all react automatically. } } @@ -97,16 +98,16 @@ Here's what each script does: #### Want to see it work immediately? -1. **Open Package Manager**: Window → Package Manager +1. **Open Package Manager**: Window -> Package Manager 1. **Find DxMessaging** in the package list -1. **Scroll to Samples** section → Find "Mini Combat" → Click **Import** +1. **Scroll to Samples** section -> Find "Mini Combat" -> Click **Import** 1. **Navigate to** Assets/Samples/DxMessaging/.../Mini Combat/ 1. **Open the scene** -1. **Press Play** 🎮 +1. **Press Play** Watch the Console logs as messages flow. -**Pro tip:** Enable diagnostics in the Inspector (MessagingComponent → Enable Diagnostics) to see message traffic in real-time. +**Pro tip:** Enable diagnostics in the Inspector (MessagingComponent -> Enable Diagnostics) to see message traffic in real-time. ### Method 2: Manual Setup in Your Scene @@ -123,21 +124,21 @@ Watch the Console logs as messages flow. For **each GameObject**, you need TWO components: 1. **Add MessagingComponent** (DxMessaging's Unity component) - - Click GameObject → Add Component → "MessagingComponent" + - Click GameObject -> Add Component -> "MessagingComponent" 1. **Add the sample script**: - - **Player** GameObject → Add [Player.cs](./Player.cs) script - - **Enemy** GameObject → Add [Enemy.cs](./Enemy.cs) script - - **UIOverlay** GameObject → Add [UIOverlay.cs](./UIOverlay.cs) script - - **Boot** GameObject → Add [Boot.cs](./Boot.cs) script + - **Player** GameObject -> Add [Player.cs](./Player.cs) script + - **Enemy** GameObject -> Add [Enemy.cs](./Enemy.cs) script + - **UIOverlay** GameObject -> Add [UIOverlay.cs](./UIOverlay.cs) script + - **Boot** GameObject -> Add [Boot.cs](./Boot.cs) script #### Step 3: Run and Observe Press Play! The Boot script will automatically: -1. Send a settings change message → UI updates -1. Send a heal message to the Player → Player's HP increases -1. Trigger Enemy damage → UI displays the damage event +1. Send a settings change message -> UI updates +1. Send a heal message to the Player -> Player's HP increases +1. Trigger Enemy damage -> UI displays the damage event **Pro Tip**: Enable **Diagnostics** on each MessagingComponent in the Inspector to see messages being sent and received in real-time! @@ -149,9 +150,9 @@ Press Play! The Boot script will automatically: #### [Boot.cs](./Boot.cs) sends messages: -1. `VideoSettingsChanged` (Untargeted) → [UIOverlay.cs](./UIOverlay.cs) receives -1. `Heal` (Targeted to Player) → [Player.cs](./Player.cs) receives -1. `TookDamage` (Broadcast from Enemy) → [UIOverlay.cs](./UIOverlay.cs) receives +1. `VideoSettingsChanged` (Untargeted) -> [UIOverlay.cs](./UIOverlay.cs) receives +1. `Heal` (Targeted to Player) -> [Player.cs](./Player.cs) receives +1. `TookDamage` (Broadcast from Enemy) -> [UIOverlay.cs](./UIOverlay.cs) receives ### Understanding Message Types @@ -168,7 +169,7 @@ Press Play! The Boot script will automatically: 1. **Broadcast Messages** (`TookDamage`) - Announced from a GameObject to all listeners - Anyone listening for this message type will receive it - - Like shouting in a room—everyone hears it + - Like shouting in a room -- everyone hears it --- @@ -205,7 +206,7 @@ Each script uses `MessagingComponent.Create(this)` to get a `MessageRegistration ```csharp protected override void RegisterMessageHandlers() { - base.RegisterMessageHandlers(); // ← MUST call this first! + base.RegisterMessageHandlers(); // <- MUST call this first! _ = Token.RegisterUntargeted(OnYourMessage); } ``` @@ -243,7 +244,7 @@ This sample is designed for **learning** and **experimentation**: Ready to understand the implementation details? -👉 **[Read the Complete Walkthrough](Walkthrough.md)** - Step-by-step explanation of how everything works +**[Read the Complete Walkthrough](Walkthrough.md)** - Step-by-step explanation of how everything works ### Need Help @@ -255,60 +256,60 @@ Ready to understand the implementation details? ## Common Pitfalls (Avoid These!) -### ❌ Pitfall #1: Forgetting base.RegisterMessageHandlers() +### Pitfall #1: Forgetting base.RegisterMessageHandlers() ```csharp -// ❌ WRONG - Missing base call +// WRONG - Missing base call protected override void RegisterMessageHandlers() { _ = Token.RegisterUntargeted(OnMessage); } -// ✅ CORRECT - Always call base first +// CORRECT - Always call base first protected override void RegisterMessageHandlers() { base.RegisterMessageHandlers(); // Essential! _ = Token.RegisterUntargeted(OnMessage); } ``` -### ❌ Pitfall #2: Overriding Awake() without calling base +### Pitfall #2: Overriding Awake() without calling base ```csharp -// ❌ WRONG - Token never created, handlers never fire +// WRONG - Token never created, handlers never fire protected override void Awake() { myCustomSetup(); } -// ✅ CORRECT - Call base.Awake() +// CORRECT - Call base.Awake() protected override void Awake() { base.Awake(); // Creates the token! myCustomSetup(); } ``` -### ❌ Pitfall #3: Registering in Start() instead of Awake() +### Pitfall #3: Registering in Start() instead of Awake() ```csharp -// ❌ WRONG - Might miss messages from other components +// WRONG - Might miss messages from other components void Start() { _ = Token.RegisterUntargeted(OnMessage); } -// ✅ CORRECT - Use Awake via RegisterMessageHandlers +// CORRECT - Use Awake via RegisterMessageHandlers protected override void RegisterMessageHandlers() { base.RegisterMessageHandlers(); _ = Token.RegisterUntargeted(OnMessage); } ``` -### ❌ Pitfall #4: Using 'new' instead of 'override' +### Pitfall #4: Using 'new' instead of 'override' ```csharp -// ❌ WRONG - Hides the base method, breaks functionality +// WRONG - Hides the base method, breaks functionality new void OnEnable() { // This doesn't override, it hides! } -// ✅ CORRECT - Always use override +// CORRECT - Always use override protected override void OnEnable() { base.OnEnable(); // Your code here @@ -317,7 +318,7 @@ protected override void OnEnable() { ## Quick Reference -**Enable Diagnostics**: Select MessagingComponent in Inspector → Enable Diagnostics +**Enable Diagnostics**: Select MessagingComponent in Inspector -> Enable Diagnostics **Message Types**: See [Messages.cs](./Messages.cs) for all available messages **Modify Behavior**: Edit handler methods in [Player.cs](./Player.cs), [Enemy.cs](./Enemy.cs), or [UIOverlay.cs](./UIOverlay.cs) **Extend Scripts**: Always call `base.RegisterMessageHandlers()` and other `base.*` methods diff --git a/Samples~/Mini Combat/Walkthrough.md b/Samples~/Mini Combat/Walkthrough.md index 195827b2..49b9ef70 100644 --- a/Samples~/Mini Combat/Walkthrough.md +++ b/Samples~/Mini Combat/Walkthrough.md @@ -4,9 +4,9 @@ ## Who This Is For -- ✅ **You've imported and run the sample** - now you want to understand it -- ✅ **You want to build your own** - need to see the patterns in action -- ✅ **You're debugging something** - need to understand the flow +- [x] **You've imported and run the sample** - now you want to understand it +- [x] **You want to build your own** - need to see the patterns in action +- [x] **You're debugging something** - need to understand the flow **Haven't run the sample yet?** Go back to [the sample README](README.md) and press Play first. Come back when you've seen it work. @@ -66,7 +66,7 @@ Let's understand what each script does: - Only *this specific Player instance* receives the heal - Other Player instances in the scene won't be affected -**Real-world analogy**: Like having your name called in a doctor's office—only you respond. +**Real-world analogy**: Like having your name called in a doctor's office -- only you respond. ### [Enemy.cs](./Enemy.cs) @@ -78,7 +78,7 @@ Let's understand what each script does: - Any listener interested in damage events will hear it - The Enemy doesn't need to know who's listening -**Real-world analogy**: Like ringing a bell—anyone nearby can hear it. +**Real-world analogy**: Like ringing a bell -- anyone nearby can hear it. ### [UIOverlay.cs](./UIOverlay.cs) @@ -137,7 +137,7 @@ Boot sends a `VideoSettingsChanged` message (untargeted). ### Why Untargeted -Settings changes are **global events**—they don't target anyone specifically. Anyone interested in settings can listen. +Settings changes are **global events** -- they don't target anyone specifically. Anyone interested in settings can listen. ### The Code Flow @@ -161,7 +161,7 @@ Settings changes are **global events**—they don't target anyone specifically. ###### Cons: -- No targeting—everyone gets it +- No targeting -- everyone gets it - Can't send to just one specific object --- diff --git a/Samples~/UI Buttons + Inspector/README.md b/Samples~/UI Buttons + Inspector/README.md index 2ef43ad9..d8504282 100644 --- a/Samples~/UI Buttons + Inspector/README.md +++ b/Samples~/UI Buttons + Inspector/README.md @@ -39,7 +39,8 @@ public class PlayButton : MonoBehaviour { ```csharp public class PlayButton : MonoBehaviour { public void OnClick() { - new ButtonClicked("Play").Emit(); + var clicked = new ButtonClicked("Play"); + clicked.Emit(); // Done! Everything reacts automatically. } } @@ -51,12 +52,12 @@ public class PlayButton : MonoBehaviour { ### Want to see it immediately? -1. **Window → Package Manager** -1. **Find DxMessaging** → Scroll to **Samples** -1. **"UI Buttons + Inspector"** → Click **Import** +1. **Window -> Package Manager** +1. **Find DxMessaging** to Scroll to **Samples** +1. **"UI Buttons + Inspector"** to Click **Import** 1. **Navigate to** Assets/Samples/.../UI Buttons + Inspector/ -1. **Open the scene** → **Press Play** -1. **Click the buttons** → Watch Console logs +1. **Open the scene** to **Press Play** +1. **Click the buttons** to Watch Console logs You are now seeing DxMessaging in action. @@ -65,20 +66,20 @@ You are now seeing DxMessaging in action. This sample includes `UIButtonEmitter` and `MessagingObserver` components: - `UIButtonEmitter` lives on a GameObject and exposes a `Click()` method. -- Hook `Click()` to a Unity UI Button’s `OnClick` from the Inspector. +- Hook `Click()` to a Unity UI Button's `OnClick` from the Inspector. - `MessagingObserver` logs incoming messages to the Console during Play Mode. -Steps if you’re wiring your own Button: +Steps if you're wiring your own Button: 1. Add a `UIButtonEmitter` component to any GameObject. 1. Select your UI Button in the scene. -1. In the Button’s `On Click ()` list, click `+`. +1. In the Button's `On Click ()` list, click `+`. 1. Drag the GameObject with `UIButtonEmitter` into the new slot. 1. Choose `UIButtonEmitter -> Click` in the function dropdown. 1. (Optional) Set a friendly `buttonId` in the `UIButtonEmitter` Inspector. -1. Press Play and click the Button — watch the Console logs. +1. Press Play and click the Button -- watch the Console logs. -## What’s Happening Under The Hood +## What's Happening Under The Hood When you click the Button, `UIButtonEmitter.Click()` constructs and emits messages using the simple two-line pattern: @@ -119,7 +120,7 @@ MessageHandler.MessageBus.DiagnosticsMode = true; ## Try These Variations - Multiple buttons: Add more UI Buttons, add more `UIButtonEmitter`s, and give each a unique `buttonId`. -- Targeted vs. untargeted: Notice the sample also sends a targeted `StringMessage` to the emitter’s `gameObject`. +- Targeted vs. untargeted: Notice the sample also sends a targeted `StringMessage` to the emitter's `gameObject`. - Your own message type: Open [Messages.cs](./Messages.cs) to see how `ButtonClicked` is declared using attributes: ```csharp @@ -133,7 +134,7 @@ Add your own partial struct next to it, then emit it from `UIButtonEmitter` usin ## Troubleshooting - Button click does nothing: Confirm the Button's `On Click ()` has the `UIButtonEmitter.Click` function assigned, and you're not editing while in Play Mode (changes revert when you exit Play Mode). -- No logs in Console: Make sure a `MessagingObserver` exists in the scene and the Console isn't filtered. Diagnostics are optional — basic logs should still appear without them. +- No logs in Console: Make sure a `MessagingObserver` exists in the scene and the Console isn't filtered. Diagnostics are optional -- basic logs should still appear without them. - Button doesn't respond: Ensure there's an `EventSystem` in the scene (Unity auto-adds one with UI); make sure the Button is interactable and not occluded by other UI. ## CRITICAL: Inheriting from MessageAwareComponent @@ -146,7 +147,7 @@ Add your own partial struct next to it, then emit it from `UIButtonEmitter` usin ```csharp protected override void RegisterMessageHandlers() { - base.RegisterMessageHandlers(); // ← MUST be first! + base.RegisterMessageHandlers(); // <- MUST be first! _ = Token.RegisterUntargeted(OnMyMessage); } ``` @@ -161,10 +162,10 @@ Add your own partial struct next to it, then emit it from `UIButtonEmitter` usin 1. **Use `override`, never `new`:** ```csharp - // ❌ WRONG - This hides the method, doesn't override it + // WRONG - This hides the method, doesn't override it new void OnEnable() { } - // ✅ CORRECT - This properly overrides + // CORRECT - This properly overrides protected override void OnEnable() { base.OnEnable(); } @@ -180,7 +181,7 @@ Add your own partial struct next to it, then emit it from `UIButtonEmitter` usin ### Common Pitfall Example ```csharp -// ❌ WRONG - Forgot base.RegisterMessageHandlers() +// WRONG - Forgot base.RegisterMessageHandlers() public class MyObserver : MessageAwareComponent { protected override void RegisterMessageHandlers() { // Missing base call! @@ -189,7 +190,7 @@ public class MyObserver : MessageAwareComponent { } // Result: String messages won't work, base class handlers missing -// ✅ CORRECT +// CORRECT public class MyObserver : MessageAwareComponent { protected override void RegisterMessageHandlers() { base.RegisterMessageHandlers(); // Essential! diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/IgnoreListReader.cs b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/IgnoreListReader.cs index 07394d07..200263cd 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/IgnoreListReader.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/IgnoreListReader.cs @@ -16,7 +16,7 @@ namespace WallstopStudios.DxMessaging.SourceGenerators.Analyzers /// The sidecar file is auto-generated from DxMessagingSettings.asset by the Editor /// integration. This reader is tolerant of missing files, blank lines, surrounding whitespace, /// #-style comments, and an optional global:: prefix on each entry (J in the - /// adversarial review — keeps the FQN comparison friendly to copy-paste from compiler output). + /// adversarial review -- keeps the FQN comparison friendly to copy-paste from compiler output). /// /// Results are cached per instance via a /// + pair so repeat callbacks @@ -26,7 +26,7 @@ namespace WallstopStudios.DxMessaging.SourceGenerators.Analyzers /// /// IDE-reuse caveat: under incremental scenarios (Roslyn workspace edits, IDE typing) the host /// may construct a fresh instance per snapshot. The cache is - /// keyed on identity, so a new instance simply re-parses on first Load — correct behaviour, + /// keyed on identity, so a new instance simply re-parses on first Load -- correct behaviour, /// just not maximally cached. Within the same options instance, only one parse ever runs. /// /// diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs index fc4e688e..a65f1ca3 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs @@ -464,11 +464,11 @@ private static bool HasIgnoreAttribute(ISymbol symbol) /// /// Good-faith textual base-call detector. Returns true when any /// InvocationExpressionSyntax anywhere inside 's body - /// targets base.<methodName>(...) — including invocations nested inside + /// targets base.<methodName>(...) -- including invocations nested inside /// lambdas or local functions (DescendantNodes() walks both). /// /// - /// We deliberately do NOT analyze reachability or data-flow — a single textual + /// We deliberately do NOT analyze reachability or data-flow -- a single textual /// base.X() call is treated as compliant. The known false-positive shape /// (helper-indirection: an override that delegates to a private method that itself /// calls base.X()) is documented and tested; users can suppress those with @@ -532,7 +532,7 @@ string methodName /// surfaces a malformed symbol. /// /// - /// Known limitation: this reuses — the same good-faith + /// Known limitation: this reuses -- the same good-faith /// textual check DXMSG006 itself uses. If an ancestor's body literally contains /// base.X() after a return; (unreachable), the chain check will still /// consider it clean, mirroring DXMSG006's policy. This is documented as acceptable: both @@ -612,7 +612,7 @@ out IMethodSymbol firstBrokenLink } /// - /// Walks the containing type's inheritance chain (stopping at — and excluding — + /// Walks the containing type's inheritance chain (stopping at -- and excluding -- /// MessageAwareComponent) looking for the most-derived override of /// RegisterForStringMessages. The most-derived override wins; if it returns /// unconditionally-literal false, the smart-case Info lowering applies. If a @@ -680,7 +680,7 @@ ISymbol member in current.GetMembers(RegisterForStringMessagesPropertyName) /// /// Returns true only when the property body unconditionally yields the literal /// false constant. Anything that introduces a conditional, a non-literal expression, - /// or even one extra return statement returns false — the smart-case Info lowering + /// or even one extra return statement returns false -- the smart-case Info lowering /// must be a high-confidence call (B3 in the adversarial review). /// private static bool PropertyReturnsLiteralFalse(SyntaxNode propertySyntax) diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs index 4a802f45..6a45a642 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallTypeScannerTests.cs @@ -30,7 +30,7 @@ namespace WallstopStudios.DxMessaging.SourceGenerators.Tests; /// /// DXMSG006 attribution when an override does not call base. /// DXMSG007 attribution for new-shadowed lifecycle methods (and the -/// fact that DXMSG009 is conservatively classified the same way — IL alone cannot distinguish +/// fact that DXMSG009 is conservatively classified the same way -- IL alone cannot distinguish /// the two). /// DXMSG010 attribution at the LEAF when an intermediate ancestor's override /// fails to call base. Includes the four-level Leaf : Middle : ddd : MAC case to prove the @@ -39,9 +39,9 @@ namespace WallstopStudios.DxMessaging.SourceGenerators.Tests; /// project-level ignored-types list. /// Skipping abstract types and generic-type definitions (they aren't /// instantiable so their override shape doesn't matter to the runtime overlay). -/// FQN normalisation (Outer+NestedOuter.Nested) so the +/// FQN normalisation (Outer+Nested to Outer.Nested) so the /// snapshot key matches what the analyzer emits. -/// Independence across types — two broken types both appear in the snapshot. +/// Independence across types -- two broken types both appear in the snapshot. /// Healthy chains report nothing (no false positives). /// /// @@ -471,23 +471,26 @@ protected override void OnDisable() } [Test] - public void ScanMethodLevelDxIgnoreMissingBaseCallAttributeExcludesFromSnapshot() + public void ScanMethodLevelDxIgnoreMissingBaseCallAttributeSuppressesOnlyAnnotatedMethod() { - // Spec 2a: the class itself is NOT marked, but a single method has the - // [DxIgnoreMissingBaseCall] attribute. The scanner's method-level check (over the five - // guarded methods) opts the entire type out from the inspector overlay — matching the - // attribute applied to a method on a non-attributed class. + // A method-level suppression applies only to that annotated guarded method. Other + // broken methods on the same type must still appear in the snapshot. Assembly fixture = CompileFixture( """ using DxMessaging.Unity; using DxMessaging.Core.Attributes; - public class IgnoredViaMethod : MessageAwareComponent + public class PartiallyIgnored : MessageAwareComponent { [DxIgnoreMissingBaseCall] protected override void OnEnable() { - // Broken, but the method is opted out — this should suppress the type. + // Broken, but explicitly suppressed at the method level. + } + + protected override void OnDisable() + { + // Broken and not suppressed. } } """ @@ -496,7 +499,43 @@ protected override void OnEnable() Dictionary snapshot = BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); - Assert.That(snapshot, Is.Empty); + Assert.That(snapshot, Contains.Key("PartiallyIgnored")); + BaseCallTypeScannerCore.ScanEntry entry = snapshot["PartiallyIgnored"]; + Assert.That(entry.MissingBaseFor, Is.EquivalentTo(new[] { "OnDisable" })); + Assert.That(entry.DiagnosticIds, Is.EquivalentTo(new[] { "DXMSG006" })); + } + + [Test] + public void ScanAllBrokenMethodsSuppressedByMethodLevelAttributesProducesNoEntry() + { + // If every broken method is method-level suppressed, the scanner should produce no row + // because MissingBaseFor is empty after suppression filtering. + Assembly fixture = CompileFixture( + """ + using DxMessaging.Unity; + using DxMessaging.Core.Attributes; + + public class AllSuppressed : MessageAwareComponent + { + [DxIgnoreMissingBaseCall] + protected override void OnEnable() + { + // Broken, but explicitly suppressed. + } + + [DxIgnoreMissingBaseCall] + protected override void OnDisable() + { + // Broken, but explicitly suppressed. + } + } + """ + ); + + Dictionary snapshot = + BaseCallTypeScannerCore.Scan(EnumerateMacSubclasses(fixture), null); + + Assert.That(snapshot, Does.Not.ContainKey("AllSuppressed")); } [Test] diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs index 5c263557..0a793146 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/CompilationMessageHarvestTests.cs @@ -10,15 +10,15 @@ namespace WallstopStudios.DxMessaging.SourceGenerators.Tests; /// Regression coverage for the dual-source console harvester. Two layers are covered here: /// /// The Unity 2021 CompilerMessage parse path (fed through -/// ) — the wire format the harvester sees on +/// ) -- the wire format the harvester sees on /// 2021 builds. /// The per-assembly merge + retirement bookkeeping in -/// — the most novel slice of the dual-source design and +/// -- the most novel slice of the dual-source design and /// the part most likely to silently corrupt the snapshot if regressed. /// /// /// -/// The harvester itself lives in the Editor assembly (which dotnet-test cannot load — it depends +/// The harvester itself lives in the Editor assembly (which dotnet-test cannot load -- it depends /// on UnityEditor types), so we test the slices that ARE pure: feeding synthetic /// `CompilerMessage`-shaped log strings through the parser, and exercising the aggregator /// directly via its public static API. diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DocsSnippetCompilationTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DocsSnippetCompilationTests.cs index b0fb68d4..23bdbb81 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DocsSnippetCompilationTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/DocsSnippetCompilationTests.cs @@ -15,6 +15,83 @@ public sealed class DocsSnippetCompilationTests "CS0106", // modifier not valid in script (partial snippets showing members only) "CS1001", // identifier expected (intentionally elided samples) "CS8803", // top-level statements mixed with declarations (visual guide style snippets) + // The following are tolerated because doc snippets routinely reference + // types and members that exist in the real assembly but are out of the + // test compilation's scope (only the SharedStubs subset is wired in). + "CS0103", // The name '...' does not exist in the current context + "CS0246", // The type or namespace name '...' could not be found + "CS0234", // The type or namespace name '...' does not exist in the namespace + "CS0117", // '...' does not contain a definition for '...' + "CS1061", // '...' does not contain a definition for '...' / no accessible extension method + "CS0411", // type arguments cannot be inferred (calling extensions whose stubs aren't loaded) + "CS7036", // required parameter has no argument (constructor signature differs in stubs) + "CS1503", // argument cannot convert (placeholder identifiers cause spurious overload mismatches) + "CS1729", // type does not contain a constructor that takes N arguments + "CS0535", // does not implement interface member (samples often skip method bodies) + "CS0738", // does not implement interface member with specified signature + "CS1955", // non-invocable member used like a method + "CS0119", // expression is not valid in given context (placeholder usage) + "CS0118", // is a namespace but used like a type (placeholder issues) + "CS0021", // cannot apply indexing to expression of type + "CS0019", // operator cannot be applied to operands (placeholder types) + "CS1503", // argument conversion (duplicate but kept for clarity) + "CS0029", // cannot implicitly convert (placeholder vars) + "CS0266", // cannot implicitly convert + "CS1660", // cannot convert lambda to non-delegate + "CS1662", // cannot convert lambda to delegate type + "CS1593", // delegate parameter mismatch + "CS0120", // object reference required for non-static (script semantics) + "CS0122", // is inaccessible due to protection level + "CS0136", // local declared in enclosing local scope (samples reuse names) + "CS0029", // duplicate + "CS0070", // event can only appear on the left-hand side of += or -= (samples may show event) + "CS0173", // type of conditional expression cannot be determined + "CS8019", // unnecessary using directive (caused by prepended usings) + // The following appear because snippets are compiled in script-mode + // (SourceCodeKind.Script). Script-mode is retained so the harness can + // catch additional semantic errors on snippets that bind cleanly, but + // the wrapping context breaks ordinary class-body samples in ways + // that are not real bugs in the documentation. Note: CS1612 is never + // produced by this stub setup -- the broken "new X().Emit()" pattern + // surfaces as CS1510 (which we intentionally ignore, see below), so + // semantic detection of that bug class is delegated to the textual + // pattern lint in scripts/validate-doc-code-patterns.js. + "CS0027", // keyword 'this' is not available in the current context + "CS0115", // no suitable method found to override (snippet defines class without true base wired) + "CS1512", // 'base' is not available in the current context + "CS1520", // method must have a return type (parses ambiguously in script) + "CS1002", // ; expected (top-level expression-bodied members) + "CS1525", // invalid expression term (top-level snippet quirks) + "CS0116", // namespace cannot directly contain members (script wrapping) + "CS1022", // type or namespace definition or end-of-file expected + "CS1513", // } expected (partial snippets) + "CS1514", // { expected + "CS8124", // tuple element name not preceded by ',' (script-mode quirks) + // Stub-mismatch / placeholder-related diagnostics. These primarily + // surface because the test compilation does not load the full runtime + // assembly; doc snippets reference real APIs (RegisterUntargeted, + // [DxAutoConstructor]-generated constructors, etc.) whose stubs are + // intentionally minimal in GeneratorTestUtilities.SharedStubs. The + // canonical defense against the "new X().Emit()" bug class is + // scripts/validate-doc-code-patterns.js (which performs a textual + // pattern check that is not subject to stub coverage gaps). The + // compilation test cannot reliably catch that specific bug here: the + // stub setup produces CS1510 (not CS1612) for the broken pattern, + // and CS1510 must remain in the ignore list to suppress unrelated + // false-positives on legitimate snippets that reference unstubbed + // ref-returning members. + "CS0102", // type already contains a definition (partial declarations re-merged in script) + "CS0111", // type already defines member with same parameter types + "CS0260", // missing partial modifier on declaration + "CS0308", // non-generic type 'X' cannot be used with type arguments (stub interface) + "CS0315", // type cannot be used as type parameter (interface constraint via stub gap) + "CS0453", // type must be non-nullable value type (placeholder strings as messages) + "CS0501", // method must declare body (partial members not generated) + "CS0579", // duplicate attribute (auto-generated partials would dedupe in real build) + "CS1510", // ref or out value must be assignable. Kept in ignore list because stub-only compilation produces CS1510 noise on legitimate snippets that touch unstubbed ref-returning APIs; this means the harness CANNOT catch the "new X().Emit()" struct-rvalue bug and that class is enforced solely by scripts/validate-doc-code-patterns.js (see struct-emit-temporary rule). + "CS1739", // overload doesn't have parameter named (placeholder constructors) + "CS0305", // generic type requires N type arguments (placeholder collections) + "CS0104", // ambiguous reference (UnityEngine.Object vs System.Object script collision) }; // Compiled regex patterns for API signature detection @@ -98,8 +175,23 @@ public void DocumentationSnippetsCompile(string markdownPath, string snippet) $"Snippet extracted from {markdownPath} should not be empty." ); + // Use the semantic-aware CompileDocSnippet wrapper so the test catches + // semantic errors that survive stub-only compilation: type errors, + // return-type mismatches, and the subset of identifier diagnostics + // (CS0103 etc.) that are NOT in the ignore list. Many doc snippets + // reference symbols not wired into the test compilation, so + // IgnoredSnippetDiagnosticIds tolerates the expected "missing + // identifier / missing type / overload mismatch" family. + // + // IMPORTANT: this harness does NOT catch the "new StructMessage().Emit()" + // bug class. The stub setup produces CS1510 (not CS1612) for that + // pattern, and CS1510 must remain ignored to keep legitimate snippets + // that touch unstubbed ref-returning members from triggering false + // positives. The textual lint scripts/validate-doc-code-patterns.js + // (struct-emit-temporary rule) is the canonical defense for that + // class of bug. var diagnostics = GeneratorTestUtilities - .ParseSnippet(snippet) + .CompileDocSnippet(snippet) .Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error) .ToArray(); @@ -140,6 +232,323 @@ string markdownPath in Directory.GetFiles(docsRoot, "*.md", SearchOption.AllDire } } + // ---- 3.4.2: inline-code-from-tables compilation ---------------------- + + [TestCaseSource(nameof(GetInlineTableSnippets))] + public void InlineTableSnippetsCompile(string markdownPath, string snippet) + { + Assert.That( + snippet, + Is.Not.Empty, + $"Inline table snippet extracted from {markdownPath} should not be empty." + ); + + // Inline snippets are wrapped in a method body so script-mode parsing + // is consistent with the doc author's intent (a single statement or + // expression, not a top-level type declaration). + string wrapped = "void __InlineProbe() {\n" + snippet + "\n}\n"; + + var diagnostics = GeneratorTestUtilities + .CompileDocSnippet(wrapped) + .Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error) + .ToArray(); + + var actionableDiagnostics = diagnostics + .Where(d => !IgnoredSnippetDiagnosticIds.Contains(d.Id)) + .ToArray(); + + if (actionableDiagnostics.Length > 0) + { + string message = string.Join( + System.Environment.NewLine, + actionableDiagnostics.Select(d => d.ToString()) + ); + Assert.Fail( + $"Inline table snippet in {markdownPath} failed to compile:" + + $"{System.Environment.NewLine}snippet: {snippet}" + + $"{System.Environment.NewLine}{message}" + ); + } + } + + private static IEnumerable GetInlineTableSnippets() + { + string docsRoot = ResolveDocsRoot(); + int testIndex = 0; + foreach ( + string markdownPath in Directory.GetFiles(docsRoot, "*.md", SearchOption.AllDirectories) + ) + { + foreach (string snippet in ExtractInlineTableCodeSnippets(markdownPath)) + { + if (!IsCompilableInlineSnippet(snippet)) + { + continue; + } + + yield return new TestCaseData(markdownPath, snippet).SetName( + $"{Path.GetFileName(markdownPath)} inline #{testIndex++}" + ); + } + } + } + + private static IEnumerable ExtractInlineTableCodeSnippets(string markdownPath) + { + string[] lines = File.ReadAllLines(markdownPath); + bool inFence = false; + foreach (string rawLine in lines) + { + string line = rawLine.TrimEnd(); + if (line.StartsWith("```") || line.StartsWith("~~~")) + { + inFence = !inFence; + continue; + } + if (inFence) + { + continue; + } + // Only parse table rows. Pure prose lines may contain backticks + // but we want to keep this focused on the documented gotcha space: + // table cells are where the historical "new X().Emit()" failures + // hid because they slipped past the fenced-block extractor. + if (line.IndexOf('|') < 0) + { + continue; + } + foreach (string snippet in ExtractInlineCodeSpans(line)) + { + yield return snippet; + } + } + } + + private static IEnumerable ExtractInlineCodeSpans(string line) + { + int i = 0; + while (i < line.Length) + { + // Skip non-backtick chars. + if (line[i] != '`') + { + i++; + continue; + } + // Count opening backticks. + int openStart = i; + int tickCount = 0; + while (i < line.Length && line[i] == '`') + { + tickCount++; + i++; + } + // Look for matching closing run of identical length. + int searchFrom = i; + while (searchFrom < line.Length) + { + int closeStart = line.IndexOf('`', searchFrom); + if (closeStart < 0) + break; + int runLen = 0; + int j = closeStart; + while (j < line.Length && line[j] == '`') + { + runLen++; + j++; + } + if (runLen == tickCount) + { + string content = line.Substring( + openStart + tickCount, + closeStart - openStart - tickCount + ); + yield return content.Trim(); + i = j; + break; + } + searchFrom = j; + } + } + } + + private static bool IsCompilableInlineSnippet(string snippet) + { + if (string.IsNullOrWhiteSpace(snippet)) + return false; + // Filter out short fragments (bare type names, single identifiers). + if (snippet.Length < 4) + return false; + // Must look like a statement: contain an opening paren AND end with ')' or ';'. + if (snippet.IndexOf('(') < 0) + return false; + string trimmed = snippet.TrimEnd(); + if (!trimmed.EndsWith(")") && !trimmed.EndsWith(";")) + return false; + // Skip API signatures (uses the same heuristic as fenced blocks). + if (IsApiSignatureDocumentation(snippet)) + return false; + // Skip snippets that look like type-name placeholders. + if (snippet.IndexOf(' ') < 0 && snippet.IndexOf('.') < 0) + return false; + return true; + } + + // ---- 3.4.3: XML doc block compilation ------------------------- + + [TestCaseSource(nameof(GetXmlDocCodeBlocks))] + public void XmlDocCodeBlocksCompile(string sourcePath, string snippet) + { + Assert.That( + snippet, + Is.Not.Empty, + $"XML snippet extracted from {sourcePath} should not be empty." + ); + + var diagnostics = GeneratorTestUtilities + .CompileDocSnippet(snippet) + .Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error) + .ToArray(); + + var actionableDiagnostics = diagnostics + .Where(d => !IgnoredSnippetDiagnosticIds.Contains(d.Id)) + .ToArray(); + + if (actionableDiagnostics.Length > 0) + { + string message = string.Join( + System.Environment.NewLine, + actionableDiagnostics.Select(d => d.ToString()) + ); + Assert.Fail( + $"XML snippet in {sourcePath} failed to compile:" + + $"{System.Environment.NewLine}{message}" + ); + } + } + + private static readonly string[] CSharpScanRoots = new[] + { + "Runtime", + "Editor", + "SourceGenerators", + }; + + private static IEnumerable GetXmlDocCodeBlocks() + { + string repoRoot = ResolveRepoRoot(); + int testIndex = 0; + foreach (string root in CSharpScanRoots) + { + string absRoot = Path.Combine(repoRoot, root); + if (!Directory.Exists(absRoot)) + continue; + foreach ( + string sourcePath in Directory.GetFiles( + absRoot, + "*.cs", + SearchOption.AllDirectories + ) + ) + { + // Skip generated/cache directories. + string normalized = sourcePath.Replace('\\', '/'); + if ( + normalized.Contains("/obj/") + || normalized.Contains("/bin/") + || normalized.Contains("/.artifacts/") + ) + { + continue; + } + foreach (string snippet in ExtractXmlDocCodeBlocks(sourcePath)) + { + if (ShouldSkipSnippet(snippet)) + continue; + if (snippet.Length < 4) + continue; + yield return new TestCaseData(sourcePath, snippet).SetName( + $"{Path.GetFileName(sourcePath)} xmldoc #{testIndex++}" + ); + } + } + } + } + + private static IEnumerable ExtractXmlDocCodeBlocks(string sourcePath) + { + string content = File.ReadAllText(sourcePath); + // Strip the leading `///` from each line first, joining adjacent doc + // comment lines into a single text block. Then locate ... + // and ... regions inside that text. + var stripped = new System.Text.StringBuilder(content.Length); + foreach (string rawLine in content.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n')) + { + string trim = rawLine.TrimStart(); + if (trim.StartsWith("///")) + { + stripped.AppendLine(trim.Substring(3).TrimStart()); + } + else + { + stripped.AppendLine(); + } + } + string text = stripped.ToString(); + + int searchFrom = 0; + while (searchFrom < text.Length) + { + int openIdx = text.IndexOf("', openIdx); + if (openClose < 0) + break; + int closeIdx = text.IndexOf("", openClose, StringComparison.OrdinalIgnoreCase); + if (closeIdx < 0) + { + searchFrom = openClose + 1; + continue; + } + string body = text.Substring(openClose + 1, closeIdx - openClose - 1); + yield return DecodeXmlEntities(body).Trim(); + searchFrom = closeIdx + "".Length; + } + } + + private static string DecodeXmlEntities(string s) + { + return s.Replace("<", "<") + .Replace(">", ">") + .Replace("&", "&") + .Replace(""", "\"") + .Replace("'", "'"); + } + + private static string ResolveRepoRoot() + { + string current = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(current)) + { + if ( + Directory.Exists(Path.Combine(current, "Runtime")) + && Directory.Exists(Path.Combine(current, "Editor")) + && File.Exists(Path.Combine(current, "package.json")) + ) + { + return current; + } + string parent = Path.GetDirectoryName(current) ?? string.Empty; + if (string.IsNullOrEmpty(parent)) + break; + current = parent; + } + throw new DirectoryNotFoundException( + "Unable to locate the repository root from the current test directory." + ); + } + private static string ExtractFirstCodeBlock(string markdownPath, string infoString) { return ExtractCodeBlocks(markdownPath, infoString).FirstOrDefault() ?? string.Empty; @@ -366,14 +775,6 @@ private static bool ShouldSkipSnippet(string snippet) return true; } - foreach (char c in snippet) - { - if (c > 127 && !char.IsWhiteSpace(c)) - { - return true; - } - } - if (IsApiSignatureDocumentation(snippet)) { return true; diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTestUtilities.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTestUtilities.cs index 664e8ca6..3106cde1 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTestUtilities.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/GeneratorTestUtilities.cs @@ -78,6 +78,26 @@ internal static ImmutableArray CompileSnippet(string userSource) return compilation.GetDiagnostics(); } + /// + /// Compiles as a snippet with the standard + /// DxMessaging documentation usings prepended. Used by the doc-sample + /// compilation tests where snippets are expected to use these namespaces + /// implicitly. + /// + internal static ImmutableArray CompileDocSnippet(string userSource) + { + const string usings = + @"using System; +using System.Collections.Generic; +using DxMessaging.Core; +using DxMessaging.Core.Attributes; +using DxMessaging.Core.Messages; +using DxMessaging.Unity; +using UnityEngine; +"; + return CompileSnippet(usings + userSource); + } + internal static ImmutableArray ParseSnippet(string userSource) { SyntaxTree userTree = CSharpSyntaxTree.ParseText(userSource, ParseOptions); @@ -277,6 +297,63 @@ public interface ITargetedMessage { } public interface IBroadcastMessage { } } +namespace DxMessaging.Core.Extensions +{ + using DxMessaging.Core; + using DxMessaging.Core.Messages; + + /// + /// Minimal stubs of the Emit shorthands. The "this ref TMessage" signature + /// is the load-bearing detail: with these stubs in place, the pattern + /// "new X().Emit()" will not compile (CS1612 / CS1510 depending on + /// context) -- which is the bug class the doc-snippet compilation tests + /// and validate-doc-code-patterns.js exist to catch. + /// + public static class MessageExtensions + { + // The "this ref TMessage" signature is the load-bearing detail. The + // "where struct" constraint is also kept (to mirror the real API), + // but interface constraints are intentionally dropped: doc snippets + // routinely omit explicit "I*Message" markers and rely on the + // [Dx*Message] attributes to auto-generate them. The auto-generators + // do not run inside this test compilation, so requiring the interface + // would surface as spurious CS0315 / CS0453 failures rather than the + // real "struct emit on a temporary" bug we want to catch (CS1612 / + // CS1510). + public static void Emit(this ref TMessage message) + where TMessage : struct { } + + public static void EmitAt(this ref TMessage message, InstanceId target) + where TMessage : struct { } + + public static void EmitFrom(this ref TMessage message, InstanceId source) + where TMessage : struct { } + + public static void EmitUntargeted(this ref TMessage message) + where TMessage : struct { } + + public static void EmitTargeted(this ref TMessage message, InstanceId target) + where TMessage : struct { } + + public static void EmitBroadcast(this ref TMessage message, InstanceId source) + where TMessage : struct { } + + public static void EmitGameObjectTargeted(this ref TMessage message, UnityEngine.GameObject target) + where TMessage : struct { } + + public static void EmitComponentBroadcast(this ref TMessage message, UnityEngine.Component source) + where TMessage : struct { } + } +} + +namespace DxMessaging.Core +{ + public readonly struct InstanceId + { + public InstanceId(int id) { } + } +} + namespace UnityEngine { public struct Color @@ -284,7 +361,10 @@ public struct Color public static readonly Color green = default; } - public class MonoBehaviour { } + public class Object { } + public class GameObject : Object { } + public class Component : Object { public GameObject gameObject => default; } + public class MonoBehaviour : Component { } } namespace DxMessaging.Unity diff --git a/Tests/Editor/MessageAwareComponentFallbackEditorTests.cs b/Tests/Editor/MessageAwareComponentFallbackEditorTests.cs new file mode 100644 index 00000000..80f9e2a3 --- /dev/null +++ b/Tests/Editor/MessageAwareComponentFallbackEditorTests.cs @@ -0,0 +1,312 @@ +#if UNITY_EDITOR && UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Editor +{ + using System; + using System.Collections.Generic; + using System.Reflection; + using DxMessaging.Editor.CustomEditors; + using DxMessaging.Editor.Settings; + using DxMessaging.Unity; + using NUnit.Framework; + using UnityEditor; + using UnityEngine; + using Object = UnityEngine.Object; + + [TestFixture] + public sealed class MessageAwareComponentFallbackEditorTests + { + public enum OverlayTargetScenario + { + NullObject, + GameObject, + Transform, + } + + private readonly List _createdObjects = new(); + private readonly List _createdEditors = new(); + private bool _previousBaseCallCheckEnabled; + private bool _baseCallCheckOverridden; + + [SetUp] + public void SetUp() + { + // Disable diagnostic noise so the overlay's BuildAndRenderOverlay returns early via + // the gating phase (no EditorGUILayout calls). Stale entries from a previous session + // could otherwise drive the body into shape != 0 and pollute the body assertion. + // + // We reset the override flag BEFORE the throwing call so that if GetOrCreateSettings + // throws, TearDown sees no override and skips restoration. We capture the previous + // value into the field BEFORE marking the override as active, so a throw on the + // capture or the subsequent write still leaves TearDown with the correct + // captured-vs-overridden state. + _baseCallCheckOverridden = false; + DxMessagingSettings settings = DxMessagingSettings.GetOrCreateSettings(); + _previousBaseCallCheckEnabled = settings._baseCallCheckEnabled; + _baseCallCheckOverridden = true; + settings._baseCallCheckEnabled = false; + } + + [TearDown] + public void TearDown() + { + foreach (UnityEditor.Editor editor in _createdEditors) + { + if (editor != null) + { + Object.DestroyImmediate(editor); + } + } + _createdEditors.Clear(); + + foreach (Object instance in _createdObjects) + { + if (instance != null) + { + Object.DestroyImmediate(instance); + } + } + _createdObjects.Clear(); + + if (_baseCallCheckOverridden) + { + DxMessagingSettings settings = DxMessagingSettings.GetOrCreateSettings(); + settings._baseCallCheckEnabled = _previousBaseCallCheckEnabled; + _baseCallCheckOverridden = false; + } + } + + [Test] + public void FallbackEditorMustRegisterAsPrimaryNonFallbackEditorForChildClasses() + { + // The [CustomEditor] attribute MUST register this editor as a PRIMARY (non-fallback) + // editor for every MessageAwareComponent subclass. Earlier attempts to use + // isFallback = true caused Unity to skip our editor entirely and pick GenericInspector + // instead — which dropped the missing-base-call HelpBox warnings on every component + // because Unity 2021's Editor.finishedDefaultHeaderGUI hook does not reliably fire for + // MonoBehaviour subclasses that have no registered [CustomEditor]. + // + // The "empty vertical gap below the header" bug that motivated the isFallback attempt + // is solved orthogonally: OnInspectorGUI calls Editor.DrawDefaultInspector(), so the + // body matches Unity's GenericInspector exactly (including the disabled "Script" row + // every MonoBehaviour shows). There is no missing row to leave a gap. + // + // CustomEditor.isFallback has been a public field on UnityEditor.CustomEditor since + // at least Unity 2017.2 — we read it directly without reflection. The contract: + // isFallback MUST be false (default), editorForChildClasses MUST be true. + Type fallbackType = typeof(MessageAwareComponentFallbackEditor); + object[] attributes = fallbackType.GetCustomAttributes( + typeof(CustomEditor), + inherit: false + ); + Assert.That( + attributes.Length, + Is.EqualTo(1), + "MessageAwareComponentFallbackEditor must declare exactly one [CustomEditor] attribute." + ); + + CustomEditor customEditor = (CustomEditor)attributes[0]; + Assert.That( + customEditor.isFallback, + Is.False, + "MessageAwareComponentFallbackEditor must register with isFallback = false (the default). Setting isFallback = true causes Unity to prefer GenericInspector for every MessageAwareComponent subclass, which silently drops the missing-base-call HelpBox warnings — the regression this test was added to prevent." + ); + + FieldInfo editorForChildClassesField = typeof(CustomEditor).GetField( + "m_EditorForChildClasses", + BindingFlags.Instance | BindingFlags.NonPublic + ); + Assert.That( + editorForChildClassesField, + Is.Not.Null, + "Unity's CustomEditor.m_EditorForChildClasses field is missing — Unity may have renamed the field; update this test." + ); + bool editorForChildClasses = (bool)editorForChildClassesField.GetValue(customEditor); + Assert.That( + editorForChildClasses, + Is.True, + "MessageAwareComponentFallbackEditor must register with editorForChildClasses: true so that ALL MessageAwareComponent subclasses get the warning HelpBox." + ); + } + + [Test] + public void FallbackEditorIsSelectedForSubclassWithoutCustomEditor() + { + // End-to-end check: Unity must select our editor for MessageAwareComponent + // subclasses that have no user-defined [CustomEditor]. With isFallback = false and + // editorForChildClasses = true, our editor is the most-specific match for any + // MessageAwareComponent subclass that has no dedicated user editor. + GameObject host = CreateTrackedObject("FallbackEditorSelectionHost"); + EmptyMessageAwareComponentForFallbackTest component = + host.AddComponent(); + Assert.That(component, Is.Not.Null, "Failed to attach test subclass to host."); + + UnityEditor.Editor editor = UnityEditor.Editor.CreateEditor(component); + _createdEditors.Add(editor); + + Assert.That( + editor, + Is.Not.Null, + "Editor.CreateEditor returned null for the empty subclass — Unity could not resolve any editor." + ); + Assert.That( + editor, + Is.InstanceOf(), + "Unity must select MessageAwareComponentFallbackEditor for a MessageAwareComponent subclass with no user-defined [CustomEditor]." + ); + } + + [TestCase(typeof(EmptyMessageAwareComponentForFallbackTest))] + [TestCase(typeof(SerializedFieldMessageAwareComponentForFallbackTest))] + public void OverlayDoesNotRenderWhenBaseCallCheckIsDisabled(Type componentType) + { + // This test intentionally avoids calling Editor.OnInspectorGUI directly: invoking + // DrawDefaultInspector() outside Unity's active IMGUI cycle throws inside + // GUILayoutUtility. Instead we assert the overlay body itself short-circuits with + // shape == 0 (returns false, emits no UI) when the base-call check is disabled. + MessageAwareComponent component = CreateTrackedMessageAwareComponent( + $"FallbackEditorBodyHost_{componentType.Name}", + componentType + ); + + bool rendered = InvokeBuildAndRenderOverlay(component); + Assert.That( + rendered, + Is.False, + "BuildAndRenderOverlay must return false (render nothing) when base-call checks are disabled. " + + $"ComponentType={componentType.FullName}; GUI context: {DescribeCurrentGuiContext()}." + ); + + Assert.DoesNotThrow( + () => MessageAwareComponentInspectorOverlay.RenderInsideOnInspectorGUI(component), + "RenderInsideOnInspectorGUI must remain a no-op when overlay rendering is gated off. " + + $"ComponentType={componentType.FullName}; GUI context: {DescribeCurrentGuiContext()}." + ); + } + + [TestCase(OverlayTargetScenario.NullObject)] + [TestCase(OverlayTargetScenario.GameObject)] + [TestCase(OverlayTargetScenario.Transform)] + public void RenderInsideOnInspectorGUIGracefullyNoOpsForUnsupportedTargets( + OverlayTargetScenario scenario + ) + { + Object target = CreateTargetForScenario(scenario); + + Assert.DoesNotThrow( + () => MessageAwareComponentInspectorOverlay.RenderInsideOnInspectorGUI(target), + "RenderInsideOnInspectorGUI must no-op for unsupported targets rather than throwing. " + + $"Scenario={scenario}; GUI context: {DescribeCurrentGuiContext()}." + ); + } + + private MessageAwareComponent CreateTrackedMessageAwareComponent(string hostName, Type type) + { + Assert.That(type, Is.Not.Null, "Component type test input must not be null."); + Assert.That( + typeof(MessageAwareComponent).IsAssignableFrom(type), + Is.True, + $"Test input type must derive from {nameof(MessageAwareComponent)}. Actual: {type.FullName}." + ); + + GameObject host = CreateTrackedObject(hostName); + Component component = host.AddComponent(type); + Assert.That( + component, + Is.Not.Null, + $"Failed to attach {type.FullName} to host GameObject." + ); + + MessageAwareComponent messageAwareComponent = component as MessageAwareComponent; + Assert.That( + messageAwareComponent, + Is.Not.Null, + $"Attached component must be assignable to {nameof(MessageAwareComponent)}. Actual: {component.GetType().FullName}." + ); + return messageAwareComponent; + } + + private static bool InvokeBuildAndRenderOverlay(MessageAwareComponent component) + { + Assert.That(component, Is.Not.Null, "Component under test must not be null."); + + MethodInfo method = typeof(MessageAwareComponentInspectorOverlay).GetMethod( + "BuildAndRenderOverlay", + BindingFlags.Static | BindingFlags.NonPublic + ); + Assert.That( + method, + Is.Not.Null, + "MessageAwareComponentInspectorOverlay.BuildAndRenderOverlay was not found. " + + "If this method was renamed, update this test helper." + ); + + try + { + object result = method.Invoke(null, new object[] { component }); + Assert.That( + result, + Is.TypeOf(), + "BuildAndRenderOverlay must return bool so callers can reason about whether overlay UI was emitted." + ); + return (bool)result; + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + Assert.Fail( + "BuildAndRenderOverlay threw unexpectedly while base-call checks were disabled. " + + $"Inner exception: {ex.InnerException.GetType().FullName}: {ex.InnerException.Message}. " + + $"GUI context: {DescribeCurrentGuiContext()}." + ); + return false; + } + } + + private static string DescribeCurrentGuiContext() + { + Event currentEvent = Event.current; + if (currentEvent == null) + { + return "Event.current="; + } + return $"Event.current.type={currentEvent.type}"; + } + + private Object CreateTargetForScenario(OverlayTargetScenario scenario) + { + switch (scenario) + { + case OverlayTargetScenario.NullObject: + return null; + case OverlayTargetScenario.GameObject: + return CreateTrackedObject("OverlayTargetGameObject"); + case OverlayTargetScenario.Transform: + return CreateTrackedObject("OverlayTargetTransform").transform; + default: + Assert.Fail($"Unhandled {nameof(OverlayTargetScenario)} value: {scenario}."); + return null; + } + } + + private GameObject CreateTrackedObject(string name) + { + GameObject gameObject = new(name); + _createdObjects.Add(gameObject); + return gameObject; + } + } + + // Helper subclass used by the editor-selection / body-emission tests. Marked internal + // because Unity cannot serialize private nested MonoBehaviours during domain reload, and + // [AddComponentMenu("")] hides it from the inspector's Add Component picker. + [AddComponentMenu("")] + internal sealed class EmptyMessageAwareComponentForFallbackTest : MessageAwareComponent { } + + [AddComponentMenu("")] + internal sealed class SerializedFieldMessageAwareComponentForFallbackTest + : MessageAwareComponent + { + [SerializeField] + private int _value; + } +} +#endif diff --git a/Tests/Editor/MessageAwareComponentFallbackEditorTests.cs.meta b/Tests/Editor/MessageAwareComponentFallbackEditorTests.cs.meta new file mode 100644 index 00000000..c7b82a9d --- /dev/null +++ b/Tests/Editor/MessageAwareComponentFallbackEditorTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a56021468f75475ba26e1964a14ae53b +timeCreated: 1777567206 diff --git a/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs b/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs new file mode 100644 index 00000000..4a0c52ce --- /dev/null +++ b/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs @@ -0,0 +1,398 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Benchmarks +{ + using System; + using System.Collections; + using DxMessaging.Core; + using DxMessaging.Core.Extensions; + using DxMessaging.Core.MessageBus; + using DxMessaging.Core.Messages; + using NUnit.Framework; + using Scripts.Components; + using Scripts.Messages; + using UnityEngine; + using UnityEngine.TestTools; + + public sealed class BenchmarkHarnessRobustnessTests : BenchmarkTestBase + { + [TestCase("untargeted")] + [TestCase("targeted-game-object")] + [TestCase("targeted-component")] + public void RunWithComponentProvidesEnabledTokenForCoreRegistrationShapes(string mode) + { + RunWithComponent( + (component, token) => + { + Assert.IsNotNull(token, "Benchmark harness should always provide a token."); + Assert.IsTrue( + token.Enabled, + "Benchmark token should be enabled before registration." + ); + + int count = 0; + switch (mode) + { + case "untargeted": + token.RegisterUntargeted( + (ref SimpleUntargetedMessage _) => ++count + ); + SimpleUntargetedMessage untargetedMessage = new(); + untargetedMessage.EmitUntargeted(); + break; + case "targeted-game-object": + token.RegisterGameObjectTargeted( + component.gameObject, + (ref SimpleTargetedMessage _) => ++count + ); + SimpleTargetedMessage targetedGameObjectMessage = new(); + targetedGameObjectMessage.EmitGameObjectTargeted(component.gameObject); + break; + case "targeted-component": + token.RegisterComponentTargeted( + component, + (ref SimpleTargetedMessage _) => ++count + ); + SimpleTargetedMessage targetedComponentMessage = new(); + targetedComponentMessage.EmitComponentTargeted(component); + break; + default: + Assert.Fail($"Unhandled benchmark registration mode '{mode}'."); + break; + } + + Assert.AreEqual( + 1, + count, + $"Expected mode '{mode}' to receive exactly one message." + ); + } + ); + } + + [UnityTest] + public IEnumerator RunWithComponentUnregistersHandlersBetweenInvocationsSinglePass() + { + yield return RunWithComponentUnregistersHandlersBetweenInvocationsCore(1); + } + + [UnityTest] + public IEnumerator RunWithComponentUnregistersHandlersBetweenInvocationsTwoPasses() + { + yield return RunWithComponentUnregistersHandlersBetweenInvocationsCore(2); + } + + [UnityTest] + public IEnumerator RunWithComponentUnregistersHandlersBetweenInvocationsFourPasses() + { + yield return RunWithComponentUnregistersHandlersBetweenInvocationsCore(4); + } + + [TestCase(0)] + [TestCase(-1)] + public void RunWithComponentUnregistersHandlersBetweenInvocationsRejectsNonPositiveCounts( + int invocations + ) + { + Assert.Throws(() => ValidateInvocationCount(invocations)); + } + + [Test] + public void RunWithComponentUnregistersHandlersWhenBenchmarkActionThrows() + { + int leakedInvocationCount = 0; + SimpleUntargetedMessage message = new(); + + Assert.Throws(() => + RunWithComponent( + (_, token) => + { + token.RegisterUntargeted( + (ref SimpleUntargetedMessage _) => ++leakedInvocationCount + ); + throw new InvalidOperationException( + "Intentional benchmark action failure." + ); + } + ) + ); + + message.EmitUntargeted(); + Assert.Zero( + leakedInvocationCount, + $"RunWithComponent should unregister handlers even when the benchmark action throws. {DescribeMessageBusState(MessageHandler.MessageBus, includeLog: true)}" + ); + AssertMessageBusCounts( + expectedUntargeted: 0, + expectedTargeted: 0, + expectedBroadcast: 0, + "after benchmark action exception" + ); + } + + private IEnumerator RunWithComponentUnregistersHandlersBetweenInvocationsCore( + int invocations + ) + { + ValidateInvocationCount(invocations); + string scenario = $"invocations={invocations}"; + + yield return WaitUntilMessageHandlerIsFresh(); + AssertMessageBusCounts( + expectedUntargeted: 0, + expectedTargeted: 0, + expectedBroadcast: 0, + $"before scenario {scenario}" + ); + + int cumulativeInvocationCount = 0; + for (int i = 0; i < invocations; ++i) + { + SimpleUntargetedMessage message = new(); + int invocationStart = cumulativeInvocationCount; + RunWithComponent( + (_, token) => + { + token.RegisterUntargeted( + (ref SimpleUntargetedMessage _) => ++cumulativeInvocationCount + ); + message.EmitUntargeted(); + + Assert.AreEqual( + invocationStart + 1, + cumulativeInvocationCount, + $"Expected exactly one invocation for pass {i + 1}/{invocations} ({scenario})." + ); + } + ); + + // Explicitly verify cross-invocation isolation so stale bus state is caught at the source. + yield return WaitUntilMessageHandlerIsFresh(); + Assert.AreEqual( + i + 1, + cumulativeInvocationCount, + $"Invocation count drift after pass {i + 1}/{invocations} ({scenario}). {DescribeMessageBusState(MessageHandler.MessageBus, includeLog: true)}" + ); + AssertMessageBusCounts( + expectedUntargeted: 0, + expectedTargeted: 0, + expectedBroadcast: 0, + $"after invocation {i + 1}/{invocations} ({scenario})" + ); + } + } + + [TestCase(1)] + [TestCase(8)] + [TestCase(32)] + public void RunWithComponentInvokesUntargetedHandlersDeterministically(int emissions) + { + RunWithComponent( + (_, token) => + { + int count = 0; + SimpleUntargetedMessage message = new(); + token.RegisterUntargeted( + (ref SimpleUntargetedMessage _) => ++count + ); + + for (int i = 0; i < emissions; ++i) + { + message.EmitUntargeted(); + } + + Assert.AreEqual( + emissions, + count, + "Benchmark harness should invoke untargeted handlers exactly once per emission." + ); + } + ); + } + + [Test] + public void RunWithComponentSupportsTokenDisableEnableCycle() + { + RunWithComponent( + (_, token) => + { + int count = 0; + SimpleUntargetedMessage message = new(); + token.RegisterUntargeted( + (ref SimpleUntargetedMessage _) => ++count + ); + + token.Disable(); + Assert.IsFalse(token.Enabled); + + message.EmitUntargeted(); + Assert.AreEqual(0, count); + + token.Enable(); + Assert.IsTrue(token.Enabled); + + message.EmitUntargeted(); + Assert.AreEqual( + 1, + count, + "Handler should resume after the benchmark token is re-enabled." + ); + } + ); + } + + [Test] + public void RunWithComponentPreparesMonoBehavioursForSendMessageInEditMode() + { + RunWithComponent( + (component, _) => + { + MonoBehaviour[] behaviours = + component.gameObject.GetComponents(); + Assert.Greater( + behaviours.Length, + 0, + "Benchmark harness should create at least one MonoBehaviour on the target GameObject." + ); + + foreach (MonoBehaviour behaviour in behaviours) + { + Assert.IsTrue( + behaviour.enabled, + $"Expected benchmark MonoBehaviour '{behaviour.GetType().Name}' to be enabled before dispatch." + ); + +#if UNITY_EDITOR + if (!Application.isPlaying) + { + Assert.IsTrue( + behaviour.runInEditMode, + $"Expected benchmark MonoBehaviour '{behaviour.GetType().Name}' to run in EditMode for SendMessage-based dispatch." + ); + } +#endif + } + } + ); + } + + [TestCase(ReflexiveSendMode.Flat, false, 0, 1, 0)] + [TestCase(ReflexiveSendMode.Downwards, false, 0, 1, 1)] + [TestCase(ReflexiveSendMode.Upwards, true, 1, 1, 1)] + public void RunWithComponentDeliversReflexiveOneArgumentMessagesAcrossFastPathModes( + ReflexiveSendMode sendMode, + bool targetChild, + int expectedGrandParentCount, + int expectedParentCount, + int expectedChildCount + ) + { + RunWithComponent( + (component, _) => + { + GameObject parent = component.gameObject; + if (!parent.TryGetComponent(out SimpleMessageAwareComponent parentReceiver)) + { + parentReceiver = parent.AddComponent(); + } + + GameObject grandParent = new( + "BenchmarkReflexiveGrandParent", + typeof(SimpleMessageAwareComponent) + ); + _spawned.Add(grandParent); + parent.transform.SetParent(grandParent.transform); + + GameObject child = new( + "BenchmarkReflexiveChild", + typeof(SimpleMessageAwareComponent) + ); + _spawned.Add(child); + child.transform.SetParent(parent.transform); + + SimpleMessageAwareComponent grandParentReceiver = + grandParent.GetComponent(); + SimpleMessageAwareComponent childReceiver = + child.GetComponent(); + PrepareBenchmarkBehaviourForSendMessage(parentReceiver); + PrepareBenchmarkBehaviourForSendMessage(grandParentReceiver); + PrepareBenchmarkBehaviourForSendMessage(childReceiver); + + int grandParentCount = 0; + int parentCount = 0; + int childCount = 0; + grandParentReceiver.slowComplexTargetedHandler = () => ++grandParentCount; + parentReceiver.slowComplexTargetedHandler = () => ++parentCount; + childReceiver.slowComplexTargetedHandler = () => ++childCount; + + ComplexTargetedMessage payload = new(Guid.NewGuid()); + ReflexiveMessage message = new( + nameof(SimpleMessageAwareComponent.HandleSlowComplexTargetedMessage), + sendMode, + payload + ); + + InstanceId target = targetChild ? child : parent; + message.EmitTargeted(target); + + string scenario = $"sendMode '{sendMode}', targetChild={targetChild}"; + Assert.AreEqual( + expectedGrandParentCount, + grandParentCount, + $"Unexpected grand-parent invocation count for {scenario}." + ); + Assert.AreEqual( + expectedParentCount, + parentCount, + $"Unexpected parent invocation count for {scenario}." + ); + Assert.AreEqual( + expectedChildCount, + childCount, + $"Unexpected child invocation count for {scenario}." + ); + } + ); + } + + private static void AssertMessageBusCounts( + int expectedUntargeted, + int expectedTargeted, + int expectedBroadcast, + string context + ) + { + IMessageBus messageBus = MessageHandler.MessageBus; + Assert.IsNotNull(messageBus, $"MessageBus was null while validating {context}."); + + Assert.AreEqual( + expectedUntargeted, + messageBus.RegisteredUntargeted, + $"Unexpected untargeted registration count {context}." + ); + Assert.AreEqual( + expectedTargeted, + messageBus.RegisteredTargeted, + $"Unexpected targeted registration count {context}." + ); + Assert.AreEqual( + expectedBroadcast, + messageBus.RegisteredBroadcast, + $"Unexpected broadcast registration count {context}." + ); + } + + private static void ValidateInvocationCount(int invocations) + { + if (invocations <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(invocations), + invocations, + "Invocation count must be positive." + ); + } + } + } +} + +#endif diff --git a/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs.meta b/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs.meta new file mode 100644 index 00000000..ee03df94 --- /dev/null +++ b/Tests/Runtime/Benchmarks/BenchmarkHarnessRobustnessTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 64773754a1842524e92ee4cd96aff4f0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Benchmarks/BenchmarkTestBase.cs b/Tests/Runtime/Benchmarks/BenchmarkTestBase.cs index fca74a3e..93978132 100644 --- a/Tests/Runtime/Benchmarks/BenchmarkTestBase.cs +++ b/Tests/Runtime/Benchmarks/BenchmarkTestBase.cs @@ -3,7 +3,9 @@ namespace DxMessaging.Tests.Runtime.Benchmarks { using System; using System.Globalization; + using DxMessaging.Core; using DxMessaging.Tests.Runtime.Core; + using DxMessaging.Unity; using Scripts.Components; using UnityEngine; using Object = UnityEngine.Object; @@ -78,19 +80,107 @@ protected void RunWithComponent(Action action) throw new ArgumentNullException(nameof(action)); } + RunWithComponent((component, _) => action(component)); + } + + protected void RunWithComponent( + Action action + ) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + GameObject go = CreateBenchmarkGameObject(); + MessageRegistrationToken token = null; try { EmptyMessageAwareComponent component = go.GetComponent(); - action(component); + if (component == null) + { + throw new InvalidOperationException( + "Benchmark GameObject was missing EmptyMessageAwareComponent." + ); + } + + token = GetOrCreateEnabledToken(go, component); + PrepareBenchmarkGameObjectForSendMessage(go); + action(component, token); } finally { + token?.UnregisterAll(); _spawned.Remove(go); - Object.Destroy(go); + if (Application.isPlaying) + { + Object.Destroy(go); + } + else + { + Object.DestroyImmediate(go); + } + } + } + + private static MessageRegistrationToken GetOrCreateEnabledToken( + GameObject go, + EmptyMessageAwareComponent component + ) + { + MessageRegistrationToken token = component.Token; + if (token == null) + { + MessagingComponent messagingComponent = go.GetComponent(); + if (messagingComponent == null) + { + throw new InvalidOperationException( + $"Benchmark GameObject '{go.name}' is missing {nameof(MessagingComponent)}." + ); + } + + token = messagingComponent.Create(component); + // Benchmarks register handlers explicitly per scenario, so they do not depend on + // MessageAwareComponent.RegisterMessageHandlers being invoked here. + } + + if (!token.Enabled) + { + token.Enable(); + } + + return token; + } + + protected static void PrepareBenchmarkGameObjectForSendMessage(GameObject target) + { + target.SetActive(true); + + foreach (MonoBehaviour behaviour in target.GetComponents()) + { + PrepareBenchmarkBehaviourForSendMessage(behaviour); } } + + protected static void PrepareBenchmarkBehaviourForSendMessage(MonoBehaviour behaviour) + { + if (behaviour == null) + { + return; + } + + behaviour.enabled = true; + +#if UNITY_EDITOR + if (!Application.isPlaying) + { + // EditMode SendMessage requires runnable behaviours, otherwise Unity logs + // repeated ShouldRunBehaviour assertions that fail benchmark tests. + behaviour.runInEditMode = true; + } +#endif + } } } diff --git a/Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs b/Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs index 82e54ef8..d46affab 100644 --- a/Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs +++ b/Tests/Runtime/Benchmarks/ComparisonPerformanceTests.cs @@ -61,42 +61,53 @@ private void BenchmarkDxMessaging(TimeSpan timeout) Stopwatch timer = Stopwatch.StartNew(); SimpleUntargetedMessage message = new(); - RunWithComponent(component => - { - int count = 0; - MessageRegistrationToken token = GetToken(component); - token.RegisterUntargeted(Handle); + RunWithComponent( + (_, token) => + { + int count = 0; + token.RegisterUntargeted(Handle); - message.EmitUntargeted(); + message.EmitUntargeted(); - timer.Restart(); - do - { - for (int i = 0; i < NumInvocationsPerIteration; ++i) + timer.Restart(); + do { - message.EmitUntargeted(); - } - } while (timer.Elapsed < timeout); - - bool allocating; - try - { - Assert.That(() => message.EmitUntargeted(), Is.Not.AllocatingGCMemory()); - allocating = false; - } - catch - { - allocating = true; - } + for (int i = 0; i < NumInvocationsPerIteration; ++i) + { + message.EmitUntargeted(); + } + } while (timer.Elapsed < timeout); - RecordBenchmark("DxMessaging (Untargeted) - No-Copy", count, timeout, allocating); - return; + bool allocating; + try + { + Assert.That(() => message.EmitUntargeted(), Is.Not.AllocatingGCMemory()); + allocating = false; + } + catch + { + allocating = true; + } - void Handle(ref SimpleUntargetedMessage _) - { - ++count; + Assert.Greater( + count, + 0, + "DxMessaging comparison benchmark should invoke handlers." + ); + RecordBenchmark( + "DxMessaging (Untargeted) - No-Copy", + count, + timer.Elapsed, + allocating + ); + return; + + void Handle(ref SimpleUntargetedMessage _) + { + ++count; + } } - }); + ); } #if ZENJECT_PRESENT @@ -135,7 +146,8 @@ private void BenchmarkZenjectSignals(TimeSpan timeout) allocating = true; } - RecordBenchmark("Zenject SignalBus", count, timeout, allocating); + Assert.Greater(count, 0, "Zenject comparison benchmark should invoke handlers."); + RecordBenchmark("Zenject SignalBus", count, timer.Elapsed, allocating); } #else private void BenchmarkZenjectSignals(TimeSpan timeout) @@ -180,7 +192,8 @@ private void BenchmarkUniRx(TimeSpan timeout) allocating = true; } - RecordBenchmark("UniRx MessageBroker", count, timeout, allocating); + Assert.Greater(count, 0, "UniRx comparison benchmark should invoke handlers."); + RecordBenchmark("UniRx MessageBroker", count, timer.Elapsed, allocating); } #else private void BenchmarkUniRx(TimeSpan timeout) @@ -225,7 +238,8 @@ private void BenchmarkMessagePipe(TimeSpan timeout) allocating = true; } - RecordBenchmark("MessagePipe (Global)", count, timeout, allocating); + Assert.Greater(count, 0, "MessagePipe comparison benchmark should invoke handlers."); + RecordBenchmark("MessagePipe (Global)", count, timer.Elapsed, allocating); } #else private void BenchmarkMessagePipe(TimeSpan timeout) diff --git a/Tests/Runtime/Benchmarks/PerformanceTests.cs b/Tests/Runtime/Benchmarks/PerformanceTests.cs index d48cfce0..0475048e 100644 --- a/Tests/Runtime/Benchmarks/PerformanceTests.cs +++ b/Tests/Runtime/Benchmarks/PerformanceTests.cs @@ -49,28 +49,47 @@ public void Benchmark() Unity(timer, timeout, component.gameObject, message) ); - RunWithComponent(component => - NormalGameObject(timer, timeout, component, message) + RunWithComponent( + (component, token) => + NormalGameObject(timer, timeout, component, token, message) ); - RunWithComponent(component => - NormalComponent(timer, timeout, component, message) + RunWithComponent( + (component, token) => + NormalComponent(timer, timeout, component, token, message) ); - RunWithComponent(component => - NoCopyGameObject(timer, timeout, component, message) + RunWithComponent( + (component, token) => + NoCopyGameObject(timer, timeout, component, token, message) ); - RunWithComponent(component => - NoCopyComponent(timer, timeout, component, message) + RunWithComponent( + (component, token) => + NoCopyComponent(timer, timeout, component, token, message) ); SimpleUntargetedMessage untargetedMessage = new(); - RunWithComponent(component => - NoCopyUntargeted(timer, timeout, component, untargetedMessage) + RunWithComponent( + (component, token) => + NoCopyUntargeted(timer, timeout, component, token, untargetedMessage) ); - RunWithComponent(component => - InterceptorHeavyUntargeted(timer, timeout, component, untargetedMessage) + RunWithComponent( + (component, token) => + InterceptorHeavyUntargeted( + timer, + timeout, + component, + token, + untargetedMessage + ) ); - RunWithComponent(component => - PostProcessorHeavyUntargeted(timer, timeout, component, untargetedMessage) + RunWithComponent( + (component, token) => + PostProcessorHeavyUntargeted( + timer, + timeout, + component, + token, + untargetedMessage + ) ); RunWithComponent(component => ReflexiveOneArgument(timer, timeout, component.gameObject, reflexiveMessage) @@ -85,9 +104,9 @@ public void Benchmark() ); } - private void DisplayCount(string testName, int count, TimeSpan timeout, bool allocating) + private void DisplayCount(string testName, int count, TimeSpan duration, bool allocating) { - RecordBenchmark(testName, count, timeout, allocating); + RecordBenchmark(testName, count, duration, allocating); } private void Unity( @@ -103,6 +122,9 @@ ComplexTargetedMessage message component = target.AddComponent(); } + // RunWithComponent prepared existing behaviours; only the newly added receiver needs setup. + PrepareBenchmarkBehaviourForSendMessage(component); + component.slowComplexTargetedHandler = () => ++count; // Pre-warm target.SendMessage( @@ -140,7 +162,8 @@ ComplexTargetedMessage message allocating = true; } - DisplayCount("Unity", count, timeout, allocating); + Assert.Greater(count, 0, "Unity benchmark should invoke handlers."); + DisplayCount("Unity", count, timer.Elapsed, allocating); } private void ReflexiveThreeArguments(Stopwatch timer, TimeSpan timeout, GameObject go) @@ -151,6 +174,9 @@ private void ReflexiveThreeArguments(Stopwatch timer, TimeSpan timeout, GameObje component = go.AddComponent(); } + // RunWithComponent prepared existing behaviours; only the newly added receiver needs setup. + PrepareBenchmarkBehaviourForSendMessage(component); + component.reflexiveThreeArgumentHandler = () => ++count; ReflexiveMessage message = new( nameof(SimpleMessageAwareComponent.HandleReflexiveMessageThreeArguments), @@ -183,7 +209,8 @@ private void ReflexiveThreeArguments(Stopwatch timer, TimeSpan timeout, GameObje allocating = true; } - DisplayCount("Reflexive (Three Arguments)", count, timeout, allocating); + Assert.Greater(count, 0, "Reflexive three-argument benchmark should invoke handlers."); + DisplayCount("Reflexive (Three Arguments)", count, timer.Elapsed, allocating); } private void ReflexiveTwoArguments(Stopwatch timer, TimeSpan timeout, GameObject go) @@ -194,6 +221,9 @@ private void ReflexiveTwoArguments(Stopwatch timer, TimeSpan timeout, GameObject component = go.AddComponent(); } + // RunWithComponent prepared existing behaviours; only the newly added receiver needs setup. + PrepareBenchmarkBehaviourForSendMessage(component); + component.reflexiveTwoArgumentHandler = () => ++count; ReflexiveMessage message = new( nameof(SimpleMessageAwareComponent.HandleReflexiveMessageTwoArguments), @@ -225,7 +255,8 @@ private void ReflexiveTwoArguments(Stopwatch timer, TimeSpan timeout, GameObject allocating = true; } - DisplayCount("Reflexive (Two Arguments)", count, timeout, allocating); + Assert.Greater(count, 0, "Reflexive two-argument benchmark should invoke handlers."); + DisplayCount("Reflexive (Two Arguments)", count, timer.Elapsed, allocating); } private void ReflexiveOneArgument( @@ -241,6 +272,9 @@ ReflexiveMessage message component = go.AddComponent(); } + // RunWithComponent prepared existing behaviours; only the newly added receiver needs setup. + PrepareBenchmarkBehaviourForSendMessage(component); + component.slowComplexTargetedHandler = () => ++count; InstanceId target = go; // Pre-warm @@ -266,18 +300,19 @@ ReflexiveMessage message allocating = true; } - DisplayCount("Reflexive (One Argument)", count, timeout, allocating); + Assert.Greater(count, 0, "Reflexive one-argument benchmark should invoke handlers."); + DisplayCount("Reflexive (One Argument)", count, timer.Elapsed, allocating); } private void NormalGameObject( Stopwatch timer, TimeSpan timeout, EmptyMessageAwareComponent component, + MessageRegistrationToken token, ComplexTargetedMessage message ) { int count = 0; - MessageRegistrationToken token = GetToken(component); GameObject go = component.gameObject; InstanceId target = go; @@ -305,7 +340,8 @@ ComplexTargetedMessage message allocating = true; } - DisplayCount("DxMessaging (GameObject) - Normal", count, timeout, allocating); + Assert.Greater(count, 0, "Normal GameObject benchmark should invoke handlers."); + DisplayCount("DxMessaging (GameObject) - Normal", count, timer.Elapsed, allocating); return; void Handle(ComplexTargetedMessage _) @@ -318,11 +354,11 @@ private void NormalComponent( Stopwatch timer, TimeSpan timeout, EmptyMessageAwareComponent component, + MessageRegistrationToken token, ComplexTargetedMessage message ) { int count = 0; - MessageRegistrationToken token = GetToken(component); InstanceId target = component; token.RegisterComponentTargeted(component, Handle); @@ -349,7 +385,8 @@ ComplexTargetedMessage message allocating = true; } - DisplayCount("DxMessaging (Component) - Normal", count, timeout, allocating); + Assert.Greater(count, 0, "Normal component benchmark should invoke handlers."); + DisplayCount("DxMessaging (Component) - Normal", count, timer.Elapsed, allocating); return; void Handle(ComplexTargetedMessage _) @@ -362,11 +399,11 @@ private void NoCopyGameObject( Stopwatch timer, TimeSpan timeout, EmptyMessageAwareComponent component, + MessageRegistrationToken token, ComplexTargetedMessage message ) { int count = 0; - MessageRegistrationToken token = GetToken(component); GameObject go = component.gameObject; InstanceId target = go; @@ -394,7 +431,8 @@ ComplexTargetedMessage message allocating = true; } - DisplayCount("DxMessaging (GameObject) - No-Copy", count, timeout, allocating); + Assert.Greater(count, 0, "No-copy GameObject benchmark should invoke handlers."); + DisplayCount("DxMessaging (GameObject) - No-Copy", count, timer.Elapsed, allocating); return; void Handle(ref ComplexTargetedMessage _) @@ -407,12 +445,11 @@ private void NoCopyComponent( Stopwatch timer, TimeSpan timeout, EmptyMessageAwareComponent component, + MessageRegistrationToken token, ComplexTargetedMessage message ) { int count = 0; - MessageRegistrationToken token = GetToken(component); - InstanceId target = component; token.RegisterComponentTargeted(component, Handle); // Pre-warm @@ -423,14 +460,17 @@ ComplexTargetedMessage message { for (int i = 0; i < NumInvocationsPerIteration; ++i) { - message.EmitTargeted(target); + message.EmitComponentTargeted(component); } } while (timer.Elapsed < timeout); bool allocating; try { - Assert.That(() => message.EmitTargeted(target), Is.Not.AllocatingGCMemory()); + Assert.That( + () => message.EmitComponentTargeted(component), + Is.Not.AllocatingGCMemory() + ); allocating = false; } catch @@ -438,7 +478,8 @@ ComplexTargetedMessage message allocating = true; } - DisplayCount("DxMessaging (Component) - No-Copy", count, timeout, allocating); + Assert.Greater(count, 0, "No-copy component benchmark should invoke handlers."); + DisplayCount("DxMessaging (Component) - No-Copy", count, timer.Elapsed, allocating); return; void Handle(ref ComplexTargetedMessage _) @@ -451,11 +492,11 @@ private void NoCopyUntargeted( Stopwatch timer, TimeSpan timeout, EmptyMessageAwareComponent component, + MessageRegistrationToken token, SimpleUntargetedMessage message ) { int count = 0; - MessageRegistrationToken token = GetToken(component); token.RegisterUntargeted(Handle); // Pre-warm @@ -481,7 +522,8 @@ SimpleUntargetedMessage message allocating = true; } - DisplayCount("DxMessaging (Untargeted) - No-Copy", count, timeout, allocating); + Assert.Greater(count, 0, "No-copy untargeted benchmark should invoke handlers."); + DisplayCount("DxMessaging (Untargeted) - No-Copy", count, timer.Elapsed, allocating); return; void Handle(ref SimpleUntargetedMessage _) @@ -494,12 +536,12 @@ private void InterceptorHeavyUntargeted( Stopwatch timer, TimeSpan timeout, EmptyMessageAwareComponent component, + MessageRegistrationToken token, SimpleUntargetedMessage message ) { int handlerInvocationCount = 0; int interceptorInvocationCount = 0; - MessageRegistrationToken token = GetToken(component); const int InterceptorCount = 8; for (int i = 0; i < InterceptorCount; ++i) @@ -541,11 +583,16 @@ SimpleUntargetedMessage message 0, "Interceptor-heavy benchmark should invoke registered interceptors." ); + Assert.Greater( + handlerInvocationCount, + 0, + "Interceptor-heavy benchmark should invoke handlers." + ); DisplayCount( "DxMessaging (Untargeted) - Interceptors", handlerInvocationCount, - timeout, + timer.Elapsed, allocating ); return; @@ -560,12 +607,12 @@ private void PostProcessorHeavyUntargeted( Stopwatch timer, TimeSpan timeout, EmptyMessageAwareComponent component, + MessageRegistrationToken token, SimpleUntargetedMessage message ) { int handlerInvocationCount = 0; int postProcessorInvocationCount = 0; - MessageRegistrationToken token = GetToken(component); const int PostProcessorCount = 8; for (int i = 0; i < PostProcessorCount; ++i) @@ -603,11 +650,16 @@ SimpleUntargetedMessage message 0, "Post-processor benchmark should invoke registered post-processors." ); + Assert.Greater( + handlerInvocationCount, + 0, + "Post-processor-heavy benchmark should invoke handlers." + ); DisplayCount( "DxMessaging (Untargeted) - Post-Processors", handlerInvocationCount, - timeout, + timer.Elapsed, allocating ); return; diff --git a/Tests/Runtime/Core/MessagingTestBase.cs b/Tests/Runtime/Core/MessagingTestBase.cs index 293a4464..cd459f99 100644 --- a/Tests/Runtime/Core/MessagingTestBase.cs +++ b/Tests/Runtime/Core/MessagingTestBase.cs @@ -55,11 +55,7 @@ public virtual void Setup() protected void LogMessageBusStatus() { IMessageBus messageBus = MessageHandler.MessageBus; - Debug.Log( - $"Untargeted registrations: {messageBus.RegisteredUntargeted}, " - + $"targeted registrations: {messageBus.RegisteredTargeted}, " - + $"broadcast registrations: {messageBus.RegisteredBroadcast}." - ); + Debug.Log(DescribeMessageBusState(messageBus)); } [TearDown] @@ -72,7 +68,7 @@ public virtual void Cleanup() continue; } - Object.Destroy(spawned); + DestroyTrackedObject(spawned); } _spawned.Clear(); @@ -88,8 +84,11 @@ public IEnumerator UnityCleanup() continue; } - Object.Destroy(spawned); - yield return null; + DestroyTrackedObject(spawned); + if (Application.isPlaying) + { + yield return null; + } } _spawned.Clear(); @@ -190,11 +189,10 @@ protected static IEnumerator WaitUntilMessageHandlerIsFresh() Assert.IsFalse( IsStale(), - "MessageHandler had {0} Untargeted registrations, {1} Targeted registrations, {2} Broadcast registrations. Registration log: {3}.", - messageBus.RegisteredUntargeted, - messageBus.RegisteredTargeted, - messageBus.RegisteredBroadcast, - messageBus.Log + "MessageHandler remained stale after waiting {0}ms (isPlaying={1}). {2}", + timer.Elapsed.TotalMilliseconds, + Application.isPlaying, + DescribeMessageBusState(messageBus, includeLog: true) ); yield break; @@ -205,6 +203,39 @@ bool IsStale() || messageBus.RegisteredBroadcast != 0; } } + + private static void DestroyTrackedObject(GameObject spawned) + { + if (Application.isPlaying) + { + Object.Destroy(spawned); + return; + } + + Object.DestroyImmediate(spawned); + } + + protected static string DescribeMessageBusState( + IMessageBus messageBus, + bool includeLog = false + ) + { + if (messageBus == null) + { + return "MessageBus=."; + } + + string details = + $"Untargeted={messageBus.RegisteredUntargeted}, " + + $"Targeted={messageBus.RegisteredTargeted}, " + + $"Broadcast={messageBus.RegisteredBroadcast}."; + if (!includeLog) + { + return details; + } + + return $"{details} Registration log: {messageBus.Log}."; + } } } diff --git a/Tests/Runtime/Core/MessagingTestBaseCleanupRobustnessTests.cs b/Tests/Runtime/Core/MessagingTestBaseCleanupRobustnessTests.cs new file mode 100644 index 00000000..d17a6880 --- /dev/null +++ b/Tests/Runtime/Core/MessagingTestBaseCleanupRobustnessTests.cs @@ -0,0 +1,246 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System.Collections; + using System.Collections.Generic; + using System.Linq; + using DxMessaging.Core; + using DxMessaging.Core.MessageBus; + using NUnit.Framework; + using Scripts.Components; + using UnityEngine; + using UnityEngine.TestTools; + using Object = UnityEngine.Object; + + public sealed class MessagingTestBaseCleanupRobustnessTests : MessagingTestBase + { + private static readonly CleanupScenario[] CleanupScenarios = + { + new( + "sync-zero", + useUnityCleanup: false, + trackedObjectCount: 0, + preDestroyFirstTrackedObject: false + ), + new( + "sync-one", + useUnityCleanup: false, + trackedObjectCount: 1, + preDestroyFirstTrackedObject: false + ), + new( + "sync-one-pre-destroy", + useUnityCleanup: false, + trackedObjectCount: 1, + preDestroyFirstTrackedObject: true + ), + new( + "sync-three", + useUnityCleanup: false, + trackedObjectCount: 3, + preDestroyFirstTrackedObject: false + ), + new( + "sync-three-pre-destroy", + useUnityCleanup: false, + trackedObjectCount: 3, + preDestroyFirstTrackedObject: true + ), + new( + "unity-zero", + useUnityCleanup: true, + trackedObjectCount: 0, + preDestroyFirstTrackedObject: false + ), + new( + "unity-one", + useUnityCleanup: true, + trackedObjectCount: 1, + preDestroyFirstTrackedObject: false + ), + new( + "unity-one-pre-destroy", + useUnityCleanup: true, + trackedObjectCount: 1, + preDestroyFirstTrackedObject: true + ), + new( + "unity-three", + useUnityCleanup: true, + trackedObjectCount: 3, + preDestroyFirstTrackedObject: false + ), + new( + "unity-three-pre-destroy", + useUnityCleanup: true, + trackedObjectCount: 3, + preDestroyFirstTrackedObject: true + ), + }; + + // Unity Test Framework supports parameterization for UnityTest via ValueSource. + [UnityTest] + public IEnumerator CleanupVariantsDestroyTrackedObjectsAndClearRegistrations( + [ValueSource(nameof(CleanupScenarios))] CleanupScenario scenario + ) + { + string scenarioLabel = scenario.ToString(); + TestContext.WriteLine( + $"Running cleanup scenario: {scenarioLabel}. isPlaying={Application.isPlaying}." + ); + + List created = new(scenario.TrackedObjectCount); + List names = new(scenario.TrackedObjectCount); + + for (int i = 0; i < scenario.TrackedObjectCount; ++i) + { + GameObject go = new( + $"MessagingTestBaseCleanup_{scenario.Name}_{scenario.TrackedObjectCount}_{i}", + typeof(SimpleMessageAwareComponent) + ); + _spawned.Add(go); + created.Add(go); + names.Add(go.name); + } + + if (scenario.PreDestroyFirstTrackedObject) + { + Assert.Greater( + scenario.TrackedObjectCount, + 0, + $"Pre-destroy requires at least one tracked object. {scenarioLabel}" + ); + + DestroyForCleanupScenario(created[0]); + if (Application.isPlaying) + { + yield return null; + } + + Assert.IsTrue( + created[0] == null, + $"Pre-destroyed object should report as destroyed before cleanup runs. {scenarioLabel}" + ); + } + + IMessageBus messageBus = MessageHandler.MessageBus; + Assert.IsNotNull(messageBus, $"Message bus must exist before cleanup. {scenarioLabel}"); + + int totalBeforeCleanup = + messageBus.RegisteredUntargeted + + messageBus.RegisteredTargeted + + messageBus.RegisteredBroadcast; + + int aliveBeforeCleanup = created.Count(go => go != null); + if (scenario.TrackedObjectCount == 0) + { + Assert.Zero( + totalBeforeCleanup, + $"Zero tracked objects should not register handlers before cleanup. {scenarioLabel} {DescribeMessageBusState(messageBus, includeLog: true)}" + ); + } + else if (aliveBeforeCleanup > 0) + { + Assert.Greater( + totalBeforeCleanup, + 0, + $"Expected at least one registration before cleanup when tracked objects are alive. {scenarioLabel} {DescribeMessageBusState(messageBus, includeLog: true)}" + ); + } + else + { + Assert.Zero( + totalBeforeCleanup, + $"No alive tracked objects should leave no active registrations before cleanup. {scenarioLabel} {DescribeMessageBusState(messageBus, includeLog: true)}" + ); + TestContext.WriteLine( + $"All tracked objects were already destroyed before cleanup. Registrations before cleanup={totalBeforeCleanup}. {scenarioLabel}." + ); + } + + if (scenario.UseUnityCleanup) + { + yield return UnityCleanup(); + } + else + { + Cleanup(); + if (Application.isPlaying) + { + yield return null; + } + } + + for (int i = 0; i < created.Count; ++i) + { + Assert.IsTrue( + created[i] == null, + $"Tracked object '{names[i]}' should be destroyed by cleanup. index={i}. {scenarioLabel}" + ); + } + + Assert.Zero( + _spawned.Count, + $"Cleanup should clear tracked spawned objects. {scenarioLabel}" + ); + yield return WaitUntilMessageHandlerIsFresh(); + + IMessageBus finalMessageBus = MessageHandler.MessageBus; + Assert.IsNotNull( + finalMessageBus, + $"Message bus must remain available after cleanup. {scenarioLabel}" + ); + + int totalAfterCleanup = + finalMessageBus.RegisteredUntargeted + + finalMessageBus.RegisteredTargeted + + finalMessageBus.RegisteredBroadcast; + Assert.Zero( + totalAfterCleanup, + $"Cleanup should leave message bus fresh. {scenarioLabel} {DescribeMessageBusState(finalMessageBus, includeLog: true)}" + ); + } + + private static void DestroyForCleanupScenario(GameObject spawned) + { + if (Application.isPlaying) + { + Object.Destroy(spawned); + return; + } + + Object.DestroyImmediate(spawned); + } + + public sealed class CleanupScenario + { + public CleanupScenario( + string name, + bool useUnityCleanup, + int trackedObjectCount, + bool preDestroyFirstTrackedObject + ) + { + Name = name; + UseUnityCleanup = useUnityCleanup; + TrackedObjectCount = trackedObjectCount; + PreDestroyFirstTrackedObject = preDestroyFirstTrackedObject; + } + + public string Name { get; } + + public bool UseUnityCleanup { get; } + + public int TrackedObjectCount { get; } + + public bool PreDestroyFirstTrackedObject { get; } + + public override string ToString() + { + return $"Name={Name},UseUnityCleanup={UseUnityCleanup},TrackedObjectCount={TrackedObjectCount},PreDestroyFirstTrackedObject={PreDestroyFirstTrackedObject}"; + } + } + } +} + +#endif diff --git a/Tests/Runtime/Core/MessagingTestBaseCleanupRobustnessTests.cs.meta b/Tests/Runtime/Core/MessagingTestBaseCleanupRobustnessTests.cs.meta new file mode 100644 index 00000000..25735277 --- /dev/null +++ b/Tests/Runtime/Core/MessagingTestBaseCleanupRobustnessTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a91e464cd7439994ca1308badb2b9158 +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 new file mode 100644 index 00000000..cb4892d5 --- /dev/null +++ b/Tests/Runtime/Core/TestAttributeContractTests.cs @@ -0,0 +1,140 @@ +#if UNITY_2021_3_OR_NEWER +namespace DxMessaging.Tests.Runtime.Core +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using NUnit.Framework; + using UnityEngine.TestTools; + + public sealed class TestAttributeContractTests + { + [Test] + public void UnityTestsDoNotUseTestCaseAttributes() + { + List offenders = FindMethods(method => + HasAttribute(method) + && ( + HasAttribute(method) + || HasAttribute(method) + ) + ) + .Select(FormatMethod) + .ToList(); + + Assert.That( + offenders, + Is.Empty, + "Found [UnityTest] methods decorated with [TestCase] or [TestCaseSource]. Use [ValueSource] for parameterized coroutine tests.\n" + + string.Join("\n", offenders) + ); + } + + [Test] + public void NonUnityTestsDoNotReturnIEnumerator() + { + List offenders = FindMethods(method => + method.ReturnType == typeof(IEnumerator) + && !HasAttribute(method) + && ( + HasAttribute(method) + || HasAttribute(method) + || HasAttribute(method) + ) + ) + .Select(FormatMethod) + .ToList(); + + Assert.That( + offenders, + Is.Empty, + "Found non-[UnityTest] methods returning IEnumerator. Use [UnityTest] for coroutine tests.\n" + + string.Join("\n", offenders) + ); + } + + [Test] + public void UnityTestsReturnIEnumerator() + { + List offenders = FindMethods(method => + HasAttribute(method) + && method.ReturnType != typeof(IEnumerator) + ) + .Select(FormatMethod) + .ToList(); + + Assert.That( + offenders, + Is.Empty, + "Found [UnityTest] methods that do not return IEnumerator.\n" + + string.Join("\n", offenders) + ); + } + + private static IEnumerable FindMethods(Func predicate) + { + return GetRuntimeTestMethods().Where(predicate); + } + + 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()) + { + if ( + type.Namespace == null + || !type.Namespace.StartsWith( + "DxMessaging.Tests.Runtime", + StringComparison.Ordinal + ) + ) + { + continue; + } + + foreach (MethodInfo method in type.GetMethods(methodFlags)) + { + 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; + } + } + } + } + + private static bool HasAttribute(MemberInfo method) + where TAttribute : Attribute + { + return method.GetCustomAttributes(typeof(TAttribute), inherit: false).Length > 0; + } + + private static string FormatMethod(MethodInfo method) + { + Type declaringType = method.DeclaringType; + string declaringTypeName = declaringType == null ? "" : declaringType.FullName; + return $"{declaringTypeName}.{method.Name} returns {method.ReturnType.FullName}"; + } + } +} + +#endif diff --git a/Tests/Runtime/Core/TestAttributeContractTests.cs.meta b/Tests/Runtime/Core/TestAttributeContractTests.cs.meta new file mode 100644 index 00000000..134c8181 --- /dev/null +++ b/Tests/Runtime/Core/TestAttributeContractTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bd29a8744120c164180bfc5252171315 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/advanced/emit-shorthands.md b/docs/advanced/emit-shorthands.md index 114d0b19..d82d4993 100644 --- a/docs/advanced/emit-shorthands.md +++ b/docs/advanced/emit-shorthands.md @@ -8,11 +8,11 @@ These shorthands provide concise syntax for sending messages, but they come with Three methods that work on any message: -| Method | Purpose | Message Type | Example | -| ---------------------- | ------------------------- | -------------------- | -------------------------------------- | -| **`Emit()`** | Send globally to everyone | `IUntargetedMessage` | `new SceneLoaded(1).Emit();` | -| **`EmitAt(target)`** | Send to a specific target | `ITargetedMessage` | `new Heal(10).EmitAt(playerId);` | -| **`EmitFrom(source)`** | Broadcast from a source | `IBroadcastMessage` | `new TookDamage(5).EmitFrom(enemyId);` | +| Method | Purpose | Message Type | Example | +| ---------------------- | ------------------------- | -------------------- | ------------------------------------------------- | +| **`Emit()`** | Send globally to everyone | `IUntargetedMessage` | `var m = new SceneLoaded(1); m.Emit();` | +| **`EmitAt(target)`** | Send to a specific target | `ITargetedMessage` | `var m = new Heal(10); m.EmitAt(playerId);` | +| **`EmitFrom(source)`** | Broadcast from a source | `IBroadcastMessage` | `var m = new TookDamage(5); m.EmitFrom(enemyId);` | ## Quick Start Examples @@ -81,7 +81,7 @@ These helpers mirror the struct/class/targeted/broadcast overloads available on ## Understanding Each Shorthand -### `Emit()` — Global Broadcast (Untargeted) +### `Emit()` -- Global Broadcast (Untargeted) **When to use:** Scene-wide notifications that everyone should know about. @@ -109,7 +109,7 @@ MessageHandler.MessageBus.UntargetedBroadcast(ref msg); - Settings changed - Level-up notifications -### `EmitAt(target)` — Targeted Message +### `EmitAt(target)` -- Targeted Message **When to use:** Commands or notifications for a specific GameObject or Component. @@ -130,7 +130,7 @@ heal.EmitAt(player); // Only the player receives this - UI updates for specific panels - State changes for specific objects -### `EmitFrom(source)` — Broadcast from Source +### `EmitFrom(source)` -- Broadcast from Source **When to use:** Announcements where the source identity matters. @@ -157,11 +157,11 @@ damage.EmitFrom(enemy); // Anyone interested in this enemy receives it ### Quick Example: The `this` Trap ```csharp -// ❌ COMMON MISTAKE +// No COMMON MISTAKE _ = token.RegisterGameObjectTargeted(gameObject, OnHeal); heal.EmitAt(this); // Won't work! 'this' is a Component, not a GameObject -// ✅ FIXES +// Yes FIXES // Option 1: Both use GameObject heal.EmitAt(gameObject); @@ -203,7 +203,7 @@ void OnAnyDamage(ref InstanceId source, ref TookDamage msg) --- -##### 📖 For the full deep dive on GameObject vs Component targeting and global observers, see [Targeting & Context](../concepts/targeting-and-context.md) +##### For the full deep dive on GameObject vs Component targeting and global observers, see [Targeting & Context](../concepts/targeting-and-context.md) --- @@ -323,7 +323,7 @@ Unity distinguishes between GameObjects and Components. Targeted and Broadcast m - `EmitGameObjectTargeted` / `EmitComponentTargeted` - `EmitGameObjectBroadcast` / `EmitComponentBroadcast` -If a handler isn’t firing, first suspect a GameObject vs Component mismatch. See the Troubleshooting checklist below. +If a handler isn't firing, first suspect a GameObject vs Component mismatch. See the Troubleshooting checklist below. ## Troubleshooting @@ -461,8 +461,8 @@ public class AchievementTracker : MessageAwareComponent ## See Also -- **[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 -- **[String Messages](string-messages.md)** — More about string message helpers -- **[Diagnostics](../guides/diagnostics.md)** — Debugging tools and Inspector integration +- **[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 +- **[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 5aa545c9..426dcfe9 100644 --- a/docs/advanced/message-bus-providers.md +++ b/docs/advanced/message-bus-providers.md @@ -30,10 +30,10 @@ This guide covers: Providers abstract away the details of how a message bus is resolved. This lets you: -- **Swap buses at design time** — Change a ScriptableObject reference without modifying code -- **Integrate with DI containers** — Resolve buses from your container -- **Support runtime reconfiguration** — Change which bus a component uses dynamically -- **Isolate scenes or features** — Use different buses for different parts of your game +- **Swap buses at design time** -- Change a ScriptableObject reference without modifying code +- **Integrate with DI containers** -- Resolve buses from your container +- **Support runtime reconfiguration** -- Change which bus a component uses dynamically +- **Isolate scenes or features** -- Use different buses for different parts of your game If you're using the default global bus everywhere, you probably don't need providers. They're most useful when you need flexibility or are integrating with DI frameworks. @@ -175,7 +175,7 @@ IMessageBus bus = handle.ResolveBus(); The handle has two fields: -1. `Provider` — A serialized reference to a ScriptableObject provider (visible in Inspector) +1. `Provider` -- A serialized reference to a ScriptableObject provider (visible in Inspector) 1. A runtime provider instance (not serialized) When you call `TryGetProvider()` or `ResolveBus()`, it checks: @@ -490,8 +490,8 @@ public class RuntimeReconfiguration : MonoBehaviour ## See Also -- **[Runtime Configuration](runtime-configuration.md)** — Setting and overriding global buses, re-binding registrations -- **[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 -- **[Unity Integration](../guides/unity-integration.md)** — MessagingComponent and MessageAwareComponent deep dive -- **[Back to Documentation Hub](../getting-started/index.md)** — Browse all docs +- **[Runtime Configuration](runtime-configuration.md)** -- Setting and overriding global buses, re-binding registrations +- **[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 +- **[Unity Integration](../guides/unity-integration.md)** -- MessagingComponent and MessageAwareComponent deep dive +- **[Back to Documentation Hub](../getting-started/index.md)** -- Browse all docs diff --git a/docs/advanced/registration-builders.md b/docs/advanced/registration-builders.md index 4cef2f86..29c8c728 100644 --- a/docs/advanced/registration-builders.md +++ b/docs/advanced/registration-builders.md @@ -243,10 +243,10 @@ public readonly struct MessageRegistrationLifecycle ### Callback Order -1. `OnBuild` — Immediately after lease creation, before activation -1. `OnActivate` — When `Activate()` is called (or automatically if `ActivateOnBuild = true`) -1. `OnDeactivate` — When `Deactivate()` is called or during disposal while active -1. `OnDispose` — During `Dispose()`, after deactivation +1. `OnBuild` -- Immediately after lease creation, before activation +1. `OnActivate` -- When `Activate()` is called (or automatically if `ActivateOnBuild = true`) +1. `OnDeactivate` -- When `Deactivate()` is called or during disposal while active +1. `OnDispose` -- During `Dispose()`, after deactivation All callbacks receive the `MessageRegistrationToken` as a parameter. @@ -440,5 +440,5 @@ MessageRegistrationBuildOptions options = new MessageRegistrationBuildOptions ## See Also -- [Message Bus Providers](message-bus-providers.md) — More on the provider system -- [Runtime Configuration](runtime-configuration.md) — Dynamic reconfiguration options +- [Message Bus Providers](message-bus-providers.md) -- More on the provider system +- [Runtime Configuration](runtime-configuration.md) -- Dynamic reconfiguration options diff --git a/docs/advanced/runtime-configuration.md b/docs/advanced/runtime-configuration.md index 849a194f..0fed72a8 100644 --- a/docs/advanced/runtime-configuration.md +++ b/docs/advanced/runtime-configuration.md @@ -16,10 +16,10 @@ This guide covers how to configure message buses at runtime and retarget existin You'll use runtime configuration when you need to: -- **Integrate with DI containers** — Replace the global bus with a container-managed instance -- **Isolate tests** — Ensure each test uses its own bus to prevent interference -- **Support multiple game modes** — Use different buses for different gameplay contexts (e.g., main game vs. mini-games) -- **Dynamically reconfigure components** — Change which bus a component listens to after it's been created +- **Integrate with DI containers** -- Replace the global bus with a container-managed instance +- **Isolate tests** -- Ensure each test uses its own bus to prevent interference +- **Support multiple game modes** -- Use different buses for different gameplay contexts (e.g., main game vs. mini-games) +- **Dynamically reconfigure components** -- Change which bus a component listens to after it's been created If you're just getting started with DxMessaging, you probably don't need these features yet. The default global bus works great for most scenarios. @@ -401,8 +401,8 @@ public class DynamicComponentManager ## See Also -- **[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 -- **[Back to Documentation Hub](../getting-started/index.md)** — Browse all docs +- **[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 +- **[Back to Documentation Hub](../getting-started/index.md)** -- Browse all docs diff --git a/docs/advanced/string-messages.md b/docs/advanced/string-messages.md index ea52c5de..fdce334e 100644 --- a/docs/advanced/string-messages.md +++ b/docs/advanced/string-messages.md @@ -1,6 +1,6 @@ # String Messages (Prototyping & Debugging) -Sometimes you just want to fire a quick string without defining a formal message. DxMessaging provides three built‑in types: +Sometimes you just want to fire a quick string without defining a formal message. DxMessaging provides three built-in types: - `StringMessage`: targeted string message (to a specific recipient) - `GlobalStringMessage`: untargeted string message (global broadcast) @@ -13,7 +13,7 @@ When to use When not to use -- Shipping gameplay code that benefits from compile‑time safety and structure. Prefer explicit message structs. +- Shipping gameplay code that benefits from compile-time safety and structure. Prefer explicit message structs. Examples @@ -63,7 +63,7 @@ void OnTextToMe(ref StringMessage m) => Debug.Log($"To me: {m.message}"); Tips - Strings are great for glue and debugging; convert hot paths to typed messages for performance and clarity. -- You can combine with interceptors/post‑processors for logging and filtering. +- You can combine with interceptors/post-processors for logging and filtering. Related diff --git a/docs/architecture/comparisons.md b/docs/architecture/comparisons.md index c824a2c1..9b0ef31f 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,886,000 | No | -| UniRx MessageBroker | 18,002,000 | No | -| MessagePipe (Global) | 97,748,000 | No | -| Zenject SignalBus | 2,350,000 | Yes | +| DxMessaging (Untargeted) - No-Copy | 19,842,500 | No | +| UniRx MessageBroker | 17,904,822 | No | +| MessagePipe (Global) | 97,610,562 | No | +| Zenject SignalBus | 2,569,043 | Yes | ### Comparisons (macOS) @@ -59,29 +59,29 @@ This section compares DxMessaging with other popular Unity messaging/eventing li ```text Need absolute simplest pub/sub setup (zero boilerplate)? - → Use UniRx MessageBroker (publish/receive in 2 lines) + -> Use UniRx MessageBroker (publish/receive in 2 lines) Need complex event stream transformations (debounce, throttle, combine)? - → Use UniRx (reactive programming paradigm) + -> Use UniRx (reactive programming paradigm) Already using Dependency Injection (Zenject, VContainer, Reflex)? - → Use MessagePipe (DI-first, best performance) or Zenject Signals (if on Zenject) - → Or DxMessaging (integrates with DI, see Integrations guides for Zenject/VContainer/Reflex) + -> Use MessagePipe (DI-first, best performance) or Zenject Signals (if on Zenject) + -> Or DxMessaging (integrates with DI, see Integrations guides for Zenject/VContainer/Reflex) Need Unity-specific features (GameObject targeting, Inspector debugging, global observers)? - → Use DxMessaging (Unity-first design) + -> Use DxMessaging (Unity-first design) Want plug-and-play with zero dependencies? - → Use DxMessaging (no setup required) + -> Use DxMessaging (no setup required) Maximum raw throughput is a top priority? - → Use MessagePipe (highest ops/sec in benchmarks) + -> Use MessagePipe (highest ops/sec in benchmarks) Need message validation, interception, or ordered execution? - → Use DxMessaging (interceptor pipeline, priority-based ordering) + -> Use DxMessaging (interceptor pipeline, priority-based ordering) Simple pub/sub with automatic lifecycle management and debugging? - → Use DxMessaging (automatic cleanup, priorities, validation, Inspector) + -> Use DxMessaging (automatic cleanup, priorities, validation, Inspector) ``` ##### One-Line Summary for Each @@ -167,20 +167,20 @@ leftClick.Merge(rightClick).Subscribe(_ => Debug.Log("Any click!")); #### What Problems It Solves -- ✅ **Complex event streams:** Chain, filter, combine, and transform events with operators -- ✅ **Async operations:** Better async/await alternative with cancellation -- ✅ **Temporal logic:** Time-based operations (throttle, debounce, sample) -- ✅ **UI reactivity:** Bind UI elements to data streams reactively -- ✅ **Memory management:** Disposable subscriptions prevent leaks +- [x] **Complex event streams:** Chain, filter, combine, and transform events with operators +- [x] **Async operations:** Better async/await alternative with cancellation +- [x] **Temporal logic:** Time-based operations (throttle, debounce, sample) +- [x] **UI reactivity:** Bind UI elements to data streams reactively +- [x] **Memory management:** Disposable subscriptions prevent leaks #### What Problems It Doesn't Solve Well -- ⚠️ **Simple pub/sub:** MessageBroker handles this well, but reactive operators may add complexity for simple use cases -- ❌ **Execution order control:** No built-in priority system for handler ordering -- ❌ **Message validation/interception:** No pre-processing pipeline to validate or transform messages before handlers -- ❌ **Unity Inspector debugging:** No Inspector integration to visualize message flow -- ❌ **GameObject/Component targeting:** Not designed for Unity-specific targeting patterns -- ❌ **Global message observation:** Cannot easily listen to all instances of a message type across different sources +- **Simple pub/sub:** MessageBroker handles this well, but reactive operators may add complexity for simple use cases +- [ ] **Execution order control:** No built-in priority system for handler ordering +- [ ] **Message validation/interception:** No pre-processing pipeline to validate or transform messages before handlers +- [ ] **Unity Inspector debugging:** No Inspector integration to visualize message flow +- [ ] **GameObject/Component targeting:** Not designed for Unity-specific targeting patterns +- [ ] **Global message observation:** Cannot easily listen to all instances of a message type across different sources #### Performance Characteristics @@ -198,59 +198,59 @@ leftClick.Merge(rightClick).Subscribe(_ => Debug.Log("Any click!")); #### Ease of Understanding -- ⭐⭐⭐⭐⭐ (Very easy) - MessageBroker pub/sub is intuitive and straightforward -- ⭐⭐⭐ (Moderate to difficult) - Advanced reactive operators require learning +- (5/5) (Very easy) - MessageBroker pub/sub is intuitive and straightforward +- (3/5) (Moderate to difficult) - Advanced reactive operators require learning - Stream operator code is concise but requires understanding of reactive patterns - Hard to debug complex observable chains without Rx knowledge - For advanced features: Team buy-in essential; not intuitive for traditional event-driven developers #### When UniRx Wins -- ✅ Simple pub/sub with minimal setup (MessageBroker is straightforward) -- ✅ Complex event transformations (e.g., double-click, gesture detection) -- ✅ Combining multiple input sources -- ✅ Time-based logic (debounce, throttle, sample) -- ✅ UI data binding with reactive updates -- ✅ Teams familiar with reactive programming +- [x] Simple pub/sub with minimal setup (MessageBroker is straightforward) +- [x] Complex event transformations (e.g., double-click, gesture detection) +- [x] Combining multiple input sources +- [x] Time-based logic (debounce, throttle, sample) +- [x] UI data binding with reactive updates +- [x] Teams familiar with reactive programming #### When DxMessaging Wins -- ✅ Need Unity-specific features (GameObject targeting, lifecycle management) -- ✅ Execution order matters (priority-based ordering) -- ✅ Message validation/interception needed (interceptor pipeline) -- ✅ Inspector debugging required (message history, registration view) -- ✅ Direct GameObject/Component targeting -- ✅ Global message observation (listen to all instances of a message type) -- ✅ Late-stage processing (post-processors after all handlers) -- ✅ Automatic lifecycle management (prevents common memory leaks) -- ✅ Teams unfamiliar with reactive programming (and don't need reactive features) +- [x] Need Unity-specific features (GameObject targeting, lifecycle management) +- [x] Execution order matters (priority-based ordering) +- [x] Message validation/interception needed (interceptor pipeline) +- [x] Inspector debugging required (message history, registration view) +- [x] Direct GameObject/Component targeting +- [x] Global message observation (listen to all instances of a message type) +- [x] Late-stage processing (post-processors after all handlers) +- [x] Automatic lifecycle management (prevents common memory leaks) +- [x] Teams unfamiliar with reactive programming (and don't need reactive features) #### Direct Comparison -| Aspect | UniRx | DxMessaging | -| ------------------------ | ---------------------- | ------------------------ | -| **Primary Use Case** | Stream transformations | Pub/sub messaging | -| **Unity Compatibility** | ✅ Built for Unity | ✅ Built for Unity | -| **Dependencies** | ✅ Standalone | ✅ Standalone | -| **Performance** | 18M ops/sec | 14M ops/sec | -| **Allocations** | ⚠️ Can allocate | ✅ Zero (structs) | -| **Learning Curve** | ⭐ Steep (Rx paradigm) | ⭐⭐⭐ Moderate | -| **Setup Complexity** | ⭐⭐⭐⭐⭐ Low | ⭐⭐⭐⭐⭐ Plug-and-play | -| **DI Integration** | ⚠️ Optional | ⚠️ Optional | -| **Async/Await** | ✅ Observables | ⚠️ Manual | -| **Type Safety** | ✅ Strong | ✅ Strong | -| **Lifecycle Management** | ⚠️ Manual dispose | ✅ Automatic | -| **Execution Order** | ❌ Not built-in | ✅ Priority-based | -| **GameObject Targeting** | ❌ Not designed for | ✅ Built-in | -| **Unity Integration** | ⭐⭐⭐⭐ Good (UI) | ⭐⭐⭐⭐⭐ Deep | -| **Inspector Debugging** | ❌ No | ✅ History + stats | -| **Interceptors** | ❌ Not built-in | ✅ Full pipeline | -| **Global Observers** | ❌ Not built-in | ✅ Listen to all | -| **Post-Processing** | ❌ Not built-in | ✅ Dedicated stage | -| **Testability** | ⭐⭐⭐⭐ Good | ⭐⭐⭐⭐⭐ Excellent | -| **Decoupling** | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐⭐ Excellent | -| **Temporal Operators** | ✅ Extensive (Rx) | ❌ Not built-in | -| **Complex Stream Logic** | ✅ LINQ-style | ❌ Not designed for | +| Aspect | UniRx | DxMessaging | +| ------------------------ | ---------------------- | ----------------- | +| **Primary Use Case** | Stream transformations | Pub/sub messaging | +| **Unity Compatibility** | Built for Unity | Built for Unity | +| **Dependencies** | Standalone | Standalone | +| **Performance** | 18M ops/sec | 14M ops/sec | +| **Allocations** | Can allocate | Zero (structs) | +| **Learning Curve** | Steep (Rx paradigm) | Moderate | +| **Setup Complexity** | Low | Plug-and-play | +| **DI Integration** | Optional | Optional | +| **Async/Await** | Observables | Manual | +| **Type Safety** | Strong | Strong | +| **Lifecycle Management** | Manual dispose | Automatic | +| **Execution Order** | Not built-in | Priority-based | +| **GameObject Targeting** | Not designed for | Built-in | +| **Unity Integration** | Good (UI) | Deep | +| **Inspector Debugging** | No | History + stats | +| **Interceptors** | Not built-in | Full pipeline | +| **Global Observers** | Not built-in | Listen to all | +| **Post-Processing** | Not built-in | Dedicated stage | +| **Testability** | Good | Excellent | +| **Decoupling** | Excellent | Excellent | +| **Temporal Operators** | Extensive (Rx) | Not built-in | +| **Complex Stream Logic** | LINQ-style | Not designed for | **Bottom Line:** UniRx is well-suited for complex event stream transformations and reactive programming patterns, with MessageBroker providing straightforward pub/sub setup. DxMessaging focuses on straightforward pub/sub communication with control, validation, debugging, and Unity-specific features. Both are viable options depending on your needs: choose UniRx when you need stream operators, reactive patterns, or simple zero-setup pub/sub; choose DxMessaging when you need Unity-native lifecycle integration, execution control, and debugging tools. @@ -317,21 +317,21 @@ public class AchievementSystem #### What Problems It Solves -- ✅ **Performance:** Zero allocations with struct-based messages (see [benchmarks](https://github.com/wallstop/DxMessaging/tree/master/Tests/Runtime/Benchmarks) for comparison data) -- ✅ **DI integration:** First-class support for dependency injection -- ✅ **Async messaging:** Native async/await without blocking -- ✅ **Leak detection:** Analyzer catches forgotten subscriptions at compile-time -- ✅ **Flexibility:** Keyed, keyless, buffered, request/response patterns -- ✅ **Cross-platform:** Works in Unity, .NET, Blazor, etc. +- [x] **Performance:** Zero allocations with struct-based messages (see [benchmarks](https://github.com/wallstop/DxMessaging/tree/master/Tests/Runtime/Benchmarks) for comparison data) +- [x] **DI integration:** First-class support for dependency injection +- [x] **Async messaging:** Native async/await without blocking +- [x] **Leak detection:** Analyzer catches forgotten subscriptions at compile-time +- [x] **Flexibility:** Keyed, keyless, buffered, request/response patterns +- [x] **Cross-platform:** Works in Unity, .NET, Blazor, etc. #### What Problems It Doesn't Solve Well -- ❌ **Unity-specific integration:** No built-in Unity MonoBehaviour lifecycle management or GameObject targeting -- ❌ **Inspector debugging:** No visual debugging or message history in Unity Inspector -- ❌ **Execution order control:** No priority system (handlers execute in subscription order) -- ❌ **Setup complexity:** Requires DI container configuration (VContainer/Zenject setup needed) -- ❌ **Global message observation:** No built-in way to listen to all instances of a message across different keys/sources -- ❌ **Standalone use:** Designed for DI-first architecture (less suitable for non-DI projects) +- [ ] **Unity-specific integration:** No built-in Unity MonoBehaviour lifecycle management or GameObject targeting +- [ ] **Inspector debugging:** No visual debugging or message history in Unity Inspector +- [ ] **Execution order control:** No priority system (handlers execute in subscription order) +- [ ] **Setup complexity:** Requires DI container configuration (VContainer/Zenject setup needed) +- [ ] **Global message observation:** No built-in way to listen to all instances of a message across different keys/sources +- [ ] **Standalone use:** Designed for DI-first architecture (less suitable for non-DI projects) #### Performance Characteristics @@ -349,57 +349,57 @@ public class AchievementSystem #### Ease of Understanding -- ⭐⭐⭐⭐ (Moderate) +- (4/5) (Moderate) - Clean, generic interfaces once you understand DI - Code is straightforward for developers familiar with DI patterns - Harder for teams without DI experience #### When MessagePipe Wins -- ✅ Performance-critical applications (high message throughput) -- ✅ Projects already using DI (VContainer, Zenject, etc.) -- ✅ Cross-platform .NET projects (not Unity-only) -- ✅ Need async messaging with cancellation -- ✅ Large-scale projects with DI architecture -- ✅ Teams experienced with DI patterns +- [x] Performance-critical applications (high message throughput) +- [x] Projects already using DI (VContainer, Zenject, etc.) +- [x] Cross-platform .NET projects (not Unity-only) +- [x] Need async messaging with cancellation +- [x] Large-scale projects with DI architecture +- [x] Teams experienced with DI patterns #### When DxMessaging Wins -- ✅ Unity-first projects (not cross-platform .NET) -- ✅ Unity lifecycle management needed (automatic MonoBehaviour cleanup) -- ✅ Inspector debugging essential (message history visualization) -- ✅ Execution order control needed (priority-based handlers) -- ✅ Message validation/interception required (interceptor pipeline) -- ✅ Global message observation needed (listen to all message instances) -- ✅ Post-processing stage needed (analytics, logging after handlers) -- ✅ Teams without DI experience or projects not using DI -- ✅ Plug-and-play simplicity preferred over DI configuration +- [x] Unity-first projects (not cross-platform .NET) +- [x] Unity lifecycle management needed (automatic MonoBehaviour cleanup) +- [x] Inspector debugging essential (message history visualization) +- [x] Execution order control needed (priority-based handlers) +- [x] Message validation/interception required (interceptor pipeline) +- [x] Global message observation needed (listen to all message instances) +- [x] Post-processing stage needed (analytics, logging after handlers) +- [x] Teams without DI experience or projects not using DI +- [x] Plug-and-play simplicity preferred over DI configuration #### Direct Comparison -| Aspect | MessagePipe | DxMessaging | -| ------------------------ | --------------------------- | ------------------------ | -| **Primary Use Case** | High-perf DI messaging | Pub/sub messaging | -| **Unity Compatibility** | ✅ Built for Unity | ✅ Built for Unity | -| **Dependencies** | ⚠️ DI container required | ✅ Standalone | -| **Performance** | 97M ops/sec | 14M ops/sec | -| **Allocations** | ✅ Zero (structs) | ✅ Zero (structs) | -| **Learning Curve** | ⭐⭐⭐⭐ Moderate (DI) | ⭐⭐⭐ Moderate | -| **Setup Complexity** | ⭐⭐⭐ DI setup required | ⭐⭐⭐⭐⭐ Plug-and-play | -| **DI Integration** | ✅ First-class | ⚠️ Optional | -| **Async/Await** | ✅ Native | ⚠️ Manual | -| **Type Safety** | ✅ Strong | ✅ Strong | -| **Lifecycle Management** | ⚠️ Manual dispose | ✅ Automatic | -| **Execution Order** | ❌ Subscription order | ✅ Priority-based | -| **GameObject Targeting** | ❌ Not built-in | ✅ Built-in | -| **Unity Integration** | ⭐⭐⭐ Basic (no lifecycle) | ⭐⭐⭐⭐⭐ Deep | -| **Inspector Debugging** | ❌ No | ✅ History + stats | -| **Interceptors** | ⚠️ Filters | ✅ Full pipeline | -| **Global Observers** | ❌ Not built-in | ✅ Listen to all | -| **Post-Processing** | ⚠️ Via filters | ✅ Dedicated stage | -| **Testability** | ⭐⭐⭐⭐⭐ DI mocking | ⭐⭐⭐⭐⭐ Local buses | -| **Decoupling** | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐⭐ Excellent | -| **Leak Detection** | ✅ Roslyn analyzer | ✅ Automatic lifecycle | +| Aspect | MessagePipe | DxMessaging | +| ------------------------ | ---------------------- | ------------------- | +| **Primary Use Case** | High-perf DI messaging | Pub/sub messaging | +| **Unity Compatibility** | Built for Unity | Built for Unity | +| **Dependencies** | DI container required | Standalone | +| **Performance** | 97M ops/sec | 14M ops/sec | +| **Allocations** | Zero (structs) | Zero (structs) | +| **Learning Curve** | Moderate (DI) | Moderate | +| **Setup Complexity** | DI setup required | Plug-and-play | +| **DI Integration** | First-class | Optional | +| **Async/Await** | Native | Manual | +| **Type Safety** | Strong | Strong | +| **Lifecycle Management** | Manual dispose | Automatic | +| **Execution Order** | Subscription order | Priority-based | +| **GameObject Targeting** | Not built-in | Built-in | +| **Unity Integration** | Basic (no lifecycle) | Deep | +| **Inspector Debugging** | No | History + stats | +| **Interceptors** | Filters | Full pipeline | +| **Global Observers** | Not built-in | Listen to all | +| **Post-Processing** | Via filters | Dedicated stage | +| **Testability** | DI mocking | Local buses | +| **Decoupling** | Excellent | Excellent | +| **Leak Detection** | Roslyn analyzer | Automatic lifecycle | **Bottom Line:** MessagePipe is the performance king with DI-first design. DxMessaging is Unity-first with lifecycle awareness and debugging. Use MessagePipe if you have DI infrastructure and need maximum performance. Use DxMessaging if you want Unity-native messaging with automatic lifecycle management. @@ -482,23 +482,23 @@ public class AchievementSystem #### What Problems It Solves -- ✅ **Decoupling:** Classes communicate without direct references -- ✅ **DI integration:** Seamless with Zenject dependency injection -- ✅ **Testability:** Easy to mock SignalBus in tests -- ✅ **Type safety:** Strongly-typed signal classes -- ✅ **Subscriber validation:** Can enforce required subscribers -- ✅ **Async support:** Fire signals synchronously or asynchronously +- [x] **Decoupling:** Classes communicate without direct references +- [x] **DI integration:** Seamless with Zenject dependency injection +- [x] **Testability:** Easy to mock SignalBus in tests +- [x] **Type safety:** Strongly-typed signal classes +- [x] **Subscriber validation:** Can enforce required subscribers +- [x] **Async support:** Fire signals synchronously or asynchronously #### What Problems It Doesn't Solve Well -- ❌ **Zenject dependency:** Must use Zenject/Extenject framework; not standalone -- ❌ **Performance overhead:** Higher than lightweight messaging (DI resolution cost) -- ❌ **Execution order control:** No priority system for handler ordering -- ❌ **Inspector debugging:** No visual message history or flow visualization -- ❌ **Allocations:** Signal parameters can cause allocations depending on usage -- ❌ **Validation pipeline:** No built-in interceptor or pre-processing stage -- ❌ **Global observation:** Cannot easily listen to all signal fires across the system -- ❌ **Post-processing:** No dedicated after-handler stage for analytics/logging +- [ ] **Zenject dependency:** Must use Zenject/Extenject framework; not standalone +- [ ] **Performance overhead:** Higher than lightweight messaging (DI resolution cost) +- [ ] **Execution order control:** No priority system for handler ordering +- [ ] **Inspector debugging:** No visual message history or flow visualization +- [ ] **Allocations:** Signal parameters can cause allocations depending on usage +- [ ] **Validation pipeline:** No built-in interceptor or pre-processing stage +- [ ] **Global observation:** Cannot easily listen to all signal fires across the system +- [ ] **Post-processing:** No dedicated after-handler stage for analytics/logging #### Performance Characteristics @@ -516,56 +516,56 @@ public class AchievementSystem #### Ease of Understanding -- ⭐⭐⭐ (Moderate) +- (3/5) (Moderate) - Clear once you understand Zenject - Signal concept is straightforward - Setup (installers, bindings) adds complexity #### When Zenject Signals Win -- ✅ Already using Zenject for dependency injection -- ✅ Testability is critical (DI makes mocking easy) -- ✅ Need subscriber validation (ensure handlers exist) -- ✅ Team experienced with Zenject -- ✅ Want DI-managed lifecycle +- [x] Already using Zenject for dependency injection +- [x] Testability is critical (DI makes mocking easy) +- [x] Need subscriber validation (ensure handlers exist) +- [x] Team experienced with Zenject +- [x] Want DI-managed lifecycle #### When DxMessaging Wins -- ✅ Not using Zenject/Extenject (or prefer standalone solution) -- ✅ Performance critical (lower overhead than DI-based signals) -- ✅ Execution order control needed (priority-based handlers) -- ✅ Inspector debugging required (message history visualization) -- ✅ Message validation/interception needed (interceptor pipeline) -- ✅ Global message observation needed (listen to all signal fires) -- ✅ Post-processing stage needed (analytics after handlers) -- ✅ Zero-allocation messaging essential (struct-based) -- ✅ GameObject/Component targeting needed (Unity-specific patterns) -- ✅ Plug-and-play simplicity preferred over DI setup +- [x] Not using Zenject/Extenject (or prefer standalone solution) +- [x] Performance critical (lower overhead than DI-based signals) +- [x] Execution order control needed (priority-based handlers) +- [x] Inspector debugging required (message history visualization) +- [x] Message validation/interception needed (interceptor pipeline) +- [x] Global message observation needed (listen to all signal fires) +- [x] Post-processing stage needed (analytics after handlers) +- [x] Zero-allocation messaging essential (struct-based) +- [x] GameObject/Component targeting needed (Unity-specific patterns) +- [x] Plug-and-play simplicity preferred over DI setup #### Direct Comparison -| Aspect | Zenject Signals | DxMessaging | -| ------------------------ | ---------------------------- | ------------------------ | -| **Primary Use Case** | DI-integrated messaging | Pub/sub messaging | -| **Unity Compatibility** | ✅ Built for Unity | ✅ Built for Unity | -| **Dependencies** | ❌ Zenject required | ✅ Standalone | -| **Performance** | 2.5M ops/sec | 14M ops/sec | -| **Allocations** | ⚠️ Can allocate | ✅ Zero (structs) | -| **Learning Curve** | ⭐⭐ Steep (Zenject+Signals) | ⭐⭐⭐ Moderate | -| **Setup Complexity** | ⭐⭐ Installers required | ⭐⭐⭐⭐⭐ Plug-and-play | -| **DI Integration** | ✅ Required (Zenject) | ⚠️ Optional | -| **Async/Await** | ✅ RunAsync support | ⚠️ Manual | -| **Type Safety** | ✅ Strong | ✅ Strong | -| **Lifecycle Management** | ⚠️ DI-managed | ✅ Automatic | -| **Execution Order** | ❌ Not built-in | ✅ Priority-based | -| **GameObject Targeting** | ❌ Not built-in | ✅ Built-in | -| **Unity Integration** | ⭐⭐⭐⭐ DI-managed | ⭐⭐⭐⭐⭐ Deep | -| **Inspector Debugging** | ❌ No | ✅ History + stats | -| **Interceptors** | ⚠️ Subscriber validation | ✅ Full pipeline | -| **Global Observers** | ❌ Not built-in | ✅ Listen to all | -| **Post-Processing** | ❌ Not built-in | ✅ Dedicated stage | -| **Testability** | ⭐⭐⭐⭐⭐ DI mocking | ⭐⭐⭐⭐⭐ Local buses | -| **Decoupling** | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐⭐ Excellent | +| Aspect | Zenject Signals | DxMessaging | +| ------------------------ | ----------------------- | ----------------- | +| **Primary Use Case** | DI-integrated messaging | Pub/sub messaging | +| **Unity Compatibility** | Built for Unity | Built for Unity | +| **Dependencies** | Zenject required | Standalone | +| **Performance** | 2.5M ops/sec | 14M ops/sec | +| **Allocations** | Can allocate | Zero (structs) | +| **Learning Curve** | Steep (Zenject+Signals) | Moderate | +| **Setup Complexity** | Installers required | Plug-and-play | +| **DI Integration** | Required (Zenject) | Optional | +| **Async/Await** | RunAsync support | Manual | +| **Type Safety** | Strong | Strong | +| **Lifecycle Management** | DI-managed | Automatic | +| **Execution Order** | Not built-in | Priority-based | +| **GameObject Targeting** | Not built-in | Built-in | +| **Unity Integration** | DI-managed | Deep | +| **Inspector Debugging** | No | History + stats | +| **Interceptors** | Subscriber validation | Full pipeline | +| **Global Observers** | Not built-in | Listen to all | +| **Post-Processing** | Not built-in | Dedicated stage | +| **Testability** | DI mocking | Local buses | +| **Decoupling** | Excellent | Excellent | **Bottom Line:** Zenject Signals are well-suited if you're already invested in Zenject and value testability through DI. DxMessaging offers standalone messaging without requiring DI setup, and includes Unity-specific features. @@ -579,17 +579,17 @@ public class AchievementSystem **Core Philosophy:** Designer-driven, asset-based communication where systems communicate through serialized SO assets instead of direct references. -**⚠️ Contested Pattern:** SOA has both proponents and critics. Supporters value its designer-friendly workflow and Inspector-based event wiring. Critics raise concerns about scalability and maintainability at scale. See [Anti-ScriptableObject Architecture](https://github.com/cathei/AntiScriptableObjectArchitecture) for one perspective on the criticisms. Unity recommends ScriptableObjects for **immutable design data**, not mutable runtime state. +**Contested Pattern:** SOA has both proponents and critics. Supporters value its designer-friendly workflow and Inspector-based event wiring. Critics raise concerns about scalability and maintainability at scale. See [Anti-ScriptableObject Architecture](https://github.com/cathei/AntiScriptableObjectArchitecture) for one perspective on the criticisms. Unity recommends ScriptableObjects for **immutable design data**, not mutable runtime state. ### Quick Comparison -| Aspect | SOA (GameEvent/Variables) | DxMessaging | -| -------------------- | ------------------------------------------------------------------------- | ----------------------------------- | -| **Designer Control** | ✅ High (create events in Inspector) | ❌ Low (code-driven) | -| **Type Safety** | ⚠️ Mixed (SO refs typed, but UnityEvent wiring loses compile-time safety) | ✅ Strong (compile-time validation) | -| **Lifecycle** | ⚠️ Manual (assets persist) | ✅ Automatic (tokens clean up) | -| **Performance** | ⚠️ List iteration, UnityAction overhead | ✅ Zero-allocation structs | -| **Testability** | ⚠️ Requires SO asset cleanup | ✅ Isolated buses per test | +| Aspect | SOA (GameEvent/Variables) | DxMessaging | +| -------------------- | ---------------------------------------------------------------------- | -------------------------------- | +| **Designer Control** | High (create events in Inspector) | Low (code-driven) | +| **Type Safety** | Mixed (SO refs typed, but UnityEvent wiring loses compile-time safety) | Strong (compile-time validation) | +| **Lifecycle** | Manual (assets persist) | Automatic (tokens clean up) | +| **Performance** | List iteration, UnityAction overhead | Zero-allocation structs | +| **Testability** | Requires SO asset cleanup | Isolated buses per test | ### When to Use Each @@ -616,7 +616,7 @@ public class AchievementSystem For detailed migration patterns, interoperability strategies, and code examples, see: -#### → [SOA Compatibility Guide](../guides/patterns.md#14-compatibility-with-scriptable-object-architecture-soa) +#### to [SOA Compatibility Guide](../guides/patterns.md#14-compatibility-with-scriptable-object-architecture-soa) Includes: @@ -687,22 +687,22 @@ public class UI : MonoBehaviour #### What Problems It Solves -- ✅ **Simple callbacks:** Straightforward notification pattern -- ✅ **Type safety:** Compile-time checking prevents errors -- ✅ **Return values:** Can get feedback from event handlers -- ✅ **Performance:** Minimal overhead, direct invocation -- ✅ **Familiarity:** Every C# developer knows events -- ✅ **No dependencies:** Built into the language +- [x] **Simple callbacks:** Straightforward notification pattern +- [x] **Type safety:** Compile-time checking prevents errors +- [x] **Return values:** Can get feedback from event handlers +- [x] **Performance:** Minimal overhead, direct invocation +- [x] **Familiarity:** Every C# developer knows events +- [x] **No dependencies:** Built into the language #### What Problems It Doesn't Solve Well -- ❌ **Memory leaks:** Forgetting to unsubscribe causes leaks -- ❌ **Tight coupling:** Subscribers need direct references to event sources -- ❌ **Execution order:** Undefined handler invocation order -- ❌ **Lifecycle management:** Manual subscribe/unsubscribe in OnEnable/OnDisable -- ❌ **Debugging:** No visibility into who's subscribed or when events fire -- ❌ **Validation/interception:** No pipeline to modify or validate before handlers -- ❌ **Global observation:** Cannot listen to all events across the system +- [ ] **Memory leaks:** Forgetting to unsubscribe causes leaks +- [ ] **Tight coupling:** Subscribers need direct references to event sources +- [ ] **Execution order:** Undefined handler invocation order +- [ ] **Lifecycle management:** Manual subscribe/unsubscribe in OnEnable/OnDisable +- [ ] **Debugging:** No visibility into who's subscribed or when events fire +- [ ] **Validation/interception:** No pipeline to modify or validate before handlers +- [ ] **Global observation:** Cannot listen to all events across the system #### Performance Characteristics @@ -719,59 +719,59 @@ public class UI : MonoBehaviour #### Ease of Understanding -- ⭐⭐⭐⭐⭐ (Very easy) +- (5/5) (Very easy) - Familiar to all C# developers - Straightforward mental model - Easy to debug with breakpoints #### When C# Events Win -- ✅ Small, stable scope (5-10 events max) -- ✅ Need return values or `out` parameters -- ✅ Writing a library (DxMessaging is Unity-specific) -- ✅ Simple, local communication within a class or module -- ✅ Team is C# experts, Unity beginners -- ✅ Performance is absolutely critical (lowest overhead) -- ✅ Quick prototypes or game jams +- [x] Small, stable scope (5-10 events max) +- [x] Need return values or `out` parameters +- [x] Writing a library (DxMessaging is Unity-specific) +- [x] Simple, local communication within a class or module +- [x] Team is C# experts, Unity beginners +- [x] Performance is absolutely critical (lowest overhead) +- [x] Quick prototypes or game jams #### When DxMessaging Wins -- ✅ Memory leaks are a problem (automatic lifecycle management) -- ✅ Need decoupling (systems don't reference each other) -- ✅ Execution order matters (priority-based handlers) -- ✅ Debugging "what fired when" (Inspector message history) -- ✅ Message validation/interception needed (interceptor pipeline) -- ✅ Global observation needed (listen to all message instances) -- ✅ Cross-system communication (10+ systems) -- ✅ Long-term maintenance (months/years) -- ✅ GameObject/Component targeting needed -- ✅ Post-processing stage needed (analytics after handlers) +- [x] Memory leaks are a problem (automatic lifecycle management) +- [x] Need decoupling (systems don't reference each other) +- [x] Execution order matters (priority-based handlers) +- [x] Debugging "what fired when" (Inspector message history) +- [x] Message validation/interception needed (interceptor pipeline) +- [x] Global observation needed (listen to all message instances) +- [x] Cross-system communication (10+ systems) +- [x] Long-term maintenance (months/years) +- [x] GameObject/Component targeting needed +- [x] Post-processing stage needed (analytics after handlers) #### Direct Comparison -| Aspect | C# Events | DxMessaging | -| ------------------------ | --------------------- | ---------------------- | -| **Primary Use Case** | Simple callbacks | Pub/sub messaging | -| **Unity Compatibility** | ✅ Built into C# | ✅ Built for Unity | -| **Dependencies** | ✅ None (language) | ✅ Standalone | -| **Performance** | ~50ns/call (fastest) | ~60ns/call | -| **Allocations** | ✅ Zero (basic) | ✅ Zero (structs) | -| **Learning Curve** | ⭐⭐⭐⭐⭐ None | ⭐⭐⭐ Moderate | -| **Setup Complexity** | ⭐⭐⭐⭐⭐ Minimal | ⭐⭐⭐ Moderate | -| **DI Integration** | ⚠️ Manual | ⚠️ Optional | -| **Async/Await** | ⚠️ Manual | ⚠️ Manual | -| **Type Safety** | ✅ Strong | ✅ Strong | -| **Lifecycle Management** | ❌ Manual unsubscribe | ✅ Automatic | -| **Execution Order** | ❌ Undefined | ✅ Priority-based | -| **GameObject Targeting** | ❌ Not built-in | ✅ Built-in | -| **Unity Integration** | ⭐ None | ⭐⭐⭐⭐⭐ Deep | -| **Inspector Debugging** | ❌ No | ✅ History + stats | -| **Interceptors** | ❌ Not built-in | ✅ Full pipeline | -| **Global Observers** | ❌ Not built-in | ✅ Listen to all | -| **Post-Processing** | ❌ Not built-in | ✅ Dedicated stage | -| **Testability** | ⭐⭐ Hard to isolate | ⭐⭐⭐⭐⭐ Local buses | -| **Decoupling** | ⭐ Tight coupling | ⭐⭐⭐⭐⭐ Excellent | -| **Return Values** | ✅ Yes | ❌ Fire-and-forget | +| Aspect | C# Events | DxMessaging | +| ------------------------ | -------------------- | ----------------- | +| **Primary Use Case** | Simple callbacks | Pub/sub messaging | +| **Unity Compatibility** | Built into C# | Built for Unity | +| **Dependencies** | None (language) | Standalone | +| **Performance** | ~50ns/call (fastest) | ~60ns/call | +| **Allocations** | Zero (basic) | Zero (structs) | +| **Learning Curve** | None | Moderate | +| **Setup Complexity** | Minimal | Moderate | +| **DI Integration** | Manual | Optional | +| **Async/Await** | Manual | Manual | +| **Type Safety** | Strong | Strong | +| **Lifecycle Management** | Manual unsubscribe | Automatic | +| **Execution Order** | Undefined | Priority-based | +| **GameObject Targeting** | Not built-in | Built-in | +| **Unity Integration** | None | Deep | +| **Inspector Debugging** | No | History + stats | +| **Interceptors** | Not built-in | Full pipeline | +| **Global Observers** | Not built-in | Listen to all | +| **Post-Processing** | Not built-in | Dedicated stage | +| **Testability** | Hard to isolate | Local buses | +| **Decoupling** | Tight coupling | Excellent | +| **Return Values** | Yes | Fire-and-forget | **Bottom Line:** C# events are the fastest and simplest for basic callbacks. DxMessaging is better for complex, decoupled systems where lifecycle management, debugging, and execution control matter. @@ -828,22 +828,22 @@ public class UI : MonoBehaviour #### What Problems It Solves -- ✅ **Visual wiring:** See connections in Inspector -- ✅ **No code required:** Designers can hook up events -- ✅ **Persistence:** Connections saved with scenes/prefabs -- ✅ **Rapid prototyping:** Quick iteration without scripting -- ✅ **Prefab workflows:** Events work across prefab instances +- [x] **Visual wiring:** See connections in Inspector +- [x] **No code required:** Designers can hook up events +- [x] **Persistence:** Connections saved with scenes/prefabs +- [x] **Rapid prototyping:** Quick iteration without scripting +- [x] **Prefab workflows:** Events work across prefab instances #### What Problems It Doesn't Solve Well -- ❌ **Hidden dependencies:** Connections invisible in code, hard to find during refactoring -- ❌ **Brittle at scale:** Renaming methods breaks wiring, no compile-time safety -- ❌ **Execution order:** Undefined call order for multiple subscribers -- ❌ **No validation:** No way to validate or intercept before invocation -- ❌ **Performance:** Slower than C# events due to reflection and boxing -- ❌ **Debugging:** Hard to trace "who called what" at runtime -- ❌ **Merge conflicts:** Inspector changes cause git conflicts -- ❌ **Refactoring challenges:** Renaming/moving methods silently breaks connections +- [ ] **Hidden dependencies:** Connections invisible in code, hard to find during refactoring +- [ ] **Brittle at scale:** Renaming methods breaks wiring, no compile-time safety +- [ ] **Execution order:** Undefined call order for multiple subscribers +- [ ] **No validation:** No way to validate or intercept before invocation +- [ ] **Performance:** Slower than C# events due to reflection and boxing +- [ ] **Debugging:** Hard to trace "who called what" at runtime +- [ ] **Merge conflicts:** Inspector changes cause git conflicts +- [ ] **Refactoring challenges:** Renaming/moving methods silently breaks connections #### Performance Characteristics @@ -859,58 +859,58 @@ public class UI : MonoBehaviour #### Ease of Understanding -- ⭐⭐⭐⭐ (Easy for wiring, hard for debugging) +- (4/5) (Easy for wiring, hard for debugging) - Simple to connect in Inspector - Difficult to understand flow when reading code - Hard to track down at scale (where is this method called from?) #### When UnityEvents Win -- ✅ Designers need to wire logic without code -- ✅ Rapid prototyping with prefabs -- ✅ Very simple games (mobile casual, hyper-casual) -- ✅ UI interactions with minimal logic -- ✅ Small projects (<5 scripts) -- ✅ One-off connections that rarely change +- [x] Designers need to wire logic without code +- [x] Rapid prototyping with prefabs +- [x] Very simple games (mobile casual, hyper-casual) +- [x] UI interactions with minimal logic +- [x] Small projects (<5 scripts) +- [x] One-off connections that rarely change #### When DxMessaging Wins -- ✅ Code-first development (programmers prefer code visibility) -- ✅ Refactoring frequently (compile-time safety) -- ✅ Execution order matters (priority-based handlers) -- ✅ Need validation/interception (interceptor pipeline) -- ✅ Performance-sensitive (zero allocation required) -- ✅ Debugging observability (message history) -- ✅ Cross-system communication (10+ components) -- ✅ Team collaboration (merge-friendly code over Inspector) -- ✅ Long-term maintenance (find usages, refactor safely) +- [x] Code-first development (programmers prefer code visibility) +- [x] Refactoring frequently (compile-time safety) +- [x] Execution order matters (priority-based handlers) +- [x] Need validation/interception (interceptor pipeline) +- [x] Performance-sensitive (zero allocation required) +- [x] Debugging observability (message history) +- [x] Cross-system communication (10+ components) +- [x] Team collaboration (merge-friendly code over Inspector) +- [x] Long-term maintenance (find usages, refactor safely) #### Direct Comparison -| Aspect | UnityEvents | DxMessaging | -| ------------------------ | ---------------------- | ---------------------- | -| **Primary Use Case** | Inspector wiring | Pub/sub messaging | -| **Unity Compatibility** | ✅ Built into Unity | ✅ Built for Unity | -| **Dependencies** | ✅ None (Unity) | ✅ Standalone | -| **Performance** | Slow (serialization) | ~60ns/call | -| **Allocations** | ❌ Boxing | ✅ Zero (structs) | -| **Learning Curve** | ⭐⭐⭐⭐⭐ Minimal | ⭐⭐⭐ Moderate | -| **Setup Complexity** | ⭐⭐⭐⭐⭐ Inspector | ⭐⭐⭐ Code-based | -| **DI Integration** | ❌ No | ⚠️ Optional | -| **Async/Await** | ❌ No | ⚠️ Manual | -| **Type Safety** | ⭐⭐ Weak (serialized) | ✅ Strong | -| **Lifecycle Management** | ⚠️ Unity-managed | ✅ Automatic | -| **Execution Order** | ❌ Undefined | ✅ Priority-based | -| **GameObject Targeting** | ⚠️ Manual references | ✅ Built-in | -| **Unity Integration** | ⭐⭐⭐ Inspector-based | ⭐⭐⭐⭐⭐ Deep | -| **Inspector Debugging** | ⭐⭐ Connections only | ✅ History + stats | -| **Interceptors** | ❌ Not built-in | ✅ Full pipeline | -| **Global Observers** | ❌ Not possible | ✅ Listen to all | -| **Post-Processing** | ❌ Not built-in | ✅ Dedicated stage | -| **Testability** | ⭐⭐ Scene setup | ⭐⭐⭐⭐⭐ Local buses | -| **Decoupling** | ⭐⭐ Hidden refs | ⭐⭐⭐⭐⭐ Excellent | -| **Refactoring Safety** | ❌ Silent breakage | ✅ Compile-time errors | -| **Code Visibility** | ❌ Hidden in Inspector | ✅ Explicit in code | +| Aspect | UnityEvents | DxMessaging | +| ------------------------ | -------------------- | ------------------- | +| **Primary Use Case** | Inspector wiring | Pub/sub messaging | +| **Unity Compatibility** | Built into Unity | Built for Unity | +| **Dependencies** | None (Unity) | Standalone | +| **Performance** | Slow (serialization) | ~60ns/call | +| **Allocations** | Boxing | Zero (structs) | +| **Learning Curve** | Minimal | Moderate | +| **Setup Complexity** | Inspector | Code-based | +| **DI Integration** | No | Optional | +| **Async/Await** | No | Manual | +| **Type Safety** | Weak (serialized) | Strong | +| **Lifecycle Management** | Unity-managed | Automatic | +| **Execution Order** | Undefined | Priority-based | +| **GameObject Targeting** | Manual references | Built-in | +| **Unity Integration** | Inspector-based | Deep | +| **Inspector Debugging** | Connections only | History + stats | +| **Interceptors** | Not built-in | Full pipeline | +| **Global Observers** | Not possible | Listen to all | +| **Post-Processing** | Not built-in | Dedicated stage | +| **Testability** | Scene setup | Local buses | +| **Decoupling** | Hidden refs | Excellent | +| **Refactoring Safety** | Silent breakage | Compile-time errors | +| **Code Visibility** | Hidden in Inspector | Explicit in code | **Bottom Line:** UnityEvents are well-suited for simple Inspector-based wiring and designer workflows. DxMessaging is better for code-first development, refactoring safety, and complex messaging needs. @@ -968,22 +968,22 @@ public class Weapon : MonoBehaviour #### What Problems It Solves -- ✅ **No references needed:** Call methods without GetComponent -- ✅ **Hierarchy traversal:** Easy parent/child communication -- ✅ **Simple API:** One-line method invocation -- ✅ **Optional receivers:** Can call non-existent methods safely -- ✅ **Built-in:** No setup or dependencies +- [x] **No references needed:** Call methods without GetComponent +- [x] **Hierarchy traversal:** Easy parent/child communication +- [x] **Simple API:** One-line method invocation +- [x] **Optional receivers:** Can call non-existent methods safely +- [x] **Built-in:** No setup or dependencies #### What Problems It Doesn't Solve Well -- ❌ **No type safety:** String-based, typos cause silent failures -- ❌ **Slow performance:** Reflection overhead on every call -- ❌ **Limited parameters:** Only 0 or 1 parameter supported -- ❌ **Boxing allocations:** Value types boxed to object, causes GC -- ❌ **Hard to debug:** No compile-time checking, no IDE "Find Usages" -- ❌ **Refactoring difficulty:** Renaming methods breaks string references -- ❌ **No validation:** No way to validate or intercept messages -- ❌ **Execution order:** Undefined call order for multiple receivers +- [ ] **No type safety:** String-based, typos cause silent failures +- [ ] **Slow performance:** Reflection overhead on every call +- [ ] **Limited parameters:** Only 0 or 1 parameter supported +- [ ] **Boxing allocations:** Value types boxed to object, causes GC +- [ ] **Hard to debug:** No compile-time checking, no IDE "Find Usages" +- [ ] **Refactoring difficulty:** Renaming methods breaks string references +- [ ] **No validation:** No way to validate or intercept messages +- [ ] **Execution order:** Undefined call order for multiple receivers #### Performance Characteristics @@ -999,57 +999,57 @@ public class Weapon : MonoBehaviour #### Ease of Understanding -- ⭐⭐⭐ (Simple to use, hard to maintain) +- (3/5) (Simple to use, hard to maintain) - Easy to write initially - Difficult to track method calls (no Find Usages) - Refactoring breaks string references silently #### When Unity SendMessage Wins -- ✅ Legacy code that already uses it -- ✅ Quick prototypes (throwaway code) -- ✅ Simple tutorials or learning examples -- ✅ Calling optional methods that may not exist -- ✅ GameObject hierarchies with optional components (DontRequireReceiver pattern) +- [x] Legacy code that already uses it +- [x] Quick prototypes (throwaway code) +- [x] Simple tutorials or learning examples +- [x] Calling optional methods that may not exist +- [x] GameObject hierarchies with optional components (DontRequireReceiver pattern) #### When DxMessaging Wins -- ✅ Type safety required (compile-time checking) -- ✅ Performance matters (zero allocation, no reflection) -- ✅ Multiple parameters needed (struct fields) -- ✅ Refactoring frequently (find usages, rename safely) -- ✅ Debugging observability (message history) -- ✅ Execution order control (priority-based handlers) -- ✅ Message validation/interception (interceptor pipeline) -- ✅ Production code (maintainability over simplicity) -- ✅ Modern projects (avoid legacy patterns) +- [x] Type safety required (compile-time checking) +- [x] Performance matters (zero allocation, no reflection) +- [x] Multiple parameters needed (struct fields) +- [x] Refactoring frequently (find usages, rename safely) +- [x] Debugging observability (message history) +- [x] Execution order control (priority-based handlers) +- [x] Message validation/interception (interceptor pipeline) +- [x] Production code (maintainability over simplicity) +- [x] Modern projects (avoid legacy patterns) #### Direct Comparison -| Aspect | Unity SendMessage | DxMessaging | -| ------------------------ | ------------------------- | ---------------------------- | -| **Primary Use Case** | Legacy GameObject calls | Pub/sub messaging | -| **Unity Compatibility** | ✅ Built into Unity | ✅ Built for Unity | -| **Dependencies** | ✅ None (Unity) | ✅ Standalone | -| **Performance** | Very slow (reflection) | ~60ns/call | -| **Allocations** | ❌ Heavy boxing | ✅ Zero (structs) | -| **Learning Curve** | ⭐⭐⭐⭐⭐ Minimal | ⭐⭐⭐ Moderate | -| **Setup Complexity** | ⭐⭐⭐⭐⭐ None | ⭐⭐⭐ Moderate | -| **DI Integration** | ❌ No | ⚠️ Optional | -| **Async/Await** | ❌ No | ⚠️ Manual | -| **Type Safety** | ❌ String-based | ✅ Strong | -| **Lifecycle Management** | ❌ None | ✅ Automatic | -| **Execution Order** | ❌ Undefined | ✅ Priority-based | -| **GameObject Targeting** | ✅ Hierarchy traversal | ✅ Built-in (ID-based) | -| **Unity Integration** | ⭐⭐ Legacy API | ⭐⭐⭐⭐⭐ Deep | -| **Inspector Debugging** | ❌ No | ✅ History + stats | -| **Interceptors** | ❌ Not built-in | ✅ Full pipeline | -| **Global Observers** | ❌ Not possible | ✅ Listen to all | -| **Post-Processing** | ❌ Not built-in | ✅ Dedicated stage | -| **Testability** | ⭐⭐ Requires GameObjects | ⭐⭐⭐⭐⭐ Local buses | -| **Decoupling** | ⭐⭐ String-based | ⭐⭐⭐⭐⭐ Excellent | -| **Refactoring Safety** | ❌ Silent breakage | ✅ Compile-time errors | -| **Parameters** | ⚠️ 0 or 1 only | ✅ Unlimited (struct fields) | +| Aspect | Unity SendMessage | DxMessaging | +| ------------------------ | ----------------------- | ------------------------- | +| **Primary Use Case** | Legacy GameObject calls | Pub/sub messaging | +| **Unity Compatibility** | Built into Unity | Built for Unity | +| **Dependencies** | None (Unity) | Standalone | +| **Performance** | Very slow (reflection) | ~60ns/call | +| **Allocations** | Heavy boxing | Zero (structs) | +| **Learning Curve** | Minimal | Moderate | +| **Setup Complexity** | None | Moderate | +| **DI Integration** | No | Optional | +| **Async/Await** | No | Manual | +| **Type Safety** | String-based | Strong | +| **Lifecycle Management** | None | Automatic | +| **Execution Order** | Undefined | Priority-based | +| **GameObject Targeting** | Hierarchy traversal | Built-in (ID-based) | +| **Unity Integration** | Legacy API | Deep | +| **Inspector Debugging** | No | History + stats | +| **Interceptors** | Not built-in | Full pipeline | +| **Global Observers** | Not possible | Listen to all | +| **Post-Processing** | Not built-in | Dedicated stage | +| **Testability** | Requires GameObjects | Local buses | +| **Decoupling** | String-based | Excellent | +| **Refactoring Safety** | Silent breakage | Compile-time errors | +| **Parameters** | 0 or 1 only | Unlimited (struct fields) | **Bottom Line:** SendMessage is legacy Unity API. Use only for maintaining old code. DxMessaging provides all the same capabilities with type safety, performance, and modern tooling. @@ -1134,22 +1134,22 @@ public class UI : MonoBehaviour #### What Problems It Solves -- ✅ **Global decoupling:** No direct references between systems -- ✅ **Easy to add events:** Just add to static class -- ✅ **Simple pattern:** Straightforward to implement and understand -- ✅ **No setup:** No DI container or framework needed +- [x] **Global decoupling:** No direct references between systems +- [x] **Easy to add events:** Just add to static class +- [x] **Simple pattern:** Straightforward to implement and understand +- [x] **No setup:** No DI container or framework needed #### What Problems It Doesn't Solve Well -- ❌ **Memory leaks:** Still manual subscribe/unsubscribe (same as C# events) -- ❌ **Global state:** Everything in one bag, hard to organize at scale -- ❌ **Execution order:** Undefined handler invocation order -- ❌ **Testing difficulty:** Global state makes unit testing hard -- ❌ **Naming conflicts:** All events in same namespace, naming gets messy -- ❌ **No validation:** No way to intercept or validate messages -- ❌ **No observability:** Can't see who's subscribed or message history -- ❌ **Ownership unclear:** Who manages what events? -- ❌ **Lifecycle management:** Manual subscribe/unsubscribe required +- [ ] **Memory leaks:** Still manual subscribe/unsubscribe (same as C# events) +- [ ] **Global state:** Everything in one bag, hard to organize at scale +- [ ] **Execution order:** Undefined handler invocation order +- [ ] **Testing difficulty:** Global state makes unit testing hard +- [ ] **Naming conflicts:** All events in same namespace, naming gets messy +- [ ] **No validation:** No way to intercept or validate messages +- [ ] **No observability:** Can't see who's subscribed or message history +- [ ] **Ownership unclear:** Who manages what events? +- [ ] **Lifecycle management:** Manual subscribe/unsubscribe required #### Performance Characteristics @@ -1165,58 +1165,58 @@ public class UI : MonoBehaviour #### Ease of Understanding -- ⭐⭐⭐⭐ (Easy initially, hard at scale) +- (4/5) (Easy initially, hard at scale) - Simple pattern to grasp - Becomes messy with 20+ events - Hard to track ownership and responsibilities #### When Static Event Bus Wins -- ✅ You've already built one and it works -- ✅ Very simple use cases (just need globals) -- ✅ Small projects (<10 events) -- ✅ No framework dependencies desired -- ✅ Quick prototypes +- [x] You've already built one and it works +- [x] Very simple use cases (just need globals) +- [x] Small projects (<10 events) +- [x] No framework dependencies desired +- [x] Quick prototypes #### When DxMessaging Wins -- ✅ More than 10-15 events (organization becomes important) -- ✅ Memory leaks are a concern (automatic lifecycle management) -- ✅ Execution order matters (priority-based handlers) -- ✅ Need message validation/interception (interceptor pipeline) -- ✅ Testing is important (local buses for isolation) -- ✅ Observability needed (Inspector debugging, message history) -- ✅ Multiple subsystems (namespacing and organization) -- ✅ GameObject/Component targeting needed -- ✅ Global observation needed (listen to all message instances) -- ✅ Post-processing needed (analytics after handlers) -- ✅ Long-term maintenance (structure prevents chaos) +- [x] More than 10-15 events (organization becomes important) +- [x] Memory leaks are a concern (automatic lifecycle management) +- [x] Execution order matters (priority-based handlers) +- [x] Need message validation/interception (interceptor pipeline) +- [x] Testing is important (local buses for isolation) +- [x] Observability needed (Inspector debugging, message history) +- [x] Multiple subsystems (namespacing and organization) +- [x] GameObject/Component targeting needed +- [x] Global observation needed (listen to all message instances) +- [x] Post-processing needed (analytics after handlers) +- [x] Long-term maintenance (structure prevents chaos) #### Direct Comparison -| Aspect | Static Event Bus | DxMessaging | -| ------------------------ | --------------------- | ---------------------- | -| **Primary Use Case** | Global event hub | Pub/sub messaging | -| **Unity Compatibility** | ✅ Works in Unity | ✅ Built for Unity | -| **Dependencies** | ✅ None (custom) | ✅ Standalone | -| **Performance** | ~50ns/call (fast) | ~60ns/call | -| **Allocations** | ✅ Zero (basic) | ✅ Zero (structs) | -| **Learning Curve** | ⭐⭐⭐⭐⭐ Minimal | ⭐⭐⭐ Moderate | -| **Setup Complexity** | ⭐⭐⭐⭐⭐ Minimal | ⭐⭐⭐ Moderate | -| **DI Integration** | ⚠️ Manual | ⚠️ Optional | -| **Async/Await** | ⚠️ Manual | ⚠️ Manual | -| **Type Safety** | ✅ Strong | ✅ Strong | -| **Lifecycle Management** | ❌ Manual unsubscribe | ✅ Automatic | -| **Execution Order** | ❌ Undefined | ✅ Priority-based | -| **GameObject Targeting** | ❌ Not built-in | ✅ Built-in | -| **Unity Integration** | ⭐ None | ⭐⭐⭐⭐⭐ Deep | -| **Inspector Debugging** | ❌ No | ✅ History + stats | -| **Interceptors** | ❌ Not built-in | ✅ Full pipeline | -| **Global Observers** | ❌ Not built-in | ✅ Listen to all | -| **Post-Processing** | ❌ Not built-in | ✅ Dedicated stage | -| **Testability** | ⭐ Hard (global) | ⭐⭐⭐⭐⭐ Local buses | -| **Decoupling** | ⭐⭐⭐⭐ Good | ⭐⭐⭐⭐⭐ Excellent | -| **Organization** | ⭐⭐ One big class | ⭐⭐⭐⭐⭐ Structured | +| Aspect | Static Event Bus | DxMessaging | +| ------------------------ | ------------------ | ----------------- | +| **Primary Use Case** | Global event hub | Pub/sub messaging | +| **Unity Compatibility** | Works in Unity | Built for Unity | +| **Dependencies** | None (custom) | Standalone | +| **Performance** | ~50ns/call (fast) | ~60ns/call | +| **Allocations** | Zero (basic) | Zero (structs) | +| **Learning Curve** | Minimal | Moderate | +| **Setup Complexity** | Minimal | Moderate | +| **DI Integration** | Manual | Optional | +| **Async/Await** | Manual | Manual | +| **Type Safety** | Strong | Strong | +| **Lifecycle Management** | Manual unsubscribe | Automatic | +| **Execution Order** | Undefined | Priority-based | +| **GameObject Targeting** | Not built-in | Built-in | +| **Unity Integration** | None | Deep | +| **Inspector Debugging** | No | History + stats | +| **Interceptors** | Not built-in | Full pipeline | +| **Global Observers** | Not built-in | Listen to all | +| **Post-Processing** | Not built-in | Dedicated stage | +| **Testability** | Hard (global) | Local buses | +| **Decoupling** | Good | Excellent | +| **Organization** | One big class | Structured | **Bottom Line:** Static event buses solve global access but inherit all the problems of C# events (leaks, undefined order, no observability). DxMessaging provides the same global access with lifecycle safety, structure, and debugging tools. @@ -1248,17 +1248,17 @@ DxMessaging involves trade-offs like any architectural choice. This section desc #### What You Give Up -- ❌ **Immediate productivity** - ~1-2 days to feel comfortable (reading docs, trying examples) -- ❌ **Familiarity** - Your team knows C# events already; DxMessaging is new -- ❌ **"Just works" intuition** - You need to think: "Which message type? What priority?" +- [ ] **Immediate productivity** - ~1-2 days to feel comfortable (reading docs, trying examples) +- [ ] **Familiarity** - Your team knows C# events already; DxMessaging is new +- [ ] **"Just works" intuition** - You need to think: "Which message type? What priority?" Your first message will take 15 minutes. By the 10th message, you'll be faster than with events. #### What You Gain -- ✅ **Long-term velocity** - Adding new features doesn't require touching 5 existing systems -- ✅ **Debugging is faster** - Inspector shows "what fired when" instantly -- ✅ **Onboarding is easier** - New devs see explicit message contracts, not hidden event chains +- [x] **Long-term velocity** - Adding new features doesn't require touching 5 existing systems +- [x] **Debugging is faster** - Inspector shows "what fired when" instantly +- [x] **Onboarding is easier** - New devs see explicit message contracts, not hidden event chains **Example:** Junior dev asks "How does damage work?" @@ -1267,7 +1267,7 @@ Your first message will take 15 minutes. By the 10th message, you'll be faster t ##### Verdict -- Game jam (1 week project): Learning curve not worth it → Stick with C# events +- Game jam (1 week project): Learning curve not worth it -> Stick with C# events - Mid-size game (1+ month): Pays off by week 2 - Large game (6+ months): Highly beneficial @@ -1275,14 +1275,14 @@ Your first message will take 15 minutes. By the 10th message, you'll be faster t #### What You Give Up -- ❌ "One-liners" - C# events can be `public event Action OnClick;` done -- ❌ Quick and dirty - Need to define message struct, attributes, handler registration +- [ ] "One-liners" - C# events can be `public event Action OnClick;` done +- [ ] Quick and dirty - Need to define message struct, attributes, handler registration #### What You Gain -- ✅ Explicit contracts - Messages are discoverable types, not hidden delegates -- ✅ Auto-generated code - `[DxAutoConstructor]` reduces boilerplate -- ✅ Compile-time safety - Refactors update all usages +- [x] Explicit contracts - Messages are discoverable types, not hidden delegates +- [x] Auto-generated code - `[DxAutoConstructor]` reduces boilerplate +- [x] Compile-time safety - Refactors update all usages #### Example Comparison @@ -1306,15 +1306,15 @@ msg.EmitGameObjectBroadcast(gameObject); #### What You Give Up -- ❌ Absolute minimal overhead - Raw C# events/delegates are faster (~10ns per call) -- ❌ Zero abstraction cost - Direct calls can be inlined by the compiler -- ❌ Simplicity in profiler - One extra layer in call stack +- [ ] Absolute minimal overhead - Raw C# events/delegates are faster (~10ns per call) +- [ ] Zero abstraction cost - Direct calls can be inlined by the compiler +- [ ] Simplicity in profiler - One extra layer in call stack #### What You Gain -- ✅ Zero-allocation struct messages - No GC pressure from boxing -- ✅ Predictable performance - No hidden allocations from lambdas -- ✅ Scalable diagnostics - Built-in profiling/logging without custom instrumentation +- [x] Zero-allocation struct messages - No GC pressure from boxing +- [x] Predictable performance - No hidden allocations from lambdas +- [x] Scalable diagnostics - Built-in profiling/logging without custom instrumentation #### Hard Numbers @@ -1322,22 +1322,22 @@ msg.EmitGameObjectBroadcast(gameObject); - DxMessaging handler: ~60ns (~10ns overhead) - Memory: Zero allocations for struct messages -**Verdict:** For UI, gameplay events, scene management → DxMessaging overhead is negligible. For ECS architectures processing millions of events per frame → consider raw delegates or native code, which are better suited for that specific use case. +**Verdict:** For UI, gameplay events, scene management -> DxMessaging overhead is negligible. For ECS architectures processing millions of events per frame -> consider raw delegates or native code, which are better suited for that specific use case. ### Flexibility #### What You Give Up -- ❌ Return values - DxMessaging is fire-and-forget (no synchronous responses) -- ❌ Out parameters - Can't use `out` or `ref` for bidirectional communication -- ❌ Dynamic subscriptions - Can't easily pass lambdas inline +- [ ] Return values - DxMessaging is fire-and-forget (no synchronous responses) +- [ ] Out parameters - Can't use `out` or `ref` for bidirectional communication +- [ ] Dynamic subscriptions - Can't easily pass lambdas inline #### What You Gain -- ✅ Interception - Validate/transform messages before handlers -- ✅ Post-processing - Analytics/logging without polluting handlers -- ✅ Priority control - Explicit execution order -- ✅ Context - Always know who sent/received +- [x] Interception - Validate/transform messages before handlers +- [x] Post-processing - Analytics/logging without polluting handlers +- [x] Priority control - Explicit execution order +- [x] Context - Always know who sent/received #### When Limitations Hurt @@ -1359,15 +1359,15 @@ if (OnValidateDamage?.Invoke(damage) == true) { #### What You Give Up -- ❌ Simplicity - Stack traces show message bus internals -- ❌ Step-through - Can't F11 directly from emit to handler (need breakpoints) +- [ ] Simplicity - Stack traces show message bus internals +- [ ] Step-through - Can't F11 directly from emit to handler (need breakpoints) #### What You Gain -- ✅ Message history - See last N messages in Inspector -- ✅ Registration view - Know exactly who's listening -- ✅ Global observability - Track all messages without instrumenting code -- ✅ Filtering - Intercept messages for debugging without changing code +- [x] Message history - See last N messages in Inspector +- [x] Registration view - Know exactly who's listening +- [x] Global observability - Track all messages without instrumenting code +- [x] Filtering - Intercept messages for debugging without changing code #### Example: Finding Who Fired a Message @@ -1387,30 +1387,30 @@ _ = debugToken.RegisterBroadcastWithoutSource( #### What You Give Up -- ❌ Quick hacks - Can't just `GetComponent().DoThing()` anymore -- ❌ Direct inspector wiring - Can't drag-and-drop references to emit messages +- [ ] Quick hacks - Can't just `GetComponent().DoThing()` anymore +- [ ] Direct inspector wiring - Can't drag-and-drop references to emit messages #### What You Gain -- ✅ True decoupling - Systems don't know about each other -- ✅ Testability - Easy to isolate with local buses -- ✅ Refactorability - Move/rename components without breaking wiring +- [x] True decoupling - Systems don't know about each other +- [x] Testability - Easy to isolate with local buses +- [x] Refactorability - Move/rename components without breaking wiring #### Impact on Architecture Before (tight coupling): ```text -UI → References 15 systems -System A → References System B, C, D +UI -> References 15 systems +System A -> References System B, C, D Every change ripples through dependencies ``` After (loose coupling): ```text -All systems → Emit messages -All systems → Listen to messages +All systems -> Emit messages +All systems -> Listen to messages Add/remove systems without affecting others ``` @@ -1420,13 +1420,13 @@ Add/remove systems without affecting others #### What You Give Up -- ❌ Simplicity - Can't just mock an event subscription +- [ ] Simplicity - Can't just mock an event subscription #### What You Gain -- ✅ Isolation - Local buses per test, zero global state -- ✅ Observability - Count messages, inspect payloads easily -- ✅ Determinism - Priority-based ordering eliminates flakiness +- [x] Isolation - Local buses per test, zero global state +- [x] Observability - Count messages, inspect payloads easily +- [x] Determinism - Priority-based ordering eliminates flakiness ##### Example @@ -1450,47 +1450,47 @@ public void TestAchievementSystem() { ### Unity Messaging Frameworks Comparison -| Aspect | DxMessaging | UniRx | MessagePipe | Zenject Signals | -| ------------------------ | ---------------------------- | ------------------------ | --------------------------- | -------------------------- | -| **Primary Use Case** | Pub/sub messaging | Stream transformations | High-perf DI messaging | DI-integrated messaging | -| **Unity Compatibility** | ✅ Built for Unity | ✅ Built for Unity | ✅ Built for Unity | ✅ Built for Unity | -| **Performance** | ⭐⭐⭐⭐ Good (14M) | ⭐⭐⭐⭐ Good (18M) | ⭐⭐⭐⭐⭐ Best (97M) | ⭐⭐ Moderate (2.5M) | -| **Zero Allocations** | ✅ Yes (structs) | ⚠️ Can allocate | ✅ Yes (structs) | ⚠️ Can allocate | -| **Unity Integration** | ⭐⭐⭐⭐⭐ Deep (lifecycle) | ⭐⭐⭐⭐ Good (UI/async) | ⭐⭐⭐ Basic (no lifecycle) | ⭐⭐⭐⭐ Good (DI-managed) | -| **Inspector Debugging** | ✅ Yes (history + stats) | ❌ No | ❌ No | ❌ No | -| **Execution Order** | ✅ Priority-based | ❌ Not built-in | ❌ Subscription order | ❌ Not built-in | -| **Lifecycle Management** | ✅ Automatic (MonoBehaviour) | ⚠️ Manual dispose | ⚠️ Manual dispose | ⚠️ DI-managed | -| **Learning Curve** | ⭐⭐⭐ Moderate | ⭐⭐ Steep (Rx paradigm) | ⭐⭐⭐⭐ Moderate (DI) | ⭐⭐ Steep (DI+Signals) | -| **Setup Complexity** | ⭐⭐⭐⭐⭐ Plug-and-play | ⭐⭐⭐⭐⭐ Low | ⭐⭐⭐ DI setup required | ⭐⭐ Installers required | -| **DI Integration** | ⚠️ Optional | ⚠️ Optional | ✅ First-class | ✅ Required (Zenject) | -| **Async/Await** | ⚠️ Manual | ✅ Native (observables) | ✅ Native | ✅ Yes | -| **Message Validation** | ✅ Interceptor pipeline | ❌ Not built-in | ⚠️ Filters (middleware) | ❌ Not built-in | -| **GameObject Targeting** | ✅ Built-in | ❌ Not designed for | ❌ Not built-in | ❌ Not built-in | -| **Global Observers** | ✅ Listen to all sources | ❌ Not built-in | ❌ Not built-in | ❌ Not built-in | -| **Post-Processing** | ✅ Dedicated stage | ❌ Not built-in | ⚠️ Via filters | ❌ Not built-in | -| **Stream Operators** | ❌ Not built-in | ✅ Extensive (LINQ) | ❌ Not built-in | ⚠️ With UniRx | -| **Testability** | ⭐⭐⭐⭐⭐ Local buses | ⭐⭐⭐⭐ Good | ⭐⭐⭐⭐⭐ DI mocking | ⭐⭐⭐⭐⭐ DI mocking | -| **Decoupling** | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐⭐ Excellent | -| **Type Safety** | ⭐⭐⭐⭐⭐ Strong | ⭐⭐⭐⭐⭐ Strong | ⭐⭐⭐⭐⭐ Strong | ⭐⭐⭐⭐⭐ Strong | -| **Dependencies** | ✅ None | ✅ None | ⚠️ MessagePipe package | ❌ Zenject required | +| Aspect | DxMessaging | UniRx | MessagePipe | Zenject Signals | +| ------------------------ | ------------------------- | ---------------------- | ---------------------- | ----------------------- | +| **Primary Use Case** | Pub/sub messaging | Stream transformations | High-perf DI messaging | DI-integrated messaging | +| **Unity Compatibility** | Built for Unity | Built for Unity | Built for Unity | Built for Unity | +| **Performance** | Good (14M) | Good (18M) | Best (97M) | Moderate (2.5M) | +| **Zero Allocations** | (structs) | Can allocate | (structs) | Can allocate | +| **Unity Integration** | Deep (lifecycle) | Good (UI/async) | Basic (no lifecycle) | Good (DI-managed) | +| **Inspector Debugging** | (history + stats) | No | No | No | +| **Execution Order** | Priority-based | Not built-in | Subscription order | Not built-in | +| **Lifecycle Management** | Automatic (MonoBehaviour) | Manual dispose | Manual dispose | DI-managed | +| **Learning Curve** | Moderate | Steep (Rx paradigm) | Moderate (DI) | Steep (DI+Signals) | +| **Setup Complexity** | Plug-and-play | Low | DI setup required | Installers required | +| **DI Integration** | Optional | Optional | First-class | Required (Zenject) | +| **Async/Await** | Manual | Native (observables) | Native | Yes | +| **Message Validation** | Interceptor pipeline | Not built-in | Filters (middleware) | Not built-in | +| **GameObject Targeting** | Built-in | Not designed for | Not built-in | Not built-in | +| **Global Observers** | Listen to all sources | Not built-in | Not built-in | Not built-in | +| **Post-Processing** | Dedicated stage | Not built-in | Via filters | Not built-in | +| **Stream Operators** | Not built-in | Extensive (LINQ) | Not built-in | With UniRx | +| **Testability** | Local buses | Good | DI mocking | DI mocking | +| **Decoupling** | Excellent | Excellent | Excellent | Excellent | +| **Type Safety** | Strong | Strong | Strong | Strong | +| **Dependencies** | None | None | MessagePipe package | Zenject required | ### Traditional Approaches Comparison -| Aspect | C# Events | UnityEvents | SOA (GameEvent) | Static Bus | DxMessaging | -| -------------------- | ------------------ | -------------------- | ------------------- | --------------- | ------------------------- | -| **Setup Complexity** | ⭐⭐⭐⭐⭐ Minimal | ⭐⭐⭐⭐ Simple | ⭐⭐ Asset creation | ⭐⭐⭐ Moderate | ⭐⭐⭐ Moderate | -| **Boilerplate** | ⭐⭐⭐⭐⭐ Low | ⭐⭐⭐⭐⭐ Low | ⭐⭐ High | ⭐⭐⭐ Medium | ⭐⭐⭐ Medium | -| **Performance** | ⭐⭐⭐⭐⭐ Fastest | ⭐⭐ Slow (boxing) | ⭐⭐⭐ Moderate | ⭐⭐⭐⭐ Fast | ⭐⭐⭐⭐ Fast | -| **Decoupling** | ⭐ Tight | ⭐⭐ Hidden | ⭐⭐⭐⭐ Good | ⭐⭐⭐⭐ Good | ⭐⭐⭐⭐⭐ Excellent | -| **Designer Control** | ⭐ None | ⭐⭐⭐⭐⭐ High | ⭐⭐⭐⭐⭐ High | ⭐ None | ⭐ None | -| **Lifecycle Safety** | ⭐ Manual | ⭐⭐⭐ Unity-managed | ⭐⭐ Manual persist | ⭐ Manual | ⭐⭐⭐⭐⭐ Automatic | -| **Observability** | ⭐ None | ⭐ None | ⭐ Inspector only | ⭐ None | ⭐⭐⭐⭐⭐ Built-in | -| **Execution Order** | ⭐ Undefined | ⭐ Undefined | ⭐ Undefined | ⭐ Undefined | ⭐⭐⭐⭐⭐ Priority-based | -| **Type Safety** | ⭐⭐⭐⭐⭐ Strong | ⭐⭐ Weak | ⭐⭐⭐ Mixed | ⭐⭐⭐ Varies | ⭐⭐⭐⭐⭐ Strong | -| **Testability** | ⭐⭐ Hard | ⭐⭐ Hard | ⭐ Very Hard | ⭐ Very Hard | ⭐⭐⭐⭐⭐ Easy | -| **Learning Curve** | ⭐⭐⭐⭐⭐ Minimal | ⭐⭐⭐⭐⭐ Minimal | ⭐⭐⭐ Moderate | ⭐⭐⭐⭐ Low | ⭐⭐⭐ Moderate | -| **Memory Safety** | ⭐ Leak-prone | ⭐⭐⭐ Unity-managed | ⭐⭐ Asset persist | ⭐ Leak-prone | ⭐⭐⭐⭐⭐ Leak-free | -| **Debugging** | ⭐⭐ Hard at scale | ⭐⭐ Hard at scale | ⭐⭐ Inspector-only | ⭐ Very Hard | ⭐⭐⭐⭐⭐ Excellent | +| Aspect | C# Events | UnityEvents | SOA (GameEvent) | Static Bus | DxMessaging | +| -------------------- | ------------- | ------------- | --------------- | ---------- | -------------- | +| **Setup Complexity** | Minimal | Simple | Asset creation | Moderate | Moderate | +| **Boilerplate** | Low | Low | High | Medium | Medium | +| **Performance** | Fastest | Slow (boxing) | Moderate | Fast | Fast | +| **Decoupling** | Tight | Hidden | Good | Good | Excellent | +| **Designer Control** | None | High | High | None | None | +| **Lifecycle Safety** | Manual | Unity-managed | Manual persist | Manual | Automatic | +| **Observability** | None | None | Inspector only | None | Built-in | +| **Execution Order** | Undefined | Undefined | Undefined | Undefined | Priority-based | +| **Type Safety** | Strong | Weak | Mixed | Varies | Strong | +| **Testability** | Hard | Hard | Very Hard | Very Hard | Easy | +| **Learning Curve** | Minimal | Minimal | Moderate | Low | Moderate | +| **Memory Safety** | Leak-prone | Unity-managed | Asset persist | Leak-prone | Leak-free | +| **Debugging** | Hard at scale | Hard at scale | Inspector-only | Very Hard | Excellent | ### Overall Verdict by Use Case @@ -1511,76 +1511,76 @@ public void TestAchievementSystem() { ### DxMessaging Wins When -- ✅ Unity-first projects (MonoBehaviour lifecycle integration) -- ✅ 10+ systems that communicate (pub/sub decoupling) -- ✅ Observability essential (Inspector debugging, message history) -- ✅ Memory leaks are a concern (automatic lifecycle management) -- ✅ Cross-team development (clear message contracts) -- ✅ Long-term maintenance (years, not weeks) -- ✅ GameObject/Component targeting needed (Unity-specific patterns) -- ✅ Execution order control essential (priority-based handlers) -- ✅ Message validation/transformation needed (interceptor pipeline) -- ✅ Global observation needed (listen to all message instances) -- ✅ Post-processing needed (analytics, logging after handlers) -- ✅ Late update semantics needed (timing-specific processing) -- ✅ Teams without DI experience (no framework dependencies) -- ✅ Want plug-and-play solution (zero dependencies, immediate use) +- [x] Unity-first projects (MonoBehaviour lifecycle integration) +- [x] 10+ systems that communicate (pub/sub decoupling) +- [x] Observability essential (Inspector debugging, message history) +- [x] Memory leaks are a concern (automatic lifecycle management) +- [x] Cross-team development (clear message contracts) +- [x] Long-term maintenance (years, not weeks) +- [x] GameObject/Component targeting needed (Unity-specific patterns) +- [x] Execution order control essential (priority-based handlers) +- [x] Message validation/transformation needed (interceptor pipeline) +- [x] Global observation needed (listen to all message instances) +- [x] Post-processing needed (analytics, logging after handlers) +- [x] Late update semantics needed (timing-specific processing) +- [x] Teams without DI experience (no framework dependencies) +- [x] Want plug-and-play solution (zero dependencies, immediate use) ### UniRx Wins When -- ✅ Simple pub/sub with minimal setup (MessageBroker is straightforward) -- ✅ Complex event stream transformations needed -- ✅ Time-based operations (throttle, debounce, buffer) -- ✅ Combining multiple input sources -- ✅ Reactive UI data binding -- ✅ Team familiar with reactive programming -- ✅ Need LINQ-style query operators on events -- ✅ Async operations with cancellation and composition +- [x] Simple pub/sub with minimal setup (MessageBroker is straightforward) +- [x] Complex event stream transformations needed +- [x] Time-based operations (throttle, debounce, buffer) +- [x] Combining multiple input sources +- [x] Reactive UI data binding +- [x] Team familiar with reactive programming +- [x] Need LINQ-style query operators on events +- [x] Async operations with cancellation and composition ### MessagePipe Wins When -- ✅ Performance is THE priority (highest throughput) -- ✅ Already using DI (VContainer, Zenject, etc.) -- ✅ Cross-platform .NET projects (not Unity-only) -- ✅ Need native async/await support -- ✅ Large-scale projects with DI architecture -- ✅ Want compile-time leak detection (Roslyn analyzer) -- ✅ High message frequency (thousands/frame) +- [x] Performance is THE priority (highest throughput) +- [x] Already using DI (VContainer, Zenject, etc.) +- [x] Cross-platform .NET projects (not Unity-only) +- [x] Need native async/await support +- [x] Large-scale projects with DI architecture +- [x] Want compile-time leak detection (Roslyn analyzer) +- [x] High message frequency (thousands/frame) ### Zenject Signals Win When -- ✅ Already using Zenject for dependency injection -- ✅ Testability through DI is critical -- ✅ Need subscriber validation (ensure handlers exist) -- ✅ Team experienced with Zenject -- ✅ Want DI-managed lifecycle -- ✅ Integration with existing Zenject architecture +- [x] Already using Zenject for dependency injection +- [x] Testability through DI is critical +- [x] Need subscriber validation (ensure handlers exist) +- [x] Team experienced with Zenject +- [x] Want DI-managed lifecycle +- [x] Integration with existing Zenject architecture ### C# Events Win When -- ✅ You need return values or out parameters -- ✅ Writing a library (DxMessaging is Unity-specific) -- ✅ Small, stable scope (5-10 events max) -- ✅ Team is C# experts, Unity beginners +- [x] You need return values or out parameters +- [x] Writing a library (DxMessaging is Unity-specific) +- [x] Small, stable scope (5-10 events max) +- [x] Team is C# experts, Unity beginners ### UnityEvents Win When -- ✅ Designers need to wire logic without code -- ✅ Rapid prototyping with prefabs -- ✅ Very simple games (mobile casual, hyper-casual) +- [x] Designers need to wire logic without code +- [x] Rapid prototyping with prefabs +- [x] Very simple games (mobile casual, hyper-casual) ### SOA (GameEvent/Variables) Wins When -- ✅ Designers must create and wire events without touching code -- ✅ Team is already heavily invested in SOA with many existing assets -- ✅ Designer empowerment is the absolute top priority -- ⚠️ **BUT:** Consider migration costs and maintainability issues (see [Anti-SOA critique](https://github.com/cathei/AntiScriptableObjectArchitecture)) -- ⚠️ **Alternative:** Use ScriptableObjects for configs only + DxMessaging for events (Pattern B in [SOA Guide](../guides/patterns.md#14-compatibility-with-scriptable-object-architecture-soa)) +- [x] Designers must create and wire events without touching code +- [x] Team is already heavily invested in SOA with many existing assets +- [x] Designer empowerment is the absolute top priority +- **BUT:** Consider migration costs and maintainability issues (see [Anti-SOA critique](https://github.com/cathei/AntiScriptableObjectArchitecture)) +- **Alternative:** Use ScriptableObjects for configs only + DxMessaging for events (Pattern B in [SOA Guide](../guides/patterns.md#14-compatibility-with-scriptable-object-architecture-soa)) ### Static Event Bus Wins When -- ✅ You've already built one and it works -- ✅ Very simple use cases (just need globals) +- [x] You've already built one and it works +- [x] Very simple use cases (just need globals) ## Cost-Benefit Summary @@ -1608,53 +1608,53 @@ public void TestAchievementSystem() { ### 1. Project Lifespan? -- **<1 week (game jam):** Skip DxMessaging → Use C# events or direct calls -- **1-4 weeks (prototype):** Maybe → If you plan to continue, use DxMessaging -- **1+ months (real project):** Yes → DxMessaging will save you time -- **6+ months (production):** Absolutely → You'll thank yourself later +- **<1 week (game jam):** Skip DxMessaging -> Use C# events or direct calls +- **1-4 weeks (prototype):** Maybe -> If you plan to continue, use DxMessaging +- **1+ months (real project):** Yes -> DxMessaging will save you time +- **6+ months (production):** Absolutely -> You'll thank yourself later ### 2. Team Size? -- **Solo dev:** Optional → Depends on project complexity -- **2-3 devs:** Valuable → Reduces communication overhead -- **4+ devs:** Highly recommended → Clear contracts between systems -- **Remote/distributed team:** Essential → Explicit message contracts prevent miscommunication +- **Solo dev:** Optional -> Depends on project complexity +- **2-3 devs:** Valuable -> Reduces communication overhead +- **4+ devs:** Highly recommended -> Clear contracts between systems +- **Remote/distributed team:** Essential -> Explicit message contracts prevent miscommunication ### 3. Codebase Size? -- **<1k lines:** Skip it → Direct method calls are fine -- **1k-5k lines:** Consider it → If you're growing fast -- **5k-20k lines:** Recommended → Coupling becomes painful -- **20k+ lines:** Absolutely → Refactoring becomes significantly more challenging without it +- **<1k lines:** Skip it -> Direct method calls are fine +- **1k-5k lines:** Consider it -> If you're growing fast +- **5k-20k lines:** Recommended -> Coupling becomes painful +- **20k+ lines:** Absolutely -> Refactoring becomes significantly more challenging without it ### 4. How Many Systems Need to Communicate? -- **1-2 systems:** Skip → Just call methods directly -- **3-5 systems:** Consider → If they don't share references -- **6-10 systems:** Recommended → Coupling becomes unmanageable -- **10+ systems:** Essential → Managing many SerializeField references becomes difficult +- **1-2 systems:** Skip -> Just call methods directly +- **3-5 systems:** Consider -> If they don't share references +- **6-10 systems:** Recommended -> Coupling becomes unmanageable +- **10+ systems:** Essential -> Managing many SerializeField references becomes difficult ### 5. Have You Had Memory Leaks From Forgotten Unsubscribes? -- **Never:** Optional → You may not need automatic lifecycle management -- **Once or twice:** Consider it → Prevention is cheaper than debugging -- **Multiple times:** Recommended → Automatic cleanup would help -- **Currently debugging one:** Strongly recommended → Consider adopting DxMessaging +- **Never:** Optional -> You may not need automatic lifecycle management +- **Once or twice:** Consider it -> Prevention is cheaper than debugging +- **Multiple times:** Recommended -> Automatic cleanup would help +- **Currently debugging one:** Strongly recommended -> Consider adopting DxMessaging ### 6. How Often Do You Debug "What Fired When?" -- **Never:** Likely not needed → Small projects may not require message debugging tools +- **Never:** Likely not needed -> Small projects may not require message debugging tools - **Rarely:** Optional, but would help -- **Monthly:** Recommended → Inspector diagnostics will save hours -- **Weekly:** Strongly recommended → Debugging tools would provide significant time savings +- **Monthly:** Recommended -> Inspector diagnostics will save hours +- **Weekly:** Strongly recommended -> Debugging tools would provide significant time savings ### Quick Decision Matrix ```text -Game Jam → C# Events (speed over safety) -Prototype → DxMessaging IF continuing, else C# Events -Production → DxMessaging (unless <1k lines) -Legacy codebase → Migrate gradually (see Migration Guide) +Game Jam -> C# Events (speed over safety) +Prototype -> DxMessaging IF continuing, else C# Events +Production -> DxMessaging (unless <1k lines) +Legacy codebase -> Migrate gradually (see Migration Guide) ``` ### The Real Question @@ -1673,9 +1673,9 @@ Legacy codebase → Migrate gradually (see Migration Guide) If you're reading this and thinking: -- **"I've experienced these pain points"** → DxMessaging will help -- **"This seems like overkill"** → You probably don't need it yet -- **"I need this yesterday"** → DxMessaging may be a good fit +- **"I've experienced these pain points"** to DxMessaging will help +- **"This seems like overkill"** to You probably don't need it yet +- **"I need this yesterday"** to DxMessaging may be a good fit See also diff --git a/docs/architecture/design-and-architecture.md b/docs/architecture/design-and-architecture.md index a72b7394..a9c96ef0 100644 --- a/docs/architecture/design-and-architecture.md +++ b/docs/architecture/design-and-architecture.md @@ -1,6 +1,6 @@ # Design & Architecture: Under the Hood -This document explains DxMessaging’s internal design, performance optimizations, and architectural decisions. Read this to understand how and why DxMessaging works the way it does. +This document explains DxMessaging's internal design, performance optimizations, and architectural decisions. Read this to understand how and why DxMessaging works the way it does. ## Table of Contents @@ -17,23 +17,23 @@ This document explains DxMessaging’s internal design, performance optimization DxMessaging was built with these principles: -1. Zero‑Allocation Communication +1. Zero-Allocation Communication - Messages are `readonly struct` types passed by `ref`. - No boxing, no temporary objects, minimal GC pressure. - Handlers receive `ref` parameters for struct messages. -1. Type‑Safe by Default - - Compile‑time guarantees via generic constraints. - - No string‑based dispatch (unlike Unity’s `SendMessage`). - - Source generators provide boilerplate‑free message definitions. +1. Type-Safe by Default + - Compile-time guarantees via generic constraints. + - No string-based dispatch (unlike Unity's `SendMessage`). + - Source generators provide boilerplate-free message definitions. 1. Predictable Execution - - Priority‑based handler ordering (lower priority runs first). - - Three‑stage pipeline: Interceptors → Handlers → Post‑Processors. + - Priority-based handler ordering (lower priority runs first). + - Three-stage pipeline: Interceptors > Handlers > Post-Processors. - Deterministic behavior within each priority level. 1. Observable & Debuggable - - Built‑in diagnostics via `CyclicBuffer`. + - Built-in diagnostics via `CyclicBuffer`. - Registration logging with `RegistrationLog`. - Inspector integration for runtime visibility. @@ -45,7 +45,7 @@ DxMessaging was built with these principles: 1. Decoupled by Nature - Three semantic categories: Untargeted, Targeted, Broadcast. - No direct references between producers and consumers. - - Context‑aware (who sent, who received) without tight coupling. + - Context-aware (who sent, who received) without tight coupling. ## Architecture Overview @@ -58,15 +58,15 @@ flowchart TB end subgraph Token["Registration Layer"] - MRT[MessageRegistrationToken
• Stages registrations
• Enable/Disable
• Lifecycle management] + MRT[MessageRegistrationToken
- Stages registrations
- Enable/Disable
- Lifecycle management] end subgraph Handler["Handler Layer"] - MH[MessageHandler
• Per-component handler
• Active/Inactive state] + MH[MessageHandler
- Per-component handler
- Active/Inactive state] end subgraph Bus["Message Bus Layer"] - MB[MessageBus
• Interceptors
• Handlers
• Post-Processors] + MB[MessageBus
- Interceptors
- Handlers
- Post-Processors] end CompA ==> MRT @@ -90,18 +90,18 @@ flowchart TB 1. **Application Layer** - Your Unity components register message handlers 1. **Registration Layer** - Token manages handler lifecycle (enable/disable/cleanup) 1. **Handler Layer** - Per-component state management (active/inactive) -1. **Message Bus Layer** - Routes messages through interceptors → handlers → post-processors +1. **Message Bus Layer** - Routes messages through interceptors > handlers > post-processors ## Performance Optimizations - Struct messages passed by `ref` to avoid copying and GC. - Minimal allocations in hot paths; logging and diagnostics use ring buffers. -- Pre‑allocated internal collections for common operations. +- Pre-allocated internal collections for common operations. - Handlers are sorted by priority once during registration. Emitting a message iterates through all active handlers in that order. ## Message Type System -- Untargeted: broadcast‑like notifications without an explicit receiver. +- Untargeted: broadcast-like notifications without an explicit receiver. - Targeted: deliver to a specific target (e.g., GameObject, `InstanceId`). - Broadcast: deliver to all listeners (optionally capturing the source). @@ -109,31 +109,31 @@ Attributes like `[DxTargetedMessage]` and `[DxBroadcastMessage]` (with source ge ## Registration and Lifecycle -- `MessageRegistrationToken` groups per‑component registrations. +- `MessageRegistrationToken` groups per-component registrations. - Enable/disable toggles all component handlers together. - Disposal cleans up handlers automatically, preventing leaks. - `MessageAwareComponent` wires Unity lifecycles to tokens for safety. ## The Message Bus -Message flow: Interceptors → Handlers → Post‑Processors. +Message flow: Interceptors > Handlers > Post-Processors. - Interceptors may transform or cancel messages before delivery. - Handlers execute in priority order; lower number executes first. -- Post‑processors observe outcomes and can emit follow‑up messages. +- Post-processors observe outcomes and can emit follow-up messages. ## Why DxMessaging is Fast -- No reflection for dispatch; compile‑time generics and static typing. +- No reflection for dispatch; compile-time generics and static typing. - No string dispatch or dynamic lookup. -- Ref‑based delivery avoids copies and allocations. +- Ref-based delivery avoids copies and allocations. - Tight internal data structures tuned for Unity hot loops. ## Design Decisions and Tradeoffs - Priorities are numeric for clarity and control; predictable ordering beats implicit timing. - Strong typing over dynamic flexibility; safer refactoring and IDE support. -- Diagnostics are opt‑in and lightweight to keep runtime overhead minimal. +- Diagnostics are opt-in and lightweight to keep runtime overhead minimal. See also: diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index 7d9a9869..f87ac82e 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,576,000 | Yes | -| DxMessaging (GameObject) - Normal | 10,264,000 | No | -| DxMessaging (Component) - Normal | 10,086,000 | No | -| DxMessaging (GameObject) - No-Copy | 11,552,000 | No | -| DxMessaging (Component) - No-Copy | 11,266,000 | No | -| DxMessaging (Untargeted) - No-Copy | 16,892,000 | No | -| DxMessaging (Untargeted) - Interceptors | 7,628,000 | No | -| DxMessaging (Untargeted) - Post-Processors | 6,562,000 | No | -| Reflexive (One Argument) | 2,868,000 | No | -| Reflexive (Two Arguments) | 2,386,000 | No | -| Reflexive (Three Arguments) | 2,372,000 | No | +| Unity | 2,622,657 | Yes | +| DxMessaging (GameObject) - Normal | 10,033,962 | No | +| DxMessaging (Component) - Normal | 10,047,509 | No | +| DxMessaging (GameObject) - No-Copy | 11,394,149 | No | +| DxMessaging (Component) - No-Copy | 8,577,398 | No | +| DxMessaging (Untargeted) - No-Copy | 19,529,600 | No | +| DxMessaging (Untargeted) - Interceptors | 7,639,572 | No | +| DxMessaging (Untargeted) - Post-Processors | 6,476,848 | No | +| Reflexive (One Argument) | 2,837,183 | No | +| Reflexive (Two Arguments) | 2,300,236 | No | +| Reflexive (Three Arguments) | 2,322,060 | No | ## macOS diff --git a/docs/concepts/index.md b/docs/concepts/index.md index c352eacb..5ddd0b46 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -1,24 +1,24 @@ # Concepts -[← Documentation Home](../index.md) | [Getting Started](../getting-started/getting-started.md) +[Documentation Home](../index.md) | [Getting Started](../getting-started/getting-started.md) --- -This section explains the core concepts behind DxMessaging. **Concepts** are the foundational ideas and mental models that inform how you design and structure your messaging—understanding these will help you make better architectural decisions and avoid common pitfalls. +This section explains the core concepts behind DxMessaging. **Concepts** are the foundational ideas and mental models that inform how you design and structure your messaging -- understanding these will help you make better architectural decisions and avoid common pitfalls. ## Start Here -- **[Mental Model](mental-model.md)** — How to think about DxMessaging. Covers the philosophy, the three message types with analogies, tokens and lifecycle, and when to use what. Read this first. +- **[Mental Model](mental-model.md)** -- How to think about DxMessaging. Covers the philosophy, the three message types with analogies, tokens and lifecycle, and when to use what. Read this first. ## 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)** -- Deep dive into Untargeted, Targeted, and Broadcast messages 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`. +- **[Targeting and Context](targeting-and-context.md)** -- How DxMessaging uses GameObjects and Components as message context, and the role of `InstanceId`. -- **[Listening Patterns](listening-patterns.md)** — All the ways to receive messages: targeted, untargeted, broadcast, and "without targeting/source" patterns. +- **[Listening Patterns](listening-patterns.md)** -- All the ways to receive messages: targeted, untargeted, broadcast, and "without targeting/source" patterns. -- **[Interceptors and Ordering](interceptors-and-ordering.md)** — Control message flow with priorities, post-processors, and interceptors. +- **[Interceptors and Ordering](interceptors-and-ordering.md)** -- Control message flow with priorities, post-processors, and interceptors. ## Quick Reference @@ -32,6 +32,6 @@ This section explains the core concepts behind DxMessaging. **Concepts** are the ## Related Sections -- [Getting Started](../getting-started/index.md) — Hands-on walkthrough -- [Guides](../guides/patterns.md) — Practical patterns and recipes -- [Reference](../reference/reference.md) — API documentation +- [Getting Started](../getting-started/index.md) -- Hands-on walkthrough +- [Guides](../guides/patterns.md) -- Practical patterns and recipes +- [Reference](../reference/reference.md) -- API documentation diff --git a/docs/concepts/interceptors-and-ordering.md b/docs/concepts/interceptors-and-ordering.md index 2c968239..b55617b3 100644 --- a/docs/concepts/interceptors-and-ordering.md +++ b/docs/concepts/interceptors-and-ordering.md @@ -1,4 +1,4 @@ -# Interceptors, Ordering, and Post‑Processing +# Interceptors, Ordering, and Post-Processing ## Snapshot Semantics: Frozen Listener Lists @@ -48,38 +48,38 @@ DxMessaging runs emissions through a fixed pipeline. This section documents the - Priority: lower numbers run earlier. - Same priority: registration order is preserved. -- Within a priority group, fast handlers (by‑ref) run before action handlers. +- Within a priority group, fast handlers (by-ref) run before action handlers. - Each category (Untargeted, Targeted, Broadcast) has its own pipeline. Untargeted pipeline 1. Interceptors for `T` (ascending priority; within priority by registration order) -1. Global Accept‑All Untargeted handlers (in the MessageHandler that registered them) +1. Global Accept-All Untargeted handlers (in the MessageHandler that registered them) 1. Untargeted handlers for `T` (ascending priority; within priority by registration order) -1. Untargeted Post‑Processors for `T` (ascending priority; within priority by registration order) +1. Untargeted Post-Processors for `T` (ascending priority; within priority by registration order) Targeted pipeline 1. Interceptors for `T` (ascending priority) -1. Global Accept‑All Targeted handlers (receive `(target, ITargetedMessage)`) +1. Global Accept-All Targeted handlers (receive `(target, ITargetedMessage)`) 1. Targeted handlers for `T` registered for the specific `target` -1. Targeted‑Without‑Targeting handlers for `T` (listen for all targets) -1. Targeted Post‑Processors for `T` registered for the specific `target` -1. Targeted‑Without‑Targeting Post‑Processors for `T` (listen for all targets) +1. Targeted-Without-Targeting handlers for `T` (listen for all targets) +1. Targeted Post-Processors for `T` registered for the specific `target` +1. Targeted-Without-Targeting Post-Processors for `T` (listen for all targets) Broadcast pipeline 1. Interceptors for `T` (ascending priority) -1. Global Accept‑All Broadcast handlers (receive `(source, IBroadcastMessage)`) +1. Global Accept-All Broadcast handlers (receive `(source, IBroadcastMessage)`) 1. Broadcast handlers for `T` registered for the specific `source` -1. Broadcast‑Without‑Source handlers for `T` (listen for all sources) -1. Broadcast Post‑Processors for `T` registered for the specific `source` -1. Broadcast‑Without‑Source Post‑Processors for `T` (listen for all sources) +1. Broadcast-Without-Source handlers for `T` (listen for all sources) +1. Broadcast Post-Processors for `T` registered for the specific `source` +1. Broadcast-Without-Source Post-Processors for `T` (listen for all sources) Notes on handler groups -- Fast vs Action: At a given priority, fast handlers (by‑ref delegates) are invoked before action handlers, and within each group the registration order is preserved. -- “Without Targeting/Source” registrations run in their own groups and do not replace the specific target/source groups. +- Fast vs Action: At a given priority, fast handlers (by-ref delegates) are invoked before action handlers, and within each group the registration order is preserved. +- "Without Targeting/Source" registrations run in their own groups and do not replace the specific target/source groups. Visual overview @@ -87,27 +87,27 @@ Visual overview flowchart LR subgraph Untargeted["Untargeted Messages"] direction TB - U1["Interceptors(T)"] --> U2[Global Accept‑All Untargeted] + U1["Interceptors(T)"] --> U2[Global Accept-All Untargeted] U2 --> U3["Handlers(T)"] - U3 --> U4["Post‑Processors(T)"] + U3 --> U4["Post-Processors(T)"] end subgraph Targeted["Targeted Messages"] direction TB - T1["Interceptors(T)"] --> T2[Global Accept‑All Targeted] + T1["Interceptors(T)"] --> T2[Global Accept-All Targeted] T2 --> T3["Handlers(T) @ target"] T3 --> T4["Handlers(T) (All Targets)"] - T4 --> T5["Post‑Processors(T) @ target"] - T5 --> T6["Post‑Processors(T) (All Targets)"] + T4 --> T5["Post-Processors(T) @ target"] + T5 --> T6["Post-Processors(T) (All Targets)"] end subgraph Broadcast["Broadcast Messages"] direction TB - B1["Interceptors(T)"] --> B2[Global Accept‑All Broadcast] + B1["Interceptors(T)"] --> B2[Global Accept-All Broadcast] B2 --> B3["Handlers(T) @ source"] B3 --> B4["Handlers(T) (All Sources)"] - B4 --> B5["Post‑Processors(T) @ source"] - B5 --> B6["Post‑Processors(T) (All Sources)"] + B4 --> B5["Post-Processors(T) @ source"] + B5 --> B6["Post-Processors(T) (All Sources)"] end classDef neutral stroke-width:2px @@ -128,12 +128,12 @@ Example sequence sequenceDiagram participant P as Producer participant I as Interceptor(s) - participant G as Global Accept‑All + participant G as Global Accept-All participant H as Handler(s) - participant PP as Post‑Processor(s) + participant PP as Post-Processor(s) P->>I: emit(ref message) I-->>P: false? cancel : continue - I->>G: message (category‑specific) + I->>G: message (category-specific) G->>H: message H->>PP: after all handlers complete ``` @@ -142,7 +142,7 @@ Interceptors - Mutate or cancel messages before any handler runs. Return `false` to cancel. - Define per category: `RegisterUntargetedInterceptor`, `RegisterTargetedInterceptor`, `RegisterBroadcastInterceptor`. -- Useful for validation, normalization, enrichment, and short‑circuiting. +- Useful for validation, normalization, enrichment, and short-circuiting. ```csharp using DxMessaging.Core; // MessageHandler, InstanceId @@ -161,9 +161,9 @@ _ = bus.RegisterTargetedInterceptor( ); ``` -Real‑World Use Cases +Real-World Use Cases -#### State‑Based Message Cancellation +#### State-Based Message Cancellation Prevent UI messages from being processed based on current UI state: @@ -466,43 +466,43 @@ var cooldowns = new CooldownManager(); cooldowns.RegisterInterceptors(); var spell1 = new CastSpell("fireball"); -spell1.EmitTargeted(playerId); // ✓ Allowed +spell1.EmitTargeted(playerId); // Yes Allowed var spell2 = new CastSpell("fireball"); -spell2.EmitTargeted(playerId); // ✗ Blocked (too soon) +spell2.EmitTargeted(playerId); // No Blocked (too soon) // ... wait 1.5s ... var spell3 = new CastSpell("fireball"); -spell3.EmitTargeted(playerId); // ✓ Allowed +spell3.EmitTargeted(playerId); // Yes Allowed ``` When to Use Interceptors -✅ **Good use cases:** +Yes **Good use cases:** - Input validation and sanitization - Value clamping and normalization - Permission and authorization checks -- State‑based message filtering +- State-based message filtering - Rate limiting and cooldown enforcement - Message enrichment (adding timestamps, session IDs, etc.) - Early exit for duplicate or redundant messages - Logging suspicious or invalid message attempts -⚠️ **Key principles:** +Warning: **Key principles:** -- **Run before handlers**: Interceptors execute before any type‑specific handlers, making them perfect for preprocessing -- **Can mutate**: Unlike post‑processors, interceptors can modify message data +- **Run before handlers**: Interceptors execute before any type-specific handlers, making them perfect for preprocessing +- **Can mutate**: Unlike post-processors, interceptors can modify message data - **Can cancel**: Return `false` to prevent the message from reaching handlers - **Priority matters**: Lower priority values run first (use negative priorities for early interceptors) -❌ **Avoid for:** +No **Avoid for:** -- Read‑only observation (use handlers or post‑processors instead) -- Actions that should run after message processing (use post‑processors) +- Read-only observation (use handlers or post-processors instead) +- Actions that should run after message processing (use post-processors) - Heavy computation that doesn't need to block the message -Post‑processors +Post-processors -- Observe after handlers. Great for logging, analytics, or follow‑up emission. +- Observe after handlers. Great for logging, analytics, or follow-up emission. - Per category and scope (per target/source or all): - Untargeted: `RegisterUntargetedPostProcessor` - Targeted (specific): `RegisterTargetedPostProcessor(target, ...)` @@ -510,11 +510,11 @@ Post‑processors - Broadcast (specific): `RegisterBroadcastPostProcessor(source, ...)` - Broadcast (all): `RegisterBroadcastWithoutSourcePostProcessor(...)` -Global Accept‑All +Global Accept-All - Register once and observe all messages on a handler. - Overloads exist for action and fast handlers. -- Runs between interceptors and type‑specific handlers. +- Runs between interceptors and type-specific handlers. Related diff --git a/docs/concepts/listening-patterns.md b/docs/concepts/listening-patterns.md index c1694dba..6c12a478 100644 --- a/docs/concepts/listening-patterns.md +++ b/docs/concepts/listening-patterns.md @@ -2,7 +2,7 @@ ## Targeted across all targets -- Accept every targeted message of a given type regardless of who it’s for. +- Accept every targeted message of a given type regardless of who it's for. ```csharp using DxMessaging.Core; // InstanceId @@ -12,7 +12,7 @@ using DxMessaging.Core.Messages; _ = token.RegisterTargetedWithoutTargeting(OnAnyHeal); void OnAnyHeal(InstanceId target, Heal m) => Audit(target, m); -// Post‑process all targeted of type +// Post-process all targeted of type _ = token.RegisterTargetedWithoutTargetingPostProcessor(OnAnyHealPost); void OnAnyHealPost(InstanceId target, Heal m) => Log(target, m); ``` @@ -29,12 +29,12 @@ using DxMessaging.Core.Messages; _ = token.RegisterBroadcastWithoutSource(OnAnyTookDamage); void OnAnyTookDamage(InstanceId source, TookDamage m) => Track(source, m); -// Post‑process all broadcast of type +// Post-process all broadcast of type _ = token.RegisterBroadcastWithoutSourcePostProcessor(OnAnyTookDamagePost); void OnAnyTookDamagePost(InstanceId source, TookDamage m) => Log(source, m); ``` -## Global accept‑all (debug/inspection) +## Global accept-all (debug/inspection) - Receive every message of every type on a handler; useful for tooling. @@ -47,7 +47,7 @@ var dereg = bus.RegisterGlobalAcceptAll(handler); // implement handler callbacks for generic categories on your MessageHandler ``` -## Real‑World Use Cases +## Real-World Use Cases ### Development Debug Dump @@ -69,12 +69,12 @@ public class DebugMessageLogger : MessageHandler public override void Handle(ref InstanceId target, ref ITargetedMessage message) { - Debug.Log($"[Targeted → {target}] {message.GetType().Name}: {message}"); + Debug.Log($"[Targeted -> {target}] {message.GetType().Name}: {message}"); } public override void Handle(ref InstanceId source, ref IBroadcastMessage message) { - Debug.Log($"[Broadcast ← {source}] {message.GetType().Name}: {message}"); + Debug.Log($"[Broadcast <- {source}] {message.GetType().Name}: {message}"); } } @@ -85,7 +85,7 @@ _ = MessageHandler.MessageBus.RegisterGlobalAcceptAll(logger); #endif ``` -### Attribute‑Based Network Replication +### Attribute-Based Network Replication Automatically replicate messages marked with custom attributes across the network: @@ -236,20 +236,20 @@ public class MessageAnalytics : MessageHandler } ``` -When to Use Global Accept‑All +When to Use Global Accept-All -✅ **Good use cases:** +Yes **Good use cases:** -- Development‑time debugging and logging -- Cross‑cutting concerns (analytics, telemetry, metrics) -- Attribute‑based systems (networking, serialization, persistence) +- Development-time debugging and logging +- Cross-cutting concerns (analytics, telemetry, metrics) +- Attribute-based systems (networking, serialization, persistence) - Testing and diagnostics tools - Message replay/recording systems -⚠️ **Performance consideration:** +Warning: **Performance consideration:** Global Accept-All handlers are invoked for **every** message of **every** type. For performance-sensitive gameplay logic, prefer type-specific registrations which use O(1) lookup instead of O(N) iteration. -❌ **Avoid for:** +No **Avoid for:** - Core gameplay logic that only needs specific message types - Hot paths with thousands of messages per frame @@ -257,7 +257,7 @@ Global Accept-All handlers are invoked for **every** message of **every** type. Tips -- Use across‑all listeners for diagnostics, analytics, or cross‑cutting observers. +- Use across-all listeners for diagnostics, analytics, or cross-cutting observers. - Prefer specific (target/source) registrations for gameplay logic. Related diff --git a/docs/concepts/mental-model.md b/docs/concepts/mental-model.md index 8b78e95a..c80d50ae 100644 --- a/docs/concepts/mental-model.md +++ b/docs/concepts/mental-model.md @@ -1,6 +1,6 @@ # Mental Model: How to Think About DxMessaging -[← Concepts Index](index.md) | [Message Types](message-types.md) | [Getting Started](../getting-started/getting-started.md) +[Concepts Index](index.md) | [Message Types](message-types.md) | [Getting Started](../getting-started/getting-started.md) --- @@ -22,12 +22,12 @@ In a typical Unity project, you face a common challenge: ```text Player takes damage - ├── Health bar needs updating - ├── Sound system plays damage audio - ├── Camera shakes - ├── Achievement system checks milestones - ├── Analytics logs the event - └── Tutorial system checks for tips + +-- Health bar needs updating + +-- Sound system plays damage audio + +-- Camera shakes + +-- Achievement system checks milestones + +-- Analytics logs the event + +-- Tutorial system checks for tips ``` Traditional approaches require tight coupling: either the Player knows about all these systems, or they all hold references to the Player. Both paths lead to tangled dependencies. @@ -42,7 +42,7 @@ DxMessaging models three fundamental patterns of communication. Each maps to a r ```mermaid flowchart LR - S[Someone] -->|announces| PA[📢 PA System] + S[Someone] -->|announces| PA[PA System] PA --> L1[Listener A] PA --> L2[Listener B] PA --> L3[Listener C] @@ -86,7 +86,7 @@ public readonly partial struct SettingsChanged ```mermaid flowchart LR - S[Sender] -->|"To: Player"| Letter[📬 Message Bus] + S[Sender] -->|"To: Player"| Letter[Message Bus] Letter --> Player[Player receives] Other1[Enemy A] -.->|ignores| Letter Other2[Enemy B] -.->|ignores| Letter @@ -121,7 +121,7 @@ heal.EmitGameObjectTargeted(playerGameObject); ```mermaid flowchart LR - Source[Enemy] -->|"I took damage!"| Radio[📻 Message Bus] + Source[Enemy] -->|"I took damage!"| Radio[Message Bus] Radio --> L1[Damage Numbers UI] Radio --> L2[Achievement Tracker] Radio --> L3[Analytics] @@ -171,11 +171,11 @@ flowchart TD | Question | Untargeted | Targeted | Broadcast | | ----------------------------------- | :--------: | :------: | :-------: | -| Has a specific sender that matters? | ❌ | ❌ | ✅ | -| Has a specific recipient? | ❌ | ✅ | ❌ | -| Is it a command? | ❌ | ✅ | ❌ | -| Is it an observable fact? | Maybe | ❌ | ✅ | -| Is it a global announcement? | ✅ | ❌ | ❌ | +| Has a specific sender that matters? | No | No | Yes | +| Has a specific recipient? | No | Yes | No | +| Is it a command? | No | Yes | No | +| Is it an observable fact? | Maybe | No | Yes | +| Is it a global announcement? | Yes | No | No | ## Tokens and Lifecycle @@ -200,7 +200,7 @@ public class HealthDisplay : MessageAwareComponent } ``` -> ⚠️ **Warning: Always call `base.RegisterMessageHandlers()`** +> **Warning: Always call `base.RegisterMessageHandlers()`** > > When overriding `RegisterMessageHandlers()`, always call `base.RegisterMessageHandlers()` first. This ensures that any registrations from parent classes are preserved. Forgetting this call can silently break inherited behavior. diff --git a/docs/concepts/message-types.md b/docs/concepts/message-types.md index d8d549e6..35e9f29f 100644 --- a/docs/concepts/message-types.md +++ b/docs/concepts/message-types.md @@ -1,6 +1,6 @@ # Message Types: When and How to Use -[← Back to Index](../getting-started/index.md) | [Getting Started](../getting-started/getting-started.md) | [Patterns](../guides/patterns.md) | [Visual Guide](../getting-started/visual-guide.md) +[Back to Index](../getting-started/index.md) | [Getting Started](../getting-started/getting-started.md) | [Patterns](../guides/patterns.md) | [Visual Guide](../getting-started/visual-guide.md) --- @@ -20,17 +20,17 @@ flowchart TD Start --> Q1{Is it a global
announcement?} - Q1 -->|Yes
e.g., game paused,
settings changed| Untargeted[✅ Use UNTARGETED
Everyone listens] + Q1 -->|Yes
e.g., game paused,
settings changed| Untargeted[ Use UNTARGETED
Everyone listens] Q1 -->|No| Q2{Are you commanding
a specific entity?} - Q2 -->|Yes
e.g., heal Player,
open Chest #3| Targeted[✅ Use TARGETED
One recipient] + Q2 -->|Yes
e.g., heal Player,
open Chest #3| Targeted[ Use TARGETED
One recipient] Q2 -->|No| Q3{Is an entity announcing
something happened?} - Q3 -->|Yes
e.g., Enemy died,
Chest opened| Broadcast[✅ Use BROADCAST
Anyone can observe] + Q3 -->|Yes
e.g., Enemy died,
Chest opened| Broadcast[ Use BROADCAST
Anyone can observe] - Q3 -->|No| Rethink[🤔 Rethink your
message design] + Q3 -->|No| Rethink[Rethink your
message design] classDef primary stroke-width:3px class Untargeted primary @@ -46,9 +46,9 @@ flowchart TD ## Untargeted Messages -- Use for cross‑cutting notifications: settings changed, scene loaded, world regenerated. +- Use for cross-cutting notifications: settings changed, scene loaded, world regenerated. - Any listener can subscribe; no specific sender/recipient required. -- Define as immutable structs; prefer generic interface for zero‑boxing. +- Define as immutable structs; prefer generic interface for zero-boxing. ```csharp using DxMessaging.Core.Messages; @@ -93,7 +93,7 @@ heal.EmitGameObjectTargeted(playerGameObject); ## Broadcast Messages -- Use for reactionary “facts” about a specific source: TookDamage, PickedUpItem. +- Use for reactionary "facts" about a specific source: TookDamage, PickedUpItem. - Many systems can observe and react independently. - Distinct from targeted: the source is the sender; listeners decide if they care. @@ -160,8 +160,8 @@ damage.EmitGameObjectBroadcast(enemy); ## Listening to everything in a category -- All targeted of a type (any target): `RegisterTargetedWithoutTargeting` or post‑process with `RegisterTargetedWithoutTargetingPostProcessor`. -- All broadcast of a type (any source): `RegisterBroadcastWithoutSource` or post‑process with `RegisterBroadcastWithoutSourcePostProcessor`. +- All targeted of a type (any target): `RegisterTargetedWithoutTargeting` or post-process with `RegisterTargetedWithoutTargetingPostProcessor`. +- All broadcast of a type (any source): `RegisterBroadcastWithoutSource` or post-process with `RegisterBroadcastWithoutSourcePostProcessor`. ```csharp using DxMessaging.Core; @@ -178,7 +178,7 @@ void OnAnyTookDamage(ref InstanceId source, ref TookDamage m) => Track(source, m ## Choosing the right type -- Start with Broadcast for “X happened at Y” facts others may observe. +- Start with Broadcast for "X happened at Y" facts others may observe. - Use Targeted when one specific recipient must act. - Use Untargeted for global state changes anyone might care about. @@ -190,12 +190,12 @@ void OnAnyTookDamage(ref InstanceId source, ref TookDamage m) => Track(source, m - Organize related messages using nested types for better structure. - Use internal visibility for implementation-only messages. -## Don’ts +## Don'ts -- Don’t use Untargeted for per‑entity commands; prefer Targeted. -- Don’t overload Broadcast for commands; commands need a recipient (Targeted). +- Don't use Untargeted for per-entity commands; prefer Targeted. +- Don't overload Broadcast for commands; commands need a recipient (Targeted). - Avoid deep inheritance trees; messages should be small, flat data. -- Don’t emit from temporaries; bind structs to a variable before `Emit*`. +- Don't emit from temporaries; bind structs to a variable before `Emit*`. --- @@ -203,16 +203,16 @@ void OnAnyTookDamage(ref InstanceId source, ref TookDamage m) => Track(source, m ### Prerequisites -- → [Getting Started](../getting-started/getting-started.md) — Understand the basics first -- → [Visual Guide](../getting-started/visual-guide.md) — See the 3 types visualized +- to [Getting Started](../getting-started/getting-started.md) -- Understand the basics first +- to [Visual Guide](../getting-started/visual-guide.md) -- See the 3 types visualized #### Next Steps -- → [Patterns](../guides/patterns.md) — Real-world examples of each type -- → [Listening Patterns](listening-patterns.md) — All the ways to receive messages -- → [Interceptors & Ordering](interceptors-and-ordering.md) — Control message flow +- to [Patterns](../guides/patterns.md) -- Real-world examples of each type +- to [Listening Patterns](listening-patterns.md) -- All the ways to receive messages +- to [Interceptors & Ordering](interceptors-and-ordering.md) -- Control message flow ##### Try It -- → [Quick Start](../getting-started/quick-start.md) — Working example -- → [Mini Combat sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) — See all 3 types in action +- to [Quick Start](../getting-started/quick-start.md) -- Working example +- to [Mini Combat sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) -- See all 3 types in action diff --git a/docs/concepts/targeting-and-context.md b/docs/concepts/targeting-and-context.md index f6daa050..3b1081d9 100644 --- a/docs/concepts/targeting-and-context.md +++ b/docs/concepts/targeting-and-context.md @@ -4,7 +4,7 @@ ## Overview -Targeted and broadcast messages carry context: an `InstanceId` for the target (targeted) or source (broadcast). In Unity, `InstanceId` can represent either a `GameObject` or a specific `Component`. **These are completely separate channels** — mixing them is the #1 cause of "why isn't my handler firing?" bugs. +Targeted and broadcast messages carry context: an `InstanceId` for the target (targeted) or source (broadcast). In Unity, `InstanceId` can represent either a `GameObject` or a specific `Component`. **These are completely separate channels** -- mixing them is the #1 cause of "why isn't my handler firing?" bugs. ## Key Concepts @@ -86,21 +86,21 @@ _ = uiToken.RegisterGameObjectTargeted(player, ui.OnHeal); // Scenario 1: Target the GameObject heal.EmitGameObjectTargeted(player); -// ✅ health.OnHeal() fires -// ✅ ui.OnHeal() fires +// Yes health.OnHeal() fires +// Yes ui.OnHeal() fires // Both components receive it! // Scenario 2: Target a Component (but registered for GameObject) heal.EmitComponentTargeted(health); -// ❌ health.OnHeal() does NOT fire (registered for GameObject, not Component) -// ❌ ui.OnHeal() does NOT fire +// No health.OnHeal() does NOT fire (registered for GameObject, not Component) +// No ui.OnHeal() does NOT fire // Nothing happens! Wrong channel! // Scenario 3: Register for Component, emit to Component _ = healthToken.RegisterComponentTargeted(health, health.OnHeal); heal.EmitComponentTargeted(health); -// ✅ health.OnHeal() fires -// ❌ ui.OnHeal() does NOT fire (different component) +// Yes health.OnHeal() fires +// No ui.OnHeal() does NOT fire (different component) ``` ## Broadcast Messages: Same Rules Apply @@ -118,11 +118,11 @@ damage.EmitGameObjectBroadcast(enemyGameObject); // Register for broadcasts from this GameObject _ = token.RegisterGameObjectBroadcast(enemyGameObject, OnEnemyDamage); -// ✅ OnEnemyDamage fires when damage.EmitGameObjectBroadcast(enemyGameObject) is called +// Yes OnEnemyDamage fires when damage.EmitGameObjectBroadcast(enemyGameObject) is called // Register for broadcasts from this Component _ = token.RegisterComponentBroadcast(enemyComponent, OnComponentDamage); -// ❌ OnComponentDamage does NOT fire (registered for Component, but emitted from GameObject) +// No OnComponentDamage does NOT fire (registered for Component, but emitted from GameObject) ``` ## The `this` Trap @@ -134,14 +134,14 @@ public class Enemy : MonoBehaviour { void Start() { - // ❌ WRONG: Registered for GameObject + // No WRONG: Registered for GameObject _ = token.RegisterGameObjectTargeted(gameObject, OnDamage); } void TakeDamageFrom(GameObject attacker) { var damage = new TakeDamage(10); - // ❌ WRONG: Emitting to Component (this) + // No WRONG: Emitting to Component (this) damage.EmitAt(this); // WON'T BE RECEIVED! } @@ -150,11 +150,11 @@ public class Enemy : MonoBehaviour // FIX 1: Both use GameObject _ = token.RegisterGameObjectTargeted(gameObject, OnDamage); -damage.EmitAt(gameObject); // ✅ Works! +damage.EmitAt(gameObject); // Yes Works! // FIX 2: Both use Component _ = token.RegisterComponentTargeted(this, OnDamage); -damage.EmitAt(this); // ✅ Works! +damage.EmitAt(this); // Yes Works! ``` **Remember:** In Unity, `this` inside a `MonoBehaviour` is **always** a Component, never a GameObject! @@ -165,9 +165,9 @@ damage.EmitAt(this); // ✅ Works! This is useful for: -- **Analytics** — Track every action in your game without coupling to individual objects -- **Debugging** — See all events of a type in one place -- **Cross-cutting concerns** — Achievements, logging, VFX that respond to any entity's events +- **Analytics** -- Track every action in your game without coupling to individual objects +- **Debugging** -- See all events of a type in one place +- **Cross-cutting concerns** -- Achievements, logging, VFX that respond to any entity's events ### Why this is different from classic event buses @@ -181,7 +181,7 @@ This is useful for: ### Classic Event Bus Anti-Pattern ```csharp -// ❌ Traditional approach: Tight coupling, multiple subscriptions +// No Traditional approach: Tight coupling, multiple subscriptions EventBus.PlayerDamaged += OnPlayerDamaged; EventBus.EnemyDamaged += OnEnemyDamaged; EventBus.NPCDamaged += OnNPCDamaged; @@ -197,7 +197,7 @@ void OnBossDamaged(int amount) { RecordDamage("Boss", amount); } ### DxMessaging Global Observer Pattern ```csharp -// ✅ DxMessaging: One subscription, zero coupling +// Yes DxMessaging: One subscription, zero coupling _ = token.RegisterBroadcastWithoutSource(OnAnyDamage); void OnAnyDamage(ref InstanceId source, ref TookDamage msg) @@ -283,14 +283,14 @@ _ = analyticsToken.RegisterBroadcastWithoutSource(OnAnyDamage); // When player takes damage damage.EmitFrom(playerGameObject); -// ✅ OnPlayerDamage fires (specific listener) -// ✅ OnAnyDamage fires (global listener) +// Yes OnPlayerDamage fires (specific listener) +// Yes OnAnyDamage fires (global listener) // Both fire! // When enemy takes damage damage.EmitFrom(enemyGameObject); -// ❌ OnPlayerDamage does NOT fire (wrong source) -// ✅ OnAnyDamage fires (global listener catches everything) +// No OnPlayerDamage does NOT fire (wrong source) +// Yes OnAnyDamage fires (global listener catches everything) ``` ### Real-World Example: Combat System @@ -392,19 +392,19 @@ Global listeners (`RegisterTargetedWithoutTargeting` / `RegisterBroadcastWithout ### Do's -✅ **Pick a context and be consistent** across emitters and listeners for that message type -✅ **Prefer GameObject when in doubt** — easier coordination among components on the same object -✅ **Use explicit helpers** (`EmitGameObjectTargeted`, `RegisterGameObjectTargeted`) to make intent clear -✅ **Document** which context your message types use (add it to your message comments) -✅ **Use global listeners** for analytics, debugging, and cross-cutting concerns +Yes **Pick a context and be consistent** across emitters and listeners for that message type +Yes **Prefer GameObject when in doubt** -- easier coordination among components on the same object +Yes **Use explicit helpers** (`EmitGameObjectTargeted`, `RegisterGameObjectTargeted`) to make intent clear +Yes **Document** which context your message types use (add it to your message comments) +Yes **Use global listeners** for analytics, debugging, and cross-cutting concerns ### Don'ts -❌ **Don't emit to GameObject** and expect Component-registered handlers to receive it (and vice versa) -❌ **Don't assume `this` means GameObject** — it's always a Component in Unity -❌ **Don't mix GameObject/Component** for the same message type across your codebase -❌ **Don't register the same handler under both** unless you intend to handle both contexts -❌ **Don't use global listeners** in performance-critical tight loops (thousands of messages/frame) +No **Don't emit to GameObject** and expect Component-registered handlers to receive it (and vice versa) +No **Don't assume `this` means GameObject** -- it's always a Component in Unity +No **Don't mix GameObject/Component** for the same message type across your codebase +No **Don't register the same handler under both** unless you intend to handle both contexts +No **Don't use global listeners** in performance-critical tight loops (thousands of messages/frame) ## Troubleshooting @@ -414,11 +414,11 @@ Global listeners (`RegisterTargetedWithoutTargeting` / `RegisterBroadcastWithout ```csharp // Check 1: Are you emitting and registering on the same type? -// ❌ WRONG +// No WRONG _ = token.RegisterGameObjectTargeted(gameObject, OnHeal); heal.EmitAt(this); // Component, not GameObject! -// ✅ CORRECT +// Yes CORRECT _ = token.RegisterGameObjectTargeted(gameObject, OnHeal); heal.EmitAt(gameObject); // Both GameObject ``` @@ -439,13 +439,13 @@ See [Troubleshooting](../reference/troubleshooting.md) for more debugging tips. | **Emit Broadcast** | `msg.EmitGameObjectBroadcast(go)` | `msg.EmitComponentBroadcast(comp)` | | **Register Targeted** | `RegisterGameObjectTargeted(go, handler)` | `RegisterComponentTargeted(comp, handler)` | | **Register Broadcast** | `RegisterGameObjectBroadcast(go, handler)` | `RegisterComponentBroadcast(comp, handler)` | -| **Global Targeted** | `RegisterTargetedWithoutTargeting(handler)` | (Same — no distinction) | -| **Global Broadcast** | `RegisterBroadcastWithoutSource(handler)` | (Same — no distinction) | +| **Global Targeted** | `RegisterTargetedWithoutTargeting(handler)` | (Same -- no distinction) | +| **Global Broadcast** | `RegisterBroadcastWithoutSource(handler)` | (Same -- no distinction) | ## See Also -- **[Emit Shorthands](../advanced/emit-shorthands.md)** — Concise ways to emit messages -- **[Message Types](message-types.md)** — Understanding Untargeted, Targeted, and Broadcast -- **[Unity Integration](../guides/unity-integration.md)** — MessageAwareComponent and lifecycle -- **[Quick Reference](../reference/quick-reference.md)** — API cheat sheet -- **[Troubleshooting](../reference/troubleshooting.md)** — Solving common issues +- **[Emit Shorthands](../advanced/emit-shorthands.md)** -- Concise ways to emit messages +- **[Message Types](message-types.md)** -- Understanding Untargeted, Targeted, and Broadcast +- **[Unity Integration](../guides/unity-integration.md)** -- MessageAwareComponent and lifecycle +- **[Quick Reference](../reference/quick-reference.md)** -- API cheat sheet +- **[Troubleshooting](../reference/troubleshooting.md)** -- Solving common issues diff --git a/docs/examples/end-to-end-scene-transitions.md b/docs/examples/end-to-end-scene-transitions.md index 3ccb9d0b..296cec13 100644 --- a/docs/examples/end-to-end-scene-transitions.md +++ b/docs/examples/end-to-end-scene-transitions.md @@ -1,12 +1,12 @@ -# End‑to‑End Example: Scene Transitions + Overlay Pause +# End-to-End Example: Scene Transitions + Overlay Pause Short intro -An end‑to‑end pattern showing scene‑scoped buses, a global overlay that persists across scenes, and pausing emissions/listeners safely. +An end-to-end pattern showing scene-scoped buses, a global overlay that persists across scenes, and pausing emissions/listeners safely. Scenario -- Each scene has its own local MessageBus for scene‑scoped flows. +- Each scene has its own local MessageBus for scene-scoped flows. - A global overlay persists across scenes and listens to global notifications. - During pause, some systems should stop processing (Disable tokens), but others still emit (emit while disabled). @@ -79,7 +79,7 @@ public sealed class SceneDriver var info = new SceneLoaded(buildIndex); info.Emit(); - // For scene‑local flows, pass the local bus to tokens + // For scene-local flows, pass the local bus to tokens // var token = MessageRegistrationToken.Create(handler, SceneBus.Current); } } @@ -126,5 +126,5 @@ messaging.emitMessagesWhenDisabled = true; Notes - Use global untargeted messages to communicate scene transitions to global overlays. -- Keep scene‑specific flows isolated on a per‑scene bus (pass to tokens for registrations and emit to it). +- Keep scene-specific flows isolated on a per-scene bus (pass to tokens for registrations and emit to it). - Toggle listeners off to pause, and opt into emission while disabled for emitters that must remain active. diff --git a/docs/examples/end-to-end.md b/docs/examples/end-to-end.md index 43eac180..7f006b76 100644 --- a/docs/examples/end-to-end.md +++ b/docs/examples/end-to-end.md @@ -1,4 +1,4 @@ -# End‑to‑End Example: Combat + UI + Settings +# End-to-End Example: Combat + UI + Settings Short intro @@ -65,7 +65,7 @@ public sealed class Enemy : UnityEngine.MonoBehaviour } ``` -UI overlay (global + all‑sources) +UI overlay (global + all-sources) ```csharp using DxMessaging.Unity; @@ -87,7 +87,7 @@ public sealed class UIOverlay : MessageAwareComponent } ``` -Interceptors and post‑processing (ordering) +Interceptors and post-processing (ordering) ```csharp using DxMessaging.Core; // MessageHandler diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 6da3ba2c..2787dee3 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -80,7 +80,7 @@ Grab a copy of this repo (either `git clone` [this repo](https://github.com/wall ## Minimum Requirements -- Unity 2021.3+ (LTS recommended). See [Compatibility](../reference/compatibility.md) for Unity × Render Pipeline support (Built‑In, URP, HDRP). +- Unity 2021.3+ (LTS recommended). See [Compatibility](../reference/compatibility.md) for Unity x Render Pipeline support (Built-In, URP, HDRP). ## After Installation diff --git a/docs/getting-started/visual-guide.md b/docs/getting-started/visual-guide.md index 61dcf9d6..55de805b 100644 --- a/docs/getting-started/visual-guide.md +++ b/docs/getting-started/visual-guide.md @@ -410,25 +410,25 @@ Think of DxMessaging like a restaurant: ### Untargeted = Restaurant Announcement -> "Attention all customers: We're closing in 10 minutes!" -> -> -> Everyone hears it +to "Attention all customers: We're closing in 10 minutes!" + +> to -> Everyone hears it ### Targeted = Waiter Delivering Food -> "Order for table 5: Here's your burger" -> -> -> Only table 5 gets it +to "Order for table 5: Here's your burger" + +> to -> Only table 5 gets it ### Broadcast = Customer Calling Waiter -> "Excuse me, I need a refill!" (from table 3) -> -> -> Comes from table 3 +to "Excuse me, I need a refill!" (from table 3) + +> to -> Comes from table 3 > -> -> Any available waiter can respond +> to -> Any available waiter can respond > -> -> Manager might track it for statistics +> to -> Manager might track it for statistics ## Debugging Visualized diff --git a/docs/guides/advanced.md b/docs/guides/advanced.md index e3438c69..34e23aba 100644 --- a/docs/guides/advanced.md +++ b/docs/guides/advanced.md @@ -2,17 +2,17 @@ Short intro -This page covers advanced patterns: manual lifetimes, token control, Unity integration switches, string message defaults, and safety tips. If you’re new, start with Quick Start, then return here as you scale up. +This page covers advanced patterns: manual lifetimes, token control, Unity integration switches, string message defaults, and safety tips. If you're new, start with Quick Start, then return here as you scale up. Table of contents - Lifecycles and tokens - Manual enable/disable and UnregisterAll - Unity integration knobs: MessageAwareComponent and MessagingComponent -- String messages opt‑in/out +- String messages opt-in/out - Local bus islands (subsystems/tests) - Safety and troubleshooting -- Side‑by‑side patterns: before vs after +- Side-by-side patterns: before vs after - More advanced use cases Lifecycles and tokens @@ -27,7 +27,7 @@ Lifecycles and tokens - `token.UnregisterAll()` disables and clears staged registrations. - `token.RemoveRegistration(handle)` removes a single registration. - Diagnostics - - `token.DiagnosticMode = true` to record per‑registration call counts and emissions. + - `token.DiagnosticMode = true` to record per-registration call counts and emissions. Example: manual lifetime @@ -65,7 +65,7 @@ MessageAwareComponent - `protected virtual bool MessageRegistrationTiedToEnableStatus => true`. - Set to `false` to manage `Enable()`/`Disable()` yourself (e.g., persistent listeners on disabled components). - `protected virtual bool RegisterForStringMessages => true`. - - Set to `false` to not auto‑register string message demos. + - Set to `false` to not auto-register string message demos. ```csharp using DxMessaging.Unity; @@ -111,7 +111,7 @@ public sealed class EmissionControl : MonoBehaviour } ``` -Emit while disabled (opt‑in) +Emit while disabled (opt-in) ```csharp // Keep emitting even when this GameObject is disabled @@ -119,7 +119,7 @@ messaging.emitMessagesWhenDisabled = true; // Now ToggleMessageHandler(false) will be ignored while emitMessagesWhenDisabled is true ``` -String messages: opt‑in/out +String messages: opt-in/out - MessageAwareComponent registers string demos by default. Override `RegisterForStringMessages` to disable. - See String Messages page for using `StringMessage` and `GlobalStringMessage` during prototyping. @@ -161,7 +161,7 @@ The scope throws if you pass `null` and guarantees the prior bus returns even if > **Editor note:** When the global bus is replaced with a decorated implementation, certain inspector diagnostics that rely on the concrete `MessageBus` type (e.g., registration graphs) are temporarily unavailable until the override scope ends. -Need a stable reference to the original bus regardless of overrides? Use `InitialGlobalMessageBusProviderAsset` (Create Asset → “DxMessaging/Message Bus Providers/Initial Global Message Bus”) to expose the startup instance. +Need a stable reference to the original bus regardless of overrides? Use `InitialGlobalMessageBusProviderAsset` (Create Asset -> "DxMessaging/Message Bus Providers/Initial Global Message Bus") to expose the startup instance. Scoped interceptors (debug) @@ -198,18 +198,18 @@ Do's ## Important behavior note: Snapshot Semantics - When a message is emitted, DxMessaging takes a snapshot of all current listeners (handlers, interceptors, post-processors). -- Listeners registered **during** an emission will **not** run for that emission — they only become active for the **next** emission. +- Listeners registered **during** an emission will **not** run for that emission -- they only become active for the **next** emission. - This prevents infinite loops (e.g., a handler that registers itself won't recurse). - This applies to all message types (Untargeted, Targeted, Broadcast) and all listener types. - See [Interceptors & Ordering](../concepts/interceptors-and-ordering.md#snapshot-semantics-frozen-listener-lists) for detailed examples. -Don’ts +Don'ts -- Don’t register in `Update`/`FixedUpdate` every frame. -- Don’t double‑create tokens on the same component (MessagingComponent logs a warning). -- Don’t forget to clear or disable tokens on destruction if you manage them manually. +- Don't register in `Update`/`FixedUpdate` every frame. +- Don't double-create tokens on the same component (MessagingComponent logs a warning). +- Don't forget to clear or disable tokens on destruction if you manage them manually. -Side‑by‑side: manual events vs DxMessaging +Side-by-side: manual events vs DxMessaging Before (manual C# events) @@ -250,7 +250,7 @@ void OnSpawned(ref Spawned m) => Refresh(); More advanced use cases -- Scene transitions: create a local bus per scene (or sub‑system), pass it to tokens. Emit globally for cross‑scene untargeted notifications, but isolate targeted/broadcast to the scene’s bus. +- Scene transitions: create a local bus per scene (or sub-system), pass it to tokens. Emit globally for cross-scene untargeted notifications, but isolate targeted/broadcast to the scene's bus. - Analytics layer: use `RegisterTargetedWithoutTargeting` and `RegisterBroadcastWithoutSource` to observe all flows; record in a buffer or file; disable in release. - Pausable systems: set `emitMessagesWhenDisabled` for senders you want alive while disabled; use `ToggleMessageHandler` to pause entire listeners. diff --git a/docs/guides/diagnostics.md b/docs/guides/diagnostics.md index fef6f09d..c0f67e26 100644 --- a/docs/guides/diagnostics.md +++ b/docs/guides/diagnostics.md @@ -1,6 +1,6 @@ # Diagnostics -DxMessaging emphasizes visibility. You can enable diagnostics globally or per token, inspect recent emissions, page through registrations, and even view contexts (targets/sources) — all from the MessagingComponent inspector. +DxMessaging emphasizes visibility. You can enable diagnostics globally or per token, inspect recent emissions, page through registrations, and even view contexts (targets/sources) -- all from the MessagingComponent inspector. ## DiagnosticsTarget Enum @@ -37,13 +37,13 @@ DxMessaging provides multiple levels of diagnostics control: ### Global Defaults -- `IMessageBus.GlobalDiagnosticsTargets` — Sets the default diagnostics mode for newly created buses and tokens. Uses the `DiagnosticsTarget` flags enum. -- `IMessageBus.GlobalMessageBufferSize` — Sets the default ring buffer size for emission history (default: 100). +- `IMessageBus.GlobalDiagnosticsTargets` -- Sets the default diagnostics mode for newly created buses and tokens. Uses the `DiagnosticsTarget` flags enum. +- `IMessageBus.GlobalMessageBufferSize` -- Sets the default ring buffer size for emission history (default: 100). ### Per-Bus and Per-Token -- `IMessageBus.DiagnosticsMode` — Read-only property indicating whether diagnostics are active for a specific bus instance. -- `MessageRegistrationToken.DiagnosticMode` — Controls diagnostics for an individual registration token. +- `IMessageBus.DiagnosticsMode` -- Read-only property indicating whether diagnostics are active for a specific bus instance. +- `MessageRegistrationToken.DiagnosticMode` -- Controls diagnostics for an individual registration token. ```csharp using DxMessaging.Core; @@ -153,16 +153,16 @@ Each logged registration is stored as a `MessagingRegistration` struct containin The `RegistrationMethod` enum captures how the handler was wired up: -- `Targeted` — Bound to a specific recipient -- `Untargeted` — Global untargeted handler -- `Broadcast` — Bound to a specific source -- `BroadcastWithoutSource` — Broadcast handler without explicit source -- `TargetedWithoutTargeting` — Targeted handler ignoring runtime target -- `GlobalAcceptAll` — Catch-all handler -- `Interceptor` — Message interceptor -- `UntargetedPostProcessor`, `TargetedPostProcessor`, `BroadcastPostProcessor` — Post-processors -- `TargetedWithoutTargetingPostProcessor` — Post-processor for targeted messages ignoring runtime target -- `BroadcastWithoutSourcePostProcessor` — Post-processor for broadcasts without explicit source +- `Targeted` -- Bound to a specific recipient +- `Untargeted` -- Global untargeted handler +- `Broadcast` -- Bound to a specific source +- `BroadcastWithoutSource` -- Broadcast handler without explicit source +- `TargetedWithoutTargeting` -- Targeted handler ignoring runtime target +- `GlobalAcceptAll` -- Catch-all handler +- `Interceptor` -- Message interceptor +- `UntargetedPostProcessor`, `TargetedPostProcessor`, `BroadcastPostProcessor` -- Post-processors +- `TargetedWithoutTargetingPostProcessor` -- Post-processor for targeted messages ignoring runtime target +- `BroadcastWithoutSourcePostProcessor` -- Post-processor for broadcasts without explicit source ## Emission History diff --git a/docs/guides/inspector-overlay.md b/docs/guides/inspector-overlay.md new file mode 100644 index 00000000..79e1224d --- /dev/null +++ b/docs/guides/inspector-overlay.md @@ -0,0 +1,286 @@ +# Inspector Overlay & Base-Call Warnings + +DxMessaging ships a Roslyn analyzer and a companion Unity Inspector overlay +that catch the most common authoring mistake when subclassing +`MessageAwareComponent`: forgetting to call `base.OnEnable()` (and friends) +in your override. Without those base calls the messaging system does +nothing -- every handler you registered silently fails to fire. This page +is the user-facing tour of how the package surfaces the problem and how +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, +exact detection policy, suppression precedence, and Unity 2021 setup +notes -- see [Roslyn Analyzers & Diagnostics](../reference/analyzers.md). + +## When a Warning Appears + +Whenever your code triggers one of the base-call diagnostics +([DXMSG006](../reference/analyzers.md#dxmsg006-missing-base-call), +[DXMSG007](../reference/analyzers.md#dxmsg007-new-hides-unity-method), +[DXMSG009](../reference/analyzers.md#dxmsg009-implicit-hide-and-missing-modifier), +or [DXMSG010](../reference/analyzers.md#dxmsg010-broken-transitive-base-call-chain)), +two things happen in parallel: + +1. **At compile time**, the Roslyn analyzer (`WallstopStudios.DxMessaging.Analyzer.dll`, + shipped under `Editor/Analyzers/`) emits a warning into Unity's + Console with the corresponding `DXMSG###` id and a message that + names the offending type and method. +1. **At Inspector time**, the overlay reads the cached scan from + `Library/DxMessaging/baseCallReport.json` and renders a HelpBox at + the very top of every `MessageAwareComponent` subclass's Inspector + that has at least one missing base call. + +You see both surfaces by default. The Console warning is authoritative +for CI builds (the analyzer is registered for the C# compiler via +`csc.rsp`, so it runs on every Unity-driven compile); the Inspector +overlay is the in-Editor reminder you cannot ignore while wiring a +prefab. + +!!! tip +Severity is per-project tunable. Add lines like `dotnet_diagnostic.DXMSG006.severity = error` to your `.editorconfig` to upgrade missing base calls into a build break, or `severity = none` to silence one project-wide. See [Suppression precedence](../reference/analyzers.md#suppression-precedence) for the full ordering. + +## The HelpBox + +When the overlay decides to render, it draws a single Unity `HelpBox` +above your component's Inspector body, followed by a horizontal row of +action buttons. + +![Inspector overlay warning HelpBox (DXMSG009 implicit-hide example) at the top of a MessageAwareComponent subclass Inspector](../images/inspector-overlay/dxmsg009-overlay.png) + +The text follows this shape: + +> `` has lifecycle methods that don't chain to +> MessageAwareComponent (``) -- DxMessaging will +> not function on this component. +> See docs/reference/analyzers.md. + +When the cache is stale (immediately after a domain reload, before the +first post-reload scan completes), the message above is followed by a +trailing `(cached from previous session -- refreshing...)` line on a new +paragraph -- see +[Cached-from-previous-session annotation](#cached-from-previous-session-annotation) +below. + +`` is taken straight from the analyzer's +per-type report -- typically one of `Awake`, `OnEnable`, `OnDisable`, +`OnDestroy`, or `RegisterMessageHandlers`. A single component can list +multiple methods if more than one override is broken. + +### Cached-from-previous-session annotation + +After a domain reload -- when you enter Play Mode, recompile, or open the +Editor -- the overlay needs a moment to rebuild its scan. Rather than +flashing an empty Inspector and then suddenly showing a warning, the +package eagerly loads the previous session's cache from +`Library/DxMessaging/baseCallReport.json` so the HelpBox is visible +immediately. + +While that cached data is being refreshed, the HelpBox annotates the +trailing line of its message with `(cached from previous session -- +refreshing...)`. + +Once the first post-reload scan completes (typically within a single +editor tick after assembly reload completes), the harvester flips its +`IsFreshThisSession` flag and the suffix disappears. You do not need to +do anything -- the Inspector repaints automatically. The annotation +exists so you understand the data is from the previous session in the +unlikely event you have just edited the offending source code and the +Inspector is showing a warning that the latest compile would have +fixed. + +## Three Inspector Actions + +Below the HelpBox the overlay draws a horizontal action row. The +buttons that appear depend on whether the component's fully-qualified +type name is currently in the project ignore list. + +### Default (warning) state + +When the component is **not** ignored, you see two buttons: + +![Open Script and Ignore this type buttons](../images/inspector-overlay/inspector-actions.png) + +- **Open Script** -- opens the offending component's source file at the + top; when the legacy console bridge is enabled and a line number is + available, the file opens at that line. +- **Ignore this type** -- appends the component's fully-qualified type + name to `Assets/Editor/DxMessaging.BaseCallIgnore.txt`. The next + Inspector repaint flips the HelpBox into its info shape (below). + The mutation is deferred to the next editor frame so the current + GUI cycle completes cleanly -- there is no perceptible delay. + +### Ignored state + +When the component **is** in the ignore list, the HelpBox is the blue +info shape -- the analyzer noticed this would normally be a problem, +but you have explicitly opted out -- and the action row collapses to a +single button: + +![Stop ignoring action for an excluded type](../images/inspector-overlay/inspector-ignored.png) + +- **Stop ignoring** -- removes the component's fully-qualified type + name from the ignore list. On the next frame the HelpBox flips back + to its warning shape if the underlying analyzer warnings still + apply. + +!!! warning +Adding a type to the ignore list silences the **overlay**, but it does not change the runtime behaviour. If the override genuinely never reaches `base.OnEnable()`, the messaging system on that component is still dead. The compile-time analyzer also continues to emit `DXMSG006/007/009/010` to the Console unless you suppress them via `.editorconfig` (see [Suppression precedence](../reference/analyzers.md#suppression-precedence)). For finer-grained control, the source-level `[DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall]` attribute suppresses the analyzer at the class or method level and is checked **before** the project ignore list -- see the [Suppression precedence ordering](../reference/analyzers.md#suppression-precedence) for the full priority. Use either suppression path only when the silencing is genuinely intentional (for example, a deliberate adapter that should not participate in messaging) and document the reason somewhere your team can find it. + +## Project Settings Panel + +The package registers a Project Settings page under **Project +Settings > Wallstop Studios > DxMessaging**. The currently-rendered controls are: + +![DxMessaging Project Settings panel](../images/inspector-overlay/project-settings-panel.png) + +- **Diagnostics Targets** -- flags-enum field (`Off`, `Editor`, + `Runtime`, `All`) controlling where global diagnostics are enabled. + See [Diagnostics](diagnostics.md) for what this toggle activates. +- **Message Buffer Size** -- integer; the default ring-buffer size + used by every newly-created bus and token when diagnostics are + active. Defaults to `IMessageBus.DefaultMessageBufferSize`. +- **Suppress Domain Reload Warning** -- checkbox; disables the warning + Unity shows when "Enter Play Mode Options" skips a domain reload. + DxMessaging still resets its statics, so the warning is noise on + most projects. + +The settings asset itself lives at +`Assets/Editor/DxMessagingSettings.asset` and stores additional +fields the overlay relies on: + +- The master toggle for the base-call check, exposed on the asset + Inspector field `DxMessagingSettings.BaseCallCheckEnabled`. When + `false`, the overlay is silenced; the underlying analyzer still + emits the Console warning unless `.editorconfig` says otherwise. +- The project ignore list + (`DxMessagingSettings.BaseCallIgnoredTypes`), edited from the asset + Inspector at `Assets/Editor/DxMessagingSettings.asset` (the Project + Settings panel does not currently expose the ignore list). Mirrored + to the sidecar `Assets/Editor/DxMessaging.BaseCallIgnore.txt` that + the analyzer reads via `csc.rsp`'s `-additionalfile:` switch. +- An opt-in legacy console-scrape bridge, exposed on the asset + Inspector field `DxMessagingSettings.UseConsoleBridge`, that + augments the IL-reflection scanner's snapshot with warnings + harvested from Unity's `LogEntries` store. Default off. + +!!! note +The Inspector overlay's **Ignore this type** / **Stop ignoring** buttons read and write the same ignore-list field that the settings asset exposes. You can also bulk-edit the list directly from the asset Inspector. + +For the field-by-field semantics -- including the ScriptableObject +behaviour around `OnValidate` regenerating the sidecar -- see +[Inspector integration](../reference/analyzers.md#inspector-integration) +in the analyzer reference. + +## Tools > Wallstop Studios > DxMessaging > Rescan Base-Call Warnings + +The package adds a manual rescan menu entry: + +![Tools menu showing DxMessaging Rescan Base-Call Warnings entry](../images/inspector-overlay/tools-menu-rescan.png) + +Click **Tools > Wallstop Studios > DxMessaging > Rescan Base-Call Warnings** to +re-run the harvester on demand. You normally do not need to invoke +this -- the package re-scans automatically on every assembly reload +and after every per-assembly compilation event -- but it is useful +when you have just toggled the master setting, edited the ignore +list outside of Unity, or want to confirm that a fix has cleared a +warning before the next domain reload. + +The rescan is a no-op while Unity is mid-compile or mid-import; the +menu entry is a no-op when the harvester is mid-compile and +reschedules itself for the next safe tick. + +## Worked Example + +Let's walk through the most common case end-to-end. Suppose you have +a `HealthComponent` that derives from `MessageAwareComponent`: + +```csharp +using DxMessaging.Unity; +using DxMessaging.Core.Messages; +using UnityEngine; + +public sealed class HealthComponent : MessageAwareComponent +{ + protected override void OnEnable() + { + // Forgot base.OnEnable() -- Token.Enable() never runs, + // every handler this component registered is dead. + Debug.Log("HealthComponent enabled"); + } + + protected override void RegisterMessageHandlers() + { + base.RegisterMessageHandlers(); + _ = Token.RegisterComponentTargeted(this, OnHit); + } + + private void OnHit(ref TookDamage m) => Debug.Log($"hit for {m.amount}"); +} +``` + +### What you see + +After the next compile, the Console shows a `DXMSG006` warning +naming `Game.HealthComponent.OnEnable`. When you click into a +GameObject that has `HealthComponent` attached, the Inspector +renders the overlay HelpBox at the top of the component: + +![HealthComponent Inspector with the missing-base-call HelpBox visible](../images/inspector-overlay/worked-example-before.png) + +The HelpBox names `OnEnable` in its missing-method list and points +you at the analyzer reference. **Open Script** jumps you to the +offending override. + +### Fixing it + +Add the base call: + +```csharp +protected override void OnEnable() +{ + base.OnEnable(); // <-- the fix + Debug.Log("HealthComponent enabled"); +} +``` + +After the next compile (or **Tools > Wallstop Studios > DxMessaging > Rescan Base-Call +Warnings**), the HelpBox disappears and the `DXMSG006` Console entry +is gone: + +![Same HealthComponent Inspector with the HelpBox cleared after the fix](../images/inspector-overlay/worked-example-after.png) + +That is the entire loop: warning > fix > silence. + +### When the fix is intentional + +If your override genuinely needs to skip the base implementation, or your fix delegates the base call into a helper method (a known false positive of the textual matcher -- see [Detection policy (good-faith textual match)](../reference/analyzers.md#detection-policy-good-faith-textual-match)), suppress the analyzer at the class or method level with `[DxIgnoreMissingBaseCall]`: + +```csharp +using DxMessaging.Core.Attributes; + +public sealed class FlashyComponent : MessageAwareComponent +{ + [DxIgnoreMissingBaseCall] + protected override void Awake() => CallHelperThatChainsToBase(); + + private void CallHelperThatChainsToBase() => base.Awake(); +} +``` + +Each suppression emits an audit-only [`DXMSG008`](../reference/analyzers.md#dxmsg008-opt-out-marker) so the opt-out shows up in your build report. + +## Related + +- [Roslyn Analyzers & Diagnostics](../reference/analyzers.md) -- the + comprehensive reference for 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. +- [Diagnostics](diagnostics.md) -- diagnostics targets, registration + logging, and emission history. +- [Troubleshooting](../reference/troubleshooting.md) -- runtime symptoms + ("my handler never fires") and how they map back to base-call + mistakes. diff --git a/docs/guides/inspector-overlay.md.meta b/docs/guides/inspector-overlay.md.meta new file mode 100644 index 00000000..870b4159 --- /dev/null +++ b/docs/guides/inspector-overlay.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9a025d5595c94bda8bfbed77c95b0f15 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/guides/migration-guide.md b/docs/guides/migration-guide.md index 422e46a2..0c589c23 100644 --- a/docs/guides/migration-guide.md +++ b/docs/guides/migration-guide.md @@ -6,16 +6,16 @@ This guide helps you introduce DxMessaging into an existing Unity project **grad ### Don't do this -- L Rip out all C# events and rewrite everything -- L Force the whole team to learn it before trying it -- L Commit to full adoption before seeing benefits +- Rip out all C# events and rewrite everything +- Force the whole team to learn it before trying it +- Commit to full adoption before seeing benefits #### Do this instead --  Pick ONE system to migrate (low risk, high visibility) --  Let old and new approaches coexist --  Expand usage as team comfort grows --  Evaluate after each migration step +- Pick ONE system to migrate (low risk, high visibility) +- Let old and new approaches coexist +- Expand usage as team comfort grows +- Evaluate after each migration step ## Phase 0: Install and Experiment (1-2 hours) @@ -52,9 +52,9 @@ This guide helps you introduce DxMessaging into an existing Unity project **grad #### Best candidates for first adoption --  **New UI system** - Add a new settings menu that reacts to game state --  **Achievement/analytics system** - Listen to existing events without coupling --  **New game mode** - Implement it with DxMessaging from scratch +- **New UI system** - Add a new settings menu that reacts to game state +- **Achievement/analytics system** - Listen to existing events without coupling +- **New game mode** - Implement it with DxMessaging from scratch ### Example: Adding an Achievement System @@ -96,10 +96,10 @@ public class Enemy : MonoBehaviour { #### Why this works --  Old code still works (zero risk) --  New system is decoupled --  Team sees immediate value (achievements without wiring) --  Easy to roll back if needed +- Old code still works (zero risk) +- New system is decoupled +- Team sees immediate value (achievements without wiring) +- Easy to roll back if needed ## Phase 2: Migrate High-Pain Areas (2-4 weeks) @@ -189,23 +189,23 @@ System: _________________ #### Team guidelines --  All new cross-system communication uses DxMessaging --  Old code migrates opportunistically (when touched) --  Code reviews check for messaging best practices +- All new cross-system communication uses DxMessaging +- Old code migrates opportunistically (when touched) +- Code reviews check for messaging best practices ##### Example team policy ```text When to use DxMessaging (for new code): -- Any UI listening to game state � DxMessaging -- Any analytics/logging � DxMessaging -- Any cross-scene communication � DxMessaging -- Any event with 2+ listeners � DxMessaging +- Any UI listening to game state -> DxMessaging +- Any analytics/logging -> DxMessaging +- Any cross-scene communication -> DxMessaging +- Any event with 2+ listeners -> DxMessaging When to use direct references/events: -- Simple UI button � method call (use UnityEvents) -- Single listener, same GameObject � direct reference -- Private implementation details � keep internal +- Simple UI button -> method call (use UnityEvents) +- Single listener, same GameObject -> direct reference +- Private implementation details -> keep internal ``` ## Coexistence Patterns @@ -260,7 +260,7 @@ public class Player : MonoBehaviour { // Phase 2: Remove direct references public class Player : MonoBehaviour { - // [SerializeField] private HealthBar healthBar; � DELETED + // [SerializeField] private HealthBar healthBar; -> DELETED void TakeDamage(int amount) { health -= amount; @@ -288,7 +288,7 @@ public class Player : MonoBehaviour { ### DON'T Migrate (Keep As-Is) -1. **Simple button onClick � method** - UnityEvents are fine +1. **Simple button onClick -> method** - UnityEvents are fine 1. **Private implementation details** - Internal events are okay 1. **Single-listener, same-GameObject** - Direct references are clearer 1. **Legacy systems about to be deleted** - Why bother? @@ -369,8 +369,8 @@ Track these to validate migration is worthwhile: ##### Example -> "Before: Adding achievement tracking required touching 12 files. -> After: Added achievement system with zero changes to existing code." +to "Before: Adding achievement tracking required touching 12 files. +to After: Added achievement system with zero changes to existing code." ## Timeline Examples diff --git a/docs/guides/patterns.md b/docs/guides/patterns.md index fc16f393..c43c05a7 100644 --- a/docs/guides/patterns.md +++ b/docs/guides/patterns.md @@ -1,6 +1,6 @@ # DxMessaging Patterns: Real-World Solutions -[← Back to Index](../getting-started/index.md) | [Getting Started](../getting-started/getting-started.md) | [Message Types](../concepts/message-types.md) | [Samples](https://github.com/wallstop/DxMessaging/tree/master/Samples~) +[Back to Index](../getting-started/index.md) | [Getting Started](../getting-started/getting-started.md) | [Message Types](../concepts/message-types.md) | [Samples](https://github.com/wallstop/DxMessaging/tree/master/Samples~) --- @@ -83,7 +83,7 @@ ### Important: Inheritance with MessageAwareComponent - Many examples derive from `MessageAwareComponent`. **When overriding hooks, you MUST call the base method.** -- **Always call `base.RegisterMessageHandlers()` FIRST** in your override to preserve default string‑message registrations and parent class registrations. +- **Always call `base.RegisterMessageHandlers()` FIRST** in your override to preserve default string-message registrations and parent class registrations. - **CRITICAL**: Call `base.OnEnable()` / `base.OnDisable()` if you override lifecycle methods; otherwise your token may never enable/disable. - **CRITICAL**: Call `base.Awake()` if you override `Awake()`; otherwise your token won't be created. - To opt out of string demos, override `RegisterForStringMessages => false` instead of skipping the base call. @@ -91,7 +91,7 @@ Registration timing (pit of success) -- **Prefer `Awake()` for all message handler registration**—this is when `MessageAwareComponent` calls `RegisterMessageHandlers()`. +- **Prefer `Awake()` for all message handler registration** -- this is when `MessageAwareComponent` calls `RegisterMessageHandlers()`. - Avoid registering in `Start()` unless you have a specific order-of-execution reason. - Early registration in `Awake()` ensures your handlers are ready before other components' `Start()` methods run. @@ -183,7 +183,7 @@ _ = token.RegisterBroadcastInterceptor((ref InstanceId source, ref T ## 5) Analytics/Logging (Post-Processors) -Post-processors run after handlers—ideal for metrics. +Post-processors run after handlers -- ideal for metrics. ```csharp _ = token.RegisterUntargetedPostProcessor((ref SceneLoaded m) => Metrics.TrackScene(m.buildIndex)); @@ -205,15 +205,15 @@ var token = MessageRegistrationToken.Create(handler, localBus); - **Stage registrations in `Awake()`** (preferred) or `Start()` (only if order-dependent). - Call `token.Enable()` in `OnEnable` and `token.Disable()` in `OnDisable`. -- Use `MessageAwareComponent` to avoid boilerplate—it handles all of this automatically. +- Use `MessageAwareComponent` to avoid boilerplate -- it handles all of this automatically. Why Awake over Start? - `Awake()` runs before any `Start()` methods, ensuring your handlers are ready early. -- Other components may emit messages in their `Start()` methods—registering in `Awake()` ensures you don't miss them. +- Other components may emit messages in their `Start()` methods -- registering in `Awake()` ensures you don't miss them. - `MessageAwareComponent` automatically calls `RegisterMessageHandlers()` in `Awake()`, following this best practice. -Side‑by‑side: lifecycle +Side-by-side: lifecycle Before @@ -257,9 +257,9 @@ _ = token.RegisterGlobalAcceptAll( (ref InstanceId source, ref IBroadcastMessage m) => Debug.Log($"Broadcast {m.MessageType} from {source}") ); -Do’s +Do's -- Use global accept‑all in tooling and debug inspectors. +- Use global accept-all in tooling and debug inspectors. - Prefer specific registrations for gameplay code to avoid surprises. ``` @@ -267,7 +267,7 @@ Do’s - Enable `IMessageBus.GlobalDiagnosticsMode` in Editor or per-token. - Adjust `IMessageBus.GlobalMessageBufferSize` for deeper history (Editor settings UI provided). -- Wire `MessagingDebug.LogFunction` to Unity’s console to see warnings/errors. +- Wire `MessagingDebug.LogFunction` to Unity's console to see warnings/errors. ## 12) Testing @@ -341,10 +341,10 @@ public class CombatAnalytics : MessageAwareComponent { #### Scale characteristics -- ✅ Each entity broadcasts ~10-50 messages/second → No GC allocations (struct messages) -- ✅ Targeted listeners (health bars) only receive relevant messages → O(1) lookup -- ✅ Global listeners (analytics) receive all messages → Single handler, not N handlers -- ✅ Adding/removing entities doesn't break registrations (no manual wiring) +- [x] Each entity broadcasts ~10-50 messages/second -> No GC allocations (struct messages) +- [x] Targeted listeners (health bars) only receive relevant messages -> O(1) lookup +- [x] Global listeners (analytics) receive all messages -> Single handler, not N handlers +- [x] Adding/removing entities doesn't break registrations (no manual wiring) ##### Performance notes @@ -463,20 +463,20 @@ public class PlayerStats : MonoBehaviour { #### Benefits at scale -- ✅ Add/remove panels without touching game logic -- ✅ Panels can be enabled/disabled freely (tokens handle lifecycle) -- ✅ Easy to add "observer panels" (e.g., debug overlays) without modifying existing code +- [x] Add/remove panels without touching game logic +- [x] Panels can be enabled/disabled freely (tokens handle lifecycle) +- [x] Easy to add "observer panels" (e.g., debug overlays) without modifying existing code ##### Anti-pattern to avoid ```csharp -// ❌ DON'T: Separate message per UI element (too granular) +// DON'T: Separate message per UI element (too granular) [DxUntargetedMessage] public struct HealthChanged { public int health; } [DxUntargetedMessage] public struct ManaChanged { public int mana; } [DxUntargetedMessage] public struct GoldChanged { public int gold; } // This creates 3x message traffic and registration overhead -// ✅ DO: Batch related updates into one message +// DO: Batch related updates into one message [DxUntargetedMessage] public struct PlayerStatsChanged { public int health; public int mana; @@ -625,14 +625,14 @@ IMessageBus.GlobalDiagnosticsMode = false; // Production builds #### Optimization 2: Use Specific Registrations Over GlobalAcceptAll ```csharp -// ❌ SLOW: Receives ALL messages, even irrelevant ones +// SLOW: Receives ALL messages, even irrelevant ones _ = Token.RegisterGlobalAcceptAll( (ref IUntargetedMessage m) => { /* called for every untargeted */ }, (ref InstanceId t, ref ITargetedMessage m) => { /* called for every targeted */ }, (ref InstanceId s, ref IBroadcastMessage m) => { /* called for every broadcast */ } ); -// ✅ FAST: Only receives relevant messages +// FAST: Only receives relevant messages _ = Token.RegisterBroadcastWithoutSource(OnDamage); _ = Token.RegisterUntargeted(OnLevelComplete); ``` @@ -642,14 +642,14 @@ _ = Token.RegisterUntargeted(OnLevelComplete); #### Optimization 3: Batch Message Emissions ```csharp -// ❌ WASTEFUL: Emit after every tiny change +// WASTEFUL: Emit after every tiny change void TakeDamage(int amount) { health -= amount; var msg = new HealthChanged(health); msg.Emit(); // Emits every frame } -// ✅ EFFICIENT: Batch updates, emit once per frame +// EFFICIENT: Batch updates, emit once per frame private bool healthDirty = false; void TakeDamage(int amount) { @@ -762,7 +762,7 @@ public class MatchStats : MessageAwareComponent { - Kill feed UI: 1 registration, receives ALL kills (~5 messages/sec) - Match stats: Post-processor, doesn't affect gameplay latency -**Total:** ~100 players × 10 damage/sec = 1000 messages/sec. DxMessaging handles this with negligible overhead (~0.06ms/frame). +**Total:** ~100 players x 10 damage/sec = 1000 messages/sec. DxMessaging handles this with negligible overhead (~0.06ms/frame). --- @@ -817,27 +817,27 @@ Use ScriptableObjects for their intended purpose: **immutable design-time data** **Yes, but with caveats.** DxMessaging and SOA solve similar problems (decoupling, communication) with different philosophies: -| Aspect | SOA | DxMessaging | -| -------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------- | -| **Paradigm** | Asset-based, persistent state | Runtime message passing, transient | -| **Designer-Centric** | ✅ High (create events in Inspector) | ❌ Low (code-driven) | -| **Type Safety** | ⚠️ Mixed (SO refs typed, but UnityEvent inspector wiring loses compile-time safety) | ✅ Strong (compile-time validation) | -| **Lifecycle** | ⚠️ Manual (SO assets persist) | ✅ Automatic (tokens clean up) | -| **Debugging** | ⚠️ Inspector-dependent | ✅ Built-in diagnostics | -| **Performance** | ⚠️ List iteration, UnityAction overhead | ✅ Zero-allocation structs | -| **Use Case** | Shared state, designer-driven configs | Event-driven communication, runtime logic | -| **Testability** | ⚠️ Requires SO asset cleanup | ✅ Isolated buses per test | +| Aspect | SOA | DxMessaging | +| -------------------- | -------------------------------------------------------------------------------- | ----------------------------------------- | +| **Paradigm** | Asset-based, persistent state | Runtime message passing, transient | +| **Designer-Centric** | High (create events in Inspector) | Low (code-driven) | +| **Type Safety** | Mixed (SO refs typed, but UnityEvent inspector wiring loses compile-time safety) | Strong (compile-time validation) | +| **Lifecycle** | Manual (SO assets persist) | Automatic (tokens clean up) | +| **Debugging** | Inspector-dependent | Built-in diagnostics | +| **Performance** | List iteration, UnityAction overhead | Zero-allocation structs | +| **Use Case** | Shared state, designer-driven configs | Event-driven communication, runtime logic | +| **Testability** | Requires SO asset cleanup | Isolated buses per test | **Summary:** For new projects, evaluate DxMessaging, DI frameworks, or other messaging approaches based on your needs. If you have existing SOA code, the patterns below show coexistence strategies. ### Pattern Overview -| Pattern | What it shows | When to use | SOA involvement | -| ------- | ------------------------------------------------------ | ----------------------------------------------------- | ---------------------------- | -| **A** | SOA Events (GameEvent) forwarding to DxMessaging | Designer-created event assets, modern code downstream | ✅ Yes - SOA Event pattern | -| **B** | ScriptableObjects for configs + DxMessaging for events | New projects / best practice | ❌ No - proper SO usage only | +| Pattern | What it shows | When to use | SOA involvement | +| ------- | ------------------------------------------------------ | ----------------------------------------------------- | ------------------------- | +| **A** | SOA Events (GameEvent) forwarding to DxMessaging | Designer-created event assets, modern code downstream | Yes - SOA Event pattern | +| **B** | ScriptableObjects for configs + DxMessaging for events | New projects / best practice | No - proper SO usage only | -### Pattern A: SOA → DxMessaging (Event Forwarding) +### Pattern A: SOA -> DxMessaging (Event Forwarding) **Use case:** Designer-created SOA events, but you want DxMessaging benefits downstream. @@ -876,7 +876,7 @@ public class GameEvent : ScriptableObject [DxUntargetedMessage] public readonly partial struct SceneTransitionRequested { } -// Bridge: SOA Event → DxMessaging +// Bridge: SOA Event -> DxMessaging public class SOAEventBridge : MonoBehaviour { [SerializeField] private GameEvent onSceneTransitionSO; // Designer-created asset @@ -917,13 +917,13 @@ public class AudioSystem : MessageAwareComponent ##### Benefits -- ✅ Designers create events in Inspector (SOA workflow preserved) -- ✅ Code uses DxMessaging (type-safe, lifecycle-safe) +- [x] Designers create events in Inspector (SOA workflow preserved) +- [x] Code uses DxMessaging (type-safe, lifecycle-safe) ###### Drawbacks -- ⚠️ Bridge boilerplate for each SOA event -- ⚠️ Double registration (SOA listener + DxMessaging handler) +- Bridge boilerplate for each SOA event +- Double registration (SOA listener + DxMessaging handler) ### Pattern B: Proper ScriptableObject Usage (Recommended) @@ -994,10 +994,10 @@ public class CombatAnalytics : MessageAwareComponent ##### Benefits -- ✅ **Best of both worlds** - ScriptableObjects for static configs, DxMessaging for runtime events -- ✅ No bridging overhead -- ✅ Uses each system correctly: SOs for their intended purpose (immutable design data), messaging for runtime communication -- ✅ This is NOT SOA - it's proper Unity architecture +- [x] **Best of both worlds** - ScriptableObjects for static configs, DxMessaging for runtime events +- [x] No bridging overhead +- [x] Uses each system correctly: SOs for their intended purpose (immutable design data), messaging for runtime communication +- [x] This is NOT SOA - it's proper Unity architecture ###### This pattern separates concerns clearly @@ -1005,11 +1005,11 @@ public class CombatAnalytics : MessageAwareComponent | Pattern | Use When | Complexity | Performance | | --------------------------- | ------------------------------------------------------- | ---------- | ----------- | -| **A: SOA → DxMessaging** | Designers create SOA events, modern code uses messaging | Medium | ⚠️ Medium | -| **B: Proper SO Usage** | Immutable configs only, messaging for events | Low | ✅ Good | -| **None (Pure DxMessaging)** | Greenfield project or full SOA migration | Lowest | ✅ Best | +| **A: SOA -> DxMessaging** | Designers create SOA events, modern code uses messaging | Medium | Medium | +| **B: Proper SO Usage** | Immutable configs only, messaging for events | Low | Good | +| **None (Pure DxMessaging)** | Greenfield project or full SOA migration | Lowest | Best | -### Migration Path: SOA → DxMessaging +### Migration Path: SOA -> DxMessaging If you're moving away from SOA: @@ -1021,27 +1021,27 @@ If you're moving away from SOA: For SOA variables: -1. Convert read-only SO configs → Keep as-is (correct SO usage) or move to JSON/ScriptableObjects for data -1. Convert mutable SO variables → DxMessaging messages or DI-injected services -1. Convert RuntimeSets → DxMessaging global observers (`RegisterBroadcastWithoutSource`) +1. Convert read-only SO configs -> Keep as-is (correct SO usage) or move to JSON/ScriptableObjects for data +1. Convert mutable SO variables -> DxMessaging messages or DI-injected services +1. Convert RuntimeSets -> DxMessaging global observers (`RegisterBroadcastWithoutSource`) ### Final Recommendations #### If you're using SOA -- ✅ **Do:** Use Pattern B (Proper SO Usage) - SOs for immutable configs ONLY, DxMessaging for runtime events -- ✅ **Do:** Use Pattern A to bridge existing SOA GameEvent assets to DxMessaging during migration -- ✅ **Do:** Read [Anti-ScriptableObject Architecture](https://github.com/cathei/AntiScriptableObjectArchitecture) to understand risks -- ✅ **Do:** Consider gradual migration to DxMessaging or DI frameworks -- ❌ **Don't:** Use SOs for mutable runtime state (health, scores, etc.) -- ❌ **Don't:** Create new SOA event assets—use DxMessaging messages instead +- [x] **Do:** Use Pattern B (Proper SO Usage) - SOs for immutable configs ONLY, DxMessaging for runtime events +- [x] **Do:** Use Pattern A to bridge existing SOA GameEvent assets to DxMessaging during migration +- [x] **Do:** Read [Anti-ScriptableObject Architecture](https://github.com/cathei/AntiScriptableObjectArchitecture) to understand risks +- [x] **Do:** Consider gradual migration to DxMessaging or DI frameworks +- [ ] **Don't:** Use SOs for mutable runtime state (health, scores, etc.) +- [ ] **Don't:** Create new SOA event assets -- use DxMessaging messages instead ##### If you're starting fresh -- ✅ **Do:** Use DxMessaging for all messaging/events -- ✅ **Do:** Use ScriptableObjects ONLY for immutable design data (weapon stats, level configs) -- ✅ **Do:** Consider DI frameworks (Zenject/VContainer) for service dependencies -- ❌ **Don't:** Adopt SOA's GameEvent/Variable patterns—they're superseded by better tools +- [x] **Do:** Use DxMessaging for all messaging/events +- [x] **Do:** Use ScriptableObjects ONLY for immutable design data (weapon stats, level configs) +- [x] **Do:** Consider DI frameworks (Zenject/VContainer) for service dependencies +- [ ] **Don't:** Adopt SOA's GameEvent/Variable patterns -- they're superseded by better tools ###### Resources @@ -1058,23 +1058,23 @@ For SOA variables: ### Learn the Basics First? -- → [Getting Started](../getting-started/getting-started.md) (10 min) — Complete introduction -- → [Message Types](../concepts/message-types.md) (10 min) — When to use what -- → [Visual Guide](../getting-started/visual-guide.md) (5 min) — Beginner-friendly pictures +- to [Getting Started](../getting-started/getting-started.md) (10 min) -- Complete introduction +- to [Message Types](../concepts/message-types.md) (10 min) -- When to use what +- to [Visual Guide](../getting-started/visual-guide.md) (5 min) -- Beginner-friendly pictures ### Try Real Examples -- → [Mini Combat sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) — Working combat example -- → [UI Buttons + Inspector sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/UI%20Buttons%20%2B%20Inspector/README.md) — Interactive diagnostics -- → [End-to-End Example](../examples/end-to-end.md) — Complete feature walkthrough +- to [Mini Combat sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) -- Working combat example +- to [UI Buttons + Inspector sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/UI%20Buttons%20%2B%20Inspector/README.md) -- Interactive diagnostics +- to [End-to-End Example](../examples/end-to-end.md) -- Complete feature walkthrough ### Deep Dives -- → [Interceptors & Ordering](../concepts/interceptors-and-ordering.md) — Control execution flow -- → [Design & Architecture](../architecture/design-and-architecture.md) — Internals and optimizations -- → [Performance](../architecture/performance.md) — Benchmarks and tuning +- to [Interceptors & Ordering](../concepts/interceptors-and-ordering.md) -- Control execution flow +- to [Design & Architecture](../architecture/design-and-architecture.md) -- Internals and optimizations +- to [Performance](../architecture/performance.md) -- Benchmarks and tuning ### Reference -- → [Quick Reference](../reference/quick-reference.md) — Cheat sheet -- → [API Reference](../reference/reference.md) — Complete API +- to [Quick Reference](../reference/quick-reference.md) -- Cheat sheet +- to [API Reference](../reference/reference.md) -- Complete API diff --git a/docs/guides/unity-integration.md b/docs/guides/unity-integration.md index 377de7c0..b6c99ce9 100644 --- a/docs/guides/unity-integration.md +++ b/docs/guides/unity-integration.md @@ -1,6 +1,6 @@ # Unity Integration -Unity‑centric helpers make registration lifecycles explicit and safe. +Unity-centric helpers make registration lifecycles explicit and safe. ## MessagingComponent @@ -11,9 +11,9 @@ Unity‑centric helpers make registration lifecycles explicit and safe. ## MessageAwareComponent -- Derive for a batteries‑included pattern; it manages a token for you. +- Derive for a batteries-included pattern; it manages a token for you. - Override `RegisterMessageHandlers()` to stage registrations. -- The token is enabled/disabled with the component’s enable state. +- The token is enabled/disabled with the component's enable state. - Call `ConfigureMessageBus(IMessageBus, MessageBusRebindMode)` before `base.Awake()` (or shortly after via a DI bootstrapper) to ensure the token is created against your container-provided bus. ```csharp @@ -42,18 +42,21 @@ public sealed class HealthComponent : MessageAwareComponent ## Don'ts -- Don’t register in Update; register once and enable/disable with component state. -- Don’t forget to call `base.RegisterMessageHandlers()` if your subclass relies on base registrations. +- Don't register in Update; register once and enable/disable with component state. +- Don't forget to call `base.RegisterMessageHandlers()` if your subclass relies on base registrations. ## Important: Inheritance and base calls - `MessageAwareComponent` uses many virtual methods (e.g., `Awake`, `OnEnable`, `OnDisable`, `RegisterMessageHandlers`). - **CRITICAL**: If you override any of these, you MUST call the base method: `base.Awake()`, `base.OnEnable()`, `base.OnDisable()`, `base.RegisterMessageHandlers()`. -- **Always call `base.RegisterMessageHandlers()` first** in your override—this ensures parent class registrations happen before yours. -- Skipping base calls can break core setup (token creation/enable) and default string‑message registrations. +- **Always call `base.RegisterMessageHandlers()` first** in your override -- this ensures parent class registrations happen before yours. +- Skipping base calls can break core setup (token creation/enable) and default string-message registrations. - If you need to opt out of string demos, prefer overriding `RegisterForStringMessages => false` rather than removing the base call. - **Don't hide Unity methods** with `new` (e.g., `new void OnEnable()`); always `override` and call `base.*`. +!!! tip "Diagnostics & Analyzer" +DxMessaging ships a Roslyn analyzer + Inspector overlay that catches missing base calls at compile time and surfaces them as a HelpBox at the top of the offending component's Inspector. See the [Inspector Overlay & Base-Call Warnings](inspector-overlay.md) guide for the day-to-day workflow, or the [Roslyn Analyzers & Diagnostics](../reference/analyzers.md) reference for every diagnostic id and the suppression-precedence ordering. + ## Registration timing - **Prefer `Awake()` for registration** rather than `Start()`. @@ -108,7 +111,7 @@ public sealed class AlwaysListening : MessageAwareComponent } ``` -## String message demos (opt‑out) +## String message demos (opt-out) ```csharp public sealed class NoStringDemos : MessageAwareComponent diff --git a/docs/images/inspector-overlay.meta b/docs/images/inspector-overlay.meta new file mode 100644 index 00000000..bd7b3c5a --- /dev/null +++ b/docs/images/inspector-overlay.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 08de8fe2ea8642edb974bb5a028784e5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/images/inspector-overlay/README.md b/docs/images/inspector-overlay/README.md new file mode 100644 index 00000000..f2f116b4 --- /dev/null +++ b/docs/images/inspector-overlay/README.md @@ -0,0 +1,187 @@ +# Inspector Overlay Screenshot Manifest + +This directory holds the screenshots referenced from +[`docs/guides/inspector-overlay.md`](../../guides/inspector-overlay.md) and the +inspector-overlay sections of +[`docs/reference/analyzers.md`](../../reference/analyzers.md). + +Every entry below currently ships as a 1x1 transparent placeholder PNG that +mkdocs accepts as a valid image so `mkdocs build --strict` produces no +warnings. Each entry tells the screenshot author exactly what to capture +(Unity version, scene state, component, expected UI annotations, recommended +dimensions). When you replace a placeholder PNG with the real screenshot at +the same filename, the docs pick up the real artwork automatically. + +## Conventions + +- **Format:** PNG (web-safe, 24-bit). No animated GIFs. +- **Width:** Aim for 960px-1200px for full-panel shots; 480px-720px for + cropped HelpBox shots. Retina-quality (2x) renders are welcome but make + sure the file size stays under 500 KB after compression. +- **Theme:** Capture using Unity's **Personal** (light) editor theme so the + HelpBox background matches the Material for MkDocs default site theme. + If you also want a Pro (dark) variant, suffix it `-dark.png` and add a + separate placeholder; the docs do not currently consume dark variants. +- **Cropping:** Trim the OS chrome (Windows title bar, macOS traffic + lights). Keep at least 16px of editor padding around the subject so the + HelpBox does not look squished against the image edge. +- **Annotations:** Avoid burned-in arrows or call-outs unless explicitly + requested by the placeholder. The docs already explain each control + in prose; redundant annotations clutter the screenshot. +- **Privacy:** Make sure no user-specific paths, Unity license badges, or + third-party asset thumbnails leak into the frame. + +## Capture target: Unity 2022 LTS + +Unless a placeholder explicitly says otherwise, capture in **Unity 2022.3 +LTS** with the **Built-in render pipeline** and the DxMessaging package +embedded under `Packages/com.wallstop-studios.dxmessaging`. This mirrors +the package's primary supported configuration. If a placeholder calls out +Unity 2021.3 explicitly (because it depicts the fallback editor's distinct +behaviour), capture that one in 2021.3. + +## Stub list + +Each entry below corresponds to a `.png` 1x1 transparent +placeholder PNG already committed in this directory. Overwrite the +placeholder PNG with the captured screenshot at the same filename; +the docs already reference the `.png` path. + +### `dxmsg009-overlay.png` + +The Inspector HelpBox illustrating DXMSG009 (implicit hide / missing +modifier), drawn at the very top of a `MessageAwareComponent` subclass's +Inspector. The component should be a throwaway subclass that declares +`private void OnEnable() {}` (missing both `override` and `new`), which +triggers DXMSG009 at compile time. The HelpBox text should read along +the lines of ` has lifecycle methods that don't chain to +MessageAwareComponent (OnEnable) - DxMessaging will not function on +this component.` (the overlay text matches DXMSG006/007 because the IL +scanner classifies all three identically; see the analyzers reference +for the caveat). Beneath the HelpBox, capture both buttons: **Open +Script** and **Ignore this type**. This image doubles as the generic +"warning state" illustration used at the top of the inspector-overlay +guide and the analyzers reference. Recommended frame: 720px wide, just +the HelpBox plus the two buttons plus 12px of padding. Unity 2022.3 +LTS, light theme. + +### `inspector-actions.png` + +A close-up of the HelpBox action row showing **Open Script** and +**Ignore this type** side by side, with no other Inspector chrome in +the frame. This is the "happy path" annotated reference image used in +the guide's "Three Inspector actions" section. Capture only the two +buttons plus ~6px padding above and below; roughly 480px wide. + +### `inspector-ignored.png` + +The HelpBox in its **info** state for a type that is currently in the +project ignore list. The HelpBox text reads ` is excluded from +the DxMessaging base-call check.` The single button below it is +**Stop ignoring**. To reproduce: pick a `MessageAwareComponent` +subclass that actually emits a warning, then add its FQN to +`Assets/Editor/DxMessaging.BaseCallIgnore.txt` (or click "Ignore this +type" once). The HelpBox icon should be the blue info glyph, not the +yellow warning glyph. Recommended dimensions: 720px wide. + +### `project-settings-panel.png` + +The **Project Settings > Wallstop Studios > DxMessaging** page, captured as it currently +renders. The provider exposes exactly three controls (see +`Editor/Settings/DxMessagingSettingsProvider.cs`): + +- **Diagnostics Targets** -- `EnumFlagsField` for the `DiagnosticsTarget` + flags enum (`Off`, `Editor`, `Runtime`, `All`). +- **Message Buffer Size** -- integer field. Default is + `IMessageBus.DefaultMessageBufferSize`. +- **Suppress Domain Reload Warning** -- boolean checkbox. + +Capture the entire DxMessaging section of the Project Settings window +plus the breadcrumb that shows "DxMessaging" is selected in the left +sidebar. Recommended dimensions: 1024px-1200px wide. Unity 2022.3 LTS, +light theme. Recapture if/when more controls are wired into the +provider -- for now the additional fields (`BaseCallCheckEnabled`, +`BaseCallIgnoredTypes`, `UseConsoleBridge`) live on the asset +Inspector at `Assets/Editor/DxMessagingSettings.asset`, not here. + +### `tools-menu-rescan.png` + +The Unity menu bar dropdown showing **Tools > Wallstop Studios > DxMessaging > Rescan +Base-Call Warnings**. Open the menu, hover over **DxMessaging** so the +sub-menu is expanded with **Rescan Base-Call Warnings** highlighted. +Crop to just the menu cascade plus a sliver of the editor window +behind it for context. Recommended dimensions: 480px-640px wide. + +### `worked-example-before.png` + +The "before" screenshot for the guide's worked example: a +`MessageAwareComponent` subclass named `HealthComponent` whose +`OnEnable` override does not call `base.OnEnable()`, attached to an +empty GameObject in the Hierarchy. The Inspector should show the +HelpBox at the top with a clear `OnEnable` callout in the missing-base +list. Frame both the GameObject Hierarchy entry on the left and the +Inspector pane on the right so the reader can see the offending +component is selected. Recommended dimensions: 1100px-1200px wide. + +### `worked-example-after.png` + +The "after" screenshot for the worked example, captured from the same +GameObject after the developer added the missing `base.OnEnable()` +call and recompiled. The HelpBox is gone -- the Inspector renders the +component cleanly with no DxMessaging overlay present. Same framing +as `worked-example-before.png` so the side-by-side comparison reads +naturally. Recommended dimensions: 1100px-1200px wide. + +### `dxmsg006-overlay.png` + +Cropped HelpBox + buttons for a class that triggers DXMSG006 on +`Awake`. Use a subclass like `MissingAwakeBase : MessageAwareComponent` +with `protected override void Awake() { /* missing base.Awake() */ }`. +Used in the analyzers reference page next to the DXMSG006 section. +Recommended dimensions: 720px wide. + +### `dxmsg007-overlay.png` + +Cropped HelpBox + buttons for a class that triggers DXMSG007 by +hiding `OnEnable` with `new`. Use a subclass like +`HidesWithNew : MessageAwareComponent` with `new void OnEnable() {}`. +The HelpBox surfaces the same "lifecycle methods that don't chain" +message -- DXMSG007 and DXMSG009 are visually indistinguishable in +the overlay because the IL scanner classifies both as DXMSG007. The +annotation in the reference page calls this out; the screenshot is +just the HelpBox. Recommended dimensions: 720px wide. + +### `dxmsg010-overlay.png` + +Cropped HelpBox + buttons for a class that triggers DXMSG010 via a +broken transitive base-call chain. Set up two subclasses: a parent +`BrokenIntermediate : MessageAwareComponent` with +`protected override void OnEnable() { }` (no base call) and a child +`LeafComponent : BrokenIntermediate` with +`protected override void OnEnable() => base.OnEnable();`. Capture +the Inspector for the **child** GameObject -- its override looks +correct in isolation, but DXMSG010 fires because the chain dies on +the parent. The HelpBox surfaces the same missing-method list the +overlay always shows. Recommended dimensions: 720px wide. + +## When you replace a placeholder + +1. Save the captured PNG with the matching filename (e.g. + `dxmsg009-overlay.png`) inside this directory, overwriting the + 1x1 placeholder PNG already present. +1. Run `mkdocs build --strict` locally to confirm no link warnings + surface; the build should be silent because the docs already + reference the `.png` filename. +1. Update the sibling `.meta` file's GUID if Unity regenerates it on + the next import. Every screenshot must have a matching `.meta` + file (this is a hard requirement of the project's Unity-asset + convention; see the existing `.meta` files in this directory for + the format). + +## Placeholder rationale + +The PNGs committed here are 1x1 transparent placeholders -- the smallest +valid PNG payload that keeps `mkdocs build --strict` quiet on the markdown +image references. Replace each one with the captured screenshot at the +same filename when the real artwork is ready; the docs already reference +the `.png` paths. diff --git a/docs/images/inspector-overlay/README.md.meta b/docs/images/inspector-overlay/README.md.meta new file mode 100644 index 00000000..6c227b6e --- /dev/null +++ b/docs/images/inspector-overlay/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8cdd55d4711d4f50b15458b5766dcad8 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/images/inspector-overlay/dxmsg006-overlay.png b/docs/images/inspector-overlay/dxmsg006-overlay.png new file mode 100644 index 0000000000000000000000000000000000000000..5ca9f9ce0b8e30f017d5ec3e4d76e64ab43ebbbc GIT binary patch literal 18255 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 literal 0 HcmV?d00001 diff --git a/docs/images/inspector-overlay/dxmsg006-overlay.png.meta b/docs/images/inspector-overlay/dxmsg006-overlay.png.meta new file mode 100644 index 00000000..86c50aa0 --- /dev/null +++ b/docs/images/inspector-overlay/dxmsg006-overlay.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: af4c0227822c4ca690519f1237a2cc02 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/images/inspector-overlay/dxmsg007-overlay.png b/docs/images/inspector-overlay/dxmsg007-overlay.png new file mode 100644 index 0000000000000000000000000000000000000000..f849cdf2b864474649c2e471db6ef28b84f45d83 GIT binary patch literal 17055 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 TzTJ=f|0DP1RPL3?7(f3%+Mw}? literal 0 HcmV?d00001 diff --git a/docs/images/inspector-overlay/dxmsg009-overlay.png.meta b/docs/images/inspector-overlay/dxmsg009-overlay.png.meta new file mode 100644 index 00000000..32eb8868 --- /dev/null +++ b/docs/images/inspector-overlay/dxmsg009-overlay.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ae2010296f264529b2e528b5c1563e01 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/images/inspector-overlay/dxmsg010-overlay.png b/docs/images/inspector-overlay/dxmsg010-overlay.png new file mode 100644 index 0000000000000000000000000000000000000000..0e17d67eb32d2f2c1817e4352eea08d51146c422 GIT binary patch literal 17125 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 literal 0 HcmV?d00001 diff --git a/docs/images/inspector-overlay/dxmsg010-overlay.png.meta b/docs/images/inspector-overlay/dxmsg010-overlay.png.meta new file mode 100644 index 00000000..e3727ef0 --- /dev/null +++ b/docs/images/inspector-overlay/dxmsg010-overlay.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e83c60dd003743ef9977f2d7a588052e +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/images/inspector-overlay/inspector-actions.png b/docs/images/inspector-overlay/inspector-actions.png new file mode 100644 index 0000000000000000000000000000000000000000..41bb9ce311a7f256a42b2ff9512305f63a7435c8 GIT binary patch literal 17679 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 E4-c^Ep#T5? literal 0 HcmV?d00001 diff --git a/docs/images/inspector-overlay/inspector-actions.png.meta b/docs/images/inspector-overlay/inspector-actions.png.meta new file mode 100644 index 00000000..fbc17a0d --- /dev/null +++ b/docs/images/inspector-overlay/inspector-actions.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 59bbe5902cb146059e8b6171eac0878b +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/images/inspector-overlay/inspector-ignored.png b/docs/images/inspector-overlay/inspector-ignored.png new file mode 100644 index 0000000000000000000000000000000000000000..589b303fed9777a2d8c2773d63ac2b1ee1d9b71d GIT binary patch literal 12681 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 literal 0 HcmV?d00001 diff --git a/docs/images/inspector-overlay/inspector-ignored.png.meta b/docs/images/inspector-overlay/inspector-ignored.png.meta new file mode 100644 index 00000000..9b8f1926 --- /dev/null +++ b/docs/images/inspector-overlay/inspector-ignored.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8aa2b84384784a7b87306f4f4faedaab +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/images/inspector-overlay/project-settings-panel.png b/docs/images/inspector-overlay/project-settings-panel.png new file mode 100644 index 0000000000000000000000000000000000000000..dd6e1af40a41e3ae519c2734755207191fc48673 GIT binary patch literal 60794 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�#)lN|DkdI5r>ig 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

FT3zcuic|b-87BH<} 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 N002ovPDHLkV1lrK$Or%c literal 0 HcmV?d00001 diff --git a/docs/images/inspector-overlay/tools-menu-rescan.png.meta b/docs/images/inspector-overlay/tools-menu-rescan.png.meta new file mode 100644 index 00000000..805a2b2c --- /dev/null +++ b/docs/images/inspector-overlay/tools-menu-rescan.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 017212032fed4acb838ec0b1927a33a8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/images/inspector-overlay/worked-example-after.png b/docs/images/inspector-overlay/worked-example-after.png new file mode 100644 index 0000000000000000000000000000000000000000..147109109f163679e41909dbba580907064a8b6a GIT binary patch literal 7019 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 jd=XQeics{Ij!T!-9ja>u7S&FHFk& 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.MessageBus); ## Choosing a Framework -| Framework | Best For | Performance | -| -------------- | -------------------------------------------- | -------------- | -| **VContainer** | Most projects, additive scenes | ⭐⭐⭐ Fast | -| **Zenject** | Complex projects, existing Zenject codebases | ⭐⭐ Good | -| **Reflex** | Performance-critical, AOT platforms | ⭐⭐⭐ Fastest | +| Framework | Best For | Performance | +| -------------- | -------------------------------------------- | ----------- | +| **VContainer** | Most projects, additive scenes | Fast | +| **Zenject** | Complex projects, existing Zenject codebases | Good | +| **Reflex** | Performance-critical, AOT platforms | Fastest | ## Common Patterns All frameworks support the same core patterns: -1. **Global Bus** — Share `MessageHandler.MessageBus` across all systems -1. **Scoped Bus** — Create per-scene buses for isolation -1. **Builder Injection** — Inject `IMessageRegistrationBuilder` for flexible handler registration -1. **Testable Design** — Mock `IMessageBus` in unit tests +1. **Global Bus** -- Share `MessageHandler.MessageBus` across all systems +1. **Scoped Bus** -- Create per-scene buses for isolation +1. **Builder Injection** -- Inject `IMessageRegistrationBuilder` for flexible handler registration +1. **Testable Design** -- Mock `IMessageBus` in unit tests See each framework's guide for detailed examples and best practices. diff --git a/docs/integrations/reflex.md b/docs/integrations/reflex.md index 00691ee6..9518e590 100644 --- a/docs/integrations/reflex.md +++ b/docs/integrations/reflex.md @@ -1,6 +1,6 @@ # DxMessaging + Reflex -[← Back to Integrations Overview](index.md) +[Back to Integrations Overview](index.md) --- @@ -10,8 +10,8 @@ - **Inject `IMessageBus`** in any class with minimal overhead - **Use DI for construction** + DxMessaging for events (best of both worlds) -- **Minimal API surface** — small number of concepts to understand -- **Compatible** — Reflex and DxMessaging can be used together +- **Minimal API surface** -- small number of concepts to understand +- **Compatible** -- Reflex and DxMessaging can be used together **Why combine DI + Messaging?** Use constructor injection for service dependencies (repositories, managers) and messaging for reactive events (damage taken, item collected), combining both approaches. @@ -312,6 +312,6 @@ public class DamageServiceTests ## Next Steps -- **[Zenject Integration](zenject.md)** — Full-featured DI with extensive Unity support -- **[VContainer Integration](vcontainer.md)** — Lightweight alternative with scoped lifetimes -- **[Back to Documentation Hub](../getting-started/index.md)** — Browse all docs +- **[Zenject Integration](zenject.md)** -- Full-featured DI with extensive Unity support +- **[VContainer Integration](vcontainer.md)** -- Lightweight alternative with scoped lifetimes +- **[Back to Documentation Hub](../getting-started/index.md)** -- Browse all docs diff --git a/docs/integrations/vcontainer.md b/docs/integrations/vcontainer.md index f73ac6c2..7734d5f2 100644 --- a/docs/integrations/vcontainer.md +++ b/docs/integrations/vcontainer.md @@ -1,6 +1,6 @@ # DxMessaging + VContainer -[← Back to Integrations Overview](index.md) +[Back to Integrations Overview](index.md) --- @@ -11,7 +11,7 @@ - **Inject `IMessageBus`** in any class with deterministic lifetimes - **Create per-scope buses** for scene isolation (perfect for additive scenes) - **Use DI for construction** + DxMessaging for events (best of both worlds) -- **Compatible** — VContainer and DxMessaging can be used together +- **Compatible** -- VContainer and DxMessaging can be used together **Why combine DI + Messaging?** Use constructor injection for service dependencies (repositories, managers) and messaging for reactive events (damage taken, item collected), combining both approaches. VContainer's scoped lifetimes support per-scene message buses. @@ -328,6 +328,6 @@ public IEnumerator PlayMode_MessageBusIsolation() ## Next Steps -- **[Zenject Integration](zenject.md)** — Full-featured DI with extensive Unity support -- **[Reflex Integration](reflex.md)** — Minimal DI framework -- **[Back to Documentation Hub](../getting-started/index.md)** — Browse all docs +- **[Zenject Integration](zenject.md)** -- Full-featured DI with extensive Unity support +- **[Reflex Integration](reflex.md)** -- Minimal DI framework +- **[Back to Documentation Hub](../getting-started/index.md)** -- Browse all docs diff --git a/docs/integrations/zenject.md b/docs/integrations/zenject.md index f9f8c226..ef4397b0 100644 --- a/docs/integrations/zenject.md +++ b/docs/integrations/zenject.md @@ -1,6 +1,6 @@ # DxMessaging + Zenject -[← Back to Integrations Overview](index.md) +[Back to Integrations Overview](index.md) --- @@ -235,7 +235,7 @@ public sealed class DxToSignalBridge : IInitializable, IDisposable }; _token = MessageRegistrationToken.Create(handler, _messageBus); - // Bridge DxMessaging → Zenject Signals + // Bridge DxMessaging -> Zenject Signals _ = _token.RegisterUntargeted(OnSceneTransition); _token.Enable(); } @@ -328,6 +328,6 @@ public class GameInitializerTests : ZenjectUnitTestFixture ## Next Steps -- **[VContainer Integration](vcontainer.md)** — Lightweight alternative to Zenject -- **[Reflex Integration](reflex.md)** — Minimal DI framework -- **[Back to Documentation Hub](../getting-started/index.md)** — Browse all docs +- **[VContainer Integration](vcontainer.md)** -- Lightweight alternative to Zenject +- **[Reflex Integration](reflex.md)** -- Minimal DI framework +- **[Back to Documentation Hub](../getting-started/index.md)** -- Browse all docs diff --git a/docs/reference/analyzers.md b/docs/reference/analyzers.md index 81b12240..66c248a1 100644 --- a/docs/reference/analyzers.md +++ b/docs/reference/analyzers.md @@ -1,6 +1,6 @@ # Roslyn Analyzers & Diagnostics -[← Back to Reference](reference.md) | [Troubleshooting](troubleshooting.md) | [Quick Reference](quick-reference.md) | [FAQ](faq.md) +[Back to Reference](reference.md) | [Troubleshooting](troubleshooting.md) | [Quick Reference](quick-reference.md) | [FAQ](faq.md) --- @@ -21,7 +21,7 @@ DxMessaging ships a Roslyn analyzer (`WallstopStudios.DxMessaging.SourceGenerato | [`DXMSG010`](#dxmsg010-broken-transitive-base-call-chain) | Warning | `base.{method}()` chains into an override that does not reach `MessageAwareComponent` | `MessageAwareComponentBaseCallAnalyzer` | !!! tip -All diagnostic IDs can be customised per project in `.editorconfig` — e.g. `dotnet_diagnostic.DXMSG006.severity = error` to upgrade missing base calls to a build break. +All diagnostic IDs can be customised per project in `.editorconfig` -- e.g. `dotnet_diagnostic.DXMSG006.severity = error` to upgrade missing base calls to a build break. --- @@ -34,15 +34,15 @@ All diagnostic IDs can be customised per project in `.editorconfig` — e.g. `do ### Fix -Pick exactly one message-shape attribute. A message can be Broadcast, Targeted, or Untargeted — not two at once. If you genuinely need both shapes, define two separate types. +Pick exactly one message-shape attribute. A message can be Broadcast, Targeted, or Untargeted -- not two at once. If you genuinely need both shapes, define two separate types. ```csharp -// ❌ Multiple shapes on one type +// Multiple shapes on one type [DxBroadcastMessage] [DxTargetedMessage] public readonly partial struct Healed { public readonly int amount; } -// ✅ One shape per type +// One shape per type [DxTargetedMessage] public readonly partial struct Healed { public readonly int amount; } ``` @@ -61,14 +61,14 @@ public readonly partial struct Healed { public readonly int amount; } Add `partial` to every enclosing type declaration. Roslyn cannot emit additional members into a nested type unless every container is partial. ```csharp -// ❌ Container is not partial; generation cannot continue +// Container is not partial; generation cannot continue public sealed class GameSystems { [DxUntargetedMessage] public readonly partial struct SceneLoaded { public readonly int buildIndex; } } -// ✅ +// public sealed partial class GameSystems { [DxUntargetedMessage] @@ -87,7 +87,7 @@ public sealed partial class GameSystems ### Fix -Identical to [`DXMSG003`](#dxmsg003-containing-type-must-be-partial-for-nested-generation) — add `partial` to the named container. +Identical to [`DXMSG003`](#dxmsg003-containing-type-must-be-partial-for-nested-generation) -- add `partial` to the named container. --- @@ -103,14 +103,14 @@ Identical to [`DXMSG003`](#dxmsg003-containing-type-must-be-partial-for-nested-g Replace the expression with a constant literal, an enum member, `default`, `null` (for reference types and nullable value types), or a `const` field reference. ```csharp -// ❌ Method calls and non-constant expressions are not legal C# defaults +// Method calls and non-constant expressions are not legal C# defaults [DxAutoConstructor] public readonly partial struct Damage { [DxOptionalParameter(GetDefaultAmount())] public readonly int amount; } -// ✅ Constants only +// Constants only [DxAutoConstructor] public readonly partial struct Damage { @@ -138,14 +138,14 @@ public readonly partial struct Damage | `RegisterMessageHandlers` | Default implementation registers built-in string-message handlers; skipping it silently disables those demos. | !!! 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. +`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. ### Detection policy (good-faith textual match) -The analyzer looks for any `base.(...)` invocation anywhere inside the override body — including invocations nested inside lambdas or local functions. It does **not** perform reachability or data-flow analysis. The single known false-positive shape is **helper indirection**: +The analyzer looks for any `base.(...)` invocation anywhere inside the override body -- including invocations nested inside lambdas or local functions. It does **not** perform reachability or data-flow analysis. The single known false-positive shape is **helper indirection**: ```csharp -// ❌ False positive: analyzer cannot follow the indirection and emits DXMSG006 +// False positive: analyzer cannot follow the indirection and emits DXMSG006 protected override void Awake() => CallHelper(); private void CallHelper() => base.Awake(); // analyzer sees this in CallHelper, not Awake ``` @@ -154,17 +154,17 @@ If you genuinely need to delegate to a helper, suppress the warning with `[DxIgn ### Smart case: `RegisterForStringMessages => false` -When the same class overrides `RegisterForStringMessages` to literally `false`, DXMSG006 on `RegisterMessageHandlers` is **lowered to Info severity** (same diagnostic ID — still configurable via `.editorconfig`). The interpretation is **strict literal-only**: +When the same class overrides `RegisterForStringMessages` to literally `false`, DXMSG006 on `RegisterMessageHandlers` is **lowered to Info severity** (same diagnostic ID -- still configurable via `.editorconfig`). The interpretation is **strict literal-only**: -| Form | Lowered to Info? | -| ------------------------------------------------------------------------------------------------- | ------------------------- | -| `protected override bool RegisterForStringMessages => false;` | ✅ yes | -| `protected override bool RegisterForStringMessages { get => false; }` | ✅ yes | -| `protected override bool RegisterForStringMessages { get { return false; } }` | ✅ yes (single statement) | -| `protected override bool RegisterForStringMessages => default;` | ❌ no — stays Warning | -| `protected override bool RegisterForStringMessages => !true;` | ❌ no — stays Warning | -| `protected override bool RegisterForStringMessages => Constants.Disable;` | ❌ no — stays Warning | -| `protected override bool RegisterForStringMessages { get { if (x) return false; return true; } }` | ❌ no — stays Warning | +| Form | Lowered to Info? | +| ------------------------------------------------------------------------------------------------- | ---------------------- | +| `protected override bool RegisterForStringMessages => false;` | yes | +| `protected override bool RegisterForStringMessages { get => false; }` | yes | +| `protected override bool RegisterForStringMessages { get { return false; } }` | yes (single statement) | +| `protected override bool RegisterForStringMessages => default;` | no -- stays Warning | +| `protected override bool RegisterForStringMessages => !true;` | no -- stays Warning | +| `protected override bool RegisterForStringMessages => Constants.Disable;` | no -- stays Warning | +| `protected override bool RegisterForStringMessages { get { if (x) return false; return true; } }` | no -- stays Warning | The smart-case is deliberately conservative: anything that introduces a conditional, a non-literal expression, or even one extra statement is treated as ambiguous and stays at Warning severity. @@ -183,20 +183,20 @@ See [Suppression precedence](#suppression-precedence) for the full ordering. ### Why this is worse than DXMSG006 -`new` doesn't override — it shadows. Unity calls the **base** lifecycle method, which still runs correctly, but if you also expect your hidden method to run (e.g. via `someComponent.OnEnable()` from a polymorphic call site) you'll get the wrong dispatch. More commonly: developers reach for `new` thinking it suppresses a CS0114 hide-warning, and the result is a silently broken component. +`new` doesn't override -- it shadows. Unity calls the **base** lifecycle method, which still runs correctly, but if you also expect your hidden method to run (e.g. via `someComponent.OnEnable()` from a polymorphic call site) you'll get the wrong dispatch. More commonly: developers reach for `new` thinking it suppresses a CS0114 hide-warning, and the result is a silently broken component. ### Fix Replace `new` with `override` and add the base call. ```csharp -// ❌ Hides the lifecycle method; Unity still calls the base, your code never runs +// Hides the lifecycle method; Unity still calls the base, your code never runs public sealed class HealthComponent : MessageAwareComponent { new void OnEnable() { _hud.Show(); } } -// ✅ Override and chain +// Override and chain public sealed class HealthComponent : MessageAwareComponent { protected override void OnEnable() @@ -218,7 +218,7 @@ public sealed class HealthComponent : MessageAwareComponent ### Purpose -DXMSG008 is purely informational. It tells you "yes, the analyzer noticed this would be a problem, but you've explicitly opted out — here's where the suppression came from". The placeholder `{1}` reports the suppression source: either the literal `[DxIgnoreMissingBaseCall]` or the file name `DxMessaging.BaseCallIgnore.txt`. +DXMSG008 is purely informational. It tells you "yes, the analyzer noticed this would be a problem, but you've explicitly opted out -- here's where the suppression came from". The placeholder `{1}` reports the suppression source: either the literal `[DxIgnoreMissingBaseCall]` or the file name `DxMessaging.BaseCallIgnore.txt`. ### Quieting it @@ -235,29 +235,29 @@ dotnet_diagnostic.DXMSG008.severity = none - **Severity:** Warning - **Source:** `MessageAwareComponentBaseCallAnalyzer` -- **Triggered when:** A subclass of `MessageAwareComponent` declares a method whose name matches one of the five guarded lifecycle methods (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, `RegisterMessageHandlers`), with neither `override` nor `new`, AND the signature is parameter-less, returns `void`, is non-static, and is non-generic. C# treats this as implicit hiding (compiler warning [CS0114](https://learn.microsoft.com/en-us/dotnet/csharp/misc/cs0114)) — the base method never runs and the messaging system will not function. +- **Triggered when:** A subclass of `MessageAwareComponent` declares a method whose name matches one of the five guarded lifecycle methods (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, `RegisterMessageHandlers`), with neither `override` nor `new`, AND the signature is parameter-less, returns `void`, is non-static, and is non-generic. C# treats this as implicit hiding (compiler warning [CS0114](https://learn.microsoft.com/en-us/dotnet/csharp/misc/cs0114)) -- the base method never runs and the messaging system will not function. - **Message:** `'{0}' declares {1} without 'override' or 'new'; this implicitly hides MessageAwareComponent.{1} (CS0114) and the messaging system will not function. Add 'override' and call base.{1}(), or add 'new' if the hiding is intentional.` ### Why this exists -DXMSG009 is the most common Unity footgun. Forgetting `override` on `private void OnEnable()` is silent at runtime — Unity calls the subclass method directly, the base implementation never gets a chance to enable the messaging token, and every registered handler stops working. C# already emits CS0114 for this, but in many Unity projects compiler warnings get ignored. DXMSG009 surfaces it to the inspector overlay and the project's analyzer report. +DXMSG009 is the most common Unity footgun. Forgetting `override` on `private void OnEnable()` is silent at runtime -- Unity calls the subclass method directly, the base implementation never gets a chance to enable the messaging token, and every registered handler stops working. C# already emits CS0114 for this, but in many Unity projects compiler warnings get ignored. DXMSG009 surfaces it to the inspector overlay and the project's analyzer report. ### Fix ```csharp -// ❌ Implicit hiding — DXMSG009 fires (alongside CS0114) +// Implicit hiding -- DXMSG009 fires (alongside CS0114) public class BrokenThing : MessageAwareComponent { private void OnEnable() { } } -// ✅ Override and chain +// Override and chain public class FixedThing : MessageAwareComponent { protected override void OnEnable() { base.OnEnable(); - // … your logic … + // ... your logic ... } } ``` @@ -266,7 +266,7 @@ Use `new` instead of `override` only if you have a deliberate reason to disable ### Suppression -DXMSG009 honors all the same suppression paths as DXMSG006 — see [Suppression precedence](#suppression-precedence) below. +DXMSG009 honors all the same suppression paths as DXMSG006 -- see [Suppression precedence](#suppression-precedence) below. ### Signature filter @@ -277,15 +277,15 @@ DXMSG009 fires only when the method shape matches a Unity lifecycle method: - Non-static. - Non-generic. -So unrelated overloads like `void OnEnable(int discriminator) {}`, unrelated static helpers, and generic same-name methods (`void Awake()`, which C# does not treat as hiding because the type-parameter arity differs from the base) all stay silent — they aren't actually hiding the base. +So unrelated overloads like `void OnEnable(int discriminator) {}`, unrelated static helpers, and generic same-name methods (`void Awake()`, which C# does not treat as hiding because the type-parameter arity differs from the base) all stay silent -- they aren't actually hiding the base. ### Coexistence with other diagnostics -DXMSG009 is mutually exclusive with DXMSG006 and DXMSG007 _for the same method_ (a method either has `override`, `new`, or neither). However, a single subclass can carry **both** DXMSG009 (on one method that's missing the modifier) and DXMSG006 (on a different method that overrides without `base.X()`) — in that case the inspector overlay lists both methods in its HelpBox. +DXMSG009 is mutually exclusive with DXMSG006 and DXMSG007 _for the same method_ (a method either has `override`, `new`, or neither). However, a single subclass can carry **both** DXMSG009 (on one method that's missing the modifier) and DXMSG006 (on a different method that overrides without `base.X()`) -- in that case the inspector overlay lists both methods in its HelpBox. ### Editor inspector overlay -The inspector overlay's `BaseCallTypeScanner` is an IL-reflection scanner — it reads each override's IL bytes via `MethodInfo.GetMethodBody()` and checks for the `call`/`callvirt` shape that `base.X()` compiles to. The C# compiler emits **the same IL** for `new void X()` (DXMSG007) and for a same-named declaration with the modifier missing (DXMSG009 / CS0114): both produce a non-virtual hide-by-sig method. **The IL scanner cannot distinguish DXMSG009 from DXMSG007 from IL alone**, so it conservatively records the diagnostic id as `DXMSG007` in the cached snapshot for both cases. The compile-time analyzer remains authoritative for the precise classification — when the cached `Snapshot.diagnosticIds` (or the JSON file at `Library/DxMessaging/baseCallReport.json`) shows `DXMSG007` but the analyzer console output is `DXMSG009`, **trust the analyzer**. The HelpBox itself lights up correctly either way because the overlay reads `missingBaseFor` (the method name list) for its rendering — the user-visible behaviour is identical. +The inspector overlay's `BaseCallTypeScanner` is an IL-reflection scanner -- it reads each override's IL bytes via `MethodInfo.GetMethodBody()` and checks for the `call`/`callvirt` shape that `base.X()` compiles to. The C# compiler emits **the same IL** for `new void X()` (DXMSG007) and for a same-named declaration with the modifier missing (DXMSG009 / CS0114): both produce a non-virtual hide-by-sig method. **The IL scanner cannot distinguish DXMSG009 from DXMSG007 from IL alone**, so it conservatively records the diagnostic id as `DXMSG007` in the cached snapshot for both cases. The compile-time analyzer remains authoritative for the precise classification -- when the cached `Snapshot.diagnosticIds` (or the JSON file at `Library/DxMessaging/baseCallReport.json`) shows `DXMSG007` but the analyzer console output is `DXMSG009`, **trust the analyzer**. The HelpBox itself lights up correctly either way because the overlay reads `missingBaseFor` (the method name list) for its rendering -- the user-visible behaviour is identical. --- @@ -298,20 +298,20 @@ The inspector overlay's `BaseCallTypeScanner` is an IL-reflection scanner — it ### Why this exists -DXMSG006 is a per-method syntactic check: "does this override contain a textual `base.X()` call?". That check fires on the broken intermediate (e.g., a parent `ddd.OnEnable() {}`), but it cannot see across the inheritance boundary into a descendant. Without DXMSG010, the user editing the descendant only sees a clean override — no diagnostic — even though their component is silently broken. +DXMSG006 is a per-method syntactic check: "does this override contain a textual `base.X()` call?". That check fires on the broken intermediate (e.g., a parent `ddd.OnEnable() {}`), but it cannot see across the inheritance boundary into a descendant. Without DXMSG010, the user editing the descendant only sees a clean override -- no diagnostic -- even though their component is silently broken. ```csharp -// ❌ Both warnings now fire. +// Both warnings now fire. public class ddd : MessageAwareComponent { - protected override void OnEnable() { } // DXMSG006 here — chain dies here + protected override void OnEnable() { } // DXMSG006 here -- chain dies here } public class BrokenThing : ddd { protected override void OnEnable() { - base.OnEnable(); // DXMSG010 here — chain still broken + base.OnEnable(); // DXMSG010 here -- chain still broken } } ``` @@ -319,27 +319,27 @@ public class BrokenThing : ddd ### Semantic difference vs DXMSG006 - **DXMSG006** is a per-method, per-class textual check: a single override either contains `base.X()` or it doesn't. It runs in isolation. -- **DXMSG010** is a transitive chain walk: it follows `IMethodSymbol.OverriddenMethod` from this override up the inheritance graph (normalising via `OriginalDefinition` so generic intermediates like `MyBase` don't confuse the lookup) and confirms every link calls base before terminating at `MessageAwareComponent`. If any intermediate link is broken, every descendant in the chain warns — not just the original offender. +- **DXMSG010** is a transitive chain walk: it follows `IMethodSymbol.OverriddenMethod` from this override up the inheritance graph (normalising via `OriginalDefinition` so generic intermediates like `MyBase` don't confuse the lookup) and confirms every link calls base before terminating at `MessageAwareComponent`. If any intermediate link is broken, every descendant in the chain warns -- not just the original offender. ### Cross-assembly assume-clean caveat -If an ancestor's override has no `DeclaringSyntaxReferences` — typically because it lives in a binary-only third-party package — the analyzer cannot inspect its body. In that case DXMSG010 trusts the ancestor and does **not** fire. Emitting the diagnostic against a type the user can't edit would be unactionable. +If an ancestor's override has no `DeclaringSyntaxReferences` -- typically because it lives in a binary-only third-party package -- the analyzer cannot inspect its body. In that case DXMSG010 trusts the ancestor and does **not** fire. Emitting the diagnostic against a type the user can't edit would be unactionable. ### Suppression options -DXMSG010 honours all the same suppression paths as DXMSG006 — see [Suppression precedence](#suppression-precedence) below. Class-level `[DxIgnoreMissingBaseCall]` on the descendant, a method-level attribute, or a project ignore-list entry all convert DXMSG010 into the informational DXMSG008. +DXMSG010 honours all the same suppression paths as DXMSG006 -- see [Suppression precedence](#suppression-precedence) below. Class-level `[DxIgnoreMissingBaseCall]` on the descendant, a method-level attribute, or a project ignore-list entry all convert DXMSG010 into the informational DXMSG008. ### Fix In order of preference: -- **Fix the broken intermediate.** Open the parent class's override and add the missing `base.{method}()`. This is the correct fix in almost every case — every descendant in the chain becomes clean automatically. +- **Fix the broken intermediate.** Open the parent class's override and add the missing `base.{method}()`. This is the correct fix in almost every case -- every descendant in the chain becomes clean automatically. - **Override directly from `MessageAwareComponent`.** If you control the descendant but not the intermediate, change the descendant's base type to skip the broken intermediate. - **Suppress with `[DxIgnoreMissingBaseCall]`.** Only when the broken chain is genuinely intentional (e.g. a deliberate adapter that shouldn't participate in messaging). Document the reason in a comment alongside the attribute. ### Known limitation -DXMSG010 reuses the same good-faith textual check as DXMSG006 (`ContainsBaseInvocation`). If an ancestor's body literally contains `base.X()` after a `return;` (i.e. unreachable but syntactically present), the chain check considers it clean — mirroring DXMSG006's policy. Both diagnostics share a single textual policy so users get consistent results; if you genuinely need flow-aware analysis, suppress with `[DxIgnoreMissingBaseCall]` and review manually. +DXMSG010 reuses the same good-faith textual check as DXMSG006 (`ContainsBaseInvocation`). If an ancestor's body literally contains `base.X()` after a `return;` (i.e. unreachable but syntactically present), the chain check considers it clean -- mirroring DXMSG006's policy. Both diagnostics share a single textual policy so users get consistent results; if you genuinely need flow-aware analysis, suppress with `[DxIgnoreMissingBaseCall]` and review manually. --- @@ -347,20 +347,20 @@ DXMSG010 reuses the same good-faith textual check as DXMSG006 (`ContainsBaseInvo When DxMessaging suppresses a base-call check, it consults the following sources in order. The **first** match wins: -1. **Method-level attribute** — `[DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall]` placed directly on the override. -1. **Class-level attribute** — `[DxIgnoreMissingBaseCall]` placed on the type declaration; suppresses all guarded overrides inside that type. -1. **Project ignore list** — fully-qualified type names listed in `Assets/Editor/DxMessaging.BaseCallIgnore.txt` (one per line). Manage entries via **Project Settings → DxMessaging → Base-Call Check → Ignored Types**. -1. **`.editorconfig` rule** — `dotnet_diagnostic.DXMSG006.severity = none` (or `DXMSG007.severity = none`, `DXMSG009.severity = none`, `DXMSG010.severity = none`) disables the diagnostic project-wide. +1. **Method-level attribute** -- `[DxMessaging.Core.Attributes.DxIgnoreMissingBaseCall]` placed directly on the override. +1. **Class-level attribute** -- `[DxIgnoreMissingBaseCall]` placed on the type declaration; suppresses all guarded overrides inside that type. +1. **Project ignore list** -- fully-qualified type names listed in `Assets/Editor/DxMessaging.BaseCallIgnore.txt` (one per line). Manage entries via **Project Settings -> DxMessaging -> Base-Call Check -> Ignored Types**. +1. **`.editorconfig` rule** -- `dotnet_diagnostic.DXMSG006.severity = none` (or `DXMSG007.severity = none`, `DXMSG009.severity = none`, `DXMSG010.severity = none`) disables the diagnostic project-wide. ```csharp -// Method-level — only this override is exempt +// Method-level -- only this override is exempt public sealed class FlashyComponent : MessageAwareComponent { [DxIgnoreMissingBaseCall] protected override void Awake() => CallHelperThatChainsToBase(); } -// Class-level — every guarded override on this type is exempt +// Class-level -- every guarded override on this type is exempt [DxIgnoreMissingBaseCall] public sealed class LegacyAdapter : MessageAwareComponent { /* ... */ } ``` @@ -372,38 +372,38 @@ Suppressing the diagnostic does not change the runtime behaviour: if your overri ## Inspector integration -The Inspector overlay's data source is the **`BaseCallTypeScanner`** — a deterministic IL-reflection scanner that walks every loaded `MessageAwareComponent` subclass via `UnityEditor.TypeCache.GetTypesDerivedFrom()` and inspects each override's IL body for the base-call shape (`call`/`callvirt` to a parent's same-named method). The scanner runs on every `AssemblyReloadEvents.afterAssemblyReload` and on every `CompilationPipeline.assemblyCompilationFinished` burst (debounced via `EditorApplication.delayCall`). +The Inspector overlay's data source is the **`BaseCallTypeScanner`** -- a deterministic IL-reflection scanner that walks every loaded `MessageAwareComponent` subclass via `UnityEditor.TypeCache.GetTypesDerivedFrom()` and inspects each override's IL body for the base-call shape (`call`/`callvirt` to a parent's same-named method). The scanner runs on every `AssemblyReloadEvents.afterAssemblyReload` and on every `CompilationPipeline.assemblyCompilationFinished` burst (debounced via `EditorApplication.delayCall`). -**Why IL reflection?** The previous console-scrape harvester read warnings from `UnityEditor.LogEntries` and from per-assembly `CompilerMessage[]` payloads. Both stores are downstream of Unity's decision to actually surface analyzer warnings — and on Unity 2021 with Bee/csc cache hits (which happen on most domain reloads after the first), Unity skips that surface entirely. The scrape returned nothing, even though the analyzer ran successfully on the original compile. The result was an intermittent "missing warnings" bug: warnings would appear after a fresh compile and then disappear after a domain reload, with no user-visible cause. IL reflection over loaded types is deterministic — the assemblies are in the AppDomain, the methods have IL bodies, the same scan produces the same result on every reload regardless of compile-pipeline state. +**Why IL reflection?** The previous console-scrape harvester read warnings from `UnityEditor.LogEntries` and from per-assembly `CompilerMessage[]` payloads. Both stores are downstream of Unity's decision to actually surface analyzer warnings -- and on Unity 2021 with Bee/csc cache hits (which happen on most domain reloads after the first), Unity skips that surface entirely. The scrape returned nothing, even though the analyzer ran successfully on the original compile. The result was an intermittent "missing warnings" bug: warnings would appear after a fresh compile and then disappear after a domain reload, with no user-visible cause. IL reflection over loaded types is deterministic -- the assemblies are in the AppDomain, the methods have IL bodies, the same scan produces the same result on every reload regardless of compile-pipeline state. -**Cross-assembly assume-clean.** Ancestors whose IL is unavailable (`MethodInfo.GetMethodBody()` returns null — abstract methods, P/Invoke, IL2CPP-stripped bodies, closed-source third-party libraries) are trusted. Emitting an unactionable warning against code the user can't edit would be hostile, and the compile-time analyzer remains the authoritative source for CI builds. +**Cross-assembly assume-clean.** Ancestors whose IL is unavailable (`MethodInfo.GetMethodBody()` returns null -- abstract methods, P/Invoke, IL2CPP-stripped bodies, closed-source third-party libraries) are trusted. Emitting an unactionable warning against code the user can't edit would be hostile, and the compile-time analyzer remains the authoritative source for CI builds. -**OpCodes-table walker.** The scanner's IL walker decodes every CIL instruction by looking up its `OpCode` in the static tables built from `System.Reflection.Emit.OpCodes` reflection (single-byte and two-byte 0xFE-prefix forms) and steps the operand-size that the opcode declares (`OpCode.OperandType`). Misalignment past multi-byte-operand opcodes (`switch` jump tables, `ldstr` 4-byte tokens, 8-byte literal constants, etc.) is therefore impossible — the walker either consumes every byte correctly or stops at the first unrecognised opcode and returns the assume-clean default. Phantom DXMSG006 from a misread `0x28` inside a wider operand is no longer a failure mode. The compile-time analyzer remains authoritative for CI; if you hit a phantom warning that the analyzer doesn't agree with, please open an issue. +**OpCodes-table walker.** The scanner's IL walker decodes every CIL instruction by looking up its `OpCode` in the static tables built from `System.Reflection.Emit.OpCodes` reflection (single-byte and two-byte 0xFE-prefix forms) and steps the operand-size that the opcode declares (`OpCode.OperandType`). Misalignment past multi-byte-operand opcodes (`switch` jump tables, `ldstr` 4-byte tokens, 8-byte literal constants, etc.) is therefore impossible -- the walker either consumes every byte correctly or stops at the first unrecognised opcode and returns the assume-clean default. Phantom DXMSG006 from a misread `0x28` inside a wider operand is no longer a failure mode. The compile-time analyzer remains authoritative for CI; if you hit a phantom warning that the analyzer doesn't agree with, please open an issue. -**DXMSG009 classified as DXMSG007 in the cache.** The scanner's IL-only probe cannot distinguish DXMSG007 (`new` modifier) from DXMSG009 (missing `override` / CS0114) — Roslyn emits the same IL for both. The cached snapshot conservatively classifies both as `DXMSG007`. The compile-time analyzer remains authoritative for the precise classification — see the [DXMSG009: Editor inspector overlay subsection](#editor-inspector-overlay) above. +**DXMSG009 classified as DXMSG007 in the cache.** The scanner's IL-only probe cannot distinguish DXMSG007 (`new` modifier) from DXMSG009 (missing `override` / CS0114) -- Roslyn emits the same IL for both. The cached snapshot conservatively classifies both as `DXMSG007`. The compile-time analyzer remains authoritative for the precise classification -- see the [DXMSG009: Editor inspector overlay subsection](#editor-inspector-overlay) above. -**Legacy console-scrape bridge (opt-in).** A toggle at **Project Settings → DxMessaging → Also Scrape Console (Legacy)** (`DxMessagingSettings.UseConsoleBridge`) re-enables the old data sources (`UnityEditor.LogEntries` reflection + `CompilationPipeline.assemblyCompilationFinished` `CompilerMessage[]`) and unions them INTO the IL scanner's snapshot — never overrides it. Default off. Enable only if you want the union of both data sources, e.g. to surface a regression in the IL byte-walker that is correctly captured by the compile-time analyzer's console output. +**Legacy console-scrape bridge (opt-in).** A toggle at **Project Settings -> DxMessaging -> Also Scrape Console (Legacy)** (`DxMessagingSettings.UseConsoleBridge`) re-enables the old data sources (`UnityEditor.LogEntries` reflection + `CompilationPipeline.assemblyCompilationFinished` `CompilerMessage[]`) and unions them INTO the IL scanner's snapshot -- never overrides it. Default off. Enable only if you want the union of both data sources, e.g. to surface a regression in the IL byte-walker that is correctly captured by the compile-time analyzer's console output. -The unified per-FQN snapshot is persisted to `Library/DxMessaging/baseCallReport.json` so the overlay has data to render before the first post-load rescan completes; it is rewritten on every successful rescan. A manual `Tools → DxMessaging → Rescan Base-Call Warnings` menu is available for force-rescan. +The unified per-FQN snapshot is persisted to `Library/DxMessaging/baseCallReport.json` so the overlay has data to render before the first post-load rescan completes; it is rewritten on every successful rescan. A manual `Tools -> DxMessaging -> Rescan Base-Call Warnings` menu is available for force-rescan. The overlay itself uses two complementary editor-injection paths, each with its own entry point in `MessageAwareComponentInspectorOverlay`: -- **`Editor.finishedDefaultHeaderGUI`** (entry point: `RenderForHeaderHook`) — the cross-version path that fires after Unity draws the default component header. Reliable on Unity 2022+. Because this hook runs _post-body_, gating the render on `EventType.Repaint` is safe — the inspector's Layout pass for the editor has already settled. -- **Fallback `[CustomEditor(typeof(MessageAwareComponent), editorForChildClasses: true, isFallback: true)]`** (entry point: `RenderInsideOnInspectorGUI`) — needed on Unity 2021, where `finishedDefaultHeaderGUI` does not always fire for `MonoBehaviour` subclasses without a registered custom editor. The `isFallback: true` flag means a user-defined `[CustomEditor]` for the same component type still wins precedence; the fallback only renders when no other editor is registered. +- **`Editor.finishedDefaultHeaderGUI`** (entry point: `RenderForHeaderHook`) -- the cross-version path that fires after Unity draws the default component header. Reliable on Unity 2022+. Because this hook runs _post-body_, gating the render on `EventType.Repaint` is safe -- the inspector's Layout pass for the editor has already settled. +- **Fallback `[CustomEditor(typeof(MessageAwareComponent), editorForChildClasses: true, isFallback: true)]`** (entry point: `RenderInsideOnInspectorGUI`) -- needed on Unity 2021, where `finishedDefaultHeaderGUI` does not always fire for `MonoBehaviour` subclasses without a registered custom editor. The `isFallback: true` flag means a user-defined `[CustomEditor]` for the same component type still wins precedence; the fallback only renders when no other editor is registered. -**Layout/Repaint balance.** Inside `Editor.OnInspectorGUI`, Unity invokes the editor twice per frame: once with `Event.current.type == EventType.Layout` (control registration) and once with `EventType.Repaint` (drawing). Both passes must emit _identical_ sequences of `EditorGUILayout.*` calls — short-circuiting `OnInspectorGUI` on event type corrupts the inspector window's layout cache and breaks adjacent components. The `RenderInsideOnInspectorGUI` entry point therefore performs all "should we render?" gating up-front (before any `EditorGUILayout` call) and never gates on `EventType`. Cross-path dedupe with the header hook is handled by an unconditional skip inside `DrawHeader` when the editor instance is our fallback editor — so the two paths never both render for the same target on the same frame, regardless of Unity version. The fallback editor also walks `SerializedObject` directly and skips `m_Script` rather than calling `DrawDefaultInspector()`, which would otherwise duplicate the script row that Unity already draws in the header. +**Layout/Repaint balance.** Inside `Editor.OnInspectorGUI`, Unity invokes the editor twice per frame: once with `Event.current.type == EventType.Layout` (control registration) and once with `EventType.Repaint` (drawing). Both passes must emit _identical_ sequences of `EditorGUILayout.*` calls -- short-circuiting `OnInspectorGUI` on event type corrupts the inspector window's layout cache and breaks adjacent components. The `RenderInsideOnInspectorGUI` entry point therefore performs all "should we render?" gating up-front (before any `EditorGUILayout` call) and never gates on `EventType`. Cross-path dedupe with the header hook is handled by an unconditional skip inside `DrawHeader` when the editor instance is our fallback editor -- so the two paths never both render for the same target on the same frame, regardless of Unity version. The fallback editor also walks `SerializedObject` directly and skips `m_Script` rather than calling `DrawDefaultInspector()`, which would otherwise duplicate the script row that Unity already draws in the header. Components that emit DXMSG006, DXMSG007, DXMSG009, or DXMSG010 show a HelpBox at the top of their Inspector with three actions: -- **Open Script** — jumps to the offending override in your IDE of choice. -- **Ignore this type** — appends the type's fully-qualified name to `Assets/Editor/DxMessaging.BaseCallIgnore.txt`. -- **Stop ignoring** — appears instead of "Ignore this type" when the type is already in the ignore list; removes it. +- **Open Script** -- jumps to the offending override in your IDE of choice. +- **Ignore this type** -- appends the type's fully-qualified name to `Assets/Editor/DxMessaging.BaseCallIgnore.txt`. +- **Stop ignoring** -- appears instead of "Ignore this type" when the type is already in the ignore list; removes it. -The HelpBox respects the per-project master toggle in **Project Settings → DxMessaging → Base-Call Check Enabled**. When this toggle is off, the Inspector overlay is silenced; the underlying DXMSG006/DXMSG007/DXMSG009 compile-time warnings still emit unless you suppress them via `.editorconfig` (e.g. `dotnet_diagnostic.DXMSG006.severity = none`). +The HelpBox respects the per-project master toggle in **Project Settings -> DxMessaging -> Base-Call Check Enabled**. When this toggle is off, the Inspector overlay is silenced; the underlying DXMSG006/DXMSG007/DXMSG009 compile-time warnings still emit unless you suppress them via `.editorconfig` (e.g. `dotnet_diagnostic.DXMSG006.severity = none`). A snapshot of the latest harvest is also persisted to `Library/DxMessaging/baseCallReport.json` so the overlay has data to show on first open before the post-load rescan completes. -**Eager-load and "cached from previous session" indicator.** The harvester's static constructor synchronously calls `LoadFromDisk` BEFORE scheduling the first scan via `EditorApplication.delayCall`. The on-disk cache populates `SnapshotInternal` immediately, so the inspector overlay can render warnings the moment the user clicks into a `MessageAwareComponent` — even before the first post-reload scan has had a chance to run. Until that first scan completes (typically within a few `EditorApplication.update` ticks after assembly reload), the harvester's `IsFreshThisSession` flag stays `false` and the overlay annotates each warning with a `(cached from previous session — refreshing…)` suffix so the user understands the data may be stale. Once the first `RescanNow` post-startup writes a new snapshot and raises `ReportUpdated`, `RepaintAllInspectors` fires and the suffix disappears for the rest of the session. This eliminates the perceived flakiness where the warning sometimes appeared and sometimes didn't, depending on how fast the user clicked into the inspector after a domain reload. +**Eager-load and "cached from previous session" indicator.** The harvester's static constructor synchronously calls `LoadFromDisk` BEFORE scheduling the first scan via `EditorApplication.delayCall`. The on-disk cache populates `SnapshotInternal` immediately, so the inspector overlay can render warnings the moment the user clicks into a `MessageAwareComponent` -- even before the first post-reload scan has had a chance to run. Until that first scan completes (typically within a few `EditorApplication.update` ticks after assembly reload), the harvester's `IsFreshThisSession` flag stays `false` and the overlay annotates each warning with a `(cached from previous session -- refreshing...)` suffix so the user understands the data may be stale. Once the first `RescanNow` post-startup writes a new snapshot and raises `ReportUpdated`, `RepaintAllInspectors` fires and the suffix disappears for the rest of the session. This eliminates the perceived flakiness where the warning sometimes appeared and sometimes didn't, depending on how fast the user clicked into the inspector after a domain reload. --- @@ -427,24 +427,24 @@ Manual edits to `csc.rsp` are rarely necessary; the setup helper detects existin DxMessaging ships **two** Roslyn DLLs because Unity 2021's analyzer loader has a hard requirement that Unity 2022+ does not: -- `WallstopStudios.DxMessaging.Analyzer.dll` — the base-call analyzer (DXMSG006/007/008/009/010). Pinned to **Roslyn 3.8.0**. Unity 2021 silently rejects analyzer DLLs built against Roslyn 4.x; Microsoft's `Microsoft.Unity.Analyzers` package pins 3.8.0 for the same reason. -- `WallstopStudios.DxMessaging.SourceGenerators.dll` — the source generators (DXMSG002/003/004/005). Stays at **Roslyn 4.2.0** because the generators use `IIncrementalGenerator`, which was introduced in Roslyn 4.0. Unity 2021 loads source generators through a different code path that tolerates the 4.x dependency. +- `WallstopStudios.DxMessaging.Analyzer.dll` -- the base-call analyzer (DXMSG006/007/008/009/010). Pinned to **Roslyn 3.8.0**. Unity 2021 silently rejects analyzer DLLs built against Roslyn 4.x; Microsoft's `Microsoft.Unity.Analyzers` package pins 3.8.0 for the same reason. +- `WallstopStudios.DxMessaging.SourceGenerators.dll` -- the source generators (DXMSG002/003/004/005). Stays at **Roslyn 4.2.0** because the generators use `IIncrementalGenerator`, which was introduced in Roslyn 4.0. Unity 2021 loads source generators through a different code path that tolerates the 4.x dependency. Both DLLs are tagged `RoslynAnalyzer` and registered in `csc.rsp`. They live side-by-side in `Editor/Analyzers/`. If you are upgrading from a prior version and DXMSG warnings stop appearing on Unity 2021: -1. Delete the package's `Library/ScriptAssemblies` folder so Unity's compiler cache re-evaluates the analyzer DLL hashes — Unity 2021 caches "rejected analyzer" decisions per-DLL-hash. -1. Reimport the package's `Editor/Analyzers/` folder (right-click → Reimport). -1. Force a clean rebuild via `Tools → DxMessaging → Rescan Base-Call Warnings` after the next compile finishes. +1. Delete the package's `Library/ScriptAssemblies` folder so Unity's compiler cache re-evaluates the analyzer DLL hashes -- Unity 2021 caches "rejected analyzer" decisions per-DLL-hash. +1. Reimport the package's `Editor/Analyzers/` folder (right-click -> Reimport). +1. Force a clean rebuild via `Tools -> DxMessaging -> Rescan Base-Call Warnings` after the next compile finishes. -The Inspector overlay also has a Unity 2021 fallback: a `[CustomEditor(typeof(MessageAwareComponent), editorForChildClasses: true, isFallback: true)]` is registered alongside the cross-version `Editor.finishedDefaultHeaderGUI` hook. User-defined `[CustomEditor]`s for the same component type still win precedence over the fallback — see the [Inspector integration](#inspector-integration) section above. +The Inspector overlay also has a Unity 2021 fallback: a `[CustomEditor(typeof(MessageAwareComponent), editorForChildClasses: true, isFallback: true)]` is registered alongside the cross-version `Editor.finishedDefaultHeaderGUI` hook. User-defined `[CustomEditor]`s for the same component type still win precedence over the fallback -- see the [Inspector integration](#inspector-integration) section above. --- ## See also -- [Troubleshooting](troubleshooting.md) — runtime symptoms and how they map to diagnostics. -- [Inheritance and base calls](../guides/unity-integration.md#important-inheritance-and-base-calls) — the inheritance contract this analyzer enforces. -- [Unity Integration](../guides/unity-integration.md) — broader Unity-side guidance for inheritance and lifecycle. -- [Quick Reference](quick-reference.md) — concise listing of all diagnostic IDs. +- [Troubleshooting](troubleshooting.md) -- runtime symptoms and how they map to diagnostics. +- [Inheritance and base calls](../guides/unity-integration.md#important-inheritance-and-base-calls) -- the inheritance contract this analyzer enforces. +- [Unity Integration](../guides/unity-integration.md) -- broader Unity-side guidance for inheritance and lifecycle. +- [Quick Reference](quick-reference.md) -- concise listing of all diagnostic IDs. diff --git a/docs/reference/compatibility.md b/docs/reference/compatibility.md index d02d62b6..08a1c7e2 100644 --- a/docs/reference/compatibility.md +++ b/docs/reference/compatibility.md @@ -1,19 +1,19 @@ # Compatibility -DxMessaging is render‑pipeline agnostic (pure C#) and targets Unity 2021.3+. The matrix below summarizes support by Unity version and Render Pipeline. +DxMessaging is render-pipeline agnostic (pure C#) and targets Unity 2021.3+. The matrix below summarizes support by Unity version and Render Pipeline. Unity Version vs Render Pipeline -| Unity | Built‑In RP | URP | HDRP | -| ---------- | ------------- | ------------- | ------------- | -| 2021.3 LTS | ✅ Compatible | ✅ Compatible | ✅ Compatible | -| 2022.3 LTS | ✅ Compatible | ✅ Compatible | ✅ Compatible | -| 2023.x | ✅ Compatible | ✅ Compatible | ✅ Compatible | -| 6.x | ✅ Compatible | ✅ Compatible | ✅ Compatible | +| Unity | Built-In RP | URP | HDRP | +| ---------- | ----------- | ---------- | ---------- | +| 2021.3 LTS | Compatible | Compatible | Compatible | +| 2022.3 LTS | Compatible | Compatible | Compatible | +| 2023.x | Compatible | Compatible | Compatible | +| 6.x | Compatible | Compatible | Compatible | Notes -- RP‑agnostic: DxMessaging does not depend on rendering APIs; it works equally across Built‑In, URP, and HDRP. +- RP-agnostic: DxMessaging does not depend on rendering APIs; it works equally across Built-In, URP, and HDRP. - Minimum version is governed by the package manifest (`unity`: 2021.3). Newer LTS versions are expected to work. ## Architecture Pattern Compatibility @@ -24,10 +24,10 @@ DxMessaging can work alongside Scriptable Object Architecture patterns, though S #### Quick summary -- ✅ **Compatible** - DxMessaging can bridge with SOA systems -- ⚠️ **Not recommended** - SOA has scalability and maintainability concerns ([detailed critique](https://github.com/cathei/AntiScriptableObjectArchitecture)) -- ✅ **Best practice** - Use ScriptableObjects for immutable design data, DxMessaging for runtime events -- → See [SOA Integration Patterns](../guides/patterns.md#14-compatibility-with-scriptable-object-architecture-soa) for three coexistence strategies with code examples +- [x] **Compatible** - DxMessaging can bridge with SOA systems +- **Not recommended** - SOA has scalability and maintainability concerns ([detailed critique](https://github.com/cathei/AntiScriptableObjectArchitecture)) +- [x] **Best practice** - Use ScriptableObjects for immutable design data, DxMessaging for runtime events +- to See [SOA Integration Patterns](../guides/patterns.md#14-compatibility-with-scriptable-object-architecture-soa) for three coexistence strategies with code examples ### Dependency Injection (DI) Frameworks diff --git a/docs/reference/faq.md b/docs/reference/faq.md index 93d225b8..9f0f6181 100644 --- a/docs/reference/faq.md +++ b/docs/reference/faq.md @@ -1,12 +1,12 @@ -# FAQ — Frequently Asked Questions +# FAQ -- Frequently Asked Questions -[← Back to Index](../getting-started/index.md) | [Troubleshooting](troubleshooting.md) | [Getting Started](../getting-started/getting-started.md) | [Glossary](glossary.md) +[Back to Index](../getting-started/index.md) | [Troubleshooting](troubleshooting.md) | [Getting Started](../getting-started/getting-started.md) | [Glossary](glossary.md) --- ## Do I need to use attributes or source generators -- No. You can implement `IUntargetedMessage`, `ITargetedMessage`, or `IBroadcastMessage` directly (recommended for structs). Attributes are optional and help tooling/source‑gen. +- No. You can implement `IUntargetedMessage`, `ITargetedMessage`, or `IBroadcastMessage` directly (recommended for structs). Attributes are optional and help tooling/source-gen. ## Which message type should I use? @@ -16,11 +16,11 @@ ## How do I enforce ordering? -- Use the `priority` parameter at registration; lower runs earlier. Interceptors run before handlers; post‑processors run after. +- Use the `priority` parameter at registration; lower runs earlier. Interceptors run before handlers; post-processors run after. ## Can I observe all targets/sources for a type? -- Yes. Use `RegisterTargetedWithoutTargeting` or `RegisterBroadcastWithoutSource` (and their post‑processor counterparts). +- Yes. Use `RegisterTargetedWithoutTargeting` or `RegisterBroadcastWithoutSource` (and their post-processor counterparts). ## How do I diagnose what's happening? @@ -29,7 +29,7 @@ ## 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. -- This is called "snapshot semantics" — when a message is emitted, DxMessaging takes a snapshot of all current listeners and uses that frozen list for the entire emission. +- This is called "snapshot semantics" -- when a message is emitted, DxMessaging takes a snapshot of all current listeners and uses that frozen list for the entire emission. - This applies to all listener types (handlers, interceptors, post-processors) and all message categories (Untargeted, Targeted, Broadcast). - This behavior prevents infinite loops and ensures predictable execution order. See [Interceptors & Ordering](../concepts/interceptors-and-ordering.md#snapshot-semantics-frozen-listener-lists) for details and examples. @@ -46,12 +46,12 @@ ## Related Documentation - **New to DxMessaging?** - - → [Visual Guide](../getting-started/visual-guide.md) — Beginner-friendly introduction - - → [Getting Started](../getting-started/getting-started.md) — Complete guide - - → [Glossary](glossary.md) — All terms explained + - to [Visual Guide](../getting-started/visual-guide.md) -- Beginner-friendly introduction + - to [Getting Started](../getting-started/getting-started.md) -- Complete guide + - to [Glossary](glossary.md) -- All terms explained - **Common Issues** - - → [Troubleshooting](troubleshooting.md) — Solutions to common problems - - → [Common Patterns](../guides/patterns.md) — See how to use it correctly + - to [Troubleshooting](troubleshooting.md) -- Solutions to common problems + - to [Common Patterns](../guides/patterns.md) -- See how to use it correctly - **Reference** - - → [Quick Reference](quick-reference.md) — API cheat sheet - - → [Message Types](../concepts/message-types.md) — Which type to use when + - to [Quick Reference](quick-reference.md) -- API cheat sheet + - to [Message Types](../concepts/message-types.md) -- Which type to use when diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 6240c058..83d6f7b3 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -1,6 +1,6 @@ -# Glossary — DxMessaging Terms Explained +# Glossary -- DxMessaging Terms Explained -[← Back to Index](../getting-started/index.md) | [Getting Started](../getting-started/getting-started.md) | [Visual Guide](../getting-started/visual-guide.md) +[Back to Index](../getting-started/index.md) | [Getting Started](../getting-started/getting-started.md) | [Visual Guide](../getting-started/visual-guide.md) --- @@ -38,7 +38,7 @@ void OnHeal(ref Heal msg) { A **registration handle** that manages the lifecycle of your message handlers. It automatically enables/disables handlers when your component is active/inactive. -Think of it like a subscription card — when you destroy it, all your subscriptions end automatically. +Think of it like a subscription card -- when you destroy it, all your subscriptions end automatically. ### MessageAwareComponent @@ -103,7 +103,7 @@ _ = token.RegisterUntargeted(ShowUI, priority: 10); // Runs third A **unique identifier** for a GameObject or Component. Used internally to route messages to the right place. -You rarely use this directly — use the GameObject/Component helpers instead: +You rarely use this directly -- use the GameObject/Component helpers instead: ```csharp msg.EmitGameObjectTargeted(gameObject); // Helper (use this) @@ -254,14 +254,14 @@ public class UI : MessageAwareComponent { ### Learn More -- → [Visual Guide](../getting-started/visual-guide.md) — See these concepts visualized -- → [Getting Started](../getting-started/getting-started.md) — Full introduction with examples -- → [Message Types](../concepts/message-types.md) — When to use each type +- to [Visual Guide](../getting-started/visual-guide.md) -- See these concepts visualized +- to [Getting Started](../getting-started/getting-started.md) -- Full introduction with examples +- to [Message Types](../concepts/message-types.md) -- When to use each type #### Reference -- → [Quick Reference](quick-reference.md) — API cheat sheet -- → [API Reference](reference.md) — Complete API documentation +- to [Quick Reference](quick-reference.md) -- API cheat sheet +- to [API Reference](reference.md) -- Complete API documentation -- → [Mini Combat sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) — See concepts in action -- → [Patterns](../guides/patterns.md) — Real-world usage patterns +- to [Mini Combat sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) -- See concepts in action +- to [Patterns](../guides/patterns.md) -- Real-world usage patterns diff --git a/docs/reference/helpers.md b/docs/reference/helpers.md index 4f22c732..1084a272 100644 --- a/docs/reference/helpers.md +++ b/docs/reference/helpers.md @@ -27,7 +27,7 @@ These tell the source generator what KIND of message you're making: #### `[DxUntargetedMessage]` - Global Messages ```csharp -[DxUntargetedMessage] // ← Tells generator: "This is a global message" +[DxUntargetedMessage] // <- Tells generator: "This is a global message" public readonly partial struct GamePaused { } ``` @@ -40,7 +40,7 @@ public readonly partial struct GamePaused { } #### `[DxTargetedMessage]` - Messages to Specific Targets ```csharp -[DxTargetedMessage] // ← Tells generator: "This goes to one specific target" +[DxTargetedMessage] // <- Tells generator: "This goes to one specific target" public readonly partial struct Heal { public readonly int amount; } @@ -55,7 +55,7 @@ public readonly partial struct Heal { #### `[DxBroadcastMessage]` - Messages from a Source ```csharp -[DxBroadcastMessage] // ← Tells generator: "This broadcasts from a source" +[DxBroadcastMessage] // <- Tells generator: "This broadcasts from a source" public readonly partial struct TookDamage { public readonly int amount; } @@ -75,7 +75,7 @@ public readonly partial struct TookDamage { ```csharp [DxUntargetedMessage] -[DxAutoConstructor] // ← Magic happens here! +[DxAutoConstructor] // <- Magic happens here! public readonly partial struct VideoSettingsChanged { public readonly int width; @@ -162,7 +162,7 @@ var settings4 = new SettingsChanged(0.8f, 2, brightness: 50); // fullscreen=f The attribute provides constructor overloads for all common primitive types: -> ℹ️ **Info: Built-in Type Support** +> **Info: Built-in Type Support** > > **Numeric:** > @@ -395,25 +395,27 @@ public readonly partial struct AudioSettingsChanged audioSettings.Emit(); // Only change music volume -new AudioSettingsChanged(musicVolume: 0.5f).Emit(); +var musicOnly = new AudioSettingsChanged(musicVolume: 0.5f); +musicOnly.Emit(); // Change multiple settings -new AudioSettingsChanged(0.7f, 0.6f, 0.9f, false).Emit(); +var multi = new AudioSettingsChanged(0.7f, 0.6f, 0.9f, false); +multi.Emit(); ``` #### Best Practices -> ✅ **Success: Recommendations** +> **Success: Recommendations** > -> 1. **Order matters** — Place required fields before optional fields in your struct -> 1. **Use meaningful defaults** — Choose defaults that represent the most common use case -> 1. **Prefer explicit values** — Use `[DxOptionalParameter(0)]` instead of `[DxOptionalParameter]` when clarity helps -> 1. **Use Expression for complex types** — Don't fight the type system; use `Expression` for enums and Unity types -> 1. **Document unusual defaults** — If a default isn't obvious, add a comment explaining why +> 1. **Order matters** -- Place required fields before optional fields in your struct +> 1. **Use meaningful defaults** -- Choose defaults that represent the most common use case +> 1. **Prefer explicit values** -- Use `[DxOptionalParameter(0)]` instead of `[DxOptionalParameter]` when clarity helps +> 1. **Use Expression for complex types** -- Don't fight the type system; use `Expression` for enums and Unity types +> 1. **Document unusual defaults** -- If a default isn't obvious, add a comment explaining why ## Why Use Attributes Instead of Manual Implementation -### ✅ Attribute Definition (Clean, Automatic) +### Attribute Definition (Clean, Automatic) ```csharp [DxTargetedMessage] @@ -443,10 +445,10 @@ public readonly partial struct Heal : ITargetedMessage #### Benefits -- ✅ **Less code** - 50% fewer lines -- ✅ **Fewer bugs** - Can't forget fields in constructor -- ✅ **Focused** - Focus on data, not boilerplate -- ✅ **Refactor-safe** - Add field? Constructor updates automatically! +- [x] **Less code** - 50% fewer lines +- [x] **Fewer bugs** - Can't forget fields in constructor +- [x] **Focused** - Focus on data, not boilerplate +- [x] **Refactor-safe** - Add field? Constructor updates automatically! ## Complete Example: Attribute Definition vs Generated Output @@ -488,15 +490,15 @@ public readonly partial struct PlayerDamaged : IBroadcastMessage ### Result -- ✅ Same functionality -- ✅ Less code to maintain -- ✅ Automatically updates when you add/remove fields -- ✅ Works for class messages too -- ✅ Zero effort once you mark the struct partial +- [x] Same functionality +- [x] Less code to maintain +- [x] Automatically updates when you add/remove fields +- [x] Works for class messages too +- [x] Zero effort once you mark the struct partial ## Advanced: Manual Implementation (When Attributes Aren't Enough) -Attributes cover almost every scenario. If you intentionally drop `[DxTargetedMessage]`, `[DxUntargetedMessage]`, or `[DxBroadcastMessage]`, you'll need to hand-write the interface implementations and constructors shown in the “generated output” examples. Keep the attributes unless you have a very specific data-backed reason not to. +Attributes cover almost every scenario. If you intentionally drop `[DxTargetedMessage]`, `[DxUntargetedMessage]`, or `[DxBroadcastMessage]`, you'll need to hand-write the interface implementations and constructors shown in the "generated output" examples. Keep the attributes unless you have a very specific data-backed reason not to. ### Generic Message Interfaces (Zero-Boxing for Structs) @@ -513,7 +515,7 @@ public readonly partial struct Heal ### "Do I HAVE to use attributes?" -Technically no—but without them you must write the constructor, interface implementation, and `MessageType` property yourself (for speed, you can optionally leave this off, but it might box on certain call paths). Leaving the attributes on keeps everything consistent for the whole team. +Technically no -- but without them you must write the constructor, interface implementation, and `MessageType` property yourself (for speed, you can optionally leave this off, but it might box on certain call paths). Leaving the attributes on keeps everything consistent for the whole team. ```csharp [DxUntargetedMessage] @@ -562,7 +564,7 @@ public readonly partial struct MessageA DxMessaging provides extension methods to make emitting messages easy: ```csharp -using DxMessaging.Core.Extensions; // ← Don't forget this! +using DxMessaging.Core.Extensions; // <- Don't forget this! using UnityEngine; // Untargeted (global) @@ -679,7 +681,7 @@ public readonly partial struct Damage Makes a constructor parameter optional. Three usage patterns: -> 📋 **Example: Default Value (type default)** +> **Example: Default Value (type default)** > > Uses the type's default value (`0`, `false`, `null`, etc.): > @@ -689,7 +691,7 @@ Makes a constructor parameter optional. Three usage patterns: > // Generates: bool flag = default > ``` > -> 📋 **Example: Custom Value (primitive)** +> **Example: Custom Value (primitive)** > > Provides a specific default value: > @@ -699,7 +701,7 @@ Makes a constructor parameter optional. Three usage patterns: > // Generates: int count = 42 > ``` > -> 📋 **Example: Expression (complex types)** +> **Example: Expression (complex types)** > > Uses a verbatim expression for enums, Unity types, etc.: > @@ -713,9 +715,9 @@ Makes a constructor parameter optional. Three usage patterns: These attributes work with: -- **Top-level types** — public structs and classes -- **Nested types** — types inside other classes -- **Internal types** — assembly-private messages +- **Top-level types** -- public structs and classes +- **Nested types** -- types inside other classes +- **Internal types** -- assembly-private messages ## Common Patterns with Attributes @@ -901,7 +903,7 @@ If you truly need to hand-craft the constructor, drop `[DxAutoConstructor]` for ### "Can I mix attributes and manual implementation?" -Yes. Attribute-driven messages happily coexist with any legacy manual messages or string messages you already emit. Convert types gradually—one message at a time: +Yes. Attribute-driven messages happily coexist with any legacy manual messages or string messages you already emit. Convert types gradually -- one message at a time: ```csharp [DxUntargetedMessage] @@ -916,44 +918,44 @@ public readonly partial struct MessageA ## Troubleshooting Source Generators -> ⚠️ **Warning: Attributes not working / code not generated** +> **Warning: Attributes not working / code not generated** > > ### Checklist > -> 1. ✅ Is type marked `partial`? -> 1. ✅ Did you rebuild the project? -> 1. ✅ Is Unity 2021.3+ (Roslyn source generator support)? -> 1. ✅ Check `obj/` folder for `.g.cs` files +> 1. Is type marked `partial`? +> 1. Did you rebuild the project? +> 1. Is Unity 2021.3+ (Roslyn source generator support)? +> 1. Check `obj/` folder for `.g.cs` files > > #### Fix > > ```csharp -> // ❌ Missing partial, will not compile +> // Missing partial, will not compile > [DxAutoConstructor] > public readonly struct MyMsg { } > -> // ✅ Correct +> // Correct > [DxAutoConstructor] > public readonly partial struct MyMsg { } > ``` > -> ⚠️ **Warning: Constructor not generated** +> **Warning: Constructor not generated** > > **Cause:** No public fields to generate from > > ```csharp -> // ❌ No public fields +> // No public fields > [DxAutoConstructor] > public readonly partial struct Empty { } > -> // ✅ Has public field +> // Has public field > [DxAutoConstructor] > public readonly partial struct WithData { > public readonly int value; > } > ``` > -> ⚠️ **Warning: Unity can't find generated code** +> **Warning: Unity can't find generated code** > > ##### Solution > @@ -964,10 +966,10 @@ public readonly partial struct MessageA ## Related Documentation -- **[API Reference](reference.md)** — Complete API documentation -- **[Message Types](../concepts/message-types.md)** — When to use Untargeted/Targeted/Broadcast -- **[Quick Reference](quick-reference.md)** — Cheat sheet -- **[Design & Architecture](../architecture/design-and-architecture.md)** — How source generation works internally +- **[API Reference](reference.md)** -- Complete API documentation +- **[Message Types](../concepts/message-types.md)** -- When to use Untargeted/Targeted/Broadcast +- **[Quick Reference](quick-reference.md)** -- Cheat sheet +- **[Design & Architecture](../architecture/design-and-architecture.md)** -- How source generation works internally ## Summary @@ -975,15 +977,15 @@ public readonly partial struct MessageA #### Use attributes for -- ✅ Clean, maintainable code -- ✅ Automatic constructor generation -- ✅ Zero boilerplate -- ✅ Refactor safety +- [x] Clean, maintainable code +- [x] Automatic constructor generation +- [x] Zero boilerplate +- [x] Refactor safety ##### Use manual implementation for -- ✅ Custom constructor logic -- ✅ Explicit control -- ✅ Understanding exactly what happens +- [x] Custom constructor logic +- [x] Explicit control +- [x] Understanding exactly what happens **Recommendation:** Start with attributes (they cover most cases), switch to manual only when needed. diff --git a/docs/reference/quick-reference.md b/docs/reference/quick-reference.md index 5deeae42..0fcae256 100644 --- a/docs/reference/quick-reference.md +++ b/docs/reference/quick-reference.md @@ -2,7 +2,7 @@ Use this as a rapid guide to define/emit/listen and manage lifecycles. -Do’s +Do's - Use attributes + `DxAutoConstructor` for clarity (or interfaces on structs for perf). - Bind struct messages to a variable before emitting. @@ -13,11 +13,11 @@ Do’s ## Don'ts -- Don’t emit from temporaries; use a local variable (e.g., `var msg = new M(...); msg.Emit();`). -- Don’t mix Component vs GameObject targeting if you expect matches (see targeting notes below). -- Don’t register in Update; use `Awake` for staging + `OnEnable`/`OnDisable` for lifecycle. -- Don’t forget base calls when inheriting from `MessageAwareComponent` — call `base.RegisterMessageHandlers()` and `base.OnEnable()`/`base.OnDisable()`. -- Don’t hide Unity methods with `new` (e.g., `new void OnEnable()`); prefer `override` and call `base.*`. +- Don't emit from temporaries; use a local variable (e.g., `var msg = new M(...); msg.Emit();`). +- Don't mix Component vs GameObject targeting if you expect matches (see targeting notes below). +- Don't register in Update; use `Awake` for staging + `OnEnable`/`OnDisable` for lifecycle. +- Don't forget base calls when inheriting from `MessageAwareComponent` -- call `base.RegisterMessageHandlers()` and `base.OnEnable()`/`base.OnDisable()`. +- Don't hide Unity methods with `new` (e.g., `new void OnEnable()`); prefer `override` and call `base.*`. ## Define messages @@ -108,7 +108,7 @@ public sealed class DamageSystem : IStartable, IDisposable Tip: Define `ZENJECT_PRESENT`, `VCONTAINER_PRESENT`, or `REFLEX_PRESENT` to enable the optional shims under [Runtime/Unity/Integrations](https://github.com/wallstop/DxMessaging/tree/master/Runtime/Unity/Integrations) that bind the builder automatically for those containers. -## Interceptors and post‑processors +## Interceptors and post-processors ```csharp using DxMessaging.Core; // MessageHandler @@ -143,7 +143,7 @@ void OnDisable() { token.Disable(); } - A targeted message matches if the emitted `InstanceId` equals the registered `InstanceId`. - Registering for a Component target listens for messages targeted at that specific Component. - Registering for a GameObject target listens for messages targeted at that GameObject. -- Emitting to a GameObject will not reach Component‑targeted listeners (and vice‑versa). Use the matching helper. +- Emitting to a GameObject will not reach Component-targeted listeners (and vice-versa). Use the matching helper. - Shorthands exist for strings too; be explicit about using a GameObject vs Component with `EmitAt`/`EmitFrom`. ## See also @@ -158,23 +158,23 @@ void OnDisable() { token.Disable(); } ### Untargeted ```text -Interceptors → Global Accept-All → Handlers → Post-Processors +Interceptors -> Global Accept-All -> Handlers -> Post-Processors ``` ### Targeted ```text -Interceptors → Global Accept-All → Handlers @ target - → Handlers (All Targets) → Post-Processors @ target - → Post-Processors (All Targets) +Interceptors -> Global Accept-All -> Handlers @ target + -> Handlers (All Targets) -> Post-Processors @ target + -> Post-Processors (All Targets) ``` ### Broadcast ```text -Interceptors → Global Accept-All → Handlers @ source - → Handlers (All Sources) → Post-Processors @ source - → Post-Processors (All Sources) +Interceptors -> Global Accept-All -> Handlers @ source + -> Handlers (All Sources) -> Post-Processors @ source + -> Post-Processors (All Sources) ``` > 📝 **Note: Priority Rules** diff --git a/docs/reference/reference.md b/docs/reference/reference.md index 31a40c68..d052ec21 100644 --- a/docs/reference/reference.md +++ b/docs/reference/reference.md @@ -24,7 +24,7 @@ token.RemoveRegistration(handle); ### Untargeted Message Registration -Register handlers for messages that have no specific target—system-wide events. +Register handlers for messages that have no specific target -- system-wide events. ```csharp // Standard handler (allocation-friendly for simple cases) @@ -329,7 +329,7 @@ public abstract class MessageAwareComponent : MessagingComponent } ``` -> ⚠️ **Warning: Inheritance Tip** +> **Warning: Inheritance Tip** > > If you override any lifecycle hooks (`Awake`, `OnDestroy`, `OnEnable`, `OnDisable`) or `RegisterMessageHandlers`, always call the base method: > diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 3a4c1a88..63cab250 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -1,6 +1,6 @@ -# Troubleshooting — Common Issues & Solutions +# Troubleshooting -- Common Issues & Solutions -[← Back to Index](../getting-started/index.md) | [FAQ](faq.md) | [Getting Started](../getting-started/getting-started.md) | [Glossary](glossary.md) +[Back to Index](../getting-started/index.md) | [FAQ](faq.md) | [Getting Started](../getting-started/getting-started.md) | [Glossary](glossary.md) --- @@ -28,7 +28,7 @@ Unexpected ordering - Check `priority` values on registrations; lower runs earlier. Same priority is registration order. - Interceptors always precede handlers and can cancel; confirm interceptors return `true`. -Double registration or over‑deregistration warnings +Double registration or over-deregistration warnings - Avoid calling stage/enable multiple times; pair registrations and lifecycles consistently. - Review logs with `bus.Log.Enabled = true` to see the registration history. @@ -36,7 +36,7 @@ Double registration or over‑deregistration warnings Allocations/boxing - Prefer struct messages implementing the generic interfaces: `I*Message`. -- Use by‑ref handler overloads to avoid copies. +- Use by-ref handler overloads to avoid copies. Emitting while disabled @@ -51,13 +51,13 @@ Diagnostics overhead ## Related Documentation - **Get Unstuck** - - → [FAQ](faq.md) — Common questions answered - - → [Getting Started](../getting-started/getting-started.md) — Learn the basics - - → [Glossary](glossary.md) — Understand the terminology + - to [FAQ](faq.md) -- Common questions answered + - to [Getting Started](../getting-started/getting-started.md) -- Learn the basics + - to [Glossary](glossary.md) -- Understand the terminology - **Debug & Inspect** - - → [Diagnostics](../guides/diagnostics.md) — Inspector tools and debugging - - → [Listening Patterns](../concepts/listening-patterns.md) — Verify you're listening correctly - - → [Message Types](../concepts/message-types.md) — Ensure you're using the right type + - to [Diagnostics](../guides/diagnostics.md) -- Inspector tools and debugging + - to [Listening Patterns](../concepts/listening-patterns.md) -- Verify you're listening correctly + - to [Message Types](../concepts/message-types.md) -- Ensure you're using the right type - **Examples** - - → [Mini Combat sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) — See working code - - → [Common Patterns](../guides/patterns.md) — Real-world solutions + - to [Mini Combat sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) -- See working code + - to [Common Patterns](../guides/patterns.md) -- Real-world solutions diff --git a/llms.txt b/llms.txt index 0133a24f..9a479f8c 100644 --- a/llms.txt +++ b/llms.txt @@ -54,7 +54,7 @@ DxMessaging is a high-performance messaging library for Unity (v2021.3+) that re ### Message Flow ```text -Emitter → MessageBus → Interceptors → Handlers (by priority) +Emitter > MessageBus > Interceptors > Handlers (by priority) ``` ## Project Structure @@ -180,7 +180,7 @@ dotnet tool run csharpier . dotnet build SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.csproj # Run tests (Unity Test Runner) -# Open Unity 2021.3+ project → Window → Test Runner → PlayMode +# Open Unity 2021.3+ project > Window > Test Runner > PlayMode # Format markdown npm run format:md @@ -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)** - 132+ specialized skill documents covering: +- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 135+ specialized skill documents covering: - **documentation/** - **github-actions/** - **packaging/** @@ -286,5 +286,5 @@ Copyright (c) 2017-2026 Wallstop Studios --- -**Last Updated:** 2026-04-29 +**Last Updated:** 2026-05-01 **Generated by:** scripts/update-llms-txt.js using package.json v2.2.0 and .llm/skills metadata diff --git a/mkdocs.yml b/mkdocs.yml index ba7399fc..06fe8561 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,8 +56,10 @@ theme: repo: fontawesome/brands/github # Extensions - Full suite for rich documentation + markdown_extensions: # Python Markdown core extensions + - abbr - admonition - attr_list @@ -73,6 +75,7 @@ markdown_extensions: case: lower # PyMdownx extensions + - pymdownx.arithmatex: generic: true - pymdownx.betterem: @@ -111,10 +114,12 @@ markdown_extensions: - pymdownx.tilde # Hooks for source code link transformation + hooks: - docs/hooks.py # Plugins - Production-ready suite + plugins: - search: separator: '[\s\-,:!=\[\]()"/]+|(?!\b)(?=[A-Z][a-z])|\.(?!\d)|&[lg]t;' @@ -133,6 +138,7 @@ plugins: remove_comments: true # Extra configuration + extra: social: - icon: fontawesome/brands/github @@ -149,6 +155,7 @@ extra_javascript: - javascripts/csharp-highlight.js # Navigation structure + nav: - Home: index.md - Getting Started: @@ -170,6 +177,7 @@ nav: - Unity Integration: guides/unity-integration.md - Testing: guides/testing.md - Diagnostics: guides/diagnostics.md + - Inspector Overlay & Base-Call Warnings: guides/inspector-overlay.md - Advanced Topics: guides/advanced.md - Migration Guide: guides/migration-guide.md - Architecture: diff --git a/package.json b/package.json index 8699bf73..d64ec9a3 100644 --- a/package.json +++ b/package.json @@ -74,10 +74,12 @@ "update:llms-txt": "node scripts/update-llms-txt.js", "check:llms-txt": "node scripts/update-llms-txt.js --check", "validate:llms-txt": "npm run test:llms-txt && npm run check:llms-txt", + "validate:changelog": "node scripts/validate-changelog.js", + "validate:changelog:coverage": "node scripts/validate-changelog.js --check-coverage", "validate:npm-meta": "node scripts/validate-npm-meta.js --check", "validate:pre-commit-tooling": "node scripts/validate-pre-commit-tooling.js", "validate:vscode-settings": "node scripts/validate-vscode-settings.js", - "preflight:pre-commit": "npm run check:package-json-format && npm run check:prettier:hooks && npm run validate:pre-commit-tooling && npm run check:cspell:scripts && npm run check:yaml && node scripts/generate-skills-index.js --check && npm run validate:npm-meta && pre-commit run script-parser-tests --all-files" + "preflight:pre-commit": "npm run check:package-json-format && npm run check:prettier:hooks && npm run validate:pre-commit-tooling && npm run check:cspell:scripts && npm run check:yaml && node scripts/generate-skills-index.js --check && npm run validate:npm-meta && npm run validate:changelog:coverage && pre-commit run script-parser-tests --all-files" }, "devDependencies": { "jest": "^30.3.0", diff --git a/scripts/__tests__/pre-commit-hook-stage-policy.test.js b/scripts/__tests__/pre-commit-hook-stage-policy.test.js index b60dc47f..3403604d 100644 --- a/scripts/__tests__/pre-commit-hook-stage-policy.test.js +++ b/scripts/__tests__/pre-commit-hook-stage-policy.test.js @@ -109,6 +109,20 @@ describe("pre-commit hook stage policy", () => { expect(stages).toEqual(expect.arrayContaining(["pre-commit", "pre-push"])); }); + test("validate-changelog-policy hook runs at pre-commit/pre-push and excludes internal Editor code", () => { + const changelogPolicyBlock = findHookBlock(configLines, "validate-changelog-policy"); + expect(changelogPolicyBlock).not.toBeNull(); + + const stages = extractStagesFromHookBlock(changelogPolicyBlock); + expect(stages).toEqual(expect.arrayContaining(["pre-commit", "pre-push"])); + + const blockText = changelogPolicyBlock.lines.join("\n"); + expect(blockText).toContain("entry: node scripts/validate-changelog.js --check-coverage"); + expect(blockText).toContain("pass_filenames: false"); + expect(blockText).toContain("files: '^(CHANGELOG\\.md|Runtime/|SourceGenerators/|Samples~/|Editor/)'"); + expect(blockText).toMatch(/exclude:\s*['\"]\^Editor\/\(Analyzers\|Testing\)\/['\"]/); + }); + test("fix-csharp-underscore-methods hook runs at pre-commit", () => { const fixerBlock = findHookBlock(configLines, "fix-csharp-underscore-methods"); expect(fixerBlock).not.toBeNull(); @@ -118,7 +132,7 @@ describe("pre-commit hook stage policy", () => { const blockText = fixerBlock.lines.join("\n"); expect(blockText).toContain("scripts/fix-csharp-underscore-methods.js"); - expect(blockText).toContain("git add \"$@\""); + expect(blockText).toContain("git diff --quiet -- \"$@\" || git add \"$@\""); expect(blockText).not.toContain("|| true"); expect(blockText).not.toContain("|| echo"); }); @@ -134,5 +148,6 @@ describe("pre-commit hook stage policy", () => { expect(blockText).toContain("scripts/__tests__/shell-command.test.js"); expect(blockText).toContain("scripts/__tests__/detect-shell-redirection-antipattern.test.js"); expect(blockText).toContain("scripts/__tests__/fix-csharp-underscore-methods.test.js"); + expect(blockText).toContain("scripts/__tests__/validate-changelog.test.js"); }); }); diff --git a/scripts/__tests__/validate-changelog.test.js b/scripts/__tests__/validate-changelog.test.js new file mode 100644 index 00000000..fd983774 --- /dev/null +++ b/scripts/__tests__/validate-changelog.test.js @@ -0,0 +1,395 @@ +/** + * @fileoverview Tests for validate-changelog.js policy rules. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const { + parseArgs, + parsePackageVersion, + parseChangelog, + validateStructuralRules, + isLikelyInternalOnlyEntry, + detectCategoryMismatch, + areLikelyMutationPair, + validateHeuristicRules, + isLikelyUserVisiblePath, + parseChangedFilesOutput, + getChangedFilesFromGit, + validateCoverageRule, + validateChangelogPolicy +} = require("../validate-changelog.js"); + +function buildValidChangelog(version = "2.2.0") { + return [ + "# Changelog", + "", + "## [Unreleased]", + "", + "### Added", + "", + "- Added message diagnostics in the inspector for users", + "", + `## [${version}]`, + "", + "### Fixed", + "", + "- Fixed a crash when listeners are disposed during dispatch", + "" + ].join("\n"); +} + +describe("validate-changelog", () => { + describe("parseArgs", () => { + test("parses coverage and changed-file arguments", () => { + const options = parseArgs([ + "--check-coverage", + "--changed-file", + "Runtime/Core/File.cs", + "Editor\\CustomEditors\\View.cs" + ]); + + expect(options.checkCoverage).toBe(true); + expect(options.changedFiles).toEqual([ + "Runtime/Core/File.cs", + "Editor/CustomEditors/View.cs" + ]); + }); + + test("throws on unknown flag", () => { + expect(() => parseArgs(["--does-not-exist"])).toThrow("Unknown argument"); + }); + }); + + describe("parsePackageVersion", () => { + test("returns package version", () => { + const version = parsePackageVersion('{"name":"pkg","version":"2.2.0"}'); + expect(version).toBe("2.2.0"); + }); + + test("throws when package version is missing", () => { + expect(() => parsePackageVersion('{"name":"pkg"}')).toThrow("missing a non-empty version"); + }); + }); + + describe("parseChangelog", () => { + test("parses sections, categories, and entries", () => { + const parsed = parseChangelog(buildValidChangelog()); + + expect(parsed.sections.map((section) => section.version)).toEqual(["Unreleased", "2.2.0"]); + expect(parsed.entries).toHaveLength(2); + expect(parsed.entries[0]).toEqual( + expect.objectContaining({ + version: "Unreleased", + category: "Added" + }) + ); + }); + + test("parses wrapped list item lines as one entry", () => { + const changelog = [ + "# Changelog", + "", + "## [Unreleased]", + "", + "### Added", + "", + "- Inspector overlay now shows cached analyzer report immediately", + " and refreshes after domain reload with a status label.", + "", + "## [2.2.0]" + ].join("\n"); + + const parsed = parseChangelog(changelog); + expect(parsed.entries).toHaveLength(1); + expect(parsed.entries[0].text).toContain("cached analyzer report immediately and refreshes"); + }); + }); + + describe("validateStructuralRules", () => { + test("detects missing Unreleased section", () => { + const parsed = parseChangelog( + ["# Changelog", "", "## [2.2.0]", "", "### Added", "", "- Added feature"].join("\n") + ); + + const errors = validateStructuralRules(parsed, "2.2.0"); + expect(errors.some((error) => error.code === "E001")).toBe(true); + }); + + test("detects missing package version section", () => { + const parsed = parseChangelog(buildValidChangelog("2.1.9")); + const errors = validateStructuralRules(parsed, "2.2.0"); + + expect(errors.some((error) => error.code === "E002")).toBe(true); + }); + + test("detects invalid category", () => { + const parsed = parseChangelog( + [ + "# Changelog", + "", + "## [Unreleased]", + "", + "### Additional", + "", + "- typo category", + "", + "## [2.2.0]" + ].join("\n") + ); + + const errors = validateStructuralRules(parsed, "2.2.0"); + expect(errors.some((error) => error.code === "E003")).toBe(true); + }); + }); + + describe("heuristics", () => { + test("flags likely internal-only entry", () => { + expect(isLikelyInternalOnlyEntry("Regenerated corrupted meta files in scripts/wiki")).toBe( + true + ); + expect( + isLikelyInternalOnlyEntry("Inspector overlay now shows cached analyzer report to users") + ).toBe(false); + }); + + test("flags automation and agent phrasing as likely internal-only", () => { + expect( + isLikelyInternalOnlyEntry("Added automation instructions for agent prompt routing") + ).toBe(true); + expect(isLikelyInternalOnlyEntry("Added large language model context scaffolding")).toBe( + true + ); + }); + + test("detects category mismatch using entry prefix", () => { + const mismatch = detectCategoryMismatch({ + category: "Fixed", + text: "Added npmignore for proper npm publishing" + }); + + expect(mismatch).toBe(true); + }); + + test("detects likely mutation pair via shared symbol", () => { + const addedEntry = { + text: "Added `MessageAwareComponent` fallback diagnostics in the inspector." + }; + const fixedEntry = { + text: "Fixed `MessageAwareComponent` fallback diagnostics not appearing after reload." + }; + + expect(areLikelyMutationPair(addedEntry, fixedEntry)).toBe(true); + }); + + test("does not detect mutation for unrelated entries", () => { + const addedEntry = { + text: "Added dependency injection sample scenes for container integration." + }; + const fixedEntry = { + text: "Fixed typo in changelog markdown header ordering." + }; + + expect(areLikelyMutationPair(addedEntry, fixedEntry)).toBe(false); + }); + + test("returns warnings for empty Unreleased section", () => { + const parsed = parseChangelog( + [ + "# Changelog", + "", + "## [Unreleased]", + "", + "## [2.2.0]", + "", + "### Added", + "", + "- Added feature" + ].join("\n") + ); + + const warnings = validateHeuristicRules(parsed); + expect(warnings.some((warning) => warning.code === "W001")).toBe(true); + }); + + test("returns mismatch warning and mutation error for Added+Fixed split in Unreleased", () => { + const parsed = parseChangelog( + [ + "# Changelog", + "", + "## [Unreleased]", + "", + "### Added", + "", + "- Added `FooFeature` support for routed dispatch.", + "", + "### Fixed", + "", + "- Added `FooFeature` null-guard for routed dispatch.", + "", + "## [2.2.0]" + ].join("\n") + ); + + const violations = validateHeuristicRules(parsed); + expect(violations.some((violation) => violation.code === "W003")).toBe(true); + expect(violations.some((violation) => violation.code === "E005")).toBe(true); + expect( + violations.some((violation) => violation.code === "E005" && violation.severity === "ERROR") + ).toBe(true); + }); + }); + + describe("coverage checks", () => { + test("recognizes user-visible paths", () => { + expect(isLikelyUserVisiblePath("Runtime/Core/MessageBus.cs")).toBe(true); + expect(isLikelyUserVisiblePath("Editor/CustomEditors/FallbackEditor.cs")).toBe(true); + expect(isLikelyUserVisiblePath("Runtime/Core/MessageBus.cs.meta")).toBe(false); + expect(isLikelyUserVisiblePath("Editor/Analyzers/Analyzer.cs")).toBe(false); + expect(isLikelyUserVisiblePath("scripts/validate-changelog.js")).toBe(false); + expect(isLikelyUserVisiblePath("CHANGELOG.md")).toBe(false); + }); + + test("parses git output with mixed line endings", () => { + const output = "Runtime/Core/A.cs\r\nEditor\\CustomEditors\\B.cs\n\n"; + expect(parseChangedFilesOutput(output)).toEqual([ + "Runtime/Core/A.cs", + "Editor/CustomEditors/B.cs" + ]); + }); + + test("prefers staged files when staged changes exist", () => { + const execFileSyncMock = jest.fn((_command, args) => { + if (args.join(" ") === "diff -M --name-only --cached") { + return "Runtime/Core/MessageBus.cs\n"; + } + + throw new Error(`Unexpected git command: ${args.join(" ")}`); + }); + + const result = getChangedFilesFromGit(execFileSyncMock, {}); + expect(result).toEqual(["Runtime/Core/MessageBus.cs"]); + }); + + test("uses local unstaged and untracked files outside CI when no staged changes exist", () => { + const execFileSyncMock = jest.fn((_command, args) => { + const joined = args.join(" "); + + if (joined === "diff -M --name-only --cached") { + return ""; + } + + if (joined === "diff -M --name-only") { + return "Runtime/Core/MessageBus.cs\nEditor/CustomEditors/MessagingComponentEditor.cs\n"; + } + + if (joined === "ls-files --others --exclude-standard") { + return "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/NewTest.cs\n"; + } + + throw new Error(`Unexpected git command: ${joined}`); + }); + + const result = getChangedFilesFromGit(execFileSyncMock, { CI: "false" }); + expect(result).toEqual([ + "Runtime/Core/MessageBus.cs", + "Editor/CustomEditors/MessagingComponentEditor.cs", + "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/NewTest.cs" + ]); + }); + + test("uses rename-aware PR diff range in CI when staged files are empty", () => { + const execFileSyncMock = jest.fn((_command, args) => { + const joined = args.join(" "); + + if (joined === "diff -M --name-only --cached") { + return ""; + } + + if (joined === "diff -M --name-only origin/main...HEAD") { + return "Runtime/Core/RenamedMessageBus.cs\n"; + } + + throw new Error(`Unexpected git command: ${joined}`); + }); + + const result = getChangedFilesFromGit(execFileSyncMock, { + CI: "true", + GITHUB_ACTIONS: "true", + GITHUB_EVENT_NAME: "pull_request", + GITHUB_BASE_REF: "main" + }); + + expect(result).toEqual(["Runtime/Core/RenamedMessageBus.cs"]); + }); + + test("fails coverage when user-visible files changed without changelog", () => { + const errors = validateCoverageRule([ + "Runtime/Core/MessageBus.cs", + "scripts/validate-changelog.js" + ]); + + expect(errors).toHaveLength(1); + expect(errors[0].code).toBe("E004"); + }); + + test("passes coverage when changelog is updated", () => { + const errors = validateCoverageRule(["Runtime/Core/MessageBus.cs", "CHANGELOG.md"]); + + expect(errors).toHaveLength(0); + }); + }); + + describe("integration", () => { + test("passes valid changelog with no warnings", () => { + const result = validateChangelogPolicy({ + changelogContent: buildValidChangelog(), + packageJsonContent: '{"version":"2.2.0"}', + checkCoverage: true, + changedFiles: ["CHANGELOG.md", "Runtime/Core/MessageBus.cs"] + }); + + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + test("returns structural errors and heuristic warnings together", () => { + const result = validateChangelogPolicy({ + changelogContent: [ + "# Changelog", + "", + "## [Unreleased]", + "", + "### Additional", + "", + "- Regenerated corrupted meta files in scripts/wiki" + ].join("\n"), + packageJsonContent: '{"version":"2.2.0"}', + checkCoverage: true, + changedFiles: ["Runtime/Core/MessageBus.cs"] + }); + + expect(result.errors.some((error) => error.code === "E002")).toBe(true); + expect(result.errors.some((error) => error.code === "E003")).toBe(true); + expect(result.errors.some((error) => error.code === "E004")).toBe(true); + expect(result.warnings.some((warning) => warning.code === "W002")).toBe(true); + }); + + test("validates repository changelog with no errors", () => { + const repoRoot = path.resolve(__dirname, "../.."); + const changelogContent = fs.readFileSync(path.join(repoRoot, "CHANGELOG.md"), "utf8"); + const packageJsonContent = fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"); + + const result = validateChangelogPolicy({ + changelogContent, + packageJsonContent, + checkCoverage: false + }); + + expect(result.errors).toHaveLength(0); + }); + }); +}); diff --git a/scripts/__tests__/validate-changelog.test.js.meta b/scripts/__tests__/validate-changelog.test.js.meta new file mode 100644 index 00000000..b1d5e504 --- /dev/null +++ b/scripts/__tests__/validate-changelog.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: daf5b6a8a9ea9cb4b8975aad89fdea60 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/validate-doc-code-patterns.test.js b/scripts/__tests__/validate-doc-code-patterns.test.js new file mode 100644 index 00000000..f56c1b81 --- /dev/null +++ b/scripts/__tests__/validate-doc-code-patterns.test.js @@ -0,0 +1,206 @@ +/** + * @fileoverview Tests for scripts/validate-doc-code-patterns.js. + * + * Drives the validator as a child process against fixture files and asserts + * exit code + stderr match the documented contract. Coverage focuses on the + * struct-emit-temporary rule because the textual lint is the canonical defense + * for the "new X().Emit()" bug class -- the Roslyn compilation harness cannot + * reliably catch it (stub setup produces CS1510 which must stay ignored). + */ + +"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-doc-code-patterns.js"); +const REPO_ROOT = path.resolve(__dirname, "../.."); + +function runValidator(filePath) { + return childProcess.spawnSync( + process.execPath, + [VALIDATOR_SCRIPT_PATH, "--paths", filePath], + { cwd: REPO_ROOT, encoding: "utf8" } + ); +} + +function withFixture(suffix, contents, callback) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-doc-code-patterns-")); + const filePath = path.join(tempDir, `fixture${suffix}`); + try { + fs.writeFileSync(filePath, contents, "utf8"); + callback(filePath); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +describe("validate-doc-code-patterns", () => { + describe("struct-emit-temporary rule", () => { + test("flags bare 'new X().Emit()' form", () => { + withFixture(".md", "- `new Foo().Emit()`\n", (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(1); + expect(result.stderr).toContain("struct-emit-temporary"); + expect(result.stderr).toContain("new Foo().Emit("); + }); + }); + + test("flags parenthesized '(new X()).Emit()' form (the previously-missed case)", () => { + withFixture(".md", "- `(new Foo()).Emit()`\n", (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(1); + expect(result.stderr).toContain("struct-emit-temporary"); + expect(result.stderr).toContain("(new Foo()).Emit("); + }); + }); + + test("flags 'new X().EmitTargeted(target)' shorthand", () => { + withFixture(".md", "- `new Foo().EmitTargeted(target)`\n", (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(1); + expect(result.stderr).toContain("struct-emit-temporary"); + expect(result.stderr).toContain("EmitTargeted("); + }); + }); + + test("flags namespaced 'new Ns.X().Emit()' form", () => { + withFixture(".md", "- `new MyNs.Foo().Emit()`\n", (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(1); + expect(result.stderr).toContain("struct-emit-temporary"); + expect(result.stderr).toContain("new MyNs.Foo().Emit("); + }); + }); + + test("flags whitespace-variant 'new X () . Emit ( )'", () => { + withFixture(".md", "- `new Foo () . Emit ( )`\n", (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(1); + expect(result.stderr).toContain("struct-emit-temporary"); + }); + }); + + test("flags multi-arg constructor 'new X(a, b).Emit()'", () => { + withFixture(".md", "- `new Foo(arg1, arg2).Emit()`\n", (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(1); + expect(result.stderr).toContain("struct-emit-temporary"); + }); + }); + + test("does NOT flag the correct 'var msg = new X(); msg.Emit();' pattern", () => { + const fixture = [ + "```csharp", + "var msg = new Foo();", + "msg.Emit();", + "```", + "", + ].join("\n"); + withFixture(".md", fixture, (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(0); + expect(result.stdout).toContain("0 violations"); + }); + }); + + test("does NOT flag 'someMethod(new X()).Emit()' (no false positive)", () => { + withFixture(".md", "- `someMethod(new Foo()).Emit()`\n", (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(0); + expect(result.stdout).toContain("0 violations"); + }); + }); + + test("counter-example marker 'won't compile' suppresses match", () => { + withFixture(".md", "- `new Foo().Emit()` won't compile.\n", (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(0); + expect(result.stdout).toContain("0 violations"); + }); + }); + + test("counter-example marker 'will not compile' suppresses match", () => { + withFixture( + ".md", + "- `new Foo().Emit()` -- will not compile.\n", + (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(0); + } + ); + }); + + test("counter-example marker 'does not compile' suppresses match", () => { + withFixture( + ".md", + "- `new Foo().Emit()` does not compile.\n", + (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(0); + } + ); + }); + }); + + describe("baseline behavior", () => { + test("empty file exits 0", () => { + withFixture(".md", "", (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(0); + expect(result.stdout).toContain("0 violations"); + }); + }); + + test("file with only prose and no violations exits 0", () => { + const fixture = [ + "# Heading", + "", + "Plain prose with no offending patterns.", + "", + "```csharp", + "var x = 1;", + "Console.WriteLine(x);", + "```", + "", + ].join("\n"); + withFixture(".md", fixture, (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(0); + }); + }); + }); + + describe("CLI surface", () => { + test("--list-rules prints the configured catalog and exits 0", () => { + 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("struct-emit-temporary"); + expect(result.stdout).toContain("Configured rules:"); + }); + }); +}); + +describe("validate-doc-code-patterns module exports", () => { + const { BANNED_PATTERNS, isCounterExampleLine } = require("../validate-doc-code-patterns.js"); + + test("BANNED_PATTERNS contains struct-emit-temporary", () => { + const ids = BANNED_PATTERNS.map((rule) => rule.id); + expect(ids).toContain("struct-emit-temporary"); + }); + + test("isCounterExampleLine detects all documented marker phrases", () => { + expect(isCounterExampleLine("// won't compile")).toBe(true); + expect(isCounterExampleLine("does not compile")).toBe(true); + expect(isCounterExampleLine("will not compile")).toBe(true); + expect(isCounterExampleLine("do not compile")).toBe(true); + expect(isCounterExampleLine("fails to compile")).toBe(true); + expect(isCounterExampleLine("regular prose")).toBe(false); + }); +}); diff --git a/scripts/__tests__/validate-doc-code-patterns.test.js.meta b/scripts/__tests__/validate-doc-code-patterns.test.js.meta new file mode 100644 index 00000000..66f8a671 --- /dev/null +++ b/scripts/__tests__/validate-doc-code-patterns.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6a152ba51f9534f70c0d6531f87d87fc +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/validate-docs-ascii.test.js b/scripts/__tests__/validate-docs-ascii.test.js new file mode 100644 index 00000000..a5867929 --- /dev/null +++ b/scripts/__tests__/validate-docs-ascii.test.js @@ -0,0 +1,233 @@ +/** + * @fileoverview Tests for scripts/validate-docs-ascii.js. + * + * Drives the validator as a child process against fixture files and asserts + * exit code + stderr match the documented contract. Coverage focuses on: + * - The ASCII-only baseline (printable ASCII passes). + * - Banned-codepoint classes: em-dash, control characters, curly quotes, + * dingbat-range geometric symbols. + * - The callout-position emoji exception ("> ..." prefix). + * + * NOTE: All non-ASCII content is constructed via String.fromCodePoint so this + * source file stays pure ASCII (matching the project's documentation policy). + */ + +"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-ascii.js"); +const REPO_ROOT = path.resolve(__dirname, "../.."); + +// Non-ASCII codepoints constructed at runtime so this source stays ASCII. +const EM_DASH = String.fromCodePoint(0x2014); +const CONTROL_U0005 = String.fromCodePoint(0x0005); +const CURLY_DOUBLE_LEFT = String.fromCodePoint(0x201c); +const CURLY_DOUBLE_RIGHT = String.fromCodePoint(0x201d); +const CURLY_SINGLE_RIGHT = String.fromCodePoint(0x2019); +const WARNING_SIGN = String.fromCodePoint(0x26a0); // dingbat-range warning +const ROCKET_EMOJI = String.fromCodePoint(0x1f680); // U+1F680 rocket +const BOM = String.fromCodePoint(0xfeff); + +function runValidator(filePath) { + return childProcess.spawnSync( + process.execPath, + [VALIDATOR_SCRIPT_PATH, "--paths", filePath], + { cwd: REPO_ROOT, encoding: "utf8" } + ); +} + +function withFixture(suffix, contents, callback) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxmsg-docs-ascii-")); + const filePath = path.join(tempDir, "fixture" + suffix); + try { + fs.writeFileSync(filePath, contents, "utf8"); + callback(filePath); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +describe("validate-docs-ascii", () => { + describe("ASCII baseline", () => { + test("file containing only printable ASCII exits 0", () => { + const fixture = [ + "# Heading", + "", + "Regular ASCII prose with punctuation: a, b, c.", + "", + "```csharp", + "var x = 1;", + "```", + "", + ].join("\n"); + withFixture(".md", fixture, (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(0); + expect(result.stdout).toContain("0 violations"); + }); + }); + + test("empty file exits 0", () => { + withFixture(".md", "", (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(0); + }); + }); + }); + + describe("banned codepoints", () => { + test("em-dash (U+2014) is rejected", () => { + withFixture(".md", "Hello " + EM_DASH + " world\n", (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(1); + expect(result.stderr).toContain("U+2014"); + }); + }); + + test("control character U+0005 is rejected", () => { + withFixture( + ".md", + "Hello " + CONTROL_U0005 + " world\n", + (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(1); + expect(result.stderr).toContain("U+0005"); + } + ); + }); + + test("curly double quotes (U+201C / U+201D) are rejected", () => { + const content = + "Hello " + CURLY_DOUBLE_LEFT + "world" + CURLY_DOUBLE_RIGHT + "\n"; + withFixture(".md", content, (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(/U\+201[CD]/); + }); + }); + + test("curly single quote (U+2019) is rejected", () => { + withFixture( + ".md", + "Hello" + CURLY_SINGLE_RIGHT + "s world\n", + (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(1); + expect(result.stderr).toContain("U+2019"); + } + ); + }); + + test("dingbat warning sign (U+26A0) is rejected outright", () => { + withFixture( + ".md", + "Hello " + WARNING_SIGN + " world\n", + (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(1); + expect(result.stderr).toContain("dingbat"); + } + ); + }); + }); + + describe("emoji policy", () => { + test("real emoji (rocket) in a callout line is allowed", () => { + withFixture( + ".md", + "> " + ROCKET_EMOJI + " Note: deploy in progress.\n", + (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(0); + } + ); + }); + + test("real emoji (rocket) in plain prose (not callout) is rejected", () => { + withFixture( + ".md", + "We launched " + ROCKET_EMOJI + " yesterday.\n", + (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(1); + expect(result.stderr).toContain("emoji outside a callout"); + } + ); + }); + + test("indented callout ' > rocket' is treated as callout", () => { + withFixture( + ".md", + " > " + ROCKET_EMOJI + " Tip: indented callout still counts.\n", + (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(0); + } + ); + }); + }); + + describe("BOM handling", () => { + test("BOM at start of file is tolerated", () => { + withFixture(".md", BOM + "# Heading\n\nProse.\n", (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(0); + }); + }); + + test("BOM mid-file is rejected", () => { + withFixture(".md", "Heading\n" + BOM + " prose\n", (filePath) => { + const result = runValidator(filePath); + expect(result.status).toBe(1); + expect(result.stderr).toContain("U+FEFF"); + }); + }); + }); +}); + +describe("validate-docs-ascii module exports", () => { + const { + classifyChar, + isCalloutLine, + EMOJI_SOFT_CAP, + } = require("../validate-docs-ascii.js"); + + test("classifyChar returns 'ok' for ASCII printable", () => { + const result = classifyChar(0x41, "A", false); + expect(result.kind).toBe("ok"); + }); + + test("classifyChar returns 'banned' for em-dash", () => { + const result = classifyChar(0x2014, "x " + EM_DASH + " y", false); + expect(result.kind).toBe("banned"); + }); + + test("classifyChar returns 'emoji' for emoji on callout line", () => { + const result = classifyChar( + 0x1f680, + "> " + ROCKET_EMOJI + " hello", + false + ); + expect(result.kind).toBe("emoji"); + }); + + test("classifyChar returns 'banned' for emoji on non-callout line", () => { + const result = classifyChar(0x1f680, "hello " + ROCKET_EMOJI, false); + expect(result.kind).toBe("banned"); + }); + + test("isCalloutLine recognizes '>' and indented '>' lines", () => { + expect(isCalloutLine("> foo")).toBe(true); + expect(isCalloutLine(" > foo")).toBe(true); + expect(isCalloutLine("foo > bar")).toBe(false); + }); + + test("EMOJI_SOFT_CAP is exported as a positive integer", () => { + expect(typeof EMOJI_SOFT_CAP).toBe("number"); + expect(EMOJI_SOFT_CAP).toBeGreaterThan(0); + }); +}); diff --git a/scripts/__tests__/validate-docs-ascii.test.js.meta b/scripts/__tests__/validate-docs-ascii.test.js.meta new file mode 100644 index 00000000..867508e4 --- /dev/null +++ b/scripts/__tests__/validate-docs-ascii.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f41e1ec19453334bb0013a6e9958beb8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/validate-pre-commit-tooling.test.js b/scripts/__tests__/validate-pre-commit-tooling.test.js index ca19f2d4..61c5d5d8 100644 --- a/scripts/__tests__/validate-pre-commit-tooling.test.js +++ b/scripts/__tests__/validate-pre-commit-tooling.test.js @@ -9,634 +9,725 @@ const os = require("os"); const path = require("path"); const { - parseHookEntries, - parseHookIds, - hasRequiredParserPrecheckCommand, - hasRequiredPackageJsonFormatCommand, - hasRequiredScriptsCspellCommand, - hasRequiredParserSuiteTestPaths, - hasNpxInstallPolicy, - hasManagedJestInvocation, - hasManagedPrettierInvocation, - validateYamllintPolicy, - validatePrettierVersionResolution, - validatePreflightScriptPolicy, - REQUIRED_PRECHECK_PARSER_COMMAND, - REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, - REQUIRED_SCRIPTS_CSPELL_COMMAND, - REQUIRED_PARSER_SUITE_HOOK_ID, - REQUIRED_PARSER_SUITE_TEST_PATHS, - validateConfigContent, - validateConfigFile, + parseHookEntries, + parseHookIds, + hasRequiredParserPrecheckCommand, + hasRequiredPackageJsonFormatCommand, + hasRequiredScriptsCspellCommand, + hasRequiredChangelogValidationCommand, + hasRequiredParserSuiteTestPaths, + hasNpxInstallPolicy, + hasManagedJestInvocation, + hasManagedPrettierInvocation, + hasGuardedFixerRestagePattern, + validateYamllintPolicy, + validatePrettierVersionResolution, + validatePreflightScriptPolicy, + REQUIRED_PRECHECK_PARSER_COMMAND, + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + REQUIRED_SCRIPTS_CSPELL_COMMAND, + REQUIRED_CHANGELOG_VALIDATION_COMMAND, + REQUIRED_PARSER_SUITE_HOOK_ID, + REQUIRED_PARSER_SUITE_TEST_PATHS, + validateConfigContent, + validateConfigFile } = require("../validate-pre-commit-tooling.js"); describe("validate-pre-commit-tooling", () => { - test("parseHookEntries reads folded and inline entry styles", () => { - const content = [ - "repos:", - " - repo: local", - " hooks:", - " - id: alpha", - " entry: node scripts/alpha.js", - " - id: beta", - " entry: >-", - " npx --yes jest --runTestsByPath scripts/__tests__/beta.test.js", - " scripts/__tests__/gamma.test.js", - ].join("\n"); - - const hooks = parseHookEntries(content); - - expect(hooks).toHaveLength(2); - expect(hooks[0]).toEqual( - expect.objectContaining({ - id: "alpha", - entry: "node scripts/alpha.js", - }) - ); - expect(hooks[1]).toEqual( - expect.objectContaining({ - id: "beta", - entry: "npx --yes jest --runTestsByPath scripts/__tests__/beta.test.js scripts/__tests__/gamma.test.js", - }) - ); - }); - - test("parseHookEntries handles consecutive folded entries", () => { - const content = [ - "repos:", - " - repo: local", - " hooks:", - " - id: alpha", - " entry: >-", - " node scripts/run-managed-jest.js --runTestsByPath", - " scripts/__tests__/alpha.test.js", - " - id: beta", - " entry: >-", - " node scripts/run-managed-jest.js --runTestsByPath", - " scripts/__tests__/beta.test.js", - ].join("\n"); - - const hooks = parseHookEntries(content); - - expect(hooks).toHaveLength(2); - expect(hooks[0]).toEqual( - expect.objectContaining({ - id: "alpha", - entry: "node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/alpha.test.js", - }) - ); - expect(hooks[1]).toEqual( - expect.objectContaining({ - id: "beta", - entry: "node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/beta.test.js", - }) - ); - }); - - test("parseHookIds captures hook ids across repos", () => { - const content = [ - "repos:", - " - repo: https://github.com/adrienverge/yamllint", - " rev: v1.38.0", - " hooks:", - " - id: yamllint", - " - repo: local", - " hooks:", - " - id: alpha", - " entry: node scripts/alpha.js", - ].join("\n"); - - const ids = parseHookIds(content); - - expect(ids).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "yamllint" }), - expect.objectContaining({ id: "alpha" }), - ]) - ); - }); - - test("hasNpxInstallPolicy rejects npx without explicit policy", () => { - const okWithYes = hasNpxInstallPolicy("npx --yes jest --runTestsByPath foo.test.js"); - const okWithNo = hasNpxInstallPolicy("npx --no jest --runTestsByPath foo.test.js"); - const bad = hasNpxInstallPolicy("npx jest --runTestsByPath foo.test.js"); - - expect(okWithYes).toBe(true); - expect(okWithNo).toBe(true); - expect(bad).toBe(false); - }); - - test("hasRequiredParserPrecheckCommand detects parser command as chained step", () => { - const script = [ - "npm run validate:pre-commit-tooling", - "npm run check:prettier:hooks", - REQUIRED_PRECHECK_PARSER_COMMAND, - ].join(" && "); - - expect(hasRequiredParserPrecheckCommand(script)).toBe(true); - }); - - test("hasRequiredParserPrecheckCommand rejects substring-only matches", () => { - const script = "npm run validate:pre-commit-tooling && echo pre-commit run script-parser-tests --all-files"; - - expect(hasRequiredParserPrecheckCommand(script)).toBe(false); - }); - - test("hasRequiredPackageJsonFormatCommand detects package.json format precheck step", () => { - const script = [ - REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, - "npm run check:prettier:hooks", - REQUIRED_PRECHECK_PARSER_COMMAND, - ].join(" && "); - - expect(hasRequiredPackageJsonFormatCommand(script)).toBe(true); - }); - - test("hasRequiredPackageJsonFormatCommand rejects substring-only matches", () => { - const script = "npm run validate:pre-commit-tooling && echo npm run check:package-json-format"; - - expect(hasRequiredPackageJsonFormatCommand(script)).toBe(false); - }); - - test("hasRequiredScriptsCspellCommand detects script cspell command as chained step", () => { - const script = [ - REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, - REQUIRED_SCRIPTS_CSPELL_COMMAND, - REQUIRED_PRECHECK_PARSER_COMMAND, - ].join(" && "); - - expect(hasRequiredScriptsCspellCommand(script)).toBe(true); - }); - - test("hasRequiredScriptsCspellCommand rejects substring-only matches", () => { - const script = "npm run validate:pre-commit-tooling && echo npm run check:cspell:scripts"; - - expect(hasRequiredScriptsCspellCommand(script)).toBe(false); - }); - - test("hasRequiredParserSuiteTestPaths detects required parser regression test path", () => { - const content = [ - "repos:", - " - repo: local", - " hooks:", - ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: >-", - " node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", - ` ${REQUIRED_PARSER_SUITE_TEST_PATHS[0]}`, - ].join("\n"); - - expect(hasRequiredParserSuiteTestPaths(content)).toBe(true); - }); - - test("hasRequiredParserSuiteTestPaths rejects missing required parser regression test path", () => { - const content = [ - "repos:", - " - repo: local", - " hooks:", - ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + test("parseHookEntries reads folded and inline entry styles", () => { + const content = [ + "repos:", + " - repo: local", + " hooks:", + " - id: alpha", + " entry: node scripts/alpha.js", + " - id: beta", + " entry: >-", + " npx --yes jest --runTestsByPath scripts/__tests__/beta.test.js", + " scripts/__tests__/gamma.test.js" + ].join("\n"); + + const hooks = parseHookEntries(content); + + expect(hooks).toHaveLength(2); + expect(hooks[0]).toEqual( + expect.objectContaining({ + id: "alpha", + entry: "node scripts/alpha.js" + }) + ); + expect(hooks[1]).toEqual( + expect.objectContaining({ + id: "beta", + entry: + "npx --yes jest --runTestsByPath scripts/__tests__/beta.test.js scripts/__tests__/gamma.test.js" + }) + ); + }); + + test("parseHookEntries handles consecutive folded entries", () => { + const content = [ + "repos:", + " - repo: local", + " hooks:", + " - id: alpha", + " entry: >-", + " node scripts/run-managed-jest.js --runTestsByPath", + " scripts/__tests__/alpha.test.js", + " - id: beta", + " entry: >-", + " node scripts/run-managed-jest.js --runTestsByPath", + " scripts/__tests__/beta.test.js" + ].join("\n"); + + const hooks = parseHookEntries(content); + + expect(hooks).toHaveLength(2); + expect(hooks[0]).toEqual( + expect.objectContaining({ + id: "alpha", + entry: "node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/alpha.test.js" + }) + ); + expect(hooks[1]).toEqual( + expect.objectContaining({ + id: "beta", + entry: "node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/beta.test.js" + }) + ); + }); + + test("parseHookIds captures hook ids across repos", () => { + const content = [ + "repos:", + " - repo: https://github.com/adrienverge/yamllint", + " rev: v1.38.0", + " hooks:", + " - id: yamllint", + " - repo: local", + " hooks:", + " - id: alpha", + " entry: node scripts/alpha.js" + ].join("\n"); + + const ids = parseHookIds(content); + + expect(ids).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "yamllint" }), + expect.objectContaining({ id: "alpha" }) + ]) + ); + }); + + test("hasNpxInstallPolicy rejects npx without explicit policy", () => { + const okWithYes = hasNpxInstallPolicy("npx --yes jest --runTestsByPath foo.test.js"); + const okWithNo = hasNpxInstallPolicy("npx --no jest --runTestsByPath foo.test.js"); + const bad = hasNpxInstallPolicy("npx jest --runTestsByPath foo.test.js"); + + expect(okWithYes).toBe(true); + expect(okWithNo).toBe(true); + expect(bad).toBe(false); + }); + + test("hasRequiredParserPrecheckCommand detects parser command as chained step", () => { + const script = [ + "npm run validate:pre-commit-tooling", + "npm run check:prettier:hooks", + REQUIRED_PRECHECK_PARSER_COMMAND + ].join(" && "); + + expect(hasRequiredParserPrecheckCommand(script)).toBe(true); + }); + + test("hasRequiredParserPrecheckCommand rejects substring-only matches", () => { + const script = + "npm run validate:pre-commit-tooling && echo pre-commit run script-parser-tests --all-files"; + + expect(hasRequiredParserPrecheckCommand(script)).toBe(false); + }); + + test("hasRequiredPackageJsonFormatCommand detects package.json format precheck step", () => { + const script = [ + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + "npm run check:prettier:hooks", + REQUIRED_PRECHECK_PARSER_COMMAND + ].join(" && "); + + expect(hasRequiredPackageJsonFormatCommand(script)).toBe(true); + }); + + test("hasRequiredPackageJsonFormatCommand rejects substring-only matches", () => { + const script = "npm run validate:pre-commit-tooling && echo npm run check:package-json-format"; + + expect(hasRequiredPackageJsonFormatCommand(script)).toBe(false); + }); + + test("hasRequiredScriptsCspellCommand detects script cspell command as chained step", () => { + const script = [ + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + REQUIRED_SCRIPTS_CSPELL_COMMAND, + REQUIRED_PRECHECK_PARSER_COMMAND + ].join(" && "); + + expect(hasRequiredScriptsCspellCommand(script)).toBe(true); + }); + + test("hasRequiredScriptsCspellCommand rejects substring-only matches", () => { + const script = "npm run validate:pre-commit-tooling && echo npm run check:cspell:scripts"; + + expect(hasRequiredScriptsCspellCommand(script)).toBe(false); + }); + + test("hasRequiredChangelogValidationCommand detects changelog validation step", () => { + const script = [ + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + REQUIRED_SCRIPTS_CSPELL_COMMAND, + REQUIRED_CHANGELOG_VALIDATION_COMMAND, + REQUIRED_PRECHECK_PARSER_COMMAND + ].join(" && "); + + expect(hasRequiredChangelogValidationCommand(script)).toBe(true); + }); + + test("hasRequiredChangelogValidationCommand rejects substring-only matches", () => { + const script = "npm run validate:pre-commit-tooling && echo npm run validate:changelog:coverage"; + + expect(hasRequiredChangelogValidationCommand(script)).toBe(false); + }); + + test("hasRequiredParserSuiteTestPaths detects required parser regression test path", () => { + const requiredParserSuitePaths = REQUIRED_PARSER_SUITE_TEST_PATHS; + const content = [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: >-", + " node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", + ` ${requiredParserSuitePaths.join(" ")}` + ].join("\n"); + + expect(hasRequiredParserSuiteTestPaths(content)).toBe(true); + }); + + test("hasRequiredParserSuiteTestPaths rejects missing required parser regression test path", () => { + const content = [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js" + ].join("\n"); + + expect(hasRequiredParserSuiteTestPaths(content)).toBe(false); + }); + + test("hasManagedJestInvocation detects unmanaged bare jest command", () => { + expect(hasManagedJestInvocation("jest --runTestsByPath foo.test.js")).toBe(false); + expect( + hasManagedJestInvocation("node scripts/run-managed-jest.js --runTestsByPath foo.test.js") + ).toBe(true); + expect(hasManagedJestInvocation("script-tests", "npm run test:scripts")).toBe(false); + expect(hasManagedJestInvocation("script-tests", "node scripts/run-managed-jest.js")).toBe(true); + }); + + test("hasManagedPrettierInvocation requires managed prettier wrapper for prettier hook", () => { + expect(hasManagedPrettierInvocation("prettier", "npx --yes prettier@3.8.3 --write")).toBe( + false + ); + expect( + hasManagedPrettierInvocation("prettier", "node scripts/run-managed-prettier.js --write") + ).toBe(true); + expect(hasManagedPrettierInvocation("other-hook", "npx --yes prettier@3.8.3 --write")).toBe( + true + ); + }); + + test("hasGuardedFixerRestagePattern requires diff-guarded git add for C# fixer hook", () => { + expect( + hasGuardedFixerRestagePattern( + "fix-csharp-underscore-methods", + "bash -c 'node scripts/fix-csharp-underscore-methods.js \"$@\" && git add \"$@\"' --" + ) + ).toBe(false); + + expect( + hasGuardedFixerRestagePattern( + "fix-csharp-underscore-methods", + "bash -c 'node scripts/fix-csharp-underscore-methods.js \"$@\" && { git diff --quiet -- \"$@\" || git add \"$@\"; }' --" + ) + ).toBe(true); + + expect(hasGuardedFixerRestagePattern("another-hook", "git add \"$@\"")).toBe(true); + }); + + test("hasGuardedFixerRestagePattern rejects single-quoted $@ variants", () => { + expect( + hasGuardedFixerRestagePattern( + "fix-csharp-underscore-methods", + "bash -c 'node scripts/fix-csharp-underscore-methods.js \"$@\" && { git diff --quiet -- '\''$@'\'' || git add '\''$@'\''; }' --" + ) + ).toBe(false); + }); + + test("validateConfigContent reports missing npx policy and unmanaged jest", () => { + const content = [ + "repos:", + " - repo: https://github.com/adrienverge/yamllint", + " rev: v1.38.0", + " hooks:", + " - id: yamllint", + " args: [-c, .yamllint.yaml]", + " - repo: local", + " hooks:", + " - id: bad-npx", + " entry: npx jest --runTestsByPath scripts/__tests__/a.test.js", + " - id: bad-jest", + " entry: jest --runTestsByPath scripts/__tests__/b.test.js", + " - id: good", + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/c.test.js" + ].join("\n"); + + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + } + }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js scripts/__tests__/validate-changelog.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js" ].join("\n"); + } - expect(hasRequiredParserSuiteTestPaths(content)).toBe(false); + return ""; }); - test("hasManagedJestInvocation detects unmanaged bare jest command", () => { - expect(hasManagedJestInvocation("jest --runTestsByPath foo.test.js")).toBe(false); - expect(hasManagedJestInvocation("node scripts/run-managed-jest.js --runTestsByPath foo.test.js")).toBe(true); - expect(hasManagedJestInvocation("script-tests", "npm run test:scripts")).toBe(false); - expect(hasManagedJestInvocation("script-tests", "node scripts/run-managed-jest.js")).toBe(true); + const violations = validateConfigContent(content, { + readFileSyncImpl: readFileSyncMock, + packageJsonPath: "/tmp/package.json", + preCommitConfigPath: "/tmp/pre-commit.yaml", + getConfiguredPrettierSpecFn: () => "prettier@3.8.3", + getPinnedPrettierSpecFn: () => "prettier@3.8.3" }); - test("hasManagedPrettierInvocation requires managed prettier wrapper for prettier hook", () => { - expect(hasManagedPrettierInvocation("prettier", "npx --yes prettier@3.8.3 --write")).toBe(false); - expect(hasManagedPrettierInvocation("prettier", "node scripts/run-managed-prettier.js --write")).toBe(true); - expect(hasManagedPrettierInvocation("other-hook", "npx --yes prettier@3.8.3 --write")).toBe(true); - }); - - test("validateConfigContent reports missing npx policy and unmanaged jest", () => { - const content = [ - "repos:", - " - repo: https://github.com/adrienverge/yamllint", - " rev: v1.38.0", - " hooks:", - " - id: yamllint", - " args: [-c, .yamllint.yaml]", - " - repo: local", - " hooks:", - " - id: bad-npx", - " entry: npx jest --runTestsByPath scripts/__tests__/a.test.js", - " - id: bad-jest", - " entry: jest --runTestsByPath scripts/__tests__/b.test.js", - " - id: good", - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/c.test.js", - ].join("\n"); - - const readFileSyncMock = jest.fn((filePath) => { - if (filePath === "/tmp/package.json") { - return JSON.stringify({ - scripts: { - "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, - }, - }); - } - - if (filePath === "/tmp/pre-commit.yaml") { - return [ - "repos:", - " - repo: local", - " hooks:", - ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js", - ].join("\n"); - } - - return ""; - }); - - const violations = validateConfigContent(content, { - readFileSyncImpl: readFileSyncMock, - packageJsonPath: "/tmp/package.json", - preCommitConfigPath: "/tmp/pre-commit.yaml", - getConfiguredPrettierSpecFn: () => "prettier@3.8.3", - getPinnedPrettierSpecFn: () => "prettier@3.8.3", + expect(violations).toHaveLength(3); + expect(violations.filter((violation) => violation.hookId === "bad-npx")).toHaveLength(2); + expect(violations.filter((violation) => violation.hookId === "bad-jest")).toHaveLength(1); + }); + + test("validateYamllintPolicy reports missing yamllint hook", () => { + const content = [ + "repos:", + " - repo: local", + " hooks:", + " - id: alpha", + " entry: node scripts/alpha.js" + ].join("\n"); + + const violations = validateYamllintPolicy(content); + + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("Missing required yamllint hook"); + }); + + test("validateYamllintPolicy rejects conditional skip pattern", () => { + const content = [ + "repos:", + " - repo: local", + " hooks:", + " - id: yamllint", + ' entry: bash -c \'if command -v yamllint >/dev/null 2>&1; then yamllint -c .yamllint.yaml "$@"; else echo "yamllint not installed; skipping"; fi\' --' + ].join("\n"); + + const violations = validateYamllintPolicy(content); + + expect(violations.length).toBeGreaterThanOrEqual(1); + expect( + violations.some((violation) => + violation.message.includes("must not be conditionally skipped") + ) + ).toBe(true); + }); + + test("validatePrettierVersionResolution passes when configured and resolved specs match", () => { + const violations = validatePrettierVersionResolution( + () => "prettier@3.8.3", + () => "prettier@3.8.3" + ); + + expect(violations).toHaveLength(0); + }); + + test("validatePrettierVersionResolution reports mismatch between configured and resolved specs", () => { + const violations = validatePrettierVersionResolution( + () => "prettier@3.8.3", + () => "prettier@3.9.0" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("prettier-version"); + expect(violations[0].message).toContain("must match package.json"); + }); + + test("validatePrettierVersionResolution reports missing configured spec", () => { + const violations = validatePrettierVersionResolution( + () => null, + () => "prettier@3.8.3" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("prettier-version"); + expect(violations[0].message).toContain("Missing pinned prettier version"); + }); + + test("validateConfigFile passes for repository pre-commit config", () => { + const repoConfigPath = path.resolve(__dirname, "../../.pre-commit-config.yaml"); + const configContent = fs.readFileSync(repoConfigPath, "utf8"); + const hooks = parseHookEntries(configContent); + const violations = validateConfigFile(repoConfigPath); + + expect(hooks.length).toBeGreaterThan(0); + expect(violations).toHaveLength(0); + }); + + test("validateConfigContent reports unmanaged prettier hook", () => { + const content = [ + "repos:", + " - repo: https://github.com/adrienverge/yamllint", + " rev: v1.38.0", + " hooks:", + " - id: yamllint", + " args: [-c, .yamllint.yaml]", + " - repo: local", + " hooks:", + " - id: prettier", + " entry: npx --yes prettier@3.8.3 --write" + ].join("\n"); + + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + } }); - - expect(violations).toHaveLength(3); - expect(violations.filter((violation) => violation.hookId === "bad-npx")).toHaveLength(2); - expect(violations.filter((violation) => violation.hookId === "bad-jest")).toHaveLength(1); - }); - - test("validateYamllintPolicy reports missing yamllint hook", () => { - const content = [ - "repos:", - " - repo: local", - " hooks:", - " - id: alpha", - " entry: node scripts/alpha.js", - ].join("\n"); - - const violations = validateYamllintPolicy(content); - - expect(violations).toHaveLength(1); - expect(violations[0].message).toContain("Missing required yamllint hook"); - }); - - test("validateYamllintPolicy rejects conditional skip pattern", () => { - const content = [ - "repos:", - " - repo: local", - " hooks:", - " - id: yamllint", - " entry: bash -c 'if command -v yamllint >/dev/null 2>&1; then yamllint -c .yamllint.yaml \"$@\"; else echo \"yamllint not installed; skipping\"; fi' --", + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js scripts/__tests__/validate-changelog.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js" ].join("\n"); + } - const violations = validateYamllintPolicy(content); - - expect(violations.length).toBeGreaterThanOrEqual(1); - expect( - violations.some((violation) => - violation.message.includes("must not be conditionally skipped") - ) - ).toBe(true); - }); - - test("validatePrettierVersionResolution passes when configured and resolved specs match", () => { - const violations = validatePrettierVersionResolution( - () => "prettier@3.8.3", - () => "prettier@3.8.3" - ); - - expect(violations).toHaveLength(0); - }); - - test("validatePrettierVersionResolution reports mismatch between configured and resolved specs", () => { - const violations = validatePrettierVersionResolution( - () => "prettier@3.8.3", - () => "prettier@3.9.0" - ); - - expect(violations).toHaveLength(1); - expect(violations[0].hookId).toBe("prettier-version"); - expect(violations[0].message).toContain("must match package.json"); - }); - - test("validatePrettierVersionResolution reports missing configured spec", () => { - const violations = validatePrettierVersionResolution( - () => null, - () => "prettier@3.8.3" - ); - - expect(violations).toHaveLength(1); - expect(violations[0].hookId).toBe("prettier-version"); - expect(violations[0].message).toContain("Missing pinned prettier version"); + return ""; }); - test("validateConfigFile passes for repository pre-commit config", () => { - const repoConfigPath = path.resolve(__dirname, "../../.pre-commit-config.yaml"); - const configContent = fs.readFileSync(repoConfigPath, "utf8"); - const hooks = parseHookEntries(configContent); - const violations = validateConfigFile(repoConfigPath); - - expect(hooks.length).toBeGreaterThan(0); - expect(violations).toHaveLength(0); + const violations = validateConfigContent(content, { + readFileSyncImpl: readFileSyncMock, + packageJsonPath: "/tmp/package.json", + preCommitConfigPath: "/tmp/pre-commit.yaml", + getConfiguredPrettierSpecFn: () => "prettier@3.8.3", + getPinnedPrettierSpecFn: () => "prettier@3.8.3" }); - test("validateConfigContent reports unmanaged prettier hook", () => { - const content = [ - "repos:", - " - repo: https://github.com/adrienverge/yamllint", - " rev: v1.38.0", - " hooks:", - " - id: yamllint", - " args: [-c, .yamllint.yaml]", - " - repo: local", - " hooks:", - " - id: prettier", - " entry: npx --yes prettier@3.8.3 --write", - ].join("\n"); - - const readFileSyncMock = jest.fn((filePath) => { - if (filePath === "/tmp/package.json") { - return JSON.stringify({ - scripts: { - "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, - }, - }); - } - - if (filePath === "/tmp/pre-commit.yaml") { - return [ - "repos:", - " - repo: local", - " hooks:", - ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js", - ].join("\n"); - } - - return ""; + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("prettier"); + expect(violations[0].message).toContain("run-managed-prettier.js"); + }); + + test("package preflight script includes YAML, runtime, and portability gates", () => { + const packageJsonPath = path.resolve(__dirname, "../../package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + const preflightScript = packageJson.scripts["preflight:pre-commit"]; + + expect(packageJson.scripts["check:prettier:hooks"]).toContain( + "node scripts/run-managed-prettier.js --check" + ); + expect(preflightScript).toContain(REQUIRED_PACKAGE_JSON_FORMAT_COMMAND); + expect(preflightScript).toContain("npm run check:prettier:hooks"); + expect(preflightScript).toContain(REQUIRED_SCRIPTS_CSPELL_COMMAND); + expect(preflightScript).toContain(REQUIRED_CHANGELOG_VALIDATION_COMMAND); + expect(packageJson.scripts["check:yaml"]).toContain("pre-commit run yamllint --all-files"); + expect(preflightScript).toContain("npm run check:yaml"); + expect(preflightScript).toContain("node scripts/generate-skills-index.js --check"); + expect(preflightScript).toContain("npm run validate:npm-meta"); + expect(preflightScript).toContain(REQUIRED_PRECHECK_PARSER_COMMAND); + expect(preflightScript).not.toContain("node scripts/run-managed-jest.js --runTestsByPath"); + }); + + test("validatePreflightScriptPolicy passes when parser precheck command exists", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + } }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js scripts/__tests__/validate-changelog.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js" + ].join("\n"); + } - const violations = validateConfigContent(content, { - readFileSyncImpl: readFileSyncMock, - packageJsonPath: "/tmp/package.json", - preCommitConfigPath: "/tmp/pre-commit.yaml", - getConfiguredPrettierSpecFn: () => "prettier@3.8.3", - getPinnedPrettierSpecFn: () => "prettier@3.8.3", - }); - - expect(violations).toHaveLength(1); - expect(violations[0].hookId).toBe("prettier"); - expect(violations[0].message).toContain("run-managed-prettier.js"); - }); - - test("package preflight script includes YAML, runtime, and portability gates", () => { - const packageJsonPath = path.resolve(__dirname, "../../package.json"); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - const preflightScript = packageJson.scripts["preflight:pre-commit"]; - - expect(packageJson.scripts["check:prettier:hooks"]).toContain( - "node scripts/run-managed-prettier.js --check" - ); - expect(preflightScript).toContain(REQUIRED_PACKAGE_JSON_FORMAT_COMMAND); - expect(preflightScript).toContain("npm run check:prettier:hooks"); - expect(preflightScript).toContain(REQUIRED_SCRIPTS_CSPELL_COMMAND); - expect(packageJson.scripts["check:yaml"]).toContain( - "pre-commit run yamllint --all-files" - ); - expect(preflightScript).toContain("npm run check:yaml"); - expect(preflightScript).toContain("node scripts/generate-skills-index.js --check"); - expect(preflightScript).toContain("npm run validate:npm-meta"); - expect(preflightScript).toContain(REQUIRED_PRECHECK_PARSER_COMMAND); - expect(preflightScript).not.toContain("node scripts/run-managed-jest.js --runTestsByPath"); + return ""; }); - test("validatePreflightScriptPolicy passes when parser precheck command exists", () => { - const readFileSyncMock = jest.fn((filePath) => { - if (filePath === "/tmp/package.json") { - return JSON.stringify({ - scripts: { - "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, - }, - }); - } - - if (filePath === "/tmp/pre-commit.yaml") { - return [ - "repos:", - " - repo: local", - " hooks:", - ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js", - ].join("\n"); - } - - return ""; + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(0); + expect(readFileSyncMock).toHaveBeenCalledWith("/tmp/package.json", "utf8"); + expect(readFileSyncMock).toHaveBeenCalledWith("/tmp/pre-commit.yaml", "utf8"); + }); + + test("validatePreflightScriptPolicy reports missing parser precheck command", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND}` + } }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js scripts/__tests__/validate-changelog.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js" + ].join("\n"); + } - const violations = validatePreflightScriptPolicy( - readFileSyncMock, - "/tmp/package.json", - "/tmp/pre-commit.yaml" - ); - - expect(violations).toHaveLength(0); - expect(readFileSyncMock).toHaveBeenCalledWith("/tmp/package.json", "utf8"); - expect(readFileSyncMock).toHaveBeenCalledWith("/tmp/pre-commit.yaml", "utf8"); + return ""; }); - test("validatePreflightScriptPolicy reports missing parser precheck command", () => { - const readFileSyncMock = jest.fn((filePath) => { - if (filePath === "/tmp/package.json") { - return JSON.stringify({ - scripts: { - "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND}`, - }, - }); - } - - if (filePath === "/tmp/pre-commit.yaml") { - return [ - "repos:", - " - repo: local", - " hooks:", - ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js", - ].join("\n"); - } - - return ""; + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_PRECHECK_PARSER_COMMAND); + }); + + test("validatePreflightScriptPolicy reports missing package.json format precheck command", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + } }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js scripts/__tests__/validate-changelog.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js" + ].join("\n"); + } - const violations = validatePreflightScriptPolicy( - readFileSyncMock, - "/tmp/package.json", - "/tmp/pre-commit.yaml" - ); - - expect(violations).toHaveLength(1); - expect(violations[0].hookId).toBe("preflight-script"); - expect(violations[0].message).toContain(REQUIRED_PRECHECK_PARSER_COMMAND); + return ""; }); - test("validatePreflightScriptPolicy reports missing package.json format precheck command", () => { - const readFileSyncMock = jest.fn((filePath) => { - if (filePath === "/tmp/package.json") { - return JSON.stringify({ - scripts: { - "preflight:pre-commit": `npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, - }, - }); - } - - if (filePath === "/tmp/pre-commit.yaml") { - return [ - "repos:", - " - repo: local", - " hooks:", - ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js", - ].join("\n"); - } - - return ""; + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_PACKAGE_JSON_FORMAT_COMMAND); + }); + + test("validatePreflightScriptPolicy reports missing scripts cspell precheck command", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + } }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js scripts/__tests__/validate-changelog.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js" + ].join("\n"); + } - const violations = validatePreflightScriptPolicy( - readFileSyncMock, - "/tmp/package.json", - "/tmp/pre-commit.yaml" - ); - - expect(violations).toHaveLength(1); - expect(violations[0].hookId).toBe("preflight-script"); - expect(violations[0].message).toContain(REQUIRED_PACKAGE_JSON_FORMAT_COMMAND); + return ""; }); - test("validatePreflightScriptPolicy reports missing scripts cspell precheck command", () => { - const readFileSyncMock = jest.fn((filePath) => { - if (filePath === "/tmp/package.json") { - return JSON.stringify({ - scripts: { - "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, - }, - }); - } - - if (filePath === "/tmp/pre-commit.yaml") { - return [ - "repos:", - " - repo: local", - " hooks:", - ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js", - ].join("\n"); - } - - return ""; + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_SCRIPTS_CSPELL_COMMAND); + }); + + test("validatePreflightScriptPolicy reports missing changelog validation command", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + } }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js scripts/__tests__/validate-changelog.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js" + ].join("\n"); + } - const violations = validatePreflightScriptPolicy( - readFileSyncMock, - "/tmp/package.json", - "/tmp/pre-commit.yaml" - ); - - expect(violations).toHaveLength(1); - expect(violations[0].hookId).toBe("preflight-script"); - expect(violations[0].message).toContain(REQUIRED_SCRIPTS_CSPELL_COMMAND); + return ""; }); - test("validatePreflightScriptPolicy reports missing parser suite hook", () => { - const readFileSyncMock = jest.fn((filePath) => { - if (filePath === "/tmp/package.json") { - return JSON.stringify({ - scripts: { - "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, - }, - }); - } - - if (filePath === "/tmp/pre-commit.yaml") { - return [ - "repos:", - " - repo: local", - " hooks:", - " - id: alpha", - " entry: node scripts/alpha.js", - ].join("\n"); - } - - return ""; + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_CHANGELOG_VALIDATION_COMMAND); + }); + + test("validatePreflightScriptPolicy reports missing parser suite hook", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + } }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + " - id: alpha", + " entry: node scripts/alpha.js" + ].join("\n"); + } - const violations = validatePreflightScriptPolicy( - readFileSyncMock, - "/tmp/package.json", - "/tmp/pre-commit.yaml" - ); - - expect(violations).toHaveLength(1); - expect(violations[0].hookId).toBe("preflight-script"); - expect(violations[0].message).toContain(REQUIRED_PARSER_SUITE_HOOK_ID); + return ""; }); - test("validatePreflightScriptPolicy reports missing required parser regression test path", () => { - const readFileSyncMock = jest.fn((filePath) => { - if (filePath === "/tmp/package.json") { - return JSON.stringify({ - scripts: { - "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}`, - }, - }); - } - - if (filePath === "/tmp/pre-commit.yaml") { - return [ - "repos:", - " - repo: local", - " hooks:", - ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, - " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js", - ].join("\n"); - } - - return ""; + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_PARSER_SUITE_HOOK_ID); + }); + + test("validatePreflightScriptPolicy reports missing required parser regression test path", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": `${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + } }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js" + ].join("\n"); + } - const violations = validatePreflightScriptPolicy( - readFileSyncMock, - "/tmp/package.json", - "/tmp/pre-commit.yaml" - ); - - expect(violations).toHaveLength(1); - expect(violations[0].hookId).toBe("preflight-script"); - expect(violations[0].message).toContain(REQUIRED_PARSER_SUITE_TEST_PATHS[0]); + return ""; }); - test("validateConfigFile handles CRLF and lone CR line endings", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pre-commit-tooling-")); - const filePath = path.join(tempDir, ".pre-commit-config.yaml"); - - try { - const content = [ - "repos:", - " - repo: local", - " hooks:", - " - id: bad", - " entry: npx jest --runTestsByPath scripts/__tests__/a.test.js", - ].join("\r"); - - fs.writeFileSync(filePath, content, "utf8"); - const violations = validateConfigFile(filePath); - - expect(violations).toHaveLength(4); - expect(violations.filter((violation) => violation.hookId === "bad")).toHaveLength(2); - expect(violations.some((violation) => violation.hookId === "yamllint")).toBe(true); - expect(violations.some((violation) => violation.hookId === "preflight-script")).toBe(true); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }); + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_PARSER_SUITE_TEST_PATHS[0]); + }); + + test("validateConfigFile handles CRLF and lone CR line endings", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pre-commit-tooling-")); + const filePath = path.join(tempDir, ".pre-commit-config.yaml"); + + try { + const content = [ + "repos:", + " - repo: local", + " hooks:", + " - id: bad", + " entry: npx jest --runTestsByPath scripts/__tests__/a.test.js" + ].join("\r"); + + fs.writeFileSync(filePath, content, "utf8"); + const violations = validateConfigFile(filePath); + + expect(violations).toHaveLength(4); + expect(violations.filter((violation) => violation.hookId === "bad")).toHaveLength(2); + expect(violations.some((violation) => violation.hookId === "yamllint")).toBe(true); + expect(violations.some((violation) => violation.hookId === "preflight-script")).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/scripts/__tests__/validate-workflows.test.js b/scripts/__tests__/validate-workflows.test.js index 61c331e0..4bba3129 100644 --- a/scripts/__tests__/validate-workflows.test.js +++ b/scripts/__tests__/validate-workflows.test.js @@ -312,6 +312,49 @@ describe("isGitIgnoredPath", () => { expect(ignored).toBe(false); }); + + test("throws when git binary is unavailable for --no-index check", () => { + const missingGitError = new Error("git is not installed"); + missingGitError.code = "ENOENT"; + + const execFileSyncMock = jest.fn().mockImplementationOnce(() => { + throw missingGitError; + }); + + expect(() => + isGitIgnoredPath( + "/repo", + "package-lock.json", + execFileSyncMock + ) + ).toThrow(/git executable was not found on PATH/i); + }); + + test("throws when fallback check-ignore cannot execute due to missing git", () => { + const unsupportedNoIndexError = new Error("unknown option"); + unsupportedNoIndexError.status = 129; + unsupportedNoIndexError.stderr = "error: unknown option `no-index`"; + + const missingGitError = new Error("git is not installed"); + missingGitError.code = "ENOENT"; + + const execFileSyncMock = jest + .fn() + .mockImplementationOnce(() => { + throw unsupportedNoIndexError; + }) + .mockImplementationOnce(() => { + throw missingGitError; + }); + + expect(() => + isGitIgnoredPath( + "/repo", + "package-lock.json", + execFileSyncMock + ) + ).toThrow(/check-ignore fallback/i); + }); }); describe("run block lockfile policy", () => { @@ -812,6 +855,42 @@ describe("validateWorkflow policy integration", () => { } }); + test("uses provided repoRoot when reporting violation file paths", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "validate-workflows-repo-root-")); + try { + const workflowDir = path.join(tempDir, ".github", "workflows"); + fs.mkdirSync(workflowDir, { recursive: true }); + + const workflowPath = path.join(workflowDir, "relative-path.yml"); + fs.writeFileSync( + workflowPath, + [ + "name: Relative Path", + "jobs:", + " lint:", + " runs-on: ubuntu-latest", + " steps:", + " - run: git add --renormalize -- '*.md' '*.json'", + ].join("\n"), + "utf8" + ); + + const violations = validateWorkflow(workflowPath, { + repoRoot: tempDir, + isIgnoredPathFn: () => false, + }); + + expect(violations.length).toBeGreaterThan(0); + expect( + violations.every((violation) => + violation.file === ".github/workflows/relative-path.yml" + ) + ).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + test("current repository workflows pass with no validation errors", () => { const workflowsDir = path.resolve(__dirname, "../../.github/workflows"); const workflowFiles = fs diff --git a/scripts/generate-skills-index.js b/scripts/generate-skills-index.js index 2a2c7b8d..19ac03d4 100644 --- a/scripts/generate-skills-index.js +++ b/scripts/generate-skills-index.js @@ -334,10 +334,10 @@ function loadSkill(skillFile) { */ function getComplexityBadge(level) { const badges = { - basic: "🟢 Basic", - intermediate: "🟡 Intermediate", - advanced: "🟠 Advanced", - expert: "🔴 Expert", + basic: "[basic]", + intermediate: "[intermediate]", + advanced: "[advanced]", + expert: "[expert]", }; return badges[level] || level; } @@ -347,10 +347,10 @@ function getComplexityBadge(level) { */ function getStatusBadge(status) { const badges = { - draft: "📝 Draft", - review: "👀 Review", - stable: "✅ Stable", - deprecated: "⚠️ Deprecated", + draft: "[draft]", + review: "[review]", + stable: "[stable]", + deprecated: "[deprecated]", }; return badges[status] || status; } @@ -360,36 +360,36 @@ function getStatusBadge(status) { */ function getImpactIndicator(rating) { const indicators = { - none: "○○○○○", - low: "●○○○○", - medium: "●●○○○", - high: "●●●○○", - critical: "●●●●●", + none: "[risk: none]", + low: "[risk: low]", + medium: "[risk: medium]", + high: "[risk: high]", + critical: "[risk: critical]", }; return indicators[rating] || "?"; } /** * Get line size indicator based on repository .llm limits. - * < 120: 📝 (short) - * 120-260: ✅ (ideal) - * 261-300: ⚠️ (warning) - * > 300: ❌ (error) + * < 120: [draft] + * 120-260: [ok] + * 261-300: [warn] + * > 300: [over] */ function getLineSizeIndicator(lineCount) { if (typeof lineCount !== "number") { return "?"; } if (lineCount > 300) { - return "❌"; + return "[over]"; } if (lineCount > 260) { - return "⚠️"; + return "[warn]"; } if (lineCount >= 120) { - return "✅"; + return "[ok]"; } - return "📝"; + return "[draft]"; } /** diff --git a/scripts/normalize-docs-ascii.js b/scripts/normalize-docs-ascii.js new file mode 100644 index 00000000..a7222741 --- /dev/null +++ b/scripts/normalize-docs-ascii.js @@ -0,0 +1,575 @@ +#!/usr/bin/env node + +/** + * Normalize docs and XML doc comments to ASCII. + * + * Targets: + * - All .md files + * - All .cs files under Runtime/, Editor/, Tests/, SourceGenerators/ -- but + * only the contents of /// XML doc comment lines are modified. + * + * The substitution table is documented inline below. Most replacements are + * unconditional; bullets and arrows are only replaced outside fenced code + * blocks. Box-drawing characters (U+2500-U+257F) are NEVER auto-replaced -- + * the script reports them and exits non-zero so a human can rewrite the + * affected diagrams. + * + * Real emojis (codepoint >= U+1F300) are preserved. A per-file cap of 5 is + * enforced as a warning only; Phase 3 will codify the policy. + * + * Usage: + * node scripts/normalize-docs-ascii.js [--check] [--paths ] [files...] + * + * Options: + * --check Exit non-zero if changes would be made; do not write. + * --paths Comma-separated list of explicit file paths or directory + * roots to scan. If omitted, the default scan set is used. + * files... Explicit file paths take precedence over --paths. + * + * Exit codes: + * 0 Success (no changes needed, or changes written). + * 1 --check failed (changes would be made), or unrecoverable error. + * 2 Box-drawing characters detected -- manual rewrite required. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const ROOT_DIR = path.resolve(__dirname, ".."); + +const EXCLUDE_DIRS = new Set([ + ".git", + "node_modules", + "Library", + "Temp", + "obj", + "bin", + "Logs", + "site", + "coverage", + "__pycache__", +]); + +// Files that are regenerated by tooling and therefore skipped by the +// normalizer. The cleanup of any non-ASCII content emitted by the generators +// must be handled by updating the generators themselves (out of scope for +// Phase 2 mechanical cleanup). +const GENERATED_FILE_PATHS = new Set([ + path.join(ROOT_DIR, "llms.txt"), + path.join(ROOT_DIR, ".llm", "skills", "index.md"), +]); + +const CS_SCAN_ROOTS = ["Runtime", "Editor", "Tests", "SourceGenerators"]; + +// --- Substitution table ----------------------------------------------------- + +// Substitutions that apply everywhere (including inside fenced code blocks +// and inline code spans -- curly quotes, em-dashes, ellipses, nbsp etc. are +// always wrong in code). +const UNCONDITIONAL_SUBS = [ + { from: /—/g, to: " -- ", id: "em-dash" }, // U+2014 EM DASH + { from: /–/g, to: "-", id: "en-dash" }, // U+2013 EN DASH + { from: /‑/g, to: "-", id: "non-breaking-hyphen" }, // U+2011 + { from: /[“”]/g, to: '"', id: "curly-double-quote" }, + { from: /[‘’]/g, to: "'", id: "curly-single-quote" }, + { from: /…/g, to: "...", id: "ellipsis" }, + { from: /×/g, to: "x", id: "multiplication-sign" }, + { from: /≤/g, to: "<=", id: "less-equal" }, // U+2264 + { from: /≥/g, to: ">=", id: "greater-equal" }, // U+2265 + { from: /≠/g, to: "!=", id: "not-equal" }, // U+2260 + { from: /²/g, to: "^2", id: "superscript-two" }, // U+00B2 + { from: /³/g, to: "^3", id: "superscript-three" }, // U+00B3 + { from: /±/g, to: "+/-", id: "plus-minus" }, // U+00B1 + { from: /›/g, to: ">", id: "single-right-angle" }, // U+203A + { from: /‹/g, to: "<", id: "single-left-angle" }, // U+2039 + { from: /«/g, to: '"', id: "left-double-angle" }, // U+00AB + { from: /»/g, to: '"', id: "right-double-angle" }, // U+00BB + { from: / /g, to: " ", id: "nbsp" }, +]; + +// Substitutions only applied outside fenced code blocks. +const PROSE_ONLY_SUBS = [ + { from: /•/g, to: "-", id: "bullet" }, // U+2022 BULLET +]; + +// Arrow chars: substituted with context awareness. The replacement choice +// depends on local context to avoid producing a leading ">" (which would +// create a markdown blockquote) or breaking link text inside "[]". +const ARROW_RIGHT_RE = /[→⇒]/g; // -> and => +const ARROW_LEFT_RE = /[←⇐]/g; // <- and <= +const ARROW_BOTH_RE = /↔/g; // <-> +const ARROW_UP_RE = /[↑]/g; // ^ + +// Check / cross / warning chars. +const CHECK_RE = /[✓✅]/g; +const CROSS_RE = /[✗✘❌]/g; +const WARNING_RE = /[⚠]️?/g; + +// --- Detection sets --------------------------------------------------------- + +const BOX_DRAWING_RE = /[─-╿]/; +const DINGBAT_RANGE_RE = /[⌀-➿]/; + +// Codepoints in the dingbat range that we already handle elsewhere. +const HANDLED_DINGBATS = new Set([ + 0x2192, 0x21D2, 0x2190, 0x21D0, 0x2194, 0x2191, // arrows + 0x2713, 0x2705, // checks + 0x2717, 0x2718, 0x274C, // crosses + 0x26A0, // warning +]); + +function isHandledDingbat(cp) { + return HANDLED_DINGBATS.has(cp); +} + +// --- File enumeration ------------------------------------------------------- + +function walk(dir, predicate, out) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (error) { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (EXCLUDE_DIRS.has(entry.name)) continue; + walk(full, predicate, out); + } else if (entry.isFile()) { + if (predicate(full)) out.push(full); + } + } +} + +function defaultFileSet() { + const out = []; + walk( + ROOT_DIR, + (p) => { + if (GENERATED_FILE_PATHS.has(p)) return false; + return p.endsWith(".md"); + }, + out, + ); + for (const root of CS_SCAN_ROOTS) { + const abs = path.join(ROOT_DIR, root); + if (!fs.existsSync(abs)) continue; + walk(abs, (p) => p.endsWith(".cs"), out); + } + return out; +} + +// --- Core logic ------------------------------------------------------------- + +/** + * Replace each arrow character one-at-a-time, choosing the replacement based + * on the surrounding text in the (already partially-substituted) string. + */ +function substituteArrows(line) { + let out = line; + out = replaceWithContext(out, ARROW_RIGHT_RE, chooseRightArrowReplacement); + out = replaceWithContext(out, ARROW_LEFT_RE, chooseLeftArrowReplacement); + out = replaceWithContext(out, ARROW_BOTH_RE, chooseBothArrowReplacement); + out = out.replace(ARROW_UP_RE, "^"); + return out; +} + +/** + * Replace all matches of `pattern` in `str`, calling `chooser(currentString, + * matchOffset)` to compute each replacement based on the up-to-date version + * of the string (so earlier replacements affect later context). + */ +function replaceWithContext(str, pattern, chooser) { + let out = str; + while (true) { + pattern.lastIndex = 0; + const match = pattern.exec(out); + if (!match) return out; + const offset = match.index; + const replacement = chooser(out, offset); + out = out.slice(0, offset) + replacement + out.slice(offset + match[0].length); + } +} + +function chooseRightArrowReplacement(line, offset) { + const before = line.slice(0, offset); + const after = line.slice(offset + 1); + + // Inside markdown link text "[...]" -- drop the arrow rather than emit + // text that breaks the visible label. + if (isInsideBrackets(before, after)) { + return ""; + } + + const beforeTrim = before.replace(/\s+$/, ""); + const afterTrim = after.replace(/^\s+/, ""); + const lastBefore = beforeTrim.slice(-1); + const firstAfter = afterTrim.slice(0, 1); + + // Empty before context -- avoid leading ">". + if (beforeTrim.length === 0) { + return ""; + } + + const menuLikeBefore = /[A-Za-z0-9_)\]"'`]/.test(lastBefore); + const menuLikeAfter = /[A-Za-z0-9_(\["'`]/.test(firstAfter); + + if (menuLikeBefore && menuLikeAfter) { + return " -> "; + } + return " to "; +} + +function chooseLeftArrowReplacement(line, offset) { + const before = line.slice(0, offset); + const after = line.slice(offset + 1); + + if (isInsideBrackets(before, after)) { + return ""; + } + + const beforeTrim = before.replace(/\s+$/, ""); + const afterTrim = after.replace(/^\s+/, ""); + const lastBefore = beforeTrim.slice(-1); + const firstAfter = afterTrim.slice(0, 1); + + const menuLikeBefore = /[A-Za-z0-9_)\]"'`]/.test(lastBefore); + const menuLikeAfter = /[A-Za-z0-9_(\["'`]/.test(firstAfter); + + if (menuLikeBefore && menuLikeAfter) { + return " <- "; + } + return " from "; +} + +function chooseBothArrowReplacement(line, offset) { + const before = line.slice(0, offset); + const after = line.slice(offset + 1); + + if (isInsideBrackets(before, after)) { + return ""; + } + return " <-> "; +} + +/** + * True when offset lies inside a "[...]" bracket pair on the same line, with + * no intervening "]" closing the bracket before the offset. + */ +function isInsideBrackets(before, after) { + const lastOpen = before.lastIndexOf("["); + if (lastOpen === -1) return false; + const lastClose = before.lastIndexOf("]"); + if (lastClose > lastOpen) return false; + const closeAhead = after.indexOf("]"); + return closeAhead !== -1; +} + +/** + * Apply check/cross/warning substitutions with context awareness. + * Tables (lines containing "|") and checklist items get word/checkbox forms; + * other prose drops the symbols entirely. + */ +function applyCheckCrossWarning(line) { + let out = line; + + // Checklist context: "- ✓" or "* ✗" at line start. + out = out.replace(/^(\s*[-*]\s+)[✓✅]\s*/u, "$1[x] "); + out = out.replace(/^(\s*[-*]\s+)[✗✘❌]\s*/u, "$1[ ] "); + + if (/\|/.test(out)) { + // Table context: words. + out = out.replace(CHECK_RE, "Yes"); + out = out.replace(CROSS_RE, "No"); + out = out.replace(/^(\s*\|?\s*)⚠️?\s*/u, "$1Warning: "); + out = out.replace(WARNING_RE, ""); + } else { + // Prose context: drop the symbols (callers should already have + // alternative phrasing). + out = out.replace(CHECK_RE, ""); + out = out.replace(CROSS_RE, ""); + out = out.replace(/^(\s*)⚠️?\s*/u, "$1Warning: "); + out = out.replace(WARNING_RE, ""); + } + return out; +} + +/** + * Apply unconditional and prose-only substitutions to a single line. + * + * @param {string} line Line content (no trailing newline). + * @param {boolean} inFence True if currently inside a fenced code block. + * @param {boolean} arrowAware Apply arrow substitutions (off inside fences). + */ +function rewriteLine(line, inFence, arrowAware) { + let out = line; + + for (const sub of UNCONDITIONAL_SUBS) { + out = out.replace(sub.from, sub.to); + } + + if (!inFence) { + for (const sub of PROSE_ONLY_SUBS) { + out = out.replace(sub.from, sub.to); + } + if (arrowAware) { + out = substituteArrows(out); + // Collapse the doubled-space artifacts that result when an em-dash + // or other punctuation already left a space adjacent to the arrow + // chooser's added " > " / " < " / " <-> ". This is prose-only and + // does not run inside fenced code blocks (where shifts/comparisons + // need exact spacing). + out = out.replace(/ > /g, " > "); + out = out.replace(/ < /g, " < "); + out = out.replace(/ <-> /g, " <-> "); + } + out = applyCheckCrossWarning(out); + } else { + // Inside fences we still strip arrows but use simple ASCII forms with + // no contextual logic so we don't alter code semantics. + out = out.replace(ARROW_RIGHT_RE, "->"); + out = out.replace(ARROW_LEFT_RE, "<-"); + out = out.replace(ARROW_BOTH_RE, "<->"); + out = out.replace(ARROW_UP_RE, "^"); + out = out.replace(CHECK_RE, ""); + out = out.replace(CROSS_RE, ""); + out = out.replace(WARNING_RE, ""); + } + + const issues = []; + for (let i = 0; i < out.length; ) { + const cp = out.codePointAt(i); + const ch = String.fromCodePoint(cp); + i += ch.length; + if (cp < 0x80) continue; + if (cp === 0xFEFF) continue; + if (cp === 0xFE0F) continue; + if (cp >= 0x1F300) continue; + if (BOX_DRAWING_RE.test(ch)) { + issues.push({ kind: "box-drawing", char: ch, codepoint: cp, column: i }); + continue; + } + if (DINGBAT_RANGE_RE.test(ch) && !isHandledDingbat(cp)) { + issues.push({ kind: "dingbat", char: ch, codepoint: cp, column: i }); + continue; + } + } + + return { line: out, issues }; +} + +function isFenceLine(line) { + return /^\s*(```|~~~)/.test(line); +} + +function processMarkdown(content) { + const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + const issues = []; + let inFence = false; + let changed = false; + let emojiCount = 0; + + for (let i = 0; i < lines.length; i++) { + const original = lines[i]; + if (isFenceLine(original)) { + const result = rewriteLine(original, /* inFence */ true, /* arrowAware */ false); + if (result.line !== original) changed = true; + for (const issue of result.issues) { + issues.push({ ...issue, line: i + 1 }); + } + lines[i] = result.line; + inFence = !inFence; + continue; + } + const result = rewriteLine(original, inFence, /* arrowAware */ !inFence); + if (result.line !== original) changed = true; + for (const issue of result.issues) { + issues.push({ ...issue, line: i + 1 }); + } + lines[i] = result.line; + emojiCount += countEmojis(result.line); + } + + return { content: lines.join("\n"), changed, issues, emojiCount }; +} + +function processCSharp(content) { + const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + const issues = []; + let changed = false; + + for (let i = 0; i < lines.length; i++) { + const original = lines[i]; + const trimmed = original.replace(/^\s+/, ""); + if (!trimmed.startsWith("///")) { + // Skip non-doc lines silently. C# string literals legitimately + // contain non-ASCII chars in some test fixtures. + continue; + } + const result = rewriteLine(original, /* inFence */ false, /* arrowAware */ true); + if (result.line !== original) changed = true; + for (const issue of result.issues) { + issues.push({ ...issue, line: i + 1 }); + } + lines[i] = result.line; + } + + return { content: lines.join("\n"), changed, issues }; +} + +function countEmojis(line) { + let n = 0; + for (let i = 0; i < line.length; ) { + const cp = line.codePointAt(i); + const ch = String.fromCodePoint(cp); + i += ch.length; + if (cp >= 0x1F300 && cp <= 0x1FAFF) n++; + } + return n; +} + +// --- CLI -------------------------------------------------------------------- + +function parseArgs(argv) { + const args = { check: false, paths: null, files: [] }; + 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.startsWith("--")) { + console.error(`Unknown option: ${a}`); + process.exit(1); + } else { + args.files.push(a); + } + } + return args; +} + +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()) { + walk( + abs, + (p) => + p.endsWith(".md") || + (p.endsWith(".cs") && + CS_SCAN_ROOTS.some((root) => + p.includes(`${path.sep}${root}${path.sep}`), + )), + out, + ); + } else if (stat.isFile()) { + out.push(abs); + } + } + return out; + } + return defaultFileSet(); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const files = resolveFileList(args); + + let totalChanged = 0; + let mdChanged = 0; + let csChanged = 0; + const boxDrawingHits = []; + const dingbatHits = []; + const emojiWarnings = []; + + for (const file of files) { + let source; + try { + source = fs.readFileSync(file, "utf8"); + } catch (error) { + console.error(`Skipping ${file}: ${error.message}`); + continue; + } + const isMd = file.endsWith(".md"); + const isCs = file.endsWith(".cs"); + if (!isMd && !isCs) continue; + + const result = isMd ? processMarkdown(source) : processCSharp(source); + + for (const issue of result.issues) { + const rel = path.relative(ROOT_DIR, file); + const record = `${rel}:${issue.line}:${issue.column} U+${issue.codepoint + .toString(16) + .toUpperCase() + .padStart(4, "0")} ${JSON.stringify(issue.char)}`; + if (issue.kind === "box-drawing") boxDrawingHits.push(record); + else if (issue.kind === "dingbat") dingbatHits.push(record); + } + + if (isMd && typeof result.emojiCount === "number" && result.emojiCount > 5) { + emojiWarnings.push(`${path.relative(ROOT_DIR, file)}: ${result.emojiCount} emojis`); + } + + if (result.changed) { + totalChanged++; + if (isMd) mdChanged++; + else if (isCs) csChanged++; + if (!args.check) { + fs.writeFileSync(file, result.content); + } + } + } + + if (boxDrawingHits.length > 0) { + console.error("Box-drawing characters detected (manual rewrite required):"); + for (const h of boxDrawingHits) console.error(` ${h}`); + } + if (dingbatHits.length > 0) { + console.error("Unhandled dingbat-range characters (U+2300-U+27BF):"); + for (const h of dingbatHits) console.error(` ${h}`); + } + if (emojiWarnings.length > 0) { + console.warn("Emoji-cap warnings (>5 per file):"); + for (const w of emojiWarnings) console.warn(` ${w}`); + } + + if (args.check) { + if (totalChanged > 0) { + console.error( + `--check: ${totalChanged} file(s) would be modified (md=${mdChanged}, cs=${csChanged})`, + ); + process.exit(1); + } + if (boxDrawingHits.length > 0) process.exit(2); + process.exit(0); + } + + console.log( + `Normalized ${totalChanged} file(s) (md=${mdChanged}, cs=${csChanged}); ` + + `box-drawing=${boxDrawingHits.length}, unhandled-dingbats=${dingbatHits.length}, ` + + `emoji-warnings=${emojiWarnings.length}`, + ); + + if (boxDrawingHits.length > 0) process.exit(2); + process.exit(0); +} + +if (require.main === module) { + main(); +} + +module.exports = { + processMarkdown, + processCSharp, + rewriteLine, +}; diff --git a/scripts/normalize-docs-ascii.js.meta b/scripts/normalize-docs-ascii.js.meta new file mode 100644 index 00000000..fa45cd2b --- /dev/null +++ b/scripts/normalize-docs-ascii.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 109972d211afc9b4081478bce005c013 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/update-llms-txt.js b/scripts/update-llms-txt.js index 32471f72..eba0f9e8 100755 --- a/scripts/update-llms-txt.js +++ b/scripts/update-llms-txt.js @@ -211,7 +211,7 @@ DxMessaging is a high-performance messaging library for Unity (v2021.3+) that re ### Message Flow \`\`\`text -Emitter → MessageBus → Interceptors → Handlers (by priority) +Emitter > MessageBus > Interceptors > Handlers (by priority) \`\`\` ## Project Structure @@ -337,7 +337,7 @@ dotnet tool run csharpier . dotnet build SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.csproj # Run tests (Unity Test Runner) -# Open Unity 2021.3+ project → Window → Test Runner → PlayMode +# Open Unity 2021.3+ project > Window > Test Runner > PlayMode # Format markdown npm run format:md @@ -490,7 +490,7 @@ function main() { process.exit(1); } - console.log("✓ llms.txt is up to date"); + console.log("[ok] llms.txt is up to date"); process.exit(0); } @@ -498,7 +498,7 @@ function main() { // Normalize to LF line endings to match .gitattributes for *.txt files const contentWithLF = normalizeToLf(newContent); fs.writeFileSync(LLMS_TXT_PATH, contentWithLF, "utf8"); - console.log("✓ Updated llms.txt"); + console.log("[ok] Updated llms.txt"); process.exit(0); } catch (error) { console.error("ERROR:", error.message); diff --git a/scripts/validate-changelog.js b/scripts/validate-changelog.js new file mode 100644 index 00000000..706f743c --- /dev/null +++ b/scripts/validate-changelog.js @@ -0,0 +1,882 @@ +#!/usr/bin/env node +/** + * validate-changelog.js + * + * Enforces changelog policy with deterministic errors and heuristic warnings: + * - ERROR: missing Unreleased section + * - ERROR: package.json version missing from CHANGELOG.md + * - ERROR: invalid changelog category header + * - ERROR: user-visible file changes without CHANGELOG.md update (when coverage checks are enabled) + * - WARNING: Unreleased section has no entries + * - WARNING: likely internal-only changelog entry in Unreleased + * - WARNING: likely category mismatch in Unreleased entry + * - WARNING: likely duplicate Added+Fixed entry pair for the same unreleased item + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); +const { normalizeToLf } = require("./lib/quote-parser"); + +const REPO_ROOT = path.join(__dirname, ".."); +const CHANGELOG_PATH = path.join(REPO_ROOT, "CHANGELOG.md"); +const PACKAGE_JSON_PATH = path.join(REPO_ROOT, "package.json"); + +const VALID_CATEGORIES = new Set([ + "Added", + "Changed", + "Deprecated", + "Removed", + "Fixed", + "Security" +]); + +const INTERNAL_ONLY_PATTERNS = [ + /\bmeta files?\b/i, + /\.meta\b/i, + /\bnpmignore\b/i, + /\.npmignore\b/i, + /\bpre-commit\b/i, + /\bworkflow\b/i, + /\bci\b/i, + /\bcspell\b/i, + /\bprettier\b/i, + /\blinter\b/i, + /\bautomation\b/i, + /\btooling\b/i, + /\brefactor(?:ed|ing)?\b/i, + /\binternal\b/i, + /\bagent(?:ic)?\b/i, + /\binstruction(?:s)?\b/i, + /\bprompt(?:s)?\b/i, + /\blarge language model(?:s)?\b/i, + /\bbuild harness\b/i, + /\bskill(?:s)?\b/i, + /\btest(?:s|ing)?(?:\s+only)?\b/i +]; + +const USER_IMPACT_HINTS = [ + /\buser(?:s)?\b/i, + /\bapi\b/i, + /\bruntime\b/i, + /\binspector\b/i, + /\bmessage(?:s|ing)?\b/i, + /\bunity editor\b/i, + /\bplayer build(?:s)?\b/i, + /\bperformance\b/i, + /\bstability\b/i, + /\bcrash\b/i, + /\bnow\b/i, + /\bsupport(?:s|ed)?\b/i, + /\bfix(?:ed|es)?\b/i +]; + +const CATEGORY_PREFIX_MISMATCH_RULES = { + Added: [/^fixed\b/i, /^resolved\b/i, /^corrected\b/i, /^removed\b/i], + Fixed: [/^added\b/i, /^new\b/i, /^introduced\b/i, /^deprecated\b/i], + Removed: [/^added\b/i, /^fixed\b/i], + Deprecated: [/^added\b/i, /^fixed\b/i], + Security: [/^added\b/i, /^removed\b/i] +}; + +const STOP_WORDS = new Set([ + "a", + "an", + "and", + "are", + "as", + "at", + "be", + "by", + "for", + "from", + "in", + "into", + "is", + "it", + "its", + "now", + "of", + "on", + "or", + "that", + "the", + "their", + "this", + "to", + "using", + "when", + "with" +]); + +class Violation { + constructor(code, severity, message, line = null, suggestion = "") { + this.code = code; + this.severity = severity; + this.message = message; + this.line = line; + this.suggestion = suggestion; + } + + toString() { + const lineSuffix = this.line == null ? "" : ` (line ${this.line})`; + const suggestionSuffix = this.suggestion ? `\n Suggestion: ${this.suggestion}` : ""; + return `${this.severity} ${this.code}${lineSuffix}: ${this.message}${suggestionSuffix}`; + } +} + +function normalizeRepoPath(filePath) { + return String(filePath || "") + .trim() + .replace(/\\/g, "/") + .replace(/^\.\//, ""); +} + +function parseArgs(argv) { + const options = { + checkCoverage: false, + strictWarnings: false, + help: false, + changelogPath: CHANGELOG_PATH, + packageJsonPath: PACKAGE_JSON_PATH, + changedFiles: [] + }; + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + + if (argument === "--help" || argument === "-h") { + options.help = true; + continue; + } + + if (argument === "--check-coverage") { + options.checkCoverage = true; + continue; + } + + if (argument === "--strict-warnings") { + options.strictWarnings = true; + continue; + } + + if (argument === "--changed-file") { + const value = argv[index + 1]; + if (!value) { + throw new Error("--changed-file requires a path value"); + } + options.changedFiles.push(normalizeRepoPath(value)); + index++; + continue; + } + + if (argument.startsWith("--changed-file=")) { + options.changedFiles.push(normalizeRepoPath(argument.slice("--changed-file=".length))); + continue; + } + + if (argument === "--changelog") { + const value = argv[index + 1]; + if (!value) { + throw new Error("--changelog requires a path value"); + } + options.changelogPath = path.resolve(value); + index++; + continue; + } + + if (argument.startsWith("--changelog=")) { + options.changelogPath = path.resolve(argument.slice("--changelog=".length)); + continue; + } + + if (argument === "--package-json") { + const value = argv[index + 1]; + if (!value) { + throw new Error("--package-json requires a path value"); + } + options.packageJsonPath = path.resolve(value); + index++; + continue; + } + + if (argument.startsWith("--package-json=")) { + options.packageJsonPath = path.resolve(argument.slice("--package-json=".length)); + continue; + } + + if (argument.startsWith("-")) { + throw new Error(`Unknown argument: ${argument}`); + } + + options.changedFiles.push(normalizeRepoPath(argument)); + } + + options.changedFiles = options.changedFiles.filter(Boolean); + return options; +} + +function parsePackageVersion(packageJsonContent) { + let parsedPackage; + + try { + parsedPackage = JSON.parse(packageJsonContent); + } catch (error) { + throw new Error(`Unable to parse package.json: ${error.message}`); + } + + if ( + !parsedPackage || + typeof parsedPackage.version !== "string" || + parsedPackage.version.trim() === "" + ) { + throw new Error("package.json is missing a non-empty version field"); + } + + return parsedPackage.version.trim(); +} + +function parseChangelog(changelogContent) { + const lines = normalizeToLf(changelogContent).split("\n"); + const sections = []; + const entries = []; + + let currentSection = null; + let currentCategory = null; + let currentEntry = null; + + const finalizeEntry = () => { + if (!currentEntry || !currentSection || !currentCategory) { + currentEntry = null; + return; + } + + const normalizedText = currentEntry.text.replace(/\s+/g, " ").trim(); + if (normalizedText.length === 0) { + currentEntry = null; + return; + } + + const entry = { + version: currentSection.version, + category: currentCategory.name, + line: currentEntry.line, + text: normalizedText + }; + + currentCategory.entries.push(entry); + entries.push(entry); + currentEntry = null; + }; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const rawLine = lines[lineIndex]; + const trimmedLine = rawLine.trim(); + + const sectionMatch = /^## \[([^\]]+)\](?:\s*-\s*(\d{4}-\d{2}-\d{2}))?\s*$/.exec(trimmedLine); + if (sectionMatch) { + finalizeEntry(); + currentCategory = null; + currentSection = { + version: sectionMatch[1].trim(), + date: sectionMatch[2] || null, + line: lineIndex + 1, + categories: [] + }; + sections.push(currentSection); + continue; + } + + const categoryMatch = /^###\s+(.+?)\s*$/.exec(trimmedLine); + if (categoryMatch && currentSection) { + finalizeEntry(); + currentCategory = { + name: categoryMatch[1].trim(), + line: lineIndex + 1, + entries: [] + }; + currentSection.categories.push(currentCategory); + continue; + } + + const entryMatch = /^-\s+(.+?)\s*$/.exec(trimmedLine); + if (entryMatch && currentSection && currentCategory) { + finalizeEntry(); + currentEntry = { + line: lineIndex + 1, + text: entryMatch[1] + }; + continue; + } + + if ( + currentEntry && + trimmedLine.length > 0 && + !/^##\s+\[/.test(trimmedLine) && + !/^###\s+/.test(trimmedLine) && + !/^-\s+/.test(trimmedLine) && + !/^\[[^\]]+\]:/.test(trimmedLine) + ) { + currentEntry.text += ` ${trimmedLine}`; + continue; + } + + if (trimmedLine.length === 0) { + continue; + } + } + + finalizeEntry(); + + return { + sections, + entries + }; +} + +function getSectionByVersion(parsedChangelog, version) { + return parsedChangelog.sections.find((section) => section.version === version) || null; +} + +function validateStructuralRules(parsedChangelog, packageVersion) { + const errors = []; + + const unreleasedSection = getSectionByVersion(parsedChangelog, "Unreleased"); + if (!unreleasedSection) { + errors.push( + new Violation( + "E001", + "ERROR", + "Missing required '## [Unreleased]' section.", + null, + "Add an Unreleased section at the top of CHANGELOG.md." + ) + ); + } + + const packageVersionSection = getSectionByVersion(parsedChangelog, packageVersion); + if (!packageVersionSection) { + errors.push( + new Violation( + "E002", + "ERROR", + `Missing changelog section for package.json version '${packageVersion}'.`, + null, + `Add '## [${packageVersion}]' to CHANGELOG.md before release.` + ) + ); + } + + for (const section of parsedChangelog.sections) { + for (const category of section.categories) { + if (!VALID_CATEGORIES.has(category.name)) { + errors.push( + new Violation( + "E003", + "ERROR", + `Invalid changelog category '${category.name}' in section [${section.version}].`, + category.line, + `Use one of: ${Array.from(VALID_CATEGORIES).join(", ")}.` + ) + ); + } + } + } + + return errors; +} + +function hasUserImpactHint(entryText) { + return USER_IMPACT_HINTS.some((pattern) => pattern.test(entryText)); +} + +function isLikelyInternalOnlyEntry(entryText) { + const hasInternalPattern = INTERNAL_ONLY_PATTERNS.some((pattern) => pattern.test(entryText)); + return hasInternalPattern && !hasUserImpactHint(entryText); +} + +function detectCategoryMismatch(entry) { + const mismatchRules = CATEGORY_PREFIX_MISMATCH_RULES[entry.category] || []; + return mismatchRules.some((pattern) => pattern.test(entry.text)); +} + +function extractBacktickSymbols(entryText) { + const symbols = new Set(); + const pattern = /`([^`]+)`/g; + let match = pattern.exec(entryText); + + while (match) { + const symbol = match[1].trim().toLowerCase(); + if (symbol.length > 0) { + symbols.add(symbol); + } + match = pattern.exec(entryText); + } + + return symbols; +} + +function tokenizeForSimilarity(entryText) { + return new Set( + entryText + .toLowerCase() + .replace(/https?:\/\/\S+/g, " ") + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .map((token) => token.trim()) + .filter((token) => token.length >= 4 && !STOP_WORDS.has(token)) + ); +} + +function hasTokenOverlap(leftTokens, rightTokens, minimumShared = 2, minimumRatio = 0.6) { + if (leftTokens.size === 0 || rightTokens.size === 0) { + return false; + } + + const shared = []; + for (const token of leftTokens) { + if (rightTokens.has(token)) { + shared.push(token); + } + } + + if (shared.length < minimumShared) { + return false; + } + + const minSize = Math.min(leftTokens.size, rightTokens.size); + return shared.length / minSize >= minimumRatio; +} + +function areLikelyMutationPair(addedEntry, fixedEntry) { + const addedSymbols = extractBacktickSymbols(addedEntry.text); + const fixedSymbols = extractBacktickSymbols(fixedEntry.text); + + for (const symbol of addedSymbols) { + if (fixedSymbols.has(symbol)) { + return true; + } + } + + const addedTokens = tokenizeForSimilarity(addedEntry.text); + const fixedTokens = tokenizeForSimilarity(fixedEntry.text); + return hasTokenOverlap(addedTokens, fixedTokens); +} + +function validateHeuristicRules(parsedChangelog) { + const violations = []; + const unreleasedSection = getSectionByVersion(parsedChangelog, "Unreleased"); + + if (!unreleasedSection) { + return violations; + } + + const unreleasedEntries = parsedChangelog.entries.filter( + (entry) => entry.version === "Unreleased" + ); + + if (unreleasedEntries.length === 0) { + violations.push( + new Violation( + "W001", + "WARNING", + "Unreleased section has no entries.", + unreleasedSection.line, + "Add at least one user-facing entry when user-visible changes are introduced." + ) + ); + } + + for (const entry of unreleasedEntries) { + if (isLikelyInternalOnlyEntry(entry.text)) { + violations.push( + new Violation( + "W002", + "WARNING", + "Unreleased entry appears internal-only and may not be user-facing.", + entry.line, + "Rewrite the entry around user impact or move internal details to developer docs." + ) + ); + } + + if (detectCategoryMismatch(entry)) { + violations.push( + new Violation( + "W003", + "WARNING", + `Unreleased entry in '${entry.category}' may belong to a different category.`, + entry.line, + "Use Added for new capabilities and Fixed for bug corrections." + ) + ); + } + } + + const addedEntries = unreleasedEntries.filter((entry) => entry.category === "Added"); + const fixedEntries = unreleasedEntries.filter((entry) => entry.category === "Fixed"); + + for (const addedEntry of addedEntries) { + for (const fixedEntry of fixedEntries) { + if (!areLikelyMutationPair(addedEntry, fixedEntry)) { + continue; + } + + violations.push( + new Violation( + "E005", + "ERROR", + "Unreleased Added/Fixed entries look like the same change. Mutate the existing unreleased entry instead of stacking bullets.", + fixedEntry.line, + `Consider merging line ${fixedEntry.line} into the Added entry at line ${addedEntry.line}.` + ) + ); + } + } + + return violations; +} + +function isLikelyUserVisiblePath(filePath) { + const normalizedPath = normalizeRepoPath(filePath); + + if (normalizedPath.length === 0 || normalizedPath === "CHANGELOG.md") { + return false; + } + + if (normalizedPath.endsWith(".meta")) { + return false; + } + + if ( + normalizedPath.startsWith("Tests/") || + normalizedPath.startsWith("scripts/") || + normalizedPath.startsWith("docs/") || + normalizedPath.startsWith(".github/") || + normalizedPath.startsWith(".llm/") + ) { + return false; + } + + if (normalizedPath.startsWith("Runtime/")) { + return true; + } + + if (normalizedPath.startsWith("SourceGenerators/")) { + return true; + } + + if (normalizedPath.startsWith("Samples~/")) { + return true; + } + + if ( + normalizedPath.startsWith("Editor/Analyzers/") || + normalizedPath.startsWith("Editor/Testing/") + ) { + return false; + } + + if (normalizedPath.startsWith("Editor/")) { + return true; + } + + return false; +} + +function parseChangedFilesOutput(commandOutput) { + return normalizeToLf(commandOutput) + .split("\n") + .map((filePath) => normalizeRepoPath(filePath)) + .filter(Boolean); +} + +function getChangedFilesFromGit(execFileSyncImpl = execFileSync, env = process.env) { + const runGit = (args) => + parseChangedFilesOutput( + execFileSyncImpl("git", args, { + cwd: REPO_ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }) + ); + + const mergeUniquePaths = (...pathLists) => { + const merged = []; + const seen = new Set(); + + for (const pathList of pathLists) { + for (const filePath of pathList) { + if (seen.has(filePath)) { + continue; + } + + seen.add(filePath); + merged.push(filePath); + } + } + + return merged; + }; + + try { + const stagedFiles = runGit(["diff", "-M", "--name-only", "--cached"]); + if (stagedFiles.length > 0) { + return stagedFiles; + } + } catch { + // Ignore and continue with other strategies. + } + + const isCiEnvironment = + String(env.CI || "").toLowerCase() === "true" || String(env.GITHUB_ACTIONS || "") === "true"; + + if (!isCiEnvironment) { + const unstagedFiles = (() => { + try { + return runGit(["diff", "-M", "--name-only"]); + } catch { + return []; + } + })(); + + const untrackedFiles = (() => { + try { + return runGit(["ls-files", "--others", "--exclude-standard"]); + } catch { + return []; + } + })(); + + return mergeUniquePaths(unstagedFiles, untrackedFiles); + } + + if ( + env.GITHUB_EVENT_NAME === "pull_request" && + typeof env.GITHUB_BASE_REF === "string" && + env.GITHUB_BASE_REF.length > 0 + ) { + try { + const baseRef = `origin/${env.GITHUB_BASE_REF}`; + const prFiles = runGit(["diff", "-M", "--name-only", `${baseRef}...HEAD`]); + if (prFiles.length > 0) { + return prFiles; + } + } catch { + // Ignore and fallback. + } + } + + if ( + env.GITHUB_EVENT_NAME === "push" && + typeof env.GITHUB_EVENT_BEFORE === "string" && + env.GITHUB_EVENT_BEFORE && + !/^0+$/.test(env.GITHUB_EVENT_BEFORE) + ) { + try { + const pushFiles = runGit(["diff", "-M", "--name-only", `${env.GITHUB_EVENT_BEFORE}...HEAD`]); + if (pushFiles.length > 0) { + return pushFiles; + } + } catch { + // Ignore and fallback. + } + } + + try { + return runGit(["diff", "-M", "--name-only", "HEAD~1...HEAD"]); + } catch { + return []; + } +} + +function validateCoverageRule(changedFiles) { + if (!Array.isArray(changedFiles) || changedFiles.length === 0) { + return []; + } + + const normalizedChangedFiles = changedFiles + .map((filePath) => normalizeRepoPath(filePath)) + .filter(Boolean); + const changelogChanged = normalizedChangedFiles.includes("CHANGELOG.md"); + const userVisibleFiles = normalizedChangedFiles.filter((filePath) => + isLikelyUserVisiblePath(filePath) + ); + + if (userVisibleFiles.length === 0 || changelogChanged) { + return []; + } + + return [ + new Violation( + "E004", + "ERROR", + "Likely user-visible files changed without a CHANGELOG.md update.", + null, + `Add or mutate an Unreleased changelog entry. Trigger files: ${userVisibleFiles.join(", ")}` + ) + ]; +} + +function validateChangelogPolicy({ + changelogContent, + packageJsonContent, + checkCoverage = false, + changedFiles = [] +}) { + const parsedChangelog = parseChangelog(changelogContent); + const packageVersion = parsePackageVersion(packageJsonContent); + + const errors = [...validateStructuralRules(parsedChangelog, packageVersion)]; + + if (checkCoverage) { + errors.push(...validateCoverageRule(changedFiles)); + } + + const heuristicViolations = validateHeuristicRules(parsedChangelog); + + const heuristicErrors = heuristicViolations.filter((violation) => violation.severity === "ERROR"); + const warnings = heuristicViolations.filter((violation) => violation.severity !== "ERROR"); + + errors.push(...heuristicErrors); + + return { + errors, + warnings, + parsedChangelog, + packageVersion + }; +} + +function printUsage() { + console.log("Usage: node scripts/validate-changelog.js [options] [changed-file ...]"); + console.log(""); + console.log("Options:"); + console.log( + " --check-coverage Fail when likely user-visible changes do not update CHANGELOG.md." + ); + console.log( + " --changed-file Add a changed file path for coverage checks (repeatable)." + ); + console.log(" --changelog Use a custom CHANGELOG path."); + console.log(" --package-json Use a custom package.json path."); + console.log(" --strict-warnings Treat warnings as failures (exit code 1)."); + console.log(" --help Show this help output."); +} + +function main() { + let options; + + try { + options = parseArgs(process.argv.slice(2)); + } catch (error) { + console.error(`ERROR: ${error.message}`); + printUsage(); + process.exit(1); + } + + if (options.help) { + printUsage(); + process.exit(0); + } + + let changelogContent; + let packageJsonContent; + + try { + changelogContent = fs.readFileSync(options.changelogPath, "utf8"); + } catch (error) { + console.error( + `ERROR: Unable to read changelog file '${options.changelogPath}': ${error.message}` + ); + process.exit(1); + } + + try { + packageJsonContent = fs.readFileSync(options.packageJsonPath, "utf8"); + } catch (error) { + console.error( + `ERROR: Unable to read package.json file '${options.packageJsonPath}': ${error.message}` + ); + process.exit(1); + } + + const resolvedChangedFiles = options.checkCoverage + ? options.changedFiles.length > 0 + ? options.changedFiles + : getChangedFilesFromGit() + : []; + + let result; + try { + result = validateChangelogPolicy({ + changelogContent, + packageJsonContent, + checkCoverage: options.checkCoverage, + changedFiles: resolvedChangedFiles + }); + } catch (error) { + console.error(`ERROR: ${error.message}`); + process.exit(1); + } + + const hasErrors = result.errors.length > 0; + const hasWarnings = result.warnings.length > 0; + + if (!hasErrors && !hasWarnings) { + console.log("PASS: changelog policy validation passed."); + process.exit(0); + } + + if (hasErrors) { + console.error(`ERRORS (${result.errors.length}):`); + for (const error of result.errors) { + console.error(`- ${error.toString()}`); + } + } + + if (hasWarnings) { + console.log(`WARNINGS (${result.warnings.length}):`); + for (const warning of result.warnings) { + console.log(`- ${warning.toString()}`); + } + } + + if (hasErrors || (options.strictWarnings && hasWarnings)) { + process.exit(1); + } + + process.exit(0); +} + +module.exports = { + CHANGELOG_PATH, + PACKAGE_JSON_PATH, + VALID_CATEGORIES, + Violation, + normalizeRepoPath, + parseArgs, + parsePackageVersion, + parseChangelog, + getSectionByVersion, + validateStructuralRules, + isLikelyInternalOnlyEntry, + detectCategoryMismatch, + extractBacktickSymbols, + tokenizeForSimilarity, + hasTokenOverlap, + areLikelyMutationPair, + validateHeuristicRules, + isLikelyUserVisiblePath, + parseChangedFilesOutput, + getChangedFilesFromGit, + validateCoverageRule, + validateChangelogPolicy, + main +}; + +if (require.main === module) { + main(); +} diff --git a/scripts/validate-changelog.js.meta b/scripts/validate-changelog.js.meta new file mode 100644 index 00000000..6f77ab82 --- /dev/null +++ b/scripts/validate-changelog.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 663b0154483dccf469d6923003861463 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/validate-doc-code-patterns.js b/scripts/validate-doc-code-patterns.js new file mode 100644 index 00000000..038d719a --- /dev/null +++ b/scripts/validate-doc-code-patterns.js @@ -0,0 +1,338 @@ +#!/usr/bin/env node + +/** + * validate-doc-code-patterns.js + * + * Lints documentation code samples for known-broken patterns. + * + * 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. + * + * Sources scanned within each .md file: + * - Triple-backtick fenced code blocks. + * - Single-backtick inline code spans (including those inside table cells). + * - Bare prose lines (some violations slip in as raw text). + * + * Pluggable via the BANNED_PATTERNS array. Each pattern carries an id, + * regex, "why" explanation, and "fix" suggestion. Adding a new pattern is a + * one-line change. + * + * Counter-example marker: lines containing one of the deliberate-counterexample + * phrases ("won't compile", "will not compile", "does not compile", + * "do not compile") on the same line as a match are treated as intentional + * negative documentation and skipped. + * + * Usage: + * node scripts/validate-doc-code-patterns.js [--check] [--list-rules] + * [--paths ] + * [files...] + * + * Exit codes: + * 0 No violations. + * 1 Violations detected (or unrecoverable error). + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const ROOT_DIR = path.resolve(__dirname, ".."); + +const EXCLUDE_DIRS = new Set([ + ".git", + "node_modules", + "Library", + "Temp", + "obj", + "bin", + "Logs", + "site", + "coverage", + "__pycache__", +]); + +const CS_SCAN_ROOTS = ["Runtime", "Editor", "Tests", "SourceGenerators"]; + +// Files that the normalizer treats as generated. Skip them here too -- their +// contents derive from sources we already scan. +const GENERATED_FILE_PATHS = new Set([ + path.join(ROOT_DIR, ".llm", "skills", "index.md"), +]); + +// --- Rules ------------------------------------------------------------------ + +const COUNTEREXAMPLE_MARKERS = [ + /won'?t\s+compile/i, + /will\s+not\s+compile/i, + /does\s+not\s+compile/i, + /do\s+not\s+compile/i, + /fails?\s+to\s+compile/i, +]; + +const BANNED_PATTERNS = [ + { + id: "struct-emit-temporary", + // Catches: "new X().Emit(", "new X().EmitTargeted(", "(new X()).Emit(", + // "new Namespace.X().Emit(", whitespace variants like "new X () . Emit (". + // Two alternatives: + // 1. Bare form: "new X(args).Emit(" with NO leading "(" wrapping. + // A negative lookbehind on the "new" rejects "(" or ")" or word + // chars immediately preceding, so "someMethod(new X()).Emit(" + // does not anchor here (the "new" is preceded by "(" from the + // method call and there is no balanced wrapping group). + // 2. Parenthesized form: "(new X(args)).Emit(" -- the leading "(" + // must NOT be preceded by an identifier or ")" (which would + // indicate a function call rather than a grouping paren). + // Both forms use a balanced (...) group for the constructor args + // (one level of nesting) to tolerate generic/method-call arguments. + pattern: + /(?:(? { + if (GENERATED_FILE_PATHS.has(p)) return false; + return p.endsWith(".md"); + }, + out, + ); + for (const root of CS_SCAN_ROOTS) { + const abs = path.join(ROOT_DIR, root); + if (!fs.existsSync(abs)) continue; + walk(abs, (p) => p.endsWith(".cs"), out); + } + return out; +} + +// --- Scanning --------------------------------------------------------------- + +function isCounterExampleLine(line) { + return COUNTEREXAMPLE_MARKERS.some((re) => re.test(line)); +} + +function scanLineForRule(rule, line, lineNumber, filePath, violations) { + if (isCounterExampleLine(line)) return; + rule.pattern.lastIndex = 0; + let match; + while ((match = rule.pattern.exec(line)) !== null) { + violations.push({ + file: filePath, + line: lineNumber, + column: match.index + 1, + id: rule.id, + why: rule.why, + fix: rule.fix, + sample: match[0], + }); + // Avoid infinite loop on zero-width matches. + if (match.index === rule.pattern.lastIndex) rule.pattern.lastIndex++; + } +} + +function scanMarkdown(filePath, content, violations) { + const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + // For markdown we scan all non-empty lines: fenced blocks, inline code in + // table cells, prose, comments. The rules are designed to be specific + // enough that prose false positives are unlikely. + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const rule of BANNED_PATTERNS) { + scanLineForRule(rule, line, i + 1, filePath, violations); + } + } +} + +function scanCSharp(filePath, content, violations) { + const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].replace(/^\s+/, ""); + if (!trimmed.startsWith("///")) continue; + // Strip the leading slashes so we don't accidentally match against + // syntax noise. + const docLine = trimmed.slice(3); + for (const rule of BANNED_PATTERNS) { + scanLineForRule(rule, docLine, i + 1, filePath, violations); + } + } +} + +function scanFile(filePath, violations) { + let content; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch (error) { + return; + } + if (filePath.endsWith(".md")) { + scanMarkdown(filePath, content, violations); + } else if (filePath.endsWith(".cs")) { + scanCSharp(filePath, content, violations); + } +} + +// --- CLI -------------------------------------------------------------------- + +function parseArgs(argv) { + const args = { + check: true, + listRules: false, + paths: null, + files: [], + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--check") { + args.check = true; + } else if (a === "--list-rules") { + args.listRules = true; + } else if (a === "--paths") { + args.paths = 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-doc-code-patterns.js [options] [files...]", + "", + "Options:", + " --check Default. Exit 1 on any violation.", + " --list-rules Print the configured rule catalog and exit 0.", + " --paths Comma-separated explicit paths or directory roots.", + " -h, --help Show this message.", + "", + ].join("\n"), + ); +} + +function listRules() { + process.stdout.write(`Configured rules: ${BANNED_PATTERNS.length}\n\n`); + for (const rule of BANNED_PATTERNS) { + process.stdout.write(`- id: ${rule.id}\n`); + process.stdout.write(` regex: ${rule.pattern}\n`); + process.stdout.write(` why: ${rule.why}\n`); + process.stdout.write(` fix: ${rule.fix}\n\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()) { + walk( + abs, + (p) => + p.endsWith(".md") || + (p.endsWith(".cs") && + CS_SCAN_ROOTS.some((root) => + p.includes(`${path.sep}${root}${path.sep}`), + )), + out, + ); + } else if (stat.isFile()) { + out.push(abs); + } + } + return out; + } + return defaultFileSet(); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.listRules) { + listRules(); + return 0; + } + + const files = resolveFileList(args); + const violations = []; + for (const file of files) { + scanFile(file, violations); + } + + if (violations.length === 0) { + process.stdout.write( + `validate-doc-code-patterns: 0 violations across ${files.length} file(s).\n`, + ); + return 0; + } + + for (const v of violations) { + const rel = path.relative(ROOT_DIR, v.file) || v.file; + process.stderr.write( + `${rel}:${v.line}:${v.column}: [${v.id}] ${v.sample}\n` + + ` why: ${v.why}\n` + + ` fix: ${v.fix}\n`, + ); + } + process.stderr.write( + `\nvalidate-doc-code-patterns: ${violations.length} violation(s) found.\n`, + ); + return 1; +} + +if (require.main === module) { + process.exit(main()); +} + +module.exports = { + BANNED_PATTERNS, + scanMarkdown, + scanCSharp, + isCounterExampleLine, +}; diff --git a/scripts/validate-doc-code-patterns.js.meta b/scripts/validate-doc-code-patterns.js.meta new file mode 100644 index 00000000..6ad6edb3 --- /dev/null +++ b/scripts/validate-doc-code-patterns.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6fa420cdb5cf8c7428d88409921839b3 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/validate-docs-ascii.js b/scripts/validate-docs-ascii.js new file mode 100644 index 00000000..b9f9680f --- /dev/null +++ b/scripts/validate-docs-ascii.js @@ -0,0 +1,377 @@ +#!/usr/bin/env node + +/** + * validate-docs-ascii.js + * + * Enforces the ASCII-only documentation policy. + * + * 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. + * + * Allowed character set: + * - Printable ASCII (0x20-0x7E) and \n \t \r. + * - Real emojis (codepoint >= U+1F300) ONLY in callout positions: a line + * beginning with ">" (markdown blockquote / GFM admonition). + * - Variation selectors U+FE0F and U+FE0E (allowed everywhere). + * - BOM U+FEFF tolerated at the start of file only. + * + * Banned: + * - Geometric/dingbat range U+2300 - U+27BF (arrows, checks, crosses, + * warning, bullet, ellipsis-as-symbol, etc.) -- these are the "fake + * emoji" set the project explicitly rejects. + * - Any other non-ASCII character not covered by the allow list. + * + * Per-file emoji cap (warning, not error): + * - More than EMOJI_SOFT_CAP real emojis in a single file produces a + * warning. The plan's policy is "zero by default; callout-position + * exceptions"; the cap exists as a tripwire. + * + * ALLOWED_PATHS: + * - The two markdown-compatibility split files include emoji-shortcode + * example data and are exempt from emoji and codepoint scanning. + * + * Usage: + * node scripts/validate-docs-ascii.js [--check] [--paths ] + * [files...] + * + * Exit codes: + * 0 No banned characters detected. + * 1 Banned characters detected (or unrecoverable error). + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const ROOT_DIR = path.resolve(__dirname, ".."); + +const EXCLUDE_DIRS = new Set([ + ".git", + "node_modules", + "Library", + "Temp", + "obj", + "bin", + "Logs", + "site", + "coverage", + "__pycache__", +]); + +const CS_SCAN_ROOTS = ["Runtime", "Editor", "Tests", "SourceGenerators"]; + +const EXTRA_FILES = [path.join(ROOT_DIR, "llms.txt")]; + +const ALLOWED_PATHS = new Set([ + path.join( + ROOT_DIR, + ".llm", + "skills", + "documentation", + "markdown-compatibility-part-1.md", + ), + path.join( + ROOT_DIR, + ".llm", + "skills", + "documentation", + "markdown-compatibility-part-2.md", + ), +]); + +const EMOJI_SOFT_CAP = 5; + +// --- Codepoint classification ---------------------------------------------- + +const TAB = 0x09; +const LF = 0x0a; +const CR = 0x0d; +const SPACE = 0x20; +const TILDE = 0x7e; +const BOM = 0xfeff; +const VS15 = 0xfe0e; +const VS16 = 0xfe0f; + +const DINGBAT_LO = 0x2300; +const DINGBAT_HI = 0x27bf; +const EMOJI_LO = 0x1f300; + +function isAsciiPrintable(cp) { + return cp >= SPACE && cp <= TILDE; +} + +function isAsciiWhitespace(cp) { + return cp === TAB || cp === LF || cp === CR; +} + +function isVariationSelector(cp) { + return cp === VS15 || cp === VS16; +} + +function isDingbat(cp) { + return cp >= DINGBAT_LO && cp <= DINGBAT_HI; +} + +function isEmoji(cp) { + return cp >= EMOJI_LO; +} + +// --- File enumeration ------------------------------------------------------- + +function walk(dir, predicate, out) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (error) { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (EXCLUDE_DIRS.has(entry.name)) continue; + walk(full, predicate, out); + } else if (entry.isFile()) { + if (predicate(full)) out.push(full); + } + } +} + +function defaultFileSet() { + const out = []; + walk(ROOT_DIR, (p) => p.endsWith(".md"), out); + for (const root of CS_SCAN_ROOTS) { + const abs = path.join(ROOT_DIR, root); + if (!fs.existsSync(abs)) continue; + walk(abs, (p) => p.endsWith(".cs"), out); + } + for (const extra of EXTRA_FILES) { + if (fs.existsSync(extra)) out.push(extra); + } + return out; +} + +// --- Scanning --------------------------------------------------------------- + +function isCalloutLine(line) { + // Markdown blockquote / GFM admonition lead character. + return /^\s*>/.test(line); +} + +function shouldScanLineCs(line) { + return /^\s*\/\/\//.test(line); +} + +function classifyChar(cp, line, isFirstCharOfFile) { + if (isAsciiPrintable(cp) || isAsciiWhitespace(cp)) { + return { kind: "ok" }; + } + if (isVariationSelector(cp)) { + return { kind: "ok" }; + } + if (cp === BOM && isFirstCharOfFile) { + return { kind: "ok" }; + } + if (isDingbat(cp)) { + return { + kind: "banned", + reason: "dingbat/geometric character (U+2300-U+27BF) is banned outright", + }; + } + if (isEmoji(cp)) { + if (isCalloutLine(line)) { + return { kind: "emoji" }; + } + return { + kind: "banned", + reason: "emoji outside a callout position (line must start with '>')", + }; + } + if (cp < SPACE) { + return { + kind: "banned", + reason: `control character U+${cp.toString(16).toUpperCase().padStart(4, "0")} is not in the allow list`, + }; + } + return { + kind: "banned", + reason: `non-ASCII codepoint U+${cp.toString(16).toUpperCase().padStart(4, "0")} is not in the allow list`, + }; +} + +function scanContent(filePath, content, isCsharp) { + const allowedFile = ALLOWED_PATHS.has(filePath); + const violations = []; + const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + let emojiCount = 0; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]; + const eligible = isCsharp ? shouldScanLineCs(line) : true; + if (!eligible) continue; + + let column = 0; + for (let i = 0; i < line.length; ) { + const cp = line.codePointAt(i); + const ch = String.fromCodePoint(cp); + const isFirstCharOfFile = lineIndex === 0 && i === 0; + const result = classifyChar(cp, line, isFirstCharOfFile); + if (result.kind === "banned" && !allowedFile) { + violations.push({ + file: filePath, + line: lineIndex + 1, + column: column + 1, + codepoint: cp, + char: ch, + reason: result.reason, + }); + } else if (result.kind === "emoji") { + emojiCount++; + } + i += ch.length; + column += ch.length; + } + } + + let warning = null; + if (!allowedFile && emojiCount > EMOJI_SOFT_CAP) { + warning = { + file: filePath, + count: emojiCount, + cap: EMOJI_SOFT_CAP, + }; + } + return { violations, emojiCount, warning }; +} + +function scanFile(filePath) { + let content; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch (error) { + return { violations: [], warning: null, emojiCount: 0 }; + } + const isCsharp = filePath.endsWith(".cs"); + return scanContent(filePath, content, isCsharp); +} + +// --- CLI -------------------------------------------------------------------- + +function parseArgs(argv) { + const args = { check: true, paths: null, files: [] }; + 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 === "--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-ascii.js [options] [files...]", + "", + "Options:", + " --check Default. Exit 1 on any banned char.", + " --paths Comma-separated explicit paths or directory roots.", + " -h, --help Show this message.", + "", + ].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()) { + walk( + abs, + (p) => + p.endsWith(".md") || + (p.endsWith(".cs") && + CS_SCAN_ROOTS.some((root) => + p.includes(`${path.sep}${root}${path.sep}`), + )), + out, + ); + } else if (stat.isFile()) { + out.push(abs); + } + } + return out; + } + return defaultFileSet(); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const files = resolveFileList(args); + + const allViolations = []; + const allWarnings = []; + + for (const file of files) { + const { violations, warning } = scanFile(file); + if (violations.length > 0) allViolations.push(...violations); + if (warning) allWarnings.push(warning); + } + + for (const w of allWarnings) { + const rel = path.relative(ROOT_DIR, w.file) || w.file; + process.stderr.write( + `WARN ${rel}: ${w.count} emoji(s) found, soft cap is ${w.cap}.\n`, + ); + } + + if (allViolations.length === 0) { + process.stdout.write( + `validate-docs-ascii: 0 violations across ${files.length} file(s).\n`, + ); + return 0; + } + + for (const v of allViolations) { + const rel = path.relative(ROOT_DIR, v.file) || v.file; + const cpHex = v.codepoint.toString(16).toUpperCase().padStart(4, "0"); + process.stderr.write( + `${rel}:${v.line}:${v.column}: U+${cpHex} '${v.char}' -- ${v.reason}\n`, + ); + } + process.stderr.write( + `\nvalidate-docs-ascii: ${allViolations.length} violation(s) found.\n`, + ); + return 1; +} + +if (require.main === module) { + process.exit(main()); +} + +module.exports = { + scanContent, + classifyChar, + isCalloutLine, + EMOJI_SOFT_CAP, +}; diff --git a/scripts/validate-docs-ascii.js.meta b/scripts/validate-docs-ascii.js.meta new file mode 100644 index 00000000..87938bf8 --- /dev/null +++ b/scripts/validate-docs-ascii.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a922d6daae4fc054b9ed9e0be8d49175 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/validate-pre-commit-tooling.js b/scripts/validate-pre-commit-tooling.js index 40f7b1e7..bd9158a1 100644 --- a/scripts/validate-pre-commit-tooling.js +++ b/scripts/validate-pre-commit-tooling.js @@ -13,582 +13,599 @@ const fs = require("fs"); const path = require("path"); const { normalizeToLf } = require("./lib/quote-parser"); -const { - getConfiguredPrettierSpec, - getPinnedPrettierSpec, -} = require("./lib/prettier-version"); +const { getConfiguredPrettierSpec, getPinnedPrettierSpec } = require("./lib/prettier-version"); const PRE_COMMIT_CONFIG_PATH = path.join(__dirname, "..", ".pre-commit-config.yaml"); const PACKAGE_JSON_PATH = path.join(__dirname, "..", "package.json"); -const REQUIRED_PRECHECK_PARSER_COMMAND = - "pre-commit run script-parser-tests --all-files"; -const REQUIRED_PACKAGE_JSON_FORMAT_COMMAND = - "npm run check:package-json-format"; -const REQUIRED_SCRIPTS_CSPELL_COMMAND = - "npm run check:cspell:scripts"; +const REQUIRED_PRECHECK_PARSER_COMMAND = "pre-commit run script-parser-tests --all-files"; +const REQUIRED_PACKAGE_JSON_FORMAT_COMMAND = "npm run check:package-json-format"; +const REQUIRED_SCRIPTS_CSPELL_COMMAND = "npm run check:cspell:scripts"; +const REQUIRED_CHANGELOG_VALIDATION_COMMAND = "npm run validate:changelog:coverage"; const REQUIRED_PARSER_SUITE_HOOK_ID = "script-parser-tests"; const REQUIRED_PARSER_SUITE_TEST_PATHS = [ - "scripts/__tests__/fix-csharp-underscore-methods.test.js", + "scripts/__tests__/fix-csharp-underscore-methods.test.js", + "scripts/__tests__/validate-changelog.test.js", + "scripts/__tests__/pre-commit-hook-stage-policy.test.js" ]; class Violation { - constructor(hookId, line, message, entry) { - this.hookId = hookId; - this.line = line; - this.message = message; - this.entry = entry; - } - - toString() { - return `${this.hookId} (line ${this.line}): ${this.message}\n entry: ${this.entry}`; - } + constructor(hookId, line, message, entry) { + this.hookId = hookId; + this.line = line; + this.message = message; + this.entry = entry; + } + + toString() { + return `${this.hookId} (line ${this.line}): ${this.message}\n entry: ${this.entry}`; + } } function getIndent(line) { - return line.length - line.trimStart().length; + return line.length - line.trimStart().length; } function parseHookEntries(content) { - const normalized = normalizeToLf(content); - const lines = normalized.split("\n"); - const entries = []; - - let currentHookId = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const idMatch = /^(\s*)-\s+id:\s*([^\s#]+)\s*$/.exec(line); - if (idMatch) { - currentHookId = idMatch[2].trim(); - continue; - } + const normalized = normalizeToLf(content); + const lines = normalized.split("\n"); + const entries = []; + + let currentHookId = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const idMatch = /^(\s*)-\s+id:\s*([^\s#]+)\s*$/.exec(line); + if (idMatch) { + currentHookId = idMatch[2].trim(); + continue; + } - if (!currentHookId) { - continue; - } + if (!currentHookId) { + continue; + } - const entryMatch = /^(\s*)entry:\s*(.*)$/.exec(line); - if (!entryMatch) { - continue; + const entryMatch = /^(\s*)entry:\s*(.*)$/.exec(line); + if (!entryMatch) { + continue; + } + + const entryIndent = entryMatch[1].length; + const entryValue = entryMatch[2].trim(); + let command; + + if ([">", ">-", "|", "|-"].includes(entryValue)) { + const blockLines = []; + let j = i + 1; + while (j < lines.length) { + const nextLine = lines[j]; + const nextLineIndent = getIndent(nextLine); + + if (nextLine.trim().length > 0 && nextLineIndent <= entryIndent) { + break; } - const entryIndent = entryMatch[1].length; - const entryValue = entryMatch[2].trim(); - let command; - - if ([">", ">-", "|", "|-"].includes(entryValue)) { - const blockLines = []; - let j = i + 1; - while (j < lines.length) { - const nextLine = lines[j]; - const nextLineIndent = getIndent(nextLine); - - if (nextLine.trim().length > 0 && nextLineIndent <= entryIndent) { - break; - } - - if (nextLine.trim().length > 0) { - blockLines.push(nextLine.trim()); - } - - j++; - } - - // Skip block lines that were consumed by this folded/literal entry. - i = j - 1; - command = blockLines.join(" ").replace(/\s+/g, " ").trim(); - } else { - command = entryValue; + if (nextLine.trim().length > 0) { + blockLines.push(nextLine.trim()); } - entries.push({ id: currentHookId, line: i + 1, entry: command }); + j++; + } + + // Skip block lines that were consumed by this folded/literal entry. + i = j - 1; + command = blockLines.join(" ").replace(/\s+/g, " ").trim(); + } else { + command = entryValue; } - return entries; + entries.push({ id: currentHookId, line: i + 1, entry: command }); + } + + return entries; } function parseHookIds(content) { - const lines = normalizeToLf(content).split("\n"); - const ids = []; + const lines = normalizeToLf(content).split("\n"); + const ids = []; - for (let i = 0; i < lines.length; i++) { - const idMatch = /^\s*-\s+id:\s*([^\s#]+)\s*$/.exec(lines[i]); - if (idMatch) { - ids.push({ id: idMatch[1].trim(), line: i + 1 }); - } + for (let i = 0; i < lines.length; i++) { + const idMatch = /^\s*-\s+id:\s*([^\s#]+)\s*$/.exec(lines[i]); + if (idMatch) { + ids.push({ id: idMatch[1].trim(), line: i + 1 }); } + } - return ids; + return ids; } function tokenizeCommand(entry) { - const tokens = entry.match(/"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\S+/g) || []; - return tokens.map((token) => token.replace(/^['"]|['"]$/g, "")); + const tokens = entry.match(/"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\S+/g) || []; + return tokens.map((token) => token.replace(/^['"]|['"]$/g, "")); } function escapeRegexLiteral(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function hasRequiredPreflightCommand(preflightScript, requiredCommand) { - if (typeof preflightScript !== "string" || preflightScript.trim().length === 0) { - return false; - } + if (typeof preflightScript !== "string" || preflightScript.trim().length === 0) { + return false; + } - const normalizedScript = preflightScript.replace(/\s+/g, " ").trim(); - const normalizedRequired = requiredCommand.replace(/\s+/g, " ").trim(); - const commandRegex = new RegExp( - `(?:^|&&\\s*)${escapeRegexLiteral(normalizedRequired)}(?:\\s*&&|$)` - ); + const normalizedScript = preflightScript.replace(/\s+/g, " ").trim(); + const normalizedRequired = requiredCommand.replace(/\s+/g, " ").trim(); + const commandRegex = new RegExp( + `(?:^|&&\\s*)${escapeRegexLiteral(normalizedRequired)}(?:\\s*&&|$)` + ); - return commandRegex.test(normalizedScript); + return commandRegex.test(normalizedScript); } function hasRequiredParserPrecheckCommand(preflightScript) { - return hasRequiredPreflightCommand(preflightScript, REQUIRED_PRECHECK_PARSER_COMMAND); + return hasRequiredPreflightCommand(preflightScript, REQUIRED_PRECHECK_PARSER_COMMAND); } function hasRequiredPackageJsonFormatCommand(preflightScript) { - return hasRequiredPreflightCommand( - preflightScript, - REQUIRED_PACKAGE_JSON_FORMAT_COMMAND - ); + return hasRequiredPreflightCommand(preflightScript, REQUIRED_PACKAGE_JSON_FORMAT_COMMAND); } function hasRequiredScriptsCspellCommand(preflightScript) { - return hasRequiredPreflightCommand( - preflightScript, - REQUIRED_SCRIPTS_CSPELL_COMMAND - ); + return hasRequiredPreflightCommand(preflightScript, REQUIRED_SCRIPTS_CSPELL_COMMAND); +} + +function hasRequiredChangelogValidationCommand(preflightScript) { + return hasRequiredPreflightCommand(preflightScript, REQUIRED_CHANGELOG_VALIDATION_COMMAND); } function hasRequiredParserSuiteTestPaths( - preCommitConfigContent, - requiredTestPaths = REQUIRED_PARSER_SUITE_TEST_PATHS + preCommitConfigContent, + requiredTestPaths = REQUIRED_PARSER_SUITE_TEST_PATHS ) { - const parserSuiteHook = parseHookEntries(preCommitConfigContent).find( - (hook) => hook.id === REQUIRED_PARSER_SUITE_HOOK_ID - ); + const parserSuiteHook = parseHookEntries(preCommitConfigContent).find( + (hook) => hook.id === REQUIRED_PARSER_SUITE_HOOK_ID + ); - if (!parserSuiteHook) { - return false; - } + if (!parserSuiteHook) { + return false; + } - const parserSuiteTokens = new Set(tokenizeCommand(parserSuiteHook.entry)); - return requiredTestPaths.every((requiredTestPath) => - parserSuiteTokens.has(requiredTestPath) - ); + const parserSuiteTokens = new Set(tokenizeCommand(parserSuiteHook.entry)); + return requiredTestPaths.every((requiredTestPath) => parserSuiteTokens.has(requiredTestPath)); } function hasNpxInstallPolicy(entry) { - const tokens = tokenizeCommand(entry); - let foundNpx = false; - - for (let i = 0; i < tokens.length; i++) { - if (tokens[i] !== "npx") { - continue; - } - - foundNpx = true; - let hasPolicy = false; + const tokens = tokenizeCommand(entry); + let foundNpx = false; - for (let j = i + 1; j < tokens.length; j++) { - const token = tokens[j]; + for (let i = 0; i < tokens.length; i++) { + if (tokens[i] !== "npx") { + continue; + } - if (token === "--yes" || token === "-y" || token === "--no") { - hasPolicy = true; - break; - } + foundNpx = true; + let hasPolicy = false; - if (token === "--") { - break; - } + for (let j = i + 1; j < tokens.length; j++) { + const token = tokens[j]; - if (!token.startsWith("-")) { - break; - } - } + if (token === "--yes" || token === "-y" || token === "--no") { + hasPolicy = true; + break; + } - if (!hasPolicy) { - return false; - } - } + if (token === "--") { + break; + } - if (foundNpx) { - return true; + if (!token.startsWith("-")) { + break; + } } - // Fallback for quoted shell fragments that contain npx but were tokenized as a single token. - // This check is intentionally lexical and does not attempt to evaluate shell expansion. - if (/\bnpx\b/.test(entry)) { - return /\b(--yes|-y|--no)\b/.test(entry); + if (!hasPolicy) { + return false; } + } + if (foundNpx) { return true; + } + + // Fallback for quoted shell fragments that contain npx but were tokenized as a single token. + // This check is intentionally lexical and does not attempt to evaluate shell expansion. + if (/\bnpx\b/.test(entry)) { + return /\b(--yes|-y|--no)\b/.test(entry); + } + + return true; } function usesManagedJestWrapper(entry) { - return /\bnode\b\s+scripts\/run-managed-jest\.js\b/.test(entry); + return /\bnode\b\s+scripts\/run-managed-jest\.js\b/.test(entry); } function usesManagedPrettierWrapper(entry) { - return /\bnode\b\s+scripts\/run-managed-prettier\.js\b/.test(entry); + return /\bnode\b\s+scripts\/run-managed-prettier\.js\b/.test(entry); } function isJestRelatedHook(hookId, entry) { - return ( - usesManagedJestWrapper(entry) || - /\bjest\b/.test(entry) || - /script-(?:parser-)?tests/.test(hookId) - ); + return ( + usesManagedJestWrapper(entry) || + /\bjest\b/.test(entry) || + /script-(?:parser-)?tests/.test(hookId) + ); } function hasManagedJestInvocation(hookIdOrEntry, maybeEntry) { - const hookId = maybeEntry === undefined ? "" : hookIdOrEntry; - const entry = maybeEntry === undefined ? hookIdOrEntry : maybeEntry; + const hookId = maybeEntry === undefined ? "" : hookIdOrEntry; + const entry = maybeEntry === undefined ? hookIdOrEntry : maybeEntry; - if (!isJestRelatedHook(hookId, entry)) { - return true; - } + if (!isJestRelatedHook(hookId, entry)) { + return true; + } - return usesManagedJestWrapper(entry); + return usesManagedJestWrapper(entry); } function hasManagedPrettierInvocation(hookId, entry) { - if (hookId !== "prettier") { - return true; - } + if (hookId !== "prettier") { + return true; + } - return usesManagedPrettierWrapper(entry); + return usesManagedPrettierWrapper(entry); } -function validateHookEntries(entries) { - const violations = []; - - for (const hook of entries) { - if (/\bnpx\b/.test(hook.entry) && !hasNpxInstallPolicy(hook.entry)) { - violations.push( - new Violation( - hook.id, - hook.line, - "npx entry must explicitly set install policy with --yes/-y or --no.", - hook.entry - ) - ); - } - - if (!hasManagedJestInvocation(hook.id, hook.entry)) { - violations.push( - new Violation( - hook.id, - hook.line, - "Jest-related hooks must invoke node scripts/run-managed-jest.js.", - hook.entry - ) - ); - } +function hasGuardedFixerRestagePattern(hookId, entry) { + if (hookId !== "fix-csharp-underscore-methods") { + return true; + } - if (!hasManagedPrettierInvocation(hook.id, hook.entry)) { - violations.push( - new Violation( - hook.id, - hook.line, - "Prettier hook must invoke node scripts/run-managed-prettier.js.", - hook.entry - ) - ); - } - } + if (!/\bgit add\b/.test(entry)) { + return false; + } - return violations; + return /git diff --quiet -- "\$@"\s*\|\|\s*git add "\$@"/.test(entry); } -function validateYamllintPolicy(content) { - const violations = []; - const normalized = normalizeToLf(content); - const lines = normalized.split("\n"); - const hookIds = parseHookIds(content); - const yamllintHook = hookIds.find((hook) => hook.id === "yamllint"); - - if (!yamllintHook) { - violations.push( - new Violation( - "yamllint", - 1, - "Missing required yamllint hook. Configure a non-optional yamllint hook in .pre-commit-config.yaml.", - "(missing hook)" - ) - ); +function validateHookEntries(entries) { + const violations = []; + + for (const hook of entries) { + if (/\bnpx\b/.test(hook.entry) && !hasNpxInstallPolicy(hook.entry)) { + violations.push( + new Violation( + hook.id, + hook.line, + "npx entry must explicitly set install policy with --yes/-y or --no.", + hook.entry + ) + ); } - const forbiddenPatterns = [ - /yamllint not installed; skipping/i, - /command\s+-v\s+yamllint/i, - ]; - - for (const pattern of forbiddenPatterns) { - const lineIndex = lines.findIndex((line) => pattern.test(line)); - if (lineIndex !== -1) { - violations.push( - new Violation( - "yamllint", - lineIndex + 1, - "yamllint hook must not be conditionally skipped; use a deterministic managed hook.", - lines[lineIndex].trim() - ) - ); - } + if (!hasManagedJestInvocation(hook.id, hook.entry)) { + violations.push( + new Violation( + hook.id, + hook.line, + "Jest-related hooks must invoke node scripts/run-managed-jest.js.", + hook.entry + ) + ); } - return violations; -} - -function validatePrettierVersionResolution( - getConfiguredPrettierSpecFn = getConfiguredPrettierSpec, - getPinnedPrettierSpecFn = getPinnedPrettierSpec -) { - const violations = []; - - const configuredSpec = getConfiguredPrettierSpecFn(); - if (!configuredSpec) { - violations.push( - new Violation( - "prettier-version", - 1, - "Missing pinned prettier version in package.json devDependencies.", - "(missing package.json devDependencies.prettier)" - ) - ); - return violations; + if (!hasManagedPrettierInvocation(hook.id, hook.entry)) { + violations.push( + new Violation( + hook.id, + hook.line, + "Prettier hook must invoke node scripts/run-managed-prettier.js.", + hook.entry + ) + ); } - const resolvedSpec = getPinnedPrettierSpecFn(); - if (resolvedSpec !== configuredSpec) { - violations.push( - new Violation( - "prettier-version", - 1, - `Resolved managed Prettier spec (${resolvedSpec}) must match package.json (${configuredSpec}).`, - "scripts/lib/prettier-version.js" - ) - ); + if (!hasGuardedFixerRestagePattern(hook.id, hook.entry)) { + violations.push( + new Violation( + hook.id, + hook.line, + "fix-csharp-underscore-methods must guard restaging with 'git diff --quiet -- \"$@\" || git add \"$@\"' to avoid unnecessary git index locking.", + hook.entry + ) + ); } + } - return violations; + return violations; } -function validatePreflightScriptPolicy( - readFileSyncImpl = fs.readFileSync, - packageJsonPath = PACKAGE_JSON_PATH, - preCommitConfigPath = PRE_COMMIT_CONFIG_PATH -) { - const violations = []; - let packageJson; - let preCommitConfig; - - try { - packageJson = JSON.parse(readFileSyncImpl(packageJsonPath, "utf8")); - } catch (error) { - violations.push( - new Violation( - "preflight-script", - 1, - "Unable to parse package.json while validating preflight script policy.", - error.message - ) - ); - return violations; - } - - const preflightScript = packageJson?.scripts?.["preflight:pre-commit"]; - if (typeof preflightScript !== "string" || preflightScript.trim().length === 0) { - violations.push( - new Violation( - "preflight-script", - 1, - "Missing package.json scripts.preflight:pre-commit command.", - "package.json" - ) - ); - return violations; - } - - if (!hasRequiredPackageJsonFormatCommand(preflightScript)) { - violations.push( - new Violation( - "preflight-script", - 1, - `preflight:pre-commit must include '${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND}' so package.json formatting drift is caught before hooks.`, - preflightScript - ) - ); - } - - if (!hasRequiredScriptsCspellCommand(preflightScript)) { - violations.push( - new Violation( - "preflight-script", - 1, - `preflight:pre-commit must include '${REQUIRED_SCRIPTS_CSPELL_COMMAND}' so script spelling regressions are caught before hooks.`, - preflightScript - ) - ); - } - - if (!hasRequiredParserPrecheckCommand(preflightScript)) { - violations.push( - new Violation( - "preflight-script", - 1, - `preflight:pre-commit must include '${REQUIRED_PRECHECK_PARSER_COMMAND}' to match hook parser coverage.`, - preflightScript - ) - ); +function validateYamllintPolicy(content) { + const violations = []; + const normalized = normalizeToLf(content); + const lines = normalized.split("\n"); + const hookIds = parseHookIds(content); + const yamllintHook = hookIds.find((hook) => hook.id === "yamllint"); + + if (!yamllintHook) { + violations.push( + new Violation( + "yamllint", + 1, + "Missing required yamllint hook. Configure a non-optional yamllint hook in .pre-commit-config.yaml.", + "(missing hook)" + ) + ); + } + + const forbiddenPatterns = [/yamllint not installed; skipping/i, /command\s+-v\s+yamllint/i]; + + for (const pattern of forbiddenPatterns) { + const lineIndex = lines.findIndex((line) => pattern.test(line)); + if (lineIndex !== -1) { + violations.push( + new Violation( + "yamllint", + lineIndex + 1, + "yamllint hook must not be conditionally skipped; use a deterministic managed hook.", + lines[lineIndex].trim() + ) + ); } + } - try { - preCommitConfig = readFileSyncImpl(preCommitConfigPath, "utf8"); - } catch (error) { - violations.push( - new Violation( - "preflight-script", - 1, - "Unable to read .pre-commit-config.yaml while validating preflight parser coverage.", - error.message - ) - ); - return violations; - } + return violations; +} - const hasParserSuiteHook = parseHookIds(preCommitConfig).some( - (hook) => hook.id === REQUIRED_PARSER_SUITE_HOOK_ID +function validatePrettierVersionResolution( + getConfiguredPrettierSpecFn = getConfiguredPrettierSpec, + getPinnedPrettierSpecFn = getPinnedPrettierSpec +) { + const violations = []; + + const configuredSpec = getConfiguredPrettierSpecFn(); + if (!configuredSpec) { + violations.push( + new Violation( + "prettier-version", + 1, + "Missing pinned prettier version in package.json devDependencies.", + "(missing package.json devDependencies.prettier)" + ) ); - if (!hasParserSuiteHook) { - violations.push( - new Violation( - "preflight-script", - 1, - `Missing required '${REQUIRED_PARSER_SUITE_HOOK_ID}' hook in .pre-commit-config.yaml.`, - ".pre-commit-config.yaml" - ) - ); - } + return violations; + } + + const resolvedSpec = getPinnedPrettierSpecFn(); + if (resolvedSpec !== configuredSpec) { + violations.push( + new Violation( + "prettier-version", + 1, + `Resolved managed Prettier spec (${resolvedSpec}) must match package.json (${configuredSpec}).`, + "scripts/lib/prettier-version.js" + ) + ); + } - if ( - hasParserSuiteHook && - !hasRequiredParserSuiteTestPaths(preCommitConfig, REQUIRED_PARSER_SUITE_TEST_PATHS) - ) { - violations.push( - new Violation( - "preflight-script", - 1, - `The '${REQUIRED_PARSER_SUITE_HOOK_ID}' hook entry must include required regression test path(s): ${REQUIRED_PARSER_SUITE_TEST_PATHS.join( - ", " - )}.`, - REQUIRED_PARSER_SUITE_HOOK_ID - ) - ); - } + return violations; +} +function validatePreflightScriptPolicy( + readFileSyncImpl = fs.readFileSync, + packageJsonPath = PACKAGE_JSON_PATH, + preCommitConfigPath = PRE_COMMIT_CONFIG_PATH +) { + const violations = []; + let packageJson; + let preCommitConfig; + + try { + packageJson = JSON.parse(readFileSyncImpl(packageJsonPath, "utf8")); + } catch (error) { + violations.push( + new Violation( + "preflight-script", + 1, + "Unable to parse package.json while validating preflight script policy.", + error.message + ) + ); + return violations; + } + + const preflightScript = packageJson?.scripts?.["preflight:pre-commit"]; + if (typeof preflightScript !== "string" || preflightScript.trim().length === 0) { + violations.push( + new Violation( + "preflight-script", + 1, + "Missing package.json scripts.preflight:pre-commit command.", + "package.json" + ) + ); + return violations; + } + + if (!hasRequiredPackageJsonFormatCommand(preflightScript)) { + violations.push( + new Violation( + "preflight-script", + 1, + `preflight:pre-commit must include '${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND}' so package.json formatting drift is caught before hooks.`, + preflightScript + ) + ); + } + + if (!hasRequiredScriptsCspellCommand(preflightScript)) { + violations.push( + new Violation( + "preflight-script", + 1, + `preflight:pre-commit must include '${REQUIRED_SCRIPTS_CSPELL_COMMAND}' so script spelling regressions are caught before hooks.`, + preflightScript + ) + ); + } + + if (!hasRequiredChangelogValidationCommand(preflightScript)) { + violations.push( + new Violation( + "preflight-script", + 1, + `preflight:pre-commit must include '${REQUIRED_CHANGELOG_VALIDATION_COMMAND}' so changelog policy drift is caught before hooks.`, + preflightScript + ) + ); + } + + if (!hasRequiredParserPrecheckCommand(preflightScript)) { + violations.push( + new Violation( + "preflight-script", + 1, + `preflight:pre-commit must include '${REQUIRED_PRECHECK_PARSER_COMMAND}' to match hook parser coverage.`, + preflightScript + ) + ); + } + + try { + preCommitConfig = readFileSyncImpl(preCommitConfigPath, "utf8"); + } catch (error) { + violations.push( + new Violation( + "preflight-script", + 1, + "Unable to read .pre-commit-config.yaml while validating preflight parser coverage.", + error.message + ) + ); return violations; + } + + const hasParserSuiteHook = parseHookIds(preCommitConfig).some( + (hook) => hook.id === REQUIRED_PARSER_SUITE_HOOK_ID + ); + if (!hasParserSuiteHook) { + violations.push( + new Violation( + "preflight-script", + 1, + `Missing required '${REQUIRED_PARSER_SUITE_HOOK_ID}' hook in .pre-commit-config.yaml.`, + ".pre-commit-config.yaml" + ) + ); + } + + if ( + hasParserSuiteHook && + !hasRequiredParserSuiteTestPaths(preCommitConfig, REQUIRED_PARSER_SUITE_TEST_PATHS) + ) { + violations.push( + new Violation( + "preflight-script", + 1, + `The '${REQUIRED_PARSER_SUITE_HOOK_ID}' hook entry must include required regression test path(s): ${REQUIRED_PARSER_SUITE_TEST_PATHS.join( + ", " + )}.`, + REQUIRED_PARSER_SUITE_HOOK_ID + ) + ); + } + + return violations; } function validateConfigContent( - content, - { - readFileSyncImpl = fs.readFileSync, - packageJsonPath = PACKAGE_JSON_PATH, - preCommitConfigPath = PRE_COMMIT_CONFIG_PATH, - getConfiguredPrettierSpecFn = getConfiguredPrettierSpec, - getPinnedPrettierSpecFn = getPinnedPrettierSpec, - } = {} + content, + { + readFileSyncImpl = fs.readFileSync, + packageJsonPath = PACKAGE_JSON_PATH, + preCommitConfigPath = PRE_COMMIT_CONFIG_PATH, + getConfiguredPrettierSpecFn = getConfiguredPrettierSpec, + getPinnedPrettierSpecFn = getPinnedPrettierSpec + } = {} ) { - const hooks = parseHookEntries(content); - return [ - ...validatePreflightScriptPolicy( - readFileSyncImpl, - packageJsonPath, - preCommitConfigPath - ), - ...validateHookEntries(hooks), - ...validateYamllintPolicy(content), - ...validatePrettierVersionResolution( - getConfiguredPrettierSpecFn, - getPinnedPrettierSpecFn - ), - ]; + const hooks = parseHookEntries(content); + return [ + ...validatePreflightScriptPolicy(readFileSyncImpl, packageJsonPath, preCommitConfigPath), + ...validateHookEntries(hooks), + ...validateYamllintPolicy(content), + ...validatePrettierVersionResolution(getConfiguredPrettierSpecFn, getPinnedPrettierSpecFn) + ]; } -function validateConfigFile( - filePath = PRE_COMMIT_CONFIG_PATH, - readFileSyncImpl = fs.readFileSync -) { - const content = readFileSyncImpl(filePath, "utf8"); - const resolvedFilePath = path.resolve(filePath); +function validateConfigFile(filePath = PRE_COMMIT_CONFIG_PATH, readFileSyncImpl = fs.readFileSync) { + const content = readFileSyncImpl(filePath, "utf8"); + const resolvedFilePath = path.resolve(filePath); - const readFileSyncWithCachedConfig = (targetPath, encoding) => { - if (path.resolve(targetPath) === resolvedFilePath) { - return content; - } + const readFileSyncWithCachedConfig = (targetPath, encoding) => { + if (path.resolve(targetPath) === resolvedFilePath) { + return content; + } - return readFileSyncImpl(targetPath, encoding); - }; + return readFileSyncImpl(targetPath, encoding); + }; - return validateConfigContent(content, { - readFileSyncImpl: readFileSyncWithCachedConfig, - preCommitConfigPath: filePath, - }); + return validateConfigContent(content, { + readFileSyncImpl: readFileSyncWithCachedConfig, + preCommitConfigPath: filePath + }); } function main() { - const violations = validateConfigFile(PRE_COMMIT_CONFIG_PATH); + const violations = validateConfigFile(PRE_COMMIT_CONFIG_PATH); - if (violations.length === 0) { - console.log("✅ Pre-commit Node tooling validation passed."); - process.exit(0); - } + if (violations.length === 0) { + console.log("✅ Pre-commit Node tooling validation passed."); + process.exit(0); + } - console.error(`❌ Found ${violations.length} pre-commit tooling violation(s):`); - for (const violation of violations) { - console.error(`\n- ${violation.toString()}`); - } + console.error(`❌ Found ${violations.length} pre-commit tooling violation(s):`); + for (const violation of violations) { + console.error(`\n- ${violation.toString()}`); + } - process.exit(1); + process.exit(1); } module.exports = { - PRE_COMMIT_CONFIG_PATH, - Violation, - getIndent, - parseHookEntries, - parseHookIds, - tokenizeCommand, - escapeRegexLiteral, - hasRequiredPreflightCommand, - hasRequiredParserPrecheckCommand, - hasRequiredPackageJsonFormatCommand, - hasRequiredScriptsCspellCommand, - hasNpxInstallPolicy, - usesManagedJestWrapper, - usesManagedPrettierWrapper, - isJestRelatedHook, - hasManagedJestInvocation, - hasManagedPrettierInvocation, - validateHookEntries, - validateYamllintPolicy, - validatePrettierVersionResolution, - validatePreflightScriptPolicy, - PACKAGE_JSON_PATH, - REQUIRED_PRECHECK_PARSER_COMMAND, - REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, - REQUIRED_SCRIPTS_CSPELL_COMMAND, - REQUIRED_PARSER_SUITE_HOOK_ID, - REQUIRED_PARSER_SUITE_TEST_PATHS, - hasRequiredParserSuiteTestPaths, - validateConfigContent, - validateConfigFile, + PRE_COMMIT_CONFIG_PATH, + Violation, + getIndent, + parseHookEntries, + parseHookIds, + tokenizeCommand, + escapeRegexLiteral, + hasRequiredPreflightCommand, + hasRequiredParserPrecheckCommand, + hasRequiredPackageJsonFormatCommand, + hasRequiredScriptsCspellCommand, + hasRequiredChangelogValidationCommand, + hasNpxInstallPolicy, + usesManagedJestWrapper, + usesManagedPrettierWrapper, + isJestRelatedHook, + hasManagedJestInvocation, + hasManagedPrettierInvocation, + hasGuardedFixerRestagePattern, + validateHookEntries, + validateYamllintPolicy, + validatePrettierVersionResolution, + validatePreflightScriptPolicy, + PACKAGE_JSON_PATH, + REQUIRED_PRECHECK_PARSER_COMMAND, + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + REQUIRED_SCRIPTS_CSPELL_COMMAND, + REQUIRED_CHANGELOG_VALIDATION_COMMAND, + REQUIRED_PARSER_SUITE_HOOK_ID, + REQUIRED_PARSER_SUITE_TEST_PATHS, + hasRequiredParserSuiteTestPaths, + validateConfigContent, + validateConfigFile }; if (require.main === module) { - main(); + main(); } diff --git a/scripts/validate-workflows.js b/scripts/validate-workflows.js index 0a4f3251..450e3677 100644 --- a/scripts/validate-workflows.js +++ b/scripts/validate-workflows.js @@ -169,6 +169,12 @@ function isGitIgnoredPath(repoRoot, relativePath, execFileSyncImpl = execFileSyn ); }; + const throwGitUnavailableError = (phase) => { + throw new Error( + `Unable to evaluate git ignore status for '${relativePath}': git executable was not found on PATH (${phase}).` + ); + }; + try { runCheckIgnore(["check-ignore", "--quiet", "--no-index", "--", relativePath]); return true; @@ -178,7 +184,7 @@ function isGitIgnoredPath(repoRoot, relativePath, execFileSyncImpl = execFileSyn } if (error && error.code === "ENOENT") { - return false; + throwGitUnavailableError("check-ignore --no-index"); } if (isUnsupportedNoIndex(error)) { @@ -195,7 +201,7 @@ function isGitIgnoredPath(repoRoot, relativePath, execFileSyncImpl = execFileSyn } if (fallbackError && fallbackError.code === "ENOENT") { - return false; + throwGitUnavailableError("check-ignore fallback"); } const fallbackMessage = @@ -882,10 +888,7 @@ function validateWorkflow(filePath, options = {}) { const violations = []; const repoRoot = options.repoRoot || REPO_ROOT; const isIgnoredPathFn = options.isIgnoredPathFn || isGitIgnoredPath; - const relativePath = path.relative( - path.join(__dirname, ".."), - filePath - ); + const relativePath = path.relative(repoRoot, filePath).replace(/\\/g, "/"); let content; try { From d527485ec98a7d4b5959660b50bb06d96f22e1e7 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Thu, 30 Apr 2026 21:41:00 -0700 Subject: [PATCH 12/12] PR feedback --- .github/scripts/check-markdown-links.ps1 | 21 ++- .github/scripts/check_markdown_links.py | 100 +++++++---- .../scripts/check_markdown_url_encoding.py | 68 ++++++- .github/scripts/test_check_markdown_links.py | 98 ++++++++++ .../test_check_markdown_url_encoding.py | 170 ++++++++++++++++++ .../workflows/markdown-link-text-check.yml | 13 +- .llm/context.md | 4 +- .../documentation/changelog-entry-writing.md | 19 ++ .llm/skills/index.md | 2 +- .../testing/inspector-overlay-invariants.md | 4 +- .pre-commit-config.yaml | 2 +- AGENTS.md | 7 +- CHANGELOG.md | 12 +- CLAUDE.md | 7 +- CONTRIBUTING.md | 4 +- .../WallstopStudios.DxMessaging.Analyzer.dll | Bin 23040 -> 21504 bytes ...opStudios.DxMessaging.SourceGenerators.dll | Bin 34816 -> 33280 bytes .../MessageAwareComponentBaseCallAnalyzer.cs | 2 +- ...sageAwareComponentBaseCallAnalyzerTests.cs | 24 +++ .../pre-commit-hook-stage-policy.test.js | 5 +- scripts/__tests__/validate-changelog.test.js | 35 ++++ scripts/validate-changelog.js | 29 ++- 22 files changed, 558 insertions(+), 68 deletions(-) create mode 100644 .github/scripts/test_check_markdown_url_encoding.py diff --git a/.github/scripts/check-markdown-links.ps1 b/.github/scripts/check-markdown-links.ps1 index d72c77d3..3f8ac463 100644 --- a/.github/scripts/check-markdown-links.ps1 +++ b/.github/scripts/check-markdown-links.ps1 @@ -14,6 +14,8 @@ function Normalize-Name { } $issueCount = 0 +$scannedFileCount = 0 +$issuesByFile = @{} # Exclude typical directories that shouldn't be scanned $excludeDirs = @('.git', 'node_modules', '.vs', '.venv', '.artifacts', 'site', 'Library', 'Obj', 'Temp', 'Samples~') @@ -28,6 +30,7 @@ $mdFiles = Get-ChildItem -Path $Root -Recurse -File -Filter *.md | $pattern = '(?[^\]]+)\]\((?[^)\s]+)(?:\s+"[^"]*")?\)' foreach ($file in $mdFiles) { + $scannedFileCount++ $lines = Get-Content -LiteralPath $file.FullName -Encoding UTF8 $inCodeBlock = $false $codeFencePattern = $null @@ -37,11 +40,12 @@ foreach ($file in $mdFiles) { # Skip fenced code blocks $trimmedLine = $line.TrimStart() - if ($trimmedLine -match '^(`{3,})') { + $trimmedFenceLine = $trimmedLine.Trim() + if ($trimmedLine -match '^(?`{3,}|~{3,})') { if (-not $inCodeBlock) { $inCodeBlock = $true - $codeFencePattern = $Matches[1] - } elseif ($trimmedLine.StartsWith($codeFencePattern) -and $trimmedLine.Trim() -match "^$([regex]::Escape($codeFencePattern))") { + $codeFencePattern = $Matches['fence'] + } elseif ($trimmedFenceLine -eq $codeFencePattern) { $inCodeBlock = $false $codeFencePattern = $null } @@ -82,6 +86,10 @@ foreach ($file in $mdFiles) { if ($isExactFileName -or $looksLikePath -or $looksLikeMarkdownFileName) { $issueCount++ + if (-not $issuesByFile.ContainsKey($file.FullName)) { + $issuesByFile[$file.FullName] = 0 + } + $issuesByFile[$file.FullName]++ $lineNo = $i + 1 $msg = "Link text '$text' should be human-readable, not a raw file name or path" # GitHub Actions annotation @@ -92,10 +100,15 @@ foreach ($file in $mdFiles) { } if ($issueCount -gt 0) { + Write-Host "Scanned $scannedFileCount markdown file(s) under '$Root'." -ForegroundColor Yellow + Write-Host "Issue count by file:" -ForegroundColor Yellow + foreach ($entry in ($issuesByFile.GetEnumerator() | Sort-Object Name)) { + Write-Host " - $($entry.Name): $($entry.Value)" -ForegroundColor Yellow + } Write-Host "Found $issueCount documentation link(s) with non-human-readable text." -ForegroundColor Red Write-Host "Use a descriptive phrase instead of the raw file name." exit 1 } else { - Write-Host "All markdown links have human-readable text." + Write-Host "Scanned $scannedFileCount markdown file(s); all markdown-to-markdown links use human-readable text." } diff --git a/.github/scripts/check_markdown_links.py b/.github/scripts/check_markdown_links.py index c874721c..6b3ffc87 100644 --- a/.github/scripts/check_markdown_links.py +++ b/.github/scripts/check_markdown_links.py @@ -5,7 +5,18 @@ import urllib.parse -EXCLUDE_DIRS = {".git", "node_modules", ".vs"} +EXCLUDE_DIRS = { + ".git", + "node_modules", + ".vs", + ".venv", + ".artifacts", + "site", + "Library", + "Obj", + "Temp", + "Samples~", +} LINK_RE = re.compile(r"(?[^\]]+)\]\((?P[^)\s]+)(?:\s+\"[^\"]*\")?\)") @@ -66,17 +77,24 @@ def check_code_fence(stripped_line: str, in_code_block: bool, code_fence_pattern Returns: Tuple of (new_in_code_block, new_code_fence_pattern, is_fence_line) """ - if not stripped_line.startswith("```"): + if not stripped_line: return in_code_block, code_fence_pattern, False - # Count the backticks at the start - backtick_count = 0 + fence_char = stripped_line[0] + if fence_char not in ("`", "~"): + return in_code_block, code_fence_pattern, False + + if not stripped_line.startswith(fence_char * 3): + return in_code_block, code_fence_pattern, False + + # Count the fence characters at the start. + fence_count = 0 for ch in stripped_line: - if ch == "`": - backtick_count += 1 + if ch == fence_char: + fence_count += 1 else: break - fence = "`" * backtick_count + fence = fence_char * fence_count if not in_code_block: # Entering a code block @@ -148,37 +166,59 @@ def check_file_content(lines: list) -> list: return issues -def main(root: str) -> int: - issues = 0 +def iter_markdown_files(root: str): + """Yield markdown files under root in deterministic order.""" for dirpath, dirnames, filenames in os.walk(root): - # prune excluded directories - dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS] - for filename in filenames: - if not filename.lower().endswith(".md"): - continue - path = os.path.join(dirpath, filename) - try: - with open(path, "r", encoding="utf-8") as f: - lines = f.readlines() - except Exception: - # Skip files that cannot be read (permission errors, encoding issues, etc.) - continue - - file_issues = check_file_content(lines) - for line_num, text, target in file_issues: - issues += 1 - msg = f"{path}:{line_num}: Link text '{text}' should be human-readable, not a raw file name or path (target: {target})" - print(msg) - - if issues: + # Prune excluded directories and sort for deterministic output across platforms. + dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDE_DIRS) + for filename in sorted(filenames): + if filename.lower().endswith(".md"): + yield os.path.join(dirpath, filename) + + +def main(root: str) -> int: + issue_count = 0 + scanned_files = 0 + file_issue_counts = {} + + for path in iter_markdown_files(root): + scanned_files += 1 + try: + with open(path, "r", encoding="utf-8") as f: + lines = f.readlines() + except Exception: + # Skip files that cannot be read (permission errors, encoding issues, etc.) + continue + + file_issues = check_file_content(lines) + if file_issues: + file_issue_counts[path] = len(file_issues) + + for line_num, text, target in file_issues: + issue_count += 1 + msg = f"{path}:{line_num}: Link text '{text}' should be human-readable, not a raw file name or path (target: {target})" + print(msg) + + if issue_count: print( - f"Found {issues} documentation link(s) with non-human-readable text.", + f"Scanned {scanned_files} markdown file(s) under '{root}'.", + file=sys.stderr, + ) + print("Issue count by file:", file=sys.stderr) + for path, count in sorted(file_issue_counts.items()): + print(f" - {path}: {count}", file=sys.stderr) + print( + f"Found {issue_count} documentation link(s) with non-human-readable text.", file=sys.stderr, ) print( "Use a descriptive phrase instead of the raw file name.", file=sys.stderr ) return 1 + + print( + f"Scanned {scanned_files} markdown file(s); all markdown-to-markdown links use human-readable text." + ) return 0 diff --git a/.github/scripts/check_markdown_url_encoding.py b/.github/scripts/check_markdown_url_encoding.py index a44da3ee..9d71b1ae 100644 --- a/.github/scripts/check_markdown_url_encoding.py +++ b/.github/scripts/check_markdown_url_encoding.py @@ -8,12 +8,15 @@ # Inline markdown link or image: ![alt](target "title") or [text](target "title") -INLINE_LINK_RE = re.compile( - r"!?(?P\[(?P[^\]]+)\]\((?P[^)\s]+)(?:\s+\"[^\"]*\")?\))" -) +INLINE_LINK_RE = re.compile(r"!?\[[^\]]+\]\((?P[^)]*)\)") # Reference-style link definitions: [id]: target "title" -REF_DEF_RE = re.compile(r"^\s*\[[^\]]+\]:\s*(?P\S+)(?:\s+\"[^\"]*\")?\s*$") +# Ignore PowerShell static-member syntax like [System.IO.File]::WriteAllText(...) +# by rejecting a second colon immediately after the delimiter colon. +REF_DEF_RE = re.compile(r"^\s*\[[^\]]+\]:\s*(?!:)(?P.+?)\s*$") + +# Optional quoted title suffix used by both inline and reference-style links. +TITLE_SUFFIX_RE = re.compile(r'^(?P.+?)(?:\s+"[^"]*")?\s*$') def is_external(target: str) -> bool: @@ -25,6 +28,47 @@ def has_unencoded_chars(target: str) -> bool: return (" " in target) or ("+" in target) +def extract_target(raw_body: str) -> str: + """Extract the link target from a markdown link body that may include a quoted title.""" + body = raw_body.strip() + if not body: + return "" + + m = TITLE_SUFFIX_RE.match(body) + if not m: + return body + + return m.group("target").strip() + + +def update_code_fence_state(stripped_line: str, in_code_block: bool, code_fence_pattern: str): + """Track fenced code blocks delimited by backticks or tildes.""" + if not stripped_line: + return in_code_block, code_fence_pattern, False + + fence_char = stripped_line[0] + if fence_char not in ("`", "~"): + return in_code_block, code_fence_pattern, False + + if not stripped_line.startswith(fence_char * 3): + return in_code_block, code_fence_pattern, False + + fence_count = 0 + for ch in stripped_line: + if ch == fence_char: + fence_count += 1 + else: + break + fence = fence_char * fence_count + + if not in_code_block: + return True, fence, True + if stripped_line.startswith(code_fence_pattern) and stripped_line.strip() == code_fence_pattern: + return False, None, True + + return in_code_block, code_fence_pattern, False + + def scan_file(path: str) -> int: issues = 0 try: @@ -33,10 +77,22 @@ def scan_file(path: str) -> int: except Exception: return 0 + in_code_block = False + code_fence_pattern = None + for idx, line in enumerate(lines, start=1): + stripped = line.lstrip() + in_code_block, code_fence_pattern, is_fence = update_code_fence_state( + stripped, + in_code_block, + code_fence_pattern, + ) + if is_fence or in_code_block: + continue + # Inline links/images for m in INLINE_LINK_RE.finditer(line): - target = m.group("target").strip() + target = extract_target(m.group("body")) if is_external(target): continue if has_unencoded_chars(target): @@ -46,7 +102,7 @@ def scan_file(path: str) -> int: # Reference-style link definitions m = REF_DEF_RE.match(line) if m: - target = m.group("target").strip() + target = extract_target(m.group("body")) if not is_external(target) and has_unencoded_chars(target): issues += 1 print(f"{path}:{idx}: Unencoded character(s) in link definition: '{target}'. Encode spaces as %20 and '+' as %2B.") diff --git a/.github/scripts/test_check_markdown_links.py b/.github/scripts/test_check_markdown_links.py index 17cd3da0..a86fd03c 100644 --- a/.github/scripts/test_check_markdown_links.py +++ b/.github/scripts/test_check_markdown_links.py @@ -5,6 +5,8 @@ Run with: python3 -m pytest test_check_markdown_links.py -v Or: python3 -m unittest test_check_markdown_links -v """ +import os +import tempfile import unittest from check_markdown_links import ( @@ -14,6 +16,7 @@ check_code_fence, check_line_for_issues, check_file_content, + iter_markdown_files, LINK_RE, ) @@ -94,6 +97,72 @@ def test_path_with_spaces_is_not_problematic(self): self.assertFalse(is_link_text_problematic("docs / guide", "guide.md")) +class TestDataDrivenMatrices(unittest.TestCase): + """Data-driven matrix tests for core link classification behavior.""" + + def test_should_check_target_matrix(self): + cases = [ + ("README.md", True), + ("docs/guide.md#intro", True), + ("My%20Doc.md", True), + ("https://example.com/readme.md", False), + ("#top", False), + ("mailto:test@example.com", False), + ("guide.txt", False), + ] + + for target, expected in cases: + with self.subTest(target=target): + self.assertEqual(should_check_target(target), expected) + + def test_is_link_text_problematic_matrix(self): + cases = [ + ("README.md", "README.md", True), + ("docs/README.md", "docs/README.md", True), + ("My File.md", "My%20File.md", True), + ("the README", "README.md", False), + ("Setup Guide", "docs/setup.md", False), + ("docs / setup", "docs/setup.md", False), + ] + + for text, target, expected in cases: + with self.subTest(text=text, target=target): + self.assertEqual(is_link_text_problematic(text, target), expected) + + +class TestIterMarkdownFiles(unittest.TestCase): + """Tests for deterministic markdown file iteration and directory exclusion.""" + + def test_iter_markdown_files_sorts_and_excludes_known_directories(self): + with tempfile.TemporaryDirectory() as tmp_dir: + docs_dir = os.path.join(tmp_dir, "docs") + node_modules_dir = os.path.join(tmp_dir, "node_modules") + temp_dir = os.path.join(tmp_dir, "Temp") + os.makedirs(docs_dir) + os.makedirs(node_modules_dir) + os.makedirs(temp_dir) + + for rel_path in [ + "b.md", + "a.md", + "docs/c.md", + "node_modules/ignored.md", + "Temp/ignored-temp.md", + "docs/not-markdown.txt", + ]: + full_path = os.path.join(tmp_dir, rel_path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + with open(full_path, "w", encoding="utf-8") as handle: + handle.write("placeholder") + + discovered = [ + os.path.relpath(path, tmp_dir).replace("\\", "/") + for path in iter_markdown_files(tmp_dir) + ] + + self.assertEqual(discovered, ["a.md", "b.md", "docs/c.md"]) + + class TestRemoveInlineCode(unittest.TestCase): """Tests for the remove_inline_code function.""" @@ -138,6 +207,18 @@ def test_exiting_triple_backtick_code_block(self): self.assertIsNone(pattern) self.assertTrue(is_fence) + def test_entering_triple_tilde_code_block(self): + in_block, pattern, is_fence = check_code_fence("~~~markdown", False, None) + self.assertTrue(in_block) + self.assertEqual(pattern, "~~~") + self.assertTrue(is_fence) + + def test_exiting_triple_tilde_code_block(self): + in_block, pattern, is_fence = check_code_fence("~~~", True, "~~~") + self.assertFalse(in_block) + self.assertIsNone(pattern) + self.assertTrue(is_fence) + def test_entering_quad_backtick_code_block(self): in_block, pattern, is_fence = check_code_fence("````markdown", False, None) self.assertTrue(in_block) @@ -157,6 +238,12 @@ def test_triple_backticks_inside_quad_block_do_not_exit(self): self.assertEqual(pattern, "````") self.assertFalse(is_fence) + def test_backticks_inside_tilde_block_do_not_exit(self): + in_block, pattern, is_fence = check_code_fence("```", True, "~~~") + self.assertTrue(in_block) + self.assertEqual(pattern, "~~~") + self.assertFalse(is_fence) + def test_non_fence_line_does_not_change_state(self): in_block, pattern, is_fence = check_code_fence("normal line", False, None) self.assertFalse(in_block) @@ -271,6 +358,17 @@ def test_skips_quad_backtick_code_blocks(self): issues = check_file_content(lines) self.assertEqual(len(issues), 0) + def test_skips_triple_tilde_code_blocks(self): + lines = [ + "# Header\n", + "~~~markdown\n", + "[README.md](README.md)\n", + "~~~\n", + "Normal text\n", + ] + issues = check_file_content(lines) + self.assertEqual(len(issues), 0) + def test_handles_nested_code_blocks(self): # ``` inside ```` should not close the outer block lines = [ diff --git a/.github/scripts/test_check_markdown_url_encoding.py b/.github/scripts/test_check_markdown_url_encoding.py new file mode 100644 index 00000000..30137267 --- /dev/null +++ b/.github/scripts/test_check_markdown_url_encoding.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Tests for check_markdown_url_encoding.py.""" + +import contextlib +import io +import os +import tempfile +import unittest + +from check_markdown_url_encoding import ( + extract_target, + has_unencoded_chars, + is_external, + main, + scan_file, +) + + +class TestIsExternal(unittest.TestCase): + """Data-driven tests for external target detection.""" + + def test_matrix(self): + cases = [ + ("https://example.com/docs/readme.md", True), + ("http://example.com/docs/readme.md", True), + ("mailto:test@example.com", True), + ("tel:+1234567890", True), + ("data:text/plain;base64,SGVsbG8=", True), + ("docs/readme.md", False), + ("./docs/readme.md", False), + ("#section", False), + ] + + for target, expected in cases: + with self.subTest(target=target): + self.assertEqual(is_external(target), expected) + + +class TestHasUnencodedChars(unittest.TestCase): + """Data-driven tests for URL encoding validation.""" + + def test_matrix(self): + cases = [ + ("docs/My File.md", True), + ("docs/Feature+Guide.md", True), + ("docs/My%20File.md", False), + ("docs/Feature%2BGuide.md", False), + ("docs/normal-file.md", False), + ] + + for target, expected in cases: + with self.subTest(target=target): + self.assertEqual(has_unencoded_chars(target), expected) + + +class TestExtractTarget(unittest.TestCase): + """Data-driven tests for link-body target extraction.""" + + def test_matrix(self): + cases = [ + ("docs/Guide.md", "docs/Guide.md"), + ("docs/Guide.md \"title\"", "docs/Guide.md"), + ("docs/Guide File.md", "docs/Guide File.md"), + ("docs/Guide File.md \"title\"", "docs/Guide File.md"), + ("", ""), + ("", ""), + ] + + for body, expected in cases: + with self.subTest(body=body): + self.assertEqual(extract_target(body), expected) + + +class TestScanFile(unittest.TestCase): + """Tests for file-level URL encoding checks.""" + + def test_reports_inline_and_reference_issues(self): + markdown = "\n".join( + [ + "[Good](docs/Good%20Guide.md)", + "[Bad Space](docs/Bad Guide.md)", + "![Bad Plus](images/Feature+Diagram.png)", + "[ref-ok]: docs/Ref%2BGuide.md", + "[ref-bad]: docs/Ref Guide.md", + "[System.IO.File]::WriteAllText($path, $content)", + ] + ) + + with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as handle: + path = handle.name + handle.write(markdown) + + try: + out_buffer = io.StringIO() + with contextlib.redirect_stdout(out_buffer): + issues = scan_file(path) + + self.assertEqual(issues, 3) + output = out_buffer.getvalue() + self.assertIn("Bad Guide.md", output) + self.assertIn("Feature+Diagram.png", output) + self.assertIn("Ref Guide.md", output) + self.assertNotIn("WriteAllText", output) + finally: + os.unlink(path) + + def test_ignores_links_inside_backtick_and_tilde_code_fences(self): + markdown = "\n".join( + [ + "```markdown", + "[Bad](docs/Bad Guide.md)", + "[ref-bad]: docs/Ref Guide.md", + "```", + "~~~markdown", + "[Also Bad](docs/Another Bad.md)", + "~~~", + "[Good](docs/Good%20Guide.md)", + ] + ) + + with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as handle: + path = handle.name + handle.write(markdown) + + try: + out_buffer = io.StringIO() + with contextlib.redirect_stdout(out_buffer): + issues = scan_file(path) + + self.assertEqual(issues, 0) + self.assertEqual(out_buffer.getvalue(), "") + finally: + os.unlink(path) + + +class TestMain(unittest.TestCase): + """Tests for root scanning and exclusion behavior.""" + + def test_excluded_directories_are_not_scanned(self): + with tempfile.TemporaryDirectory() as root: + temp_dir = os.path.join(root, "Temp") + os.makedirs(temp_dir) + + excluded_file = os.path.join(temp_dir, "ignored.md") + with open(excluded_file, "w", encoding="utf-8") as handle: + handle.write("[Bad](docs/Bad Guide.md)\n") + + stderr_buffer = io.StringIO() + with contextlib.redirect_stderr(stderr_buffer), contextlib.redirect_stdout(io.StringIO()): + exit_code = main(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr_buffer.getvalue(), "") + + def test_returns_nonzero_when_issues_exist(self): + with tempfile.TemporaryDirectory() as root: + markdown_file = os.path.join(root, "README.md") + with open(markdown_file, "w", encoding="utf-8") as handle: + handle.write("[Bad](docs/Bad Guide.md)\n") + + stderr_buffer = io.StringIO() + with contextlib.redirect_stderr(stderr_buffer), contextlib.redirect_stdout(io.StringIO()): + exit_code = main(root) + + self.assertEqual(exit_code, 1) + self.assertIn("Found 1 markdown link(s)", stderr_buffer.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/markdown-link-text-check.yml b/.github/workflows/markdown-link-text-check.yml index 7732cb2c..5eb0c01c 100644 --- a/.github/workflows/markdown-link-text-check.yml +++ b/.github/workflows/markdown-link-text-check.yml @@ -7,6 +7,8 @@ on: - "**/*.markdown" - ".github/scripts/check_markdown_links.py" - ".github/scripts/check_markdown_url_encoding.py" + - ".github/scripts/test_check_markdown_links.py" + - ".github/scripts/test_check_markdown_url_encoding.py" push: branches: - main @@ -16,6 +18,8 @@ on: - "**/*.markdown" - ".github/scripts/check_markdown_links.py" - ".github/scripts/check_markdown_url_encoding.py" + - ".github/scripts/test_check_markdown_links.py" + - ".github/scripts/test_check_markdown_url_encoding.py" workflow_dispatch: jobs: @@ -25,8 +29,13 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Run markdown link checker unit tests + run: >- + PYTHONPATH=.github/scripts + python3 -m unittest discover -s .github/scripts -p "test_check_markdown_*.py" + - name: Validate link text for markdown-to-markdown links - run: python .github/scripts/check_markdown_links.py . + run: python3 .github/scripts/check_markdown_links.py . - name: Verify relative links are URL-encoded - run: python .github/scripts/check_markdown_url_encoding.py . + run: python3 .github/scripts/check_markdown_url_encoding.py . diff --git a/.llm/context.md b/.llm/context.md index 8be2e4fe..3cb565d3 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -26,6 +26,7 @@ This file is intentionally concise. It contains only critical, high-signal guida - Never commit repository settings that auto-approve chat-invoked terminal commands. - Ensure fenced markdown examples are closed and do not swallow real sections (for example `## See Also`). - Run file-scoped validation during editing; do not treat git hooks as the first signal of quality issues. +- 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. - 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. @@ -107,7 +108,8 @@ This file is intentionally concise. It contains only critical, high-signal guida - Keep examples accurate and aligned with real usage. - Update `CHANGELOG.md` only for user-facing DxMessaging changes, not developer-only tooling/process updates. - For `## [Unreleased]` entries, mutate existing bullets as behavior evolves; do not stack separate `Added` then `Fixed` bullets for the same unreleased change. -- When likely user-visible files change (`Runtime/`, `SourceGenerators/`, `Samples~/`, and user-facing `Editor/` code), ensure `CHANGELOG.md` is updated in the same change and run `npm run validate:changelog:coverage`. +- When likely user-visible files change (`Runtime/`, `Samples~/`, user-facing `Editor/`, `SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/`, or `SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/`), ensure `CHANGELOG.md` is updated in the same change and run `npm run validate:changelog:coverage`. +- If changelog validation raises `W002`, rewrite the entry to foreground user impact or move internal-only details to developer docs. - 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). diff --git a/.llm/skills/documentation/changelog-entry-writing.md b/.llm/skills/documentation/changelog-entry-writing.md index ddba9614..31cdcc17 100644 --- a/.llm/skills/documentation/changelog-entry-writing.md +++ b/.llm/skills/documentation/changelog-entry-writing.md @@ -210,6 +210,25 @@ To upgrade from v2.x: - Fixed memory leak in MessageBus when used in Play Mode tests ``` +### Bad: Internal Tooling Framing + +```markdown +### Added + +- Added automation script for agent prompt routing +``` + +**Why it's wrong**: This describes internal process/tooling, not user-facing product impact. + +**Correct**: + +```markdown +### Changed + +- Improved AI-assistant onboarding for users by adding `llms.txt` and README guidance + so package docs and APIs are discovered with accurate context +``` + ### Bad: Missing Breaking Change Warnings ```markdown diff --git a/.llm/skills/index.md b/.llm/skills/index.md index 62769f95..d2107133 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -31,7 +31,7 @@ | Skill | Lines | Complexity | Status | Performance | Tags | | ----------------------------------------------------------------------------------------------------- | ---------- | -------------- | -------- | ------------ | ---------------------------- | | [ASCII-Only Documentation Policy](./documentation/ascii-only-docs.md) | [ok] 173 | [basic] | [stable] | [risk: none] | documentation, ascii | -| [Changelog Entry Writing and Anti-Patterns](./documentation/changelog-entry-writing.md) | [warn] 277 | [basic] | [stable] | [risk: none] | changelog, release-notes | +| [Changelog Entry Writing and Anti-Patterns](./documentation/changelog-entry-writing.md) | [warn] 296 | [basic] | [stable] | [risk: none] | changelog, release-notes | | [Changelog Entry Writing and Anti-Patterns Part 1](./documentation/changelog-entry-writing-part-1.md) | [draft] 56 | [intermediate] | [stable] | [risk: low] | migration, split | | [Changelog Management](./documentation/changelog-management.md) | [ok] 229 | [basic] | [stable] | [risk: none] | changelog, documentation | | [Changelog Release Workflow](./documentation/changelog-release-workflow.md) | [ok] 250 | [basic] | [stable] | [risk: none] | changelog, release-workflow | diff --git a/.llm/skills/testing/inspector-overlay-invariants.md b/.llm/skills/testing/inspector-overlay-invariants.md index d002386d..1fa93e03 100644 --- a/.llm/skills/testing/inspector-overlay-invariants.md +++ b/.llm/skills/testing/inspector-overlay-invariants.md @@ -7,12 +7,12 @@ created: "2026-04-30" updated: "2026-04-30" source: - repository: "wallstop-studios/com.wallstop-studios.dxmessaging" + repository: "wallstop/DxMessaging" files: - path: "Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs" - path: "Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs" - path: "Tests/Editor/MessageAwareComponentFallbackEditorTests.cs" - url: "https://github.com/wallstop-studios/com.wallstop-studios.dxmessaging" + url: "https://github.com/wallstop/DxMessaging" tags: - "testing" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 587511c3..fe3f3137 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -233,7 +233,7 @@ repos: language: system pass_filenames: false files: '^(CHANGELOG\.md|Runtime/|SourceGenerators/|Samples~/|Editor/)' - exclude: "^Editor/(Analyzers|Testing)/" + exclude: "^(Editor/(Analyzers|Testing)/|SourceGenerators/.*\\.Tests/|SourceGenerators/.*/(bin|obj)/)" stages: - pre-commit - pre-push diff --git a/AGENTS.md b/AGENTS.md index 41b41830..c4ba59d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,8 @@ See the [AI Agent Guidelines](./.llm/context.md) for all AI agent guidelines. -Two project-wide rules: +Three project-wide rules: -- Documentation must be pure ASCII (see [.llm/skills/documentation/ascii-only-docs.md](./.llm/skills/documentation/ascii-only-docs.md)). -- Code samples must compile (see [.llm/skills/documentation/code-samples-must-compile.md](./.llm/skills/documentation/code-samples-must-compile.md)). +- 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 4f043935..fb209f61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,20 +9,12 @@ 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. +- 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 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. - New `docs/reference/analyzers.md` reference page documenting every `DXMSG###` diagnostic the package emits, with severity, source generator/analyzer, trigger conditions, message text, and code samples for each. Added to the Reference section of the documentation site navigation. -- Added `llms.txt` file following [llmstxt.org](https://llmstxt.org/) standard for improved AI agent integration -- Added automation script `scripts/update-llms-txt.js` to keep `llms.txt` up-to-date -- Added documentation about AI agent integration in README - -### Fixed - -- Fixed `scripts/validate-workflows.js` to fail loudly when `git` is unavailable during ignore-policy checks instead of silently bypassing validation. -- Fixed `scripts/validate-workflows.js` violation path reporting to compute file paths relative to the provided `repoRoot` override. -- Removed unused `using Unity;` imports from editor custom-editor and harness files to avoid unnecessary namespace dependencies. +- Added `llms.txt` plus README onboarding guidance so users can connect AI assistants with accurate DxMessaging package context. ## [2.2.0] diff --git a/CLAUDE.md b/CLAUDE.md index 930e7aa3..fc85196b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,8 @@ See the [AI Agent Guidelines](./.llm/context.md) for all AI agent guidelines. -Two project-wide rules: +Three project-wide rules: -- Documentation must be pure ASCII (see [.llm/skills/documentation/ascii-only-docs.md](./.llm/skills/documentation/ascii-only-docs.md)). -- Code samples must compile (see [.llm/skills/documentation/code-samples-must-compile.md](./.llm/skills/documentation/code-samples-must-compile.md)). +- 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/CONTRIBUTING.md b/CONTRIBUTING.md index 2a69bc22..81ba4f5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,8 +73,8 @@ If `npm run check:yaml` reports a YAML line-length failure: Two strict rules apply to all documentation (Markdown files and `///` XML doc comments) and to every C# code sample: -1. **ASCII-only.** Pure ASCII is required. Real Unicode emojis are allowed only on callout lines (lines starting with `>`), capped at five per file. See [.llm/skills/documentation/ascii-only-docs.md](./.llm/skills/documentation/ascii-only-docs.md). Run `node scripts/validate-docs-ascii.js` (or `node scripts/normalize-docs-ascii.js` to auto-fix). -1. **Code samples must compile.** Every C# snippet - inline backticks, fenced blocks, table cells, and XML `` blocks - must compile against the snippet harness. See [.llm/skills/documentation/code-samples-must-compile.md](./.llm/skills/documentation/code-samples-must-compile.md). Run `node scripts/validate-doc-code-patterns.js` and the `DocsSnippetCompilationTests` suite under `SourceGenerators/`. +1. **ASCII-only.** Pure ASCII is required. Real Unicode emojis are allowed only on callout lines (lines starting with `>`), capped at five per file. See the [ASCII-only documentation guideline](./.llm/skills/documentation/ascii-only-docs.md). Run `node scripts/validate-docs-ascii.js` (or `node scripts/normalize-docs-ascii.js` to auto-fix). +1. **Code samples must compile.** Every C# snippet - inline backticks, fenced blocks, table cells, and XML `` blocks - must compile against the snippet harness. See the [Code samples must compile guideline](./.llm/skills/documentation/code-samples-must-compile.md). Run `node scripts/validate-doc-code-patterns.js` and the `DocsSnippetCompilationTests` suite under `SourceGenerators/`. Both rules are enforced by pre-commit hooks (`validate-docs-ascii`, `validate-doc-code-patterns`) and the `.github/workflows/docs-lint.yml` CI job. diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll index fdaba6c0f1b4d551a3e515f75ba03997c45b2da9..26d34b1be762abe719babd025ac20bce4c86eb8c 100644 GIT binary patch delta 7386 zcmb7Jdw5jUwO@OmGv~}XGjk^A%w!S_j|n7jl1xHghFu=TRhZn1fet#{EkGM+It^8axrJatVdf4&YcwB+(D2~X zbbUlx5|-MJFN!Y38wxt2+G=y^00g$`4THcNE~LJuyC8CW?g+tYIj4@+A450u z;zFw1x?2pQ@>*N>Mhc1F_Ism1B<9-wm{$U7#HhObic4&N@=}cMiaxJ2rzu4jd85Im z5|NzcdA;g?*B~2%-q8rdMLT}o*|TSlS&pwihCrED2H8=hjY65@&-R}^t9d9r>c&u5 zPHSnq#w=t(k!l}}L(7pQcN5S_XqzjADlgnN6RuLFu@Pf;lopIwy2Go2+|**VCf5p< z$Y9HPc~#d~h|{W=y5W2i3ayrZp~O6_C{yuj1a3w()zZqy=xD!Ypx#}zkd~%}v|t-n z4=atupG1{*cnG7S96?#MO(DXcoeGm%5A}_x4KuY$R<3yn*o=gou-gD4uo=rj)xAd0 z=!JR{C=G2hT6a7k=tqa&!@@H|21vs-5ruR$0kZq%!}VrN9%3vQy%_~m`!y^Mt!pAt z3W16)7r|6SRLwhYf%zY7f#oADU?6(32EM@tSQ>*3kpF;z=*b#rwpy96eU+}QRhIa@ zOGN=Ihx)xqXcJMtHyLdt&&hMAfQa>D!^N=L)Jg+3K3hjpnI3I&Dlx-0Z2vM_UuFlE zC7QytIUhp|D&16=7jDDmLh+5LHx2CW(t>0Q*5Yn=IyiR*K$H`!z#OP1il7-TwzC=Y zFma}|iv;bUEdTjIQ~%tkV*d>u)z%Z^5Hfc1KMq;Hz)+Qj(bPY0X;jX(1Bq!E76-?- zhaK{rO6j{x3vE-bsa*92Rn16cWs%UgDju=))ukKaScGc6U2d`ab#FPGgdD|fM=OdPi>Tl_z(sr&)i~%oK{|SW zKDGtJYh?vkc`8OUTT6s(O>u)_HTeo6YO2D3!AfXUzJ$ZUr|K=Say=WTo(O8Xhc~l1 zegWPZ1?6J?_9J$`D6=Wk_=l@td^ZfVG0~7IMtq1&W~Ek)aMWy8S}rS1W+ko#nkWIfWe#u0%Hee_yj1QUSbO7J5qu26 zb9w`@;Jsq=jd(MNmjqA6utu?TOst5Y?s=fxJUE4Pz3{?g<Tru- z499S91yN81yFtR3dmE%1@or+`jv&0^-M$I3Y{1zJ@m3Y95E)(~GsG0eYX(9q7dk$g z1Re44qHQBH`>n>h+hKzqg9>rJil=DXG+39$l3^9+El&F)*%l@%WjiD}IWs)mluE-c zEzXbRV#t##1$-bt97cr&(IhtHdKdekqD!hqg=!yU$rlBT({ri?Q0)aXNHt%mcKD%i zlYqYq$a+maZ-Oof(fZUNeJph~`4)u9lLCHdo!9>;4Ejsw0{7zN#)Sj{X=&W_#D&Dg zLN&{8>w+h(RJpwf(tZtkTGt)uDD1jZbPuS_;G2qYSykOFkcyK=gEu(R(&AEZPOVmn z?~Y?#;3PN8!TfA6^YA$)vp=t7;@6nAQJaab4~Wwp^r8*PRqIw_ff2op8^C@Pdeq`Y zbWb%ARw#{{xp+2ai5>#)&)D{uVr+Y{x+#ti3Vt-3~^YrTd>b0qpDmP&kA;)_1fyIOkK>=Pd1`fph=)1xz|>3Y-uS`d5bqy#VM?b6yg*%ltxqx5Vj&prgQ) zNl!`9lT3(aNctAg8Xcmo%%N|>$*@C(aG}xLkeFmELi0CLb3SN??nbXePe{#Gl0FRD zr1$8R;72lChn|%3UWp|VKY)&wqDM)TrH%>gF^{7ji!4!(eHcum2W1&`$TeD{2%HZZ zK?g-<&+-z}q%cBpC@)lEIy6qYZULI~t|ih~rc5z4`WG2`6C@`60YPh2Xx@n_OLa(h zUApLKv|kBY)WU97Oxgse8eOLpha7rcX7-?nm+}L}A(M8RLjNH-@SSqVLlW~s!o#UN zk-(We5$=y~1U{!Es8XbCJ0_t#KM;4R?85_=_fr)0ARSWH{_QGWxiWFPorR7oe1YmxmW z=uV?w%Kb(R$pnE*BZn35r zep;5}j{4>p2K_3>%{LYrCQTcY)!c1eZ3Jm3zAK91-IQ;d5vI3uY|yvD$fIACWjnvt z*BUk*m0UJMT*=C1pA6KYx%e(Q5Vk{0BsZYBOmcTp18p!IN)76Z(A~MdEtI;$h|qS) zX0wmbV;89I%c%xvD>Q$dtynP^9jgxW!@g4*H zdJ1mU)dSg6OP*_%@S1`cILjnZ`y&TmGaNZSE4Xl#Iy4b*4|(4;b8 z9@R)}mN;4BY>6!rQ?0UTleh|~Q8zFjdt_{&h;EdUAg#1rih`bmgTG$Cl1d1dI5PMt z{U^8!Y%m|9?X*6)j}lZIc%JI%u;o!NUBfjxLQm+gQ8A~`5zw>3@6tQ;VDJQ;faZsE zG6mJY(7p5nsnpL;LG>fQFnC5k4O|jB1N^Dp4?LtRYy!1%o1LVutO8cUukvXaOwbeP zex-;N^UG}E0pWrkVOOxBGJzrNM6iYpVSmE{o+i^6!X7dvvtpK-u5`09c87U4o6Rc1 zuip|IN|ZVJ80 zt{@TmY&OOC7*;>j|A)1*PTy&GdyFZ(nAL|&elO=%7m4Gv0!gY0bH5cLrIJTyXmnqId~)6?{pzfOHeInLzI>Fb*c@?3iVz&-_OYjDgkz@z4W;HBUOxYziQ#D#X(?@z5qr^ z_oXacT?%~`4W%+*oW=u7Xb#Y&rN9Jr0jue^zY$Kd$-sL)7CRnrXWq-W@N6ktv4m+Uy}!BRZRKjl@*Lgi}Z5#>?kb463j)tgi` zEsmYR&$GB);p{s1{8as4$tnXs;G*Z}>MOYR$#3Q7^!BSa;};zKuqI|?;Q6^)k~v$H zUsOSRXeDk9x6|wFDSDHip}Fceke#JHePf5-=*v7?+}c+$@)rTPn3(CA73=$J-8FWr)Y<{GyPJ_?H{;Oe zkvzsZxXIaU7#~htjGunYK3b2*u%RL)CzalZah-Xx zO4I#u1~t2TwvTXzW=l8EAQi?f*3hB21v%Y|tr`*;AgS)fc6TfOxF4kGfnOQdKx=`! zMmW+D(~gL)$SF!6$Od-+vedbd59C4?E#@(Oz}8h1_#nvVo%A7*cr>0(8$K{7d|2+W={H&6*(=idu@rH67m~=_7XQXkC}Cg|Ig}La&p4O=zW{mK)P< zh3j0em2=gbbJaUmH=XozRJ zHU_u|R_y~Se$HqWjxm35-1i?dZ&%pP3lEcpnew?aqS>vaXikZ#!Co%)%$B*M7jMI| z6I%`q!rPH;aOV@jK41k%LA%|`17y37WSi3=RMTn62Ib7tv)kIjsu!_j&dwdjIb|B= z-Lx>sNMG1_LuXr8C;sOPOifj__(%AjM^%!ksBfxmTE1dMO=ojmb#rxFpGKd8@y!KQbaN|lfK+Pbg8#|_0n9HOV@R7 z=-SY>uA?p4QCn3#pcJjm4Y}>I+>nTT6wC+-EQNg8e_87&UVM delta 8512 zcmb_h3v^V~x&HTg&N(x4Cg;p#l0XP~5EwE^n1okC0S)pHkVlB1X{8uQK+5C5gn$oV zCZfGUrIn7ga+Os@twqafSwStWtn1@mZ|%cM)voHTF45YnwJ+tC-rm}9zkkmp67OB> zuDh0JGXMTx```cm_rL$W=Op_rkv%EyxUKrS9jo4^!t*9oR|Z9jXgL^xJk!{E=#f>< z$t^_7n2{yAO*>Q-4c$i+0=;`P(UrL>r9RgwCfK2+U{&I~Ja<4O#fseHVuH9j_lBr0 zdlwe?xg1}Gboy>1(NAU(iCop<=`*U8L=llqb+ZeIqV5qyS{<~bDNXhQ?UE836hX7z zqP;HkL&+(*?aJtk2fQ{r3LX3?P}}fhQc8ik(J7U7p{v5Z6UAUBKqYNb{1Vpjltd}o zV9PA9J&`B_5vGVpz?w}KhKm}mQEyO7ITS2^G}sB9$cSpLmIiCv=tQO*%s4bn*JRCe z@Y+)SG2ljZS7#dmH^7otAt6`yDw!j#lH?ZEk-+jQbSHg(sfch4qA8s&MXSn1O*B9?zpO|LENh)odT*hVZfsPgI;xkbFjai|#nG{~c_=O(5D3GPLPYNp=;oHaeyW6j@T_Au27 zu7P$2+Qer7LV;M`V$_YY-akVx;Ln64EE6*3#+ZE^Y%5_Cl^Sfc*ez!6-QXHNc9wm6 zd+v8yY1wR`wOQnnmVhW3)rO^G^dTuyu^`olrAzfvF*~8Ca^!#&^XGv)T3eECM;aeZ%mrGFx5Tlx(Ew<>`X#4afzpVwv$t_O^{BgJ0lYX{w-E%+o%Lr z^-!Q*r;>0oZsdEBfvw4(uZG0`JV4a+ zOj*p-d}Ip8Vo5ac#%MDAHBf2z>KiEHrdc=+;?V$H4bD>`>(2NH=O|o56wQ31DTo>lEF`hr@(%nhr0^#raMAp+ASlAm5$Ycq>0*tx z`N&o~?5T-OXhSVuf)!kXHiL+2s6Bls!6JWckx9b0AEd$t+*)vj8@}d%btT_qWHc?@ z3uMd!$tEw5kC{!b?S%?43;7#({_-A!^WTv77|v}6+YmjjgRtSbILPtvWhL@@bsUZO&i|>uIz!Lc{QV9zSS8DsO2~B*Hdu z_}Xbe$(Bmn!5O-(PgN5o`j|voAnI-~ z#A|OQc7oTCd0FNz=Skl5-7p>ScR?mMq`a>c;f8sUTghDk|9UovoywkbPOZTj`8P1r z^Ss23KuQJ$ClwL~gK~XnyGSFRxCttKvuQ0>6?s<(PyS}X%EfMxzZ)urNF?>)3d7;W zwSo6660WaJhPOU!R5lGw6nPp<5^_`C=h|{-gL9_ug{{Jk$)Zw(m7mcEaa!XfPcK9Q z@;Sazwx#I}vMo#Blv@>Q$@HUdmdkniE8wT^XE0VT91f)=vGfcBhXM*-vRA6|>6o5i zLZOwxF$R2<8sMFhaGMRXqy|;}{%ez^bJs=}kf4KM&Kk%tfZL)wT3LIgp%Turu

x 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 diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll index c7e4c5ca7bc8df9b3b0960f1490a4a5a750acc0f..0c1fa5f62670357fe17c033c4c6c042dd9904e65 100644 GIT binary patch literal 33280 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 literal 34816 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 DbwQS2 diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs index a65f1ca3..e17bac65 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs @@ -80,7 +80,7 @@ public sealed class MessageAwareComponentBaseCallAnalyzer : DiagnosticAnalyzer "'{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-studios/com.wallstop-studios.dxmessaging/blob/master/docs/reference/analyzers.md#"; + "https://github.com/wallstop/DxMessaging/blob/master/docs/reference/analyzers.md#"; private static readonly ImmutableHashSet GuardedMethodNames = ImmutableHashSet.Create( diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs index ba1d6086..b7f24786 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs @@ -16,6 +16,30 @@ public sealed class MessageAwareComponentBaseCallAnalyzerTests // no more duplicated literal in the tests. Drift risk eliminated. private static readonly string IgnoreFileName = IgnoreListReader.IgnoreFileName; + [TestCase("DXMSG006", "dxmsg006")] + [TestCase("DXMSG007", "dxmsg007")] + [TestCase("DXMSG008", "dxmsg008")] + [TestCase("DXMSG009", "dxmsg009")] + [TestCase("DXMSG010", "dxmsg010")] + public void SupportedDiagnosticsUseCanonicalAnalyzerDocsLinks( + string diagnosticId, + string expectedAnchor + ) + { + MessageAwareComponentBaseCallAnalyzer analyzer = new(); + DiagnosticDescriptor descriptor = analyzer.SupportedDiagnostics.Single(d => + d.Id == diagnosticId + ); + + string expectedLink = + $"https://github.com/wallstop/DxMessaging/blob/master/docs/reference/analyzers.md#{expectedAnchor}"; + Assert.That( + descriptor.HelpLinkUri, + Is.EqualTo(expectedLink), + $"Unexpected help link for {diagnosticId}." + ); + } + [Test] public void OverrideAwakeWithoutBaseEmitsDxmsg006() { diff --git a/scripts/__tests__/pre-commit-hook-stage-policy.test.js b/scripts/__tests__/pre-commit-hook-stage-policy.test.js index 3403604d..304d9271 100644 --- a/scripts/__tests__/pre-commit-hook-stage-policy.test.js +++ b/scripts/__tests__/pre-commit-hook-stage-policy.test.js @@ -120,7 +120,10 @@ describe("pre-commit hook stage policy", () => { expect(blockText).toContain("entry: node scripts/validate-changelog.js --check-coverage"); expect(blockText).toContain("pass_filenames: false"); expect(blockText).toContain("files: '^(CHANGELOG\\.md|Runtime/|SourceGenerators/|Samples~/|Editor/)'"); - expect(blockText).toMatch(/exclude:\s*['\"]\^Editor\/\(Analyzers\|Testing\)\/['\"]/); + expect(blockText).toContain("exclude:"); + expect(blockText).toContain("Editor/(Analyzers|Testing)/"); + expect(blockText).toContain("SourceGenerators/.*\\\\.Tests/"); + expect(blockText).toContain("SourceGenerators/.*/(bin|obj)/"); }); test("fix-csharp-underscore-methods hook runs at pre-commit", () => { diff --git a/scripts/__tests__/validate-changelog.test.js b/scripts/__tests__/validate-changelog.test.js index fd983774..6d3a0703 100644 --- a/scripts/__tests__/validate-changelog.test.js +++ b/scripts/__tests__/validate-changelog.test.js @@ -247,8 +247,24 @@ describe("validate-changelog", () => { test("recognizes user-visible paths", () => { expect(isLikelyUserVisiblePath("Runtime/Core/MessageBus.cs")).toBe(true); expect(isLikelyUserVisiblePath("Editor/CustomEditors/FallbackEditor.cs")).toBe(true); + expect( + isLikelyUserVisiblePath( + "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/MessageBusEmitterGenerator.cs" + ) + ).toBe(true); + expect( + isLikelyUserVisiblePath( + "SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs" + ) + ).toBe(true); expect(isLikelyUserVisiblePath("Runtime/Core/MessageBus.cs.meta")).toBe(false); expect(isLikelyUserVisiblePath("Editor/Analyzers/Analyzer.cs")).toBe(false); + expect( + isLikelyUserVisiblePath( + "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/BaseCallScannerTests.cs" + ) + ).toBe(false); + expect(isLikelyUserVisiblePath("SourceGenerators/Directory.Build.props")).toBe(false); expect(isLikelyUserVisiblePath("scripts/validate-changelog.js")).toBe(false); expect(isLikelyUserVisiblePath("CHANGELOG.md")).toBe(false); }); @@ -341,6 +357,24 @@ describe("validate-changelog", () => { expect(errors).toHaveLength(0); }); + + test("passes coverage for SourceGenerators test-only changes", () => { + const errors = validateCoverageRule([ + "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageBusGeneratorTests.cs" + ]); + + expect(errors).toHaveLength(0); + }); + + test("fails coverage for shipped analyzer/source-generator changes without changelog", () => { + const errors = validateCoverageRule([ + "SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs", + "SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/MessageBusEmitterGenerator.cs" + ]); + + expect(errors).toHaveLength(1); + expect(errors[0].code).toBe("E004"); + }); }); describe("integration", () => { @@ -390,6 +424,7 @@ describe("validate-changelog", () => { }); expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); }); }); }); diff --git a/scripts/validate-changelog.js b/scripts/validate-changelog.js index 706f743c..d37e3c85 100644 --- a/scripts/validate-changelog.js +++ b/scripts/validate-changelog.js @@ -565,7 +565,34 @@ function isLikelyUserVisiblePath(filePath) { } if (normalizedPath.startsWith("SourceGenerators/")) { - return true; + // Only shipped source-generator/analyzer code should require changelog coverage. + if (/\/(?:bin|obj)\//.test(normalizedPath)) { + return false; + } + + if (/\.Tests(?:\/|$)/.test(normalizedPath)) { + return false; + } + + if (normalizedPath === "SourceGenerators/Directory.Build.props") { + return false; + } + + if ( + normalizedPath.startsWith("SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/") + ) { + return true; + } + + if ( + normalizedPath.startsWith( + "SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/" + ) + ) { + return true; + } + + return false; } if (normalizedPath.startsWith("Samples~/")) {