Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions .agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
name: purview-sourcegenerator-attribute-data-models
description: >-
Use when declaring or fixing a [Generate] attribute-data model in Purview.SourceGeneratorFramework -
mapping constructor and named arguments with [Argument]/[Property], defaults, reusing a shape with
[NestedModel], reading an ADM0001-ADM0015 diagnostic, or testing that a model reads the values an
attribute application supplies.
metadata:
product: Purview.SourceGeneratorFramework
repository: purview-dev/sourcegenerator-framework
---

# Attribute-data models

`AttributeDataModelGenerator` turns a `readonly partial record struct` marked `[Generate(typeof(TAttribute))]`
into a parser for `TAttribute`'s `AttributeData`: `Exists`, `Empty`, `TargetAttribute`, the guarded
`FromAttributeData`/`TryFromAttributeData`/`AllAttributeData` overloads, and `FromAttributeDataUnchecked`.
The generated model is a value type, so it is safe to hold in an incremental pipeline (an `AttributeData`
is not).

The one thing to keep in mind: **the generated code looks names up case-sensitively at run time, and a
name it cannot find quietly yields the default value.** A mapping mistake compiles and produces `null`/`0`.
The naming analyzer (`ADM0011`–`ADM0015`) exists to report those mistakes where they are declared; do not
suppress it without understanding the finding, and test the model against real applications.

## Mapping each property

Each primary-constructor parameter becomes a property, PascalCased (`message_template` → `MessageTemplate`).
Its sources are read in this order, first hit wins:

| Declaration | Reads |
| --- | --- |
| *(no mapping attribute)* | the named argument with the property's name: `[Log(EventId = 7)]` |
| `[Property]` / `[Property(Name = "X")]` | the named argument `X` (default: the property name) |
| `[Argument("messageTemplate")]` | the constructor parameter **named** `messageTemplate` |
| `[Argument]` | the constructor parameter named after the property, camel-cased: `MessageTemplate` → `messageTemplate` |
| `[Argument(1)]` | the constructor argument at **position** 1 |
| `[Argument(...)] [Property]` | the named argument first, then the constructor argument |
| `[NestedModel]` | another generated model, read from the same attribute (below) |
| `[GenericTypeArgument]` | a generic type argument of the attribute class, as a `TypeIdentity` |
| `[Exclude]` | nothing; the parameter is left out of the model |

Methodology:

- **Map a constructor parameter by name, not by index.** A name survives a new overload or a reordered
parameter list; an index silently starts reading a different parameter.
- **Map both sources when the attribute accepts both.** `[Argument("name")] [Property] string? Name` reads
`[A(name: "x")]`, `[A("x")]` and `[A(Name = "x")]`. The named argument is read first because it is assigned
after the constructor runs, so it is the attribute instance's effective value.
- **Only map `[Property]` to a public settable property or field.** A property with a private or `internal`
setter can never be a named argument; read it through the constructor parameter that sets it (`ADM0012`).
- **Match case exactly.** `[Argument("MessageTemplate")]` never matches a parameter called
`messageTemplate` (`ADM0011`).
- **Give every property that might be absent a `DefaultValue`**: `[Property(DefaultValue = -1)] int EventId`.
A missing argument takes it; without one, it takes `default(T)`. `Empty` always uses `default(T)`.
- **Use `IsEnum = true` with a `string` property** when the enum type is not available to the generator; a
bare member-name default (`DefaultValue = "Inherit"`) is expanded to the fully-qualified member.
- **Never name a property `Exists`, `Empty`, `TargetAttribute`, `AllAttributeData`, `FromAttributeData`,
`TryFromAttributeData` or `FromAttributeDataUnchecked`** — they are generated (`ADM0014`, generation is
skipped). Keyword names (`Event`, `Class`) are fine.

## Reusing a shape with `[NestedModel]`

A `[NestedModel]` parameter is populated by the nested model's `FromAttributeDataUnchecked`, from the
**outer** attribute's arguments, with no `TargetAttribute` check. The outer attribute does not have to derive
from the nested model's target; it only has to use the same constructor parameter and property names.

```csharp
[Generate(typeof(LogAttribute))]
readonly partial record struct LogAttributeData(
[Argument("level")] [Property(DefaultValue = 99)] int Level,
[Argument("messageTemplate")] [Property] string? MessageTemplate,
[Argument("eventId")] [Property(DefaultValue = -1)] int EventId,
[Property] string? Name
);

// InfoAttribute has messageTemplate, eventId and Name, but no level.
[Generate(typeof(InfoAttribute))]
readonly partial record struct InfoAttributeData([NestedModel] LogAttributeData Log);
```

- The nested model's `Exists` is always `true`; a mapping the outer attribute does not declare (`level`
here) takes its `DefaultValue`. `ADM0013` (info) lists such mappings, so an intended absence is visible
and an accidental one is caught.
- Map nested properties **by name**. A constructor index means a different parameter on each attribute
(`ADM0015`).
- `MatchByInheritance = true` is for using a model on its own against derived attributes. A nested model
does not need it.
- Call `FromAttributeData`/`TryFromAttributeData` from generator code. `FromAttributeDataUnchecked` skips the
type check and is for nested models.

## Diagnostics

| Rule | Severity | Meaning and fix |
| --- | --- | --- |
| `ADM0001` | Error | The `[Generate]` target cannot be resolved. Use `typeof`, a resolvable full name, or a type-library `…FullName` constant. |
| `ADM0002` | Error | Unsupported property type (array, pointer, function pointer). |
| `ADM0003` | Error | `[Argument("name")]`/`[Argument(index)]` names no constructor parameter of the target. |
| `ADM0004` | Error | A `[NestedModel]` type is not itself a `[Generate]` model. |
| `ADM0005` | Error | A `DefaultValue` cannot be emitted for the property type. |
| `ADM0006` | Error | A non-nullable reference property has no default. Add a `DefaultValue` or make it nullable. |
| `ADM0007` | Error | `AutoDiscover` needs the `typeof` target overload. |
| `ADM0008` | Error | A `[GenericTypeArgument]` property must be `TypeIdentity`. |
| `ADM0009` | Error | An `IsEnum` property must be `string`/`string?`. |
| `ADM0010` | Error | An `ISymbol`/`Type` property is not cacheable; use `TypeIdentity` or a string. |
| `ADM0011` | Warning | A mapped name matches an attribute member only when case is ignored, so it is never read. Fix the case. |
| `ADM0012` | Warning | The mapped named argument is not a public settable member of the attribute, or a bare `[Argument]` implies a constructor parameter it lacks. Map the member that actually carries the value. |
| `ADM0013` | Info | A nested model property reads only names the outer attribute lacks, so it always takes its default. Expected for a deliberately absent value; otherwise rename to match. |
| `ADM0014` | Error | A property has a generated member's name. Rename the parameter. |
| `ADM0015` | Warning | A nested model reads a constructor index that names a different parameter on the outer attribute. Map it by name. |

## Testing a model

Test the attribute side and the model side separately, against what Roslyn actually produces. Use
`AttributeDataFactory` from `Purview.SourceGeneratorFramework.Testing` to compile an application, and the
`AttributeDataAssertions` from the TUnit package to pin each argument by ordinal, parameter name or named
argument, including how it was supplied (`AttributeArgumentForm`):

```csharp
var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] };
var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\", Name = \"N\")]", options);

await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}");
await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted);
await Assert.That(attribute).HasNamedArgument("Name", "N");

var model = InfoAttributeData.FromAttributeData(attribute);
await Assert.That(model.Log.MessageTemplate).IsEqualTo("custom {Value}");
await Assert.That(model.Log.Level).IsEqualTo(99);
```

Cover, for each model: positional, by-name and named-argument applications; an application omitting every
optional argument (proves the `DefaultValue`s); for a nested model, an outer attribute that lacks a mapped
member; and the nested target's own `FromAttributeData` returning `Empty` for the outer attribute.
`FromUsage` places the application on a declaration its `AttributeUsage` allows and throws on a compile
error, so a misspelt argument name fails the test. `FromSource` reads applications from a full source.

Generated-code assertions (`Contains("FromAttributeDataUnchecked(attributeData)")`) prove *what* the
generator emits; the tests above prove the model *reads the right values*. Prefer the latter.

## Related skills

`purview-sourcegenerator-testing` for the runner layer, `AttributeDataFactory` and `AttributeArguments`;
`purview-sourcegenerator-tunit-tests` for the assertion extensions; `purview-sourcegenerator-pipeline` for
holding models in an incremental pipeline; `purview-sourcegenerator-diagnostics` for the other analyzer
families.
22 changes: 18 additions & 4 deletions .agents/skills/purview-sourcegenerator-diagnostics/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: purview-sourcegenerator-diagnostics
description: >-
Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD or TLB diagnostic,
Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD, TLB or ADM diagnostic,
when choosing which code fix applies, or when a source generator, diagnostic analyzer or code fix
provider is silently not running at all.
metadata:
Expand Down Expand Up @@ -106,8 +106,21 @@ Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD00
## Type-library and attribute-model diagnostics

The bundled generators carry their own families: `TypeLibraryValidationAnalyzer`
(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model validation analyzers. They are
documented on their own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`.
(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model analyzers. They are documented on their
own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`.

The attribute-data-model rules come from three analyzers:

| Rules | Analyzer | Covers |
| --- | --- | --- |
| `ADM0001`–`ADM0009` | `AttributeDataModelValidationAnalyzer` | the target, property types, defaults, explicit constructor members |
| `ADM0010` | `AttributeDataModelSymbolPropertyAnalyzer` | non-cacheable `ISymbol`/`Type` properties |
| `ADM0011`–`ADM0015` | `AttributeDataModelNamingAnalyzer` | mapped names the generated code would never find, generated-member name clashes, `[NestedModel]` mappings the outer attribute does not line up with |

Treat the naming rules as real defects: the generated code reads names case-sensitively and silently falls
back to a default, so a naming mistake never fails the build on its own. `ADM0013` is `Info` because a
nested model may deliberately read a value the outer attribute does not have; check each one. The
`purview-sourcegenerator-attribute-data-models` skill has the full table and the fix for each rule.

## Code fixes

Expand Down Expand Up @@ -139,4 +152,5 @@ Most rules above have a fix in `Purview.SourceGeneratorFramework.CodeFixers`:
## Related skills

`purview-sourcegenerator-codewriter` for the emission patterns the `CodeWriter` rules push you
towards, and `purview-sourcegenerator-testing` for proving a pipeline actually caches.
towards, `purview-sourcegenerator-testing` for proving a pipeline actually caches, and
`purview-sourcegenerator-attribute-data-models` for the `ADM` rules.
12 changes: 8 additions & 4 deletions .agents/skills/purview-sourcegenerator-packaging/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,14 @@ identity. Both are `IsRoslynComponent`; only the generator references the framew
- The referenced component is returned as **its own merged assembly** and is never IL-merged into the
dependent one — that would duplicate its types, including `InternalsVisibleTo`-visible internals,
inside a second analyzer in the same host.
- The framework assembly is declared as copy-to-output content beside the generator's `bin`, and
content with `CopyToOutputDirectory` flows transitively through `ProjectReference`. That is what
lets Visual Studio load the dependent code-fix provider from its own `bin` without a
`FileNotFoundException` for the framework assembly.
- The framework assembly is copied beside the generator's `bin` (a plain copy, not copy-to-output
content). A dependent component that references the generator as an assembly gets it copied
beside its own `bin` by ordinary assembly-reference resolution, which is what lets Visual Studio
load the dependent code-fix provider without a `FileNotFoundException` for the framework assembly.
- A project that references a component only as an analyzer (`ReferenceOutputAssembly="false"`)
never receives the framework assembly, and neither does any application downstream of it. Since
1.2.2 it no longer leaks into app build output, `deps.json`, publish output, single-file bundles or
Blazor WebAssembly `_framework`, where it broke Native AOT and WebAssembly AOT publishing.
- Visual Studio's project system resolves an `OutputItemType=Analyzer` project reference to the
referenced project's **unmerged** `bin` assembly and adds it to the compiler's analyzers — the
command-line build does not. `Purview.BuildSdk` removes that item before adding the resolved
Expand Down
44 changes: 42 additions & 2 deletions .agents/skills/purview-sourcegenerator-testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ name: purview-sourcegenerator-testing
description: >-
Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes or
refactorings built on Purview.SourceGeneratorFramework - picking the right runner and base class,
configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, and asserting
incremental pipeline caching stage by stage.
configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, testing attribute-data
models against real AttributeData with AttributeDataFactory, and asserting incremental pipeline caching
stage by stage.
metadata:
product: Purview.SourceGeneratorFramework
repository: purview-dev/sourcegenerator-framework
Expand Down Expand Up @@ -167,8 +168,47 @@ an assertion, nor calling a helper that is handed the result — an extension on
parameter: both hand the same instance back, so `await Assert.That(result).SomeAssertion()` and
`result.AssertNoGenerationExceptions()` are not flagged.

## Testing attribute applications and attribute-data models

A generator that reads attributes has two halves to test: what Roslyn gives it for an application, and what
its parsing (usually a generated attribute-data model) makes of that. `AttributeDataFactory` produces the
real `AttributeData` without running a generator; `AttributeArguments` describes it.

```csharp
var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] };

// One application. Placed on a class, or on the first declaration its AttributeUsage allows.
AttributeData attribute = AttributeDataFactory.FromUsage("[Log(\"text\", eventId: 7, Name = \"N\")]", options);

// Every application on a type, or on one of its members.
var onMember = AttributeDataFactory.FromSource(source, "Sample.IEvents", "Raised", options);

var arguments = AttributeArguments.From(attribute);
arguments.TryGetConstructorArgument(0, out var byOrdinal); // #0 message: "text"
arguments.TryGetConstructorArgument("eventId", out var byName); // Form = NamedParameter
arguments.TryGetNamedArgument("Name", out var named); // Form = NamedArgument
arguments.ToString(); // [LogAttribute(#0 message: "text", #1 eventId: 7 (by name), Name = "N")]

var model = LogAttributeData.FromAttributeData(attribute);
```

- References and usings come from the options: `AdditionalAssemblyTypes` for the assembly declaring the
attribute, `AdditionalNamespaces` for its namespace, or declare the attribute in `AdditionalSources`.
- A compilation error throws `InvalidOperationException` listing the errors, so a misspelt parameter name
fails the test rather than producing an application with a missing argument.
- `AttributeArgumentForm` tells `Positional`, `NamedParameter` (`message: "x"`), `Defaulted` (an optional
parameter that was not written) and `NamedArgument` (`Name = "x"`) apart, which `AttributeData` alone
cannot.
- `AttributeArgument.ValueEquals` compares numbers across types, enums by value or member name, `typeof`
by `Type`/`TypeIdentity`/name, and arrays element-wise. Names are case-sensitive, as generated models are.
- The TUnit assertions over these (`HasConstructorArgument`, `HasNamedArgument`, …) are in
`purview-sourcegenerator-tunit-tests`; what to cover for a model is in
`purview-sourcegenerator-attribute-data-models`.

## Best practices

- **Test attribute parsing against real `AttributeData`** (`AttributeDataFactory`), not only by asserting
on emitted parsing code: emitted code can look right and still read the wrong name.
- **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with
`ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps.
- **Generator references**: to use a generated type in the test project AND pass the generator type to a
Expand Down
18 changes: 17 additions & 1 deletion .agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ name: purview-sourcegenerator-tunit-tests
description: >-
Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes or
refactorings built on Purview.SourceGeneratorFramework - choosing the correct TUnit base class and
method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions, and
method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions (including
attribute-argument assertions for testing attribute-data models), and
modernising existing tests.
metadata:
product: Purview.SourceGeneratorFramework.Testing.TUnit
Expand Down Expand Up @@ -125,6 +126,21 @@ when awaited.
```
- **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`,
`DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results.
- **`AttributeDataAssertions`** — on an `AttributeData` (typically from `AttributeDataFactory`):
`HasConstructorArgument(ordinal, expected)`, `HasConstructorArgument(parameterName, expected[, form])`,
`DoesNotHaveConstructorArgument(parameterName)` (defaulted or absent), `HasNamedArgument(name, expected)`,
`DoesNotHaveNamedArgument(name)`. Each returns the matched `AttributeArgument`, and a failure lists every
argument of the application with how it was supplied. Pin the attribute side with these before asserting
on the model, so a failure says which half is wrong:
```csharp
var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\")]", options);

await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}");
await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted);
await Assert.That(attribute).DoesNotHaveNamedArgument("Name");

await Assert.That(InfoAttributeData.FromAttributeData(attribute).Log.MessageTemplate).IsEqualTo("custom {Value}");
```
- **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`.
- **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)`
(whitespace-flattened string comparison).
Expand Down
Loading
Loading