Skip to content

feat: add TsInterfaceName property to GenerateDtosFor attribute - #408

Merged
Tim-Maes merged 27 commits into
Tim-Maes:masterfrom
dkattan:feature/ts-interface-name
Jul 28, 2026
Merged

Tim-Maes merged 27 commits into
Tim-Maes:masterfrom
dkattan:feature/ts-interface-name

Conversation

@dkattan

@dkattan dkattan commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a TsInterfaceName property to GenerateDtosForAttribute. When set, the generator emits a TsInterface attribute on the generated DTO class for Reinforced.Typings.

Changes

  • GenerateDtosAttribute.cs: Add TsInterfaceName property
  • GenerateDtosTargetModel.cs: Add TsInterfaceName to model, constructor, copy methods, Equals, GetHashCode
  • GenerateDtosGenerator.cs: Read TsInterfaceName from attribute, emit TsInterface in type declaration

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

@Tim-Maes

Copy link
Copy Markdown
Owner

Hi @dkattan

There's 2 other big PR's you created still in draft. Are these related or dependant? This one is marked as ready? What's the plan here?

@Tim-Maes

Copy link
Copy Markdown
Owner

@dkattan ?

@dkattan

dkattan commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

@dkattan ?

Hey sorry went on vacation and lost stream. As I try to adopt Facet I discover myself/AI adding features to Facet that may be over-fitted to my scenario. Like in this case, we use ReinforcedTypings with a custom TS client generator. But I feel like most sane projects use OpenAPI or GraphQL or OData. Also, this PR appears to be a dumping ground for a ton of independently mergeable features. Let me get it split out.

@dkattan

dkattan commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

PR Split

This PR has been split into focused, independently mergeable PRs:

  1. ci: add GitHub Packages publishing workflow for POC branch #409ci: add GitHub Packages publishing workflow — CI workflow for publishing NuGet packages to GitHub Packages. Completely independent, flat split.
  2. feat: FAC109 migration complexity analyzer #410feat: FAC109 migration complexity analyzer — Roslyn analyzer that scores DTO migration complexity. Depends on the EF model manifest infrastructure already in this PR.

This PR (#408) now focuses on the primary feature: TsInterfaceName property on GenerateDtosFor attribute, plus the supporting DTO generation features (RenameProperties, DtoPreset, PropertySuffix, GenerateReadOnlyProperties).

🤖 Generated with Copilot

dkattan and others added 22 commits July 28, 2026 06:48
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>
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>
…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>
…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).
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>
dkattan and others added 5 commits July 28, 2026 06:48
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>
…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>
When set, the generator emits [TsInterface(Name = ...)] on the
generated DTO class for Reinforced.Typings. This allows controlling
the TypeScript interface name from the Facet attribute without
requiring a hand-written attribute in the partial class.

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.

2 participants