ADR-021: Dependency manifests and verdict¶
Status¶
Accepted; amended by ADR-050, ADR-053 and ADR-056.
Decision¶
D1. A manifest is selected by name, outside include and inside everything else¶
scan/manifests.py publishes the closed set of manifest names, and
config.Selection.selects_manifest applies the always-excluded set and the
user's exclude, but not include, which picks source files. The walker lists
them as Walk.manifests beside Walk.files, on the same traversal and behind
the same path guard. setup.py is in both: the code pass finds nothing in it,
the manifest pass reads it. An excluded manifest is neither read nor written.
D2. The row count is the edit count: F-2's two edits collapse when neither is blocked¶
When nothing blocks the removal,
ADR-010 F-2's add and remove are one
rewrite of the legacy line (google-generativeai==0.8.6 -> google-genai>=1)
and one row. When the removal is blocked they are two rows: the add is eligible
(unless that manifest already declares the new distribution) and the legacy pin
is withheld. Both sit on the declaration's line, the address the edit uses.
D3. Migrated means eligible; blocking means a row that still brings the distribution in¶
A file has migrated when its plan holds an eligible finding. A file blocks
the removal when a non-eligible row still loads the distribution: import,
star_import, dynamic, parse_error (fail-closed), or a mock_patch_target,
which imports the module when the patch is entered; a prose mention does not.
A file the default include leaves out (a notebook, a stub, a windowed script)
that matches the byte prefilter blocks as an import does, except a notebook that D14
reads and finds no use of the legacy API in; under a narrowed include, or when the user's
exclude matches it, it is named as excluded and blocks nothing, and is not read.
D4. Nothing migrated means nothing is added, and the pin is not touched¶
With nothing migrated the manifest is not edited. The legacy pin is withheld
with repo_not_fully_migrated while a file still imports it (D12 where one distribution holds
both APIs). Where nothing imports it, the pin is manifest_code_mismatch
(D5): the code has moved on and the manifest has not, and removing the pin
would edit a manifest for a migration this run did not make. It stays
not_a_usage, context only, where something else may still need it: a file the
user's exclude removed names the distribution, a module only it installs is
imported, or one distribution holds both APIs (a pin of it over new-SDK code is
openai==0.28.1 beside 1.x code, which no row here can tell from a pin the code
still runs on, unless the pack names what only the new API defines: ADR-056 D6).
D5. manifest_code_mismatch is both directions, and it is a review item¶
A manifest that declares the new distribution and no legacy pin, while code
still imports the legacy one, is manifest_code_mismatch: needs_review,
never an edit, because the code is what must change. The "no legacy pin" clause
keeps the rule idempotent: F-2's own intermediate state declares both. A legacy
pin nothing imports (D4) is the same code for the opposite disagreement, and
also never an edit: a pin kept on purpose for another library reads this way
until it is removed.
D6. Manifest rows get their own plan, and ImpactPlan's refusal stands¶
ImpactPlan refuses manifest rows, and ManifestPlan is the
repository-wide plan, because a manifest declaring both distributions keeps a
partial migration installable where a half-rewritten file breaks. It carries
the findings and the blocking, excluded and transitive files, and refuses
a non-manifest row, file_not_fully_migrated, and repo_not_fully_migrated
or transitive_dependency_in_use with no file named behind it: each code
claims an in-scope file.
D7. The two manifest codes are outside §6's ladder¶
A manifest row names one declaration, so manifest_code_mismatch never meets
repo_not_fully_migrated on a row. scan/manifests.py publishes BAILS and
no RUNG, and tests/oracle/ lists manifest_code_mismatch as unranked.
repo_not_fully_migrated and transitive_dependency_in_use sit at §6's rung
6: like file_not_fully_migrated they name no concrete defect, so they never
displace one that does. The latter withholds the pin while nothing imports the
legacy distribution but an in-scope file imports a module only it installed
(the pack's match.transitive) and no manifest of that file's app declares that module's
distribution: one in its directory or above it, a requirements/ directory counting for the
directory it is in. Another app's manifest does not cover it, and a constraints file does not
declare: it bounds a version and installs nothing.
D8. Lines, not a parse-and-serialise round trip — except for setup.py¶
A manifest is read as lines, and a finding records the line and the column the
name is written at, so the scan's address is the edit's. The dependency budget
(ADR-005) has no TOML writer, and a round trip would
reformat the file. setup.py is parsed with libcst: a declaration is a string
literal inside install_requires, setup_requires, tests_require or
extras_require, and any other string is not one. A name there (install_requires=REQS, a list
element, an extras_require value) stands for the list, tuple or dict literal assigned to it
(EXTRAS = {"ai": [...]}), where one assignment binds the name, and the strings of that literal
are the declarations, edited where they are written; the names in it stand for theirs in turn. A
name bound twice, by an import, a parameter or an augmented assignment, or one given to a call
(sorted(REQS)), is not followed. A literal Python cannot evaluate (an invalid \U escape, a NUL)
declares nothing, and the rest of the file is read.
D9. The comment rule is per layout, because the layouts do not agree¶
- Requirements files: a
#at the start of a line or after a blank starts a comment, as pip reads it, so a#egg=fragment is part of its line. A line ending in a backslash goes on with the next (a comment line never does), and the options after a requirement (--hash) are cut before it is read. TOML: a#outside a quoted string. setup.cfg: a comment only when#begins the line, asconfigparserand setuptools read it; a requirement with a trailing comment is invalid, so it is known by its name alone (below).setup.py: no comment rule; D8 excludes every other string.
A requirement string PEP 508 refuses (PyPDF2~=1, PyPDF2==1.*.*, PyPDF2 1.26) is still a row,
for the name it starts with, with no pin and no version: the legacy version gate asks every row, and
a line it skipped could hide a pin below the floor. A line that starts with no name (-e ., a URL)
is no row, and neither is a Poetry or Pipenv table key that is no name.
A name is compared after packaging.utils.canonicalize_name, never as a
substring.
D10. A lockfile is not a manifest in v0¶
v0 reads the five hand-written layouts plus the requirements/<name>.txt
directory form, and a requirements file is any *requirement*.txt. A constraints file and a
pip-tools input (*requirements*.in) are read for their declarations and never written: the
compiled file beside an input is what installs, and a declaration there has no pin, so the rule
refuses it with manifest_pin_shape_unsupported. A lockfile is generated, and a line edit would
break its hashes.
In pyproject.toml the arrays read are PEP 621's and PEP 735's, [tool.uv] dev-dependencies, every
group of [tool.pdm.dev-dependencies] and a Hatch environment's dependencies and
extra-dependencies; the tables are Poetry's and Pipenv's, whose value is a version string or an
inline table. A table that holds nothing but a version has its pin inside the quotes of that
string; with another key beside it (extras, a marker, a source) it has none, since those keys would
be carried to a distribution that may not have them, and the rule refuses it. A dependency given
as a table of its own ([tool.poetry.dependencies.<name>]) is named by its header and read for its
version, and never written: the name is on one line and the version on another, and an edit has
one line to a declaration, so the rule refuses it with manifest_pin_shape_unsupported.
D11. An unread file is a limitation and not a blocker¶
A manifest is opened through parse.contents, with the source files' size limit
and O_NOFOLLOW; an unreadable one yields a Limitation row and no
declaration. An unreadable source file does not block the removal:
repo_not_fully_migrated claims a file imports the distribution, and an unread
file is the absence of that fact. A file read but not parsed does block (D3).
Where one distribution holds both APIs (D12) the absence says nothing, since an import does not mark
the old API, so a selected file that could not be read (over max_file_bytes, or one that will not
open) blocks as a notebook that is not analysed does (D14): Scan.unanalysed names it, the pin
waits, and its limitation says so. A raised max_file_bytes reads it, and exclude takes it out.
D12. One distribution on both sides: any withheld row blocks¶
Where the new distribution is the legacy one (manifests.coupled), the survey is paired. No
import marks the old API, since import openai is in both, so a file blocks when it holds any
withheld row that is not not_a_usage, and D2's two edits are never two rows: a pin nothing blocks
is rewritten in place (openai==0.28.1 to openai>=1.109.1), and one that is blocked is
repo_not_fully_migrated, or transitive_dependency_in_use, with no row to add, because the
declaration already names the new distribution. The driver then leaves every file as it was
(ADR-031 D12). A declaration whose lowest admitted version is already in the new range has no row, and a legacy one
the rule cannot write in place is withheld by this plan, so the driver holds every file
(ADR-053 D11 and D12). The reasons are in
ADR-053 D6.
D13. A pin that waits for a module can declare it¶
The module the legacy distribution installed for an app that never declared it (requests for
openai==0.28.1) held the pin, and with it the whole repository where one distribution holds both
APIs (D6 of ADR-053), until the user declared it by hand. A pack may set declare_transitive on its
manifest_dependency instead (ScanSpec.declare_transitive, in spec_digest once set, since it
changes rows). The module is then declared where it came from, as the pin moves:
- A distribution is written once for each file that imports it and has no declaration in its app
(D7's reading), below the legacy requirement nearest that file: the one in the deepest directory
that is at or above the file, a
requirements/directory counting for the directory above it. Its row is a manifest row on that requirement's line and column, with the distribution as its symbol, so the plan,REPORT.mdand the driver need no second kind of row. - The line holds the distribution's name, folded as PEP 503 folds it, and nothing else: no version
(0.28.1 asked for a floor,
requests>=2.20, and the release the app ran is not known), no marker, no comment, the indent and the line ending of the requirement it follows. The legacy line itself is written as D2 says. - Only a requirements file takes it (
requirements*.txt,requirements/*.txt) and only on a requirement with a pin the rule can write. If any distribution has no such requirement, because the pin is in apyproject.toml, asetup.cfg, asetup.py, aPipfileor a constraints file, or no requirement is at or above the file, none is declared and every legacy pin waits as before (transitive_dependency_in_use), since a pin that moves with a module still undeclared is the half-migrated repository D6 of ADR-053 forbids. Files that still import the legacy distribution keep the pin (repo_not_fully_migrated) and nothing is declared. - The plan's
transitivelist is empty when the run declares them, since nothing waits.
undo restores each manifest from its journalled original, so the inserted lines go with the pin.
D14. A notebook is read for the legacy API and never written¶
A notebook that names the library held the pin whether or not it called the old API: five of the
six such notebooks in the openai frame, in five repositories, did not call it: they installed a
package from openai's GitHub organization, used the new client, used another library's openai
names, or named a model in an output. Under the default include, one that matches the prefilter
is now read as code (parse.notebook), in the scan's own process and never by a rule:
- Its code cells become one module, in order, since a cell sees what the cells before it bound.
A line that starts with
!or%is a shell escape or a line magic, no Python, and becomes apassat its indent, so every other line keeps its number. A cell magic that runs a shell or markup (%%bash,%%html) leaves its cell out; the body of any other (%%time,%%capture) is code after its first line, and one that is not Python is a notebook that does not compile, which holds the pin. A cell mayawaitat its top level, whichcompile()is told to accept for it. - The module goes through the gates and the analysis of any source file, and the notebook holds the
pin only where a row that is not
not_a_usageappears. It is the only effect: no row of a notebook is in the findings, the plan or the model's questions, and a notebook is never inRun.files, so nothing can write it. Scan.unwrittennames each file that uses the legacy API and that no rule writes, with the places of the cells of a notebook, counted from 1 among all its cells. The terminal andREPORT.mdsay so, under Limitations in the report, and a pack that has no other row still applies when it has one (runner.applies), so a notebook beside a pin that has arrived is no longer "No pack applies".- A notebook that is not a list of cells with text sources, or whose cells do not compile, is a
input_does_not_parselimitation (the cell is named for a refused parse), is not analysed and holds the pin where it names the library, as before. A stub and a.pyware not analysed either; where their bytes spell a symbol of a shared module (its module otherwise), they are named too. - Under a narrowed
include, or whereexcludetakes it, a notebook is the user's choice and is not read.
D15. A file that -r or -c names is a manifest the scan reads and never writes¶
A requirements, input or constraints file can include another under any name (-r base/common.pip,
-c pins.txt), which D10's names never select: a legacy pin there was no row, and the -r line
alone stopped the newest-release check (ADR-056).
walker.follow, which runner.read calls on the walk because the walker opens nothing, takes the
file that each -r, -c, --requirement or --constraint line of a pip manifest names as a
manifest, and again each file those name, up to manifests.MAX_DEPTH (8) includes deep.
- The target is the line's argument from the including file's directory, a backslash read as a separator, as pip does on Windows, so the same files are read on every system. An absolute path, a drive, a path above the repository root and an argument with a blank or a quote name no file.
- It is taken only if the listing has it, a manifest may be read there (the user's
excludeand the always-excluded directories still apply) and the walk's path guard passes it; a link or a file gone from the disk is a skipped path, named. A name D10 already selects is read as it was. A cycle ends where it began, and a file named twice is read once. - It is read as an input is (D10), for its declarations, with no pin, so a legacy pin in it is
manifest_pin_shape_unsupportedand a coupled pack holds the repository (D12). A bare name is not read, and the newest-release check stops at it, as at an input. It is never a rewrite target: the layout of a file whose name tells nothing is not known tomanifests.layout, soScan.followedcarries it to the codemod, which re-reads its declarations from the bytes. - A file named by
-conly, or by a constraints file, isconstrained: its declarations carryDeclaration.constraintand declare nothing for the app that imports a module (D13). A file named both ways isrequired. - A file beyond the depth bound is not read, and its
-rline stays an option that can move the release, which the newest-release check stops at.
Consequences¶
- ADR-005's cache key covers findings only. The manifest pass grades one file's
pin on every other file's plan and on
.obelize.yml's exclusions, so it runs after every file is scanned and is not cacheable on that key. - A
setup.cfgrequirement with a trailing comment is invalid for setuptools and is a row for its name with no pin (D9): the scan marks itmanifest_pin_shape_unsupported, and a coupled pack holds until the line is changed by hand.