On this page
Does each lockfile still resolve the manifest beside it? An offline, structural staleness check that never invokes a resolver and never reaches an index.
#rlsbl.dep_locks
#rlsbl.dep_locks
Does each lockfile still resolve the manifest beside it?
The sibling failure class to :mod:rlsbl.dep_floors. That module asks whether a DECLARED floor is behind what the lock resolved; this one asks the question underneath it: does the lock resolve THIS manifest at all, or was a dependency added, removed or re-constrained after the lock was written?
A stale lock is not a cosmetic problem. uv sync/npm ci install what the lock says, so CI and every contributor keep resolving the old dependency set while the manifest advertises a new one; dep-floors compares against a lock that no longer describes the manifest; and the release refreshes the lock at bump time, so the drift surfaces as an unrelated diff in the release commit.
Offline and structural, on purpose ----------------------------------
Nothing here runs a package manager and nothing touches the network. The obvious alternative -- shelling out to uv lock --check -- was rejected:
- it is a resolver invocation, so it can reach the index (and its exit code
conflates "the lock is stale" with "the index could not be reached"), which would make a check declared local and offline answer differently depending on the network;
- it writes -- caches, and the lock itself in some modes -- so it could not be
an observe-allowlisted program and the check could not stay pure;
- it is not available for every ecosystem anyway, so half the check would be
structural regardless.
So each ecosystem is compared structurally, against the parts of the lock that record what the manifest asked for:
| target | manifest | lock | compared | ------ | --------------------- | --------------------- | ----------------------------------------------------- | pypi | pyproject.toml | uv.lock | the project's own package entry: version, metadata.requires-dist, metadata.requires-dev | npm | package.json | package-lock.json | the root entry (packages[""]): name, version, and the four dependency maps | go | go.mod | go.sum | every required module has a recorded hash |
uv and npm both record the requirements they resolved FROM, which is exactly what a staleness comparison needs: a requirement in the manifest that the lock never saw (or one the lock still carries that the manifest dropped) is a stale lock, with no resolution required to see it. Go records no such thing -- the require lines are the resolution -- so the comparison there is the one consistency go itself guarantees: every required module has a go.sum entry.
Which uv.lock is read is :func:rlsbl.uv_workspace.locate_uv_lock's answer: a uv workspace member has no lock of its own, so its manifest is resolved by the workspace root's lock and its entry is found under the member's own path.
Limits, stated rather than hidden ---------------------------------
- A requirement uv resolves from a SOURCE -- a direct reference
(name @ file:///...) or a [tool.uv.sources] workspace/path/git/url entry -- is compared by PRESENCE only. uv records the source in requires-dist and drops the version specifier entirely, so there is nothing on the lock's side for the declared constraint to be compared against. Adding or removing the source itself is still drift, and is still reported.
- A lockfileVersion 1
package-lock.jsonrecords no root requirement map, so
only presence of each declared dependency is compared, and the outcome says so.
- A
pyproject.tomlwith a dynamic version has no version to compare. - No ecosystem's absent lockfile is an error here: whether a project must
commit a lock is not this check's question. An absent lock is a note.
#DepLockVerdict
Result of comparing every lockfile in one project against its manifest.
#ok
def ok(self)#normalize_version
def normalize_version(text)A PEP 440 version in the canonical spelling uv writes.
uv re-serializes every requirement it locks from its PARSED form, so uv.lock carries the canonical spelling of what pyproject.toml declared -- verified against uv's own output, which rewrote >=1.0.0-alpha1 to >=1.0.0a1, >=1.0.0RC1 to >=1.0.0rc1 and >=01.02.03 to >=1.2.3. Comparing the two texts therefore means canonicalizing the declared side the same way, or a manifest that spelled a version legally but not canonically reads as a lock that predates it.
Anything this does not recognize -- a wildcard (1.0.*), a local directory, an unparseable string -- is returned unchanged rather than guessed at.
#normalize_specifier
def normalize_specifier(text)A PEP 440 / npm specifier reduced to a comparable form.
Whitespace is dropped, each clause's version is canonicalized (see :func:normalize_version) and comma-separated clauses are sorted, so ">=1, <2" and "<2,>=1" are the same constraint -- which they are, and which is exactly the reordering uv performs when it writes the lock.
=== is left alone: PEP 440 defines arbitrary equality as a literal string match, so canonicalizing its operand would change its meaning.
#_normalize_clause
def _normalize_clause(clause)One comparison clause with its version canonicalized.
#_declared_pypi_requirements
def _declared_pypi_requirements(data, sourced=frozenset())(runtime_specs, dev_specs) declared by a parsed pyproject.toml.
runtime_specs is {name: {specifier, ...}} over [project]'s dependencies AND every optional-dependency extra, because uv folds extras into requires-dist with an extra == marker. dev_specs is {group: {name: {specifier, ...}}} over PEP 735 [dependency-groups] plus the legacy [tool.uv].dev-dependencies, which uv records as the dev group.
A direct reference (name @ file:///...) and a requirement redirected by [tool.uv.sources] both contribute their name with the sentinel specifier :data:_URL_SPEC: the lock records the source, not a specifier, so only presence is comparable.
#uv_sources_table
def uv_sources_table(data)The [tool.uv.sources] table of a parsed manifest, or {}.
#source_backed_names
def source_backed_names(*tables)Normalized names whose [tool.uv.sources] entry erases the specifier.
Later tables override earlier ones, which is uv's own precedence: the sources a WORKSPACE ROOT declares apply to every member, unless the member declares its own entry for that name. Reading only the member's table reported every member of a flat uv workspace -- where the sibling sources are declared once at the root -- as a stale lock.
A source may be declared as a table or as a LIST of marker-gated tables; one source-bearing element is enough, because the lock then records the source for that requirement.
#_inherited_sources
def _inherited_sources(root)The [tool.uv.sources] a uv workspace root lends to root, or {}.
#_add_requirement
def _add_requirement(bucket, entry, sourced=frozenset())Record one manifest requirement in bucket ({name: {spec, ...}}).
#_locked_pypi_requirements
def _locked_pypi_requirements(entries){name: {specifier, ...}} from a lock's list of requires-dist entries.
#_project_lock_entry
def _project_lock_entry(lock, relpath)The lock's package entry for the project at relpath, or None.
uv records a workspace member (and a standalone project) as an editable or virtual source naming its directory relative to the lock, so the entry is found by that path rather than by name -- a name in pyproject.toml that the lock has not caught up with is exactly the drift being looked for.
#_compare_requirement_sets
def _compare_requirement_sets(declared, locked, *, where, problems)Report every name and specifier difference between the two sides.
#parse_go_mod
def parse_go_mod(text)(requires, replaced) from a go.mod's text.
requires is {module: version} over every require line, block form and single-line form alike. replaced is the set of module paths a replace directive redirects to a filesystem path -- those resolve from disk and carry no go.sum entry.
#_record_replace
def _record_replace(line, replaced)Record a a [vX] => target line when its target is a local path.
#_go_sum_keys
def _go_sum_keys(path){(module, version)} recorded by a go.sum-format file.
#_find_go_work_sum
def _find_go_work_sum(root)A go.work.sum of the go workspace above root, or None.
#evaluate_dep_locks
def evaluate_dep_locks(project_root)Compare every lockfile in project_root against its manifest.
Returns a :class:DepLockVerdict. A project with no readable manifest in any of the three ecosystems comes back with a skip reason.