Dependency Graph

What a package or NodeType depends on is what its compilation actually USED — computed by Roslyn, never declared and never inferred from names. The forward edges are written once, where the compile runs, carrying the identity of the build they were read from. The reverse direction ("everything that depends on X") is never stored as authored data: it is a closure the store keeps by itself, read through the query surface.

This page is the implementation plan and the record of what landed. The design it implements is MeshWeaver.Plugins Hosting/CiAffectedClosure.md → Next: the dependency graph from Roslyn, reversed in Postgres; its step (4) — CI selection reading the graph — lives in MeshWeaver.Plugins.

The forward edge (step 2)

What an edge is

One node per compiled source, holding that source's whole forward edge set:

path {NodeTypePath}/_Dependency/Forward
node type DependencyEdgeSet (registered by AddGraph, keeps no history)
content DependencyEdgeSet — Source, BuildMvid, StampVersion, InputDigest, Commit, Edges, IncludedWithoutUse
one edge DependencyEdge — Target, Kind, TargetBuildMvid, Evidence, EvidenceClass, TargetKind

Kind is an open vocabulary (DependencyEdgeKinds, policy open-vocabulary-string-constants):

kind target evidence class read from
NodeType the owning NodeType's path Static the SemanticModel walk (below)
Module the module assembly's simple name Static the EMITTED assembly's AssemblyRefs ∩ the installed module files (GetUsedAssemblyReferences() only for a caller with no image)
Platform a MeshWeaver.* host assembly's simple name Static the EMITTED assembly's AssemblyRef/TypeRef tables
Package package:{id} — the package id (#r "nuget:", or a shelf package for a module build) in its own namespace Static the emitted AssemblyRefs ∩ the package files
Dynamic {type}, {assembly} — * for a whole-assembly load, ? for an unknown part Dynamic a reflection call with a CONSTANT argument
DynamicUnresolved ?{api}, e.g. ?System.Type.GetType Dynamic a reflection call whose argument is not constant

Targets are unique within one set by construction (DependencyEdgeTargets): a NodeType target is a path (contains /), a module or platform target an assembly simple name (one runtime identity per name — when a module and a host assembly share one, the installed module is the edge), a package target package:{id} (a package id can equal an assembly name: Acme.Bundle shipping Acme.Widget.dll beside a module ASSEMBLY Acme.Bundle), a dynamic target {type}, {assembly} or ?{api}. DependencyEdgeExtraction.UniqueTargets proves it on every set written and fails LOUDLY on a collision, so a target-keyed reader — the Postgres projection's (source, target) key — never has to choose between two kinds. TargetKind says what a dynamic target IS — Platform, Module, Package, NodeType (a type in the source's own compilation), Framework (the BCL), or null when the compile cannot tell, which a reader must read as "may be the platform".

How Roslyn computes it

DependencyEdgeExtraction.Extract (MeshWeaver.Compiler) runs on the compilation the emit just used — after the source generators, before the emitted bytes are loaded — and is pure:

  1. Who owns each declared type. Every Code file the compile consumed is parsed on its own (parse only, no binding) and its declared types' metadata names are attributed to the file's owner — everything before its first /Source/ or /Test/ segment (CodeConventions.OwnerOfCodePath, the rule NodeTypeDependencyGraph.ForeignSourceOwners already used). @@ include text is attributed to the INCLUDED node's owner, not to the file it was spliced into. The emitted compilation is one concatenated tree with no namespace wrapper, so a type's metadata name in a per-file parse is its metadata name in the compilation.
  2. NodeType edges — a SemanticModel walk. For every name in the authored trees, the bound symbol's outermost containing type is looked up in that ownership map. A reference located in code the compiling type owns (its own files, the generated skeleton, its configuration) to a type another NodeType owns is an edge self → owner, with the referenced type names as evidence. References inside a foreign owner's code are NOT this type's edges — that owner's own compile records them, and the closure joins the two.
  3. Module edges — the emitted image's AssemblyRefs. Every AssemblyRef the compiler wrote that resolves (by full identity) to one of the mesh's installed modules is an edge self → module assembly, carrying the module's MVID as TargetBuildMvid — the same instrument as the platform and package edges (Platform and package edges below), for the same reasons. Compilation.GetUsedAssemblyReferences() is used only by a caller that hands no image. See What the extraction costs below for why it is not the default.
  4. Included but unused. A foreign owner whose code was compiled in but never referenced from the compiling type's own code is listed in IncludedWithoutUse, not as an edge — the inclusion is a declared source query, the edge is what Roslyn bound. The candidates are read off the consumed FILES (roots and includes), so a foreign file that declares no type still counts.

What "binds" covers: every simple name, plus the bindings no name spells — a foreach enumerator pattern (an extension GetEnumerator), an await awaiter, a deconstruction's Deconstruct, query clause operators, a collection initializer's Add, a target-typed new(), user-defined unary, binary and compound-assignment operators, an indexer (Make()[0], Make()?[0]), the Dispose/DisposeAsync a using calls on a value nobody names, a delegate invocation (Make()()), the record clone a with expression calls, a foreach's per-element conversion, and a user-defined implicit conversion. A bound type counts together with every type its construction mentions (type arguments, containing types' arguments, array elements), so an alias over an external generic (using AList = List<AThing>;) is still an edge to AThing's owner. A referenced type is looked up from its innermost declaration outwards, so a foreign record spliced into the compiling type's class (Host+AThing) stays the foreign owner's. A using directive is an import, not a use, and is skipped (hoisted directives sit in the skeleton, outside every attributed type). An @@ include spliced inside a namespace of the including file is attributed under the name it carries there, by its span in that file.

Known limits, each failing towards an extra edge rather than a missing one:

Telling these apart exactly needs each reference's file origin carried through source shaping. That is a change to the emitted input, and it moves every content key.

The walk is skipped entirely when the compile consumed no foreign-owned code, so a self-contained type pays two metadata-table reads and the dynamic scan's name filter, and nothing else. A node inside a foreign owner's code is skipped BEFORE it is bound, so the walk binds only the compiling type's own code.

What the extraction costs

Edge extraction runs after EVERY NodeType compile — the portal's runtime compile, the batch bake and the build-process bake (mw-plugin-test compile, which every gate shard runs) — so its price is paid per type, per portal, per CI shard. The first version doubled the compile: on the MeshWeaver.Plugins tree a gate shard's compile phase went from 530–554 s (platform set 3.1.11752/3.1.11754, before the extraction) to 1631–1987 s (3.1.11792 and later, after it) for the SAME 131 and 165 NodeTypes. Two costs dominated, measured locally over 30 compiled types (emit ≈ 6 s in total):

phase before after
the SemanticModel walk 5.4 s — it bound every node, foreign code included, and dropped foreign-located results afterwards 2.4 s — the location is checked first, so only the compiling type's own code is bound
module edges 3.4 s — GetUsedAssemblyReferences() re-binds and lowers every method body a second time ≈ 0 — the emitted AssemblyRefs, already read for the platform edges

The NodeType and platform/package/dynamic output is byte-identical before and after. The module edges changed only where GetUsedAssemblyReferences() over-counted: a module the compile merely looked into — its namespace imported, its extension methods considered — is no longer an edge of a type whose emitted code references nothing in it (measured: MeshWeaver.Markdown.Collaboration, which declares types in the MeshWeaver.Graph namespace, dropped out of 2 of 30 sets) — the same over-count the platform edges were moved off for. A module symbol the compiler ERASED from the image is still a module edge: an inlined constant (an enum member included), a call to a [Conditional] method or a [Conditional] attribute whose symbol is undefined — together with every module member inside that call's arguments and receiver, whoever declares the conditional method (Trace("x".Probe()) on a consumer's own method, Debug.Assert(…) compiled without DEBUG) — a module type that only appears in a constant expression ((int)default(Color)), and any name inside a nameof, a generic argument included. A change to that module changes or breaks the type's compile, and CI selection reads the module edge to know it. The candidates are nominated by name: every identifier inside a nameof, plus every identifier that spells a type name, a constant or a [Conditional] method of a module that is not yet an edge; and every name inside an invocation whose callee ERASES its call — a [Conditional] method, or a partial method declared without an implementation — known by name from EVERY module (an already-edged module's conditional method still erases another module's member in its arguments), the compilation's own source (decided by the declared symbol, so an aliased attribute counts) and the framework's Debug, Trace, Debugger and Contract, and confirmed by binding that one invocation. Code the compiler discards as constant-UNREACHABLE is covered the same way (#7449): the body of if (false) (the else of if (true)), the right operand of false && … and true || …, a constant ?:'s dead arm, the body of while (false) and of for (…; false; …) with its incrementors — a named const bool condition included. Measured under both optimisation levels (the bake emits Debug): every one of those shapes leaves NO AssemblyRef, so every name in such a branch is bound like an erased call's arguments, and so is every use no name spells (an extension GetEnumerator behind a foreach, an awaiter, a Deconstruct, a collection initializer's Add, a user-defined operator or conversion). Only a condition built solely from shapes a constant can take (literals, names, member accesses, parentheses, casts, default, sizeof, ?:, an opaque nameof, operators — never another call) is bound to ask whether it is constant. Only those nodes are bound, so this never costs a second binding pass.

🚨 Why not "a symbol whose containing assembly is another NodeType's assembly". A NodeType compile is never referenced against another NodeType's emitted assembly: its reference set is the process baseline, the installed modules and #r "nuget:" packages (MeshNodeCompilationService.References). Cross-type use is a shared=@Other/Source source query that compiles the other type's code INTO this assembly, so the symbol's containing assembly is always this one. Ownership therefore has to be read from where the declaration's SOURCE came from, which is what step 1 does.

Where it runs

compile extraction written by
runtime NodeType compile (MeshNodeCompilationService.EmitCompiled) after the emit, on the CPU lane; modules are the files of the SAME reference snapshot Roslyn compiled against, so a live module swap mid-compile cannot drop a used module the compile settle, beside the terminal stamp, as the edge-set node
cold-start batch bake (NodeTypeBatchBake.BakeOne) the same compile service its own gated stamp (WriteStamp), the same writer, inside the same publication gate — and only when the stamp LANDED (a refused compare-and-set writes no edges over a newer build's)
build-process bake (NodeSetCompiler.CompileWithModules; the published Compile forwards with none) after the emit; modules are the files the caller NAMES (moduleAssemblyPaths, from BakeHost) carried on CompiledNode.DependencyEdges. TreeBake writes every attempted type's set to dependency-edges.json beside the bundles (DependencyEdgesFile), which node-repo-publish-bake.yml uploads as dependency-edges-<identity> for CI selection, AND into each bundle entry (BundleWriter.AssemblyEntry.DependencyEdges), so an adopting mesh can write it (below)
prebuilt ADOPTION (ShippedPrebuiltBundles, PluginBundleClient → PrebuiltAssemblySeeder.WithAdoptedEdges) none — the mesh compiles nothing the bundle's set for the bytes, via DependencyEdgeSetWriter.WriteAdopted: only for a COVERED outcome, only while the record's LatestAssemblyMvid equals the set's BuildMvid, under the durable record's version as the generation; offered to the MeshPublicationGate BEHIND the adoption stamp, so a stamp held while the process is provisional is released first and the edges follow it; idempotent across boots (DescribesSameBuild, which never skips a NEWER generation of the same build). An entry the image-bundle pass finds ALREADY CURRENT (it never reaches the seeding hook) still writes the edges its manifest carries, so a build adopted before its bundle carried edges is backfilled on the next pass
module workspace build (tools/MeshWeaver.PluginTester/ProjectBuild) opt-in, build-project --dependency-edges: after each green emit, GetUsedAssemblyReferences() and the emitted image's metadata plus the dynamic scan, timed and printed as the phases used assembly references and platform/package/dynamic edges dependency-edges.json in the output directory: one set per project, a Module edge to each IN-TREE project assembly the compilation used (a declared-but-unused ProjectReference is not an edge), plus its Platform, Package and dynamic edges. A --prebuilt sibling is an eligible target but gets no set of its own. The file is keyed by the host's compatibility key — the identity a bake writes — and a host that resolves none is refused before anything builds. Still opt-in — see Module workspace builds below for the measurement and the reason

The bake's edge file — the transport to CI (2026-10-27)

CI selection has no mesh to ask, so the build-process bake is where it gets the graph. dependency-edges.json (tools/MeshWeaver.PluginTester/DependencyEdgesFile.cs, schema 1) holds frameworkIdentity, commit, sets (one DependencyEdgeSet per type that compiled, each with commit and buildMvid) and failed (types the bake attempted that produced no set). Every attempted type appears exactly once; a type the bake did not attempt — a narrowed bake, a type with nothing to compile — is absent and must be read as UNKNOWN. An empty edges list means "extracted, depends on nothing". A reader refuses a schema it does not know.

The publish-bake lane uploads the file as the run artifact dependency-edges-<framework identity> (30 days, never part of the publication). A missing file only warns; a failed upload is RED, like every artifact transfer (a transfer failure is never swallowed), and the step runs before the publication lock, so a red there has published nothing. A narrowed bake writes only what it compiled, so a reader merges runs by source, newest first. The reader and its rules (currency against the tree, unknown widens) are MeshWeaver.Plugins scripts/dependency-graph.py and Hosting/DependencyGraphSelection.md.

The three evidence classes

"Who breaks if X moves" and "what can leave core" need three different kinds of fact, and the edge says which one it is (DependencyEdge.EvidenceClass, DependencyEvidenceClasses):

A set written before EvidenceClass existed reads as Static, which is what every edge of that era was.

Platform and package edges

The extractor used to drop platform (TPA) assemblies and NuGet packages, so nothing recorded which in-mesh code binds which core assembly — exactly what a core carve-out needs. Both are now edges, read off the EMITTED assembly (DependencyEdgeExtraction.EmittedReferenceEdges): every AssemblyRef the compiler wrote, classified by the compile's reference FILES (module / package / host — DependencyReferenceClasses), with the TypeRefs resolved through it as evidence.

🚨 Why the emitted metadata, and neither of the two obvious sources. GetUsedAssemblyReferences() counts an assembly whose namespace a using merely imports, and every NodeType compile carries the skeleton's global usings: measured on core's doc tree, every type reported the SAME 21 MeshWeaver assemblies — "who binds MeshWeaver.Graph" answered with "everyone". A semantic walk that re-binds every name gives the right set, but measured on a 90-file module it cost 792 ms against 621 ms for the emit itself, and the runtime compile runs on every portal. The C# compiler writes an AssemblyRef only for an assembly the IL or metadata needs, so two metadata tables give the bound set for about 50 ms. With it core's two trees report 3–8 platform assemblies per type, differing by type. What metadata cannot show — a constant inlined from a platform type, a nameof — is not an edge. A NodeType's emitted image also carries the foreign owners' code compiled in through shared=/@@, so a platform assembly only that code binds is still this type's edge: an extra edge, never a missing one, the direction every limit here fails in. Each emitted AssemblyRef is matched to the compilation's references by FULL identity — name, version, culture, public key (token) — because a host assembly and a package assembly can share a simple name with different publishers and both stay referenced; when no reference matches the full identity, every candidate of that name is classified (an extra edge, never a lost one).

Why the BCL is not an edge. Only MeshWeaver and MeshWeaver.* host assemblies are recorded. The base class library and the host's third-party libraries are on every host, at the version the image pins; nothing carves out of them, and no change in this tree moves them. Recording them would add a dozen rows per source to the closure and answer no question anybody asks. A #r "nuget:" package is different — the source chose it, so it IS an edge (Package).

Dynamic edges

DynamicBindingExtraction scans the source's OWN code (the same ownership rule as the NodeType walk) for the APIs that resolve a type or an assembly by name: Type.GetType, Assembly.GetType, Module.GetType, Assembly.CreateInstance, Activator.CreateInstance(string, string…) and CreateInstanceFrom, AppDomain.CreateInstance*, Assembly.Load / LoadFrom / LoadFile / LoadWithPartialName / UnsafeLoadFrom, AssemblyLoadContext.LoadFromAssemblyName / LoadFromAssemblyPath / LoadFromNativeImagePath / LoadFromStream, and the mesh's own name-to-type surface, ITypeRegistry.GetType / TryGetType (any implementation). A call is matched by its BOUND method, so using static cannot hide it; overloads that take a Type (Activator.CreateInstance(typeof(T))) and object.GetType() are static and not matched. A method group handed along (names.Select(Type.GetType)) is unresolved.

The $type polymorphic converter resolves type names that live in DATA (node content), not in code, so no compile can see it; that, and the registry names a TryGetType receives at run time, are the subject of phase 3 below.

Old edge sets lack the new kinds — read that as unknown

An edge set or a dependency-edges.json written by an extractor that predates these kinds (an old bundle, an older pinned tester, an edge-set node a portal wrote before it rolled) simply has no Platform, Package or dynamic edges. A reader must treat their absence as unknown, never as "binds no platform assembly" or "uses no reflection". The tell: every NodeType binds the platform through its skeleton (MeshNodeProviderAttribute lives in MeshWeaver.Mesh.Contract), so a NodeType set with ZERO Platform edges was written before the kind existed. The change itself is additive: new constants, new init-only members with defaults (EvidenceClass defaults to Static), new overloads — every published signature of Extract, ModuleReferences and the edge records stays, so it is binary-compatible under the platform compatibility ladder and moves no compatibility key.

The file says what it measured. dependency-edges.json carries measured — referenced-assemblies, dynamic-bindings, and for a module build declarations (DependencyEdgesFile.Measures). A reader judges a kind only when its measurement is listed; a schema-1 file without the field measured none of them. The rule everywhere: an unmeasured input reads as UNVERIFIABLE, never as a pass.

The reconciliation report

mw-plugin-test dependency-report <dependency-edges.json> --tree <checkout> — and, with no extra step, every compile (the bake) — writes dependency-report.json beside the edges (DependencyReconciliation). Per package (a node-repo top-level folder, for NodeType sources) or per module (a project, for a module workspace build's sources):

flag NodeType package module project
used-but-undeclared a NodeType edge into a package outside its declared closure (requires, source queries, node types — LocalNodeRepo.DependencyMap, transitively); a Module edge no package in that closure declares as its module; a Package edge no #r "nuget:" names a Module edge that is not a direct ProjectReference (a transitive use); a Platform edge no PackageReference/ProjectReference names; a Package edge no PackageReference names
declared-but-unused never — see THE RULE below: each of requires, module and #r "nuget:" has a use channel no compile measures, so an unmatched one is unverifiable a ProjectReference no Module/Platform/Package edge matches, when compiled code AND by-name loading were both measured clean
dynamic into core every Dynamic edge whose TargetKind is Platform or unknown, and every DynamicUnresolved edge the same

Plus, per unit, the platform assemblies its sources bind and how many sources bind each — the carve-out question's direct answer — and everything it could NOT judge, each named:

#r "nuget:" directives are read with NuGetDirectiveParser from the DECODED code — a .json Code node's content.code string, not its escaped JSON text. A module-libraries shelf is a flat directory, so a shelf Package edge is named by the shelf's deps.json ownership (ModuleLibrariesShelf.OwnerOf), never by the DLL's file name. It is a report, never a gate. Measured on core's trees when it landed: 0 used-but-undeclared, 0 declared-but-unused, 0 dynamic edges.

THE RULE for declared-vs-used (the reconciliation's counterpart of the scanner's rule): a declaration is "unused" ONLY when the extractor PROVABLY saw every way it can be used; otherwise it is UNVERIFIABLE, naming the first channel nobody measured. Each kind lists its channels (DependencyReconciliation):

declaration measured not measured — so never provably unused
package requires compiled code (NodeType, Module edges) runtime use of the package's node types, data and layout areas
package module compiled code (Module edges) runtime hosting of the module with its package
#r "nuget:" bound assemblies (Package edges) host unification (the host's copy keeps no package provenance); source generation (no AssemblyRef)
ProjectReference compiled code; by-name loading (clean only when the project has no dynamic edge) —
PackageReference compiled code (Platform or Package edge — a shelf MeshWeaver.* package is a Package edge) analyzers and source generators the package ships

So only a ProjectReference can ever be reported unused; every other unmatched declaration is listed as unverifiable with its reason. A use is ANY edge naming it, including a package: edge.

The ratchet

The one gate: a shrink-only allow-file of the dynamic edges INTO CORE, in the shape of --warning-baseline (Doc/Architecture/InMeshWarningStandard). compile --dynamic-baseline <file> (or dependency-report --dynamic-baseline <file>); lines are <source> <Dynamic|DynamicUnresolved> <target>, the target being the rest of the line.

Core baselines its own two trees — .github/doc-gate-dynamic.allow and .github/samples-gate-dynamic.allow, both empty (0 dynamic edges measured) — and bake-then-gate.sh asserts the ENFORCED line, so a dropped argument is red, not quietly observe-only. Satellites adopt with the dynamic-baseline-file: input on BOTH node-repo-gate.yml and node-repo-publish-bake.yml; until then the lanes pass no flag (a pinned tester that predates it would refuse an unknown option) and print a ::notice:: naming the repository as observe-only. The publish-bake lane uploads dependency-report.json in the same dependency-edges-<identity> artifact as the edges. There is no skip path: an armed baseline with no edges file is red.

Module workspace builds: still opt-in, now cheap

build-project --dependency-edges now also writes Platform, Package (shelf packages) and dynamic edges, beside the in-tree Module ones (ProjectResult.OtherEdges). Measured on a 90-file module (MeshWeaver.Plugins MeshWeaver.Import) against core's tester host: emit 375 ms, used assembly references 58 ms, platform/package/dynamic edges 53 ms — about 30 % of the emit and under 10 % of the project's build. The cost no longer argues for keeping it off; the reason it stays OPT-IN is that --dependency-edges keys its file by the host's compatibility key and REFUSES a host that resolves none, so flipping the default would turn every existing build-project call against such a host into a refusal. The module-pack lane turning it on (MeshWeaver.Plugins) is the follow-up, not a default flip here.

The Postgres projection

MeshWeaver.Plugins DependencyGraphSchema projects every edge of a canonical DependencyEdgeSet row into public.dependency_edge(source, target, kind, build_identity) with PK (source, target), DISTINCT ON (target) and kind TEXT — it filters no kind and assumes nothing about a target's shape (Module targets were already assembly names). The new kinds therefore project unchanged, and because a set's targets are unique by construction, DISTINCT ON (target) never has to choose between two kinds. A non-path target (an assembly name, {type}, {assembly}, ?{api}) becomes a SINK node of the closure — it has no outgoing edges — so dependents:MeshWeaver.Graph lists every source that binds it. Open: platform edges multiply the closure by roughly the number of platform assemblies per source; whether the closure should keep them or the projection should restrict the closure to NodeType/Module is a decision for the projection's owner, once the volume is measured on a real portal.

Phase 3 (follow-up): runtime observation

Compile-time extraction cannot see a type name that arrives at RUN time — the $type discriminator in node content resolved by the polymorphic converter, a registry name passed into TryGetType, an assembly-qualified string read from configuration. The next step is to observe type-name resolution per replica (the converter, ITypeRegistry, the load contexts), record each (caller, name, resolved assembly) as a Dynamic edge with an Observed evidence class, and fold it into the same report — turning today's DynamicUnresolved edges into resolved ones where the running mesh has seen them resolve. Not built.

The reverse closure (step 3)

The query surface is the only reader: dependents:<path> (everything that depends on <path>, transitively) and dependencies:<path> (everything <path> depends on). Each answer row is (node, depth, path) — path the chain from the start to that node, so a caller can see WHY.

Postgres (MeshWeaver.Plugins, PostgreSqlSchemaInitializer)

In memory (core, DependencyClosure)

MeshWeaver.Mesh.DependencyClosure (Mesh.Contract/Query) answers the same (node, depth, path) rows from a set of forward edges: breadth-first distances, and the same next-hop rule (shortest chain, then the smallest next hop under ordinal comparison), with every path rebuilt by following the ancestor's next hops, as dependency_path does. It is pure and immutable, so an instance built from one edge set is held by a mesh-scoped service (never a static) and replaced when an edge set changes.

Parity

One corpus both providers are held to: test/MeshWeaver.Fixture/DependencyClosureCorpus. It holds the cycle a → b → c → a plus the leaf d → a, and a diamond with two equally short chains, and asserts membership, depth AND path. Core's DependencyClosureParityTest runs every case in memory. Plugins' DependencyClosureTests reads the same answers from public.dependents / public.dependencies, and on Postgres also asserts that incremental maintenance equals rebuild_dependency_closure():

What is not wired yet: the query terms

dependents:<path> / dependencies:<path> are not parsed yet. A query answers MeshNodes, and a MeshNode has no slot for a row's depth and path. So the term needs a decision first: it either returns the dependent nodes ordered by depth, with the chain only through a separate read, or it returns closure rows as their own content type. Whichever it is, the Postgres side filters each row by the reader's access to the node: the closure table spans every partition, the edge sets do not.

Open questions