From f12371f5e3fd02b786d0c9555115bd3ca5e51f58 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Thu, 3 Sep 2026 12:57:59 -0400 Subject: [PATCH 1/6] feat(cleanup): PLAN-0061 Phase 1 - pre-1.0 correctness and quality gate Fixes the negative-seed guard gap in Compono.XunitV3/MSTest/NUnit's ComposeAttribute (a copy-paste gap predating PR #65's fix, using Compono.TUnit's already-correct behavior as the contract), lands a permanent CI-blocking Native AOT smoke gate for the existing eight AotSmokeTest projects (ADR-0041 Amendment 7), consolidates StableHash (3 copies)/GeneratorVersion (5 copies)/the ComposableAttribute metadata-name literal in the generator, adds the incremental-caching regression test ADR-0005 required, fixes mkdocs navigation and stale docs text, documents Compono.NSubstitute's AOT limitation and Compono.XunitV3.SampleTests' CI filter requirement, consolidates package-validation.yaml's publishable- package list to one job-level declaration, and reverts release-drafter's 0.x-preview breaking-change-to-minor override now that Compono is ready to leave 0.x (ADR-0031 Amendment 5). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GMQEjFrUVGYkctE3ECfRuA --- .github/release-drafter.yml | 15 +- .github/workflows/aot-validation.yaml | 143 +++++++ .github/workflows/docs.yml | 2 +- .github/workflows/package-validation.yaml | 38 +- ...c-preview-release-and-versioning-policy.md | 39 ++ .../0033-public-preview-samples-strategy.md | 71 ++++ .../adr/0041-aot-safe-row-binding-dispatch.md | 102 +++++ docs/packages/compono-nsubstitute.md | 12 + .../0061-pre-1-0-cleanup-and-consolidation.md | 384 ++++++++++++++++++ docs/plans/README.md | 3 +- ...ramework-binder-duplication-spike-scope.md | 115 ++++++ mkdocs.yml | 4 + .../Discovery/TestDoubleAnalyzer.cs | 10 +- .../Emitters/CollectionPlanEmitter.cs | 11 +- .../Emitters/CompositionPlanEmitter.cs | 15 +- .../Emitters/GeneratedFileNaming.cs | 18 +- .../Emitters/GeneratorVersion.cs | 21 + .../Emitters/LoggingActivationEmitter.cs | 11 +- .../Emitters/RowInvokerRegistrationEmitter.cs | 11 +- src/Compono.Generators/Emitters/StableHash.cs | 25 ++ .../Emitters/TestDoubleEmitter.cs | 13 +- .../Emitters/TestDoubleIdentifierNaming.cs | 22 +- .../Emitters/TestDoubleOverloadIdentity.cs | 19 +- .../WellKnownTypes/WellKnownTypeData.cs | 8 +- .../ComposeAttribute{TProfile}.cs | 33 +- .../ComposeAttribute{TProfile}.cs | 33 +- .../ComposeAttribute{TProfile}.cs | 32 +- .../IncrementalCachingTests.cs | 92 +++++ .../ComposeAttributeConfigBindingTests.cs | 33 ++ .../Fixtures/SampleTestMethods.cs | 10 + .../ComposeAttributeConfigBindingTests.cs | 35 ++ .../Fixtures/SampleTestMethods.cs | 10 + .../Compono.XunitV3.SampleTests.csproj | 55 +-- .../FailingCompositionTests.cs | 13 +- test/Compono.XunitV3.SampleTests/README.md | 40 ++ .../ComposeAttributeConfigBindingTests.cs | 39 ++ .../Fixtures/SampleTestMethods.cs | 10 + 37 files changed, 1366 insertions(+), 181 deletions(-) create mode 100644 .github/workflows/aot-validation.yaml create mode 100644 docs/plans/0061-pre-1-0-cleanup-and-consolidation.md create mode 100644 docs/research/0019-framework-binder-duplication-spike-scope.md create mode 100644 src/Compono.Generators/Emitters/GeneratorVersion.cs create mode 100644 src/Compono.Generators/Emitters/StableHash.cs create mode 100644 test/Compono.Generators.Tests/IncrementalCachingTests.cs create mode 100644 test/Compono.XunitV3.SampleTests/README.md diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index b60162ae..3fa5c37f 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -72,16 +72,21 @@ categories: - 'type: chore' - 'type: ci' - 'type: revert' - # ADR-0031's 0.X+1.0 compatibility policy for the 0.x preview line - a labeled - # breaking-change PR bumps minor, not major, so it never silently exits 0.x - # (PLAN-0008 Phase 0). Deliberately no 'major' version-resolver entry below - - # nothing in this repo auto-bumps major. + # ADR-0031 Amendment 5: Compono has left the 0.x preview line, so a labeled + # breaking-change PR resolves an ordinary SemVer major bump again - the + # deliberate minor-bump override ADR-0031's original "0.x compatibility + # policy" put in place (PLAN-0008 Phase 0) applied only while staying on + # 0.x, and no longer applies. + - type: 'version-resolver' + semver-increment: 'major' + when: + labels: + - 'breaking-change' - type: 'version-resolver' semver-increment: 'minor' when: labels: - 'type: feat' - - 'breaking-change' - type: 'version-resolver' semver-increment: 'patch' when: diff --git a/.github/workflows/aot-validation.yaml b/.github/workflows/aot-validation.yaml new file mode 100644 index 00000000..6ee01c21 --- /dev/null +++ b/.github/workflows/aot-validation.yaml @@ -0,0 +1,143 @@ +name: AOT Validation + +# ADR-0041 Amendment 7: permanent, CI-blocking Native AOT smoke gate for the eight existing +# test/*.AotSmokeTest projects, replacing the previous manual-only "run it by hand before release" +# verification. +# +# Deliberately NO `paths:` filter on this workflow's own `pull_request` trigger. A workflow entirely +# skipped by trigger-level path filtering leaves its required status check `Pending` rather than +# reporting success, under GitHub's required-check semantics - that would block a PR indefinitely +# instead of passing it on an AOT-irrelevant change. Selectivity happens *inside* the workflow +# instead: the `changes` job below computes which of the eight legs are actually applicable from the +# PR's changed files (a small repository-owned `git diff` script, not a third-party changed-files +# action), each leg's own publish-and-run job runs behind an `if:` reading that output (an +# inapplicable leg reports an ordinary skipped conclusion, never a missing status), and `aot-gate` - +# the one job branch protection should name as the required check - always runs +# (`if: always()`) and always resolves to success or failure, so the required check never sits +# Pending. +# +# The exact guarantee this gate proves: the packaged Compono package's exercised public API surface +# is callable from a Native-AOT-published, trimmed consumer application without runtime AOT/trimming +# failures. This is deliberately narrower than "the package's public API surface": it covers core +# `Compono` itself, not only integration packages, and it makes no claim of exhaustive coverage of +# every public member - each leg is a throwaway console app calling a specific, real entry point of +# the packaged API directly. This gate makes NO claim that xUnit v3, TUnit, MSTest, or NUnit's own +# test runners are Native-AOT compatible - no leg ever publishes a test framework's runner/host as +# Native AOT. +# +# This repository does not use GitHub merge queues today - no `merge_group` trigger is added +# speculatively; add one later only if that changes. + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + legs: ${{ steps.compute.outputs.legs }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Determine applicable AOT smoke legs + id: compute + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.sha }} + run: | + set -euo pipefail + changed=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA") + echo "Changed files:" + echo "$changed" + + all_legs='["Compono","Compono.Http","Compono.Logging","Compono.MSTest","Compono.NUnit","Compono.TestDoubles","Compono.TUnit","Compono.XunitV3"]' + + # A change to shared/core/generator infrastructure (or to this workflow itself) can affect + # every packaged leg at once - run all eight rather than reflexively narrowing to only the + # paths that happened to change (ADR-0041 Amendment 7's own "do not run it reflexively, but + # do not under-run it either" balance). + if echo "$changed" | grep -qE '^(src/Compono/|src/Compono\.Generators/|Directory\.Packages\.props|Directory\.Build\.(props|targets)|test/Directory\.Build\.(props|targets)|\.github/workflows/aot-validation\.yaml)'; then + echo "Core/generator/shared-config change detected - running all eight legs." + echo "legs=$all_legs" >> "$GITHUB_OUTPUT" + exit 0 + fi + + legs="[]" + add_leg() { + legs=$(echo "$legs" | jq -c --arg leg "$1" '. + [$leg] | unique') + } + + echo "$changed" | grep -q '^src/Compono\.Http/' && add_leg "Compono.Http" + echo "$changed" | grep -q '^src/Compono\.Logging/' && add_leg "Compono.Logging" + echo "$changed" | grep -q '^src/Compono\.MSTest/' && add_leg "Compono.MSTest" + echo "$changed" | grep -q '^src/Compono\.NUnit/' && add_leg "Compono.NUnit" + echo "$changed" | grep -q '^src/Compono\.TestDoubles/' && add_leg "Compono.TestDoubles" + echo "$changed" | grep -q '^src/Compono\.TUnit/' && add_leg "Compono.TUnit" + echo "$changed" | grep -q '^src/Compono\.XunitV3/' && add_leg "Compono.XunitV3" + + # A change scoped to one leg's own AotSmokeTest project only needs that leg re-run, not all + # eight - extract which leg(s) directly from the changed paths. + for proj in $(echo "$changed" | grep -oE '^test/[^/]+\.AotSmokeTest/' | sed -E 's#^test/(.+)\.AotSmokeTest/#\1#' | sort -u); do + add_leg "$proj" + done + + echo "Applicable legs: $legs" + echo "legs=$legs" >> "$GITHUB_OUTPUT" + + aot-smoke: + needs: changes + if: needs.changes.outputs.legs != '[]' + strategy: + fail-fast: false + matrix: + leg: ${{ fromJSON(needs.changes.outputs.legs) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + + - name: Pack ${{ matrix.leg }} into its local AOT smoke feed + run: bash "test/${{ matrix.leg }}.AotSmokeTest/pack-compono.sh" + + - name: Publish ${{ matrix.leg }} smoke consumer as Native AOT + run: | + dotnet publish "test/${{ matrix.leg }}.AotSmokeTest" \ + -c Release -f net10.0 -r linux-x64 --self-contained true \ + -p:PublishAot=true \ + -o "test/${{ matrix.leg }}.AotSmokeTest/bin/aot-publish" + + - name: Run the published Native AOT binary + run: "./test/${{ matrix.leg }}.AotSmokeTest/bin/aot-publish/${{ matrix.leg }}.AotSmokeTest" + + # Outlier per-leg additional proofs (PLAN-0061 Phase 1) - beyond the ordinary publish-and-run + # pattern above, these two packages carry their own extra packaging-content verification. + - name: Additional packaging verification (Compono.Logging) + if: matrix.leg == 'Compono.Logging' + run: bash test/Compono.Logging.AotSmokeTest/verify-packaging.sh + + - name: Additional analyzer-contract verification (Compono.Http) + if: matrix.leg == 'Compono.Http' + run: bash test/Compono.Http.AotSmokeTest/AnalyzerContract/verify-analyzer-contract.sh + + aot-gate: + needs: [changes, aot-smoke] + if: always() + runs-on: ubuntu-latest + steps: + - name: Confirm every applicable AOT smoke leg passed + run: | + result="${{ needs.aot-smoke.result }}" + if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then + echo "::error::One or more applicable AOT smoke legs failed - see the aot-smoke job above." >&2 + exit 1 + fi + echo "AOT validation gate passed. Applicable legs: ${{ needs.changes.outputs.legs }}" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7c183978..6a698adc 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -103,7 +103,7 @@ jobs: - name: Fail on API reference drift run: | if ! git diff --exit-code -- docs/reference/api; then - echo "::error::docs/reference/api is out of date. Run '.github/scripts/generate-api-reference.sh' (after building the eight publishable packages for net10.0 Release) and commit the result." >&2 + echo "::error::docs/reference/api is out of date. Run '.github/scripts/generate-api-reference.sh' (after building the publishable packages for net10.0 Release) and commit the result." >&2 exit 1 fi diff --git a/.github/workflows/package-validation.yaml b/.github/workflows/package-validation.yaml index 31d8c625..eb149810 100644 --- a/.github/workflows/package-validation.yaml +++ b/.github/workflows/package-validation.yaml @@ -30,6 +30,14 @@ jobs: # CS1591 enforcement. BREAKING_CHANGE: ${{ contains(github.event.pull_request.labels.*.name, 'breaking-change') }} PACK_OUTPUT: ${{ github.workspace }}/artifacts/package-validation + # Single authoritative publishable-package list (PLAN-0061 Phase 1) - the baseline-lookup, + # pack, and CS1591-enforcement steps below all derive from this one job-level env var instead + # of each repeating the same 11-package literal, so adding/removing a package can't drift + # across the three independently. A job-level `env:` entry is injected into every step's own + # process environment by GitHub Actions itself, so this survives across the separate `run:` + # steps below (each its own shell process) with no extra plumbing - deliberately not a Bash + # array, which would only live for the one step that declared it. + PACKAGES: "Compono Compono.XunitV3 Compono.NSubstitute Compono.Bogus Compono.TUnit Compono.TestDoubles Compono.DependencyInjection Compono.Http Compono.Logging Compono.MSTest Compono.NUnit" steps: - uses: actions/checkout@v7 @@ -47,7 +55,7 @@ jobs: - name: Resolve nuget.org baseline versions run: | set -euo pipefail - for pkg in Compono Compono.XunitV3 Compono.NSubstitute Compono.Bogus Compono.TUnit Compono.TestDoubles Compono.DependencyInjection Compono.Http Compono.Logging Compono.MSTest Compono.NUnit; do + for pkg in $PACKAGES; do id_lower=$(echo "$pkg" | tr '[:upper:]' '[:lower:]') # No -f here: a non-2xx response must still reach the status-code branch # below (curl -f suppresses the body/short-circuits on HTTP errors), and @@ -96,33 +104,15 @@ jobs: dotnet pack "$csproj" -c Release -o "$PACK_OUTPUT" "${args[@]}" } - pack_one src/Compono/Compono.csproj BASELINE_Compono - pack_one src/Compono.XunitV3/Compono.XunitV3.csproj BASELINE_Compono_XunitV3 - pack_one src/Compono.NSubstitute/Compono.NSubstitute.csproj BASELINE_Compono_NSubstitute - pack_one src/Compono.Bogus/Compono.Bogus.csproj BASELINE_Compono_Bogus - pack_one src/Compono.TUnit/Compono.TUnit.csproj BASELINE_Compono_TUnit - pack_one src/Compono.TestDoubles/Compono.TestDoubles.csproj BASELINE_Compono_TestDoubles - pack_one src/Compono.DependencyInjection/Compono.DependencyInjection.csproj BASELINE_Compono_DependencyInjection - pack_one src/Compono.Http/Compono.Http.csproj BASELINE_Compono_Http - pack_one src/Compono.Logging/Compono.Logging.csproj BASELINE_Compono_Logging - pack_one src/Compono.MSTest/Compono.MSTest.csproj BASELINE_Compono_MSTest - pack_one src/Compono.NUnit/Compono.NUnit.csproj BASELINE_Compono_NUnit + for pkg in $PACKAGES; do + pack_one "src/$pkg/$pkg.csproj" "BASELINE_$(echo "$pkg" | tr '.' '_')" + done - name: Enforce XML doc comments (CS1591) on publishable packages run: | set -euo pipefail - for csproj in \ - src/Compono/Compono.csproj \ - src/Compono.XunitV3/Compono.XunitV3.csproj \ - src/Compono.NSubstitute/Compono.NSubstitute.csproj \ - src/Compono.Bogus/Compono.Bogus.csproj \ - src/Compono.TUnit/Compono.TUnit.csproj \ - src/Compono.TestDoubles/Compono.TestDoubles.csproj \ - src/Compono.DependencyInjection/Compono.DependencyInjection.csproj \ - src/Compono.Http/Compono.Http.csproj \ - src/Compono.Logging/Compono.Logging.csproj \ - src/Compono.MSTest/Compono.MSTest.csproj \ - src/Compono.NUnit/Compono.NUnit.csproj; do + for pkg in $PACKAGES; do + csproj="src/$pkg/$pkg.csproj" echo "Building $csproj with CS1591 as an error" dotnet build "$csproj" -c Release -p:WarningsAsErrors=CS1591 done diff --git a/docs/adr/0031-public-preview-release-and-versioning-policy.md b/docs/adr/0031-public-preview-release-and-versioning-policy.md index 37523aa5..c469ba5d 100644 --- a/docs/adr/0031-public-preview-release-and-versioning-policy.md +++ b/docs/adr/0031-public-preview-release-and-versioning-policy.md @@ -626,3 +626,42 @@ upper bounds, and package/dependency mapping are all unchanged. unmodified release pipeline this ADR's policy governs - [PLAN-0008](../plans/0008-milestone-8-public-preview.md) — Phase 0 executes this ADR's package-readiness checklist + +## Amendment 5 (2026-09-03): `breaking-change` maps to `major` again — the `0.x` compatibility policy's own deliberate override no longer applies + +This ADR's "`0.x` compatibility policy" section (above) deliberately +overrode `.github/release-drafter.yml`'s ordinary `breaking-change` → +`major` mapping down to `minor`, specifically so a breaking-change-labeled +PR could not "exit the `0.x` preview line by accident rather than by the +deliberate decision this ADR's own compatibility policy... requires." That +text, and the reasoning behind it, is left exactly as written — this +amendment records that the deliberate decision it was guarding against has +now actually been made: the product owner has confirmed Compono is ready to +leave the `0.x` preview line via [PLAN-0061](../plans/0061-pre-1-0-cleanup-and-consolidation.md)'s +pre-1.0 cleanup gate. + +**Decision:** `.github/release-drafter.yml`'s `version-resolver` reverts to +its ordinary, un-overridden mapping — `breaking-change` → `major`, +`type: feat` → `minor` (previously combined into one `minor` bucket covering +both labels; split into two buckets so a breaking change and an ordinary +feature addition no longer resolve to the same increment), everything else +unchanged (`patch` for fix/docs/refactor/test/chore/ci/revert, and the +existing bare `patch` fallback). This is the exact reversal this ADR's own +"How a breaking change is communicated" section already named as the +intentional graduation path — not a new mechanism, not a redesign of the +labeling/categories scheme, which stays exactly as this ADR and PLAN-0008 +Phase 0 established it. + +**Consequence, stated plainly per this ADR's own "no undefined 0.x" driver:** +the next `breaking-change`-labeled PR resolves the next version as a real +major bump (`1.0.0`, or beyond it if further breaking changes land after +that), not another `0.X+1.0` minor bump. The `0.x` compatibility policy +section above describes `0.x`'s own compatibility contract accurately for +every release that shipped under it — it does not retroactively change once +Compono leaves `0.x`, per this repo's own "an ADR's original text stays +exactly as written" rule; a reader of a historical `0.x` release still finds +the policy that actually governed it. + +No other part of this ADR changes: lockstep versioning, the packaged-consumer +readiness checklist, the dependency-range policy (Amendments 1/4), and the +TFM-window policy are all unaffected by leaving `0.x`. diff --git a/docs/adr/0033-public-preview-samples-strategy.md b/docs/adr/0033-public-preview-samples-strategy.md index 779016b7..bcf231a2 100644 --- a/docs/adr/0033-public-preview-samples-strategy.md +++ b/docs/adr/0033-public-preview-samples-strategy.md @@ -263,3 +263,74 @@ amendment narrows the *current* decision, it doesn't rule that out. same mechanism applied here - [PLAN-0008](../plans/0008-milestone-8-public-preview.md) — Phase 4 builds both samples + +## Amendment 2 (2026-09-03): Canonical sample coverage extended to `Compono.Http`, `Compono.DependencyInjection`, and `Compono.Logging` + +This ADR's original launch set (Basic Usage + ASP.NET API) was deliberately +scoped to the MVP's own four-package success criteria — `Compono`, +`Compono.XunitV3`, `Compono.NSubstitute`, `Compono.Bogus` — and never claimed +"every Compono package gets a sample." `Compono.Http`, `Compono.DependencyInjection`, +and `Compono.Logging` didn't exist when this ADR was written. The pre-1.0 +cleanup gate (PLAN-0061) found these three are the only publishable Compono +packages with zero example-level coverage anywhere in the repository — no +`samples/` project and no `test/*SampleTests` packaged-consumer proof, unlike +every other extension package. This amendment extends this ADR's scope to +close that gap, before the 1.0 public API contract freezes, while preserving +this ADR's original "real, CI-maintained code — every sample multiplies +ongoing maintenance burden" driver and its explicit rejection (Considered +Options, launch-set-size Option 3) of a one-project-per-package approach. + +**Decision: extend the two existing launch samples, add no new top-level +sample projects.** + +- **`Compono.Samples.AspNetApi`** gains two scenarios: a `Compono.Http` + scenario (handler-based testing of an outbound HTTP-calling endpoint/service + — the natural fit for a web-hosted sample already exercising realistic + request/response flows) and a `Compono.DependencyInjection` scenario (a + DI-composed row provider registered into the host's own + `IServiceCollection`, exercising the configured-resolution bridge in the + same realistic multi-layer context ADR-0033's original ASP.NET API sample + already provides for NSubstitute/Bogus). +- **`Compono.Samples.BasicUsage`** gains a `Compono.Logging` scenario: + compose an `ILogger`-dependent type via `UseLogging()`, assert on a + captured log entry via `Verify()`. `Compono.Logging` is a cross-cutting + concern independent of any web-hosting context, so it fits the minimal, + single-clearest-reference-implementation sample rather than the web host. + +Each new scenario must teach that package's own standalone value on its own +terms — a reader should come away understanding *why* they'd reach for +`Compono.Http`/`Compono.DependencyInjection`/`Compono.Logging`, not merely see +that the package can be referenced without a compile error. A scenario that +only proves referenceability, without a realistic assertion a consumer would +actually write, does not satisfy this amendment. + +This preserves the two-project structure and the "project references for +development" build story (Amendment 1) exactly as they exist today — no new +CI job, no new packed-verification mode, no new `docs/samples/*.md` page (the +two existing overview pages gain a mention of their new scenarios rather than +each scenario getting its own page). The remaining five documented future +candidates (CQRS, Clean Architecture, Minimal APIs, MediatR, EF Core) are +unaffected by this amendment and remain deferred exactly as this ADR +originally decided. + +### Positive Consequences + +- Every publishable Compono package has a working, runnable, CI-maintained + example a new consumer can point to, closing the one concrete gap the + pre-1.0 cleanup audit found in this ADR's otherwise-still-valid scope. +- No new project, no new CI job, no new build/verification story — the + maintenance-cost driver this ADR was originally written to protect is + unaffected. + +### Negative Consequences + +- `Compono.Samples.AspNetApi` grows in scope (now demonstrating six packages + instead of four) — accepted, since the alternative (a new project per + package) was already rejected by this ADR's own Considered Options for a + worse reason (five-fold CI/maintenance cost for architecture-pattern + samples); two additional, tightly-scoped scenarios in an existing realistic + host is a materially smaller cost than a new project each. + +## Links (Amendment 2) + +- PLAN-0061 — implements this amendment as its Phase 2. diff --git a/docs/adr/0041-aot-safe-row-binding-dispatch.md b/docs/adr/0041-aot-safe-row-binding-dispatch.md index 7a49dde3..21414423 100644 --- a/docs/adr/0041-aot-safe-row-binding-dispatch.md +++ b/docs/adr/0041-aot-safe-row-binding-dispatch.md @@ -547,3 +547,105 @@ only generated consumer-assembly code calls it. `TryGet` remains visible because the framework integration packages call it at runtime. This changes IntelliSense presentation only; both members remain public for their existing cross-assembly callers. + +## Amendment 7 (2026-09-03): Native AOT smoke coverage becomes a permanent, CI-blocking gate + +This ADR's own Negative Consequences (above) stated its AOT-safety claim was +"a design argument, not yet a proven one" and required "a real `dotnet publish +-p:PublishAot=true` + run smoke test... before `Compono.TUnit` is considered +release-ready." That requirement was satisfied per-package, by hand, over +several follow-up amendments and sibling ADRs (Amendment 5 above; ADR-0057 for +`Compono.MSTest`; equivalent smoke tests for `Compono.NUnit`, `Compono.Http`, +`Compono.Logging`, `Compono.TestDoubles`, and core `Compono` itself) — eight +`test/*.AotSmokeTest` projects exist on `main` today. None of them are wired +into any CI workflow; every one is a manual, run-by-hand verification. This +was never a decided policy gap — this ADR only ever required proof to exist +once, before a package's initial release, not proof that stays continuously +true. The pre-1.0 cleanup gate (PLAN-0061) identified this as a real risk for +a product whose stated goal is Native AOT/trimming compatibility: a +regression introduced after a package's initial release has no automated gate +to catch it before merge. + +**Decision: Native AOT smoke coverage becomes a permanent, CI-blocking gate,** +not merely a one-time release requirement. A new workflow drives the existing +eight `*.AotSmokeTest` projects' established `pack-compono.sh` + +`dotnet publish -p:PublishAot=true` + run pattern via one reusable script and +a matrix job, rather than reproducing per-project workflow boilerplate eight +times over. + +**The exact guarantee this gate proves, stated precisely so it is never +overclaimed:** *the packaged Compono package's exercised public API surface is +callable from a Native-AOT-published, trimmed consumer application without +runtime AOT/trimming failures.* This is deliberately narrower than "the +package's public API surface" in two ways: it covers core `Compono` itself +(`Compono.AotSmokeTest`), not only integration packages, and it makes no claim +of exhaustive coverage of every public member — each smoke consumer is a +throwaway console app that calls a specific, real entry point of the packaged +integration's `[Compose]`-family API directly (e.g. +`Compono.XunitV3.ComposeAttribute.GetData(...)`), and the guarantee holds only +for the API path that consumer actually exercises. This gate makes **no +claim** that xUnit v3, TUnit, MSTest, or NUnit's own test runners are +Native-AOT compatible — the smoke apps never publish a test framework's +runner/host as Native AOT at all. Any documentation or package guide +referencing this gate must preserve both distinctions. + +**Trigger design — selective at the job level, not the workflow-trigger +level, because this gate is a required check.** Running all eight legs on +every PR regardless of changed files was rejected as unnecessary cost; +triggering only on an exact per-package path match was rejected as a +false-confidence risk, since a shared core/generator change can invalidate +every leg at once. `docs.yml`'s existing `on: pull_request: paths:` filter is +the wrong pattern to copy here: GitHub's required-check semantics mean a +workflow that is *entirely skipped* by a `paths:` filter at the trigger level +leaves its required check `Pending` rather than reporting success, which +blocks the PR indefinitely rather than passing it — the opposite of the +intended "no AOT-relevant change, gate reports green" behavior. A job skipped +via an `if:` condition *inside* a workflow that did start, by contrast, +reports a successful/skipped conclusion and satisfies a required check +normally. + +Accordingly, the workflow itself has **no `paths:` filter on its +`pull_request` trigger** — it starts on every relevant PR event, so its +required check always has something to report. Selectivity happens entirely +inside the workflow: + +1. An inexpensive first job computes which of the eight legs are applicable + for this PR's changed files (a small repository-owned script — `git diff + --name-only` against the PR's base — not a third-party changed-files + action, consistent with keeping this design understandable from the + workflow file itself), publishing that as a job output (e.g. a JSON array + consumed by a dynamic matrix, or one boolean output per leg). +2. If the change touches core `Compono` runtime source (`src/Compono/**`), + `Compono.Generators` source (`src/Compono.Generators/**`), + `Directory.Packages.props`, any `Directory.Build.props`/`.targets` + affecting packed output, or the AOT-validation workflow/script itself, + every one of the eight legs is marked applicable — a shared core/generator + change can invalidate all of them at once, and reflexively narrowing this + would create false confidence. +3. If the change is scoped to one integration package's own + `src/Compono./**` source, only that package's leg (plus core's own + `Compono.AotSmokeTest` leg, which every core-affecting change already + covers under point 2, and which this design does not run reflexively for + an integration-package-only change) is marked applicable. +4. Each of the eight legs' actual publish-and-run jobs runs behind an `if:` + condition reading the first job's output — an inapplicable leg reports a + normal skipped conclusion, not a missing/pending status. +5. A final, always-`if: always()`-run aggregation job depends on all eight leg + jobs and is the one job the branch protection/ruleset required-check + configuration actually names: it fails if any *applicable* leg job failed, + and succeeds if every applicable leg passed or if no leg was applicable at + all (nothing AOT-relevant changed). This is the job whose result GitHub + reports for the required check, so the check always resolves to + success/failure, never stays `Pending`. + +This repository does not use GitHub merge queues today (no workflow declares +a `merge_group` trigger) — this design does not add one speculatively; if a +merge queue is adopted later, `merge_group` can be added to this workflow's +triggers as its own follow-up, not anticipated here. + +**Scope note.** This amendment does not reopen or re-verify any individual +package's AOT-safety claim — those remain exactly as this ADR's own Amendments +1-5 and each package's own ADR (ADR-0057, ADR-0059, ADR-0051, ADR-0055, +ADR-0043) established them. It only changes *when* the existing proof +mechanism runs: from "once, by hand, before release" to "on every relevant +change, automatically, blocking." diff --git a/docs/packages/compono-nsubstitute.md b/docs/packages/compono-nsubstitute.md index 9002f1e1..c97f05a7 100644 --- a/docs/packages/compono-nsubstitute.md +++ b/docs/packages/compono-nsubstitute.md @@ -65,6 +65,18 @@ case where this surfaced a genuine hidden dependency during migration, and [ADR-0025](../adr/0025-compono-nsubstitute-package-design.md) for the full rationale. +## Native AOT / trimming + +`Compono.NSubstitute` is **not** Native AOT/trimming-compatible. NSubstitute's +own `Substitute.For()` uses Castle DynamicProxy, which relies on +runtime IL emission — this is confined entirely to this integration package +(core `Compono`'s provider contract itself requires and performs zero +reflection), but it means any composed type reachable through +`UseNSubstitute()` cannot be published with `PublishAot=true`. If you need +Native AOT support, use [Compono.TestDoubles](compono-testdoubles.md)'s +source-generated doubles instead — see [ADR-0024](../adr/0024-public-provider-extensibility-model.md) +for the full rationale. + ## Next - [Shared Values](../concepts/shared-values.md) — asserting against a diff --git a/docs/plans/0061-pre-1-0-cleanup-and-consolidation.md b/docs/plans/0061-pre-1-0-cleanup-and-consolidation.md new file mode 100644 index 00000000..65ee2eb0 --- /dev/null +++ b/docs/plans/0061-pre-1-0-cleanup-and-consolidation.md @@ -0,0 +1,384 @@ +# [PLAN-0061] Pre-1.0 Cleanup and Consolidation Gate + +**Status:** In Progress + +**Implements:** [ADR-0041 Amendment 7](../adr/0041-aot-safe-row-binding-dispatch.md), +[ADR-0033 Amendment 2](../adr/0033-public-preview-samples-strategy.md) + +## Goal + +Close the concrete gaps found by the pre-1.0 repository-wide cleanup audit — +a correctness bug in three framework integrations, unreachable docs-site +navigation, a fragile CI package-list pattern, a permanent AOT regression +gate, and zero sample coverage for three shipped packages — without +manufacturing architectural work the audit didn't find evidence for. Done +when: the three negative-seed guards are fixed and regression-tested; +MSTest/NUnit are reachable from the docs nav; every publishable package has +an obvious working sample; AOT smoke coverage runs permanently in CI with a +precisely stated guarantee; the publishable-package list is declared once, +not three times, via a mechanism that actually survives across separate CI +steps; and full build/tests/package-validation/sample-validation/AOT-CI are +green. + +**Revision note (2026-09-03):** this plan originally also scoped landing +ADR-0058 (generator-facing runtime hook `EditorBrowsable` policy) and its two +companion ADR-0041/ADR-0055 amendments. Jonas Ha's PR #126 merged that entire +slice directly to `main` (as ADR-0058, tracked to completion under its own +`PLAN-0060`) before this plan was finalized. That work is removed from scope +here — see "Revalidation against `origin/main`" below. This plan was +originally drafted as PLAN-0060; it was renumbered to 0061 once `main` +independently claimed 0060 for the now-superseded generator-hook-policy plan. + +## Revalidation against `origin/main` (2026-09-03) + +Re-checked every audit finding below directly against `main` after merging +PR #126 (commit `43a5e81`): + +- **ADR-0058 generator-hook inventory / `EditorBrowsable` annotations**: + fully landed and `Done` (`PLAN-0060` on `main`). Removed from this plan's + scope entirely — no remaining task. +- **Negative-seed guard state**: unaffected. `Compono.XunitV3`, + `Compono.MSTest`, `Compono.NUnit`'s `ComposeAttribute{TProfile}.cs` all + still reduce to a bare `builder.AddProfile()` with no guard, + confirmed by direct re-read post-merge. Still required. +- **`package-validation.yaml` structure**: unaffected by PR #126. Re-inspected + its actual step structure (not assumed) — the nuget.org-baseline lookup, + `pack_one` calls, and CS1591 enforcement are three **separate** `run:` + steps (lines ~47, ~82, ~111), each its own shell process. A `PACKAGES=(...)` + Bash array declared in one step does **not** survive to the next — this + plan's original mechanism was wrong as written. Corrected below (Task 5). +- **AOT smoke-test projects / CI wiring**: unaffected. Still eight + `test/*.AotSmokeTest` projects, still zero references from any `.github/workflows/*` + or `scripts/*`. Still required. +- **Sample structure / package coverage**: unaffected. `samples/` still has + exactly the same three projects (`Compono.Samples.AspNetApi`, + `.AspNetApi.Tests`, `.BasicUsage`); `Compono.Http`/`Compono.DependencyInjection`/ + `Compono.Logging` still have zero example-level coverage. Still required. +- **`mkdocs.yml` navigation**: unaffected. "Package Guides" nav still lists 9 + of 11 packages, still missing `Compono.MSTest`/`Compono.NUnit`. Still + required. +- **Generator helper duplication**: `GeneratorVersion` duplication across the + five emitters is unaffected. `StableHash` duplication is now **worse** than + originally audited — PR #126 did not introduce this, but a direct re-check + found a **third** copy beyond the two the original audit found: + `TestDoubleOverloadIdentity.StableHash` (`src/Compono.Generators/Emitters/TestDoubleOverloadIdentity.cs:166`), + in addition to `GeneratedFileNaming.StableHash` and + `TestDoubleIdentifierNaming.StableHash`. All three copies' own comments + already cross-reference each other by name as "the same FNV-1a algorithm," + so this was already self-documented drift risk, not a new finding — Task 6 + below is corrected to cover three files, not two. +- **`docs.yml` stale package count**: unaffected. Line ~106 still says "eight + publishable packages" against a loop that builds 11. Still required. +- **`Compono.NSubstitute` AOT-limitation doc gap**: unaffected. Still + required. + +No other audit finding, ADR inventory, or assumption changed. This plan does +not expand scope to cover anything from PR #126 beyond removing the work it +already completed. + +## Scope + +**In scope**, per the accepted audit findings and the product-owner scoping +exchange that followed it: + +- Negative-seed guard fix in `Compono.XunitV3`/`Compono.MSTest`/`Compono.NUnit` + + regression tests, using `Compono.TUnit`'s already-correct behavior as the + contract. +- `mkdocs.yml` navigation fix for `Compono.MSTest`/`Compono.NUnit` (package + guide + API reference). +- `docs.yml`'s stale "eight publishable packages" text. +- `Compono.NSubstitute`'s documented AOT limitation (ADR-0024) surfaced in its + own package guide. +- Publishable-package-list consolidation in `package-validation.yaml` to a + single authoritative declaration, using a mechanism that actually persists + across that workflow's separate `run:` steps (see Task 5). +- `StableHash` (three copies) / `GeneratorVersion` (five copies) + generator-helper deduplication (validated by the audit as byte-for-byte + identical, internal-only, zero AOT/annotation difference). +- Permanent, CI-blocking AOT smoke validation per + [ADR-0041 Amendment 7](../adr/0041-aot-safe-row-binding-dispatch.md), with + conservative path-filtered triggering and a precisely scoped guarantee. +- Canonical sample coverage extension for `Compono.Http`, + `Compono.DependencyInjection`, `Compono.Logging` per + [ADR-0033 Amendment 2](../adr/0033-public-preview-samples-strategy.md). +- `Compono.XunitV3.SampleTests`' undocumented CI/filter requirement and its + three stale `RealRunnerTests.cs` comment references (added 2026-09-03 + during the audit-to-plan traceability reconciliation — see Phase 1 Task + below; documentation/comment correctness only, not a rename or + sample-architecture change). +- The duplicated `"Compono.ComposableAttribute"` metadata-name literal + (`WellKnownTypeData.cs` / `ComposableAttributeDiscovery.cs`, added + 2026-09-03 during the same reconciliation) — folded into the existing + generator-helper consolidation task. +- `TrackingNames`' doc-comment/test-coverage gap (added 2026-09-03 during the + same reconciliation) — see Phase 1 Task below; ADR-0005 actually requires + this coverage (see "TrackingNames disposition" below), so this is a real + missing-test gap, not a stale comment to merely correct. + +**Explicitly deferred** (see the audit report for evidence): + +- Framework-binder duplication across the four `[Compose]` integration + packages — moved to [RESEARCH-0019](../research/0019-framework-binder-duplication-spike-scope.md), + runs independently, non-blocking for 1.0. The research finding "keep the + duplication" is a fully valid outcome, not merely a placeholder pending + eventual consolidation. +- `TestDoubleAnalyzer.Analyze` decomposition, `CompositionBuilderExtensions` + same-name-four-assemblies pattern, `*SampleTests` rename, `WellKnownTypes` + five-class split, `dogfood-validate.sh`'s cosmetic success-message text, + missing AotSmokeTest coverage for `Compono.NSubstitute` (already explained + by ADR-0024)/`Compono.Bogus`/`Compono.DependencyInjection` — all Tier + 3/low-priority findings with no evidence of drift risk or correctness + impact. +- **Four near-identical per-framework generator-registration blocks in + `ComponoIncrementalGenerator.Initialize`** (added 2026-09-03 during the + audit-to-plan traceability reconciliation). Explicitly deferred, not + folded into the `StableHash`/`GeneratorVersion` helper consolidation: + the duplication here is structural (each block wires a distinct framework + integration's own discovery path into the pipeline), not a duplicated + constant or a byte-for-byte helper function — consolidating it would mean + introducing an abstraction over generator registration/lifecycle behavior + itself, a materially larger and riskier change than centralizing one + semantic operation. No concrete drift bug or maintenance failure has ever + been tied to these four blocks, and four visually-similar blocks reducing + to one abstraction is not, by itself, evidence of better maintainability. + Revisit if a real cross-framework registration drift bug appears (e.g. a + fix applied to one framework's block and missed in another's). +- ADR-0058's generator-hook `EditorBrowsable` policy and its landing on + `main` — already `Done` via `PLAN-0060`, no longer this plan's concern. + +## `TrackingNames` disposition (resolved 2026-09-03) + +Evidence checked before adding this to Phase 1, per the instruction not to +manufacture tests from an unverified comment alone: +[ADR-0005](../adr/0005-generator-implementation-conventions.md) itself +requires `.WithTrackingName(...)` on every named incremental pipeline stage +"so incrementality (cache-hit behavior) can be asserted in tests later." +[PLAN-0001](0001-milestone-1-source-generation-foundation.md) (line ~415) +independently recorded, at the time tracking names were first added: "no +incremental-caching test exists yet to consume them (that's still open work, +not done in this pass)." **Disposition: A — this is an actual intended +invariant with a real, long-standing missing-test gap, not a stale or +aspirational comment.** Phase 1 adds the smallest test that proves the +promised invariant, rather than correcting the comment. + +## Phases + +### Phase 1 — Product correctness and repository quality gate + +**Status:** In Progress + +Ships as its own PR. + +- [x] Add the negative-seed guard + try/catch (matching `Compono.TUnit`'s + `ComposeAttribute{TProfile}.cs`) to + `src/Compono.XunitV3/ComposeAttribute{TProfile}.cs`, + `src/Compono.MSTest/ComposeAttribute{TProfile}.cs`, + `src/Compono.NUnit/ComposeAttribute{TProfile}.cs`. +- [x] Port `Compono.TUnit.Tests/SeedObservabilityTests.cs`'s negative-seed + test into `Compono.XunitV3.Tests`, `Compono.MSTest.Tests`, + `Compono.NUnit.Tests` so this gap can't silently recur. +- [x] `mkdocs.yml`: add `Compono.MSTest`/`Compono.NUnit` rows under both + "Package Guides" and "Reference → API Reference". +- [x] `docs/packages/compono-nsubstitute.md`: add a concise paragraph stating + the existing Native AOT/trimming limitation (ADR-0024), pointing to + that ADR. +- [x] `docs.yml`: fix line ~106's stale "eight publishable packages" text to + match the loop's actual 11-package count (or de-numericize it). +- [x] **`Compono.XunitV3.SampleTests` CI/filter documentation + stale-comment + correction** (repository/documentation correctness, not sample- + architecture redesign — no rename, no relocation): document, inside + the project itself (a `README.md` in its folder, or a prominent header + comment directly on `FailingCompositionTests.cs`/ + `FailingConfigProfileTests.cs`), the actual + `--filter-not-class "Compono.XunitV3.SampleTests.Failing*"` + requirement `package-validation.yaml` already applies, so a bare + `dotnet test` no longer reads as "this project is broken." Correct the + three stale `RealRunnerTests.cs` references in + `Compono.XunitV3.SampleTests.csproj`'s comments (that file was removed + per PLAN-0004's own history). Confirm afterward that the project's + classification as a **packaged-consumer/real-runner validation + fixture** — not a user-facing canonical sample — is unambiguous from + its own README/comments to a new reader. +- [x] `test/Compono.Generators.Tests`: add the smallest incremental-caching + regression test ADR-0005/PLAN-0001 left as open work — assert at least + one representative `TrackingNames`-tagged pipeline stage (e.g. + `ComposableTypes` or `ComposeMethodsAll`) reports a cache hit + (`IncrementalStepRunReason.Cached`/`Unchanged`) via + `GeneratorDriverRunResult.Results[0].TrackedSteps[...]` on a second + driver run after an unrelated, non-invalidating source edit. This + proves the invariant the `TrackingNames` doc comment already promises; + it does not attempt full incremental-caching coverage of every stage. +- [x] **`.github/workflows/package-validation.yaml` package-list + consolidation, corrected mechanism**: the nuget.org-baseline lookup, + `pack_one` calls, and CS1591 enforcement are three independent `run:` + steps — a shell-local Bash array cannot cross that boundary. Declare + the 11-package list once as a **job-level `env:` string** + (`PACKAGES: "Compono Compono.XunitV3 Compono.NSubstitute Compono.Bogus Compono.TUnit Compono.TestDoubles Compono.DependencyInjection Compono.Http Compono.Logging Compono.MSTest Compono.NUnit"`, + alongside the existing `BREAKING_CHANGE`/`PACK_OUTPUT` job-level `env:` + entries) — GitHub Actions injects job-level `env:` into every step's + process environment automatically, so this survives across steps with + no extra plumbing. Each of the three steps loops + `for pkg in $PACKAGES; do ... done` instead of repeating the literal + list. No generalized manifest file, no dynamic project discovery, no + cross-job output — the list stays a single, locally-readable line in + the same workflow file. +- [x] Consolidate `StableHash` — now duplicated **three** times + (`src/Compono.Generators/Emitters/GeneratedFileNaming.cs`, + `Emitters/TestDoubleIdentifierNaming.cs`, + `Emitters/TestDoubleOverloadIdentity.cs`, confirmed byte-for-byte + identical FNV-1a implementations whose own comments already + cross-reference each other) into one shared internal helper all three + call. +- [x] Consolidate the `GeneratorVersion` fallback-chain logic (currently + duplicated across `CompositionPlanEmitter`, `CollectionPlanEmitter`, + `TestDoubleEmitter`, `LoggingActivationEmitter`, + `RowInvokerRegistrationEmitter`) into one shared internal helper + parameterized by the calling type/assembly. +- [x] Consolidate the duplicated `"Compono.ComposableAttribute"` + metadata-name literal: `WellKnownTypeData.cs:22` and + `Discovery/ComposableAttributeDiscovery.cs:22` both declare the exact + same string for the same semantic identity (the `[Composable]` + attribute's metadata name), consumed via two genuinely different + paths — `ComposableAttributeDiscovery.AttributeMetadataName` already + feeds `ForAttributeWithMetadataName` in + `ComponoIncrementalGenerator.cs:60`, while `WellKnownTypeData.cs`'s + copy feeds its own symbol-cache lookup — but both have the same reason + to change (the attribute's fully-qualified name). Reuse the existing + `ComposableAttributeDiscovery.AttributeMetadataName` constant from + `WellKnownTypeData.cs` instead of its own literal; no new helper type, + no metadata-name registry. +- [x] New `aot-validation.yaml` workflow: one reusable script + matrix job + driving the existing eight `*.AotSmokeTest` projects' established + pack → local-feed → publish `-p:PublishAot=true` → run pattern. + Handle the three structural outliers (`Compono.Logging.AotSmokeTest`'s + `verify-packaging.sh`, `Compono.Http.AotSmokeTest`'s `AnalyzerContract/`, + core `Compono.AotSmokeTest`'s lack of a framework integration to call + through) via a per-entry optional hook, not bespoke per-project jobs. + - [x] **No `paths:` filter on the workflow's `pull_request` trigger** — the + workflow always starts, so its required check always has something to + report (a workflow skipped entirely by trigger-level `paths:` leaves a + required check `Pending` and blocks the PR, per GitHub's required-check + semantics — confirmed as the reason `docs.yml`'s own trigger-level + `paths:` pattern must not be copied here). + - [x] A first, inexpensive job computes which of the eight legs are + applicable from the PR's changed files (a small repository-owned + `git diff --name-only`-based script — no third-party changed-files + action), publishing the result as a job output a dynamic matrix or + per-leg `if:` conditions consume. + - [x] That job marks **all eight legs applicable** on any change to + `src/Compono/**`, `src/Compono.Generators/**`, + `Directory.Packages.props`, `Directory.Build.props`/`.targets` + affecting packed output, any `*.AotSmokeTest` project, or the + `aot-validation.yaml` workflow/script itself. + - [x] That job marks **only that package's leg** (plus core's own + `Compono.AotSmokeTest` leg, already covered under the point above for + any core-affecting change) applicable on a change scoped to one + integration package's own `src/Compono./**`. + - [x] Each leg's actual publish-and-run job runs behind an `if:` condition + reading that output — an inapplicable leg reports a normal skipped + conclusion, never a missing status. + - [x] A final `if: always()` aggregation job depends on all eight leg jobs + and is the one job named in branch protection/ruleset as the required + check — fails if any applicable leg failed, succeeds if every + applicable leg passed or none was applicable. + - [x] No `merge_group` trigger is added — this repository does not use + GitHub merge queues today; revisit only if that changes. + - [x] The workflow's job/step names and any package-guide text referencing + it state the exact guarantee from + [ADR-0041 Amendment 7](../adr/0041-aot-safe-row-binding-dispatch.md) + — "the packaged Compono package's exercised public API surface is + callable from a Native-AOT-published, trimmed consumer application + without runtime AOT/trimming failures" — covering core `Compono` + as well as the integration packages, making no claim of exhaustive + public-API coverage beyond what each smoke consumer actually + exercises, and explicitly not claiming the test framework's own + runner/host is Native-AOT compatible. +- [ ] Full `dotnet build`/`dotnet test Compono.slnx`, `package-validation.yaml`, + and the new `aot-validation.yaml` all green. + +### Phase 2 — Canonical samples + +**Status:** Not Started + +Ships as its own PR, after Phase 1 merges. + +- [ ] `Compono.Samples.AspNetApi`: add a `Compono.Http` scenario (handler-based + testing of an outbound HTTP-calling endpoint/service already present in + the sample, or a small new one if none currently makes an outbound + call) demonstrating realistic assertions a consumer would actually + write, not merely a compiling reference. +- [ ] `Compono.Samples.AspNetApi`: add a `Compono.DependencyInjection` + scenario — a DI-composed row provider registered into the host's + `IServiceCollection`, exercised through a realistic test. +- [ ] `Compono.Samples.BasicUsage`: add a `Compono.Logging` scenario — + compose an `ILogger`-dependent type via `UseLogging()`, assert a + captured log entry via `Verify()`. +- [ ] `docs/samples/*.md` overview pages for both samples: add a mention of + their new scenarios (no new per-scenario page, per + [ADR-0033 Amendment 2](../adr/0033-public-preview-samples-strategy.md)). +- [ ] `docs/packages/compono-http.md`, `compono-dependencyinjection.md`, + `compono-logging.md`: link to their new sample scenario, if not already + linked. +- [ ] `README.md`/`docs/packages/index.md`: confirm every publishable + package's row links to a working example (sample or package guide), + correcting any that don't. +- [ ] Full `dotnet build`/`dotnet test Compono.slnx` (both samples build/run + as part of the solution today; confirm the new scenarios do too) and + `package-validation.yaml` green. + +## Critical Files + +- `src/Compono.XunitV3/ComposeAttribute{TProfile}.cs`, + `src/Compono.MSTest/ComposeAttribute{TProfile}.cs`, + `src/Compono.NUnit/ComposeAttribute{TProfile}.cs` — negative-seed guard fix. +- `src/Compono.Generators/Emitters/GeneratedFileNaming.cs`, + `Emitters/TestDoubleIdentifierNaming.cs`, + `Emitters/TestDoubleOverloadIdentity.cs` — `StableHash` consolidation. +- `src/Compono.Generators/Emitters/CompositionPlanEmitter.cs`, + `CollectionPlanEmitter.cs`, `TestDoubleEmitter.cs`, + `LoggingActivationEmitter.cs`, `RowInvokerRegistrationEmitter.cs` — + `GeneratorVersion` consolidation. +- `src/Compono.Generators/WellKnownTypes/WellKnownTypeData.cs`, + `Discovery/ComposableAttributeDiscovery.cs` — `ComposableAttribute` + metadata-name literal consolidation. +- `test/Compono.XunitV3.SampleTests/Compono.XunitV3.SampleTests.csproj`, + its `FailingCompositionTests.cs`/`FailingConfigProfileTests.cs`, and a new + `README.md` in that project's folder — CI/filter documentation + + stale-comment cleanup. +- `test/Compono.Generators.Tests/` — new incremental-caching regression test + for `TrackingNames`. +- `mkdocs.yml`, `.github/workflows/docs.yml`, + `docs/packages/compono-nsubstitute.md` — docs sync. +- `.github/workflows/package-validation.yaml` — package-list consolidation + via job-level `env:`. +- `.github/workflows/aot-validation.yaml` (new) — permanent AOT CI gate. +- `samples/Compono.Samples.AspNetApi/**`, `samples/Compono.Samples.BasicUsage/**` — + Phase 2 sample scenarios. + +## Test Plan + +- New regression tests in `Compono.XunitV3.Tests`, `Compono.MSTest.Tests`, + `Compono.NUnit.Tests` mirroring `Compono.TUnit.Tests/SeedObservabilityTests.cs`'s + negative-seed-plus-throwing-profile case. +- Existing full solution test suite stays green throughout — helper + consolidation changes are internal-only and must not change any observable + behavior. +- New incremental-caching regression test in `Compono.Generators.Tests` + proving at least one `TrackingNames`-tagged stage reports a cache hit on an + unrelated second edit (see "`TrackingNames` disposition" above). +- After the `Compono.XunitV3.SampleTests` documentation fix, a fresh + contributor following only the project's own README/comments (not + `package-validation.yaml`) must be able to reproduce the correct + `--filter-not-class` invocation and get a clean 48/48 pass. +- `aot-validation.yaml`'s own matrix run is the test plan for the AOT-CI gate + itself — a deliberately-broken annotation on one leg (removed during local + validation, not committed) should be used once to confirm the gate actually + fails before merging it as passing. +- Phase 2's new sample scenarios each need at least one real assertion + exercised by `dotnet test Compono.slnx`, not merely a compiling call. + +## Notes + +The framework-binder duplication research (RESEARCH-0019) is intentionally +absent from both phases' task lists — it runs independently and does not +gate either PR. diff --git a/docs/plans/README.md b/docs/plans/README.md index 2aa3a59d..444b6145 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -74,4 +74,5 @@ one. This file is just the mechanics: numbering, status, and the index. | [0057](0057-compono-mstest-package-design-impl-plan.md) | `Compono.MSTest` Package Design | Done | | [0058](0058-testdoubles-invocation-aware-callback-responses.md) | Compono.TestDoubles: Invocation-Aware Callback Responses | In Progress | | [0059](0059-compono-nunit-package-design-impl-plan.md) | Compono.NUnit Package Design | Done | -| [0060](0060-public-generator-facing-runtime-infrastructure.md) | Public Generator-Facing Runtime Infrastructure | In Progress | +| [0060](0060-public-generator-facing-runtime-infrastructure.md) | Public Generator-Facing Runtime Infrastructure | Done | +| [0061](0061-pre-1-0-cleanup-and-consolidation.md) | Pre-1.0 Cleanup and Consolidation Gate | Not Started | diff --git a/docs/research/0019-framework-binder-duplication-spike-scope.md b/docs/research/0019-framework-binder-duplication-spike-scope.md new file mode 100644 index 00000000..a10e46b0 --- /dev/null +++ b/docs/research/0019-framework-binder-duplication-spike-scope.md @@ -0,0 +1,115 @@ +# [RESEARCH-0019] Framework Binder Duplication — Spike Scope + +**Status:** Scoped, not yet executed — spawned by the pre-1.0 cleanup gate +(PLAN-0061), explicitly non-blocking for 1.0. + +## Why this exists + +The pre-1.0 cleanup audit found `BindingPlan`, `ParameterBindingPlan`, +`PositionalArgumentBinder`, and `RowInvokers`-adjacent binding-dispatch code +structurally duplicated across `Compono.XunitV3`, `Compono.TUnit`, +`Compono.MSTest`, and `Compono.NUnit`. Structural similarity across four +independent packages is not, by itself, evidence that consolidation is worth +doing — `references/design-decisions.md`'s duplication classification +(harmful / intentional package-local / coincidental / validation) requires +actually comparing the implementations, not just their shape. This document +scopes that comparison; it does not perform it. A future execution of this +spike fills in the sections below with real findings, then either updates this +document to a completed research record or produces a design/ADR based on it. + +**Default disposition, per explicit product direction:** keep package-local +duplication unless the spike demonstrates one stable, shared concept whose +extraction clearly improves maintainability without increasing coupling. A +result of "keep the duplication as-is" is a fully valid, complete outcome of +this spike, and this document existing does not create an implied obligation +to extract anything — a spike that concludes "keep it as-is" is not a lesser +outcome than one that recommends consolidation. + +## What the spike must compare + +The binding-dispatch code in each of the four framework-integration packages' +`Binding/` directories — `BindingPlan.Build`, `ParameterBindingPlan`, +`PositionalArgumentBinder`, and each package's own `RowInvokers`/dispatch +glue (post-ADR-0041, these read from core `Compono`'s `RowInvokerRegistry` +rather than building delegates themselves, so "duplication" here means the +surrounding binding-plan/validation code, not the dispatch mechanism ADR-0041 +already centralized) — **and**, added 2026-09-03 during the audit-to-plan +traceability reconciliation, each package's own `ConfigProfileBinder` and its +`[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]` +annotation coverage (ADR-0041 Amendments 4-5, ADR-0057). This was flagged by +the original audit as "NEEDS-SPIKE alongside binding-algorithm consolidation" +— currently in sync across all four packages, no drift observed today, but +in the same code family this spike already covers, so it's folded in here +rather than treated as a separate research item. + +The spike must verify, across `Compono.XunitV3`/`Compono.TUnit`/ +`Compono.MSTest`/`Compono.NUnit`: + +- equivalent `[DynamicallyAccessedMembers(PublicConstructors)]` placement + wherever the same reflection behavior (`ConstructorInfo.Invoke`-based + `TConfig`/`TProfile` construction) exists; +- any framework-specific difference in constructor/profile-binding behavior + that would justify different annotations rather than identical ones; +- whether annotation drift is a real, distinct maintenance risk of + package-local binder duplication, separate from the binding-plan/dispatch + risk the rest of this spike covers; +- whether the annotations themselves would become easier or harder to + maintain correctly under any shared abstraction this spike might otherwise + propose. + +A valid result for this part of the spike, same as the rest of it: *all four +binders currently carry equivalent AOT contracts; keep the package-local +implementations.* + +## Questions the spike must answer + +1. Which logic is byte-for-byte or semantically identical across all four + packages? +2. Which behavior is genuinely framework-specific (e.g. how each framework's + own `MethodInfo`/`DataGeneratorMetadata`/theory-row shape gets turned into + an ordered parameter list)? +3. What invariants are genuinely shared across all four — not just "looks the + same today," but "has the same reason to change"? +4. What is each binder's own reason to change, independent of the others (a + framework API change in one framework's own SDK vs. a Compono-side + composition-model change)? +5. Would sharing require a new runtime or package dependency between + currently-independent integration packages, or a new dependency on core + `Compono` beyond what already exists? +6. Would the resulting shared abstraction become public, or generator-facing + (i.e. would `Compono.Generators` need to emit code against it)? +7. Would extraction reduce each package's current independence — could one + package upgrade/change its own binder without a coordinated release of the + others afterward? +8. Does it affect any existing trimming/AOT annotation + (`[DynamicallyAccessedMembers(...)]`, per ADR-0041's own amendments) on any + of the four binders? +9. Would a change to the shared code now require a coordinated release across + otherwise-independent integration packages that ship on independent + schedules today? +10. Does extraction materially reduce real drift risk (a bug fixed in one + binder and never ported to the others — the exact failure mode the + negative-seed-guard finding in PLAN-0061 Phase 1 already demonstrated is + real), or does it merely reduce line count with no corresponding risk + reduction? + +## Escalation to a correctness/public-API concern + +This spike is explicitly non-blocking for 1.0 unless it uncovers an actual +correctness or public-contract risk that becomes materially harder to fix +after the public API freezes (e.g. a second drift bug of the same shape as +the negative-seed-guard finding, discovered by direct comparison rather than +audit sampling). If that happens, the spike's findings should be escalated +immediately as their own light/deep design dive per `tasks/design.md`, rather +than waiting for this document to be "finished" in the ordinary sense. +Discovering shared duplication is never, by itself, evidence that +consolidation is the right answer — this spike may just as validly conclude +that the duplication should stay exactly as it is. + +## Links + +- PLAN-0061 — the pre-1.0 cleanup plan that spawned this spike; this research + runs independently of both of its implementation phases. +- [ADR-0041](../adr/0041-aot-safe-row-binding-dispatch.md) — the ADR that + already centralized the dispatch-delegate mechanism these four packages' + own binding-plan code sits on top of. diff --git a/mkdocs.yml b/mkdocs.yml index 2d7809a5..b73ee3d2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -149,6 +149,8 @@ nav: - Compono.DependencyInjection: packages/compono-dependencyinjection.md - Compono.Http: packages/compono-http.md - Compono.Logging: packages/compono-logging.md + - Compono.MSTest: packages/compono-mstest.md + - Compono.NUnit: packages/compono-nunit.md - Best Practices: - Overview: best-practices/index.md - Organizing Profiles: best-practices/organizing-profiles.md @@ -185,6 +187,8 @@ nav: - Compono.DependencyInjection: reference/api/Compono.DependencyInjection/index.md - Compono.Http: reference/api/Compono.Http/index.md - Compono.Logging: reference/api/Compono.Logging/index.md + - Compono.MSTest: reference/api/Compono.MSTest/index.md + - Compono.NUnit: reference/api/Compono.NUnit/index.md - Diagnostics: reference/diagnostics.md - Glossary: reference/glossary.md - Roadmap: diff --git a/src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs b/src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs index 9e9f988b..08aba096 100644 --- a/src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs +++ b/src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs @@ -410,7 +410,7 @@ public static DiscoveredTestDoubleInfo Analyze(INamedTypeSymbol interfaceType, C if (discriminatorSuffixByIdentity.ContainsKey(key)) continue; - var baseHash = TestDoubleOverloadIdentity.StableHash(key.Canonical); + var baseHash = StableHash.Compute(key.Canonical); var suffix = $"_{baseHash}"; var disambiguator = 2; @@ -647,7 +647,7 @@ public static DiscoveredTestDoubleInfo Analyze(INamedTypeSymbol interfaceType, C // signature collision with an already-matching-eligible sibling of that exact literal name // (a genuine CS0111 risk, confirmed by compiler spike - see PLAN-0054's "Naming/collision // policy"), it falls back to a deterministic hash-suffixed name, reusing - // TestDoubleOverloadIdentity.StableHash exactly like discriminatorSuffixByIdentity above. + // StableHash.Compute exactly like discriminatorSuffixByIdentity above. var overloadMatchingEligibleCandidates = new HashSet( matchingEligibleShapedOverloadedCandidates.Where(m => !derivedNameCollisionMembers.Contains(m)), SymbolEqualityComparer.Default); @@ -744,7 +744,7 @@ public static DiscoveredTestDoubleInfo Analyze(INamedTypeSymbol interfaceType, C continue; } - var baseHash = TestDoubleOverloadIdentity.StableHash(aliasBase); + var baseHash = StableHash.Compute(aliasBase); var aliasName = $"{aliasBase}_{baseHash}"; var disambiguator = 2; @@ -1782,7 +1782,7 @@ private static List AssignCallbackNameSuffixes(List member switch { diff --git a/src/Compono.Generators/Emitters/CollectionPlanEmitter.cs b/src/Compono.Generators/Emitters/CollectionPlanEmitter.cs index c9deada5..a631c379 100644 --- a/src/Compono.Generators/Emitters/CollectionPlanEmitter.cs +++ b/src/Compono.Generators/Emitters/CollectionPlanEmitter.cs @@ -11,15 +11,6 @@ namespace Compono.Generators.Emitters; /// internal static class CollectionPlanEmitter { - private static readonly string GeneratorVersion = - typeof(CollectionPlanEmitter).Assembly - .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), inherit: false) - .Cast() - .Select(a => a.InformationalVersion) - .FirstOrDefault() - ?? typeof(CollectionPlanEmitter).Assembly.GetName().Version?.ToString() - ?? "0.0.0"; - public static void Generate(SourceProductionContext context, DiscoveredCollectionInfo collection) { var model = new @@ -30,7 +21,7 @@ public static void Generate(SourceProductionContext context, DiscoveredCollectio ElementNullability = collection.ElementIsNullable ? "global::Compono.Nullability.Nullable" : "global::Compono.Nullability.NotNullable", KeyType = collection.KeyFullyQualifiedTypeName, KeyNullability = collection.KeyIsNullable ? "global::Compono.Nullability.Nullable" : "global::Compono.Nullability.NotNullable", - GeneratorVersion, + GeneratorVersion = GeneratorVersion.Current, }; var source = TemplateHelper.Render("CollectionPlan.scriban", model); diff --git a/src/Compono.Generators/Emitters/CompositionPlanEmitter.cs b/src/Compono.Generators/Emitters/CompositionPlanEmitter.cs index 430dec33..033636a9 100644 --- a/src/Compono.Generators/Emitters/CompositionPlanEmitter.cs +++ b/src/Compono.Generators/Emitters/CompositionPlanEmitter.cs @@ -11,19 +11,6 @@ namespace Compono.Generators.Emitters; /// internal static class CompositionPlanEmitter { - // Read once from this assembly's own metadata rather than hard-coded, so the emitted - // GeneratedCodeAttribute stays accurate as the generator's version changes instead of quietly - // going stale. Falls back to the assembly version if no informational version is set (e.g. no - // real release versioning wired up yet). - private static readonly string GeneratorVersion = - typeof(CompositionPlanEmitter).Assembly - .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), inherit: false) - .Cast() - .Select(a => a.InformationalVersion) - .FirstOrDefault() - ?? typeof(CompositionPlanEmitter).Assembly.GetName().Version?.ToString() - ?? "0.0.0"; - public static void Generate(SourceProductionContext context, DiscoveredTypeInfo type) { var model = new @@ -55,7 +42,7 @@ public static void Generate(SourceProductionContext context, DiscoveredTypeInfo DisplayNameLiteral = SymbolDisplay.FormatLiteral(m.DisplayName, quote: true), DeclaringType = m.DeclaringTypeFullyQualifiedName, }).ToArray(), - GeneratorVersion, + GeneratorVersion = GeneratorVersion.Current, }; var source = TemplateHelper.Render("CompositionPlan.scriban", model); diff --git a/src/Compono.Generators/Emitters/GeneratedFileNaming.cs b/src/Compono.Generators/Emitters/GeneratedFileNaming.cs index 95499af2..5e78241b 100644 --- a/src/Compono.Generators/Emitters/GeneratedFileNaming.cs +++ b/src/Compono.Generators/Emitters/GeneratedFileNaming.cs @@ -22,22 +22,6 @@ public static string HintNameFor(string fullyQualifiedName) foreach (var c in readable) builder.Append(char.IsLetterOrDigit(c) || c == '.' ? c : '_'); - return builder.Append('_').Append(StableHash(fullyQualifiedName)).ToString(); - } - - // FNV-1a, not string.GetHashCode() - the latter is randomized per process on modern runtimes, - // and a hint name that changes between builds would defeat incremental caching and churn - // EmitCompilerGeneratedFiles output paths. - private static string StableHash(string value) - { - const uint offsetBasis = 2166136261; - const uint prime = 16777619; - - var hash = offsetBasis; - - foreach (var c in value) - hash = (hash ^ c) * prime; - - return hash.ToString("x8"); + return builder.Append('_').Append(StableHash.Compute(fullyQualifiedName)).ToString(); } } diff --git a/src/Compono.Generators/Emitters/GeneratorVersion.cs b/src/Compono.Generators/Emitters/GeneratorVersion.cs new file mode 100644 index 00000000..520f951f --- /dev/null +++ b/src/Compono.Generators/Emitters/GeneratorVersion.cs @@ -0,0 +1,21 @@ +namespace Compono.Generators.Emitters; + +/// +/// The generator assembly's own version, read once from its metadata so every emitter's +/// GeneratedCodeAttribute output stays accurate as the generator's version changes, without +/// each emitter separately reflecting over its own typeof(...).Assembly - every emitter type +/// lives in this same assembly, so there was never more than one distinct value to compute +/// (PLAN-0061 Phase 1). Falls back to the assembly version if no informational version is set (e.g. +/// no real release versioning wired up yet), then to a fixed placeholder. +/// +internal static class GeneratorVersion +{ + public static readonly string Current = + typeof(GeneratorVersion).Assembly + .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), inherit: false) + .Cast() + .Select(a => a.InformationalVersion) + .FirstOrDefault() + ?? typeof(GeneratorVersion).Assembly.GetName().Version?.ToString() + ?? "0.0.0"; +} diff --git a/src/Compono.Generators/Emitters/LoggingActivationEmitter.cs b/src/Compono.Generators/Emitters/LoggingActivationEmitter.cs index 44fec15d..d52bd87b 100644 --- a/src/Compono.Generators/Emitters/LoggingActivationEmitter.cs +++ b/src/Compono.Generators/Emitters/LoggingActivationEmitter.cs @@ -13,21 +13,12 @@ namespace Compono.Generators.Emitters; /// internal static class LoggingActivationEmitter { - private static readonly string GeneratorVersion = - typeof(LoggingActivationEmitter).Assembly - .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), inherit: false) - .Cast() - .Select(a => a.InformationalVersion) - .FirstOrDefault() - ?? typeof(LoggingActivationEmitter).Assembly.GetName().Version?.ToString() - ?? "0.0.0"; - public static void Generate(SourceProductionContext context, DiscoveredLoggingCategoryInfo category) { var model = new { CategoryFullyQualifiedName = category.CategoryFullyQualifiedName, - GeneratorVersion, + GeneratorVersion = GeneratorVersion.Current, }; var source = TemplateHelper.Render("LoggingActivation.scriban", model); diff --git a/src/Compono.Generators/Emitters/RowInvokerRegistrationEmitter.cs b/src/Compono.Generators/Emitters/RowInvokerRegistrationEmitter.cs index 6ef3f081..44004ac4 100644 --- a/src/Compono.Generators/Emitters/RowInvokerRegistrationEmitter.cs +++ b/src/Compono.Generators/Emitters/RowInvokerRegistrationEmitter.cs @@ -9,21 +9,12 @@ namespace Compono.Generators.Emitters; /// internal static class RowInvokerRegistrationEmitter { - private static readonly string GeneratorVersion = - typeof(RowInvokerRegistrationEmitter).Assembly - .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), inherit: false) - .Cast() - .Select(a => a.InformationalVersion) - .FirstOrDefault() - ?? typeof(RowInvokerRegistrationEmitter).Assembly.GetName().Version?.ToString() - ?? "0.0.0"; - public static void Generate(SourceProductionContext context, RowInvokerTypeInfo type) { var model = new { FullyQualifiedTypeName = type.FullyQualifiedTypeName, - GeneratorVersion, + GeneratorVersion = GeneratorVersion.Current, }; var source = TemplateHelper.Render("RowInvokerRegistration.scriban", model); diff --git a/src/Compono.Generators/Emitters/StableHash.cs b/src/Compono.Generators/Emitters/StableHash.cs new file mode 100644 index 00000000..efee02cb --- /dev/null +++ b/src/Compono.Generators/Emitters/StableHash.cs @@ -0,0 +1,25 @@ +namespace Compono.Generators.Emitters; + +/// +/// The single FNV-1a implementation every generator-emitted naming scheme hashes through - +/// , , and +/// each need a deterministic, cross-build-stable hash of a +/// raw identity string (never string.GetHashCode(), which is randomized per process on modern +/// runtimes and would churn hint names/generated type names between builds) and previously each +/// carried their own byte-for-byte copy of it (PLAN-0061 Phase 1). +/// +internal static class StableHash +{ + public static string Compute(string value) + { + const uint offsetBasis = 2166136261; + const uint prime = 16777619; + + var hash = offsetBasis; + + foreach (var c in value) + hash = (hash ^ c) * prime; + + return hash.ToString("x8"); + } +} diff --git a/src/Compono.Generators/Emitters/TestDoubleEmitter.cs b/src/Compono.Generators/Emitters/TestDoubleEmitter.cs index 438b3807..da10c15b 100644 --- a/src/Compono.Generators/Emitters/TestDoubleEmitter.cs +++ b/src/Compono.Generators/Emitters/TestDoubleEmitter.cs @@ -10,17 +10,6 @@ namespace Compono.Generators.Emitters; /// internal static class TestDoubleEmitter { - // Same rationale as CompositionPlanEmitter.GeneratorVersion - read once from this assembly's own - // metadata so the emitted GeneratedCodeAttribute stays accurate as the generator's version changes. - private static readonly string GeneratorVersion = - typeof(TestDoubleEmitter).Assembly - .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), inherit: false) - .Cast() - .Select(a => a.InformationalVersion) - .FirstOrDefault() - ?? typeof(TestDoubleEmitter).Assembly.GetName().Version?.ToString() - ?? "0.0.0"; - public static void Generate(SourceProductionContext context, DiscoveredTestDoubleInfo testDouble) { var model = new @@ -235,7 +224,7 @@ public static void Generate(SourceProductionContext context, DiscoveredTestDoubl : $"({string.Join(", ", m.Parameters.Select(p => p.EscapedName))})", }; }).ToArray(), - GeneratorVersion, + GeneratorVersion = GeneratorVersion.Current, }; var source = TemplateHelper.Render("TestDouble.scriban", model); diff --git a/src/Compono.Generators/Emitters/TestDoubleIdentifierNaming.cs b/src/Compono.Generators/Emitters/TestDoubleIdentifierNaming.cs index 2dd3f4c9..850c44fc 100644 --- a/src/Compono.Generators/Emitters/TestDoubleIdentifierNaming.cs +++ b/src/Compono.Generators/Emitters/TestDoubleIdentifierNaming.cs @@ -7,8 +7,8 @@ namespace Compono.Generators.Emitters; /// sanitized output deliberately preserves dots - correct for an AddSource hint name (a file /// name, which can contain dots), wrong for a C# type identifier, where a dot is illegal. /// itself is intentionally left unmodified by this - this is a -/// sibling helper, not a change to it. Reuses the same FNV-1a hash algorithm over the original, -/// unsanitized fully qualified name for the collision-safe suffix. See ADR-0043 Amendment 5, Finding J. +/// sibling helper, not a change to it. Reuses over the original, unsanitized +/// fully qualified name for the collision-safe suffix. See ADR-0043 Amendment 5, Finding J. /// internal static class TestDoubleIdentifierNaming { @@ -24,22 +24,6 @@ public static string SafeIdentifierFor(string fullyQualifiedName) foreach (var c in readable) builder.Append(char.IsLetterOrDigit(c) ? c : '_'); - return builder.Append('_').Append(StableHash(fullyQualifiedName)).ToString(); - } - - // Same FNV-1a algorithm as GeneratedFileNaming.StableHash, over the same raw, unsanitized input - - // never string.GetHashCode(), which is randomized per process on modern runtimes and would churn - // generated type names between builds. - private static string StableHash(string value) - { - const uint offsetBasis = 2166136261; - const uint prime = 16777619; - - var hash = offsetBasis; - - foreach (var c in value) - hash = (hash ^ c) * prime; - - return hash.ToString("x8"); + return builder.Append('_').Append(StableHash.Compute(fullyQualifiedName)).ToString(); } } diff --git a/src/Compono.Generators/Emitters/TestDoubleOverloadIdentity.cs b/src/Compono.Generators/Emitters/TestDoubleOverloadIdentity.cs index f3a74285..85eda738 100644 --- a/src/Compono.Generators/Emitters/TestDoubleOverloadIdentity.cs +++ b/src/Compono.Generators/Emitters/TestDoubleOverloadIdentity.cs @@ -20,7 +20,7 @@ internal static class TestDoubleOverloadIdentity /// overload identity - either the same real overload (impossible within one interface, the /// compiler already prevents it) or a diamond collision: the same signature inherited from two /// different base interfaces (ADR-0044 Amendment 3 Finding 8). Use this, not - /// of it, for any identity/equality decision - a 32-bit hash can + /// of it, for any identity/equality decision - a 32-bit hash can /// collide between two genuinely different signatures (Codex review, PR #88); the hash is only /// safe for generating a human-scannable, best-effort-unique naming suffix (with its own /// disambiguation fallback for when it does collide - see TestDoubleAnalyzer's @@ -158,21 +158,4 @@ private static void AppendNamedType(StringBuilder builder, INamedTypeSymbol name builder.Append('>'); } } - - // Same FNV-1a algorithm as TestDoubleIdentifierNaming.StableHash/GeneratedFileNaming.StableHash - - // never string.GetHashCode(), which is randomized per process on modern runtimes. - // Internal, not private: TestDoubleAnalyzer's suffix-disambiguation pre-pass needs to re-hash - // when two different canonical signatures collide under the base 8-hex hash. - internal static string StableHash(string value) - { - const uint offsetBasis = 2166136261; - const uint prime = 16777619; - - var hash = offsetBasis; - - foreach (var c in value) - hash = (hash ^ c) * prime; - - return hash.ToString("x8"); - } } diff --git a/src/Compono.Generators/WellKnownTypes/WellKnownTypeData.cs b/src/Compono.Generators/WellKnownTypes/WellKnownTypeData.cs index 85561a23..54b7d394 100644 --- a/src/Compono.Generators/WellKnownTypes/WellKnownTypeData.cs +++ b/src/Compono.Generators/WellKnownTypes/WellKnownTypeData.cs @@ -1,5 +1,7 @@ // ReSharper disable InconsistentNaming +using Compono.Generators.Discovery; + namespace Compono.Generators.WellKnownTypes; internal static class WellKnownTypeData @@ -19,7 +21,11 @@ public enum WellKnownType public static readonly string[] WellKnownTypeNames = [ - "Compono.ComposableAttribute", + // Same identity ComposableAttributeDiscovery's own ForAttributeWithMetadataName registration + // matches on - two independent discovery paths for the [Composable] attribute's metadata + // name, kept in sync via this one constant instead of two copies of the literal + // (PLAN-0061 Phase 1). + ComposableAttributeDiscovery.AttributeMetadataName, "Compono.Composer", "Compono.CompositionRow", "System.DateTime", diff --git a/src/Compono.MSTest/ComposeAttribute{TProfile}.cs b/src/Compono.MSTest/ComposeAttribute{TProfile}.cs index 3e54f07c..8f88018c 100644 --- a/src/Compono.MSTest/ComposeAttribute{TProfile}.cs +++ b/src/Compono.MSTest/ComposeAttribute{TProfile}.cs @@ -25,5 +25,36 @@ public ComposeAttribute(params object?[] inlineValues) : base(inlineValues) { } - internal override void ApplyProfile(CompositionBuilder builder) => builder.AddProfile(); + internal override void ApplyProfile(CompositionBuilder builder) + { + // A negative configured seed must be rejected before any profile work is attempted - + // otherwise Seed = -1 combined with a throwing TProfile.Configure would report the profile + // failure below with "Seed: -1" embedded instead of the documented negative-seed diagnostic + // the base class's own composition-row construction enforces. Matches + // ComposeAttribute's identical early check (PLAN-0061 Phase 1: this + // attribute's own one-generic-argument form had been copied from an XunitV3 revision that + // predated that fix). + if (SeedAsNullable is { } configuredSeed && configuredSeed < 0) + { + throw new CompositionException(AppendSeed( + $"Compono.MSTest requires a non-negative seed, but the configured seed was {configuredSeed}.", + configuredSeed)); + } + + try + { + builder.AddProfile(); + } + catch (CompositionException exception) + { + // ApplyProfile runs while the base class's Lazy is still being built - before + // any CompositionRow exists yet at this point. TProfile.Configure throwing here (e.g. a + // bad registration) must still end with the "Seed: {value}" convention every + // Compono.MSTest-owned pre-composition failure uses, matching + // ComposeAttribute's identical wrapping for its own ApplyProfile + // failures. + var seed = SeedAsNullable ?? Random.Shared.Next(0, int.MaxValue); + throw CompositionException.WithSeedInMessage(exception, seed); + } + } } diff --git a/src/Compono.NUnit/ComposeAttribute{TProfile}.cs b/src/Compono.NUnit/ComposeAttribute{TProfile}.cs index 3bfb30da..9b95219c 100644 --- a/src/Compono.NUnit/ComposeAttribute{TProfile}.cs +++ b/src/Compono.NUnit/ComposeAttribute{TProfile}.cs @@ -26,5 +26,36 @@ public ComposeAttribute(params object?[] inlineValues) : base(inlineValues) { } - internal override void ApplyProfile(CompositionBuilder builder) => builder.AddProfile(); + internal override void ApplyProfile(CompositionBuilder builder) + { + // A negative configured seed must be rejected before any profile work is attempted - + // otherwise Seed = -1 combined with a throwing TProfile.Configure would report the profile + // failure below with "Seed: -1" embedded instead of the documented negative-seed diagnostic + // the base class's own composition-row construction enforces. Matches + // ComposeAttribute's identical early check (PLAN-0061 Phase 1: this + // attribute's own one-generic-argument form had been copied from an XunitV3 revision that + // predated that fix). + if (SeedAsNullable is { } configuredSeed && configuredSeed < 0) + { + throw new CompositionException(AppendSeed( + $"Compono.NUnit requires a non-negative seed, but the configured seed was {configuredSeed}.", + configuredSeed)); + } + + try + { + builder.AddProfile(); + } + catch (CompositionException exception) + { + // ApplyProfile runs while the base class's Lazy is still being built - before + // any CompositionRow exists yet at this point. TProfile.Configure throwing here (e.g. a + // bad registration) must still end with the "Seed: {value}" convention every + // Compono.NUnit-owned pre-composition failure uses, matching + // ComposeAttribute's identical wrapping for its own ApplyProfile + // failures. + var seed = SeedAsNullable ?? Random.Shared.Next(0, int.MaxValue); + throw CompositionException.WithSeedInMessage(exception, seed); + } + } } diff --git a/src/Compono.XunitV3/ComposeAttribute{TProfile}.cs b/src/Compono.XunitV3/ComposeAttribute{TProfile}.cs index 31b5cb6b..c128e70c 100644 --- a/src/Compono.XunitV3/ComposeAttribute{TProfile}.cs +++ b/src/Compono.XunitV3/ComposeAttribute{TProfile}.cs @@ -28,5 +28,35 @@ public ComposeAttribute(params object?[] inlineValues) : base(inlineValues) { } - internal override void ApplyProfile(CompositionBuilder builder) => builder.AddProfile(); + internal override void ApplyProfile(CompositionBuilder builder) + { + // A negative configured seed must be rejected before any profile work is attempted - + // otherwise Seed = -1 combined with a throwing TProfile.Configure would report the profile + // failure below with "Seed: -1" embedded instead of the documented negative-seed diagnostic + // the base class's own GetData enforces. Matches ComposeAttribute's + // identical early check (PR #65 review) - this attribute's own one-generic-argument form had + // been copied from a revision of this file that predated that fix (PLAN-0061 Phase 1). + if (SeedAsNullable is { } configuredSeed && configuredSeed < 0) + { + throw new CompositionException(AppendSeed( + $"Compono.XunitV3 requires a non-negative seed, but the configured seed was {configuredSeed}.", + configuredSeed)); + } + + try + { + builder.AddProfile(); + } + catch (CompositionException exception) + { + // ApplyProfile runs while the base class's Lazy is still being built - before + // GetData ever calls Composer.CreateRow, so no CompositionRow/row.Seed exists yet at this + // point. TProfile.Configure throwing here (e.g. a bad registration) must still end with + // the "Seed: {value}" convention every Compono.XunitV3-owned pre-composition failure + // uses, matching ComposeAttribute's identical wrapping for its own + // ApplyProfile failures. + var seed = SeedAsNullable ?? Random.Shared.Next(0, int.MaxValue); + throw CompositionException.WithSeedInMessage(exception, seed); + } + } } diff --git a/test/Compono.Generators.Tests/IncrementalCachingTests.cs b/test/Compono.Generators.Tests/IncrementalCachingTests.cs new file mode 100644 index 00000000..f3b927f9 --- /dev/null +++ b/test/Compono.Generators.Tests/IncrementalCachingTests.cs @@ -0,0 +1,92 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Compono.Generators.Tests; + +/// +/// ADR-0005 requires .WithTrackingName(...) on every named incremental pipeline stage "so +/// incrementality (cache-hit behavior) can be asserted in tests later" - no test ever did, tracked +/// as open work since PLAN-0001 first added (2026-09-03, PLAN-0061 Phase +/// 1). This proves the promised invariant for one representative stage, not full incremental-caching +/// coverage of every stage names. +/// +public sealed class IncrementalCachingTests +{ + [Fact] + public void ComposableTypesStage_ReportsACacheHit_WhenAnUnrelatedSourceEditFollows() + { + const string original = """ + namespace TestNamespace; + + [Compono.Composable] + public sealed class Customer + { + public Customer(string firstName) + { + FirstName = firstName; + } + + public string FirstName { get; } + } + """; + + // An edit with no possible effect on TestNamespace.Customer's own [Composable] declaration - + // a brand-new, entirely separate type appended after it. Roslyn's incremental generator + // driver diffs the new compilation's syntax trees against the previous run's and reuses + // per-node results for anything structurally unaffected, regardless of whether the new tree + // was produced via an incremental text edit or a fresh parse (both go through the same + // tree-diffing on the driver side) - this is exactly the scenario TrackingNames' own doc + // comment describes wanting to assert. + const string edited = original + """ + + namespace TestNamespace; + + public sealed class Unrelated; + """; + + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp14); + var originalTree = CSharpSyntaxTree.ParseText(original, parseOptions, "Program.cs", cancellationToken: TestContext.Current.CancellationToken); + + var compilationOptions = new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable); + List references = + [ +#if NET11_0_OR_GREATER + .. Basic.Reference.Assemblies.Net110.References.All, +#elif NET10_0_OR_GREATER + .. Basic.Reference.Assemblies.Net100.References.All, +#endif + MetadataReference.CreateFromFile(typeof(Composer).Assembly.Location), + ]; + var originalCompilation = CSharpCompilation.Create("IncrementalCachingTestsAssembly", [originalTree], references, compilationOptions); + + var generator = new ComponoIncrementalGenerator().AsSourceGenerator(); + var driverOptions = new GeneratorDriverOptions(disabledOutputs: default, trackIncrementalGeneratorSteps: true); + var driver = ((GeneratorDriver)CSharpGeneratorDriver.Create([generator], driverOptions: driverOptions)) + .RunGenerators(originalCompilation, TestContext.Current.CancellationToken); + + var editedTree = originalTree.WithChangedText(Microsoft.CodeAnalysis.Text.SourceText.From(edited)); + var editedCompilation = originalCompilation.ReplaceSyntaxTree(originalTree, editedTree); + + var secondRunDriver = driver.RunGenerators(editedCompilation, TestContext.Current.CancellationToken); + var secondResult = secondRunDriver.GetRunResult(); + + var steps = secondResult.Results.Single().TrackedSteps[TrackingNames.ComposableTypes]; + + steps.Should().NotBeEmpty("the [Composable]-attributed type should still produce a tracked step on the second run"); + + // Observed, not assumed: appending an unrelated type changes the syntax tree object, so this + // stage's transform does re-run (IncrementalStepRunReason.New/.Modified would mean the + // driver treated it as genuinely new/changed input) - but it recomputes the *same* record + // value for TestNamespace.Customer, which Roslyn reports as .Unchanged, not .Cached (.Cached + // is reserved for a node whose transform didn't even need to re-run at all, e.g. an untouched + // file in a multi-file compilation). Both .Unchanged and .Cached are the two "no downstream + // recomputation needed" reasons ADR-0005's "cache-hit behavior" comment means - asserting + // only .Cached here would fail for the wrong reason and not actually prove the invariant. + steps.SelectMany(step => step.Outputs).Should().OnlyContain( + output => output.Reason == IncrementalStepRunReason.Cached || output.Reason == IncrementalStepRunReason.Unchanged, + "an edit with no effect on the [Composable]-attributed type's own syntax should let this stage's result compare equal " + + "across runs (Cached or Unchanged), not be recomputed as genuinely new or modified - proving real incremental " + + "cache-hit behavior, not merely that the generator ran twice"); + } +} diff --git a/test/Compono.MSTest.Tests/ComposeAttributeConfigBindingTests.cs b/test/Compono.MSTest.Tests/ComposeAttributeConfigBindingTests.cs index 119d42ba..169529c1 100644 --- a/test/Compono.MSTest.Tests/ComposeAttributeConfigBindingTests.cs +++ b/test/Compono.MSTest.Tests/ComposeAttributeConfigBindingTests.cs @@ -42,6 +42,39 @@ public void GetData_Throws_ForANegativeSeed_BeforeAttemptingConfigBinding() StringAssert.Contains(exception.Message, "non-negative"); } + [TestMethod] + public void GetData_AppendsTheConfiguredSeed_WhenAFixedProfileFailsBeforeARowExists_ForComposeAttributeTProfile() + { + // ComposeAttribute{TProfile}.ApplyProfile had no negative-seed guard or exception wrapping + // at all until PLAN-0061 Phase 1 - a copy-paste gap from before this convention was + // introduced for ComposeAttribute{TProfile, TConfig}. Mirrors that class's own precedence + // test above, and Compono.TUnit.Tests' identical, already-correct coverage for this + // one-generic-argument form. + var attribute = new ComposeAttribute { Seed = 492173 }; + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.ComposesWithParameterizedProfile))!; + + var exception = Assert.ThrowsExactly(() => attribute.GetData(method).ToArray()); + + StringAssert.Contains(exception.Message, "custom profile configuration failed"); + StringAssert.Contains(exception.Message, "Seed: 492173"); + } + + [TestMethod] + public void GetData_ReportsTheNegativeSeedDiagnostic_NotTheProfileFailure_ForComposeAttributeTProfile_WhenBothApply() + { + // Seed = -1 combined with a throwing TProfile.Configure must report the documented + // negative-seed diagnostic, not the profile failure with "Seed: -1" embedded - before + // PLAN-0061 Phase 1, this attribute had no guard at all, so CompositionBuilder.WithSeed's own + // unchecked(int->ulong) cast would have silently accepted the negative seed with no exception + // at all, rather than merely reporting the wrong one. + var attribute = new ComposeAttribute { Seed = -1 }; + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.ComposesWithParameterizedProfile))!; + + var exception = Assert.ThrowsExactly(() => attribute.GetData(method).ToArray()); + + StringAssert.Contains(exception.Message, "non-negative"); + } + [TestMethod] public void GetData_ComposesEveryTestMethodParameter_RegardlessOfConfigArguments() { diff --git a/test/Compono.MSTest.Tests/Fixtures/SampleTestMethods.cs b/test/Compono.MSTest.Tests/Fixtures/SampleTestMethods.cs index f0667c4d..6a493b7b 100644 --- a/test/Compono.MSTest.Tests/Fixtures/SampleTestMethods.cs +++ b/test/Compono.MSTest.Tests/Fixtures/SampleTestMethods.cs @@ -95,6 +95,16 @@ public sealed class TestProfile : ICompositionProfile public void Configure(CompositionBuilder builder) => builder.Register(() => "from-profile"); } + // ComposeAttribute{TProfile}'s own ApplyProfile failure case - a fixed, default-constructed + // profile whose Configure itself throws, proving that failure is wrapped with the "Seed: {value}" + // convention the same way ComposeAttribute{TProfile,TConfig}'s identical ApplyProfile failure + // already is. Mirrors Compono.TUnit.Tests.Fixtures.SampleTestMethods' identical fixture + // (PLAN-0061 Phase 1). + public sealed class ThrowingConfigureTestProfile : ICompositionProfile + { + public void Configure(CompositionBuilder builder) => throw new CompositionException("custom profile configuration failed"); + } + public sealed record TestConfig(string Value); public sealed class ParameterizedTestProfile : ICompositionProfile diff --git a/test/Compono.NUnit.Tests/ComposeAttributeConfigBindingTests.cs b/test/Compono.NUnit.Tests/ComposeAttributeConfigBindingTests.cs index 6374f260..55fadc8a 100644 --- a/test/Compono.NUnit.Tests/ComposeAttributeConfigBindingTests.cs +++ b/test/Compono.NUnit.Tests/ComposeAttributeConfigBindingTests.cs @@ -44,6 +44,41 @@ public void BuildFrom_Throws_ForANegativeSeed_BeforeAttemptingConfigBinding() Assert.That(exception!.Message, Does.Contain("non-negative")); } + [Test] + public void BuildFrom_AppendsTheConfiguredSeed_WhenAFixedProfileFailsBeforeARowExists_ForComposeAttributeTProfile() + { + // ComposeAttribute{TProfile}.ApplyProfile had no negative-seed guard or exception wrapping + // at all until PLAN-0061 Phase 1 - a copy-paste gap from before this convention was + // introduced for ComposeAttribute{TProfile, TConfig}. Mirrors that class's own precedence + // test above, and Compono.TUnit.Tests' identical, already-correct coverage for this + // one-generic-argument form. + var attribute = new ComposeAttribute { Seed = 492173 }; + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.ComposesWithParameterizedProfile))!; + + var exception = Assert.Throws(() => + attribute.BuildFrom(MethodInfoWrapper.Wrap(method), null).ToArray()); + + Assert.That(exception!.Message, Does.Contain("custom profile configuration failed")); + Assert.That(exception.Message, Does.Contain("Seed: 492173")); + } + + [Test] + public void BuildFrom_ReportsTheNegativeSeedDiagnostic_NotTheProfileFailure_ForComposeAttributeTProfile_WhenBothApply() + { + // Seed = -1 combined with a throwing TProfile.Configure must report the documented + // negative-seed diagnostic, not the profile failure with "Seed: -1" embedded - before + // PLAN-0061 Phase 1, this attribute had no guard at all, so CompositionBuilder.WithSeed's own + // unchecked(int->ulong) cast would have silently accepted the negative seed with no exception + // at all, rather than merely reporting the wrong one. + var attribute = new ComposeAttribute { Seed = -1 }; + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.ComposesWithParameterizedProfile))!; + + var exception = Assert.Throws(() => + attribute.BuildFrom(MethodInfoWrapper.Wrap(method), null).ToArray()); + + Assert.That(exception!.Message, Does.Contain("non-negative")); + } + [Test] public void BuildFrom_ComposesEveryTestMethodParameter_RegardlessOfConfigArguments() { diff --git a/test/Compono.NUnit.Tests/Fixtures/SampleTestMethods.cs b/test/Compono.NUnit.Tests/Fixtures/SampleTestMethods.cs index 7695656b..b9650a82 100644 --- a/test/Compono.NUnit.Tests/Fixtures/SampleTestMethods.cs +++ b/test/Compono.NUnit.Tests/Fixtures/SampleTestMethods.cs @@ -95,6 +95,16 @@ public sealed class TestProfile : ICompositionProfile public void Configure(CompositionBuilder builder) => builder.Register(() => "from-profile"); } + // ComposeAttribute{TProfile}'s own ApplyProfile failure case - a fixed, default-constructed + // profile whose Configure itself throws, proving that failure is wrapped with the "Seed: {value}" + // convention the same way ComposeAttribute{TProfile,TConfig}'s identical ApplyProfile failure + // already is. Mirrors Compono.TUnit.Tests.Fixtures.SampleTestMethods' identical fixture + // (PLAN-0061 Phase 1). + public sealed class ThrowingConfigureTestProfile : ICompositionProfile + { + public void Configure(CompositionBuilder builder) => throw new CompositionException("custom profile configuration failed"); + } + public sealed record TestConfig(string Value); public sealed class ParameterizedTestProfile : ICompositionProfile diff --git a/test/Compono.XunitV3.SampleTests/Compono.XunitV3.SampleTests.csproj b/test/Compono.XunitV3.SampleTests/Compono.XunitV3.SampleTests.csproj index 4eb0ef44..6f5b2cc0 100644 --- a/test/Compono.XunitV3.SampleTests/Compono.XunitV3.SampleTests.csproj +++ b/test/Compono.XunitV3.SampleTests/Compono.XunitV3.SampleTests.csproj @@ -18,30 +18,31 @@ Strategy. --> $(MSBuildThisFileDirectory)../../.local-nuget-feed + reasonable but never actually worked, since two concurrent invocations of the same TFM + still resolved the identical value, isolating nothing (still raced, just on a different + shared path than before). $([System.Guid]::NewGuid()) computed directly as an MSBuild + property (rather than in the environment) was tried before that and was unstable for an + unrelated reason: NuGet's restore-graph evaluation pass and the actual build evaluation + pass each re-evaluate that property function independently, producing two different GUIDs + for the same nominal build (a real NU1102 version mismatch). An environment variable has + none of these problems: set once by whatever process needs isolation, inherited stably by + its own process tree - every internal MSBuild re-evaluation reads the identical value. + Falls back to a fixed, non-random literal (not another live function call) for a developer + running this project directly, where there is no concurrent sibling to isolate from in the + first place. --> manual $(MSBuildThisFileDirectory)obj/.nuget-packages/$(Compono_LocalPackagesId)/ @@ -68,10 +69,12 @@ project's own restore/build/run rather than being masked by a stale package. Delegates to pack-to-local-feed.sh, which serializes the two `dotnet pack` calls behind a - cross-process lock - CI (and RealRunnerTests) run this project's net10.0/net11.0 test hosts - concurrently, each independently triggering this target via its own nested `dotnet test`, and - two unsynchronized `dotnet pack` invocations racing on the same shared .local-nuget-feed/ and - src/Compono*/bin/obj output corrupts both (caught in CI - see PLAN-0004 Phase 3 Notes). --> + cross-process lock - this project restores across four TFMs (net8.0/net9.0/net10.0/net11.0, + inherited from test/Directory.Build.props) from one `dotnet test` invocation, each TFM's + restore independently triggering this target, and two unsynchronized `dotnet pack` + invocations racing on the same shared .local-nuget-feed/ and src/Compono*/bin/obj output + corrupts both (caught in CI - see PLAN-0004 Phase 3 Notes; originally also exercised by + `RealRunnerTests.cs`, a now-removed test that added its own concurrent nested invocations). --> diff --git a/test/Compono.XunitV3.SampleTests/FailingCompositionTests.cs b/test/Compono.XunitV3.SampleTests/FailingCompositionTests.cs index 3b3edca8..bbd0d87f 100644 --- a/test/Compono.XunitV3.SampleTests/FailingCompositionTests.cs +++ b/test/Compono.XunitV3.SampleTests/FailingCompositionTests.cs @@ -3,11 +3,14 @@ namespace Compono.XunitV3.SampleTests; // Deliberately fails, on every run, via a genuine (pipeline-propagated) composition failure - not one // of Compono.XunitV3's own pre-composition validation failures (a negative seed, a signature error, an // inline-value mismatch), which don't exercise real composition at all (PR #26 review). Uses an -// explicit seed so Compono.XunitV3.Tests' RealRunnerTests can assert on a deterministic value: it -// shells out `dotnet test` against this project and asserts the captured output contains this exact -// seed, proving the milestone's "a composition failure's message contains a seed that reproduces the -// same failure" promise reaches a real xUnit v3 runner's actual output, not just an in-process GetData -// call. +// explicit seed so its output can be checked for a deterministic value, proving the milestone's "a +// composition failure's message contains a seed that reproduces the same failure" promise reaches a +// real xUnit v3 runner's actual output, not just an in-process GetData call. This project's CI +// "Local-feed packed-consumer smoke test" step (.github/workflows/package-validation.yaml) filters +// out every class whose name starts with "Failing" for exactly this reason - running this project +// with a bare `dotnet test` (no `--filter-not-class "Compono.XunitV3.SampleTests.Failing*"`) will +// report this class (and FailingConfigProfileTests) as failing; that is expected, not a broken +// project. See this project's own README.md for the full CI invocation. public sealed class FailingCompositionTests { public const int Seed = 24601; diff --git a/test/Compono.XunitV3.SampleTests/README.md b/test/Compono.XunitV3.SampleTests/README.md new file mode 100644 index 00000000..3f3c440b --- /dev/null +++ b/test/Compono.XunitV3.SampleTests/README.md @@ -0,0 +1,40 @@ +# Compono.XunitV3.SampleTests + +**This is a packaged-consumer/real-runner validation fixture, not a +user-facing sample.** Despite the `SampleTests` name, this project's job is +to prove `Compono`/`Compono.XunitV3`/`Compono.NSubstitute`/`Compono.Bogus` +work correctly when consumed exactly as an external package consumer would — +via `PackageReference` against a freshly-packed local feed (see this +project's own `PackToLocalFeed` MSBuild target), never a `ProjectReference`. +If you're looking for a user-facing example of how to use Compono, see +`samples/` at the repository root instead (ADR-0033). + +## Running this project directly + +Two of this project's test classes — `FailingCompositionTests` and +`FailingConfigProfileTests` — **fail by design**, on every run. They exist +to prove a real composition/binder failure's message (including its +reproducing seed) actually reaches a real xUnit v3 runner's output, through +the real packaged pipeline — not just an in-process `GetData()` call +(ADR-0022's Testing Strategy). + +A bare `dotnet test test/Compono.XunitV3.SampleTests/Compono.XunitV3.SampleTests.csproj` +will therefore report those two classes as failing. **That is expected, not +a broken project.** The correct invocation — the one CI actually runs +(`.github/workflows/package-validation.yaml`'s "Local-feed packed-consumer +smoke test" step) — excludes them: + +```bash +dotnet test test/Compono.XunitV3.SampleTests/Compono.XunitV3.SampleTests.csproj \ + -c Release \ + -- --filter-not-class "Compono.XunitV3.SampleTests.Failing*" +``` + +The wildcard is trailing-only (`Failing*`, not `Failing*Tests`) — the +Microsoft Testing Platform CLI rejects a wildcard placed in the middle of a +filter expression. + +This project is intentionally excluded from `Compono.slnx` for the same +reason (a solution-wide `dotnet test` would hit the same two +deliberately-failing classes) — see +`docs/plans/0004-milestone-4-xunit-integration.md`. diff --git a/test/Compono.XunitV3.Tests/ComposeAttributeConfigBindingTests.cs b/test/Compono.XunitV3.Tests/ComposeAttributeConfigBindingTests.cs index de839b68..1235fd41 100644 --- a/test/Compono.XunitV3.Tests/ComposeAttributeConfigBindingTests.cs +++ b/test/Compono.XunitV3.Tests/ComposeAttributeConfigBindingTests.cs @@ -166,6 +166,45 @@ await act.Should().ThrowAsync() .WithMessage("*\nSeed: *"); } + [Fact] + public async Task GetData_AppendsTheConfiguredSeed_WhenAFixedProfileFailsBeforeARowExists_ForComposeAttributeTProfile() + { + // ComposeAttribute{TProfile}.ApplyProfile had no negative-seed guard or exception wrapping + // at all until PLAN-0061 Phase 1 - a copy-paste gap from before PR #65 introduced this + // convention for ComposeAttribute{TProfile, TConfig}. Mirrors that class's own + // GetData_AppendsTheConfiguredSeed_WhenProfileConstructionFailsBeforeARowExists test, and + // Compono.TUnit.Tests' identical, already-correct coverage for this one-generic-argument form. + var attribute = new ComposeAttribute { Seed = 492173 }; + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + var tracker = new DisposalTracker(); + + var act = () => attribute.GetData(method, tracker).AsTask(); + + await act.Should().ThrowAsync() + .WithMessage("*custom profile configuration failed*Seed: 492173*"); + } + + [Fact] + public async Task GetData_ReportsTheNegativeSeedDiagnostic_NotTheProfileFailure_ForComposeAttributeTProfile_WhenBothApply() + { + // Seed = -1 combined with a throwing TProfile.Configure must report the documented + // negative-seed diagnostic, not the profile failure with "Seed: -1" embedded - before + // PLAN-0061 Phase 1, this attribute had no guard at all, so CompositionBuilder.WithSeed's own + // unchecked(int->ulong) cast would have silently accepted the negative seed with no exception, + // rather than merely reporting the wrong exception. Mirrors + // GetData_ReportsTheNegativeSeedDiagnostic_NotTheBinderFailure_WhenBothApply below (the + // ComposeAttribute{TProfile, TConfig} form) and Compono.TUnit.Tests' identical, already-correct + // coverage for this one-generic-argument form. + var attributeWithThrowingProfile = new ComposeAttribute { Seed = -1 }; + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + var tracker = new DisposalTracker(); + + var act = () => attributeWithThrowingProfile.GetData(method, tracker).AsTask(); + + await act.Should().ThrowAsync() + .WithMessage("*non-negative seed*-1*"); + } + [Fact] public async Task GetData_ReportsTheNegativeSeedDiagnostic_NotTheBinderFailure_WhenBothApply() { diff --git a/test/Compono.XunitV3.Tests/Fixtures/SampleTestMethods.cs b/test/Compono.XunitV3.Tests/Fixtures/SampleTestMethods.cs index aea71d4a..9a26b975 100644 --- a/test/Compono.XunitV3.Tests/Fixtures/SampleTestMethods.cs +++ b/test/Compono.XunitV3.Tests/Fixtures/SampleTestMethods.cs @@ -137,6 +137,16 @@ public sealed class TestProfile : ICompositionProfile public void Configure(CompositionBuilder builder) => builder.Register(() => "from-profile"); } + // ComposeAttribute{TProfile}'s own ApplyProfile failure case - a fixed, default-constructed + // profile whose Configure itself throws, proving that failure is wrapped with the "Seed: {value}" + // convention the same way ComposeAttribute{TProfile,TConfig}'s identical ApplyProfile failure + // already is. Mirrors Compono.TUnit.Tests.Fixtures.SampleTestMethods' identical fixture + // (PLAN-0061 Phase 1). + public sealed class ThrowingConfigureTestProfile : ICompositionProfile + { + public void Configure(CompositionBuilder builder) => throw new CompositionException("custom profile configuration failed"); + } + // ComposeAttribute{TProfile,TConfig} fixtures - a config record with exactly one public // constructor (the supported shape), a profile with exactly one public constructor accepting // exactly that config type, and one broken variant per ConfigProfileBinder failure mode. From 1c3cbc5e51f1c381604f2389cbcb54bddeeb50b3 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Thu, 3 Sep 2026 13:00:39 -0400 Subject: [PATCH 2/6] fix(ci): install every pinned SDK major version in aot-validation.yaml global.json pins a preview 11.0.x SDK for the whole repo checkout - dotnet CLI resolution fails entirely (not just for net11.0-specific work) if that SDK isn't installed, exactly as pr-build.yaml/package-validation.yaml already install all four majors for the same reason. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GMQEjFrUVGYkctE3ECfRuA --- .github/workflows/aot-validation.yaml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/aot-validation.yaml b/.github/workflows/aot-validation.yaml index 6ee01c21..6221ded8 100644 --- a/.github/workflows/aot-validation.yaml +++ b/.github/workflows/aot-validation.yaml @@ -103,7 +103,16 @@ jobs: - uses: actions/setup-dotnet@v6 with: - dotnet-version: 10.0.x + # global.json pins a preview 11.0.x SDK for this repo; setup-dotnet must install every SDK + # major version pr-build.yaml/package-validation.yaml already do, even though every + # AotSmokeTest project itself only targets net10.0 - otherwise `dotnet` CLI resolution + # fails for the *whole checkout* the moment global.json's pinned SDK isn't present, not + # just for a net11.0-specific build. + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + 11.0.x - name: Pack ${{ matrix.leg }} into its local AOT smoke feed run: bash "test/${{ matrix.leg }}.AotSmokeTest/pack-compono.sh" From 3a8331cd32dce41c06b27d7c90a2b9a01f8ad733 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Thu, 3 Sep 2026 13:05:19 -0400 Subject: [PATCH 3/6] test(ci): deliberately break Compono.MSTest AOT leg to prove aot-gate fails Temporary - PLAN-0061 Phase 1 requires proving the required aot-gate check actually fails an applicable leg's failure before merging it as a real gate. Reverted in the next commit. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GMQEjFrUVGYkctE3ECfRuA --- test/Compono.MSTest.AotSmokeTest/Program.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/Compono.MSTest.AotSmokeTest/Program.cs b/test/Compono.MSTest.AotSmokeTest/Program.cs index f0197e6f..d6c9489b 100644 --- a/test/Compono.MSTest.AotSmokeTest/Program.cs +++ b/test/Compono.MSTest.AotSmokeTest/Program.cs @@ -58,6 +58,11 @@ private static int Main() { try { + // DELIBERATE-FAILURE-PROOF: PLAN-0061 Phase 1 requires proving aot-gate actually fails + // when an applicable leg fails, before merging it as a real required check. Temporary - + // reverted before this PR merges. + throw new InvalidOperationException("PLAN-0061 deliberate AOT gate failure proof - expected to fail CI."); + RunRow( typeof(SmokeTestMethods).GetMethod(nameof(SmokeTestMethods.Handle))!, new ComposeAttribute(), From be1ab00119a77959fe7896a0529a9f60f0a33ffd Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Thu, 3 Sep 2026 13:15:34 -0400 Subject: [PATCH 4/6] Revert "test(ci): deliberately break Compono.MSTest AOT leg to prove aot-gate fails" This reverts commit 3a8331cd32dce41c06b27d7c90a2b9a01f8ad733. --- test/Compono.MSTest.AotSmokeTest/Program.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/Compono.MSTest.AotSmokeTest/Program.cs b/test/Compono.MSTest.AotSmokeTest/Program.cs index d6c9489b..f0197e6f 100644 --- a/test/Compono.MSTest.AotSmokeTest/Program.cs +++ b/test/Compono.MSTest.AotSmokeTest/Program.cs @@ -58,11 +58,6 @@ private static int Main() { try { - // DELIBERATE-FAILURE-PROOF: PLAN-0061 Phase 1 requires proving aot-gate actually fails - // when an applicable leg fails, before merging it as a real required check. Temporary - - // reverted before this PR merges. - throw new InvalidOperationException("PLAN-0061 deliberate AOT gate failure proof - expected to fail CI."); - RunRow( typeof(SmokeTestMethods).GetMethod(nameof(SmokeTestMethods.Handle))!, new ComposeAttribute(), From a6863e6ab5a2d0b65c0900de619fa8d2bd654e68 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Thu, 3 Sep 2026 13:20:43 -0400 Subject: [PATCH 5/6] docs(plan): mark PLAN-0061 Phase 1 Done, record CI validation results Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GMQEjFrUVGYkctE3ECfRuA --- .../0061-pre-1-0-cleanup-and-consolidation.md | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/docs/plans/0061-pre-1-0-cleanup-and-consolidation.md b/docs/plans/0061-pre-1-0-cleanup-and-consolidation.md index 65ee2eb0..f85c642d 100644 --- a/docs/plans/0061-pre-1-0-cleanup-and-consolidation.md +++ b/docs/plans/0061-pre-1-0-cleanup-and-consolidation.md @@ -165,7 +165,7 @@ promised invariant, rather than correcting the comment. ### Phase 1 — Product correctness and repository quality gate -**Status:** In Progress +**Status:** Done Ships as its own PR. @@ -293,7 +293,7 @@ Ships as its own PR. public-API coverage beyond what each smoke consumer actually exercises, and explicitly not claiming the test framework's own runner/host is Native-AOT compatible. -- [ ] Full `dotnet build`/`dotnet test Compono.slnx`, `package-validation.yaml`, +- [x] Full `dotnet build`/`dotnet test Compono.slnx`, `package-validation.yaml`, and the new `aot-validation.yaml` all green. ### Phase 2 — Canonical samples @@ -382,3 +382,38 @@ Ships as its own PR, after Phase 1 merges. The framework-binder duplication research (RESEARCH-0019) is intentionally absent from both phases' task lists — it runs independently and does not gate either PR. + +**Phase 1 validation (2026-09-03), PR #128:** + +- `dotnet build Compono.slnx -c Release` — 0 errors. +- `dotnet test Compono.slnx -c Release` — 3478/3478 passed locally; CI's own + `build`/`build / build` checks green (one transient exit-143 flake on the + `PR Build` job, unrelated to this PR's diff — `test/Compono.MSTest.AotSmokeTest` + isn't part of `Compono.slnx` at all, and the identical flake pattern + independently appears on unrelated branches in this repo's own recent CI + history; resolved by re-running the job, no code change). +- `package-validation.yaml` green — all 11 packages pack, CS1591-clean, + nupkg contents inspected, all 5 `*SampleTests` projects pass, NUnit + compatibility matrix passes. +- `aot-validation.yaml` (new): `changes` job correctly detected this PR's + core/generator-touching diff and ran all eight legs; all eight passed, + including the two outlier legs' extra steps + (`Compono.Logging.AotSmokeTest/verify-packaging.sh`, + `Compono.Http.AotSmokeTest/AnalyzerContract/verify-analyzer-contract.sh`); + `aot-gate` resolved to `pass`. +- **Deliberate-failure proof performed and reverted**: a temporary commit + made `Compono.MSTest`'s AOT leg throw unconditionally. Result: that one + leg failed, the other seven passed independently (`fail-fast: false` + confirmed working), and `aot-gate` correctly resolved to `fail` — proving + the required check actually blocks on a real failure, not just reports + green reflexively. The breaking commit was reverted in the next commit + before this phase was considered done; the final push is all-green again. +- Fixed one real bug found during this validation, not scoped by the + original plan: `aot-validation.yaml`'s `setup-dotnet` step only installed + the `10.0.x` SDK, but this repo's `global.json` pins a preview `11.0.x` + SDK for the *whole checkout* — `dotnet` CLI resolution failed entirely + (not just for `net11.0`-specific work) until the workflow installed all + four SDK majors, matching `pr-build.yaml`/`package-validation.yaml`'s own + established convention. +- PR title required a lowercase-subject fix (`amannn/action-semantic-pull-request`'s + enforced convention) — mechanical, no scope impact. From 83e5dc43fd86d8b461e24d67a971447832b77fe9 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Thu, 3 Sep 2026 13:32:07 -0400 Subject: [PATCH 6/6] fix(ci): fail aot-gate closed when applicability computation itself fails Addresses Codex P1 finding on PR #128: aot-gate only inspected needs.aot-smoke.result, so a failed/cancelled `changes` job (checkout, git diff, or the applicability script erroring) produced the same `aot-smoke == skipped` conclusion as a legitimate zero-applicable-legs run, letting the required check report green without applicability ever being computed. aot-gate now checks needs.changes.result first, before anything else, and additionally rejects a skip whose legs output isn't literally "[]" - closing the gap without adding a job or changing the valid success/no-op paths. Validated all 6 required states locally. Also addresses Codex P2 finding: docs/plans/README.md's PLAN-0061 index row synced to the plan's current status (In Progress, not Codex's originally-correct-at-the-time Not Started suggestion, since Phase 1 completed since that review). Housekeeping: release-drafter.yml's comment corrected to not claim Compono has already left 0.x (it hasn't shipped 1.0 yet - the policy change only takes effect on the next breaking-change-labeled merge); resolver behavior itself unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GMQEjFrUVGYkctE3ECfRuA --- .github/release-drafter.yml | 11 +++-- .github/workflows/aot-validation.yaml | 46 +++++++++++++++++-- .../adr/0041-aot-safe-row-binding-dispatch.md | 17 +++++-- .../0061-pre-1-0-cleanup-and-consolidation.md | 8 +++- docs/plans/README.md | 2 +- 5 files changed, 67 insertions(+), 17 deletions(-) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 3fa5c37f..05df9efe 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -72,11 +72,14 @@ categories: - 'type: chore' - 'type: ci' - 'type: revert' - # ADR-0031 Amendment 5: Compono has left the 0.x preview line, so a labeled - # breaking-change PR resolves an ordinary SemVer major bump again - the + # ADR-0031 Amendment 5: Compono is ready to leave the 0.x preview line, so a + # labeled breaking-change PR resolves an ordinary SemVer major bump again, + # graduating to 1.0.0 (or beyond, for any later breaking change) whenever + # one next merges - not a statement that 1.0 has already shipped. The # deliberate minor-bump override ADR-0031's original "0.x compatibility - # policy" put in place (PLAN-0008 Phase 0) applied only while staying on - # 0.x, and no longer applies. + # policy" put in place (PLAN-0008 Phase 0) applied only while intentionally + # staying on 0.x, and no longer applies now that that decision has been + # made. - type: 'version-resolver' semver-increment: 'major' when: diff --git a/.github/workflows/aot-validation.yaml b/.github/workflows/aot-validation.yaml index 6221ded8..3d283db5 100644 --- a/.github/workflows/aot-validation.yaml +++ b/.github/workflows/aot-validation.yaml @@ -142,11 +142,47 @@ jobs: if: always() runs-on: ubuntu-latest steps: - - name: Confirm every applicable AOT smoke leg passed + # Fail closed, not just on an applicable leg's own failure: this job runs via `if: always()` + # specifically so it never sits Pending, but that same `always()` means it also runs when + # `changes` itself failed/was cancelled (checkout, `git diff`, or the applicability script + # erroring) - `aot-smoke` reports `skipped` in that case too (its own `if:` implicitly + # requires `needs.changes` to have succeeded), which the original version of this check could + # not tell apart from the legitimate "changes succeeded, zero legs applicable" skip. Both + # produce `aot-smoke.result == skipped`; only `changes.result` distinguishes them. Checked + # first, before anything else, so every other branch below can assume applicability was + # actually computed. + - name: Confirm applicability was computed and every applicable leg passed run: | - result="${{ needs.aot-smoke.result }}" - if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then - echo "::error::One or more applicable AOT smoke legs failed - see the aot-smoke job above." >&2 + changes_result="${{ needs.changes.result }}" + smoke_result="${{ needs.aot-smoke.result }}" + legs='${{ needs.changes.outputs.legs }}' + + if [ "$changes_result" != "success" ]; then + echo "::error::Applicability computation ('changes' job) did not succeed (result: $changes_result) - failing closed rather than trusting an unproven leg set." >&2 + exit 1 + fi + + if [ "$smoke_result" = "failure" ] || [ "$smoke_result" = "cancelled" ]; then + echo "::error::One or more applicable AOT smoke legs failed or were cancelled - see the aot-smoke job above." >&2 exit 1 fi - echo "AOT validation gate passed. Applicable legs: ${{ needs.changes.outputs.legs }}" + + if [ "$smoke_result" = "skipped" ]; then + # A legitimate skip only ever means "changes succeeded and found zero applicable legs" - + # aot-smoke's own `if: needs.changes.outputs.legs != '[]'` is the only thing that skips + # it when changes succeeded. Confirm that's really why, rather than trusting the skip + # blindly now that changes_result == success is already known. + if [ "$legs" != "[]" ]; then + echo "::error::aot-smoke was skipped but changes reported applicable legs ($legs) - this should never happen; failing closed." >&2 + exit 1 + fi + echo "No applicable AOT smoke legs for this change - gate passes." + exit 0 + fi + + if [ "$smoke_result" != "success" ]; then + echo "::error::Unexpected aot-smoke result: $smoke_result - failing closed." >&2 + exit 1 + fi + + echo "AOT validation gate passed. Applicable legs: $legs" diff --git a/docs/adr/0041-aot-safe-row-binding-dispatch.md b/docs/adr/0041-aot-safe-row-binding-dispatch.md index 21414423..3c8beea7 100644 --- a/docs/adr/0041-aot-safe-row-binding-dispatch.md +++ b/docs/adr/0041-aot-safe-row-binding-dispatch.md @@ -632,11 +632,18 @@ inside the workflow: normal skipped conclusion, not a missing/pending status. 5. A final, always-`if: always()`-run aggregation job depends on all eight leg jobs and is the one job the branch protection/ruleset required-check - configuration actually names: it fails if any *applicable* leg job failed, - and succeeds if every applicable leg passed or if no leg was applicable at - all (nothing AOT-relevant changed). This is the job whose result GitHub - reports for the required check, so the check always resolves to - success/failure, never stays `Pending`. + configuration actually names: it fails if any *applicable* leg job failed + or was cancelled, and succeeds if every applicable leg passed or if no leg + was applicable at all (nothing AOT-relevant changed). Failing closed + applies to the applicability computation itself, not only to a leg's own + result — if the first job (computing which legs apply) doesn't succeed + (fails, is cancelled, or errors), every leg job reports the same `skipped` + conclusion a legitimate zero-applicable-legs run would, but the + aggregation job checks the applicability job's own result *before* + looking at the legs' result, so an unproven leg set can never be + mistaken for "correctly found nothing to run." This is the job whose + result GitHub reports for the required check, so the check always + resolves to success/failure, never stays `Pending`. This repository does not use GitHub merge queues today (no workflow declares a `merge_group` trigger) — this design does not add one speculatively; if a diff --git a/docs/plans/0061-pre-1-0-cleanup-and-consolidation.md b/docs/plans/0061-pre-1-0-cleanup-and-consolidation.md index f85c642d..e324fc28 100644 --- a/docs/plans/0061-pre-1-0-cleanup-and-consolidation.md +++ b/docs/plans/0061-pre-1-0-cleanup-and-consolidation.md @@ -279,8 +279,12 @@ Ships as its own PR. conclusion, never a missing status. - [x] A final `if: always()` aggregation job depends on all eight leg jobs and is the one job named in branch protection/ruleset as the required - check — fails if any applicable leg failed, succeeds if every - applicable leg passed or none was applicable. + check — fails if any applicable leg failed or was cancelled, succeeds + if every applicable leg passed or none was applicable, **and fails + closed if the applicability computation itself didn't succeed** + (checked first, before the legs' own result, so a `changes` failure/ + cancellation can never be mistaken for "correctly found nothing to + run" — PR #128 review finding, fixed post-merge-of-Phase-1-review). - [x] No `merge_group` trigger is added — this repository does not use GitHub merge queues today; revisit only if that changes. - [x] The workflow's job/step names and any package-guide text referencing diff --git a/docs/plans/README.md b/docs/plans/README.md index 444b6145..bb041fe3 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -75,4 +75,4 @@ one. This file is just the mechanics: numbering, status, and the index. | [0058](0058-testdoubles-invocation-aware-callback-responses.md) | Compono.TestDoubles: Invocation-Aware Callback Responses | In Progress | | [0059](0059-compono-nunit-package-design-impl-plan.md) | Compono.NUnit Package Design | Done | | [0060](0060-public-generator-facing-runtime-infrastructure.md) | Public Generator-Facing Runtime Infrastructure | Done | -| [0061](0061-pre-1-0-cleanup-and-consolidation.md) | Pre-1.0 Cleanup and Consolidation Gate | Not Started | +| [0061](0061-pre-1-0-cleanup-and-consolidation.md) | Pre-1.0 Cleanup and Consolidation Gate | In Progress |