From ba3a744873e6f7d0d0082259442e2a97342338ed Mon Sep 17 00:00:00 2001 From: Keith Voels Date: Mon, 6 Jul 2026 13:11:36 -0500 Subject: [PATCH 1/4] =?UTF-8?q?docs(todo):=20TRIM=20recon=20=E2=80=94=20co?= =?UTF-8?q?nfirm=20generator=20gaps,=20re-split=20adding=20TRIM-004=20harn?= =?UTF-8?q?ess=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-repo reconnaissance confirmed both preservation gaps from the outside- perspective split and surfaced three new findings: the TrimmingTests harness is unenforced (outside sln/CI, exits 0 on failure), EventRelaySmokeTest cannot pin the subscribe-only event shape, and Design.Domain event-pattern comments still document the removed PreserveType emission. Co-Authored-By: Claude Fable 5 --- ...ositional-record-signature-preservation.md | 11 ++++ ...2-factory-entity-property-dto-discovery.md | 11 ++++ .../003-verify-event-record-preservation.md | 11 ++++ .../plans/004-trimming-harness-ci-gate.md | 11 ++++ .../todo.md | 62 +++++++++++++++++++ 5 files changed, 106 insertions(+) create mode 100644 docs/todos/TRIM-dto-trimming-preservation-gaps/plans/001-positional-record-signature-preservation.md create mode 100644 docs/todos/TRIM-dto-trimming-preservation-gaps/plans/002-factory-entity-property-dto-discovery.md create mode 100644 docs/todos/TRIM-dto-trimming-preservation-gaps/plans/003-verify-event-record-preservation.md create mode 100644 docs/todos/TRIM-dto-trimming-preservation-gaps/plans/004-trimming-harness-ci-gate.md create mode 100644 docs/todos/TRIM-dto-trimming-preservation-gaps/todo.md diff --git a/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/001-positional-record-signature-preservation.md b/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/001-positional-record-signature-preservation.md new file mode 100644 index 00000000..8e061601 --- /dev/null +++ b/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/001-positional-record-signature-preservation.md @@ -0,0 +1,11 @@ +# TRIM-001 — Positional-record preservation in factory signatures + +**Plan #:** 001 +**Status:** Draft +**Plan-review opt-in:** TBD at draft +**Code-review opt-in:** TBD at draft +**Related Todo:** [../todo.md](../todo.md) + +## Scope + +Make the factory-signature DTO walk preserve positional records (types with only parameterized ctors) instead of silently dropping them. Today `DtoTypeWalker.WalkFactoryReturn` requires `HasParameterlessCtor`, so a record like zTreatment's `StartVisitResultV2` — returned from a `[Remote, Execute]` command — gets no preservation and the trimmed client throws `DeserializeNoConstructor`. The fix shape already exists in the codebase's own history: bucket-sort discovered types the way the (now-dead) `WalkEventRoot` did — parameterless ctor → `DtoConstructorRegistry.Register(() => new T())`, parameterized/record → `DtoConstructorRegistry.PreserveType()` (deserialization then flows through the existing `RecordBypassConverterFactory`). Applies uniformly to return types, non-service parameters, and nested properties of discovered DTOs. Includes disposing of the dead `WalkEventRoot` helper (delete or refit as the shared bucket-sort walk), a publish-trimmed test in `RemoteFactory.TrimmingTests` covering record-as-return, record-as-parameter, and record-nested-in-DTO, and the `docs/trimming.md` "What Qualifies as a DTO" correction. Does NOT touch `[Factory]` entity property descent (TRIM-002) or event preservation (TRIM-003). diff --git a/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/002-factory-entity-property-dto-discovery.md b/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/002-factory-entity-property-dto-discovery.md new file mode 100644 index 00000000..d9f0f74d --- /dev/null +++ b/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/002-factory-entity-property-dto-discovery.md @@ -0,0 +1,11 @@ +# TRIM-002 — `[Factory]` entity property-graph DTO discovery + +**Plan #:** 002 +**Status:** Draft +**Plan-review opt-in:** TBD at draft +**Code-review opt-in:** TBD at draft +**Related Todo:** [../todo.md](../todo.md) + +## Scope + +Extend DTO discovery to descend into `[Factory]`-annotated types' public property graphs without treating the entity itself as a DTO. Today `WalkFactoryReturn` rejects a `[Factory]` root (correct — entities are preserved via DI registration) but returns before walking its properties, so a plain DTO reachable *only* as an entity property is never discovered and gets trimmed on the client. Consumer evidence from the zTreatment cut-over: `TreatmentBanner` (a record property on the `[Execute]`-opened `TreatmentContext` aggregate) and `DashboardContactResult` (a `List` property on the `PatientSearchQuery` factory entity) both required manual LinkerConfig entries. The descent must reuse the same bucket-sort emission as TRIM-001 (Register vs PreserveType), share the visited-set for cycle safety across entity graphs (entities referencing entities, child lists), and skip entity-typed properties themselves while walking through them for DTO-typed leaves. This is the most design-open of the three plans — settle the walk's boundary rules (which factory-rooted types get their properties walked: all `[Factory]` types in the compilation, or only those reachable from factory method signatures) at draft time. Includes a publish-trimmed `RemoteFactory.TrimmingTests` case (DTO reachable only via entity property) and a `docs/trimming.md` update. Does NOT change entity preservation itself (already handled by `NeatooFactoryRegistrar` + DI registration). diff --git a/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/003-verify-event-record-preservation.md b/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/003-verify-event-record-preservation.md new file mode 100644 index 00000000..aee96076 --- /dev/null +++ b/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/003-verify-event-record-preservation.md @@ -0,0 +1,11 @@ +# TRIM-003 — Verify event-record preservation needs no consumer entries + +**Plan #:** 003 +**Status:** Draft +**Plan-review opt-in:** TBD at draft +**Code-review opt-in:** TBD at draft +**Related Todo:** [../todo.md](../todo.md) + +## Scope + +Verification plan, expected no-code-change. `FactoryEventBase` has carried inherited `[FactoryEvent]` + `[DynamicallyAccessedMembers(PublicConstructors | PublicProperties)]` since v1.4.0 (`68e7324`), which should make every derived event record trimming-safe with no `[FactoryEventHandler]` and no consumer LinkerConfig entry. But the consuming evidence is ambiguous: zTreatment's LinkerConfig event entries predate v1.4.0, were carried forward during its 1.5.0 migration ("updated to cover fine-grained panel events too"), and were never re-tested against the annotation. Confirm with a publish-trimmed repro matching the consumer's exact shape — event record whose ONLY client-side static reference is a generic `Subscribe(...)` lambda call site in a consumer-implemented `IFactoryEventRelay` aggregator (no handler attribute anywhere), deserialized from `RemoteResponseDto.RelayedEvents` and dispatched by runtime type. If the existing `EventRelaySmokeTest` doesn't already pin this consumer shape, add a `RemoteFactory.TrimmingTests` case for it. Outcome either way is recorded: green → zTreatment PCB-003 deletes its event-record entries on verification alone; red → the gap becomes a new TRIM plan with the repro as its failing test. Does NOT touch generator emission. diff --git a/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/004-trimming-harness-ci-gate.md b/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/004-trimming-harness-ci-gate.md new file mode 100644 index 00000000..0fd7564d --- /dev/null +++ b/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/004-trimming-harness-ci-gate.md @@ -0,0 +1,11 @@ +# TRIM-004 — Trimming harness pass/fail semantics + CI gate + +**Plan #:** 004 +**Status:** Draft +**Plan-review opt-in:** TBD at draft +**Code-review opt-in:** TBD at draft +**Related Todo:** [../todo.md](../todo.md) + +## Scope + +Give the publish-trimmed harness enforceable pass/fail semantics and make CI run it. Today `RemoteFactory.TrimmingTests` sits outside `Neatoo.RemoteFactory.sln`, is never published or executed by `.github/workflows/build.yml`, and its `Program.cs` / smoke tests print `FAILED` lines to the console but always exit 0 — so the trimmed-repro acceptance signals that TRIM-001/002/003 depend on would verify nothing in CI. Convert failure paths to a non-zero process exit (aggregated across all checks so one failure doesn't mask others), add a CI step that publishes the trimmed exe (Release; RID matching the ubuntu runner) and runs it, and settle solution membership at draft time (the csproj is deliberately single-TFM net9.0 with `PublishTrimmed=true`, which the solution-wide multi-targeted build/test may not tolerate as-is). Does NOT add new trimming test cases — those land with TRIM-001/002/003. diff --git a/docs/todos/TRIM-dto-trimming-preservation-gaps/todo.md b/docs/todos/TRIM-dto-trimming-preservation-gaps/todo.md new file mode 100644 index 00000000..10ce9957 --- /dev/null +++ b/docs/todos/TRIM-dto-trimming-preservation-gaps/todo.md @@ -0,0 +1,62 @@ +# TRIM — DTO Trimming Preservation Gaps + +**ID:** TRIM +**Type:** Generator defect / trimming-support completion +**Status:** In Progress +**Priority:** High (blocks a consumer from retiring a ~50-entry LinkerConfig.xml workaround) +**Created:** 2026-07-06 + +--- + +## Goal + +Close the remaining IL-trimming preservation gaps in the source generator's DTO discovery so that a consumer's Blazor WASM client needs **no manual `LinkerConfig.xml` entries** for types that flow through RemoteFactory. Driving evidence: zTreatment's 2026-06-28 production cut-over hit repeated `DeserializeNoConstructor` / stripped-property failures on its trimmed client and accumulated a bulk-preserve block of ~50 `` entries (`zTreatment.BlazorStandAlone/LinkerConfig.xml`) as a whack-a-mole mitigation. The consumer-side retirement of that block is tracked in zTreatment as **PCB-003**; this todo is the framework side. + +The two confirmed gaps (verified by reading the generator at v1.6.1 = current HEAD): + +1. **Positional records in factory method signatures are silently dropped.** `MethodInfo.DiscoverDtoTypes` walks return types AND non-service parameters, but delegates to `DtoTypeWalker.WalkFactoryReturn`, which requires `HasParameterlessCtor` — any record with only a parameterized ctor is skipped with no preservation at all. `DtoConstructorRegistry.PreserveType()` exists for exactly this shape but is emitted nowhere (its only caller, `WalkEventRoot`, is dead code since the v1.4.0 event-relay redesign). Deserialization-side support (`RecordBypassConverterFactory`) is present; preservation-side emission is missing. +2. **DTO-typed properties on `[Factory]` entities are never discovered.** `WalkFactoryReturn` bails on `[Factory]`-annotated roots *without descending into their properties*, so a plain DTO reachable only as an entity property (e.g. a record carried by an `[Execute]`-opened aggregate) gets trimmed on the client. + +A third suspected gap turned out to be already fixed: event records derive `FactoryEventBase`, which has carried inherited `[FactoryEvent]` + `[DynamicallyAccessedMembers(PublicConstructors | PublicProperties)]` since v1.4.0 (commit `68e7324`) — no handler required. The consumer's event-record LinkerConfig entries predate v1.4.0 and were carried forward untested; TRIM-003 verifies this with a trimmed repro rather than assuming it. + +## Acceptance Criteria + +1. A positional-record DTO appearing in a remote factory method signature — as return type, as parameter, or nested as a property of another discovered DTO — deserializes on a publish-trimmed client with no consumer LinkerConfig entry. [TRIM-001] +2. A plain DTO reachable only as a public property of a `[Factory]` entity survives publish-trimming and deserializes on the client with no consumer LinkerConfig entry. [TRIM-002] +3. Verified (not assumed): a `FactoryEventBase`-derived record whose only client-side reference is a subscription-lambda call site deserializes on a publish-trimmed client. [TRIM-003] +4. `docs/trimming.md` ("What Qualifies as a DTO", "DTO Return Type Preservation") updated to match the shipped behavior; release notes per CI/CD standards. +5. Consumer proof: released version consumed by zTreatment (PCB-003) with the LinkerConfig bulk-preserve block deleted and a Release WASM publish verified. (Tracked zTreatment-side; this todo closes on the framework release, not the consumer rollout.) + +## Out of Scope + +- zTreatment's upgrade / LinkerConfig retirement / smoke verification — that is zTreatment PCB-003. +- The `IFactorySaveMeta` visibility-narrowing interaction (documented in `docs/trimming.md`; separate concern). +- DTOs that never flow through RemoteFactory (consumer's own HTTP/JSON paths) — those remain the consumer's responsibility, documented as such. +- The v1.6.1 `[Execute]`-only DI-registration fix — already shipped. + +## Plan Index + +(Stubs carry Scope only; Steps/Acceptance flesh out at each plan's turn, per the iterative-todo workflow.) + +| # | Status | Plan | Source | +|-----|--------|------|--------| +| 004 | Draft | [Trimming harness pass/fail semantics + CI gate](./plans/004-trimming-harness-ci-gate.md) | 2026-07-06 recon: TrimmingTests outside .sln/CI, exits 0 on failure — 001–003's trimmed acceptance signals need this gate first | +| 001 | Draft | [Positional-record preservation in factory signatures](./plans/001-positional-record-signature-preservation.md) | `DtoTypeWalker.WalkFactoryReturn` `HasParameterlessCtor` gate; zTreatment cut-over `StartVisitResultV2` hotfix | +| 002 | Draft | [`[Factory]` entity property-graph DTO discovery](./plans/002-factory-entity-property-dto-discovery.md) | `WalkFactoryReturn` bails on `[Factory]` roots without descending; zTreatment `TreatmentBanner` / `DashboardContactResult` hotfixes | +| 003 | Draft | [Verify event-record preservation needs no consumer entries](./plans/003-verify-event-record-preservation.md) | `FactoryEventBase` DAM annotation shipped v1.4.0; consumer entries predate it, never re-tested | + +Execution order: 004 → 001 → 002 → 003 (rows listed in execution order; numbering stays monotonic by creation). Branching: todo/plan docs commit on the `TRIM` branch; each plan's implementation gets its own branch off `TRIM`. + +## Discovery Log + +### 2026-07-06 — Todo created from zTreatment PCB-003 reconnaissance +- **Finding:** zTreatment's cut-over LinkerConfig block traces to two real generator gaps and one already-fixed one. (1) `DiscoverDtoTypes` walks parameters and returns, but `WalkFactoryReturn` drops any type without a public parameterless ctor — positional records get no `PreserveType` emission because the only `PreserveType` caller (`WalkEventRoot`) has been dead since the v1.4.0 relay redesign. (2) `[Factory]` roots are rejected without walking their properties, so entity-carried DTOs are undiscoverable. (3) Event records have been annotation-preserved since v1.4.0 (`68e7324`); the consumer's event entries predate that and were carried forward untested. +- **Decision:** three plans — fix the record bucket (001), add entity property descent (002), verify events with a trimmed repro instead of assuming (003). Consumer side tracked as zTreatment PCB-003 (full-vertical scope confirmed by user 2026-07-06). +- **Index changes:** initial split, 001–003. +- **Follow-up:** target release consumed by zTreatment PCB-003. + +### 2026-07-06 — TRIM re-split after in-repo recon (TRIM branch) +- **Finding:** Both gaps confirmed in code (`WalkFactoryReturn` ctor gate at `DtoTypeWalker.cs:145`; `[Factory]` roots rejected before any property descent; `PreserveType` emitted nowhere — the parameterized bucket lost its renderer in the v1.4.0 relay-codegen removal, and `WalkEventRoot`'s header comment claiming a `FactoryGenerator.RelayHandler` caller is stale). New findings the outside-perspective split missed: (1) `RemoteFactory.TrimmingTests` is outside `Neatoo.RemoteFactory.sln` and CI, and exits 0 even when its smoke tests print FAILED — TRIM-001/002/003's publish-trimmed acceptance signals have no enforceable gate. (2) `EventRelaySmokeTest` constructs its event via `new TrimTestRelayEvent(...)`, statically rooting the ctor — it cannot pin TRIM-003's subscribe-only consumer shape, so that repro is confirmed necessary. (3) `Design.Domain/FactoryPatterns/FactoryEventHandlerPattern.cs` (~119–137) and a `FactoryEventHandlerTests.cs` doc comment still describe the removed per-handler `PreserveType` emission — internal contradiction with CLAUDE-DESIGN.md/docs/trimming.md; callout carried to TRIM-003's doc delta. +- **Decision:** Re-split. +- **Index changes:** add TRIM-004 (harness pass/fail semantics + CI gate), executed first; 001–003 unchanged. +- **Follow-up:** TRIM-004. From 8c48f66d29a09ca158d796cb3d6131b577520d40 Mon Sep 17 00:00:00 2001 From: Keith Voels Date: Mon, 6 Jul 2026 13:13:51 -0500 Subject: [PATCH 2/4] =?UTF-8?q?docs(todo):=20draft=20TRIM-004=20=E2=80=94?= =?UTF-8?q?=20trimming=20harness=20pass/fail=20semantics=20+=20CI=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../plans/004-trimming-harness-ci-gate.md | 92 ++++++++++++++++++- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/004-trimming-harness-ci-gate.md b/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/004-trimming-harness-ci-gate.md index 0fd7564d..b947f655 100644 --- a/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/004-trimming-harness-ci-gate.md +++ b/docs/todos/TRIM-dto-trimming-preservation-gaps/plans/004-trimming-harness-ci-gate.md @@ -1,11 +1,95 @@ # TRIM-004 — Trimming harness pass/fail semantics + CI gate **Plan #:** 004 -**Status:** Draft -**Plan-review opt-in:** TBD at draft -**Code-review opt-in:** TBD at draft +**Date:** 2026-07-06 **Related Todo:** [../todo.md](../todo.md) +**Status:** Draft +**Last Updated:** 2026-07-06 +**Plan-review opt-in:** No (test-infrastructure/CI wiring only; no public API, schema, or documented business-rule surface) +**Code-review opt-in:** No (no library behavior change; harness and workflow only) + +--- ## Scope -Give the publish-trimmed harness enforceable pass/fail semantics and make CI run it. Today `RemoteFactory.TrimmingTests` sits outside `Neatoo.RemoteFactory.sln`, is never published or executed by `.github/workflows/build.yml`, and its `Program.cs` / smoke tests print `FAILED` lines to the console but always exit 0 — so the trimmed-repro acceptance signals that TRIM-001/002/003 depend on would verify nothing in CI. Convert failure paths to a non-zero process exit (aggregated across all checks so one failure doesn't mask others), add a CI step that publishes the trimmed exe (Release; RID matching the ubuntu runner) and runs it, and settle solution membership at draft time (the csproj is deliberately single-TFM net9.0 with `PublishTrimmed=true`, which the solution-wide multi-targeted build/test may not tolerate as-is). Does NOT add new trimming test cases — those land with TRIM-001/002/003. +Give the publish-trimmed harness enforceable pass/fail semantics and make CI run it. Today `RemoteFactory.TrimmingTests` sits outside `Neatoo.RemoteFactory.sln`, is never published or executed by `.github/workflows/build.yml`, and its `Program.cs` / smoke tests print `FAILED` lines to the console but always exit 0 — so the trimmed-repro acceptance signals that TRIM-001/002/003 depend on would verify nothing in CI. Convert failure paths to a non-zero process exit (aggregated across all checks so one failure doesn't mask others), add a CI step that publishes the trimmed exe (Release; RID matching the ubuntu runner) and runs it, automate the README's manual binary-inspection check, and settle solution membership at the keyboard. Does NOT add new trimming test cases — those land with TRIM-001/002/003. + +--- + +## Intent + +- Every remaining TRIM plan's acceptance signal has the shape "X survives publish-trimming and deserializes on the client." This plan makes that class of signal *enforceable*: a trimmed-repro regression turns CI red instead of printing to a console nobody runs. +- After this lands, TRIM-001/002/003 add their repro cases into an already-gated harness — their acceptance bullets become CI-checkable facts, and the gate keeps protecting consumers (zTreatment PCB-003) after this todo closes. + +--- + +## Framework & Architectural Alignment + +- CI/CD standards (user-level CLAUDE.md): single `build.yml` workflow, build-job structure, no new workflow files. +- TrimmingTests project conventions per its README: standalone single-TFM net9.0 console exe (clearing `TargetFrameworks` is what makes ILLink actually run), feature-switch client configuration (`IsServerRuntime=false`, `Trim="true"`). +- The harness stays a plain console exe, not an xUnit project — trimmed-publish executables and test SDKs don't mix; the exe's exit code is the test result. + +--- + +## Constraints & Invariants + +- Existing per-check console diagnostics remain (they are the failure forensics when CI goes red). +- All five existing checks keep passing in the trimmed run: service-provider build with `ValidateOnBuild`, class-factory resolution, static-delegate resolution, event-delegate resolution, feature-switch fold, event-relay smoke. +- `dotnet build` / `dotnet test` of `Neatoo.RemoteFactory.sln` stay green on net9.0 + net10.0 — the harness's single-TFM/`PublishTrimmed` setup must not leak into the multi-target solution build. +- The CI publish must actually trim (self-contained, RID-specific) — a non-trimmed run would pass vacuously. +- No changes to `src/RemoteFactory` or `src/Generator` in this plan. + +--- + +## Steps + +1. Convert the harness's reporting to aggregated exit semantics: every check contributes to a single failure flag, the process exits non-zero if any check failed, and no early `return` masks later checks. +2. Add a CI step to the build job that publishes the trimmed harness (Release, runner RID, self-contained) and runs it, failing the job on non-zero exit. +3. Automate the README's binary-inspection check in the same CI step: assert the server-only marker strings are absent from the published assembly. +4. Settle solution membership — in-sln (so plain builds surface compile breaks in harness code) vs. standalone (publish step restores it independently) — at the keyboard, based on how the multi-target solution build tolerates the project; record the decision here. +5. Update the TrimmingTests README to describe the gate and the one-command local run. + +--- + +## Acceptance + +- [ ] A deliberately-injected smoke-check failure makes the published trimmed exe exit non-zero; the all-green run exits 0. `[explicit-skip: harness-gate semantics — verified by one-off failure injection at the keyboard; the harness itself is the test]` +- [ ] CI publishes and runs the trimmed harness on every push/PR build, and the job fails when the harness fails. `[explicit-skip: CI wiring — verified by this plan's own workflow run]` +- [ ] Server-only marker absence in the published assembly is asserted by CI, not just documented in the README. `[explicit-skip: binary-inspection gate — workflow grep step]` +- [ ] `dotnet build` and `dotnet test` of `Neatoo.RemoteFactory.sln` remain green (net9.0 + net10.0). `[explicit-skip: build gate]` + +--- + +## Current State (Pre-Flight) + +Walked 2026-07-06 on the `TRIM` branch (recon commit ba3a744): + +- `src/Tests/RemoteFactory.TrimmingTests/Program.cs` — top-level statements; service-provider failure paths print and `return;` with implicit exit 0 (lines 39, 45); factory/delegate checks print `resolved: True/False` booleans without affecting exit; `DirectFeatureSwitchTest.Run()` and `EventRelaySmokeTest.Run()` are `void` and print `PASSED`/`FAILED` lines only. +- `EventRelaySmokeTest.Run` early-returns on each failure branch (lines 59, 79, 84, 92, 98, 104) — six distinct FAILED messages, none propagated. +- `RemoteFactory.TrimmingTests.csproj` — single-TFM net9.0 (deliberately clears `TargetFrameworks`; README documents ILLink silently not running otherwise), `PublishTrimmed=true`, `TrimMode=full`, no `RuntimeIdentifier` pinned (local `bin/Release/net9.0/win-x64/publish` artifacts came from a manual `-r win-x64` publish), feature switch `Neatoo.RemoteFactory.IsServerRuntime=false Trim=true` at line 30. +- Project is absent from `src/Neatoo.RemoteFactory.sln`; `.github/workflows/build.yml` restores/builds/tests only the solution, on `ubuntu-latest` (CI RID will be `linux-x64`), .NET 9 + 10 SDKs installed. +- Binary markers for the trimmed-away assertion: `"ServerOnlyDirect_MARKER"` (`DirectFeatureSwitchTest.cs:34`) and the `ServerOnly*` type names (`ServerOnlyTypes.cs`); README's manual check greps `"ServerOnly"` in the published dll. +- README documents the manual workflow (publish win-x64, grep, ilspycmd, run exe) and the `TargetFrameworks` gotcha — keep both, add the gate. + +--- + +## Test Evidence + +Filled after implementation, before the Step 5 gate. All four Acceptance bullets are `explicit-skip` (gate/infrastructure signals — the harness itself is the test), so the expectation is a `test-reviewer` skip recorded in Skipped Steps rather than an evidence map with cited xUnit methods. + +| Acceptance bullet (short) | Tier declared | Test method | Tier confirmed | +|---|---|---|---| +| — | — | — | — | + +--- + +## Plan Amendments + +(None yet.) + +--- + +## Notes + +- The harness cannot self-report "type was trimmed away" for the marker check — string absence is only observable from outside the process, hence the CI grep step (Step 3). +- TRIM-001/002/003 will each add repro cases to this harness; keep the check-aggregation shape easy to extend (one flag, many named checks). From a2916044e60a17d2e91720003f1f233668d72211 Mon Sep 17 00:00:00 2001 From: Keith Voels Date: Mon, 6 Jul 2026 13:31:50 -0500 Subject: [PATCH 3/4] test: make the publish-trimmed harness an enforceable CI gate (TRIM-004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TrimmingTests harness printed FAILED lines but always exited 0, sat outside the solution, and was never run by CI — it had not even compiled since the [Event] method API was deleted in v1.5.0, and its class-factory check had been silently failing (root-provider scope violation, missing keyed HttpClient registration). - Aggregate named checks into a failure list; exit non-zero on any failure - Remove dead [Event] method usage; event coverage is EventRelaySmokeTest - Resolve factories inside a scope; register keyed HttpClient with a no-op handler (harness never sends requests) - Add the project to Neatoo.RemoteFactory.sln so plain builds catch rot - CI: publish trimmed (linux-x64), assert server-only implementation types absent from the binary, run the harness - Interface-name over-retention in guarded-dead LocalCreate bodies is deferred to TRIM-005 (docs currently claim full removal) Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 24 +++++++ .../plans/004-trimming-harness-ci-gate.md | 42 ++++++++--- ...05-server-only-reference-over-retention.md | 11 +++ .../todo.md | 18 ++++- src/Neatoo.RemoteFactory.sln | 15 ++++ .../DirectFeatureSwitchTest.cs | 11 +-- .../EventRelaySmokeTest.cs | 15 ++-- .../RemoteFactory.TrimmingTests/Program.cs | 71 ++++++++++++++----- .../RemoteFactory.TrimmingTests/README.md | 26 +++++-- .../RemoteFactory.TrimmingTests.csproj | 5 +- .../TrimTestCommands.cs | 9 --- 11 files changed, 192 insertions(+), 55 deletions(-) create mode 100644 docs/todos/TRIM-dto-trimming-preservation-gaps/plans/005-server-only-reference-over-retention.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0120df32..86878f0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -96,6 +96,30 @@ jobs: path: ./test-results/*.trx retention-days: 7 + - name: Trimming verification (publish-trimmed harness) + run: | + dotnet publish src/Tests/RemoteFactory.TrimmingTests/RemoteFactory.TrimmingTests.csproj \ + --configuration Release \ + --runtime linux-x64 \ + --self-contained true \ + -p:ContinuousIntegrationBuild=true + + PUBLISH_DIR="src/Tests/RemoteFactory.TrimmingTests/bin/Release/net9.0/linux-x64/publish" + + # Server-only IMPLEMENTATION types must be trimmed out of the published + # assembly. The IServerOnlyRepository interface name is expected to remain + # (referenced from guarded-dead LocalCreate bodies the trimmer keeps — + # tracked as TRIM-005); the implementations must not. + if grep -aq "ServerOnlyDirect" "$PUBLISH_DIR/RemoteFactory.TrimmingTests.dll" \ + || grep -aqP '(? public static class DirectFeatureSwitchTest { - public static void Run() + public static bool Run() { if (NeatooRuntime.IsServerRuntime) { // This code path should be dead when IsServerRuntime is constant-folded to false. var helper = new ServerOnlyDirect(); Console.WriteLine(helper.Marker); + Console.WriteLine("Feature switch FAILED: IsServerRuntime is true in the client-configured harness."); + return false; } - else - { - Console.WriteLine("Client mode - server-only code trimmed."); - } + + Console.WriteLine("Client mode - server-only code trimmed."); + return true; } } diff --git a/src/Tests/RemoteFactory.TrimmingTests/EventRelaySmokeTest.cs b/src/Tests/RemoteFactory.TrimmingTests/EventRelaySmokeTest.cs index e3d5420c..1a38e184 100644 --- a/src/Tests/RemoteFactory.TrimmingTests/EventRelaySmokeTest.cs +++ b/src/Tests/RemoteFactory.TrimmingTests/EventRelaySmokeTest.cs @@ -38,7 +38,7 @@ public Task Relay(IReadOnlyList events) public static class EventRelaySmokeTest { - public static void Run() + public static bool Run() { var services = new ServiceCollection(); services.AddLogging(); @@ -57,7 +57,7 @@ public static void Run() if (string.IsNullOrEmpty(json)) { System.Console.WriteLine("Event relay smoke FAILED: serializer produced null/empty JSON."); - return; + return false; } var wire = new[] @@ -77,12 +77,12 @@ public static void Run() catch (UnknownFactoryEventTypeException ex) { System.Console.WriteLine($"Event relay smoke FAILED: registry could not resolve TrimTestRelayEvent post-trim. {ex.Message}"); - return; + return false; } catch (System.Exception ex) { System.Console.WriteLine($"Event relay smoke FAILED: deserialization threw {ex.GetType().Name}: {ex.Message}"); - return; + return false; } capturing.Relay(deserialized).GetAwaiter().GetResult(); @@ -90,21 +90,22 @@ public static void Run() if (capturing.Captured.Count != 1) { System.Console.WriteLine($"Event relay smoke FAILED: expected 1 captured event, got {capturing.Captured.Count}."); - return; + return false; } if (capturing.Captured[0] is not TrimTestRelayEvent rt) { System.Console.WriteLine($"Event relay smoke FAILED: captured event is {capturing.Captured[0].GetType().FullName}, not TrimTestRelayEvent."); - return; + return false; } if (rt.Id != 42 || rt.Message != "trim-smoke") { System.Console.WriteLine($"Event relay smoke FAILED: round-trip values lost. Got Id={rt.Id}, Message=\"{rt.Message}\"."); - return; + return false; } System.Console.WriteLine("Event relay smoke PASSED: FactoryEventBase descendant survived trimming and round-tripped through registry+deserializer+relay."); + return true; } } diff --git a/src/Tests/RemoteFactory.TrimmingTests/Program.cs b/src/Tests/RemoteFactory.TrimmingTests/Program.cs index 32da6c2f..4d0b6b54 100644 --- a/src/Tests/RemoteFactory.TrimmingTests/Program.cs +++ b/src/Tests/RemoteFactory.TrimmingTests/Program.cs @@ -14,6 +14,14 @@ // to discover all factory types in a trimming-safe way. services.AddNeatooRemoteFactory(NeatooFactory.Remote, typeof(TrimTestEntity).Assembly); +// Remote-mode factories resolve a keyed HttpClient for the server call channel +// (standard client setup, e.g. Design.Client.Blazor). The harness never sends a +// request — the registration only has to satisfy resolution. A no-op handler +// keeps SocketsHttpHandler (and its System.Net.Security dependency, trimmed out +// of this full-trim publish) from being constructed. +services.AddKeyedScoped(RemoteFactoryServices.HttpClientKey, + (sp, key) => new HttpClient(new NoOpHttpHandler()) { BaseAddress = new Uri("https://localhost/") }); + // Guard server-only DI registrations behind the feature switch. // If these were registered unconditionally, the types would be kept alive // by the DI container regardless of the feature switch in generated code. @@ -22,6 +30,10 @@ services.AddScoped(); } +// Every named check appends to failedChecks; the process exits non-zero if any +// check failed. Per-check console lines stay as the failure forensics in CI logs. +var failedChecks = new List(); + ServiceProvider sp; try { @@ -36,59 +48,82 @@ Console.WriteLine($" - {inner.GetType().Name}: {inner.Message}"); } Console.WriteLine("Exiting due to service validation failure."); - return; + return 1; } catch (Exception ex) { Console.WriteLine($"ServiceProvider construction FAILED: {ex.GetType().Name}: {ex.Message}"); Console.WriteLine("Exiting due to service validation failure."); - return; + return 1; } +// Factories are registered scoped; resolve within a scope (root resolution +// throws under ValidateScopes=true). +using var checkScope = sp.CreateScope(); + // Verify class factory survived trimming (regression test). ITrimTestEntityFactory? factory = null; try { - factory = sp.GetService(); + factory = checkScope.ServiceProvider.GetService(); } catch (Exception ex) { Console.WriteLine($"Class factory resolution FAILED: {ex.GetType().Name}: {ex.Message}"); } +if (factory == null) +{ + failedChecks.Add("class factory resolution"); +} // Verify static factory delegate survived trimming (the original bug scenario). TrimTestCommands.DoWork? doWorkDelegate = null; try { - doWorkDelegate = sp.GetService(); + doWorkDelegate = checkScope.ServiceProvider.GetService(); } catch (Exception ex) { Console.WriteLine($"DoWork delegate resolution FAILED: {ex.GetType().Name}: {ex.Message}"); } - -// Verify event delegate resolution. -TrimTestCommands.OnWorkCompletedEvent? eventDelegate = null; -try +if (doWorkDelegate == null) { - using var scope = sp.CreateScope(); - eventDelegate = scope.ServiceProvider.GetService(); -} -catch (Exception ex) -{ - Console.WriteLine($"Event delegate resolution FAILED: {ex.GetType().Name}: {ex.Message}"); + failedChecks.Add("static factory delegate resolution"); } // Direct feature switch test: verifies that the trimmer constant-folds // NeatooRuntime.IsServerRuntime and removes dead code. -DirectFeatureSwitchTest.Run(); +if (!DirectFeatureSwitchTest.Run()) +{ + failedChecks.Add("feature switch constant fold"); +} // Event relay smoke test: verifies a FactoryEventBase descendant survives trimming // and round-trips through FactoryEventTypeRegistry → FactoryEventDeserializer → IFactoryEventRelay. -EventRelaySmokeTest.Run(); +if (!EventRelaySmokeTest.Run()) +{ + failedChecks.Add("event relay smoke"); +} Console.WriteLine($"IsServerRuntime: {NeatooRuntime.IsServerRuntime}"); Console.WriteLine($"Class factory resolved: {factory != null}"); Console.WriteLine($"Static factory delegate resolved: {doWorkDelegate != null}"); -Console.WriteLine($"Event delegate resolved: {eventDelegate != null}"); -Console.WriteLine("Trimming verification app completed."); + +if (failedChecks.Count > 0) +{ + Console.WriteLine($"Trimming verification FAILED ({failedChecks.Count} check(s)): {string.Join(", ", failedChecks)}"); + return 1; +} + +Console.WriteLine("Trimming verification app completed. All checks passed."); +return 0; + +/// +/// The harness resolves Remote-mode factories but never calls the server; +/// any actual send is a harness bug. +/// +internal sealed class NoOpHttpHandler : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => throw new NotSupportedException("The trimming harness never sends HTTP requests."); +} diff --git a/src/Tests/RemoteFactory.TrimmingTests/README.md b/src/Tests/RemoteFactory.TrimmingTests/README.md index c1b718d9..c2779207 100644 --- a/src/Tests/RemoteFactory.TrimmingTests/README.md +++ b/src/Tests/RemoteFactory.TrimmingTests/README.md @@ -1,7 +1,16 @@ # RemoteFactory Trimming Verification Tests -Standalone console app that verifies whether the IL trimmer removes server-only types -when `NeatooRuntime.IsServerRuntime` is set to `false` via `RuntimeHostConfigurationOption`. +Console app that verifies whether the IL trimmer removes server-only types +when `NeatooRuntime.IsServerRuntime` is set to `false` via `RuntimeHostConfigurationOption`, +and that types RemoteFactory must preserve (factories, delegates, event records) survive +`PublishTrimmed=true`. + +**This harness is a CI gate.** The process exits non-zero if any check fails; the +`Trimming verification` step in `.github/workflows/build.yml` publishes the trimmed +exe on every push/PR build, asserts the server-only marker strings are absent from +the published assembly, and runs the harness. A `FAILED` line in the output always +comes with a non-zero exit — new checks must follow that contract (append to +`failedChecks` in `Program.cs`; return `bool` from check methods). ## How It Works @@ -17,16 +26,23 @@ when `NeatooRuntime.IsServerRuntime` is set to `false` via `RuntimeHostConfigura # Clean and publish with trimming (net9.0) dotnet publish -c Release -r win-x64 --self-contained true -# Search for server-only types in output (should return nothing) -grep -aob "ServerOnly" bin/Release/net9.0/win-x64/publish/RemoteFactory.TrimmingTests.dll +# Search for server-only IMPLEMENTATION types in output (should return nothing). +# The IServerOnlyRepository interface name is expected to remain — it is referenced +# from guarded-dead LocalCreate bodies the trimmer retains (tracked as TRIM-005). +grep -aob "ServerOnlyDirect" bin/Release/net9.0/win-x64/publish/RemoteFactory.TrimmingTests.dll +grep -aobP '(?true Generated - - CA1861, CA1865, CA1510, IDE0021, IDE0022, IDE0023, IDE1006, CA1050, CA1822 + + CA1861, CA1865, CA1510, IDE0021, IDE0022, IDE0023, IDE1006, CA1050, CA1822, CA1031 diff --git a/src/Tests/RemoteFactory.TrimmingTests/TrimTestCommands.cs b/src/Tests/RemoteFactory.TrimmingTests/TrimTestCommands.cs index 13405fca..37d06f39 100644 --- a/src/Tests/RemoteFactory.TrimmingTests/TrimTestCommands.cs +++ b/src/Tests/RemoteFactory.TrimmingTests/TrimTestCommands.cs @@ -16,13 +16,4 @@ private static Task _DoWork(string input, [Service] IServerOnlyRepositor { return Task.FromResult(repo.DoServerWork(input)); } - - [Remote, Event] - private static async Task _OnWorkCompleted( - string workId, - [Service] IServerOnlyRepository repo, - CancellationToken cancellationToken) - { - await Task.Run(() => repo.DoServerWork(workId), cancellationToken); - } } From cd7c1d7ea9247c102279e4296849b89a28c6ec2b Mon Sep 17 00:00:00 2001 From: Keith Voels Date: Mon, 6 Jul 2026 14:23:37 -0500 Subject: [PATCH 4/4] docs(todo): bootstrap todo conventions and ID registry Co-Authored-By: Claude Fable 5 --- docs/todos/CONVENTIONS.md | 37 +++++++++++++++++++++++++++++++++++++ docs/todos/_ids.md | 15 +++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 docs/todos/CONVENTIONS.md create mode 100644 docs/todos/_ids.md diff --git a/docs/todos/CONVENTIONS.md b/docs/todos/CONVENTIONS.md new file mode 100644 index 00000000..a5689b3d --- /dev/null +++ b/docs/todos/CONVENTIONS.md @@ -0,0 +1,37 @@ +# Todo Conventions + +This directory holds iterative todos managed with the `iterative-todo` workflow +(durable todo container + small amendable plans + append-only Discovery Log). + +## Structure + +``` +docs/todos/ + CONVENTIONS.md # this file + _ids.md # ID registry — one row per todo, IDs never reused + {ID}-{kebab-name}/ # one folder per active todo + todo.md # goal, acceptance, Plan Index, Discovery Log + plans/{NNN}-{short-name}.md # monotonic numbering; Abandoned/Retired kept + reviews/ # gate output + build/test logs (logs gitignored) + completed/{ID}-{kebab-name}/ # finished todos move here, ID prefix preserved +``` + +## IDs + +- 3–5 uppercase letters, assigned at todo creation, registered in `_ids.md`. +- Unique forever — retired IDs are never reused. +- Cross-reference plans as `{ID}-{NNN}` (e.g. `TRIM-004`), never bare `Plan 004`. + +## Branching (this repo) + +- Each todo gets a branch named `{ID}` off `main`; todo/plan documentation commits + land there. +- Each plan's implementation gets its own branch `{ID}-{NNN}-{short-name}` off the + todo branch. +- PRs target `main` (CI's `pull_request` trigger only watches `main`); after merge, + pull `main` back into the todo branch and continue. + +## Commits + +Conventional commits per the repo root `CLAUDE.md` (`feat:`/`fix:` drive release +notes; `docs:`/`test:`/`chore:` are omitted). Todo bookkeeping commits use `docs(todo):`. diff --git a/docs/todos/_ids.md b/docs/todos/_ids.md new file mode 100644 index 00000000..9657d4bd --- /dev/null +++ b/docs/todos/_ids.md @@ -0,0 +1,15 @@ +# Todo ID Registry + +One row per todo, newest first. IDs are never reused, even after completion or +abandonment. Move rows to the Completed section together with the folder move. + +## Active + +| ID | Todo | Created | Location | +|----|------|---------|----------| +| TRIM | DTO trimming preservation gaps | 2026-07-06 | [TRIM-dto-trimming-preservation-gaps](./TRIM-dto-trimming-preservation-gaps/todo.md) | + +## Completed + +| ID | Todo | Completed | Location | +|----|------|-----------|----------|