On this page
Dependency validation for monorepo workspaces that detects unused declared dependencies and undeclared imports across workspace projects.
#rlsbl.dep_validation
#rlsbl.dep_validation
Dependency validation for monorepo workspaces.
Checks for unused declared dependencies and undeclared imports across workspace projects. Uses import scanners to compare actual source imports against manifest-declared dependencies.
#load_dep_overrides
def load_dep_overrides(root: str) -> dict[tuple[str, str], str]Load dep-overrides.toml from the monorepo config directory.
Returns a dict mapping (package, dep) to reason string. Raises ConfigError if an entry is not a table or is missing a required 'package', 'dep', or 'reason' field (or has an empty reason). Returns empty dict if the file does not exist.
#load_dead_module_exclusions
def load_dead_module_exclusions(config_dir: str) -> dict[str, str]Load dead-modules.toml from a project's config directory.
Reads <config_dir>/dead-modules.toml and parses its [[known_non_entry]] array of tables. Each entry declares a source unit (path) that is legitimately unreachable by design (demo apps, tool configs) together with a mandatory reason. For standalone projects config_dir is <project_root>/.rlsbl; for releasable members it is the releasable's state directory (resolved by the caller via resolve_releasable_config_dir_for_ctx).
Paths are relative to the project root and are interpreted per detector: Python/npm/Dart/JVM paths name a source file; Go paths name a package directory (Go's dead unit is a package directory, not a single file).
Returns a dict mapping each declared path to its reason string. Returns an empty dict if the file does not exist.
Raises ConfigError if an entry is not a table, is missing 'path' or 'reason', has a non-string value, or has an empty 'path' or 'reason'.
#_get_imported_workspace_packages
def _get_imported_workspace_packages(project_dir: str, workspace_names: set[str], exclude_dirs: list[str] | None=None, *, module_path_map: dict[str, str] | None=None, namespace_map: dict[str, str] | None=None, import_names: dict[str, str] | None=None, jvm_package_map: dict[str, str] | None=None) -> tuple[set[str], set[str], set[str]]Scan a project for workspace imports, split by context.
Args:
project_dir: absolute path to the project root.workspace_names: set of all workspace member package names.exclude_dirs: directory paths to skip during the walk.module_path_map: mapping of workspace project name to its Go
module path (from go.mod). Passed through to GoImportScanner.
namespace_map: mapping of namespace-qualified import paths to
workspace project names. Passed through to PythonImportScanner.
import_names: mapping of project_name -> import_name from workspace
config. Passed through to PythonImportScanner.
jvm_package_map: mapping of dotted package prefix to workspace
project name. Passed through to JavaImportScanner and KotlinImportScanner.
Returns (lib_imports, test_imports, guarded_imports) where each is a set of workspace package names. Guarded imports are those inside try/except ImportError blocks -- they count as "used" (not unused) but should not trigger undeclared-dep errors.
#check_unused_deps
def check_unused_deps(project_name: str, project_dir: str, manifest_deps_with_scope: dict[str, str], workspace_names: set[str], whitelist: dict[tuple[str, str], str], *, _cached_imports: tuple[set[str], set[str], set[str]] | None=None) -> list[str]Check for declared workspace deps that no source file imports.
Args:
project_name: name of the project being checked.project_dir: absolute path to the project directory.manifest_deps_with_scope: mapping of declared intra-workspace
dependency name -> scope ("runtime", "dev", "peer", "explicit").
workspace_names: set of all workspace member package names.whitelist: mapping of (package, dep) -> reason for allowed unused deps._cached_imports: optional pre-computed (lib_imports, test_imports,
guarded_imports) tuple to avoid redundant scans when multiple checks share the same project.
Returns:
- list of error strings (empty means all good).
#check_undeclared_deps
def check_undeclared_deps(project_name: str, project_dir: str, manifest_deps: set[str], workspace_names: set[str], *, _cached_imports: tuple[set[str], set[str], set[str]] | None=None) -> list[str]Check for imports from workspace packages not declared as deps.
Only checks lib/ imports (non-test context) against declared dependencies. Test files have more lenient rules and are skipped. Guarded imports (try/except ImportError) are excluded -- optional imports don't need to be declared as dependencies.
Args:
project_name: name of the project being checked.project_dir: absolute path to the project directory.manifest_deps: set of declared intra-workspace dependency names.workspace_names: set of all workspace member package names._cached_imports: optional pre-computed (lib_imports, test_imports,
guarded_imports) tuple to avoid redundant scans when multiple checks share the same project.
Returns:
- list of error strings (empty means all good).
#check_runtime_test_only
def check_runtime_test_only(manifest_deps_with_scope: dict[str, str], lib_imports: set[str], test_imports: set[str]) -> list[str]Find runtime deps that are only used in test code.
For each dependency where scope="runtime": if it appears in test_imports but NOT in lib_imports, it is flagged.
Args:
manifest_deps_with_scope: mapping of dep name -> scope string.lib_imports: workspace package names found in production code.test_imports: workspace package names found in test code.
Returns:
- list of flagged dependency names.
#check_dev_in_lib
def check_dev_in_lib(manifest_deps_with_scope: dict[str, str], lib_imports: set[str]) -> list[str]Find dev deps that are imported in production code.
For each dependency where scope="dev": if it appears in lib_imports, it is flagged.
Args:
manifest_deps_with_scope: mapping of dep name -> scope string.lib_imports: workspace package names found in production code.
Returns:
- list of flagged dependency names.
#_is_non_production_path
def _is_non_production_path(filepath: str, project_dir: str) -> boolCheck if a file path is in a non-production context.
Delegates to _is_test_context from import_scanners.py to detect test directories, example directories, and test file patterns.
#_python_module_name
def _python_module_name(filepath: str, project_dir: str) -> str | NoneDerive the dotted module name from a Python file path.
Returns None for files that cannot be mapped to a module name (e.g. files outside a package structure).
#_collect_python_imports
def _collect_python_imports(filepath: str, project_dir: str) -> set[str]Collect all import targets from a Python file.
Returns a set of dotted module names that are imported. Handles absolute imports (import foo, from foo.bar import baz) and relative imports (from .utils import helper, from ..core import x). Relative imports are resolved to absolute dotted paths using the file's position within the project directory.
#_collect_init_exports
def _collect_init_exports(filepath: str) -> set[str]Collect names exported from an __init__.py file.
Looks for __all__ definitions and import statements. Returns module names that are imported by the __init__.py.
#find_dead_modules
def find_dead_modules(project_dir: str, exclude_dirs: list[str] | None=None, suppress: set[str] | None=None) -> list[str]Find Python modules not referenced by any other module in the project.
A module is considered dead if:
- No other module in the project imports it (by any prefix match)
- It is not listed in any __init__.py's __all__ or imported by
any __init__.py
Only checks Python projects. Non-production files (tests, examples) are excluded from the scan.
Args:
project_dir: absolute path to the project root.exclude_dirs: directory paths to skip during the walk
(relative to project_dir or absolute).
suppress: set of relative file paths declared as legitimate
non-entry points. A suppressed file is removed from the candidate set (never reported dead) AND from the import-union reference set, so its own imports cannot keep any other module alive (no entry-point laundering).
Returns:
- list of relative paths of dead modules (e.g. ["mylib/unused.py"]).
#_go_package_dir
def _go_package_dir(filepath: str) -> strReturn the directory of a Go file (its package directory).
#find_dead_go_packages
def find_dead_go_packages(project_dir: str, exclude_dirs: list[str] | None=None, suppress: set[str] | None=None) -> list[str]Find Go internal packages not referenced by any non-test code.
A Go internal package is dead if no non-test .go file outside that package imports it. Only packages under internal/ subdirectories are checked, since those are the packages with restricted visibility in Go's module system.
Args:
project_dir: absolute path to the Go project root (where go.mod lives).exclude_dirs: directory paths to skip during the walk
(relative to project_dir or absolute).
suppress: set of relative package-directory paths declared as
legitimate non-entry points (Go's dead unit is a package DIRECTORY, so each path names a directory, e.g. "internal/demo"). A suppressed package is removed from the candidate set (never reported dead) AND its files' imports are removed from the reference union, so it cannot keep any other package alive (no entry-point laundering).
Returns:
- list of relative paths of dead internal packages
- (e.g. ["internal/unused"]).
#_resolve_npm_file
def _resolve_npm_file(path: str) -> str | NoneResolve a single path to an existing file using npm conventions.
Tries the exact path, then with each extension appended, then as a directory with index files. Also handles .js -> .ts mapping for TypeScript projects.
Returns the absolute file path if found, None otherwise.
#_collect_export_paths
def _collect_export_paths(value: object) -> list[str]Recursively collect all file path strings from a package.json exports value.
The exports field can be:
- A string: "./dist/index.js"
- A dict with condition keys: {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}
- A nested subpath map: {".": {"import": "..."}, "./sub": "..."}
- A list (rarely): ["./a.js", "./b.js"]
Collects all string values that look like file paths (start with ".").
#_resolve_npm_entry_points
def _resolve_npm_entry_points(project_dir: str) -> set[str]Extract and resolve entry point file paths from package.json.
Parses exports, main, and bin fields. Resolves each declared path to an absolute filesystem path, handling .js -> .ts mapping and directory -> index file resolution.
Returns a set of absolute file paths. Missing files are skipped.
#_build_npm_import_graph
def _build_npm_import_graph(project_dir: str, exclude_dirs: list[str] | None=None) -> dict[str, set[str]]Build a file-level import graph for an npm project.
Uses NpmAstLinter.scan_imports() to collect all imports, then resolves relative imports to absolute file paths.
Returns a dict mapping each source file's absolute path to a set of absolute paths it imports (only resolved relative imports).
#_is_inside_python_package
def _is_inside_python_package(filepath: str, project_dir: str) -> boolCheck if a file is inside a directory containing __init__.py.
Walks up from the file's directory looking for __init__.py, stopping at project_dir. If any directory from the file's parent up to (but not above) project_dir contains __init__.py, the file is considered a Python data resource, not an npm module.
#find_dead_npm_modules
def find_dead_npm_modules(project_dir: str, exclude_dirs: list[str] | None=None) -> list[str]Find npm source files unreachable from any package.json entry point.
A source file is "dead" if there is no path through the import graph from any declared entry point (exports, main, bin) to that file.
Args:
project_dir: absolute path to the npm project root.exclude_dirs: directory paths to skip during the walk
(relative to project_dir or absolute).
Returns:
- sorted list of relative paths of dead source files.
#_read_dart_package_name
def _read_dart_package_name(project_dir: str) -> str | NoneRead the package name from pubspec.yaml.
Returns None if pubspec.yaml does not exist or has no 'name' field.
#_resolve_dart_entry_points
def _resolve_dart_entry_points(project_dir: str) -> set[str]Determine Dart entry point files for reachability analysis.
Entry points are:
- lib/
.dart (barrel file / main library entry) - bin/*.dart (executable scripts)
Returns a set of absolute file paths.
#_resolve_dart_import
def _resolve_dart_import(specifier: str, importing_file: str, project_dir: str, package_name: str | None) -> str | NoneResolve a Dart import specifier to an absolute file path.
Handles:
- Relative imports: 'src/foo.dart', '../utils.dart'
- Self-package imports: 'package:mylib/src/foo.dart' -> lib/src/foo.dart
Returns absolute path if resolved, None otherwise. Skips dart: imports and external package: imports.
#_build_dart_import_graph
def _build_dart_import_graph(project_dir: str, exclude_dirs: list[str] | None=None) -> dict[str, set[str]]Build a file-level import graph for a Dart project.
Walks all .dart files, extracts import/export statements via regex, and resolves relative and self-package imports to absolute paths.
Returns a dict mapping each source file's absolute path to a set of absolute paths it imports/exports (only resolved intra-package refs).
#find_dead_dart_modules
def find_dead_dart_modules(project_dir: str, exclude_dirs: list[str] | None=None) -> list[str]Find Dart source files unreachable from any entry point.
A .dart file is "dead" if there is no path through the import/export graph from any entry point (barrel file, bin scripts) to that file.
Test files (test/, *_test.dart) are excluded from the scan.
Args:
project_dir: absolute path to the Dart project root.exclude_dirs: directory paths to skip during the walk
(relative to project_dir or absolute).
Returns:
- sorted list of relative paths of dead source files.
#DeadWorkspacePackage
A workspace package with no workspace importers.
#find_dead_workspace_packages
def find_dead_workspace_packages(projects: list[dict], import_cache: dict[str, tuple[set[str], set[str]]], *, published_members: set[str] | None=None) -> list[DeadWorkspacePackage]Find library packages that no other workspace package imports.
A library package is "dead" at the workspace level if its name does not appear in any other project's lib_imports or test_imports sets.
Args:
projects: list of workspace project dicts (must have "name",
and optionally "library" and "dev_node" keys).
import_cache: mapping of project name to (lib_imports, test_imports,
guarded_imports) as produced by _build_dep_import_cache in rlsbl/checks/_common.py.
published_members: optional set of project names that are published
members of a releasable (publish_mode != "none", with pipelines). These are exempt from the dead-workspace check because they are consumed externally via a package registry.
Returns:
- list of DeadWorkspacePackage for packages with no workspace importers.
#find_circular_deps
def find_circular_deps(import_graph: dict[str, set[str]]) -> list[list[str]]Find circular dependencies in a file-level import graph using Tarjan's SCC.
Args:
import_graph: mapping of file path to the set of file paths it imports.
Returns:
- list of cycles, where each cycle is a list of file paths forming
- the strongly connected component. Only SCCs with 2+ nodes are
- returned (self-loops are not interesting).
#_build_python_import_graph
def _build_python_import_graph(project_dir: str, exclude_dirs: list[str] | None=None) -> dict[str, set[str]]Build a file-level import graph for a Python project.
Uses _collect_python_imports to get dotted module names, then resolves them to file paths via a module-name-to-file mapping.
Returns a dict mapping each source file's relative path to a set of relative paths it imports (only intra-project imports that resolve to actual files).
#find_circular_python_deps
def find_circular_python_deps(project_dir: str, exclude_dirs: list[str] | None=None) -> list[list[str]]Find circular dependencies in a Python project.
Builds a file-level import graph from Python source files and runs Tarjan's SCC algorithm to detect cycles.
Args:
project_dir: absolute path to the project root.exclude_dirs: directory paths to skip during the walk.
Returns:
- list of cycles, each a sorted list of relative file paths.
#find_circular_npm_deps
def find_circular_npm_deps(project_dir: str, exclude_dirs: list[str] | None=None) -> list[list[str]]Find circular dependencies in an npm project.
Reuses _build_npm_import_graph() and runs Tarjan's SCC algorithm.
Args:
project_dir: absolute path to the project root.exclude_dirs: directory paths to skip during the walk.
Returns:
- list of cycles, each a sorted list of relative file paths.
#find_circular_dart_deps
def find_circular_dart_deps(project_dir: str, exclude_dirs: list[str] | None=None) -> list[list[str]]Find circular dependencies in a Dart project.
Builds a file-level import graph from Dart source files using regex to extract relative imports, then runs Tarjan's SCC algorithm.
Args:
project_dir: absolute path to the project root.exclude_dirs: directory paths to skip during the walk.
Returns:
- list of cycles, each a sorted list of relative file paths.
#_build_jvm_class_index
def _build_jvm_class_index(project_dir: str) -> dict[str, str]Build a mapping of fully-qualified class names to source file paths.
Walks src/main/java and src/main/kotlin directories, reads package declarations and type declarations from each file, and builds a mapping of fully-qualified names to relative file paths.
Known limitations:
- Inner classes map to their outer class file (only top-level
declarations are indexed).
- Kotlin file-level functions compile to FileNameKt classes, which
are not indexed here.
- Multi-class Java files only index visible declarations (the regex
captures all top-level type keywords but may miss unusual formats).
Args:
project_dir: absolute path to the project root.
Returns:
- dict mapping e.g. "com.example.Foo" -> "src/main/java/com/example/Foo.java"
#_build_jvm_import_graph
def _build_jvm_import_graph(project_dir: str, class_index: dict[str, str], exclude_dirs: list[str] | None=None) -> dict[str, set[str]]Build a file-level import graph for a JVM project.
Reads each source file, extracts import statements via _JVM_IMPORT_RE, resolves them against the class index, and builds a mapping of source file -> set of imported source files.
For unresolvable imports (class not in the index), falls back to package-level: marks all files in the matching package directory as dependencies.
Args:
project_dir: absolute path to the project root.class_index: mapping of FQN -> relative file path (from _build_jvm_class_index).exclude_dirs: directory paths to skip during the walk.
Returns:
- dict mapping relative file path -> set of relative file paths it imports.
#find_dead_jvm_modules
def find_dead_jvm_modules(project_root: str, exclude_dirs: list[str] | None=None) -> list[str]Find JVM source files unreachable from any entry point.
A source file is "dead" if there is no path through the import graph from any entry point to that file. Entry points are:
- Java files containing public static void main(String
- Kotlin files containing top-level fun main(
Args:
project_root: absolute path to the project root.exclude_dirs: directory paths to skip during the walk.
Returns:
- sorted list of relative paths of dead source files.
#find_circular_jvm_deps
def find_circular_jvm_deps(project_root: str, exclude_dirs: list[str] | None=None) -> list[list[str]]Find circular dependencies in a JVM project.
Builds a file-level import graph from Java/Kotlin source files and runs Tarjan's SCC algorithm to detect cycles.
Args:
project_root: absolute path to the project root.exclude_dirs: directory paths to skip during the walk.
Returns:
- list of cycles, each a sorted list of relative file paths.