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

Kind is an open vocabulary (DependencyEdgeKinds): NodeType and Module today.

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 — Compilation.GetUsedAssemblyReferences(). Every used reference that is one of the mesh's installed modules is an edge self → module assembly, carrying the module's MVID as TargetBuildMvid. Platform (TPA) assemblies and NuGet packages are not edges.
  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 one GetUsedAssemblyReferences call and nothing else.

🚨 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 for the bundle — writing it into a mesh at adoption is a follow-up
module workspace build (tools/MeshWeaver.PluginTester/ProjectBuild) not yet follow-up: it runs in CI with no mesh, so its edges need a transport (the bundle, then the registry) that belongs with step (4); and its one-body-pass discipline means GetUsedAssemblyReferences' extra binding cost is measured there before it is added

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