ADR-023: Scan command¶
Status¶
Accepted; amended by ADR-050, ADR-052 and ADR-056.
Decision¶
D1. obelize scan is registered, and nothing else is¶
A subcommand is registered only once it works, never as a stub, because
.github/workflows/e2e.yml runs the whole flow as soon as scan, fix and
verify answer --help, and a stub would turn that job red.
D2. Two documents come out of one run, and only one of them may carry a clock¶
findings.json, byte for byte what --json prints, carries no run id,
timestamp, timing or absolute path; run.json carries all four. The command
serialises the findings once and hands the same string to stdout and to
the file, so the two cannot drift. Under --json the run folder's path goes to
stderr, so stdout stays the document and the run id is still reported.
D3. A scan writes its run folder, and T19 keeps the modes a scan has no answer for¶
obelize scan writes a scan-only run folder (run.json, findings.json,
packs/<id>/, REPORT.md), because CLI.md says it records
evidence and a command that promises evidence it does not write is the failure
this project forbids. The plan, the diff, the snapshots and the verification
artefacts belong to the modes that plan or apply.
D4. The record declares every field the document names, and four of them are typed as what a scan writes¶
models.RunRecord carries every top-level field of RUN_FOLDER.md's table, in
its order, and a test asserts the two lists equal in both directions. A field
is typed as narrowly as the implemented modes write it (a type, not a default),
so a record claiming work no mode performed cannot be constructed; it widens in
the task that fills it. run.schema.json is generated from the model and
committed, and ci / schema-drift guards it.
D5. The evidence is written before the answer is printed, and a folder that cannot be written is 1¶
Inside the folder: every artefact, then run.json (which carries exit_code),
then .obelize/latest, so a latest that exists names a complete run. The
folder is written before stdout, because an answer without its audit trail is
the wrong half to keep: a folder that cannot be written exits 1, says the scan
completed, and prints nothing. So in scan mode every record carries
exit_code 0.
D6. blocked: runtime_unsupported is read from a manifest, and a blocked run still reports¶
The floor comes from a manifest, never the parser, since libcst's native parser
ignores PartialParserConfig(python_version=...) (C-37): scan/runtime.py
reads PEP 621's requires-python, setup.cfg's python_requires,
tool.poetry.dependencies.python (translating ^ and ~) and a Pipfile's [requires]
(python_version = "3.9" as ==3.9.*, python_full_version = "3.9.18" as ==3.9.18), and
the python_requires= of setup.py where it is a string literal: one built from a variable is a
guess and reads as no floor, and a setup.py that does not parse says nothing here, since the code
pass reports it. A run is blocked (runtime_unsupported) when the declaration
admits any Python the pack's to.requires_python excludes, asked of 2.7 and 3.0 to 3.29 one by
one and of any release the declaration names itself (==3.9.18): >=3.9 against >=3.10 blocks,
because pip then installs an older, different API surface (C-15). A declaration that cannot be parsed blocks nothing. A pack whose from.version has a
floor adds legacy_version_unsupported: any manifest declaring the legacy distribution with no
version, or one admitting a version below that floor, and any repository that uses the library
and declares it nowhere, is blocked too, since the pack's rules were measured above it. A pack that
names its newest release takes a declaration that installs it as that release, unless the repository
shows another (ADR-056), and one that names what only its old range defines does so beside code that
uses one (ADR-056 D5). A pack with two distributions adds new_version_unsupported: a manifest
that pins the new distribution so that it admits no release the pack writes (pypdf==3.0.0) blocks
the run too (ADR-050 D5). A blocked run still grades and reports every finding, but plans no
change and asks a configured model nothing, so fix --apply over it writes
nothing and exits 4.
D7. --list-files loads the pack and reads no file¶
--list-files prints the selection and stops without reading any file's bytes,
so a repository of unparsable files still lists. It still loads the pack, so a
broken --pack is an error here as everywhere else.
D8. --pack is repeatable, and naming none chooses the packs the repository uses¶
Without --pack, scan and fix try every known pack and run those with a finding that is not
not_a_usage (ADR-052), so no pack is named "the default" and none changes meaning when another
ships. --apply, not the pack, is what makes a run write.
D9. --jobs is the runner's to validate, and every argument refusal is 2¶
runner.worker_count refuses --jobs below one, and the command maps its
ConfigError to exit 2, like every argument it cannot use: an unparsable
.obelize.yml, a --repo that is not a directory, an unknown flag. The command
refuses --repo itself, because it can name the flag where the walker can only
name the path.
D10. git state is read here, and untracked files are not uncommitted changes¶
git_sha and git_branch are recorded only when git describes this root, the
walker's rule (ADR-016). git_dirty is the whole repository's, read from a
directory inside it, and null when git cannot answer
(ADR-032 D11). It
ignores untracked files (--untracked-files=no), because obelize writes
.obelize/runs/<id>/ itself and would otherwise make every later run dirty.
The dirty-tree gate does not read it: it asks gitutil.tree with the plan, so
it also refuses a planned file git does not track (ADR-032 D2), which
git_dirty never counts. Every git call goes through gitutil.run, with one
timeout.
D11. argv is what the process received¶
argv records the arguments as received, excluding argv[0], absolute paths
included and, since 0.3.0, redacted (ADR-048), because it is
the audit record of what somebody typed (including
--trust-repo-config). An embedder calling app() in process therefore
records its host's sys.argv.
D12. The exit codes are a closed set in code¶
models.ExitCode is Literal[0, 1, 2, 3, 4, 5, 6, 7], RunRecord.exit_code
takes it, and a test asserts it equals CLI.md's exit-code table: changing a
code is a breaking change, so the two may not disagree.
Consequences¶
- A scan writes only under
.obelize/, which every scan excludes before any user configuration, so a second scan never reports the first one's evidence. - A read-only checkout cannot be scanned: it exits
1(D5). No flag turns the run folder off; one could be added later without breaking anything. - Importing
obelize.clipulls in none of libcst, pydantic, pyyaml or pathspec; a subprocess test asserts it. - A floor declared only in
setup.py(python_requires=">=3.8"in one of eighty repositories measured, ADR-024) is read where it is a string literal, which was the narrowest fix; one built from a variable still reads as no floor. - Open under D6: a project declaring
>=3.9that no longer tests on 3.9 is blocked; if that is disputed, the answer is a flag, not a quieter rule.