Skip to content

feat: ExcludeNavigationProperties on [GenerateDtos] — EF model-manifest driven - #399

Draft
dkattan wants to merge 29 commits into
Tim-Maes:masterfrom
dkattan:feature/generate-dtos-verbosity
Draft

feat: ExcludeNavigationProperties on [GenerateDtos] — EF model-manifest driven#399
dkattan wants to merge 29 commits into
Tim-Maes:masterfrom
dkattan:feature/generate-dtos-verbosity

Conversation

@dkattan

@dkattan dkattan commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #396. ORM entities carry navigation and back-reference properties (Tenant? Owner, List<Order> Orders, …) that don't belong in request DTOs — and today each one must be hand-listed in ExcludeProperties. On wide entities that means 30+ nameof(...) entries per attribute, kept in sync by hand as navigations are added.

Manifest shaping drops them automatically, following the EF Core model's own navigation designation — not a type-shape heuristic. And in a project that wires a model manifest into <AdditionalFiles>, shaping is the default: every [GenerateDtos] attribute that leaves ExcludeNavigationProperties unset is shaped, an explicit value wins in both directions, and = false is the per-type opt-out for non-entity source types.

[GenerateDtos(Types = DtoTypes.Create | DtoTypes.Update)]   // shaping needs no per-attribute flag
public class Schedule
{
    public int Id { get; set; }
    public int? TenantId { get; set; }              // kept (mapped scalar column)
    public Tenant? OwnerTenant { get; set; }        // dropped (navigation)
    public List<Job> Jobs { get; } = new();         // dropped (collection navigation)
}

The DTO keeps exactly the properties EF maps as data (scalar columns, complex/value-object members, primitive collections) and drops navigations, skip navigations, owned references, and [NotMapped] members. Because it reads the real model, it's correct where a type-shape guess can't be: a same-assembly class stored through a value converter is kept (the model maps it as a column), and a scalar-looking property the model ignores is dropped. IncludeProperties forces aggregate children (edited with their parent) back in.

How it works: the EF model manifest

Facet.Extensions.EFCore ships design-time services that write a manifest ({ContextName}.facetmodel.json) beside the migrations model snapshot on every dotnet ef migrations add/remove, by subclassing MigrationsScaffolder and walking the finalized IModel. Per entity it records which members EF maps as data (scalar/complex) and which are nav/owned/skipnav/ignored/service. It's committed like the snapshot and stays exactly as fresh as it — the same workflow that keeps migrations honest keeps DTO shapes honest. Register the services with one property in the DbContext project's csproj — the package ships a buildTransitive targets file that expands it to the assembly attribute below (EF reads that attribute from the DbContext project's assembly or the startup assembly, and de-duplicates, so either placement works):

<FacetEfDesignTime>true</FacetEfDesignTime>
[assembly: Microsoft.EntityFrameworkCore.Design.DesignTimeServicesReference(
    "Facet.Extensions.EFCore.Design.FacetDesignTimeServices, Facet.Extensions.EFCore")]

The same property also wires the project's own manifests into the compilation as AdditionalFiles, so a single-project setup has exactly one knob: write manifests, consume manifests, shaping on. It is deliberately opt-in — a package reference alone never changes what migrations write or what the compiler consumes — and a coded warning (FACET001) flags the property under GenerateAssemblyInfo=false, where assembly attributes cannot be emitted.

The generator consumes it as an AdditionalFile in the project that declares [GenerateDtos] — pre-wired by FacetEfDesignTime when that is the DbContext project itself, or one explicit cross-project glob otherwise (a cross-project manifest path is knowledge only the consuming project has, so that step cannot be automated away; duplicate wiring is a pinned no-op — the reader merges per file by union):

<AdditionalFiles Include="../MyApp.Persistence/Migrations/*.facetmodel.json" />

In a layered solution three different projects are involved (startup / migrations / [GenerateDtos]), so the glob usually reaches across projects — the README has a Mermaid diagram of the layout and a role table. The manifest is versioned JSON, written with Utf8JsonWriter and read with JsonDocument; each file is parsed atomically (a malformed or future-version file is ignored in full, never half-applied), unknown properties are skipped so the schema can grow, and reader equality is structural so formatting-only edits don't bust the incremental cache.

No heuristic, no silent fallback

There is deliberately no type-shape heuristic. An earlier revision of this branch shipped one as a zero-setup default; it was removed before merge because it's fragile in exactly the ways the manifest fixes (value-converted columns, cross-assembly entities, [NotMapped] scalars), and shipping a fragile default only to remove it later would strand adopters. EF is the ORM Facet users overwhelmingly reach for, so requiring its model up front is the right constraint. Consequently every failure is a diagnostic — nothing is ever silently guessed:

Rule Severity Fires when
FAC103 Error a manifest file is malformed (ignored in full, and said so)
FAC104 Error a manifest declares an unsupported version (package mismatch)
FAC105 Error a shaped type — explicit or defaulted; the message says which — has no manifest entry. With an explicit = true this also catches a mistyped <AdditionalFiles> glob (matches-nothing is silently empty)
FAC106 Warning a settable property on a listed type is unknown to the model — the manifest predates it, and it would otherwise silently vanish from the DTO

Wiring the manifest is the project-level switch: a project with a manifest shapes by default, a project without one copies properties as-is, and a rejected-only manifest set does not flip the default — FAC103/FAC104 stand alone instead of being buried under a FAC105 cascade. The obsolete [GenerateAuditableDtos] is exempt from the default; it cannot express the opt-out. One caveat is documented honestly with a tripwire: under the default, a glob that matches nothing means no wired manifest, no shaping, and no diagnostics — pinning = true on one representative entity turns that into a hard error.

To make FAC106 sound, the manifest records everything the model has an opinion on, including ignored (explicitly ignored members, recovered from the convention surface across the inheritance chain) and service (ILazyLoader-style). Computed get-only properties are exempt — the model never maps them. On a large real-world consumer model that recovered 69 ignored members across 128 entities, each a FAC106 false positive otherwise. FAC101–FAC106 anchor to the [GenerateDtos] attribute (a cache-safe location capture on the model), so they land as squiggles and are #pragma-suppressible. FAC105 is already an error; escalate FAC106 for CI with <WarningsAsErrors>$(WarningsAsErrors);FAC106</WarningsAsErrors> and a PR that changes the model without regenerating the manifest can't merge green.

Verified end to end against the real-world consumer: the design-time hook produces a 128-entity manifest; a wide entity's two ~37-entry ExcludeProperties lists collapse to a handful of scalar excludes; generated contracts match the model's designation exactly; and a deliberately broken <AdditionalFiles> path on an explicitly shaped type fails the build with FAC105 rather than silently mis-shaping DTOs. A separate cold-consumer end-to-end (local NuGet feed → fresh console app → dotnet ef → build) verified the packaged flow: the FacetEfDesignTime opt-in respected in both directions, the manifest written on migrations add and surviving remove, implicit shaping with zero AdditionalFiles in the csproj (-getItem:AdditionalFiles shows exactly the package glob's match), FAC105 with the defaulted-mode message on an uncovered type, the = false escape hatch, and identical output under double wiring.

Generator framework: SourceGenerator.Foundations

Reading JSON inside a netstandard2.0 analyzer is possible because this PR also adopts SourceGenerator.Foundations (2.0.16, PrivateAssets="all" — nothing leaks to consumers). SGF hoists the generator behind a generated wrapper and embeds referenced assemblies (System.Text.Json and friends, even Facet.Attributes.dll) into Facet.dll, resolving them at load time — the sanctioned way for an analyzer to carry NuGet dependencies into every compiler host, including VS's .NET Framework one — which also retires the manual <Analyzer Include> registration ProjectReference consumers previously needed for Facet.Attributes (cold builds died with CS8784). It brings exception isolation around every callback and a logger (SgfLogLevel). Embedding is trimmed to the runtime closure (Workspaces + System.Composition excluded — IDE hosts provide them; embedding Roslyn-family assemblies risks type identity), keeping the analyzer ~4 MB. Driver-based tests instantiate the generated internal hoist (InternalsVisibleTo).

Tests

Suite 962/962 (rebased onto master with #397 and #400 merged; includes two facts pinning the integration seam — an assembly-level [GenerateDtosFor] registration gets the same manifest-driven shaping through the shared pipeline, and an uncovered assembly-level type reports FAC105 anchored at the [assembly:] attribute — plus eleven pinning the wired-manifest default: unset-flag shaping with and without a manifest, implicit FAC105 naming the defaulted mode and the escape hatch, explicit-false opt-out, an empty-but-valid manifest still counting as wired, rejected-only manifests not flipping the default, FAC106 on implicitly shaped types, and the [GenerateAuditableDtos] exemption in both shape and diagnostics). Coverage: generator (driver + in-memory AdditionalFiles) — manifest keep-set drops navs and [NotMapped] scalars, value-converted entity-typed columns survive, IncludeProperties overrides a manifest drop, an uncovered type drops nothing (keeps all members) and is FAC105, malformed/unsupported files are ignored atomically with FAC103/FAC104 reported, multi-context manifests union per type; diagnostics — FAC105 error for no-manifest and type-absent (once per type despite flags expansion, attribute-anchored), FAC106 for stale manifest, quiet for computed properties / complete manifests / non-ExcludeNavigationProperties types; manifest writer (real Sqlite model) — scalars/complex/primitive collections kept, nav/owned/skipnav/ignored categorized, shadow members absent, deterministic output; end-to-end through EF's real design-time pipeline — FacetDesignTimeServices swaps in the scaffolder and ScaffoldMigration + Save produces the manifest beside the snapshot.

🤖 Generated with Claude Code

@dkattan
dkattan marked this pull request as draft July 9, 2026 10:37
@dkattan
dkattan force-pushed the feature/generate-dtos-verbosity branch from bbc3211 to 45ad7fc Compare July 9, 2026 10:43
@dkattan dkattan changed the title feat: ExcludeNavigationProperties option on [GenerateDtos] feat: ExcludeNavigationProperties on [GenerateDtos] — heuristic + EF model manifest Jul 9, 2026
@dkattan
dkattan force-pushed the feature/generate-dtos-verbosity branch from 97885fa to 5f5dfde Compare July 9, 2026 14:03
@dkattan

dkattan commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto #401's branch so the preview-publish guard applies here too — that's why this PR temporarily shows the one-line preview.yml commit. It falls away automatically on the next rebase once #401 lands on master; the feature diff is unchanged.

🤖 Addressed by Claude Code

@dkattan dkattan changed the title feat: ExcludeNavigationProperties on [GenerateDtos] — heuristic + EF model manifest feat: ExcludeNavigationProperties on [GenerateDtos] — EF model-manifest driven Jul 9, 2026
Comment thread docs/09_GenerateDtosAttribute.md Outdated
dkattan and others added 11 commits July 10, 2026 02:15
Skips properties whose type (or collection element type, for any
IEnumerable other than string - arrays included, dictionary key/value
types unwrapped) is a class or interface declared in the same assembly
as the source model - removing ORM navigation and back-reference
properties from generated DTOs without hand-listing each one in
ExcludeProperties.

Scalars, enums, framework types, primitive collections, classes from
other assemblies, and user-defined value types (e.g. strongly-typed ID
structs) are always kept.

IncludeProperties is the escape hatch: names listed there survive every
automatic and explicit exclusion, for aggregate children (task
parameters, order lines) that the heuristic would otherwise drop.

Documented limitations, pinned by tests: non-collection wrapper
generics (Lazy<T>, Task<T>) and entities declared in a different
assembly are not detected.

Composes with flags-combined OutputType: one attribute can emit a
nav-free Interface + PartialClass pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ristic to authoritative

Facet.Extensions.EFCore now ships design-time services (FacetDesignTimeServices +
a MigrationsScaffolder subclass) that write a {ContextName}.facetmodel manifest
beside the migrations model snapshot on every 'dotnet ef migrations add/remove',
recording per entity which properties EF maps as data (scalar/complex) and which
are navigations, owned references, or skip navigations.

The GenerateDtos generator reads these manifests as AdditionalFiles: for source
types listed there, ExcludeNavigationProperties keeps exactly the mapped data
properties — value-converted columns survive and EF-ignored properties drop,
both unlike the heuristic — while unlisted types keep the heuristic behavior.
The transform now only marks heuristic candidates; the final member set is
resolved in the generation stage where AdditionalFiles are visible.

The manifest format is line-based on purpose: the netstandard2.0 generator
cannot assume a JSON library in every compiler host without packaging one into
the analyzer, and the flat records diff better in review anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SGF (SourceGenerator.Foundations 2.0.16) hoists GenerateDtosGenerator behind a
generated wrapper carrying [Generator]: callbacks get exception isolation and a
logger, and — the load-bearing feature — referenced assemblies are embedded into
Facet.dll as resources and resolved at generator load time, which is the one
sanctioned way for an analyzer to carry NuGet dependencies into compiler hosts.

That dissolves the reason the manifest format was line-based, so it is now JSON
(*.facetmodel.json), written with Utf8JsonWriter and read with JsonDocument.
The rewrite also fixes three review findings in the old reader: files now parse
atomically into a local buffer (a malformed or future-version manifest is
ignored in full — never half-applied, where an entity left with an accidental
empty keep-set would drop every property), TryGetKeepSet exposes an
ImmutableHashSet instead of the internal mutable set, and equality is structural
over the parsed content (formatting-only edits no longer invalidate the
incremental cache) rather than fingerprinting the raw text.

Embedding is trimmed to what the generator actually needs at runtime:
Workspaces and its System.Composition closure are excluded (IDE hosts provide
them; embedding Roslyn-family assemblies risks type identity), keeping the
analyzer at ~4 MB instead of ~11 MB. Driver-based tests instantiate the
generated internal hoist (InternalsVisibleTo) per SGF's testing story.
Verified against a large real-world consumer codebase: cold CLI build with the
generator consumed as a ProjectReference analyzer, 128-entity JSON manifest
parsed via embedded System.Text.Json, generated contracts byte-identical to the
line-format output, and no generator log noise in build output.

Suite: 932/932.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SGF embeds Facet.Attributes.dll into Facet.dll and resolves it at generator
load time, so the analyzer is self-contained even when consumed as a
ProjectReference. Verified with a cold build (compiler server shut down,
obj/bin wiped) — previously that path failed with CS8784 because Roslyn's
analyzer loader does not probe sibling files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The only silent state left is having no manifests at all (tier 1, by design).
Once manifests exist, every degradation is reported:

- FAC103 (error): a *.facetmodel.json file is malformed — ignored in full,
  never half-applied, and never quietly.
- FAC104 (error): a manifest declares an unsupported version (Facet /
  Facet.Extensions.EFCore package mismatch).
- FAC105 (warning): an ExcludeNavigationProperties source type is missing from
  every manifest — stale manifest or type-name mismatch; suppressible at the
  attribute for genuinely non-entity types.
- FAC106 (warning): a settable property on a listed type appears in none of
  the manifest's categories — the manifest predates the property, which would
  otherwise silently vanish from DTOs.

To make FAC106 sound, the manifest now records everything the model has an
opinion on: new 'ignored' (explicitly ignored members, recovered from the
convention surface across the inheritance chain — 69 real ones in a large
consumer model, all FAC106 false positives otherwise) and 'service'
(ILazyLoader-style) categories. Computed get-only properties are exempt: the
model never maps them. IncludeProperties remains explicit user intent.

FAC101–FAC106 now anchor to the [GenerateDtos] attribute's location instead of
Location.None, so they are #pragma-suppressible per type and land as squiggles
in the IDE; the model carries a cache-safe SourceLocationInfo instead of a
Location. Strictness needs no new API: WarningsAsErrors escalates FAC105/106.

Docs: FAC101–106 added to the analyzer rules reference, release tracking, and
the GenerateDtos guide. Suite: 942/942, including a real-codebase negative
test (property added without regenerating → FAC106 at the attribute).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'Startup project' alone left the reader guessing at a file. Name the
conventional homes (any compiled .cs file, typically Properties/AssemblyInfo.cs),
show the zero-new-file csproj <AssemblyAttribute> form, and note the startup
project must transitively reference Facet.Extensions.EFCore so dotnet ef can
load the assembly named in the attribute.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the buildable form of 'error when the manifest glob matches nothing'.
The generator cannot see an AdditionalFiles glob that matched zero files — an
empty glob passes nothing to the compiler, indistinguishable from no glob — so
strictness is signalled with a build property:

  <Facet_RequireEfModelManifest>true</Facet_RequireEfModelManifest>

When set, the same-assembly heuristic is off and manifest coverage is
mandatory: an ExcludeNavigationProperties source type with no manifest entry
(no manifest supplied at all, or this type absent) is FAC107 — an error —
instead of the silent FAC105-advisory fallback. Read via a CompilerVisibleProperty
(registered in Facet.props) threaded through AnalyzerConfigOptionsProvider;
default false, so the two-tier behavior is unchanged unless opted in.

Docs rewritten from the immybot wiring experience:
- the three-project triangle (startup / migrations / DTO project) with a role
  table and the cross-project AdditionalFiles relative path — the previous
  local 'Migrations/*.facetmodel.json' only worked in a single-project layout;
- what writes the manifest (migrations add/remove only) and the first-run
  bootstrap, so 'I wired it up and nothing changed' is answered;
- the design-time-vs-runtime model trap for programmatic generation (use
  IDesignTimeModel.Model or every [NotMapped] becomes a spurious FAC106);
- the drift-is-a-build-failure story (RequireEfModelManifest + WarningsAsErrors)
  framed as a CI guarantee, not a caveat;
- multi-context manifests each write their own file and merge.

FAC107 in analyzer rules doc + release tracking. 5 new strict-mode tests
(no-manifest -> FAC107, uncovered -> FAC107, covered -> quiet, non-ExcludeNav
unaffected, FAC105 replaced not doubled). Suite 946/946.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…heuristic

The same-assembly type-shape heuristic is gone. ExcludeNavigationProperties is
now driven solely by the EF model manifest: a source type with no manifest
entry is FAC105, an error, not a silent fallback to a guess.

Rationale (Darren's call): the heuristic was a known-fragile hack (value-
converted columns wrongly dropped, cross-assembly entities wrongly kept,
[NotMapped] scalars wrongly kept). Shipping it as a default and removing it
later would break adopters — and since none of this is released yet, the
honest move is to not ship the fragile path at all. EF is the ORM Facet users
overwhelmingly reach for; requiring its model up front is the right constraint.

Removed:
- IsNavigationProperty / IsSameAssemblyDomainType and the transform's
  heuristic-marking pass; HeuristicNavigationProperties off the model.
- The opt-in Facet_RequireEfModelManifest property + its CompilerVisibleProperty
  and AnalyzerConfigOptions plumbing, and FAC107 — all subsumed: manifest
  coverage is unconditional now, so FAC105 (promoted from warning to error)
  is the single 'not covered' diagnostic. FAC106 stays a warning (stale-
  manifest freshness; escalate via WarningsAsErrors).
- Heuristic-era tests (GenerateDtosNavigationEdgeCaseTests, the reflection-based
  GenerateDtosNavigationExclusionTests) and their compile-time entities, whose
  behavior is fully covered by the driver-based manifest tests.

Docs rewritten so the manifest is THE mechanism, not an upgrade: a Mermaid
diagram of the single-project vs three-project layouts (answering 'where do the
DTOs live' — in the [GenerateDtos] project, always), FAC105-as-error, no
heuristic language. Suite 934/934; analyzer self-contained (STJ + Attributes
still embedded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ic' contradiction

The manifest is just a file with two ways to produce it; the docs claimed
migrations were the only way and then, separately, described a programmatic
path — leaving readers unsure which is true or when they'd use the tool.

Reframed both docs so the migration hook and the public writer are presented
as two callers of the same API:
- the hook writes it automatically as part of a migration you'd add anyway
  (and is what makes the committed file a drift guard);
- FacetEfModelManifest.Write is the no-migration path, with its use cases named
  up front — first-time bootstrap (model unchanged, so 'migrations add' says
  'no changes'), workflows that avoid dotnet ef, and CI drift-check tests.
Explicitly: you never have to invent a no-op migration to get a manifest.
Dropped the 'only thing that writes it' / 'regenerated only when migrations
are' absolutes that created the contradiction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tos guide

The heuristic-removal rewrite collapsed it to a prose mention ('or generated
from the csproj with an <AssemblyAttribute> item'); put the runnable XML block
back so the no-new-file registration path is copy-pasteable, matching the
Facet.Extensions.EFCore README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-rebase onto master with Tim-Maes#400 (assembly-level [GenerateDtosFor]) and Tim-Maes#397
(Patch wire format) merged: two facts pin the integration seam. An assembly-
level registration with ExcludeNavigationProperties gets the same manifest-
driven shaping as the class-level attribute (shared BuildModels pipeline), and
an uncovered assembly-level type reports FAC105 anchored at the [assembly:]
attribute. Rebase notes: BuildModels threads the Compilation for the Patch
wire-support detection, and every model copy-constructor preserves the
SupportsSystemTextJson/SupportsNewtonsoftJson flags across the merged field set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dkattan
dkattan force-pushed the feature/generate-dtos-verbosity branch from 48afc1e to df95c5f Compare July 10, 2026 08:05
dkattan added a commit to dkattan/Facet that referenced this pull request Jul 10, 2026
… archive

Curated port of the 2025-09 fluent navigation work from the closed PR Tim-Maes#47
branch (feature/enhanced-dto-generation-and-ef-updates), brought onto current
master so it is preserved, reviewable, and compiling — not rotting on a stale
base. Inert by construction: IsPackable=false, referenced by nothing, generator
wired into no consumer.

Ported verbatim: ChainUseDiscovery (usage-driven shape generation, avoids the
2^N navigation-combination explosion), the Emission suite (shape interfaces,
per-navigation capability interfaces generic in the navigation's shape, fluent
builders, selectors), ModelRoot/FacetDtoInfo/EfJsonReader/FacetConfiguration,
and the facet-fluent-navigation specifications.

Curation applied: FacetEfGenerator moved into the project and its hand-rolled
AppDomain.AssemblyResolve hook removed (SourceGenerator.Foundations, adopted in
'Tim-Maes#399', provides that properly); single netstandard2.0 target (multi-targeting
served the unported MSBuild-task hosting); ExportEfModelTask/efmodel.json infra
deliberately left behind — the *.facetmodel.json model manifest supersedes it.

specifications/facet-fluent-navigation/RESURRECTION.md records provenance, the
port table, inherited unfinished work (SelectorsEmitter placeholders, terminal
NotImplementedExceptions, the generated-file ordering issue), and the
modernization path (manifest-reader retarget, SGF hosting, projection
completion).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dkattan and others added 4 commits July 10, 2026 04:05
Review feedback: the docs assumed the consumer adopts mid-migration and
handwaved producing the initial facetmodel.json in the most probable case —
an existing app with a migration history and no model change pending, where
'migrations add' alone would create an unwanted migration.

The zero-code answer was implemented but never documented: the hook fires on
'migrations remove' too, so an add/remove pair bootstraps the manifest and
leaves no migration behind (remove re-scaffolds the snapshot and the hook
rewrites the manifest beside it; nothing touches a database). Both doc sites
now lead the bootstrap story with that pair, with the programmatic writer kept
for dotnet-ef-free workflows and CI drift checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pter's starting point

Ground-up rework of the manifest walkthrough. It now assumes the realistic
starting state — an existing app with a DbContext and migration history, no
model change in flight — and walks the four setup steps in order: install
Facet.Extensions.EFCore, register design-time services in the startup
project, bootstrap the first manifest with an add/remove migration pair,
point AdditionalFiles at it, and flip the attribute on.

Accuracy fixes surfaced by an adversarial review of the draft: name the
package install explicitly, state that FAC103/FAC104 are file-level while
FAC105/FAC106 anchor to the attribute, give the concrete FAC106 remedy
(dotnet ef migrations add), note FAC106 also covers get-only collections,
qualify the IncludeProperties override (Create DTOs still drop Id), correct
the no-snapshot edge on last-migration removal, and describe the
--startup-project default as the current directory's project.

The diagnostics table now lives only in the Facet.Extensions.EFCore README;
docs/09 keeps a summary and links there and to the analyzer rules page, so
the two can't drift. Also removes the unused EfModelManifest.HasEntities
member and corrects the manifest file extension in FacetDesignTimeServices
XML docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rojects, add FacetEfDesignTime MSBuild opt-in

Wiring an EF model manifest into a project's AdditionalFiles is now the
project-level switch for DTO shaping: every [GenerateDtos]/[GenerateDtosFor]
attribute that leaves ExcludeNavigationProperties unset is shaped by the
model, held to the same FAC105/FAC106 coverage rules as an explicit opt-in.
An explicit value wins in both directions — false is the per-type escape
hatch for non-entity source types. Projects without a manifest are
unchanged, and a rejected-only manifest set does not flip the default, so
FAC103/FAC104 stand alone instead of being buried under a FAC105 cascade.
The obsolete [GenerateAuditableDtos] attribute is exempt: it cannot express
the opt-out, so it keeps its legacy unshaped behavior.

Registration also drops to one line: Facet.Extensions.EFCore now ships a
buildTransitive targets file where <FacetEfDesignTime>true</FacetEfDesignTime>
emits the DesignTimeServicesReference assembly attribute. EF reads that
attribute from both the DbContext project's assembly and the startup
assembly and de-duplicates, so the property works in either project;
it is deliberately opt-in so a package reference alone never changes what
migrations write. A coded warning (FACET001) flags the property under
GenerateAssemblyInfo=false, where assembly attributes cannot be emitted.

EfModelManifest equality now keys manifest presence as a bool rather than
an exact accepted-file count, so consolidating per-context manifest files
without changing content does not invalidate cached generator output.

FAC105's message states whether shaping came from the explicit flag or the
wired-manifest default, and names the full remedy chain including the
design-time services registration. Docs rewritten around the three-step
adoption flow, including an honest account of the one silent failure mode:
under the default, an AdditionalFiles glob that matches nothing means no
shaping and no diagnostics — pinning ExcludeNavigationProperties = true on
one representative entity is the documented tripwire.

Verified end to end from a cold NuGet consumer: pack, FacetEfDesignTime
opt-in respected both ways, manifest written on migrations add and
surviving remove, implicit shaping with zero per-attribute config, FAC105
on an uncovered type with the defaulted message, and the explicit-false
escape hatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…itionalFiles

Single-project adopters now touch exactly one knob. The buildTransitive
targets glob the project's own *.facetmodel.json files (DefaultItemExcludes
keeps bin/obj out) under the same FacetEfDesignTime property that emits the
design-time registration attribute: write manifests, consume manifests,
shaping on. The glob matches nothing until the first migration runs, which
is harmless — dotnet ef builds the project before it writes anything.

Cross-project setups keep the hand-written <AdditionalFiles> glob, which
cannot be automated away: a cross-project manifest path is knowledge only
the consuming project has, and nothing forces it to reference the DbContext
project at all. Duplicate wiring (auto-glob plus a manual glob matching the
same file) is a pinned no-op — the manifest reader merges per file by union.

The FACET001 remediation is reworked to match the property's two halves:
under GenerateAssemblyInfo=false only the attribute half is lost, so the
warning now says to keep the property (the AdditionalFiles half still
works), register via a source-file attribute, and demote the coded warning
once done.

Re-verified end to end from packed artifacts: with no AdditionalFiles in
the consuming csproj, -getItem:AdditionalFiles shows exactly the package
glob's match, generated DTOs are shaped, and double-wiring builds
identically with no new diagnostics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dkattan added a commit to dkattan/Facet that referenced this pull request Jul 10, 2026
… archive

Curated port of the 2025-09 fluent navigation work from the closed PR Tim-Maes#47
branch (feature/enhanced-dto-generation-and-ef-updates), brought onto current
master so it is preserved, reviewable, and compiling — not rotting on a stale
base. Inert by construction: IsPackable=false, referenced by nothing, generator
wired into no consumer.

Ported verbatim: ChainUseDiscovery (usage-driven shape generation, avoids the
2^N navigation-combination explosion), the Emission suite (shape interfaces,
per-navigation capability interfaces generic in the navigation's shape, fluent
builders, selectors), ModelRoot/FacetDtoInfo/EfJsonReader/FacetConfiguration,
and the facet-fluent-navigation specifications.

Curation applied: FacetEfGenerator moved into the project and its hand-rolled
AppDomain.AssemblyResolve hook removed (SourceGenerator.Foundations, adopted in
'Tim-Maes#399', provides that properly); single netstandard2.0 target (multi-targeting
served the unported MSBuild-task hosting); ExportEfModelTask/efmodel.json infra
deliberately left behind — the *.facetmodel.json model manifest supersedes it.

specifications/facet-fluent-navigation/RESURRECTION.md records provenance, the
port table, inherited unfinished work (SelectorsEmitter placeholders, terminal
NotImplementedExceptions, the generated-file ordering issue), and the
modernization path (manifest-reader retarget, SGF hosting, projection
completion).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dkattan added a commit to dkattan/Facet that referenced this pull request Jul 10, 2026
Written after rebasing this branch onto the manifest work (Tim-Maes#399), while both
configuration surfaces are still unshipped and can be designed together.
INTERACTION-WITH-MODEL-MANIFEST.md maps each prototype-era piece to its Tim-Maes#399
replacement (efmodel.json/EfJsonReader/ModelRoot, the AssemblyResolve hook,
the debug-file diagnostics), and establishes the load-bearing findings:

- A fluent shape interface's member set is exactly the manifest keep-set, so
  one manifest entry feeds both features and one FacetEfDesignTime knob
  produces the data for both.
- Navigation targets and cardinality do not need schema support — they
  resolve from Roslyn symbols once the manifest names the navs. Entity keys
  do: they are pure model configuration, and the prototype's GetByIdAsync
  hard-codes a literal "Id" today. `keys` is an additive field (unknown
  properties are ignored by design), so it lands with the fluent feature,
  not in the alpha.
- IncludeProperties and .WithX() are contract-time vs query-time inclusion,
  not competing knobs; chain-use discovery keeps manifest presence from
  lighting up fluent machinery by surprise.
- Open decision recorded for builder emission (manifest-driven behind one
  enable knob vs attribute-driven), with [GenerateAuditableDtos] discovery
  dropped either way, consistent with its exemption in Tim-Maes#399.

Also corrects RESURRECTION.md's claim that the manifest already records
navigation targets and cardinality — v1 records names only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ring layout

FacetEfDesignTime in a consuming project now also collects *.facetmodel.json
from each direct ProjectReference via a BeforeTargets=CoreCompile target —
the knob stays local to the project it affects while the path comes from a
reference the user already maintains, so the usual layered setup (DTO
project references the DbContext project) needs no hand-written glob and
has nothing to typo. A target rather than an evaluation-time item because
MSBuild only expands wildcards derived from item metadata inside targets;
referenced projects' bin/obj are excluded, and overlaps with the other
wiring forms merge idempotently in the reader.

The README walkthrough's wiring step now shows a concrete solution tree and
ranks the four layouts: same-project (automatic), direct reference
(automatic with the same property), any layout via one
Directory.Build.props line anchored to $(MSBuildThisFileDirectory) — with
an explanation that its power is per-project directory-walk imports, not
property inheritance — and the plain relative glob.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dkattan added a commit to dkattan/Facet that referenced this pull request Jul 10, 2026
… archive

Curated port of the 2025-09 fluent navigation work from the closed PR Tim-Maes#47
branch (feature/enhanced-dto-generation-and-ef-updates), brought onto current
master so it is preserved, reviewable, and compiling — not rotting on a stale
base. Inert by construction: IsPackable=false, referenced by nothing, generator
wired into no consumer.

Ported verbatim: ChainUseDiscovery (usage-driven shape generation, avoids the
2^N navigation-combination explosion), the Emission suite (shape interfaces,
per-navigation capability interfaces generic in the navigation's shape, fluent
builders, selectors), ModelRoot/FacetDtoInfo/EfJsonReader/FacetConfiguration,
and the facet-fluent-navigation specifications.

Curation applied: FacetEfGenerator moved into the project and its hand-rolled
AppDomain.AssemblyResolve hook removed (SourceGenerator.Foundations, adopted in
'Tim-Maes#399', provides that properly); single netstandard2.0 target (multi-targeting
served the unported MSBuild-task hosting); ExportEfModelTask/efmodel.json infra
deliberately left behind — the *.facetmodel.json model manifest supersedes it.

specifications/facet-fluent-navigation/RESURRECTION.md records provenance, the
port table, inherited unfinished work (SelectorsEmitter placeholders, terminal
NotImplementedExceptions, the generated-file ordering issue), and the
modernization path (manifest-reader retarget, SGF hosting, projection
completion).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dkattan added a commit to dkattan/Facet that referenced this pull request Jul 10, 2026
Written after rebasing this branch onto the manifest work (Tim-Maes#399), while both
configuration surfaces are still unshipped and can be designed together.
INTERACTION-WITH-MODEL-MANIFEST.md maps each prototype-era piece to its Tim-Maes#399
replacement (efmodel.json/EfJsonReader/ModelRoot, the AssemblyResolve hook,
the debug-file diagnostics), and establishes the load-bearing findings:

- A fluent shape interface's member set is exactly the manifest keep-set, so
  one manifest entry feeds both features and one FacetEfDesignTime knob
  produces the data for both.
- Navigation targets and cardinality do not need schema support — they
  resolve from Roslyn symbols once the manifest names the navs. Entity keys
  do: they are pure model configuration, and the prototype's GetByIdAsync
  hard-codes a literal "Id" today. `keys` is an additive field (unknown
  properties are ignored by design), so it lands with the fluent feature,
  not in the alpha.
- IncludeProperties and .WithX() are contract-time vs query-time inclusion,
  not competing knobs; chain-use discovery keeps manifest presence from
  lighting up fluent machinery by surprise.
- Open decision recorded for builder emission (manifest-driven behind one
  enable knob vs attribute-driven), with [GenerateAuditableDtos] discovery
  dropped either way, consistent with its exemption in Tim-Maes#399.

Also corrects RESURRECTION.md's claim that the manifest already records
navigation targets and cardinality — v1 records names only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dkattan and others added 13 commits July 11, 2026 05:39
…ed entities

Emits an info-level diagnostic after generation showing how many EF model
manifest entities have [GenerateDtos] configured versus the total available.
Lists up to 10 uncovered entity names, then a count. Only fires when a
manifest is wired in and there are uncovered entities.

Exposes GetEntityNames() on EfModelManifest so the generator can iterate
the full entity key set without breaking encapsulation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Owned entities (EF Core IsOwned()) are never independent DTO targets —
they appear only as nested properties of their owning entity. The
manifest writer now records 'isOwned' and the reader excludes owned
entities from GetEntityNames() and EntityCount, so FAC107's coverage
metric no longer counts them as uncovered.
FAC108 reports one diagnostic per uncovered or partially covered EF
manifest entity. It reads *.facetmodel.json AdditionalFiles (same as
the generator's FAC107 summary) and scans both class-level
[GenerateDtos] and assembly-level [assembly: GenerateDtosFor]
attributes, tracking DtoTypes granularity — an entity with Create
but not Update produces 'has Create configured, but Update missing'.

The code fixer offers 'Generate DTOs for {EntityName}', inserting an
[assembly: GenerateDtosFor(typeof(Entity), Types=Create|Update,
OutputType=PartialClass, ExcludeAuditFields=true)] attribute into the
file that already contains other assembly-level GenerateDtosFor
attributes.

8 new tests cover: uncovered entities, message format, no manifest,
fully configured entity, partial coverage, owned type exclusion,
severity, and source anchoring.
Analyzer: use ContainsSyntaxTree to verify entity locations are in the
current compilation before anchoring diagnostics — Roslyn rejects
diagnostics with locations in referenced assemblies. Pass EntityFullName
via diagnostic properties so the code fixer can resolve types precisely.

Code fixer: use GetTypeByMetadataName with the full name from diagnostic
properties instead of fuzzy GetSymbolsWithName matching, which matched
unrelated types sharing a simple name (e.g. User → Microsoft.Graph.User).
Diagnostics with Location.None can't be fixed by Roslynator or IDE
code fixers — the fixer needs a document to modify. Anchor FAC108 to
the first [assembly: GenerateDtosFor] attribute location (typically
FacetGeneration.cs) so the fixer knows which document to modify.

Also pass EntityFullName via diagnostic properties for precise type
resolution in the code fixer (avoids fuzzy simple-name matching).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Allows renaming entity properties in generated DTOs via 'EntityProp:DtoProp'
pairs. The generated property uses the DTO name but maps to the entity name
in constructors and projections.

Not yet working in SGF hoist pipeline — needs further investigation into
why the renameMap isn't applied during member creation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add DtoPreset enum (ResponsePartial, RequestPartial, InterfaceRequest)
that bundles common property defaults. Explicit attribute values always
override preset defaults.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Renamed properties (e.g. CreatedDate→CreatedDateUtc) were being filtered
out by EF model manifest shaping because the keep/include sets contain
the source entity name, not the renamed DTO name. Now checks both
m.Name and m.SourcePropertyName against the keep and include sets.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Scores handwritten DTOs for Facet migration difficulty:
- Info severity: easy migration (all get/set auto-properties)
- Warning severity: needs behavior changes (read-only or computed props)

Features:
- Shadow cache (ConditionalWeakTable<Compilation, ConcurrentDictionary>)
  inspired by Vogen's BoundedCache pattern, keyed by Compilation with
  SymbolEqualityComparer for proper symbol equality
- Fail-fast: throws InvalidOperationException on unexpected manifest state
- Gratuitous logging with severity levels (DEBUG-only via Conditional attribute)
- DTO-to-entity name matching by stripping common prefixes/suffixes
- Skips entity classes themselves and already-Facet-configured types
- 10 unit tests covering easy/medium/no-diagnostic/multiple-DTO scenarios

Includes devloop-fac109.sh inner dev loop script.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…eration for partials

- PropertySuffix: auto-append suffix (e.g. 'UTC') to all DateTime/DateTimeOffset
  property names, eliminating RenameProperties boilerplate for audit date fields
- GenerateReadOnlyProperties: emit { get; init; } instead of { get; set; },
  suppressing 'required' modifiers and '= default!;' initializers
- Constructor generation for ResponsePartial preset: generate entity-to-DTO
  constructor with partial void OnInitialized(SourceType) hook for computed props
- Generated constructor is 'internal' for partial classes to avoid S3427 overlap
  with partial constructors that have optional parameters
- Fix source property name bug in constructor/FromSource/Projection (use
  SourcePropertyName instead of DTO name when properties are renamed)
- Suppress [SetsRequiredMembers] when GenerateReadOnlyProperties is true
- #pragma warning disable CS8618 around generated class for read-only properties

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Prevents double-suffixing (e.g. MarkedForDeletionAtUtc → MarkedForDeletionAtUtcUTC)
when an entity property already has the suffix in its name.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add 'RequestBody' and 'Body' to suffix list, track DtoKind (Response,
Create, Update) from prefix, adjust line savings estimate per kind,
update diagnostic message to show kind label, add per-kind configured
filtering so entities with only Response configured still surface
Create/Update candidates.

Add 3 new tests for Create/Update request DTO matching and configured
filtering.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant