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.
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.
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 itemMSBuild 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"
}
}Runs before the project file is evaluated. Sets overridable defaults:
- Chains to
Microsoft.NET.Sdkvia<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" /> - Sets analyzer project defaults:
TargetFramework=netstandard2.0,LangVersion=14,EnforceExtendedAnalyzerRules=true,IncludeBuildOutput=false,PackageType=Analyzer, embedded PDB, nullable enabled - Initialises
ScribeSdkIncludeStubstotrue(opt-out via<ScribeSdkIncludeStubs>false</ScribeSdkIncludeStubs>) - Imports
Scribe.SolutionAnalyzer.propsfor solution-local analyzer auto-pack configuration - Imports
Scribe.LocalDev.propsfor 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.
Runs after the project file. Enforces packaging behaviour:
- Chains to
Microsoft.NET.Sdktargets - Auto-includes
Stubs.csas aCompileitem withLink="Generated\Stubs.cs"(invisible in Solution Explorer). Guarded byScribeSdkIncludeStubs=true. _ScribeSdkAddAnalyzerDllstarget: Places the analyzer DLL intoanalyzers/dotnet/cs/in the NuGet package_ScribeSdkAddAnalyzerDependenciestarget: Bundles private NuGet dependencies alongside the analyzer DLL. ExcludesMicrosoft.CodeAnalysis.*DLLs (provided by the compiler host).- Sets
CopyLocalLockFileAssemblies=trueto enable dependency bundling - Imports
Scribe.SolutionAnalyzer.targetsfor solution-local override file generation - Imports
Scribe.LocalDev.targetsfor version override wildcard import
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.
Scribe/build/
Scribe.SolutionAnalyzer.props <- Configuration (early phase)
Scribe.SolutionAnalyzer.targets <- Override file generation (late phase)Scribe.SolutionAnalyzer.props runs when ScribeSolutionAnalyzer=true:
- Generates a timestamp version
0.0.0-dev.yyyyMMddHHmmss— forces Visual Studio to reload analyzers on every build - Disables NBGV (
NerdbankGitVersioningEnabled=false) — version is managed by the SDK - Enables auto-pack via
GeneratePackageOnBuild=true - Normalises
$(ArtifactsPath)to a guaranteed trailing separator (_ScribeSaArtifactsDir) for safe path concatenation - Redirects pack output to
$(ArtifactsPath)packages/ - Registers as NuGet source via
RestoreAdditionalProjectSources(for the analyzer project itself)
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.
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.
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 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.
Scribe.LocalDev.props runs early in MSBuild evaluation — before Directory.Packages.props and before most project-level properties.
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'.
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):
-
Sentinel file (recommended): Create
.<producer>.scribein$(ScribeRoot)— e.g..scribe.scribeto build Scribe locally,.hermetic.scribeto build Hermetic locally. Delete to deactivate. Add to.gitignorevia*.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.gitignoreline covers every producer. -
Explicit property:
-p:IsLocalScribe=trueon the command line — consumer infra only. Does not activate any specific producer; pair with a-p:IsLocalProducer=trueor a sentinel. -
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.
When active, derives the shared artifacts and packages directories from $(ScribeRoot):
<ScribeArtifactsDir>$(ScribeRoot)\.artifacts\</ScribeArtifactsDir>
<ScribePackagesDir>$(ScribeArtifactsDir)packages\</ScribePackagesDir>
<RestoreAdditionalProjectSources>
$(RestoreAdditionalProjectSources);$(ScribePackagesDir)
</RestoreAdditionalProjectSources>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>Scribe.LocalDev.targets runs late — after Directory.Packages.props has declared all PackageVersion Include items.
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.
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.targetsContents:
<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).
The _ScribeLocalDevCleanupStale target runs BeforeTargets="Build" on the trigger project when $(IsLocalProducer) != true and $(ScribesName) != ''. It deletes:
- The stale
$(ScribesName).Directory.Packages.targetsoverride file (if present). - Stale
-dev.*packages matching each ID in$(ScribeLocalDevPackageNames)(.nupkgand.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.
The design supports arbitrary depth chains:
Scribe -> Hermetic -> MyApp- Scribe ships LocalDev files in its NuGet package.
- 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
- Auto-packs to
- Hermetic re-exports the LocalDev files inside its own NuGet package, so consumers get them transitively.
- 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.
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.
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.propswhen the.{ScribesName}.scribesentinel marks the project asIsLocalProducer=true(Library / Companion / standalone Analyzer producer-mode pack).Scribe.Sdk/Sdk/Phases/Meta.targetsunconditionally 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.
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 fileThe packages/ subdirectory is registered as a NuGet source. The override file is imported via wildcard.