ADR-008: Scanner and codemod corrections¶
Status¶
Accepted; amended by ADR-010 (F-1, F-4, F-6, F-7, F-8), ADR-012 (D6), ADR-017 (D1, D2), ADR-019 (D1), ADR-022 and ADR-050.
Decision¶
These rules hold for libcst 1.9.0 (1.8.6 on an Intel Mac), google-genai 2.23.0 and
google-generativeai 0.8.6 as installed, where a design read off their
documentation needed 57 corrections, and are re-checked when any version changes.
Each names its entry in Spike corrections, which lists every correction the
repository cites. The closed vocabularies they use live in
docs/SCAN_VOCABULARY.md (ADR-010 F-4).
Resolving a legacy usage¶
- Collect the whole qualified-name set, then branch. Exactly one name with
the
google.generativeaiprefix andsource == IMPORT-> resolved; two or more ->conditional_binding; anyLOCALname alongside -> rebound, never auto-fix (C-04). - Flag module-alias escapes. An
Accessof a legacyImportAssignmentwhose parent is not anAttributemakes the whole fileneeds_review(C-05). - Detect star imports through the scope, not the provider. With a legacy
ImportStarin the module, a candidate is a name inscope.accesseswith no assignment record anywhere in the scope chain (C-06). - String annotations are real findings (
kind=attribute,from_import_resolved), neverstring_or_comment_mention;mock_patch_targetis string matching (C-26). module_alias_reboundis theconfidence_reasonfor both C-04's rebinding and C-05's alias escape (C-43).
Reading and writing files¶
- Bytes in, bytes out.
Path.read_bytes()->cst.parse_module(bytes)-> writemodule.bytes->compile(module.bytes, path, "exec"); neverread_text()orparse_module(str)(C-10). - Gate every file on a byte round-trip before any edit. A file where
cst.parse_module(data).bytes != datais analysed and reported but never written:roundtrip_mismatch(C-09, C-43). The gates runcompile(), then libcst, then the round-trip (ADR-017 D1). - Run
compile()on the input as well as the output. A file libcst parses butcompile()rejects is aparse_errorand never edited (C-08). cst.ParserSyntaxErroris not aSyntaxErrorsubclass. The libcst gate catches(cst.ParserSyntaxError, SyntaxError, ValueError)and thecompile()gateSyntaxError,RecursionErrorandMemoryError(ADR-017 D2); coordinates come from.raw_line(1-based) and.raw_column(0-based), or.lineno. No source excerpt goes into evidence:.contextis one (C-08).
Selecting files¶
- Resolve both sides of the containment check:
candidate.resolve(strict=True).is_relative_to(root.resolve(strict=True))insideexcept (OSError, RuntimeError): return False, plus the separateis_symlink()guard (C-13). Path.is_file()is mandatory, not defensive: it drops a gitlink (C-14).- Prune nested
.gitentries in theos.walkfallback; thegit ls-files -z --cached --others --exclude-standardprimary path stays (C-14). - Findings are ordered by the total key below before anything is hashed or written.
Determinism¶
- Sort every libcst set by source position before consuming it:
sorted(..., key=lambda x: position_provider[x.node].start), before any id is hashed or any finding is emitted (C-11). - Make the output sort key total:
(file path bytes, sha256 of content)(C-12).
Transforming¶
MetadataWrapper(module, unsafe_skip_copy=True)is load-bearing, since the default copy makes a transform a silent no-op; a unit test assertsMetadataWrapper(m, unsafe_skip_copy=True).module is m. Transforms run throughwrapper.visit(transformer)and declareMETADATA_DEPENDENCIES(C-07).- Promote a positional argument to a keyword (
contents=) before prependingmodel=, and convertcst.CSTValidationErrorintoBailErrorinside the rule, so the group becomesneeds_reviewand the file's other groups still apply (C-25).
Bindings and the client¶
- D1 -- parent lookup uses libcst's
ParentNodeProvider, not a hand-rolled parent stack. The alias-escape rule and the walk fromAssignment.nodeto the constructorCallneed it, and its cost falls only on prefilter survivors (C-35). The providers resolve in oneresolve_many,ScopeProviderfree. Analysis resolves and records,impact/planner.pydecides, andanalysis.BAILSis the seam (ADR-019 D1). - D2 --
self_attrbindings ship in v0, not v0.2, because two of the three corpus repositories need them. Match on (enclosingClassDefnode, first-parameter name, attribute name), never on the qualified-name string. - D3 --
ClientSourceis module-wide. Exactly oneconfigure(...)anywhere in a module -- module level, a method or a plain function -- is its client source. Two or more bail (multiple_configure_calls); zero isclient_source_unresolved->needs_reviewfor that module's call rewrites. A module whoseconfigureanother in-scope module without one still relies on is withheld whole, underconfigure_consumed_elsewhere(ADR-031 D11) (C-30).
Pack rule corrections¶
GenerationConfigmust map toGenerateContentConfig, never identity-map,genai.types.GenerationConfig(...)call sites included (C-03).- Safety names are validated against
__members__, in tests, never by construction, and the pack'slimitationssay so (C-02). - The safety maps are the legacy lookup's closed table, keyed by
value.lower()(the pack'ssafety.category_mapandsafety.threshold_map), with identity rows for the canonical long forms;dangerous_content,offandnoneare never keys (C-16, C-32). send_message(content=X)becomessend_message(message=X)(C-17).- Async streaming preserves its
await:async for x in await model.generate_content_async(p, stream=True)becomesasync for x in await client.aio.models.generate_content_stream(...),autowithasync_stream_await_preserved; theawait-less form bailsasync_stream_await_missing(ADR-010 F-8) (C-18). - The legacy constructor's positional order is
(model_name, safety_settings, generation_config, tools, tool_config, system_instruction); positionals are mapped by index, withpositional_args_mapped_by_index(ADR-010 F-7) (C-22). - The legal
generation_configkey set is exactly the 15protos.GenerationConfigfields; anything else,seedincluded, is an unknown key ->needs_review(C-23). configure's unsupported kwargs aretransport,client_options,client_info,default_metadata; a dict-literalcredentials=bailscredentials_shape_differs(C-19).- The API-key environment precedence is inverted between the SDKs, and
Client()raises at construction when the key is absent or empty. Theconfigure()->Client()rewrite staysauto, with both as limitations and the warningclient_constructed_eagerly(ADR-010 F-6) (C-20). - A bare
GenerativeModel()isneeds_review(default_model_name_required): the legacy default model is retired, so none is substituted (C-21). - D4 -- chat history is rewritten only in the fully literal case. A list
literal of dict literals with string-literal
partsand rolesuserormodelhas each part rewritten to{"text": <the string>}; everything else bailshistory_parts_shape_incompatible, because pass-through imports and then throws, the worst outcome available (C-01).
Benchmark integrity¶
- Version pinning is mandatory and asserted after install:
bench/cases.yamlpinsto_version, and a case whose installed version differs fails (C-15). bench/cases.yaml'spythonfield carries a full uv interpreter key, e.g.cpython-3.12.9-macos-aarch64-none; provenance records the interpreter path andplatform.machine()(C-15).- Blocklist a second Vertex origin:
vertexai.preview.generative_models; negative controls importGenerativeModelfrom both origins (C-24).
Deferred¶
- D5 -- the process-pool threshold -- settled by
ADR-022: 32 candidate
files, with a default of
min(8, cpu_count - 1)workers (C-33). - D6 -- pack rules 7-10 (tools/AFC,
embed_content, files, models) ship in v0, their mappings verified against the installed packages; the pack'slimitationssay the development corpus never exercised them, each occurring zero times in its three repositories (C-45).
Spike corrections¶
| Id | Correction |
|---|---|
| C-01 | Legacy chat history's bare string parts and non-user/model roles fail the new SDK's validation, so only the fully literal shape is rewritten. |
| C-02 | The new SDK's HarmCategory and HarmBlockThreshold fabricate any member named, with only a warning, so a wrong safety name never raises. |
| C-03 | types.GenerationConfig exists in both SDKs, and an identity rewrite imports cleanly and does nothing. |
| C-04 | QualifiedNameProvider is flow-insensitive and multi-valued: genai = None after the import still yields the IMPORT name. |
| C-05 | After g = genai, g.GenerativeModel("m") resolves only to LOCAL, so the prefix rule never fires. |
| C-06 | Star-imported names resolve to an empty set in the provider but appear in scope.accesses. |
| C-07 | Without unsafe_skip_copy=True the wrapper deep-copies the module and every transform silently changes nothing. |
| C-08 | libcst accepts some code CPython refuses, and cst.ParserSyntaxError is not a SyntaxError. |
| C-09 | A file whose only line terminator is a bare CR loses its last byte through libcst and still compiles. |
| C-10 | parse_module(str) ignores the PEP 263 cookie and a BOM breaks compile() on a str, so files are handled as bytes. |
| C-11 | scope[name] and Assignment.references iterate in heap order, which differs between processes. |
| C-12 | Paths repeat across roots, so a sort by path alone leaks input order when bench/run.py aggregates many. |
| C-13 | On macOS /tmp is a symlink, so resolving only the candidate puts every file outside the root. |
| C-14 | git ls-files lists a submodule as an ordinary path, and the os.walk fallback descends into it. |
| C-15 | An unpinned install on Python 3.9 silently resolves google-genai==1.47.0, and a bare --python version can pick another interpreter. |
| C-16 | The legacy safety lookup is a closed lower-cased table; canonical long forms need identity rows, and DANGEROUS_CONTENT was never a key. |
| C-17 | Legacy send_message takes content, the new one message, so the keyword form is a TypeError unless renamed. |
| C-18 | Both async streaming calls are coroutine functions, so real legacy code already has the await the new call needs. |
| C-19 | configure accepts transport, client_options, client_info and default_metadata, which Client lacks, and a plain-dict credentials. |
| C-20 | The SDKs read GEMINI_API_KEY and GOOGLE_API_KEY in opposite order, and Client() raises ValueError at construction without a key. |
| C-21 | Legacy GenerativeModel() defaults to "gemini-1.5-flash-002", a retired model, while the new call requires model=. |
| C-22 | The legacy constructor puts safety_settings before generation_config, and every parameter is positional-or-keyword. |
| C-23 | seed is in neither the legacy dataclass nor the protos, so it reaches obelize only through an unvalidated dict. |
| C-24 | vertexai.preview.generative_models exports a second live GenerativeModel that only its origin tells apart. |
| C-25 | libcst raises CSTValidationError when a keyword is prepended before a positional argument. |
| C-26 | A string annotation resolves to its legacy qualified name even under if TYPE_CHECKING:; a mock.patch string resolves to nothing. |
| C-27 | from google import generativeai becomes from google import genai, every generativeai.Y becomes genai.Y, and the alias_collision bail applies. |
| C-28 | match.imports is a dotted-prefix match on google.generativeai, not a list of names. |
| C-29 | The manifest reader includes tool.poetry.group.<name>.dependencies. |
| C-30 | Corpus code calls configure() inside functions and helper methods, so the client source is module-wide. |
| C-31 | A generation_config or safety_settings that is a name assigned once, and read only as such an argument, resolves like a literal instead of bailing generation_config_not_static (ADR-027 D4); COVERAGE gap 29 is closed. |
| C-32 | The only static safety_settings in the corpus writes the canonical long forms, which is why the identity rows are required. |
| C-33 | The pool pays from 32 candidate files once the default worker count leaves the parent a core. |
| C-35 | ParentNodeProvider adds 17-30% to metadata resolution, paid only by the 2.19% of files that survive the byte prefilter. |
| C-37 | The Python floor behind blocked: runtime_unsupported comes from the manifest, never from the parser. |
| C-43 | The closed vocabularies gain exactly two members: module_alias_rebound and roundtrip_mismatch. |
| C-45 | Tools and AFC, embed_content, the files calls and the models calls occur zero times in the three-repository corpus: absent there, not shown rare. |
| C-46 | Every legacy file in the three-repository corpus bails somewhere, so it yields "plausibly imports and little else"; that is a corpus problem as much as a rules problem. |
| C-47 | A resumable= upload has no counterpart and is refused as an unsupported keyword (ADR-029 D8); client.files.delete returns a DeleteFileResponse where legacy delete_file returned None. |
| C-48 | base_model_id and supported_generation_methods are gone from the new types.Model, and get_model is rewritten only for a models/ literal, since legacy returned another class for a tunedModels/ name. |
| C-56 | The new response.text returns None where the legacy one raised ValueError, so code catching that error is reported, not rewritten, and the pack's limitations say so. |
| C-57 | A test that patches through the application's own module path never names the SDK, so a per-file scan misses it; the pool threshold counts candidate files. |
Consequences¶
- The honest one. Every legacy file in the three development-corpus repositories carries a bailing usage, so under ADR-010 F-1 their automatic edit count is zero; over eighty repositories from GitHub code search, 57 have a file where nothing bails (ADR-024).
- A module-wide
ClientSource(D3) asserts that oneconfiguregoverns its whole module.