Skip to content

feat: solution-local analyzer support (auto-pack & inject without LocalScribe) #8

Description

@crazycrank

Context

The Scribe SDK (#4) and LocalScribe infrastructure currently support one workflow well: standalone analyzer/generator packages developed in their own repository and consumed via NuGet (e.g. Hermetic consumed by Mish). This is the "framework" or "library" use case — the analyzer is a public product with its own release cycle.

But there's a second, equally common workflow that has no first-class support today: solution-local analyzers. These are analyzers and generators that live inside the solution they serve, generating code specific to that solution's domain. They are not published to NuGet. They are not consumed cross-repo. They exist to automate this codebase and nothing else.

The two kinds of analyzer projects

Kind Example Published? Dev mechanism
Standalone Hermetic (public framework) Yes, to NuGet LocalScribe for cross-repo dev
Solution-local A solution-specific generator that emits DTOs from domain models No Should "just work" intra-solution

Examples of solution-local analyzers

  • A generator that reads domain model types and emits mapping code, DTOs, or validation rules
  • An analyzer that enforces solution-specific coding conventions (e.g. "all command handlers must be nested inside their command type")
  • A generator that emits strongly-typed configuration accessors from appsettings schema
  • Hermetic itself, before it was extracted into a separate repo — it started as a solution-local generator inside Mish

The problem today

To use a solution-local analyzer, developers must:

  1. Build the analyzer project manually (or rely on build ordering)
  2. Reference it with <ProjectReference OutputItemType="Analyzer" ReferenceOutputAssembly="false"> — a verbose, error-prone incantation
  3. Manage TFM negotiation (SetTargetFramework="TargetFramework=netstandard2.0") since the analyzer targets netstandard2.0 but the consuming project targets net8.0+
  4. Handle dependency DLL exposure via GetDependencyTargetPaths / TargetPathWithTargetPlatformMoniker so the Roslyn host can load private deps
  5. Rebuild manually when the analyzer changes — no automatic rebuild-on-change

This is the same boilerplate wall that the SDK was designed to eliminate, but applied at the consumption side rather than the production side.

Goal

Add first-class support for solution-local analyzers to the Scribe SDK. When the SDK detects a marker on an analyzer project (e.g. a project property or reference pattern), it should automatically:

  1. Pack the analyzer on build — produce a local .nupkg without publishing to any feed
  2. Inject it as a local NuGet reference for consuming projects in the same solution
  3. Just work — no sentinel file, no cross-repo setup, no manual configuration

This closes the full dev loop for ~90% of analyzer workflows without requiring the LocalScribe mechanism.

Desired developer experience

<!-- Analyzer project — uses the SDK, marks itself as solution-local -->
<Project Sdk="BulletsForHumanity.Scribe.Sdk">
  <PropertyGroup>
    <ScribeSolutionAnalyzer>true</ScribeSolutionAnalyzer>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
    <PackageReference Include="BulletsForHumanity.Scribe" PrivateAssets="all" />
  </ItemGroup>
</Project>
<!-- Consuming project — references the analyzer like any other package -->
<ItemGroup>
  <PackageReference Include="MyAnalyzer" />
</ItemGroup>

The SDK handles the rest: auto-pack on build, local feed injection, version management, rebuild-on-change.

Design Considerations

Mechanism: auto-pack + local NuGet injection

Unlike the ProjectReference approach (which requires TFM negotiation and dependency exposure hacks), this uses NuGet's own resolution — the analyzer is packed into a proper .nupkg and placed in a solution-local package directory. Consuming projects reference it as a PackageReference, getting all the same analyzer wiring that any published analyzer package gets.

This is similar in spirit to LocalScribe's auto-pack mechanism but:

  • No .localscribe sentinel — triggered by ScribeSolutionAnalyzer=true on the project
  • No cross-repo concerns — everything stays within the solution
  • No version override files — version is managed automatically (e.g. always 0.0.0-local)
  • No shared .artifacts/ directory — uses a solution-local directory (e.g. .packages/)

Detection mechanism

Options for marking a project as a solution-local analyzer:

  1. Property-based<ScribeSolutionAnalyzer>true</ScribeSolutionAnalyzer> in the project. Simple, explicit.
  2. Convention-based — Any Scribe SDK project that is IsPackable=false (or has no PackageId) is assumed solution-local.
  3. Reference-based — Detect when a non-SDK project references a Scribe SDK project and auto-wire.

Recommendation: start with explicit property (#1).

Relationship to LocalScribe

This is not the same as LocalScribe. Key differences:

Concern LocalScribe Solution Analyzer
Scope Cross-repo (Scribe to Hermetic to Mish) Intra-solution
Trigger .localscribe sentinel file ScribeSolutionAnalyzer=true property
Published? Yes (eventually to NuGet) Never
Version management NBGV + override files Fixed local version (e.g. 0.0.0-local)
Package directory Shared .artifacts/packages/ Solution-local (e.g. .packages/)

Interaction with templates (#5)

The scribe-analyzer and scribe-generator templates should consider adding a --solution-local flag (or a separate template variant) that:

  • Adds <ScribeSolutionAnalyzer>true</ScribeSolutionAnalyzer>
  • Skips NuGet publishing configuration
  • Includes a sample consuming project with the correct PackageReference

What the SDK targets need to do

On the analyzer project side:

  • Detect ScribeSolutionAnalyzer=true
  • Auto-pack on build (not just on explicit dotnet pack)
  • Output .nupkg to a well-known solution-local directory
  • Use a fixed version (e.g. 0.0.0-local) to avoid cache/restore issues
  • Ensure the package contains analyzer DLLs in analyzers/dotnet/cs/ (already handled by SDK)

On the consuming project side:

  • A NuGet.config or Directory.Build.props adds the solution-local package directory as a NuGet source
  • Standard PackageReference resolution handles the rest
  • IDE picks up analyzer diagnostics and generated code automatically

Open questions

  1. Package directory location.packages/ at solution root? obj/ of the analyzer project? A well-known subdirectory?
  2. Version strategy — Fixed 0.0.0-local? Timestamp-based? Does NuGet cache invalidation work correctly?
  3. NuGet.config management — Should the SDK auto-generate/update it? Or require the developer to add the local source once?
  4. Multi-analyzer solutions — What if a solution has 3 solution-local analyzers? All pack to the same directory?
  5. IDE experience — Does VS/Rider pick up changes automatically when the .nupkg is rebuilt, or is a restore needed?

Acceptance criteria

  • A Scribe SDK project with ScribeSolutionAnalyzer=true auto-packs on build
  • Consuming projects can reference it via standard PackageReference
  • The analyzer's diagnostics and generated code appear in consuming projects without manual configuration
  • Private dependency DLLs are correctly bundled and resolved
  • Building the solution rebuilds and re-packs the analyzer when its source changes
  • No .nupkg files are published to any external feed
  • Works with dotnet build, dotnet test, and IDE (Visual Studio / Rider)
  • No LocalScribe sentinel, version overrides, or cross-repo setup required
  • Documentation: README or dedicated doc explaining solution-local analyzer setup
  • Design decisions documented: detection mechanism, version strategy, package directory

References

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions