Skip to content
rlsbl.dep_locks
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:

rlsbl.dep_locks
targetmanifestlockcompared-----------------------------------------------------------------------------------------------------pypipyproject.tomluv.lockthe project's own package entry: version, metadata.requires-dist, metadata.requires-devnpmpackage.jsonpackage-lock.jsonthe root entry (packages[""]): name, version, and the four dependency mapsgogo.modgo.sumevery 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.json records no root requirement map, so

only presence of each declared dependency is compared, and the outcome says so.

  • A pyproject.toml with 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

python
def ok(self)

#normalize_version

python
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

python
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

python
def _normalize_clause(clause)

One comparison clause with its version canonicalized.

#_declared_pypi_requirements

python
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

python
def uv_sources_table(data)

The [tool.uv.sources] table of a parsed manifest, or {}.

#source_backed_names

python
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

python
def _inherited_sources(root)

The [tool.uv.sources] a uv workspace root lends to root, or {}.

#_add_requirement

python
def _add_requirement(bucket, entry, sourced=frozenset())

Record one manifest requirement in bucket ({name: {spec, ...}}).

#_locked_pypi_requirements

python
def _locked_pypi_requirements(entries)

{name: {specifier, ...}} from a lock's list of requires-dist entries.

#_project_lock_entry

python
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

python
def _compare_requirement_sets(declared, locked, *, where, problems)

Report every name and specifier difference between the two sides.

#parse_go_mod

python
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

python
def _record_replace(line, replaced)

Record a a [vX] => target line when its target is a local path.

#_go_sum_keys

python
def _go_sum_keys(path)

{(module, version)} recorded by a go.sum-format file.

#_find_go_work_sum

python
def _find_go_work_sum(root)

A go.work.sum of the go workspace above root, or None.

#evaluate_dep_locks

python
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.

Search