On this page
Dependency graph builder for monorepo workspaces that parses project manifests and provides topological sorting for ordered operations.
#rlsbl.workspace_graph
#rlsbl.workspace_graph
Dependency graph builder for monorepo workspaces that parses project manifests and provides topological sorting for ordered operations.
#WorkspaceScanner
Protocol for pluggable workspace dependency scanners.
#scan
def scan(self, project_dir: str, workspace_names: set[str]) -> list[Dependency]Scan a project directory for intra-workspace dependencies.
project_dir: absolute path to the project directory. workspace_names: set of all workspace project names (raw, unnormalized). Returns a list of Dependency namedtuples for deps found within the workspace.
#CycleError
Raised when the workspace dependency graph contains a cycle.
#ManifestScanError
A manifest a scanner could not read or parse.
Raised by the scanners rather than swallowed into an empty dependency list. A failed scan contributes NO edges, and edges are what a dependent's CI paths filter is derived from, so a swallowed failure quietly NARROWS that filter: the dependent stops reacting to changes in its dependency's territory, its job concludes skipped on the very commit a release tags, and the freshness check -- re-deriving from the same broken manifest -- agrees the narrowed router is fresh.
:class:WorkspaceGraph catches it, keeps the tolerant behaviour its rendering consumers rely on (a warning on stderr, no edges from that manifest, the rest of the workspace still answerable), and records it on :attr:WorkspaceGraph.scan_errors. A consumer whose output must never be narrower than the truth reads that attribute and refuses.
#acknowledged_by
def acknowledged_by(self, project) -> boolHas project already stated by hand what this scan could not read?
Always False here. A manifest nobody could READ withheld its whole dependency section, and a depends_on declaration says nothing about what the unread file would have added -- the operator wrote the declaration for the edges they know about, not to certify a file they cannot see either. Only a failure whose class is confined to declarations an explicit edge can replace overrides this; see :meth:UnrecognizedGradleDependencyError.acknowledged_by.
#UnrecognizedGradleDependencyError
A Gradle file that parsed, carrying a declaration nobody could read.
The same failure as :class:ManifestScanError wearing different clothes: a dependency declared through a variable, a helper function, or a catalog alias with no catalog behind it exists in the build and not in the graph, so every filter derived from the edges is NARROWER than the workspace -- and the freshness check, re-deriving from the same line, agrees the narrowed router is fresh.
It therefore travels the road the read failures already built: the scanner raises, :class:WorkspaceGraph keeps the edges the file DID declare and records the failure, the rendering consumers still get their answer with a warning, and a consumer that must never narrow refuses. The remedy it names is the one no scanner has to recognize -- declaring the edge in the member's depends_on -- and that remedy really clears the refusal, see :meth:acknowledged_by.
#acknowledged_by
def acknowledged_by(self, project) -> boolDoes project's workspace entry DECLARE a depends_on key?
The declaration is the operator's own statement of that member's workspace edges, and it is what the refusal asks for. With it present -- a list of names, or an explicit empty list -- the member's edges no longer depend on this file being readable: the explicit ones are in the graph whatever the line says, so an unreadable declaration can no longer make the derived filter narrower than the workspace.
Presence is the whole test, not content: an empty list is a member saying "no workspace edges", which is an answer. Absence is not, so a member without the key keeps the hard refusal.
#_parse_pypi_dep_name
def _parse_pypi_dep_name(dep_string)Extract the package name from a PEP 508 dependency string.
Handles forms like: - "requests>=2.0" - "my-lib[extra]>=1.0" - "foo @ file:///path/to/foo" - "foo @ {root:uri}/path" Returns (name, is_path_dep, constraint) where constraint is the version specifier string or the full @ URI for path deps.
#PypiScanner
Scan pyproject.toml for intra-workspace PyPI dependencies.
#scan
def scan(self, project_dir: str, workspace_names: set[str], *, pypi_name_map: dict[str, str] | None=None) -> list[Dependency]#NpmScanner
Scan package.json for intra-workspace npm dependencies.
#scan
def scan(self, project_dir: str, workspace_names: set[str]) -> list[Dependency]#DartScanner
Scan pubspec.yaml for intra-workspace Dart/Flutter dependencies.
#scan
def scan(self, project_dir: str, workspace_names: set[str]) -> list[Dependency]#MavenScanner
Scan Gradle (Kotlin/Groovy) and Maven (pom.xml) for intra-workspace JVM dependencies.
#scan
def scan(self, project_dir: str, workspace_names: set[str], *, workspace_root: str | None=None) -> list[Dependency]#_load_catalog
def _load_catalog(self, project_dir: str, workspace_root: str | None) -> dict[str, str]Load Gradle version catalog, returning alias -> 'group:artifact' map.
Checks workspace root first (shared catalog), then project dir. Results are cached per workspace root.
#_parse_version_catalog
def _parse_version_catalog(catalog_path: str) -> dict[str, str]Parse libs.versions.toml and return alias -> 'group:artifact' map.
#_declares_no_workspace_edge
def _declares_no_workspace_edge(cls, arg: str) -> boolIs arg a dependency argument that cannot name a workspace member?
The unrecognized-declaration failure is a hard error for the consumers that refuse on it, so it must fire only on a line that plausibly declares a dependency the scanner failed to parse. These forms are parsed fine; they simply declare no edge.
#_blank_comments
def _blank_comments(content: str) -> strcontent with every comment blanked out, offsets left untouched.
Gradle's configuration names are ordinary English words, so a comment mentioning one (// implementation is intentionally omitted, // api docs: ...) reads exactly like a declaration to the line-oriented patterns below -- and reporting a comment as an unreadable dependency blocks a workspace whose build files are fine.
Comments are replaced character-for-character with spaces rather than removed, and newlines are kept, so every offset into the returned text addresses the same place in the original: the scanners match against this and render the reported line from the original, which keeps the operator's own text (comment included) in the message.
Quote-aware, because // inside a string literal is not a comment -- a repository URL is the common case.
#_line_at
def _line_at(content: str, offset: int) -> tuple[int, str](line number, source line) for the line offset falls on.
#_resolve_catalog_alias
def _resolve_catalog_alias(ref: str, catalog: dict[str, str]) -> str | NoneResolve a libs.
Gradle normalizes dashes, underscores, and dots in alias names, so libs.someLib, libs.some-lib, libs.some_lib, libs.some.lib all refer to the same catalog entry.
#_scan_gradle_kts
def _scan_gradle_kts(self, filepath: str, workspace_names: set[str], catalog: dict[str, str] | None=None) -> list[Dependency]Parse build.gradle.kts for dependency declarations.
#_scan_gradle_groovy
def _scan_gradle_groovy(self, filepath: str, workspace_names: set[str], catalog: dict[str, str] | None=None) -> list[Dependency]Parse build.gradle (Groovy DSL) for dependency declarations.
#_scan_pom
def _scan_pom(self, filepath: str, workspace_names: set[str]) -> list[Dependency]Parse pom.xml for
#WorkspaceGraph
Directed dependency graph of intra-workspace project dependencies.
Construction is tolerant of a manifest it cannot read and of a Gradle file that parsed but declares a dependency in a form no scanner recognizes: the failure warns on stderr, contributes no edge for what could not be read, and the rest of the workspace is still answerable -- which is what monorepo impact, monorepo graph and monorepo status need on a half-broken tree.
Tolerance is only safe for a consumer that RENDERS the graph. A consumer that derives something narrowing from it cannot tell "this member has no dependencies" apart from "nobody could read this member's dependencies", and picking the first silently drops a real edge. Every failed scan is therefore recorded on :attr:scan_errors (a list of :class:ScanError), and such a consumer refuses when the list is non-empty -- see :class:rlsbl.router_filters.RouterFilters.
One failure class is exempt from the recording, never from the warning: a Gradle declaration no scanner recognizes, in a member whose workspace entry declares its own depends_on (:meth:ManifestScanError.acknowledged_by). That declaration is what the failure's remedy asks for, so honouring it here is what makes the remedy actually clear the refusal.
#dependencies
def dependencies(self, project_name)Return list of Dependency namedtuples for intra-workspace deps.
#dependents
def dependents(self, project_name)Return list of project names that depend on this project.
#topological_order
def topological_order(self)Return project names in topological order (leaves first).
Raises CycleError if the graph contains cycles.
#has_cycles
def has_cycles(self)Return True if the dependency graph contains cycles.
#transitive_deps
def transitive_deps(self, name, depth=None)Return transitive dependency names in BFS discovery order.
Excludes the starting node. Optional depth limits traversal (None = unlimited, 0 = empty list). Raises KeyError if name is not in the graph.
#transitive_rdeps
def transitive_rdeps(self, name, depth=None, scope_filter=None)Return transitive reverse-dependency names in BFS discovery order.
Excludes the starting node. Optional depth limits traversal (None = unlimited, 0 = empty list). Optional scope_filter restricts traversal to edges whose scope matches the given string. Raises KeyError if name is not in the graph.
#dep_count
def dep_count(self, project_name)Return number of intra-workspace dependencies for a project.
#rdep_count
def rdep_count(self, project_name)Return number of projects that depend on this project.