Synaptic documentation

Workspaces and Federation

Source: GitHub Wiki · Published · Updated Edit this article

Synaptic can turn a set of member sources into one federated graph. A member is either a local package (a monorepo crate/package) or a separate repository (multi-repo). Each member is extracted into its own subgraph, node ids are namespaced as tag::id, the subgraphs are composed into one graph, cross-repo edges are resolved against each member's published export surface, and the merged graph is re-clustered at the workspace level.

Three layers cooperate:

  • The workspace subcommands build and manage a federated graph driven by a synaptic-workspace.toml manifest (or auto-discovery).
  • The global subcommands maintain a persistent cross-repo store under ~/.synaptic.
  • merge-graphs composes several existing graph.json files into one namespaced graph.

See also [Commands], [Querying], [Output-Formats], and [Configuration].

The workspace subcommands

All workspace subcommands operate on the current working directory as the workspace root.

workspace init

Auto-discovers members and writes synaptic-workspace.toml. The workspace name defaults to the root directory name, default_branch is main, and each discovered member is recorded as a root-relative member glob (path separators normalized to /).

synaptic workspace init

Optional sibling-repo discovery appends [[repos]] entries:

synaptic workspace init --scan-repos
synaptic workspace init --scan-repos ../ --depth 3 --max 50
  • --scan-repos [DIR]: also scan a directory for sibling git repositories. Bare --scan-repos scans the parent of the current repo. Each discovered repo is added as a [[repos]] entry with a root-relative path (it is not cloned). The current repo is excluded. Repos already present (by name) are skipped.
  • --depth (default 3): directory levels below the scan root to examine.
  • --max (default 50): maximum repos to include; the rest are reported as skipped (over --max cap). Git repos without a recognized manifest are also reported as skipped (no recognized manifest).

workspace add

Adds one member to the manifest (creating it if absent). The argument is classified as a git URL or a local path: a value that does not exist on disk and contains :// (or an scp-style user@host:path) becomes a [[repos]] git member; anything else (including an existing local path) is appended to the [workspace].members list.

synaptic workspace add services/billing
synaptic workspace add https://github.com/acme/identity

For a git URL, the member name is the last path segment minus a trailing .git.

workspace discover

Ephemeral federation of sibling git repos with no manifest written. Scans a parent directory for sibling repos and immediately federates the ones with a recognized manifest, writing the federated outputs.

synaptic workspace discover
synaptic workspace discover ../ --depth 3 --max 50

The scan root defaults to the parent of the current repo. The current repo is excluded from the scan. Discovered repos are federated as local path members.

workspace build

Builds every member and federates them into synaptic-out/graph.json plus the standard outputs (see [Output-Formats]), and writes each member's export surface to synaptic-out/surfaces/<repo>.json.

synaptic workspace build
synaptic workspace build --directed
synaptic workspace build --changed
synaptic workspace build --watch
  • --directed: produce a directed federated graph.
  • --changed: incremental. Skips the whole federation when no local member's source changed and no remote [[repos]] are declared and a federated graph.json already exists; otherwise does a full rebuild. After a rebuild it also reports which members' export surface changed (the dependents whose cross-repo edges may be affected).
  • --watch: keep running and re-federate whenever a member changes (implies --changed). See Watching a workspace.

A full build records workspace incremental state; the incremental path persists state only after the artifacts are durably written. See [Incremental-Updates].

Watching a workspace

workspace build --watch keeps a federated graph current while you work across several repositories at once.

synaptic workspace build --watch
synaptic workspace build --watch --debounce-ms 1000
synaptic workspace build --watch --artifacts
  • Watches every member, wherever it lives. In-tree members are covered by one recursive watcher on the workspace root; each member checked out outside the workspace ([[repos]] path = "../identity") gets its own root, so a multi-repo workspace of sibling checkouts is fully covered. Overlapping roots are collapsed so no event is processed twice.
  • Reacts only to graph inputs. Events are filtered by the same rules the single-repo watcher uses: detect's noise rules (synaptic-out, .git, target, node_modules, dist, virtualenvs, ...) applied to member-relative paths, then the extractable set (code + .md/.mdx/.qmd). Writing the federated graph.json therefore cannot self-trigger, and neither can a member's own synaptic-out/cache.
  • Debounces a burst of saves into one re-federation; the settle window defaults to 3000 ms (--debounce-ms, or SYNAPTIC_WATCH_DEBOUNCE_MS).
  • Incremental at the member level. Each cycle runs the same incremental update as --changed: unchanged members are skipped by source hash, and the members that did change re-extract through their own AST caches. Composition, cross-repo resolution, and clustering are workspace-global by construction, so they always re-run — a cross-repo edge cannot be resolved from one member alone.
  • Skips the visual artifact suite by default (graph.json + surfaces/ + the store only), because a watcher re-federates on every save. Pass --artifacts to regenerate the SVG/3D/HTML suite each time.
  • Adding or removing a repository takes effect live. Editing synaptic-workspace.toml re-resolves the member set and re-registers the watchers without a restart.
  • Survives a bad member. A member that cannot be read or built is reported by tag and the watcher keeps running; a member whose path does not exist is skipped for watching and reported as an empty member by the build.
  • Serializes with other rebuilds. Each cycle takes the per-repo rebuild lock, so a concurrent synaptic update or git hook in the same workspace cannot interleave its write. If the lock is held, the cycle is skipped — safe, because member change detection is driven by persisted source hashes, so the members still read as changed and the next save retries.

Stop with Ctrl-C. Workspace state is persisted only after the artifacts land, so an interrupted cycle leaves the workspace reading as changed and the next run redoes it — never an "up to date" marker over outputs that never landed.

Use workspace build --watch, not synaptic watch, at a workspace root. synaptic watch is the single-repo watcher: it would rebuild the whole tree as one flat repository and overwrite the federated graph with un-namespaced, un-tagged nodes.

Sample output:

Watching 3 member(s) across 2 root(s) (debounce 3000ms; Ctrl-C to stop):
  /work/platform
  /work/identity

1 changed file(s) in identity → re-federating…
Wrote /work/platform/synaptic-out/{graph.json} + surfaces/ (3 member surface(s))
Federated graph: 812 nodes · 2104 edges · 24 communities · 3 member(s)
Cross-repo links: 17 extracted, 4 inferred, 6 cross-language · 2 external package(s)
Surfaces changed: identity

The build prints a summary: federated node/edge/community/member counts, the cross-repo report (extracted, inferred, cross-language, external_package), and per-member node/edge counts. extracted/inferred count import/coordinate resolution (see Cross-repo symbol resolution); cross-language counts the HTTP/RPC/FFI/WebSocket/queue/SQL boundaries that span repos (see Cross-Language-Edges) — these are flagged on the edge, so a graph with only WebSocket or route coupling no longer reads as "0 cross-repo links".

workspace federate

Artifact-mode federation: composes from a directory of already-published per-member artifacts. Members are never checked out together; each member is a subdirectory containing graph.json (required) and optionally export-surface.json.

synaptic workspace federate ./artifacts

Layout:

artifacts/
  billing/
    graph.json
    export-surface.json
  identity/
    graph.json
    export-surface.json

The member tag is the subdirectory name. A surface's repo field is rewritten to the tag so cross-repo targets line up.

workspace sync

For each declared git [[repos]] member already cloned under synaptic-out/workspace-repos/<tag>, runs git pull, then performs an incremental update (same logic as build --changed) and writes the federated outputs if anything rebuilt.

synaptic workspace sync

workspace status

Shows each local member's change status (changed or unchanged) against the saved workspace state, without building. If remote [[repos]] are present it notes that build --changed forces a rebuild.

synaptic workspace status

workspace list

Lists the resolved members (from the manifest when present, else auto-discovery) with their tag, package coordinate, and path, plus any remote [[repos]] with their git URL, subgraph URL, or path. When there is no workspace build-file but projects were discovered by manifest presence, it notes the discovery.

synaptic workspace list

workspace coordinate

Detects the package coordinate that a repository publishes for cross-repository import resolution. The path defaults to the current directory. --json emits the stable manifest shape, or null when no recognized package manifest exists, which lets hosted orchestrators freeze the coordinate beside an immutable graph artifact.

synaptic workspace coordinate
synaptic workspace coordinate ../shared-api --json

Example JSON:

{"ecosystem":"npm","name":"@acme/shared-api"}

The synaptic-workspace.toml manifest

The manifest declares the workspace and its members. When the file is absent, a workspace is auto-discovered instead. The conventional filename is synaptic-workspace.toml at the workspace root.

[workspace]
name = "acme-platform"
default_branch = "main"
members = ["services/*", "libs/*"]

[[repos]]
name = "billing"
git  = "https://github.com/acme/billing"
rev  = "main"

[[repos]]
name = "@acme/identity"
tag = "identity"
subgraph = "https://artifacts.acme.com/identity/latest/graph.json"
coordinate = { ecosystem = "npm", name = "@acme/identity" }

[[repos]]
name = "shared"
path = "../shared"

[workspace] fields:

  • name (string, required).
  • default_branch (string, default main).
  • members (array of strings): local package-root globs, relative to the workspace root (like Cargo/pnpm workspace member globs). A declared glob that resolves outside the workspace root is a configuration error.

Each [[repos]] entry is a separate repository federated into the workspace. Exactly one of path, git, or subgraph drives how it is built:

  • name (string, required): the basis for the member tag.
  • tag (string, optional): an explicit stable graph namespace. When omitted, the tag is derived from name for backward compatibility. An explicit tag must already be canonical lowercase kebab case. It is used in namespaced node IDs, repository filters, paths, cache directories, and export surfaces.
  • coordinate (table, optional): the published package identity used for import resolution, with ecosystem and name fields. Supported ecosystems are cargo, npm, go, python, jvm, gradle, dotnet, and other. Local and Git members detect a coordinate from their package manifest when this field is absent. Artifact-backed subgraph members should supply it explicitly because their source manifest is not available.
  • path (string, optional): a local, already-checked-out repo (relative to the root). Built locally.
  • git (string, optional): a git URL to clone into synaptic-out/workspace-repos/<tag>, then built. Only https, ssh, git, file schemes and scp-style user@host:path are accepted. A directory that already exists on disk is cloned directly (offline); workspace sync pulls updates.
  • rev (string, optional): branch/revision for the git clone (passed as --branch).
  • subgraph (string, optional): artifact federation. A prebuilt subgraph, either a local path (relative to the root) or an http(s) URL, consumed directly instead of cloning and building.

Declared [[repos]] members load on a dedicated pool of at most four workers. Results and the first reported error remain in manifest order. Repository names must resolve to distinct tags: for example, two names that both become acme-billing are rejected before any clone/build starts, because they would otherwise share one cache directory and graph namespace. Explicit tags are validated rather than silently rewritten, so a persisted hosted namespace cannot drift.

Member auto-discovery

When no synaptic-workspace.toml exists, members are auto-discovered from build files at the workspace root:

  • Cargo: the root crate (if the root Cargo.toml has [package]) plus each [workspace].members glob, minus [workspace].exclude. default-members is not used to narrow scope.
  • npm/yarn: package.json workspaces (an array of globs, or { "packages": [...] }).
  • pnpm: pnpm-workspace.yaml packages: list.
  • Go: go.work use directives (use ./path or a use ( ... ) block).
  • Python: pyproject.toml [tool.uv.workspace].members globs.
  • Maven: pom.xml <modules><module> directories.
  • Gradle: settings.gradle(.kts) include directives (:a:b maps to a/b).
  • .NET: .sln Project(...) rows (member dir is the project file's parent).

Discovered members are deduplicated by resolved path, filtered to those inside the root, sorted, and assigned a unique sanitized tag (the directory base name, with collisions disambiguated by a -2, -3, ... suffix). Negation glob patterns (a leading !) are dropped (negation is not applied). Nested workspaces are expanded up to a bounded depth.

!-prefixed patterns are not treated as literal globs. A member directory must contain a recognized manifest (Cargo.toml, package.json, go.mod, pyproject.toml, pom.xml, build.gradle(.kts), a *.csproj/*.fsproj/ *.vbproj, or a *.sln) to be kept. The .sln covers the standard .NET layout (a solution at the repo root with its projects in subdirectories, and no .csproj directly at the root) — without it such a repo would be skipped for "no recognized manifest" and dropped from a multi-repo federation.

If no build file declares any member, a manifest-presence fallback scans the root (gitignore- and noise-aware, bounded depth) for directories that contain a recognized manifest and treats each as a member. Descent stops at the first project root on each branch, and git submodules whose checkout is a project root are surfaced as separate members.

Package coordinate detection

A member's published coordinate is the name another member would import. It is read from the member's package manifest, with precedence Cargo to npm to Go to Python to Maven to Gradle to .NET:

EcosystemSourceCoordinate
cargoCargo.toml[package].name
npmpackage.jsonname
gogo.modmodule path
pythonpyproject.toml[project].name, else [tool.poetry].name
jvmpom.xmlgroupId:artifactId (artifactId alone if no groupId)
gradlesettings.gradle(.kts)rootProject.name, else the dir name
dotnet*.csproj/*.fsproj/*.vbproj, else a root *.slnAssemblyName, else RootNamespace, else the project-file stem. With only a .sln at the root, the first project it references supplies the name (falling back to the .sln stem)

A member with no recognized manifest and no explicit manifest coordinate has no detected coordinate (and so contributes no coordinate-based export surface in co-located mode). When coordinate is present on [[repos]], it overrides detection for local, Git, and artifact-backed members.

Cross-repo symbol resolution

Export surfaces

Each member publishes an export-surface.json: its coordinate plus its public symbols. Every code node defined in the member (non-empty source_file, i.e. not an external stub) is exported, keyed by its label. The surface schema also records a version (current schema version is 1; a newer version is rejected on load) and, for JVM/.NET members, a synthesized namespace: the longest dotted prefix shared by at least half the qualified symbol ids (because JVM/.NET imports spell a package namespace, not the build coordinate).

Import-edge matching

Resolution runs on the composed (already tag::-prefixed) graph. Each external stub that is the target of an imports/imports_from edge (or an edge whose context is import) is resolved once, importer-independently:

  1. Alias match (tried first): an import map, tsconfig paths, or module-federation remote that maps the specifier to a member tag. Resolves package-level to the member's anchor node (INFERRED, confidence 0.75).
  2. Coordinate match: the imported path equals or is under a member's coordinate (/- or .-separated). The longest matching coordinate wins. A submodule import (from billing.ledger import ..., import ".../billing/ledger") resolves module-exactly (EXTRACTED); a bare package import (use billing::..., import billing) resolves package-level to the member's anchor (INFERRED, confidence 0.75). Cargo coordinates are matched with - normalized to _ (Rust use paths use the underscore lib name). JVM/.NET imports match the synthesized namespace.
  3. Single-owner symbol fallback: when the imported stub names a symbol (e.g. Rust use billing::Ledger yields a stub labeled Ledger) and exactly one member exports that symbol, it resolves to that member (EXTRACTED). A symbol exported by two or more members is ambiguous and left unresolved.

Once a stub is resolved, every edge pointing at it (including non-import edges like references/inherits) is rewired into the owning member and marked cross_repo, except self-references (an importer importing its own repo). Import edges adopt the resolution's confidence; other edges keep theirs. Colliding rewired edges are deduped, keeping the highest-confidence one. Import targets that match no member are retagged as third-party external_package nodes so nothing dangles; orphaned rewired stubs are dropped.

The build reports counts of extracted, inferred, and external_package resolutions, plus a cross-language count for the HTTP/RPC/FFI/WebSocket boundaries that span repos (flagged on the edge by the cross-language pass, not by this import resolver — see Cross-Language-Edges).

Aliases: tsconfig paths, module-federation remotes, import maps

Several JS/TS toolchains let one member reference another by an alias decoupled from the target's package.json name. Synaptic collects all of them into one alias map (alias -> member tag), built from a bounded walk over each member's source tree (skipping node_modules, .git, synaptic-out). A self-alias (an alias pointing at its own member) is dropped.

  • Import maps (single-spa / SystemJS / native): the first imports object in a candidate file (.json, .js, .mjs, .cjs, .ts, .ejs, .html, .htm). The alias is exact (the import specifier must equal the alias). The member is identified by a path segment of the target value (e.g. @acme/Hub to ${url}/hub/dist/... resolves to member hub).
  • tsconfig paths: compilerOptions.paths in tsconfig*.json. A "@app/*" key (trailing /*) is a prefix alias; a bare key is exact. The member is identified by a path segment of the target paths (e.g. ../hub/src/*). A bare "*" catch-all is ignored.
  • module-federation remotes (webpack ModuleFederationPlugin, Vite @originjs/vite-plugin-federation): the remotes object. Each remote is a prefix alias. The member is identified by a path segment of the remote value (the name@url form, or a URL path) or, as a fallback, the alias key itself.

Resolution tries an exact alias first, then the longest matching prefix alias (with a path-segment boundary, so @app matches @app and @app/Button but never @apple). On duplicate aliases, the first mapping wins.

Composition and namespacing

Composition prefixes every member's subgraph with its tag (id becomes tag::id), sets a repo attribute on each node, stashes the original id as local_id, repo-prefixes node source files and every primary/additional edge source site, and unions all subgraphs in one indexed pass. Shared external nodes (those with an empty source_file) are collapsed to one node across repos by a typed identity — its _node_type plus a canonical label (a route's normalized path, else the case-folded label) — so the federated graph has one serde/requests node, an Express /users/:id route meets an axum /users/{id}, but a command stub named orders never merges with a SQL table of the same name. Cross-repo resolution then re-runs the boundary passes over the composed graph (PyO3 module ↔ importer, subprocess command ↔ in-repo binary, code ↔ SQL table), each of which only ever has both sides present once the members are federated, and the merged graph is re-clustered at the workspace level. Edge dedup identity includes endpoints, relation, and optional context, so context-bearing boundaries such as GET and POST remain separate; exact duplicates retain every distinct extraction site.

The global cross-repo store

The global store is a persistent, namespaced union of many repos' graphs kept under a store directory: the default is ~/.synaptic (%USERPROFILE%\.synaptic on Windows, falling back to .synaptic in the CWD if no home is set). It holds global-graph.json (the merged graph) and global-manifest.json (per-repo bookkeeping: source path, node/edge counts, and a source hash).

synaptic global add synaptic-out/graph.json
synaptic global add path/to/graph.json --as billing
synaptic global remove billing
synaptic global list
synaptic global path
  • global add <graph> [--as <tag>]: add (or replace) a repo's graph.json under a tag. The default tag is the graph's grandparent directory name (so <repo>/synaptic-out/graph.json becomes <repo>). The tag is sanitized. add is idempotent: a source whose hash is unchanged is skipped. Re-adding a repo prunes its previous nodes first, then unions the new version in and collapses shared externals onto the existing global externals.
  • global remove <tag>: remove a repo's nodes (and the edges/hyperedges touching them) from the store; prints the node count removed.
  • global list: list the stored repos (tag, node/edge counts, source path), sorted by tag.
  • global path: print the path to global-graph.json.

Serving the global graph (synaptic serve --graph ~/.synaptic/global-graph.json) reads global-manifest.json next to it and registers each member's own source root (the grandparent of its recorded source_path), so get_source reads a federated node from its real repo even though the members live in sibling directories outside any single --source-root. A co-located workspace build (members are subdirectories of one root, the common case) needs none of this: the single --source-root already resolves the tag/... paths. When source still cannot be read, get_source names the configured root and says whether the path was missing or outside it, rather than a bare "not available".

The sharded store and scale

workspace build also writes each member into its own shard by default under synaptic-out/store/ (plus a bridge pseudo-shard holding the cross-repo edges); pass --no-store to federate into graph.json only. A federated serve over the store is shard-aware — see MCP Server — which removes the federation-aggregate size ceiling: per-shard safety guards (SYNAPTIC_MAX_SHARD_MB / SYNAPTIC_MAX_SHARD_NODES, default 2 GiB / 5M nodes per repo) replace the single-file 50 MiB / 100k caps, and serve memory is bounded by the shard LRU rather than the sum of all members. graph.json remains the interchange artifact: synaptic migrate builds a store from it and the store exports back byte-identically.

merge-graphs

Composes several existing graph.json files into one namespaced graph. Unlike the global store, inputs are composed verbatim with no external dedup.

synaptic merge-graphs a/synaptic-out/graph.json b/synaptic-out/graph.json
synaptic merge-graphs g1.json g2.json --out merged.json

Each input's repo tag is derived from its grandparent directory name (<repo>/synaptic-out/graph.json becomes <repo>), falling back to the file stem; repeated tags are disambiguated with a numeric suffix. The default output is synaptic-out/merged-graph.json. The command prints the merged node/edge counts and the tags used.

Repo scoping

The federated graph.json carries a repo tag on every node, so a federated graph can be sliced to a single member with no extra index.

The --repo <tag> flag scopes a query or export to one federated member before running. It keeps only that repo's nodes and the edges/hyperedges fully inside it (cross-repo edges, whose other end is in another repo, are dropped). --repo is available on:

synaptic query "billing ledger" --repo billing
synaptic path NodeA NodeB --repo billing
synaptic explain Ledger --repo billing
synaptic references Ledger --repo billing
synaptic export svg --repo billing

synaptic search --file <path> and the find_references / structural_search MCP tools work over the whole federated graph without --repo: a bare file path matches that file across every member (a tag/-qualified path scopes to one), and a find_references result includes cross-repo uses of the symbol.

See [Querying] for query/path/explain/references and [Output-Formats] for export.