Skip to content

Latest commit

 

History

History
353 lines (245 loc) · 16.5 KB

architecture-infrastructure.md

File metadata and controls

353 lines (245 loc) · 16.5 KB

How the Infrastructure Works

Internal architecture of Scribe's build infrastructure — the Scribe SDK, Solution-Local Analyzer, and LocalDev systems. Read this if you're contributing to Scribe or want to understand the MSBuild mechanics.

For setup instructions, see Project Setup & Infrastructure.


Scribe SDK

The Scribe SDK (BulletsForHumanity.Scribe.Sdk) is a custom MSBuild SDK that wraps Microsoft.NET.Sdk and auto-configures all boilerplate for Roslyn analyzer/generator projects.

Package Layout

BulletsForHumanity.Scribe.Sdk.nupkg
  Sdk/
    Sdk.props               <- Chains to Microsoft.NET.Sdk, sets analyzer defaults
    Sdk.targets             <- Auto-includes Stubs.cs, defines packaging targets
  build/
    Scribe.LocalDev.props   <- Shared LocalDev infrastructure (early phase)
    Scribe.LocalDev.targets <- Shared LocalDev infrastructure (late phase)
    Scribe.SolutionAnalyzer.props   <- Solution-local analyzer support (early phase)
    Scribe.SolutionAnalyzer.targets <- Solution-local analyzer support (late phase)
  content/
    Stubs.cs                <- netstandard2.0 polyfills, injected as Compile item

SDK Resolution

MSBuild resolves custom SDKs from NuGet packages that contain Sdk/Sdk.props and/or Sdk/Sdk.targets. Consumers declare the SDK version in global.json:

{
  "msbuild-sdks": {
    "BulletsForHumanity.Scribe.Sdk": "0.3.0"
  }
}

Sdk.props (Early Phase)

Runs before the project file is evaluated. Sets overridable defaults:

  1. Chains to Microsoft.NET.Sdk via <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
  2. Sets analyzer project defaults: TargetFramework=netstandard2.0, LangVersion=14, EnforceExtendedAnalyzerRules=true, IncludeBuildOutput=false, PackageType=Analyzer, embedded PDB, nullable enabled
  3. Initialises ScribeSdkIncludeStubs to true (opt-out via <ScribeSdkIncludeStubs>false</ScribeSdkIncludeStubs>)
  4. Imports Scribe.SolutionAnalyzer.props for solution-local analyzer auto-pack configuration
  5. Imports Scribe.LocalDev.props for sentinel file detection and local NuGet source registration

All properties can be overridden by the consuming .csproj because they're set in the early phase.

Sdk.targets (Late Phase)

Runs after the project file. Enforces packaging behaviour:

  1. Chains to Microsoft.NET.Sdk targets
  2. Auto-includes Stubs.cs as a Compile item with Link="Generated\Stubs.cs" (invisible in Solution Explorer). Guarded by ScribeSdkIncludeStubs=true.
  3. _ScribeSdkAddAnalyzerDlls target: Places the analyzer DLL into analyzers/dotnet/cs/ in the NuGet package
  4. _ScribeSdkAddAnalyzerDependencies target: Bundles private NuGet dependencies alongside the analyzer DLL. Excludes Microsoft.CodeAnalysis.* DLLs (provided by the compiler host).
  5. Sets CopyLocalLockFileAssemblies=true to enable dependency bundling
  6. Imports Scribe.SolutionAnalyzer.targets for solution-local override file generation
  7. Imports Scribe.LocalDev.targets for version override wildcard import

Solution-Local Analyzer

The Solution-Local Analyzer infrastructure enables intra-solution analyzer development — analyzers that live inside the same solution as the projects they serve, without publishing to NuGet. It uses the same mechanism as LocalDev (version override files + NuGet source registration) but scoped to a single solution.

For setup instructions, see Solution-Local Analyzers.

File Layout

Scribe/build/
  Scribe.SolutionAnalyzer.props    <- Configuration (early phase)
  Scribe.SolutionAnalyzer.targets  <- Override file generation (late phase)

Props Phase (Early)

Scribe.SolutionAnalyzer.props runs when ScribeSolutionAnalyzer=true:

  1. Generates a timestamp version 0.0.0-dev.yyyyMMddHHmmss — forces Visual Studio to reload analyzers on every build
  2. Disables NBGV (NerdbankGitVersioningEnabled=false) — version is managed by the SDK
  3. Enables auto-pack via GeneratePackageOnBuild=true
  4. Normalises $(ArtifactsPath) to a guaranteed trailing separator (_ScribeSaArtifactsDir) for safe path concatenation
  5. Redirects pack output to $(ArtifactsPath)packages/
  6. Registers as NuGet source via RestoreAdditionalProjectSources (for the analyzer project itself)

Targets Phase (Late)

Scribe.SolutionAnalyzer.targets defines one target:

_ScribeSolutionAnalyzerOverride — Runs after Pack. Reads $(NuGetPackageVersion) (falling back to $(PackageVersion)) and generates a .Directory.Packages.targets file in $(ArtifactsPath) containing a PackageVersion Update entry:

<Project>
  <!-- Auto-generated by Scribe SolutionAnalyzer — do not edit. -->
  <ItemGroup>
    <PackageVersion Update="MyAnalyzer" Version="0.0.0-dev.20260406054112" />
  </ItemGroup>
</Project>

Consuming projects import this file via wildcard from $(ArtifactsPath) in their Directory.Build.targets. This is the same pattern as LocalDev's _ScribeLocalDevOverride target.

Relationship to LocalDev

Solution-Local Analyzer and LocalDev use the same underlying mechanism but at different scopes:

Concern LocalDev Solution-Local Analyzer
Scope Cross-repo Intra-solution
Trigger .<producer>.scribe sentinel (e.g. .scribe.scribe, .hermetic.scribe) ScribeSolutionAnalyzer=true
Version NBGV + timestamp suffix Timestamp-only (0.0.0-dev.yyyyMMddHHmmss)
Override files Generated .Directory.Packages.targets Same — generated .Directory.Packages.targets
Package directory Shared $(ScribeRoot)/.artifacts/packages/ Solution $(ArtifactsPath)packages/
Override file location $(ScribeRoot)/.artifacts/ $(ArtifactsPath)

Both features are independent and can coexist. A solution can have solution-local analyzers and also participate in a LocalDev chain.


LocalDev File Layout

The LocalDev infrastructure lives in Scribe/build/ and is shipped inside both the BulletsForHumanity.Scribe NuGet package and the BulletsForHumanity.Scribe.Sdk package:

Scribe/build/
  BulletsForHumanity.Scribe.props    <- NuGet auto-import entry point (early)
  BulletsForHumanity.Scribe.targets  <- NuGet auto-import entry point (late)
  Scribe.LocalDev.props              <- Actual logic (early phase)
  Scribe.LocalDev.targets            <- Actual logic (late phase)

NuGet Auto-Import Convention

NuGet automatically imports build/<PackageId>.props and build/<PackageId>.targets for any installed package. The entry-point files are thin wrappers that delegate to the Scribe.LocalDev.* files:

<!-- BulletsForHumanity.Scribe.props -->
<Import Project="$(MSBuildThisFileDirectory)Scribe.LocalDev.props" />

The Scribe.LocalDev.* files can also be imported directly from a sibling repo checkout, without requiring the NuGet package. This dual-import design enables the bootstrap chain.


Props Phase (Early)

Scribe.LocalDev.props runs early in MSBuild evaluation — before Directory.Packages.props and before most project-level properties.

Import Guard

Prevents double-import when both a NuGet package and a direct file import bring in the same file:

<PropertyGroup Condition="'$(_ScribeLocalDevImported)' == 'true'">
  <_ScribeLocalDevSkip>true</_ScribeLocalDevSkip>
</PropertyGroup>
<PropertyGroup Condition="'$(_ScribeLocalDevImported)' != 'true'">
  <_ScribeLocalDevImported>true</_ScribeLocalDevImported>
</PropertyGroup>

All subsequent property groups and imports are conditioned on '$(_ScribeLocalDevSkip)' != 'true'.

Sentinel File Detection

Two tiers of activation — one per-producer, one umbrella:

Per-producer ($(IsLocalProducer)): each producer declares $(ScribesName) in its Directory.Build.props (e.g. Scribe, Hermetic). .$(ScribesName).scribe (lowercased, e.g. .hermetic.scribe) at $(ScribeRoot) flips $(IsLocalProducer) on for that project. Producer mode gates auto-pack, timestamped versions, and override-file emission.

<PropertyGroup Condition="'$(ScribesName)' != '' and '$(ScribeRoot)' != ''">
  <_ScribeProducerSentinel>$(ScribeRoot)\.$(ScribesName.ToLowerInvariant()).scribe</_ScribeProducerSentinel>
</PropertyGroup>
<PropertyGroup Condition="'$(IsLocalProducer)' != 'true'
                          and '$(_ScribeProducerSentinel)' != ''
                          and Exists('$(_ScribeProducerSentinel)')">
  <IsLocalProducer>true</IsLocalProducer>
</PropertyGroup>

Umbrella ($(IsLocalScribe)): ANY .*.scribe sentinel at $(ScribeRoot) flips $(IsLocalScribe) on. The umbrella flag governs consumer-side infrastructure: NuGet source registration and override-file imports.

<ItemGroup>
  <_ScribeLocalSentinel Include="$(ScribeRoot)\.*.scribe" />
</ItemGroup>
<!-- MSBuild forbids @(ItemList) references in Condition attributes (MSB4099),
     so project the ItemGroup into a property first, then condition on it. -->
<PropertyGroup>
  <_ScribeLocalSentinelList>@(_ScribeLocalSentinel)</_ScribeLocalSentinelList>
</PropertyGroup>
<PropertyGroup Condition="'$(_ScribeLocalSentinelList)' != ''">
  <IsLocalScribe>true</IsLocalScribe>
</PropertyGroup>

Three activation methods (umbrella):

  1. Sentinel file (recommended): Create .<producer>.scribe in $(ScribeRoot) — e.g. .scribe.scribe to build Scribe locally, .hermetic.scribe to build Hermetic locally. Delete to deactivate. Add to .gitignore via *.scribe.

    Why .scribe? The suffix is brand-unique — zero risk of collision with .user (VS project user settings) or .local (Vite/Next.js/prettier env files) that a future tool might also scan. One .gitignore line covers every producer.

  2. Explicit property: -p:IsLocalScribe=true on the command line — consumer infra only. Does not activate any specific producer; pair with a -p:IsLocalProducer=true or a sentinel.

  3. Props file: <IsLocalScribe>true</IsLocalScribe> in Directory.Build.props or Directory.Solution.props.

Typo tolerance: $(ScribeName) (non-possessive) forwards to $(ScribesName) if only the former is set.

Path Setup

When active, derives the shared artifacts and packages directories from $(ScribeRoot):

<ScribeArtifactsDir>$(ScribeRoot)\.artifacts\</ScribeArtifactsDir>
<ScribePackagesDir>$(ScribeArtifactsDir)packages\</ScribePackagesDir>
<RestoreAdditionalProjectSources>
  $(RestoreAdditionalProjectSources);$(ScribePackagesDir)
</RestoreAdditionalProjectSources>

Auto-Pack

For the trigger project only — identified by $(MSBuildProjectName) == $(ScribesName) — and only when $(IsLocalProducer) == true, enables GeneratePackageOnBuild and redirects output to the shared packages directory:

<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageOutputPath>$(ScribePackagesDir)</PackageOutputPath>

Targets Phase (Late)

Scribe.LocalDev.targets runs late — after Directory.Packages.props has declared all PackageVersion Include items.

Override File Import

Uses a wildcard import to pick up all override files from the shared artifacts directory:

<Import
  Project="$(ScribeArtifactsDir)*.Directory.Packages.targets"
  Condition="'$(IsLocalScribe)' == 'true'
             and '$(ScribeArtifactsDir)' != ''
             and Exists('$(ScribeArtifactsDir)')" />

Why .targets and not .props? PackageVersion Update items must be evaluated after Directory.Packages.props declares the PackageVersion Include items. .props files are evaluated before .props imports finish; .targets files are evaluated after.

Override File Generation

The _ScribeLocalDevOverride target runs after Pack on the trigger project only, gated on $(IsLocalProducer) == true. The file is named after $(ScribesName) so a consumer can load it by string-concat:

$(ScribeArtifactsDir)$(ScribesName).Directory.Packages.targets
// e.g. .artifacts/Hermetic.Directory.Packages.targets

Contents:

<Project>
  <!-- Auto-generated by Scribe LocalDev — do not edit. -->
  <ItemGroup>
    <PackageVersion Update="MyFramework" Version="1.2.3-dev.20260405120000" />
  </ItemGroup>
</Project>

The version is read from $(NuGetPackageVersion) (set by Nerdbank.GitVersioning or the SDK).

Cleanup on Sentinel Removal

The _ScribeLocalDevCleanupStale target runs BeforeTargets="Build" on the trigger project when $(IsLocalProducer) != true and $(ScribesName) != ''. It deletes:

  • The stale $(ScribesName).Directory.Packages.targets override file (if present).
  • Stale -dev.* packages matching each ID in $(ScribeLocalDevPackageNames) (.nupkg and .snupkg).

Cleanup is scoped to this producer's own artifacts only — other producers' files are untouched. Removing a producer's .<producer>.user / .<producer>.local sentinel and rebuilding the producer normally is enough to clean its dev packages out of the shared artifacts folder.


Multi-Repo Chain

The design supports arbitrary depth chains:

Scribe  ->  Hermetic  ->  MyApp

How Chaining Works

  1. Scribe ships LocalDev files in its NuGet package.
  2. Hermetic references Scribe via NuGet. NuGet auto-imports Scribe's LocalDev files. Hermetic configures itself as a producer (trigger project, package names). When built in Local Scribe mode, it:
    • Auto-packs to /.artifacts/packages/
    • Generates /.artifacts/Hermetic.Directory.Packages.targets
  3. Hermetic re-exports the LocalDev files inside its own NuGet package, so consumers get them transitively.
  4. MyApp references Hermetic via NuGet. NuGet auto-imports Hermetic's targets, which import Scribe's LocalDev files. When built in Local Scribe mode, the wildcard import picks up Hermetic.Directory.Packages.targets.

Re-Exporting

A framework package that depends on Scribe can re-export the LocalDev files by including them in its own build/ directory:

<!-- In the framework's .csproj -->
<None Include="path/to/Scribe.LocalDev.props" Pack="true" PackagePath="build" />
<None Include="path/to/Scribe.LocalDev.targets" Pack="true" PackagePath="build" />

The framework's own NuGet auto-import files (build/Framework.props and .targets) delegate to them, just like Scribe's entry points do.


Version Suffixing

Every local pack appends a unique -dev.{stamp} suffix where {stamp} is yyyyMMddHHmmssfff (UTC, millisecond precision). Without this, NuGet's content-addressable cache would serve stale bits any time the consumer's Version string didn't change between builds — which is the common case both with NBGV (commit-hash unchanged when working in-place) and without NBGV (everyone shares the default 1.0.0).

Implementation lives in Scribe.LocalDev.targets as the _ScribeLocalDevStampVersion target. It runs before Pack, GenerateNuspec, and the override-file emitters in this same file plus Scribe.Sdk's _ScribeSdkMetaOverride. The target fires when _ScribeAutoStampVersion=true, set automatically by:

  • Scribe.LocalDev.props when the .{ScribesName}.scribe sentinel marks the project as IsLocalProducer=true (Library / Companion / standalone Analyzer producer-mode pack).
  • Scribe.Sdk/Sdk/Phases/Meta.targets unconditionally for Debug auto-pack (Meta is local-only by design — every Meta build is a local pack).

The stamp is idempotent: re-evaluation that already saw a -dev. suffix won't double-stamp. It composes cleanly with NBGV — 0.5.9-g{hash} becomes 0.5.9-g{hash}-dev.{stamp}. Without NBGV the bare 1.0.0 becomes 1.0.0-dev.{stamp}. Scribe is NBGV-friendly, not NBGV-dependent — pulling Scribe.Sdk into a project that doesn't use NBGV is fully supported; you'll just see versions anchored at 1.0.0-dev.* instead of {nbgv}-dev.*.

The stamp ensures:

  • Versions are unique per build (millisecond-precision so two builds in the same second still differ)
  • Versions sort chronologically
  • Versions are human-readable
  • No collision with published NuGet versions

Consumers (still typical Mish-style projects) need NOT add their own version-suffix target. If you have one inherited from older Scribe versions, you can delete it; the per-pack stamp now lives where it belongs — next to the pack itself.


Artifacts Directory Layout

When LocalDev is active, the shared /.artifacts/ directory looks like:

.artifacts/
  packages/
    MyFramework.1.2.3-dev.20260405120000.nupkg
    MyFramework.1.2.3-dev.20260405120000.snupkg
  MyFramework.Directory.Packages.targets    <- Version override file

The packages/ subdirectory is registered as a NuGet source. The override file is imported via wildcard.