Skip to content

ADR-061: Reach

Status

Accepted.

Decision

obelize is offered to the places where migrations are already decided: a CI job, a code scanning view and a coding agent. Each is a way to read or call what the commands already do, and none adds a way to write. The decisions below are numbered as they land.

D1. SARIF is a view of the findings

obelize scan --sarif prints the findings as SARIF 2.1.0 instead of the table, as --json prints them as JSON, and the two cannot be combined. A result is a finding that is not not_a_usage. Its rule is the code that withheld it, or eligible, so a viewer groups places by what to do about them, and its level says how much a person must do: note where obelize can write the migration, warning where it needs review, error where no change can express it. The document holds nothing time-derived, so two scans of one input print the same bytes, and it names the packs in a property. It is written from the scan in memory and not stored in the run folder, which keeps one format of record.

D2. A scan can fail a job

--fail-on <status> takes eligible, needs_review or unsupported, repeatable, and exits 4 when any finding has one of them. 4 already means that manual review is required. not_a_usage is context and cannot be named. The run folder records the exit code the process returns, so run.json and the shell agree. A scan with no --fail-on exits 0 as before.

D3. --json on verify and undo prints the file the command wrote

verify --json prints the bytes of the run.json it wrote and undo --json those of undo.json, so a program reads the record that is on disk and the existing schemas describe it. The exit code is the verdict's, and the run folder's path goes to stderr, as for scan --json.

D4. A library with no pack is listed, never rewritten

obelize scan --library <distribution> runs the scanner over a spec made on the spot, with the modules the distribution installed as its legacy modules and no symbol list, so every import, call and attribute of the library is a finding. Each is needs_review with the new reason no_pack_for_usage, graded at the first rung beside a pack's refusals, and an import may carry it. The library's lines in a manifest are context (not_a_usage), because nothing migrates and no pin moves. The modules come from --module, else from the distribution's RECORD in an environment kept in the repository (a namespace package gives the package under it, not every library that shares the namespace), else from the distribution's name. Nothing is imported, so reading an environment runs no code.

The run folder records source: inventory and the hash of the spec, and holds no copy of a pack. --library cannot be combined with --pack. When no pack applies, the table names the modules the repository imports most and the command that lists one. fix has nothing to do for an inventory and is not offered.

D5. A pack is made and tested with the tool

obelize pack new writes a pack that loads: one rename_import change and one manifest_dependency change for the two distributions the author names, a positive and a negative fixture for each, and a SOURCES.md with a section per change. The .after answer keys are what the rules write into the fixtures at that moment, so the skeleton passes at once; the pack and its sources say to read them. It refuses a folder that exists and removes the one it made if the pack would not load.

obelize pack test runs the checks the bundled packs already pass in tests/packs/, moved into src/obelize/packtools/check.py so that a pack of your own is held to the same standard: fixtures on disk and named, both kinds per change, parsing and prefilter, answer keys equal to what the rules write, a second run that changes nothing, equal bytes on two runs, and sources that match the changes. pack validate stays a reading of the pack alone, as before.

D6. A verification can be run on the tree as it stands

obelize verify answers inconclusive / tree_changed when a file the run wrote is no longer the run's bytes, which is right when the question is whether obelize's change works. It is the wrong answer when someone finished the migration, a person or a coding agent editing the files obelize left, and wants the tests to say whether the result holds. --current runs the verification commands on the tree as it stands, through the same trust rules, and records in verify.edited the files whose bytes differ from the run's, so the record still says that the verdict is not about obelize's own bytes. It works for an apply that wrote nothing, because the agent may have written everything, and not for a dry run, which has no verification to replace.

D7. A coding agent calls tools that cannot ask for more than the person gave

obelize mcp serves the commands over the Model Context Protocol on standard input and output, with the protocol's initialize, ping, tools/list and tools/call written against the standard library so that no dependency is added. A tool runs the command of its name as a child process with no input and its output captured: the stream carries protocol and nothing else, and a tool does what the command does, trust rules and refusals included.

The agent is an untrusted caller and the person who started the server is the owner. A tool argument is one of a pack id, a distribution, a module, a run id, a number or a boolean, each checked against its shape before it reaches a command line, so no flag, path or command can be passed. No tool takes --verify, a model, --accept-model, --allow-dirty or --trust-repo-config: verification commands come from the person's own configuration, the repository's .obelize.yml runs only if the person starts the server with --trust-repo-config, a model is never consulted (--model none; the agent is the model), and a dirty tree is refused as ever. The two tools that write files are marked destructive, and --read-only leaves them out.

obelize_residue is what makes the agent useful: the places a scan could not migrate, each with its reason, what to change, the pack's own guidance and the window of code a model would be shown, redacted by the same redactor. What the tools return quotes the repository's code and is data to the agent, which the server's instructions say.

A review of the first version found where the line between agent and owner was drawn too loosely, and each was closed. The child runs with -P, because python -m puts the repository first on the import path and a typer.py in it would have run as obelize. A pack is a known id and never a path, because a path would be read from the repository. The trust of --trust-repo-config is pinned to the hash of .obelize.yml at start, because the agent can edit that file. verify and undo take only runs the session made, because a run folder the repository carries names the files an undo writes; and undo refuses a row in .git/ or .obelize/ whoever asks. A fault of the server ends one call and not the session. What cannot be closed is that the person's tests run the repository's code, an agent's edits included, which the documentation says.

D8. A pipeline step and a hook run the scan

The repository's action.yml is a composite action over scan --sarif --fail-on: it installs uv, runs the pinned release, prints an annotation per place with the file name and message escaped so that repository text cannot end a workflow command, appends a summary, uploads the SARIF to code scanning if asked, and only then fails the job with the scan's exit code. Each input reaches the script as an environment variable and is checked against the shape it may have (a release, a pack id, a distribution, a module, a status) before it joins a command line. .pre-commit-hooks.yaml offers obelize-scan with the same default statuses. Neither writes a file of the repository.

Consequences

docs/CLI.md documents the flags and replaces its CI recipe, which parsed the JSON with a one-line script, with --fail-on.