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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ npx skills add purview-dev/sourcegenerator-framework
| `skills/purview-sourcegenerator-testing/` | the runner layer, `CodeQuery`, options, stage-by-stage cache tests |
| `skills/purview-sourcegenerator-tunit-tests/` | which TUnit base class and method, and the assertion extensions |
| `skills/purview-sourcegenerator-packaging/` | the self-contained merged analyzer, embedding a generator in another package, Roslyn compatibility |
| `skills/purview-sourcegenerator-attribute-data-models/` | declaring `[Generate]` attribute-data models: argument mapping, defaults, `[NestedModel]`, the `ADM` rules, testing a model with `AttributeDataFactory` |
| `skills/purview-sourcegenerator-diagnostics/` | what a `PSGFR`/`PSGF`/`PRSGD` diagnostic means, and why a component is silently not running |

Every skill carries the `purview-sourcegenerator` prefix. Skills install into one flat directory per
Expand Down
63 changes: 63 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,68 @@ that version to nuget.org.
This file was reconstructed from the conventional-commit history at 1.1.1, so entries before that
release summarise what the commits recorded rather than what was written at the time.

## [1.3.0] - 2026-11-03

### Changed

- A bare `[Argument]` on an attribute-data model parameter now reads the constructor parameter named after
the record parameter, camel-cased (`MessageTemplate` → `messageTemplate`), as `ArgumentAttribute` has
always documented. It previously added no constructor source at all, so the property fell back to reading
a *named* argument of the same name. A model relying on that should use `[Property]` instead; the naming
analyzer reports a bare `[Argument]` whose implied parameter does not exist (`ADM0012`).

### Fixed

- `AttributeDataModelGenerator` emitted a local variable named after each property, camel-cased, without
escaping it. A property whose camel-cased name is a C# keyword (`Event`, `Class`, `Default`) produced
`var event = …` and did not compile, and a property named `AttributeData` shadowed the parse method's
parameter. Keyword names are now emitted with `@`, and `AttributeData` gets a distinct local.
- The example generator's `LogAttributeData` mapped `Message` with `[Property]`, but `LogAttribute.Message`
has a private setter, so the message only ever arrives through the constructor and `[Log("text")]` lost
it. It now reads the `message` constructor parameter.
- The example generator's mirror of the emitted `GenerateServiceAttribute` declared `Name` with an
`internal init` setter, unlike the emitted attribute's public setter. Found by `ADM0012`.

- `AttributeDataModelGenerator` populated a `[NestedModel]` parameter by calling the nested model's
public `FromAttributeData(AttributeData)`, which begins with its own `TargetAttribute` check. When the
outer attribute was not the nested model's target (directly or through `MatchByInheritance`), the check
always failed and the nested model was always `Empty`: every value was `default(T)` and no
`[Property]`/`[Argument]` `DefaultValue` was applied. A level-shortcut attribute such as `[Info("…")]`
nesting the model of a general `[Log]` attribute lost its message template, event ID and name. Every
generated model now has a `FromAttributeDataUnchecked(AttributeData)` method holding the argument
reading without the target check, and nested models call it. The nested model is read from the outer
attribute's constructor and named arguments, `Exists` is `true`, and a mapped argument the outer
attribute does not declare takes its `DefaultValue`. The guarded `FromAttributeData` and
`TryFromAttributeData` overloads keep their check and their behaviour; they now delegate to
`FromAttributeDataUnchecked` after it.

### Added

- **`AttributeDataModelNamingAnalyzer` (`ADM0011`–`ADM0015`).** The generated code reads names
case-sensitively and falls back to a default when a name is missing, so a mapping mistake compiled
cleanly and silently produced default values. The analyzer mirrors the generator's source resolution
and reports: a mapped name that matches an attribute member only by case (`ADM0011`, warning); a named
source that is not a public settable member, or a bare `[Argument]` whose implied parameter does not
exist (`ADM0012`, warning); a `[NestedModel]` property that reads only names the outer attribute lacks
(`ADM0013`, info); a property named like a generated member, for which generation is now skipped
(`ADM0014`, error); and a nested model reading a constructor index that names a different parameter on
the outer attribute (`ADM0015`, warning). `ADM0003` is unchanged.
- **Attribute testing helpers in `Purview.SourceGeneratorFramework.Testing`.** `AttributeDataFactory`
compiles an attribute application (`FromUsage`, placed on a declaration its `AttributeUsage` allows) or
a source (`FromSource`) into real `AttributeData`, throwing on compilation errors. `AttributeArguments`
describes each argument as an `AttributeArgument` — name, ordinal, value, and `AttributeArgumentForm`
(`Positional`, `NamedParameter`, `Defaulted`, `NamedArgument`) — with case-sensitive lookups and
value comparison across numeric types, enum members, `typeof` arguments and arrays.
- **`AttributeDataAssertions` in `Purview.SourceGeneratorFramework.Testing.TUnit`:**
`HasConstructorArgument` by ordinal or parameter name (optionally with the expected form),
`DoesNotHaveConstructorArgument`, `HasNamedArgument` and `DoesNotHaveNamedArgument`. A failure lists
every argument of the application.
- The `purview-sourcegenerator-attribute-data-models` skill: mapping methodology, nesting, the `ADM`
rules and how to test a model. The diagnostics, testing and TUnit skills cover the new rules and helpers.
- `LogEventAttribute` / `InfoEventAttribute` and their models in the example generator: a level-shortcut
attribute that does not derive from the attribute its nested model targets, with tests that use
`AttributeDataFactory` and the attribute-argument assertions against the generated models.

## [1.2.2] - 2026-10-27

### Fixed
Expand Down Expand Up @@ -152,6 +214,7 @@ release summarise what the commits recorded rather than what was written at the
- Removed the separate `Shared` assembly; its sources are link-compiled into the framework.

[Unreleased]: https://github.com/purview-dev/sourcegenerator-framework/compare/v1.2.0...HEAD
[1.3.0]: https://github.com/purview-dev/sourcegenerator-framework/compare/v1.2.2...v1.3.0
[1.2.0]: https://github.com/purview-dev/sourcegenerator-framework/compare/v1.1.1...v1.2.0
[1.1.1]: https://github.com/purview-dev/sourcegenerator-framework/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/purview-dev/sourcegenerator-framework/compare/v1.0.0...v1.1.0
Expand Down
10 changes: 7 additions & 3 deletions docs/wiki/Analyzers.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,15 @@ Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD00
## Type-library and attribute-model diagnostics

The bundled generators carry their own diagnostic families, reported by the
`TypeLibraryValidationAnalyzer` (`TLB0001`–`TLB0019`) and the attribute-data-model validation
analyzers. These are documented on their feature pages:
`TypeLibraryValidationAnalyzer` (`TLB0001`–`TLB0019`) and the attribute-data-model analyzers:
`AttributeDataModelValidationAnalyzer` (`ADM0001`–`ADM0009`), `AttributeDataModelSymbolPropertyAnalyzer`
(`ADM0010`) and `AttributeDataModelNamingAnalyzer` (`ADM0011`–`ADM0015`). The naming rules catch mapped
names the generated code would never find at run time — a case-only mismatch, a named argument without a
public setter, a `[NestedModel]` mapping the outer attribute does not declare — which otherwise compile
cleanly and silently produce default values. These are documented on their feature pages:

- [Type-Library.md](Type-Library.md#validation)
- [Attribute-Data-Models.md](Attribute-Data-Models.md)
- [Attribute-Data-Models.md](Attribute-Data-Models.md) ([naming analyzer](Attribute-Data-Models.md#naming-analyzer))

## Code fixes

Expand Down
106 changes: 102 additions & 4 deletions docs/wiki/Attribute-Data-Models.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ The generator emits marker attributes into your compilation:
| `[Property]` | A record parameter is populated from a named attribute property (the property name is inferred from the parameter name unless overridden). When combined with `[Argument]` on the same parameter, the named argument is read first. |
| `[Property(string name)]` | Explicit named property source. |
| `[Property(..., DefaultValue = ...)]` | Fallback value when the named property is not present. |
| `[Argument]` | Populated from a constructor argument by parameter name. |
| `[Argument]` | Populated from the constructor parameter named after the record parameter, camel-cased (`MessageTemplate` → `messageTemplate`). |
| `[Argument(int index)]` | Constructor argument by parameter index. |
| `[Argument(string name)]` | Constructor argument by parameter name. |
| `[Argument(..., DefaultValue = ...)]` | Fallback when the constructor argument is not present. |
| `[NestedModel]` | Populated by recursively calling `FromAttributeData` on a nested generated model. |
| `[NestedModel]` | Populated from the same attribute by calling the nested generated model's `FromAttributeDataUnchecked`, which skips its target-attribute check. |
| `[Exclude]` | Skips auto-discovery for this parameter. |
| `[GenericTypeArgument]` | Populated from a generic type argument of the attribute class. |
| `[GenericTypeArgument(int index)]` | Generic type argument by position. |
Expand Down Expand Up @@ -62,12 +62,21 @@ readonly record struct RequiredAttributeData(bool Exists, bool AllowEmptyStrings
if (!TargetAttribute.Equals(attributeData.AttributeClass))
return Empty;

return FromAttributeDataUnchecked(attributeData);
}

public static RequiredAttributeData FromAttributeDataUnchecked(AttributeData attributeData)
{
attributeData.TryGetNamedArgument<bool>("AllowEmptyStrings", out var allowEmptyStrings);
return new(true, allowEmptyStrings);
}
}
```

`FromAttributeData` and `TryFromAttributeData` check the attribute's type against `TargetAttribute`.
`FromAttributeDataUnchecked` holds the argument reading without that check; it exists for
[nested models](#nested-models), and is rarely called directly.

## String target names

When the attribute type is not referenced in the generator project, pass the fully-qualified name as
Expand Down Expand Up @@ -153,8 +162,97 @@ public readonly partial record struct RequiredAttributeData(
);
```

Because `ValidationAttributeData` uses `MatchByInheritance = true`, it matches any attribute that
derives from `ValidationAttribute`, including `RequiredAttribute`.
The nested model is read from the **outer** attribute's constructor and named arguments, using its own
`[Argument]`/`[Property]` mappings, through its `FromAttributeDataUnchecked` method. Its own
`TargetAttribute` is not checked, because the outer model has already matched its attribute. So the
outer attribute does not have to derive from the nested model's target: it only needs to declare the
same constructor parameter and property names. `ValidationAttributeData` also uses
`MatchByInheritance = true`, so it can be used on its own against any attribute deriving from
`ValidationAttribute`.

A nested model populated this way always has `Exists` set to `true`. A mapped argument or property that
the outer attribute does not declare takes the nested model's `DefaultValue`, or `default(T)` when it
has none:

```csharp
[Generate(typeof(LogEventAttribute))]
public readonly partial record struct LogEventAttributeData(
[Argument("level")] [Property(DefaultValue = LogLevel.Information)] LogLevel Level,
[Argument("messageTemplate")] [Property] string? MessageTemplate,
[Argument("eventId")] [Property(DefaultValue = -1)] int EventId,
[Property] string? Name
);

// InfoEventAttribute does not derive from LogEventAttribute, and has no `level`.
[Generate(typeof(InfoEventAttribute))]
public readonly partial record struct InfoEventAttributeData([NestedModel] LogEventAttributeData Log);
```

`[InfoEvent("Cycle done: {Count}")]` gives `Log.MessageTemplate == "Cycle done: {Count}"`,
`Log.EventId == -1` and `Log.Level == LogLevel.Information`.

> [!IMPORTANT]
> Up to 1.2.2, the nested model was read through its guarded `FromAttributeData`. Unless the outer
> attribute matched the nested model's `TargetAttribute`, directly or through `MatchByInheritance`, the
> nested model was always `Empty`: every value was `default(T)` and no `DefaultValue` was applied.

## Naming rules

The generated code looks every name up **case-sensitively at run time**, and a name it cannot find yields
the property's `DefaultValue` (or `default(T)`). A mapping mistake therefore compiles cleanly and produces
default values. Follow these rules, and let the naming analyzer check them:

- A constructor source must name a parameter exactly: `[Argument("messageTemplate")]`, not
`[Argument("MessageTemplate")]`. A bare `[Argument]` uses the record parameter's name, camel-cased.
- A named source (`[Property]`, or a parameter with no mapping attribute) must name a **public settable**
property or field of the attribute. A property with a private or `internal` setter can only be read
through the constructor parameter that sets it.
- Prefer constructor sources by name over `[Argument(index)]`: an index silently moves to another parameter
when overloads change, and means a different parameter on each attribute a nested model is read from.
- A property must not be named `Exists`, `Empty`, `TargetAttribute`, `AllAttributeData`, `FromAttributeData`,
`TryFromAttributeData` or `FromAttributeDataUnchecked`; those are generated. Keyword names such as `Event`
or `Class` are supported.

### Naming analyzer

`AttributeDataModelNamingAnalyzer` mirrors the generator's source resolution and reports at the mapping:

| Rule | Severity | Reported when |
| --- | --- | --- |
| `ADM0011` | Warning | A mapped constructor parameter or named argument matches an attribute member only when case is ignored. |
| `ADM0012` | Warning | A named source is not a public settable member of the attribute, or a bare `[Argument]` implies a constructor parameter the attribute lacks. |
| `ADM0013` | Info | A `[NestedModel]` property reads only names the outer attribute lacks, so it always takes its default. |
| `ADM0014` | Error | A property has the name of a generated member. Generation is skipped for the model. |
| `ADM0015` | Warning | A `[NestedModel]` property reads a constructor index that names a different parameter on the outer attribute. |

An explicit `[Argument("name")]` or `[Argument(index)]` that matches nothing at all remains `ADM0003`.
`ADM0013` is informational because a nested model may read a value the outer attribute deliberately does
not have (the `level` of a `[Log]` model nested in an `[Info]` model); check each one.

## Testing a model

Test a model against the `AttributeData` Roslyn really produces. `AttributeDataFactory`
(`Purview.SourceGeneratorFramework.Testing`) compiles an application without running a generator, and the
TUnit `AttributeDataAssertions` pin each argument by ordinal, parameter name or named argument, including
whether it was written positionally, by parameter name, as a named argument, or defaulted:

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

await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}");
await Assert.That(attribute).DoesNotHaveNamedArgument("Name");

var model = InfoEventAttributeData.FromAttributeData(attribute);
await Assert.That(model.Log.MessageTemplate).IsEqualTo("custom {Value}");
await Assert.That(model.Log.EventId).IsEqualTo(-1); // DefaultValue, not default(int)
await Assert.That(model.Log.Level).IsEqualTo(LogLevel.Information);
```

For each model, cover positional, by-name and named-argument applications, an application that omits every
optional argument, and — for a nested model — an outer attribute missing a mapped member. See
[Testing.md](Testing.md#testing-attribute-applications) for the factory and
[Testing-TUnit.md](Testing-TUnit.md#assertion-extensions) for the assertions.

## Generic type arguments

Expand Down
29 changes: 29 additions & 0 deletions docs/wiki/Testing-TUnit.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ All assertion extensions are under `Purview.SourceGeneratorFramework.Testing.TUn
- `HasDiagnostic` / `HasDiagnostics` / `HasNoDiagnostics` / `DoesNotHaveDiagnostic` /
`HasNoErrorDiagnostics`.
- `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`.
- `HasConstructorArgument(ordinal | parameterName, expected[, form])` / `DoesNotHaveConstructorArgument` /
`HasNamedArgument` / `DoesNotHaveNamedArgument` — on an `AttributeData`; see
[Attribute argument assertions](#attribute-argument-assertions).
- `GeneratesCode(expected)` / `ContainsGeneratedCode(expected)` (whitespace-flattened).

The `CodeQuery` assertions operate on a `CodeQuery` directly, so they accept a query from any test
Expand All @@ -231,6 +234,32 @@ CodeQueryResult<ClassDeclarationSyntax> attributeClass = await Assert.That(query
await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String));
```

### Attribute argument assertions

`AttributeDataAssertions` assert on an `AttributeData`, typically one compiled by `AttributeDataFactory`
from the Testing package. They are the way to test attribute parsing — usually a generated attribute-data
model — against real input: pin the application first, then the model, so a failure says which half is
wrong.

```csharp
var attribute = AttributeDataFactory.FromUsage("[InfoEvent(\"custom {Value}\", Name = \"N\")]", options);

await Assert.That(attribute).HasConstructorArgument(0, "custom {Value}"); // by ordinal
await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); // by parameter name
await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted);
await Assert.That(attribute).HasNamedArgument("Name", "N");
await Assert.That(attribute).DoesNotHaveNamedArgument("EventId");

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

Names are matched case-sensitively, as generated models match them. Values compare with
`AttributeArgument.ValueEquals`: numbers across numeric types, enums by value or member name (`"High"`,
`"Level.High"`, `"Sample.Level.High"`), `typeof` arguments by `Type`, `TypeIdentity` or name, arrays
element-wise. Each assertion returns the matched `AttributeArgument`, and a failure lists every argument of
the application, for example
`[InfoEventAttribute(#0 messageTemplate: "custom {Value}", Name = "N")]`.
## Incremental cache tests

`GenerateIncrementalAsync` proves the pipeline caches stage-by-stage (first run `New`, identical rerun
Expand Down
Loading
Loading