mirror of
https://github.com/radixark/miles.git
synced 2026-10-02 07:14:53 +08:00
Update the mechanical-refactor-verify skill to the latest sglang version (#1890)
This commit is contained in:
@@ -1,152 +1,82 @@
|
||||
---
|
||||
name: mechanical-refactor-verify
|
||||
description: Verify mechanical refactoring commits by requiring a reproducible transform script (gist) in the PR description. Use when doing or reviewing file splits, function moves, or module extractions.
|
||||
description: Make mechanical refactoring (file splits, function moves, module extractions, renames) machine-checkable instead of eyeballed. Reproduce a relocation commit byte-for-byte from faithful primitives, and split an extraction into a verifiable prepare + move + postpare. Use when doing or reviewing such changes.
|
||||
user_invocable: true
|
||||
argument: "[verify <pr_url_or_commit>] — verify an existing PR, or omit to see the workflow guide"
|
||||
argument: "split <base>..<tip> | construct <base>..<tip> [--match REGEX] [--out DIR] | verify --base <base> --branch <branch> --proof <folder> [--jobs N] [--skip-passed]"
|
||||
---
|
||||
|
||||
# Mechanical Refactor — Reproducible Verification
|
||||
# Mechanical Refactor — Machine-Checkable Verification
|
||||
|
||||
## Core Principle
|
||||
## 1. Overview
|
||||
|
||||
The deliverable of a mechanical move (file split, function move, module extraction) is NOT the diff — it is **the script that produces the diff**.
|
||||
A script is auditable; a diff is not.
|
||||
- The correctness of a mechanical change (file split, function move, module extraction,
|
||||
rename) must be **machine-checkable, not eyeballed** — the proof is something anyone can
|
||||
re-run, whoever made the change and whenever.
|
||||
- **One property**: *a commit is a pure relocation*. **One proof**: **reproduce** —
|
||||
regenerate the move from the base commit with faithful primitives, run the formatter,
|
||||
byte-diff against the target.
|
||||
- Empty diff = the proof. Any residual = a bundled non-move change, surfaced for review.
|
||||
- A reshape must not ride along: split into optional **prepare** + certified **move** +
|
||||
optional **postpare** (`guide-split.md`).
|
||||
|
||||
## Workflow
|
||||
## 2. Commands — what do you want to do?
|
||||
|
||||
Regardless of who did the move (human or agent) and when (before or after committing), the workflow is the same:
|
||||
The skill takes an argument naming one of three commands; invoked without one, pick the
|
||||
row matching your task.
|
||||
|
||||
### Step 1: Write the transform script to /tmp/
|
||||
- **`split <base>..<tip>`** — author a compliant refactor branch: split it into commits,
|
||||
satisfy the contract (extract, move, file split) → `guide-split.md`: §1 splits the PR
|
||||
into classified pieces (the chain contract: classification format, correct labeling,
|
||||
proofs PASS, non-mechanical commits correctness-reviewed); §2 splits one piece into
|
||||
prepare + move + postpare (the case recipes and the anti-patterns). The argument is the
|
||||
chain to author (or a single commit / a description of the change to split).
|
||||
- **`construct <base>..<tip> [--match REGEX] [--out DIR]`** — construct the proof, for
|
||||
the chain or one commit → `guide-construct-proof.md`: §1 generates + publishes the
|
||||
whole chain's proof folder (the flags are the generator's:
|
||||
`scripts/mechanical_refactor_proof_generator.py <base>..<tip> --match REGEX --out DIR`);
|
||||
§2 proves a single commit — pass just `<commit>` (the generator, or a hand-written
|
||||
`Repro` when it reports `UNSUPPORTED`).
|
||||
- **`verify --base <base> --branch <branch> --proof <folder> [--jobs N] [--skip-passed]`**
|
||||
— verify someone's proof: a whole chain / PR branch → `guide-verify-proof.md`: run the
|
||||
chain verifier with exactly these flags
|
||||
(`scripts/mechanical_refactor_reproduction_cli.py`) — it checks every commit declares
|
||||
`mechanical_provable` or `non_mechanical_provable`, runs **every** provable commit's
|
||||
proof (never a sample), and writes one full report; then audit the authored surfaces
|
||||
and the `HUMAN_REVIEW` rows. Re-running one commit's script is for diagnosis only.
|
||||
- **Decide whether a change counts as a clean move** → `spec-reproduction-utils.md`: the
|
||||
property, the whole whitelist / not-allowed list, and each primitive's contract. The
|
||||
source of truth for the reproduction module; if any other file disagrees, it wins.
|
||||
- **Change this skill itself** (edit the engine, the generator, or the spec) →
|
||||
`guide-modify-skill.md`: the spec-leads rule, the faithfulness invariant, and the testing
|
||||
bar a change must clear before it is trusted.
|
||||
|
||||
Write the script to `/tmp/transform_<short_description>.py`. **Never write it inside the repo.**
|
||||
## 3. Files
|
||||
|
||||
The scaffold (worktree creation, diff check, ruff format, result reporting) lives in `mechanical_refactor_verify_utils.py` next to this skill.
|
||||
|
||||
**MANDATORY**: The transform script MUST use `verify_mechanical_refactor()` from the utils module. Do NOT reimplement the verification scaffold — no hand-written worktree management, no hand-written diff checking. The script only defines `transform()` and calls `verify_mechanical_refactor`.
|
||||
|
||||
#### Key principle: move lines, don't paste content
|
||||
|
||||
The transform script must **read lines from the source file and write them to the target**, not hardcode large blocks of code as string literals. This is what makes the script auditable — a reviewer can verify line ranges against the original file.
|
||||
|
||||
**Rules:**
|
||||
1. **Move code by line ranges**: read the source, extract line slices, write to target. Never paste >3 lines of code as string literals in the script.
|
||||
2. **Minimal replacements**: when adapting moved code (e.g. `self.foo` → `foo`), use small targeted `str.replace()` or `re.sub()` calls. Each replacement should be a short pattern, not a full block of code.
|
||||
3. **New content is OK only for glue**: import statements, function signatures (the `def` line + parameters), and other 1-2 line connective tissue can be written as literals. The *body* of moved functions must come from line extraction.
|
||||
|
||||
#### Script template (follow this structure exactly)
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Reproducible transform for: <describe the mechanical move>
|
||||
|
||||
Run from the repo root: python3 /tmp/transform_<short_description>.py
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(".claude/skills/mechanical-refactor-verify")
|
||||
from mechanical_refactor_verify_utils import verify_mechanical_refactor, exec_command, git_add_and_commit, dedent
|
||||
|
||||
BASE_COMMIT = "<base_sha>"
|
||||
TARGET_COMMIT = "<pr_mechanical_move_final_sha>"
|
||||
|
||||
|
||||
def transform(dir_root: Path) -> None:
|
||||
"""Perform the mechanical transformation and commit each step.
|
||||
|
||||
Args:
|
||||
dir_root: Path to the worktree (checked out at BASE_COMMIT).
|
||||
"""
|
||||
# --- Step 1: Extract functions by line range ---
|
||||
source = dir_root / "path/to/source.py"
|
||||
content = source.read_text()
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Move lines 100-150 to new file, dedenting from method to top-level
|
||||
extracted = "".join(lines[99:150])
|
||||
extracted = dedent(extracted, 4) # remove class indentation
|
||||
# Minimal adaptations: self.x -> x (parameter)
|
||||
extracted = extracted.replace("self.args", "args")
|
||||
|
||||
target = dir_root / "path/to/pkg/target.py"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("import logging\n\n\n" + extracted)
|
||||
|
||||
# --- Step 2: Remove extracted lines from source, add import ---
|
||||
# Delete lines 100-150 from source
|
||||
new_lines = lines[:99] + lines[150:]
|
||||
source.write_text("".join(new_lines))
|
||||
|
||||
# Small targeted replacement: add import, replace call site
|
||||
content = source.read_text()
|
||||
content = content.replace(
|
||||
"from path.to.old import foo",
|
||||
"from path.to.old import foo\nfrom path.to.pkg.target import extracted_func",
|
||||
)
|
||||
# Replace inline code with function call (use a short unique anchor)
|
||||
content = content.replace("old_call_pattern", "new_call_pattern")
|
||||
source.write_text(content)
|
||||
|
||||
git_add_and_commit("mechanical: extract functions", cwd=str(dir_root))
|
||||
|
||||
# Note: pre-commit run --all-files is run automatically after transform() returns
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
verify_mechanical_refactor(
|
||||
base_commit=BASE_COMMIT,
|
||||
target_commit=TARGET_COMMIT,
|
||||
transform=transform,
|
||||
)
|
||||
```
|
||||
|
||||
### Step 2: Run the script from the repo root
|
||||
|
||||
```bash
|
||||
cd <repo_root>
|
||||
python3 /tmp/transform_<short_description>.py
|
||||
# Expected: "PASS: transform reproduces the commit exactly."
|
||||
```
|
||||
|
||||
If FAIL, fix the script and re-run until PASS.
|
||||
|
||||
### Step 3: Upload gist, delete local file, update PR description
|
||||
|
||||
One gist per PR. Do all three:
|
||||
|
||||
```bash
|
||||
# 1. Create gist (or update existing)
|
||||
gh gist create --public -d "Mechanical refactor transform: <description>" /tmp/transform_<short_description>.py
|
||||
# Or update: gh gist edit <gist_id> -a /tmp/transform_<short_description>.py
|
||||
|
||||
# 2. Delete local file
|
||||
rm /tmp/transform_<short_description>.py
|
||||
|
||||
# 3. Update PR description (paste the block below)
|
||||
```
|
||||
|
||||
PR description must include:
|
||||
|
||||
````markdown
|
||||
## Mechanical Move
|
||||
|
||||
Transform script: <gist_url>
|
||||
|
||||
### One-click verification
|
||||
|
||||
```bash
|
||||
python3 <(curl -sL <gist_raw_url>)
|
||||
```
|
||||
````
|
||||
|
||||
### Step 4: PR scope
|
||||
|
||||
A mechanical refactor PR must contain **only** mechanical changes (moves, splits, renames, import fixes, formatting). All of these must be reproducible by the transform script.
|
||||
|
||||
Semantic changes (new logic, API restructuring, behavior changes) belong in a **separate PR**.
|
||||
|
||||
## Verifying an existing PR (`/mechanical-refactor-verify verify`)
|
||||
|
||||
1. Find the gist URL and one-click command in the PR description
|
||||
2. Run the one-click command from the repo root
|
||||
3. Report: PASS or show the diff
|
||||
- [`guide-split.md`](guide-split.md) — split the PR into classified pieces (§1, the
|
||||
chain contract) and each piece into prepare + move + postpare (§2: case recipes, what
|
||||
stays mechanical, anti-patterns).
|
||||
- [`guide-construct-proof.md`](guide-construct-proof.md) — produce the proof: the whole
|
||||
chain's proof folder + publishing (§1), and a single commit's proof — generator or
|
||||
hand-written `Repro` (§2).
|
||||
- [`guide-verify-proof.md`](guide-verify-proof.md) — consume the proof: the whole-chain
|
||||
verifier, single-commit re-runs, verdicts, and the audit checklist for authored
|
||||
surfaces.
|
||||
- [`spec-reproduction-utils.md`](spec-reproduction-utils.md) — the normative spec of the
|
||||
clean-move property and the reproduction primitives.
|
||||
- [`spec-reproduction-cli.md`](spec-reproduction-cli.md) — the normative spec of the
|
||||
verified-chain property: the classification word rule, the proof obligation, the report,
|
||||
and the exit codes.
|
||||
- [`guide-modify-skill.md`](guide-modify-skill.md) — change the engine, the generator, or
|
||||
the spec: the spec-leads rule, the byte-faithfulness invariant, and the testing bar.
|
||||
- [`scripts/mechanical_refactor_proof_generator.py`](scripts/mechanical_refactor_proof_generator.py) —
|
||||
the **generator**: infers a reproduce recipe from a commit's diff and emits/runs a
|
||||
standalone, auditable script per commit, with a `PASS` / `RESIDUAL` / `UNSUPPORTED` verdict.
|
||||
- [`scripts/mechanical_refactor_reproduction_utils.py`](scripts/mechanical_refactor_reproduction_utils.py) — the
|
||||
**proof engine**: the `Repro` builder's faithful relocation primitives plus the worktree +
|
||||
pre-commit + byte-diff scaffold. Self-contained — only git and the standard library.
|
||||
- [`scripts/mechanical_refactor_reproduction_cli.py`](scripts/mechanical_refactor_reproduction_cli.py) — the
|
||||
**chain verifier**: classifies every commit in `base..branch`, runs every provable
|
||||
commit's proof from the proof folder, and emits the full chain report.
|
||||
- [`scripts/tests/`](scripts/tests/) — pytest suites, one folder per module:
|
||||
`reproduction_utils/` for the proof engine, `proof_generator/` for the generator,
|
||||
`reproduction_cli/` for the chain verifier.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# Construct a proof
|
||||
|
||||
- Two levels, one chapter each: §1 constructs the **proof folder for a whole chain** (and
|
||||
publishes it with the PR); §2 constructs the proof for a **single commit** (the
|
||||
generator, the hand-written `Repro`, the hand-written transform).
|
||||
- The property and primitive contracts: `spec-reproduction-utils.md`. Splitting the change
|
||||
so the move is provable: `guide-split.md`. Consuming the proof: `guide-verify-proof.md`.
|
||||
|
||||
## 1. Construct proofs for a whole chain
|
||||
|
||||
### 1.1 Generate the proof folder
|
||||
|
||||
```bash
|
||||
# a range: write a self-contained folder for every mechanical_provable commit
|
||||
python3 .claude/skills/mechanical-refactor-verify/scripts/mechanical_refactor_proof_generator.py \
|
||||
<base>..<tip> --match '(?<!_)mechanical_provable' --out repro_out
|
||||
```
|
||||
|
||||
- `mechanical_refactor_proof_generator.py` infers each recipe from a commit's diff and
|
||||
before-state AST.
|
||||
- It emits and runs a standalone, auditable script per commit — no one hand-writes it.
|
||||
|
||||
### 1.2 The folder product
|
||||
|
||||
Self-contained, auditable without the skill installed:
|
||||
|
||||
- `repro_scripts/<sha>.py` — one script per commit;
|
||||
- `output.log` + `output.html` — the verdicts;
|
||||
- a copy of `mechanical_refactor_reproduction_utils.py` — the scripts' only dependency.
|
||||
|
||||
The folder is also the `--proof` input to the chain verifier
|
||||
(`mechanical_refactor_reproduction_cli.py`, contract in `spec-reproduction-cli.md`).
|
||||
|
||||
### 1.3 Publish the proof with the PR
|
||||
|
||||
#### 1.3.1 What to share
|
||||
|
||||
- Share the scripts **plus** the copied `mechanical_refactor_reproduction_utils.py` — the
|
||||
scripts import it, so a lone raw file is not runnable.
|
||||
- Never a `python3 <(curl ...)` one-liner: process substitution gives the script no real
|
||||
directory, so the import breaks.
|
||||
- Flat layouts work: Python puts the script's own directory on `sys.path`, so the utils
|
||||
module can sit either next to the script or one level up (the `--out` layout).
|
||||
|
||||
#### 1.3.2 Author: create a gist
|
||||
|
||||
```bash
|
||||
cd repro_out
|
||||
gh gist create --desc "mechanical-move proof for PR #NNNN" \
|
||||
repro_scripts/*.py mechanical_refactor_reproduction_utils.py output.log
|
||||
# prints https://gist.github.com/<user>/<gist_id> -- put it in the PR description
|
||||
```
|
||||
|
||||
- `gh gist create` flattens paths — fine per §1.3.1.
|
||||
- Alternatives: a PR attachment (zip the `--out` folder) or a branch holding it.
|
||||
- Put the reviewer's download-and-re-run commands (`guide-verify-proof.md` §2.1) in the
|
||||
PR description under a "Mechanical move — reproducible" heading.
|
||||
|
||||
#### 1.3.3 Keep the classification honest — in both directions
|
||||
|
||||
- A `mechanical_provable` commit (and a PR made only of such commits) contains **only**
|
||||
mechanical changes (moves, splits, renames, import fixes, formatting). Semantic changes
|
||||
go in their own `non_mechanical_provable` commits — a chain, and therefore a PR, need
|
||||
not be purely mechanical, but a single commit never mixes the two.
|
||||
- The dual holds too: a semantic (`non_mechanical_provable`) commit must not swallow a
|
||||
provable relocation to skip the proof — split it out and prove it
|
||||
(`guide-split.md` §2.2; property: `spec-reproduction-cli.md` §2.1).
|
||||
|
||||
## 2. Construct the proof for a single commit
|
||||
|
||||
### 2.1 What a proof is
|
||||
|
||||
- A runnable script that regenerates the commit from its base with the faithful relocation
|
||||
primitives and byte-diffs the result against it.
|
||||
|
||||
### 2.2 Auto-generate the script (primary path)
|
||||
|
||||
```bash
|
||||
# one commit: print the inferred script and run it (non-zero exit unless PASS)
|
||||
python3 .claude/skills/mechanical-refactor-verify/scripts/mechanical_refactor_proof_generator.py <commit>
|
||||
```
|
||||
|
||||
#### 2.2.1 What the inference covers
|
||||
|
||||
- **Method → existing class**: call sites lowered (`Owner.m(recv, …)` → `recv.m(…)`), the
|
||||
orphaned local import removed.
|
||||
- **Method → module-level free function**: call sites requalified (`Owner.m(…)` → `m(…)`).
|
||||
- **Free function → existing module**: the call stays bare; callers repath their import
|
||||
(`repath_import` when function-scoped; module-level repoints realised as remove-old +
|
||||
add-new).
|
||||
- **New-module extract of scattered defs**: `extract_symbols_to_new_module` under the
|
||||
audited header; a constant that relocated into the header is dropped from the source.
|
||||
A contiguous-tail source still uses `extract_to_new_module`.
|
||||
- **Inline-block extract-function** (intra-file): a new helper whose verbatim body is a
|
||||
block cut from a sibling function, that function's block replaced by a call — inferred as
|
||||
`extract_function`, authoring only the signature, the call, and (when the block ends in
|
||||
`lhs = expr` returned by the helper) a `return lhs`. A body edited on the way out (a
|
||||
de-self / restructure) does not infer — the residual surfaces it.
|
||||
- **A move landing just above an `if TYPE_CHECKING:` guard**: anchored with
|
||||
`move_symbol(after=<preceding symbol>)` rather than a `before=` that would overshoot past
|
||||
the guard.
|
||||
- **A source file the commit deletes** once its defs relocated: `delete_file`.
|
||||
- **The module-level import diff**, realised directly from the target: gained names added
|
||||
(a wholly new module's statement verbatim, wrapping kept, or one name folded into an
|
||||
existing `from module import …` with `add_imported_name`), lost names removed with
|
||||
`remove_imported_name`.
|
||||
- Non-Python files in the commit do not block inference; their diff is noted and left to
|
||||
the residual.
|
||||
|
||||
#### 2.2.2 What it reports `UNSUPPORTED`
|
||||
|
||||
- Single-commit mode prints the verdict with notes and exits non-zero; range mode
|
||||
records it in the outputs.
|
||||
- Review such a commit as prepare, or hand-write the `Repro` (§2.3).
|
||||
- The cases:
|
||||
- **no definition relocated** — a rename (even a privacy flip `_foo` → `foo`) or a
|
||||
statement-level reorder; reshapes belong in prepare;
|
||||
- **a new-module extract whose symbols are not all top-level in the source** — a
|
||||
method still inside a class; prepare must de-self it out first;
|
||||
- **an extract drawing from more than one source file** — compose `extract_function` by
|
||||
hand. An inline-block extract-function within one file is inferred (§2.2.1), but only
|
||||
when the body is a verbatim cut; a de-self / restructure on the way out is a separate
|
||||
semantic commit and does not infer.
|
||||
|
||||
### 2.3 Hand-write the `Repro` when inference falls short
|
||||
|
||||
- Compose the transform from the same primitives (`spec-reproduction-utils.md` §3).
|
||||
- The same byte-diff then certifies it.
|
||||
- **`UNSUPPORTED`, or a primitive that cannot express the exact edit, is never a reason to
|
||||
relabel a relocation as `non_mechanical_provable`.** If the change is a relocation, it
|
||||
gets a proof: hand-write the `Repro` here, or (when a primitive genuinely lacks the needed
|
||||
form, e.g. an insertion anchor) enhance the primitive first, then prove it. See
|
||||
guide-split.md §2.7.6.
|
||||
|
||||
```python
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(".claude/skills/mechanical-refactor-verify/scripts")
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
|
||||
r = Repro(base="<base_sha>", target="<commit>")
|
||||
# Adapt call sites / repath imports BEFORE moving, so a call to a moved method from inside
|
||||
# another moved method is lowered while still in the source and travels with the body.
|
||||
r.lower_call_sites("update_weights_from_ipc", "ModelRunner", paths=["a.py", "b.py"])
|
||||
r.remove_import("a.py", "from x import ModelRunner", in_function="update_weights_from_ipc")
|
||||
r.move_symbol("update_weights_from_ipc", src="a.py", dst="dst.py", into_class="WeightUpdater", dedent=0)
|
||||
r.add_import("dst.py", "import gc")
|
||||
r.run() # PASS = byte-identical; otherwise prints the residual
|
||||
```
|
||||
|
||||
### 2.4 A hand-written transform for a non-relocation mechanical change
|
||||
|
||||
- For a whole-file split or rename — no single symbol relocates — write a `transform()`
|
||||
and call `verify_mechanical_refactor`.
|
||||
- The scaffold (worktree, pre-commit, diff, reporting) lives in the skill's utils.
|
||||
|
||||
```python
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(".claude/skills/mechanical-refactor-verify/scripts")
|
||||
from mechanical_refactor_reproduction_utils import verify_mechanical_refactor, git_add_and_commit
|
||||
|
||||
BASE_COMMIT = "<base_sha>"
|
||||
TARGET_COMMIT = "<final_sha>"
|
||||
|
||||
def transform(dir_root: Path) -> None:
|
||||
source = dir_root / "path/to/source.py"
|
||||
lines = source.read_text().splitlines(keepends=True)
|
||||
for target_path, start, end in [("path/to/a.py", 1, 50), ("path/to/b.py", 51, 120)]:
|
||||
target = dir_root / target_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("".join(lines[start - 1 : end]))
|
||||
source.unlink()
|
||||
git_add_and_commit("split source.py", cwd=str(dir_root))
|
||||
# A rename is just: for each file, write content.replace(OLD, NEW); commit.
|
||||
|
||||
if __name__ == "__main__":
|
||||
verify_mechanical_refactor(BASE_COMMIT, TARGET_COMMIT, transform)
|
||||
```
|
||||
@@ -0,0 +1,100 @@
|
||||
# Modify this skill: the engine, the generator, or the spec
|
||||
|
||||
- How to change `scripts/mechanical_refactor_reproduction_utils.py` (the proof engine),
|
||||
`scripts/mechanical_refactor_proof_generator.py` (the generator),
|
||||
`scripts/mechanical_refactor_reproduction_cli.py` (the chain verifier), or the specs —
|
||||
without silently weakening the proof.
|
||||
- Read this **before** editing any file under this skill. The engine is trusted: a wrong
|
||||
primitive certifies a non-mechanical commit as clean, and every downstream reviewer
|
||||
believes it. Changes here carry a higher bar than ordinary code.
|
||||
|
||||
## 1. What has which bar
|
||||
|
||||
| File | Role | Bar |
|
||||
|---|---|---|
|
||||
| `spec-reproduction-utils.md` | **normative** source of truth (SKILL.md §2): the clean-move property, the whitelist / not-allowed lists, each primitive's contract, the arbiter | any behavior change lands here first |
|
||||
| `spec-reproduction-cli.md` | **normative** source of truth for the chain verifier: the word rule, the proof obligation, the report, the exit codes | any behavior change lands here first |
|
||||
| `mechanical_refactor_reproduction_utils.py` | **trusted engine**: the relocation primitives + the arbiter | highest — byte-faithfulness proven by tests |
|
||||
| `mechanical_refactor_proof_generator.py` | **convenience**: infers a recipe from a diff | lower — may report `RESIDUAL`/`UNSUPPORTED` without compromising trust, but still tested |
|
||||
| `mechanical_refactor_reproduction_cli.py` | **gatekeeper**: walks a chain, runs the proofs, reports | high — a false chain PASS certifies an unproven commit; classification, resolution, and PASS-criterion behavior proven by tests |
|
||||
| `guide-*.md`, `SKILL.md` | workflow + file map | kept in sync, never describe behavior the code lacks |
|
||||
|
||||
## 2. Cardinal rule — the spec leads, code follows
|
||||
|
||||
- `spec-reproduction-utils.md` wins over every other file (SKILL.md §2). Code serves the
|
||||
spec, not the reverse.
|
||||
- A behavior change to a primitive, to §2.1/§2.2 (what counts as a clean move), or to the
|
||||
§4 arbiter **must edit the spec in the same commit as the code**. Code and spec never
|
||||
diverge across commits.
|
||||
- New primitive → add its contract to §3.
|
||||
- Changed clean-move boundary → edit §2.1 (allowed) / §2.2 (not allowed).
|
||||
- Changed reproduce/diff behavior → edit §4.
|
||||
- If you discover code and spec already disagree, the spec is authoritative: fix the code
|
||||
to match — or, if the spec itself is wrong, change it **deliberately** in one commit with
|
||||
the reasoning, not as a silent side effect.
|
||||
|
||||
## 3. The faithfulness invariant — never break this
|
||||
|
||||
- Every primitive relocates **original source bytes**: AST-located, spliced as the source
|
||||
text that was there, **never regenerated**. A byte match after the formatter is the
|
||||
*entire* proof — the moment a primitive regenerates instead of splicing, the proof is
|
||||
worthless (it can no longer distinguish "moved" from "rewritten to look moved").
|
||||
- A new or changed primitive **must**:
|
||||
- locate its target through the AST, not by string search over source;
|
||||
- splice the original bytes (interiors of multi-line strings, comments, a magic
|
||||
trailing comma, semicolon-joined statements all survive verbatim);
|
||||
- preserve the file's newline style (CRLF round-trips) and UTF-8-byte-accurate columns.
|
||||
- Do **not** add a primitive that normalizes, reflows, or reformats. Formatting belongs to
|
||||
the pre-commit pass in the §4 arbiter, applied to **both** sides; primitives do
|
||||
relocation only.
|
||||
- When the generator cannot infer a move, the answer is a hand-written `Repro`
|
||||
(guide-construct-proof §2.3) — **not** loosening a primitive to make it fit.
|
||||
|
||||
## 4. Testing rules — the hard bar
|
||||
|
||||
- The engine is trusted, so an untested change to it is not acceptable. "It ran once" is
|
||||
not a test.
|
||||
- Run the **full** suite and keep it green:
|
||||
|
||||
```bash
|
||||
cd scripts && uv run --with pytest --python 3.12 python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
Baseline at the time of writing: **188 passed**. Your change must leave the count at or
|
||||
above baseline — never delete a case to make the suite pass.
|
||||
- Layout mirrors the modules; put your test where it belongs:
|
||||
- `tests/reproduction_utils/` — one `test_<primitive>.py` per engine primitive.
|
||||
- `tests/proof_generator/` — the inference layer (`test_infer_*`, `test_script_and_diff`).
|
||||
- `tests/reproduction_cli/` — the chain verifier (classification, proof discovery,
|
||||
chain walking, the report).
|
||||
- A new or changed **primitive** requires, in its `test_<primitive>.py`:
|
||||
- a **byte-exact** assertion on the resulting file (compare full bytes, not "contains");
|
||||
- at least one **adversarial** case where a regenerating implementation would differ
|
||||
from splicing — a comment mid-body, a magic trailing comma, odd indentation, a
|
||||
semicolon-joined import, non-ASCII text, or a CRLF file — asserting the original
|
||||
bytes survive;
|
||||
- the **raise paths** the spec promises: ambiguous anchor raises, missing anchor
|
||||
raises, wrong/absent `from_class` raises.
|
||||
- A change to the **generator** requires a `tests/proof_generator/` case that runs it on a
|
||||
synthetic commit and asserts `PASS`, plus one non-move / bundled-change case that asserts
|
||||
`RESIDUAL` or `UNSUPPORTED` — so a future regression that makes it "pass" a dirty commit
|
||||
is caught.
|
||||
- A change to the **chain verifier** requires a `tests/reproduction_cli/` case asserting a
|
||||
verified chain passes, plus one asserting the broken shape it guards (an unclassified
|
||||
commit, a missing proof, a failing proof) still fails — so a regression cannot silently
|
||||
green a dirty chain.
|
||||
- The engine stays **self-contained**: `mechanical_refactor_reproduction_utils.py` imports
|
||||
only `git` (via subprocess) and the standard library. Do not add a third-party dependency
|
||||
to it.
|
||||
|
||||
## 5. Before you commit — checklist
|
||||
|
||||
- [ ] `spec-reproduction-utils.md` / `spec-reproduction-cli.md` edited in this same
|
||||
commit (if any behavior changed).
|
||||
- [ ] Full pytest suite green; case count ≥ prior baseline.
|
||||
- [ ] New/changed primitive has: byte-exact test + ≥1 adversarial (regeneration-would-differ)
|
||||
case + the raise-path tests.
|
||||
- [ ] Generator change has a `PASS` test **and** a `RESIDUAL`/`UNSUPPORTED` test.
|
||||
- [ ] `SKILL.md` §3 file map and the relevant `guide-*.md` updated for any new file or
|
||||
workflow change.
|
||||
- [ ] `mechanical_refactor_reproduction_utils.py` still imports only git + stdlib.
|
||||
@@ -0,0 +1,357 @@
|
||||
# Split a mechanical refactor
|
||||
|
||||
- Two levels of splitting, one chapter each: §1 splits the **PR/branch** into small
|
||||
classified pieces (the chain contract); §2 splits **one piece** into prepare + move +
|
||||
postpare so its move is provable.
|
||||
|
||||
## 1. Split the PR into small verifiable pieces
|
||||
|
||||
### 1.1 The chain contract — what a compliant branch satisfies
|
||||
|
||||
When asked to make (or fix) a refactor branch so it "satisfies this skill", ALL of the
|
||||
following must hold over `base..branch`; run the chain verifier
|
||||
(`guide-verify-proof.md` §1) to check the machine-checkable part in one command.
|
||||
|
||||
1. **Every commit is classified, in the required subject format:**
|
||||
|
||||
```text
|
||||
<group-id>(<commit-id>,<kind>): <message>
|
||||
```
|
||||
|
||||
with `<kind>` exactly `mechanical_provable` or `non_mechanical_provable`, and
|
||||
`<group-id>` / `<commit-id>` kebab-case (contiguous same-`<group-id>` commits form one
|
||||
future PR). The verifier machine-checks the standalone-word rule
|
||||
(`spec-reproduction-cli.md` §2.1); the full format is required on top of it so the
|
||||
chain can be grouped into PRs.
|
||||
2. **Classification is correct — mechanical work is labeled mechanical.** Every operation
|
||||
expressible as the whitelisted relocations (an extract-function, a bulk move, a file
|
||||
split, an import repoint, …) is its own `mechanical_provable` commit. Hiding provable
|
||||
content inside a `non_mechanical_provable` commit — dodging the verifier — is
|
||||
forbidden (§2.2 maximality); catching it is the reviewer's duty
|
||||
(`guide-verify-proof.md` §1). How to split so this holds: §2.
|
||||
3. **Every `mechanical_provable` commit has a proof that PASSes.** Produce the proofs
|
||||
with the generator (`guide-construct-proof.md` §1); the chain verifier re-runs every
|
||||
one of them against the proof folder.
|
||||
4. **Every `non_mechanical_provable` commit is correctness-reviewed by eyes.** Its diff
|
||||
must be confirmed to do exactly what its message claims: no lost logic, no hidden bug,
|
||||
no unintended behavior change riding along (`guide-verify-proof.md` §1). When the
|
||||
commit claims to be behavior-preserving that means checking equivalence — but a chain
|
||||
need not be a pure refactor, and a commit that intentionally changes behavior is
|
||||
reviewed for the correctness of that change instead. The machine never certifies
|
||||
these — that is exactly why they must stay minimal (item 2).
|
||||
|
||||
### 1.2 Commit naming and classification
|
||||
|
||||
- The subject format is exactly §1.1 item 1 — no reserved phase suffixes are required.
|
||||
The `<commit-id>` is free (naming it after the phase, e.g. `foo-prepare` / `foo-move`,
|
||||
is fine but optional).
|
||||
- The phases map onto the classification word directly: **move** commits declare
|
||||
`mechanical_provable`; **prepare**, **postpare**, and standalone semantic commits
|
||||
declare `non_mechanical_provable`.
|
||||
- The generator's range command selects the provable commits by the word itself:
|
||||
`--match '(?<!_)mechanical_provable'` (the lookbehind keeps `non_mechanical_provable`
|
||||
from matching).
|
||||
|
||||
## 2. Split one piece into prepare + move + postpare
|
||||
|
||||
### 2.1 Why split
|
||||
|
||||
- A "move a method/function" change is really **two operations with different
|
||||
correctness criteria**:
|
||||
|
||||
| Operation | What it does | How you check it |
|
||||
|---|---|---|
|
||||
| **Semantic reshape** | method → free function or method; `self.X` → a parameter, or `self` retyped to the target class; signature / typing change | behavior unchanged: lint + tests pass |
|
||||
| **Physical move** | cut from the source, paste into the target, fix imports | the moved body is byte-identical, line for line; the only other changes are move artifacts |
|
||||
|
||||
- Put both in one commit and the criteria contaminate each other:
|
||||
- one hunk then holds the reshape **and** an indentation shift **and** a cross-file
|
||||
relocation;
|
||||
- neither a human nor a tool can mechanically confirm "the body that landed is the
|
||||
body that left" — you must re-read the logic.
|
||||
|
||||
### 2.2 The rule — up to three commits, in this order
|
||||
|
||||
- **prepare (optional)** — a **minimal** in-place reshape the relocation needs (de-self a
|
||||
method, retype `self`). Human-reviewed, so: small, **no cross-file def relocation, no
|
||||
body relocation** — the code stays where it is.
|
||||
- **move** — the pure relocation; carries the **bulk**; certified by the reproduce proof
|
||||
(`guide-construct-proof.md`; property: `spec-reproduction-utils.md`).
|
||||
- **postpare (optional)** — a **minimal** tail fixup the move cannot do mechanically (a
|
||||
module path inside a string literal, a doc reference). Human-reviewed.
|
||||
|
||||
Hard lines ("prep" below = the prepare phase):
|
||||
|
||||
- Both ends are optional, minimal, and covered by tests; neither ever relocates a def
|
||||
across files or moves a body.
|
||||
- The move-artifact whitelist is what a relocation *forces* — **not** a licence to fold
|
||||
reshape work into the move. Anything outside the artifacts in the move's diff = the
|
||||
reshape leaked; push it back into prep.
|
||||
- **A large semantic refactor is not a phase.** Consolidating bookkeeping, deduplicating
|
||||
logic, restructuring control flow, redesigning an API → its **own commit**, reviewed for
|
||||
**equivalence** (tests or a written argument). Never smuggled into prep as a "small
|
||||
reshape".
|
||||
- **Provable content never hides in a non-provable commit.** The dual of the previous
|
||||
two rules: a commit declared `non_mechanical_provable` must be the **minimal residue**
|
||||
the relocation primitives cannot express. Any part reproducible as whitelisted
|
||||
relocations (`spec-reproduction-utils.md` §2.1) — a def moved across files, a scattered
|
||||
extract, an import repoint riding along — is split into its own `mechanical_provable`
|
||||
commit with a proof, never folded into a semantic commit where the verifier cannot see
|
||||
it. Declaring provable work non-provable to dodge the verifier violates the chain
|
||||
property (`spec-reproduction-cli.md` §2.1); the reviewer is instructed to hunt for
|
||||
exactly this (`guide-verify-proof.md` §1).
|
||||
- **"Semantic" is not banned from prepare — oversized or hidden semantics are.** prepare's
|
||||
own edits *are* meaning-carrying (de-self, retype-`self`, co-locating bookkeeping);
|
||||
"minimal" caps their **size**, it does not forbid semantics. The two bans are narrower:
|
||||
(1) no semantic change inside the **move** commit — the move is a pure relocation; and
|
||||
(2) don't pass a **large** reshape off as a trivial "small reshape" to dodge the
|
||||
equivalence review. A large but honestly-labeled, equivalence-reviewed reshape placed
|
||||
*before* the move is legitimate — that is exactly what "its own commit" means, and it
|
||||
may serve as the prepare.
|
||||
|
||||
- The prep's shape depends on the destination: a module-level function (§2.3) or a class
|
||||
(§2.4).
|
||||
- The move is the same idea in both: a pure relocation, body byte-identical.
|
||||
|
||||
### 2.3 Case 1: method → free function
|
||||
|
||||
#### 2.3.1 Commit 1 — prep: de-self in place (no relocation)
|
||||
|
||||
Reshape the method **in its original file and position** so it no longer needs `self`.
|
||||
The body stays put:
|
||||
|
||||
- `self.X` (read) → pass `X` in as a parameter.
|
||||
- `self.X = v` (write) → `return v`; the caller assigns. (Or pass an explicit mutable
|
||||
object.)
|
||||
- `self.other_method(...)` → prep that method in the same commit, or inject it as a
|
||||
`Callable` argument.
|
||||
- Once `self` is gone → mark `@staticmethod`; the body **does not move**.
|
||||
- Call site: `self.foo(args)` → `TheClass.foo(args)`.
|
||||
- **Seed the destination's module-level scaffolding here too**, if the target module
|
||||
lacks what the moved body needs — a `logger = logging.getLogger(__name__)`, a
|
||||
module-level constant the body reads (`_is_hip = is_hip()`), and the `import` each
|
||||
requires. This is destination groundwork (like a class skeleton, §2.7.4), **not** the
|
||||
body: adding it in prep keeps the move a pure cut+paste. Folding it into the move
|
||||
instead bundles a non-relocation edit and breaks the byte proof (the move would both
|
||||
paste the body **and** author a new `logger`, which the whitelist does not forgive).
|
||||
A move into a **new** module is the exception — there the whole header, logger
|
||||
included, is authored in the move itself (§2.5).
|
||||
|
||||
- The decorator and the qualifier are the only artifacts the move will carry — exactly
|
||||
what the whitelist (`spec-reproduction-utils.md` §2.1) forgives.
|
||||
|
||||
**Check:** lint + tests pass; the diff is the body reshape, the call-site qualifier, and
|
||||
any destination scaffolding seeded above; nothing moved.
|
||||
|
||||
#### 2.3.2 Commit 2 — move: relocate to the module
|
||||
|
||||
- Cut the `@staticmethod` block; paste into the target module.
|
||||
- Drop `@staticmethod`, dedent to module level — body **unchanged, line for line**.
|
||||
- Source file: import the moved symbol; drop now-unused imports.
|
||||
- Call site: `TheClass.foo(args)` → `foo(args)` (args untouched).
|
||||
|
||||
**Check:** `mechanical_refactor_proof_generator.py <commit>` reports `PASS`. Cross-check:
|
||||
`git show <commit> --color-moved=dimmed-zebra --color-moved-ws=allow-indentation-change`
|
||||
marks the whole block as moved.
|
||||
|
||||
### 2.4 Case 2: method → method on a class
|
||||
|
||||
- For pulling **several methods and the fields they touch** into a new (or existing)
|
||||
class.
|
||||
- Prep does **not** de-self — it builds the class and retypes `self`, body untouched.
|
||||
|
||||
#### 2.4.1 Commit 1 — prep: build the class, retype `self`
|
||||
|
||||
1. Create the target class with the fields the moved methods touch (a frozen dataclass is
|
||||
simplest; drop `frozen` only if they mutate).
|
||||
2. Wire an instance into the call path — composition (`self.component = Target(...)` in
|
||||
the source ctor), construction at the call site, or temporarily both.
|
||||
3. Retype each moved method as a `@staticmethod` whose parameter is still **named** `self`
|
||||
but **typed** as the target class — body unchanged:
|
||||
|
||||
```python
|
||||
class Source:
|
||||
component: Target
|
||||
|
||||
@staticmethod
|
||||
def foo(self: Target) -> None:
|
||||
... # body still reads self.field_a / self.field_b
|
||||
```
|
||||
|
||||
4. Caller: `self.foo(...)` → `Source.foo(self.component, ...)`.
|
||||
|
||||
Why keep the name `self`:
|
||||
|
||||
- it is an ordinary parameter name, so every `self.X` resolves against the target class
|
||||
statically and at runtime (the argument *is* a target-class instance);
|
||||
- renaming it would rewrite every `self.X` and destroy the "body unchanged across both
|
||||
commits" invariant.
|
||||
|
||||
Boundaries:
|
||||
|
||||
- **Prep stays minimal.** Signature redesign, helper extraction, parameter objects,
|
||||
mutate→return, renames, method splits, dead-branch removal → later non-mechanical
|
||||
commits, never prep.
|
||||
- **Runtime-mutable state → inject a `Callable` getter (still prep).** State that changes
|
||||
every step (counters, the current batch, running stats): inject `Callable[[], T]` into
|
||||
the target ctor; rewrite `self.X` → `self.get_X()`. Do **not** thread it per call and do
|
||||
**not** reach back into the source object — per-call kwargs make every call site noisy,
|
||||
the API non-self-contained, and the threading a caller chore.
|
||||
|
||||
```python
|
||||
class Target:
|
||||
def __init__(self, *, static_field, get_running_state: "Callable[[], State]"):
|
||||
self.static_field = static_field
|
||||
self.get_running_state = get_running_state
|
||||
|
||||
@staticmethod
|
||||
def check(self: "Target") -> None:
|
||||
running = self.get_running_state() # was self.running_state
|
||||
...
|
||||
```
|
||||
|
||||
```python
|
||||
# source ctor
|
||||
self.component = Target(
|
||||
static_field=...,
|
||||
get_running_state=lambda: self.running_state,
|
||||
)
|
||||
```
|
||||
|
||||
**Check:** lint + tests pass; body unchanged; types check (`self: Target` matches the
|
||||
instance the caller passes).
|
||||
|
||||
#### 2.4.2 Commit 2 — move: relocate into the class
|
||||
|
||||
- Cut `foo` into the target class; drop `@staticmethod` — body **unchanged, line for
|
||||
line**.
|
||||
- Header: `def foo(self: Target)` → `def foo(self)` (type redundant inside the class).
|
||||
- Caller: `Source.foo(self.component, ...)` → `self.component.foo(...)` — the receiver
|
||||
moves out of the argument list (replayed by `lower_call_sites`).
|
||||
|
||||
**Check:** `mechanical_refactor_proof_generator.py <commit>` reports `PASS`. The split
|
||||
paid off: prep left the body untouched, so the move is a clean cut/paste.
|
||||
|
||||
### 2.5 Case 3: extract to a new module — one move commit, no prep
|
||||
|
||||
- The move gathers the defs **from wherever they sit** — no prep staging at the source
|
||||
tail. Replayed by `extract_symbols_to_new_module`.
|
||||
- Each def/class is cut **verbatim** (the byte diff certifies the bodies); the new file's
|
||||
small header (imports, a logger, constants, a `TYPE_CHECKING` block) is authored from
|
||||
the target and audited (`spec-reproduction-utils.md` §2.1).
|
||||
- A module-level constant that moved into the header (e.g. `_is_hip = is_hip()`) is
|
||||
dropped from the source too.
|
||||
- The only work outside the move: a non-mechanical reference the move cannot derive (a
|
||||
string-literal module path) — a one-line **postpare**.
|
||||
- A symbol **not top-level** in the source (a method still in a class): prepare de-selfs
|
||||
it out first (§2.3); the proof reports `UNSUPPORTED` until then.
|
||||
|
||||
### 2.6 Case 4: extract-function — the bulk goes in the move
|
||||
|
||||
- The relocated body belongs in a certified move, not buried in a prep: the
|
||||
`extract_function` primitive cuts the inline block **verbatim** and authors only the
|
||||
interface (signature, optional `return`, the replacing `call`).
|
||||
- Faithful **only when the body moves unchanged.** De-self, control-flow restructure, or a
|
||||
bookkeeping change folded in → do that as a separate semantic commit (reviewed for
|
||||
equivalence) **first**, then move the now-unchanged body.
|
||||
- An extraction that rewrites the body *as* it extracts is a semantic commit, not a
|
||||
certifiable move — do not dress it up as one.
|
||||
|
||||
### 2.7 Remarks
|
||||
|
||||
#### 2.7.1 A move never renames
|
||||
|
||||
- The moved symbol keeps the **same name on both sides**.
|
||||
- A rename — even a privacy flip `_foo` → `foo` — is its own single-purpose commit
|
||||
*before* the move (rename in place, update call sites).
|
||||
- A move that also renames cannot be machine-certified: split it — rename first, then
|
||||
move.
|
||||
|
||||
#### 2.7.2 Anti-pattern: prep adds the body, move deletes it
|
||||
|
||||
- Symptom: prep **adds** a large block to the target; the move **deletes** the same block
|
||||
from the source. The order is reversed.
|
||||
- Correct order: prep leaves the body in the source (target skeleton, header retype,
|
||||
caller qualification only); the move does the cut/paste.
|
||||
- The body appears and disappears exactly once — on the move side. Fix by pushing the
|
||||
"add the body" work out of prep into the move.
|
||||
|
||||
#### 2.7.3 Anti-pattern: the giant prep (relocating inside the source to stage the move)
|
||||
|
||||
- Symptom: the prep's diff is **hundreds of lines** for a single function — because it
|
||||
also moved the function to the source file's tail, rewrote it as a free function next
|
||||
to a staged import/constant block, or reordered its neighbors so the move can cut one
|
||||
contiguous block.
|
||||
- All of that staging is unnecessary: `extract_symbols_to_new_module` gathers symbols
|
||||
**from wherever they sit** (§2.5) — the move needs no contiguity and no tail parking.
|
||||
- A prep's legitimate diff is the handful of lines the primitives cannot derive: the
|
||||
`@staticmethod` decorator, the kwargs signature, `self.x` → parameter reads, an added
|
||||
`return`, the class-qualified call site. For one function that is **tens of lines, not
|
||||
hundreds** — a prep in the hundreds is the signal the relocation leaked into it.
|
||||
- Why it matters: every relocated-but-not-certified line in a prep is a line the machine
|
||||
never checks and a reviewer must eyeball; parking blocks mid-file also leaves broken or
|
||||
duplicated intermediate states (a staged import for a module that does not exist yet).
|
||||
- Fix: strip the prep back to the interface edits above, leave the body **in place**,
|
||||
and let the certified move do all relocation.
|
||||
|
||||
#### 2.7.4 When NOT to split (single commit)
|
||||
|
||||
- Moving an **already** module-level free function.
|
||||
- Pure file rename / whole-file move.
|
||||
- Trivial field deletion, or `getattr(obj, "x", ...)` → direct attribute access.
|
||||
- A class-internal helper relocated next to another helper in the same module.
|
||||
|
||||
#### 2.7.5 Which actions are mechanical vs not
|
||||
|
||||
- Boundary: building the component correctly the first time is mechanical; reshaping it
|
||||
*after* it exists is not.
|
||||
|
||||
| Action | Bucket |
|
||||
|---|---|
|
||||
| target class skeleton + ctor + fields | mechanical (prep) |
|
||||
| destination module scaffolding (a `logger`, a module-level constant) the moved symbol needs, when moving into an **existing** module | mechanical (prep) |
|
||||
| `@dataclass(frozen=True, slots=True, kw_only=True)` decoration | mechanical (prep) |
|
||||
| composition wiring (`self.component = Target(...)`) | mechanical (prep) |
|
||||
| `Callable` getter injection for runtime-mutable state | mechanical (prep) |
|
||||
| platform conditionals carried along with the body | mechanical (prep / move) |
|
||||
| cross-file import path rewrites | mechanical (move) |
|
||||
| field-ownership migration into the component ctor | mechanical (a single pre-step) |
|
||||
| inlining an `init_*` method body into a ctor | mechanical (a single pre-step) |
|
||||
| privacy flip (`_x` ↔ `x`) | mechanical (a single rename) |
|
||||
| signature redesign (new kwargs, changed defaults, positional → kw-only) | **not** mechanical |
|
||||
| body simplification / dead-branch removal / logic rewrite | **not** mechanical |
|
||||
| semantic method rename | **not** mechanical |
|
||||
|
||||
- The smaller the prep, the easier "behavior unchanged" is to confirm.
|
||||
- Many small, independently reviewable commits beat one big prep mixing ten flavors of
|
||||
change.
|
||||
- Review order = commit order: prep → move → non-mechanical follow-ups.
|
||||
|
||||
#### 2.7.6 Anti-pattern: the non-mechanical label as an escape hatch
|
||||
|
||||
- Symptom: a commit whose body is a **pure relocation** (a cut+paste move, a module-level
|
||||
constant move, a verbatim inline-block extract) is labelled `non_mechanical_provable` and
|
||||
ships with no proof — because the generator reported `UNSUPPORTED` or a primitive could
|
||||
not express the exact insertion point, so the author reached for the softer label instead
|
||||
of a proof.
|
||||
- Real example from this repo's history: `kvc-move-lazy-compaction-gate` relocated the
|
||||
module-level `_should_enable_lazy_compaction` unchanged into `kv_cache_configurator.py` but
|
||||
was labelled `non_mechanical_provable`, because it had to land *above* an
|
||||
`if TYPE_CHECKING:` guard and `move_symbol` only anchored with `before=`, which overshot
|
||||
past the guard. The relocation was fully mechanical; only the tool's insertion-anchor was
|
||||
missing — so the fix was to add a `move_symbol(after=)` anchor and prove it, not to keep
|
||||
the softer label. (A sibling commit that moves a *contiguous block* of constants plus their
|
||||
leading comment needs a block-move primitive the toolkit does not yet have — that one is
|
||||
still awaiting an enhancement, which is the correct disposition, not a relabel.)
|
||||
- The rule, in order:
|
||||
1. A pure relocation **must** be `mechanical_provable` and carry a proof. The label is
|
||||
a claim about the change, not about how easy the tooling made it.
|
||||
2. Generator says `UNSUPPORTED` but the change *is* a relocation → **hand-write the
|
||||
`Repro`** from the same primitives (guide-construct-proof.md §2.3). Inference falling
|
||||
short is not a licence to drop the proof.
|
||||
3. A primitive genuinely cannot express the faithful edit (the missing `after=` anchor
|
||||
above) → **enhance the primitive first**, then prove it. The fix for a tooling gap is
|
||||
to close the gap, not to relabel the commit as unprovable.
|
||||
- Only a change that is genuinely *not* a relocation (a signature redesign, a logic rewrite,
|
||||
a de-self restructure) earns `non_mechanical_provable`. If you cannot say which
|
||||
non-relocation edit justifies the label, the label is wrong.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Verify a proof
|
||||
|
||||
- How the reviewer of a claimed-mechanical chain (or a single commit) consumes its proof.
|
||||
- The certified property and primitive contracts: `spec-reproduction-utils.md`; the
|
||||
chain-level contract: `spec-reproduction-cli.md`.
|
||||
- How the proof was produced and the folder it arrives in: `guide-construct-proof.md`.
|
||||
|
||||
## 0. Do not trust the PR — verify yourself
|
||||
|
||||
- Everything the PR shows you is a **claim**, not evidence: a pasted `PASS` verdict, a
|
||||
pasted chain report, a green checkmark, the classification words themselves. All of it
|
||||
is text the author (or the author's tooling) produced and could be wrong or fabricated.
|
||||
- The proof is only ever the run **you** perform locally: run the chain verifier (§1)
|
||||
against the PR's actual base and head, with the proof folder you downloaded — never
|
||||
approve from the author's pasted output.
|
||||
- This is cheap by design: the whole point of the machinery is that re-verification is
|
||||
one command, so there is no excuse to trust instead of re-run.
|
||||
- **Sampling is not verification.** Re-running a subset of the proofs ("spot-check 8 of
|
||||
43") proves nothing about the rest and must never be the basis for approval — the only
|
||||
acceptable run is the §1 chain verifier, which executes **every** provable commit's
|
||||
proof. The same holds for the manual duties: audit every `HUMAN_REVIEW` row and every
|
||||
PASS's authored surfaces (§2.3), not a sample of them.
|
||||
|
||||
## 1. Verify the whole chain
|
||||
|
||||
- The default — and the only sufficient — entry point: do not re-run proofs one by one,
|
||||
and never a sample; run the chain verifier over the whole chain:
|
||||
|
||||
```bash
|
||||
python3 .claude/skills/mechanical-refactor-verify/scripts/mechanical_refactor_reproduction_cli.py \
|
||||
--base <base-commit> --branch <pr-branch-name> --proof <folder>
|
||||
```
|
||||
|
||||
- It checks every commit declares `mechanical_provable` or `non_mechanical_provable`,
|
||||
runs every provable commit's proof, and prints + writes a full report
|
||||
(`<folder>/chain_report.md`); exit 0 iff the chain verifies.
|
||||
- Proofs run up to `--jobs` at a time (default 3; each proof works in its own throwaway
|
||||
worktree, so this is safe) — raise it to shorten a long chain's wall clock.
|
||||
- Re-running a long chain: add `--skip-passed` to reuse **this machine's own** earlier
|
||||
PASS verdicts for unchanged proofs (keyed by sha + script hash + utils hash, stored
|
||||
under the repo's `.git/`, never shipped with the proof folder — so §0 still holds;
|
||||
contract: `spec-reproduction-cli.md` §3.5).
|
||||
- The contract (word rule, proof resolution, PASS criterion, exit codes):
|
||||
`spec-reproduction-cli.md`.
|
||||
- The `HUMAN_REVIEW` rows in the report are your remaining manual surface — the declared
|
||||
non-mechanical commits, plus the §2.3 authored-surface audit of each PASS.
|
||||
- Each `HUMAN_REVIEW` row carries **two** review duties, and the commit is not approved
|
||||
until both hold.
|
||||
- Duty 1 — **correctness-review the diff itself**: a `non_mechanical_provable` commit is
|
||||
exactly the part the machine never certifies, so read its diff and confirm it does
|
||||
exactly what its message claims — no lost logic (a branch, a write, an early return
|
||||
dropped on the floor), no hidden bug, no unintended behavior change riding along. When
|
||||
the commit claims to be behavior-preserving, that means checking equivalence; a commit
|
||||
that intentionally changes behavior (a chain need not be a pure refactor) is reviewed
|
||||
for the correctness of that change instead. Tests passing is supporting evidence, not
|
||||
the review.
|
||||
- Duty 2 — **verify the declaration itself**: the commit asserts **nothing in it is a
|
||||
provable relocation** (`spec-reproduction-cli.md` §2.1), and hiding provable content
|
||||
there to dodge the verifier is exactly the escape this chain check exists to close.
|
||||
- Read the commit's diff for relocated code. Concretely, run
|
||||
`git show <sha> --color-moved=dimmed-zebra --color-moved-ws=allow-indentation-change`
|
||||
and look for moved blocks, and run
|
||||
`python3 .claude/skills/mechanical-refactor-verify/scripts/mechanical_refactor_proof_generator.py <sha>`
|
||||
to see what a relocation recipe would cover.
|
||||
- A hidden provable part is not a judgement call: demand the split
|
||||
(`guide-split.md` §2.2) — do not approve the commit as-is.
|
||||
- **A `non_mechanical_provable` commit whose body is a large verbatim block relocation
|
||||
the primitives can express** — a cut+paste move (including one landing above an
|
||||
`if TYPE_CHECKING:` guard, now anchorable with `move_symbol(after=)`), a module-level
|
||||
constant move, or a verbatim inline-block extract (the generator now infers it as
|
||||
`extract_function`) — is a **FINDING**, not an acceptable label. The generator being
|
||||
unable to infer it, or a past tooling gap, does not license the softer label: demand
|
||||
it be relabelled `mechanical_provable` with a hand-written `Repro`, or the primitive
|
||||
enhanced (guide-split.md §2.7.6). Only a genuine non-relocation edit (signature
|
||||
redesign, logic rewrite, de-self restructure) justifies the label.
|
||||
|
||||
## 2. Verify a single commit
|
||||
|
||||
- For diagnosing one commit (a failing proof, a suspicious script) — never a substitute
|
||||
for §1: approving a chain requires the full §1 run, not single-commit re-runs of a
|
||||
chosen subset.
|
||||
|
||||
### 2.1 Re-run it
|
||||
|
||||
- From the repo root:
|
||||
|
||||
```bash
|
||||
python3 <folder>/repro_scripts/<sha>.py
|
||||
```
|
||||
|
||||
- When the proof arrived as a gist (`guide-construct-proof.md` §1.3), download it first:
|
||||
|
||||
```bash
|
||||
gh gist clone <gist_id> /tmp/proof # or: git clone https://gist.github.com/<gist_id>.git /tmp/proof
|
||||
cd <repo-root> # the run resolves the repo from the cwd
|
||||
python3 /tmp/proof/<sha>.py # PASS = byte-identical to this commit
|
||||
```
|
||||
|
||||
- The run *is* the proof — it replays the primitives from the base commit and byte-diffs
|
||||
against the target in a throwaway worktree.
|
||||
- The script prints the verdict and exits 0 only on PASS (a residual exits non-zero), so
|
||||
a harness can consume the exit code.
|
||||
- Do not trust a pasted verdict you did not re-run.
|
||||
|
||||
### 2.2 Read the verdict
|
||||
|
||||
- **PASS** — byte-identical: the commit is exactly the relocations listed in the script,
|
||||
nothing else.
|
||||
- **RESIDUAL** — a non-empty diff: precisely the bundled non-move change. Review it as
|
||||
semantic content; a legitimate tail fixup (string-literal module path, doc reference)
|
||||
belongs in a postpare commit, not the move.
|
||||
- **UNSUPPORTED** — no recipe inferred (cases: `guide-construct-proof.md` §2.2.2). Not
|
||||
thereby wrong, but not machine-certified: review by hand as a prepare-style reshape, or
|
||||
ask the author for a hand-written `Repro`.
|
||||
|
||||
### 2.3 Audit the authored surfaces
|
||||
|
||||
- A PASS certifies the relocated bytes; the small **authored** surfaces are reproduced
|
||||
from the target and need human eyes.
|
||||
- In the script, check:
|
||||
- the `header=` of `extract_symbols_to_new_module` — the module audits its content
|
||||
(imports / docstring / TYPE_CHECKING imports / logger / relocated `drop_assigns`
|
||||
copies only); what remains for you: should those assignments move at all?
|
||||
- a `leave_delegate=` on `move_symbol` — the forwarding stub is authored code in the
|
||||
source file;
|
||||
- the `signature=` / `return_text=` / `call=` of `extract_function` — the new
|
||||
function's interface is authored; only its body is certified;
|
||||
- the `drop_assigns=` list — each named constant leaves the source file.
|
||||
|
||||
### 2.4 Know what a PASS does and does not assert
|
||||
|
||||
- Requalification / lowering / repath in a script is tied to symbols the same script
|
||||
relocates; a consumer-only call or import rewrite (no relocated definition) cannot
|
||||
reproduce as a move — it surfaces as a residual.
|
||||
- Whatever the repo's pre-commit hooks auto-fix is absorbed on both sides
|
||||
(`spec-reproduction-utils.md` §4) — the hook set is part of what you trust.
|
||||
- A PASS judges the **shape of a relocation**, not **intent**: "this commit is exactly
|
||||
these relocations", not "this relocation was a good idea". Confirm the commit's subject
|
||||
matches what the script actually moves before approving.
|
||||
|
||||
### 2.5 Why the mechanism is trustworthy
|
||||
|
||||
- It runs the real formatter and compares bytes — no diff-shape heuristic to fool
|
||||
(`spec-reproduction-utils.md` §4).
|
||||
- The proof is the few primitive calls in the script; auditing them (plus §2.3) is the
|
||||
whole human surface.
|
||||
- The folder is self-contained and re-runnable by anyone — a CI step or a reviewer —
|
||||
without the skill installed.
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Utilities for mechanical refactor verification scripts.
|
||||
|
||||
See SKILL.md for usage and transform script template.
|
||||
"""
|
||||
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def exec_command(cmd: str, cwd: str | None = None, check: bool = True) -> str:
|
||||
print(f" $ {cmd}", flush=True)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
print(f"FAILED: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def git_add_and_commit(message: str, cwd: str) -> None:
|
||||
exec_command(f"git add -A && git commit -m {shlex.quote(message)}", cwd=cwd)
|
||||
|
||||
|
||||
def dedent(text: str, n: int) -> str:
|
||||
"""Remove exactly n leading spaces from each line."""
|
||||
lines = text.splitlines(keepends=True)
|
||||
return "".join(line[n:] if line[:n] == " " * n else line for line in lines)
|
||||
|
||||
|
||||
def verify_mechanical_refactor(
|
||||
base_commit: str,
|
||||
target_commit: str,
|
||||
transform: "Callable[[Path], None]",
|
||||
) -> None:
|
||||
repo_root = exec_command("git rev-parse --show-toplevel")
|
||||
worktree_dir = tempfile.mkdtemp(prefix="verify-mechanical-")
|
||||
branch_name = f"verify-mechanical-{base_commit[:8]}"
|
||||
|
||||
try:
|
||||
print(f"[1/4] Creating worktree at {base_commit[:8]}...")
|
||||
exec_command(
|
||||
f"git worktree add -b {branch_name} {worktree_dir} {base_commit}",
|
||||
cwd=repo_root,
|
||||
)
|
||||
|
||||
print("[2/4] Running transformation...")
|
||||
transform(Path(worktree_dir))
|
||||
|
||||
print("[3/4] Running pre-commit...")
|
||||
exec_command("pre-commit run --all-files", cwd=worktree_dir, check=False)
|
||||
if exec_command("git status --porcelain", cwd=worktree_dir):
|
||||
git_add_and_commit("pre-commit fixes", cwd=worktree_dir)
|
||||
|
||||
print(f"[4/4] Diffing against {target_commit[:8]}...")
|
||||
diff = exec_command(
|
||||
f"git diff {target_commit} -- .",
|
||||
cwd=worktree_dir,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if diff:
|
||||
print(f"\nFAIL: diff is non-empty:\n{diff}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\nPASS: transform reproduces the commit exactly.")
|
||||
|
||||
finally:
|
||||
print(f"\nWorktree left at: {worktree_dir}")
|
||||
print(f"Branch: {branch_name}")
|
||||
print("To clean up manually:")
|
||||
print(f" git worktree remove {worktree_dir} && git branch -D {branch_name}")
|
||||
+1401
File diff suppressed because it is too large
Load Diff
+484
@@ -0,0 +1,484 @@
|
||||
"""Verify a whole mechanical-refactor chain: classification, proofs, and a full report.
|
||||
|
||||
Every commit in ``base..branch`` must classify itself by carrying exactly one of the two
|
||||
words ``mechanical_provable`` or ``non_mechanical_provable`` anywhere in its message (the
|
||||
rest of the message format is free). Every ``mechanical_provable`` commit must ship a
|
||||
proof script in the proof folder (``<proof>/repro_scripts/<sha-prefix>.py`` or a flat
|
||||
``<proof>/<sha-prefix>.py``), and running the proof must PASS -- reproduce the commit
|
||||
byte-for-byte. A ``non_mechanical_provable`` commit carries no machine proof and is left
|
||||
to human review.
|
||||
|
||||
The run prints a markdown report, writes it into the proof folder (``chain_report.md``),
|
||||
and exits 0 iff the whole chain verifies. Normative contract: spec-reproduction-cli.md.
|
||||
|
||||
python3 mechanical_refactor_reproduction_cli.py \
|
||||
--base <base-commit> --branch <pr-branch-name> --proof path/to/proof/folder
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
_DEFAULT_JOBS = 3
|
||||
_PASSED_CACHE_FILENAME = "mechanical_refactor_passed_proofs.json"
|
||||
_CACHED_PASS_DETAIL = "reused this machine's earlier PASS (--skip-passed)"
|
||||
|
||||
KIND_MECHANICAL = "mechanical_provable"
|
||||
KIND_NON_MECHANICAL = "non_mechanical_provable"
|
||||
|
||||
VERDICT_PASS = "PASS"
|
||||
VERDICT_FAIL = "FAIL"
|
||||
VERDICT_MISSING_PROOF = "MISSING_PROOF"
|
||||
VERDICT_AMBIGUOUS_PROOF = "AMBIGUOUS_PROOF"
|
||||
VERDICT_HUMAN_REVIEW = "HUMAN_REVIEW"
|
||||
VERDICT_UNCLASSIFIED = "UNCLASSIFIED"
|
||||
VERDICT_AMBIGUOUS_KIND = "AMBIGUOUS_KIND"
|
||||
|
||||
_OK_VERDICTS = (VERDICT_PASS, VERDICT_HUMAN_REVIEW)
|
||||
|
||||
# The words are matched standalone: delimited by any non-[0-9A-Za-z_] character or the
|
||||
# string boundary, so `non_mechanical_provable` never also counts as the bare word.
|
||||
_KIND_WORD_RE = re.compile(r"(?<![0-9A-Za-z_])(non_)?mechanical_provable(?![0-9A-Za-z_])")
|
||||
|
||||
# The arbiter's verdict line (Repro.run / verify_mechanical_refactor both print `PASS:`).
|
||||
_PASS_LINE_RE = re.compile(r"^PASS:", re.MULTILINE)
|
||||
|
||||
_MIN_PROOF_STEM_LEN = 7
|
||||
_REPORT_FILENAME = "chain_report.md"
|
||||
_FAIL_OUTPUT_TAIL_LINES = 60
|
||||
|
||||
|
||||
class ChainVerificationError(Exception):
|
||||
"""A setup problem (bad refs, non-linear range, missing proof folder): exit code 2."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommitVerdict:
|
||||
sha: str
|
||||
subject: str
|
||||
kind: "str | None"
|
||||
verdict: str
|
||||
detail: str = ""
|
||||
cached: bool = False
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.verdict in _OK_VERDICTS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PendingProof:
|
||||
sha: str
|
||||
subject: str
|
||||
kind: str
|
||||
script: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChainResult:
|
||||
base: str
|
||||
branch: str
|
||||
base_sha: str
|
||||
branch_sha: str
|
||||
proof_dir: Path
|
||||
verdicts: "list[CommitVerdict]" = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
return bool(self.verdicts) and all(v.ok for v in self.verdicts)
|
||||
|
||||
|
||||
def main(argv: "list[str]") -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify a whole mechanical-refactor chain against its proof folder.")
|
||||
parser.add_argument("--base", required=True, help="base commit of the chain")
|
||||
parser.add_argument("--branch", required=True, help="PR branch name (chain tip)")
|
||||
parser.add_argument("--proof", required=True, help="proof folder path")
|
||||
parser.add_argument("--repo-root", default=None, help="repo root (default: cwd's)")
|
||||
parser.add_argument(
|
||||
"--report",
|
||||
default=None,
|
||||
help=f"report file path (default: <proof>/{_REPORT_FILENAME})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jobs",
|
||||
type=int,
|
||||
default=_DEFAULT_JOBS,
|
||||
help=f"max concurrent proof runs (default {_DEFAULT_JOBS})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-passed",
|
||||
action="store_true",
|
||||
help="reuse this machine's earlier PASS verdicts for unchanged proofs",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
result = verify_chain(
|
||||
base=args.base,
|
||||
branch=args.branch,
|
||||
proof=Path(args.proof),
|
||||
repo_root=args.repo_root,
|
||||
jobs=args.jobs,
|
||||
skip_passed=args.skip_passed,
|
||||
)
|
||||
except ChainVerificationError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
report = render_report(result)
|
||||
report_path = Path(args.report) if args.report else result.proof_dir / _REPORT_FILENAME
|
||||
report_path.write_text(report)
|
||||
print(report)
|
||||
print(f"report written to: {report_path}")
|
||||
return 0 if result.passed else 1
|
||||
|
||||
|
||||
def verify_chain(
|
||||
*,
|
||||
base: str,
|
||||
branch: str,
|
||||
proof: Path,
|
||||
repo_root: "str | None" = None,
|
||||
jobs: int = _DEFAULT_JOBS,
|
||||
skip_passed: bool = False,
|
||||
) -> ChainResult:
|
||||
"""Classify every commit in ``base..branch`` and run every provable commit's proof.
|
||||
|
||||
Classification and proof resolution are sequential (cheap); the proof runs execute
|
||||
concurrently, up to ``jobs`` at a time — safe because each proof works in its own
|
||||
throwaway worktree. The verdict list keeps chain order. With ``skip_passed``, a
|
||||
pending proof whose (sha, script hash, utils hash) triple this machine already ran to
|
||||
a PASS is reused instead of re-executed; every fresh PASS is recorded either way."""
|
||||
root = repo_root or _repo_root()
|
||||
if not proof.is_dir():
|
||||
raise ChainVerificationError(f"proof folder does not exist: {proof}")
|
||||
base_sha = _rev_parse(base, root)
|
||||
branch_sha = _rev_parse(branch, root)
|
||||
commits = _linear_commits(base_sha=base_sha, branch_sha=branch_sha, root=root)
|
||||
|
||||
resolved: list[CommitVerdict | _PendingProof] = []
|
||||
for sha in commits:
|
||||
subject = _git_output(["log", "-1", "--format=%s", sha], root).strip()
|
||||
message = _git_output(["log", "-1", "--format=%B", sha], root)
|
||||
resolved.append(_resolve_commit(sha=sha, subject=subject, message=message, proof=proof))
|
||||
|
||||
cache_path = _passed_cache_path(root)
|
||||
cache = _load_passed_cache(cache_path)
|
||||
if skip_passed:
|
||||
resolved = [_reuse_cached_pass(item, cache=cache) for item in resolved]
|
||||
|
||||
pending_by_sha = {item.sha: item for item in resolved if isinstance(item, _PendingProof)}
|
||||
verdicts: list[CommitVerdict] = _run_pending_proofs(resolved=resolved, root=root, jobs=jobs)
|
||||
_record_passes(
|
||||
cache=cache,
|
||||
cache_path=cache_path,
|
||||
verdicts=verdicts,
|
||||
pending_by_sha=pending_by_sha,
|
||||
)
|
||||
return ChainResult(
|
||||
base=base,
|
||||
branch=branch,
|
||||
base_sha=base_sha,
|
||||
branch_sha=branch_sha,
|
||||
proof_dir=proof,
|
||||
verdicts=verdicts,
|
||||
)
|
||||
|
||||
|
||||
def render_report(result: ChainResult) -> str:
|
||||
"""The full chain report as markdown: header, per-commit table, failure details."""
|
||||
n_mech = sum(1 for v in result.verdicts if v.kind == KIND_MECHANICAL)
|
||||
n_non_mech = sum(1 for v in result.verdicts if v.kind == KIND_NON_MECHANICAL)
|
||||
n_unclassified = sum(1 for v in result.verdicts if v.kind is None)
|
||||
n_pass = sum(1 for v in result.verdicts if v.verdict == VERDICT_PASS)
|
||||
n_cached = sum(1 for v in result.verdicts if v.cached)
|
||||
|
||||
lines = [
|
||||
"# Mechanical refactor chain report",
|
||||
"",
|
||||
f"- base: `{result.base}` (`{result.base_sha[:12]}`)",
|
||||
f"- branch: `{result.branch}` (`{result.branch_sha[:12]}`)",
|
||||
f"- proof folder: `{result.proof_dir}`",
|
||||
f"- chain verdict: **{'PASS' if result.passed else 'FAIL'}**",
|
||||
f"- commits: {len(result.verdicts)} total — {n_mech} {KIND_MECHANICAL}, "
|
||||
f"{n_non_mech} {KIND_NON_MECHANICAL}, {n_unclassified} classification error(s)",
|
||||
f"- proofs: {n_pass}/{n_mech} PASS",
|
||||
*([f"- reused from the passed-proof cache (--skip-passed): {n_cached}"] if n_cached else []),
|
||||
"",
|
||||
"| # | commit | kind | verdict | subject |",
|
||||
"|---|--------|------|---------|---------|",
|
||||
]
|
||||
for i, v in enumerate(result.verdicts, start=1):
|
||||
kind = v.kind or "?"
|
||||
subject = v.subject.replace("|", "\\|")
|
||||
lines.append(f"| {i} | `{v.sha[:9]}` | {kind} | {v.verdict} | {subject} |")
|
||||
|
||||
failures = [v for v in result.verdicts if not v.ok]
|
||||
if failures:
|
||||
lines += ["", "## Failure details"]
|
||||
for v in failures:
|
||||
lines += [
|
||||
"",
|
||||
f"### `{v.sha[:9]}` — {v.verdict}",
|
||||
"",
|
||||
v.detail or "(no detail)",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _reuse_cached_pass(item: "CommitVerdict | _PendingProof", *, cache: dict) -> "CommitVerdict | _PendingProof":
|
||||
"""Turn a pending proof into a cached PASS verdict on an exact cache-key match."""
|
||||
if not isinstance(item, _PendingProof):
|
||||
return item
|
||||
if cache.get("passed", {}).get(item.sha) != _proof_cache_key(item.script):
|
||||
return item
|
||||
print(f"proof {item.sha[:9]} {VERDICT_PASS} (cached)", flush=True)
|
||||
return CommitVerdict(
|
||||
sha=item.sha,
|
||||
subject=item.subject,
|
||||
kind=item.kind,
|
||||
verdict=VERDICT_PASS,
|
||||
detail=_CACHED_PASS_DETAIL,
|
||||
cached=True,
|
||||
)
|
||||
|
||||
|
||||
def _record_passes(
|
||||
*,
|
||||
cache: dict,
|
||||
cache_path: Path,
|
||||
verdicts: "list[CommitVerdict]",
|
||||
pending_by_sha: "dict[str, _PendingProof]",
|
||||
) -> None:
|
||||
"""Record every freshly-run PASS into the cache (a FAIL is never recorded)."""
|
||||
fresh = [v for v in verdicts if v.verdict == VERDICT_PASS and not v.cached and v.sha in pending_by_sha]
|
||||
if not fresh:
|
||||
return
|
||||
for v in fresh:
|
||||
cache.setdefault("passed", {})[v.sha] = _proof_cache_key(pending_by_sha[v.sha].script)
|
||||
try:
|
||||
cache_path.write_text(json.dumps(cache, indent=2, sort_keys=True) + "\n")
|
||||
except OSError as exc:
|
||||
print(f"note: could not write passed-proof cache {cache_path}: {exc}")
|
||||
|
||||
|
||||
def _proof_cache_key(script: Path) -> "dict[str, str]":
|
||||
"""The cache key parts beyond the sha: hashes of the script and its utils module."""
|
||||
utils_sha256 = ""
|
||||
for directory in (script.parent, script.parent.parent):
|
||||
utils = directory / "mechanical_refactor_reproduction_utils.py"
|
||||
if utils.is_file():
|
||||
utils_sha256 = hashlib.sha256(utils.read_bytes()).hexdigest()
|
||||
break
|
||||
return {
|
||||
"script_sha256": hashlib.sha256(script.read_bytes()).hexdigest(),
|
||||
"utils_sha256": utils_sha256,
|
||||
}
|
||||
|
||||
|
||||
def _passed_cache_path(root: str) -> Path:
|
||||
common_dir = _git_output(["rev-parse", "--git-common-dir"], root).strip()
|
||||
common = Path(common_dir)
|
||||
if not common.is_absolute():
|
||||
common = Path(root) / common
|
||||
return common / _PASSED_CACHE_FILENAME
|
||||
|
||||
|
||||
def _load_passed_cache(path: Path) -> dict:
|
||||
"""The cache is best-effort: missing, corrupt, or unreadable means empty."""
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {"passed": {}}
|
||||
if not isinstance(data, dict) or not isinstance(data.get("passed"), dict):
|
||||
return {"passed": {}}
|
||||
return data
|
||||
|
||||
|
||||
def _run_pending_proofs(
|
||||
*, resolved: "list[CommitVerdict | _PendingProof]", root: str, jobs: int
|
||||
) -> "list[CommitVerdict]":
|
||||
"""Execute the pending proofs on a bounded thread pool; keep chain order."""
|
||||
pending = [(i, item) for i, item in enumerate(resolved) if isinstance(item, _PendingProof)]
|
||||
finished: dict[int, CommitVerdict] = {}
|
||||
if pending:
|
||||
with ThreadPoolExecutor(max_workers=max(1, jobs)) as pool:
|
||||
futures = {i: pool.submit(_proof_verdict, item, root=root) for i, item in pending}
|
||||
for i, future in futures.items():
|
||||
finished[i] = future.result()
|
||||
return [finished[i] if isinstance(item, _PendingProof) else item for i, item in enumerate(resolved)]
|
||||
|
||||
|
||||
def _proof_verdict(pending: _PendingProof, *, root: str) -> CommitVerdict:
|
||||
passed, output = _run_proof(script=pending.script, root=root)
|
||||
if passed:
|
||||
verdict = CommitVerdict(
|
||||
sha=pending.sha,
|
||||
subject=pending.subject,
|
||||
kind=pending.kind,
|
||||
verdict=VERDICT_PASS,
|
||||
detail="",
|
||||
)
|
||||
else:
|
||||
tail = "\n".join(output.splitlines()[-_FAIL_OUTPUT_TAIL_LINES:])
|
||||
verdict = CommitVerdict(
|
||||
sha=pending.sha,
|
||||
subject=pending.subject,
|
||||
kind=pending.kind,
|
||||
verdict=VERDICT_FAIL,
|
||||
detail=(f"proof `{pending.script}` did not PASS; output tail:\n\n" f"```\n{tail}\n```"),
|
||||
)
|
||||
print(f"proof {pending.sha[:9]} {verdict.verdict}", flush=True)
|
||||
return verdict
|
||||
|
||||
|
||||
def _resolve_commit(*, sha: str, subject: str, message: str, proof: Path) -> "CommitVerdict | _PendingProof":
|
||||
kind, classification_error = _classify(message)
|
||||
if kind is None:
|
||||
return CommitVerdict(
|
||||
sha=sha,
|
||||
subject=subject,
|
||||
kind=None,
|
||||
verdict=classification_error,
|
||||
detail=(
|
||||
f"the commit message must contain exactly one of the words "
|
||||
f"`{KIND_MECHANICAL}` or `{KIND_NON_MECHANICAL}`"
|
||||
),
|
||||
)
|
||||
if kind == KIND_NON_MECHANICAL:
|
||||
return CommitVerdict(
|
||||
sha=sha,
|
||||
subject=subject,
|
||||
kind=kind,
|
||||
verdict=VERDICT_HUMAN_REVIEW,
|
||||
detail="declared non_mechanical_provable: no machine proof, review by hand",
|
||||
)
|
||||
|
||||
scripts = _find_proof_scripts(proof=proof, sha=sha)
|
||||
if not scripts:
|
||||
return CommitVerdict(
|
||||
sha=sha,
|
||||
subject=subject,
|
||||
kind=kind,
|
||||
verdict=VERDICT_MISSING_PROOF,
|
||||
detail=(
|
||||
f"no proof script found; searched `{proof / 'repro_scripts'}` and "
|
||||
f"`{proof}` for `<sha-prefix>.py` (>= {_MIN_PROOF_STEM_LEN} hex chars)"
|
||||
),
|
||||
)
|
||||
if len(scripts) > 1:
|
||||
listing = ", ".join(f"`{p}`" for p in scripts)
|
||||
return CommitVerdict(
|
||||
sha=sha,
|
||||
subject=subject,
|
||||
kind=kind,
|
||||
verdict=VERDICT_AMBIGUOUS_PROOF,
|
||||
detail=f"multiple proof scripts match this commit: {listing}",
|
||||
)
|
||||
|
||||
return _PendingProof(sha=sha, subject=subject, kind=kind, script=scripts[0])
|
||||
|
||||
|
||||
def _classify(message: str) -> "tuple[str | None, str]":
|
||||
"""The commit's declared kind, or (None, error-verdict) when the word rule is broken.
|
||||
|
||||
Exactly one of the two words must appear (any number of times, but only one of the
|
||||
two): zero occurrences is UNCLASSIFIED, both words present is AMBIGUOUS_KIND."""
|
||||
kinds = {KIND_NON_MECHANICAL if match.group(1) else KIND_MECHANICAL for match in _KIND_WORD_RE.finditer(message)}
|
||||
if not kinds:
|
||||
return None, VERDICT_UNCLASSIFIED
|
||||
if len(kinds) > 1:
|
||||
return None, VERDICT_AMBIGUOUS_KIND
|
||||
return kinds.pop(), ""
|
||||
|
||||
|
||||
def _find_proof_scripts(*, proof: Path, sha: str) -> "list[Path]":
|
||||
"""Proof scripts naming this commit: a ``<sha-prefix>.py`` (lowercase hex, >= 7 chars)
|
||||
under ``<proof>/repro_scripts/`` or flat in ``<proof>/``."""
|
||||
found: list[Path] = []
|
||||
for directory in (proof / "repro_scripts", proof):
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
for path in sorted(directory.glob("*.py")):
|
||||
stem = path.stem
|
||||
is_sha_prefix = (
|
||||
len(stem) >= _MIN_PROOF_STEM_LEN
|
||||
and all(c in "0123456789abcdef" for c in stem)
|
||||
and sha.startswith(stem)
|
||||
)
|
||||
if is_sha_prefix:
|
||||
found.append(path)
|
||||
return found
|
||||
|
||||
|
||||
def _run_proof(*, script: Path, root: str) -> "tuple[bool, str]":
|
||||
"""Run one proof script from the repo root. A PASS is exit code 0 AND the arbiter's
|
||||
``PASS:`` verdict line on stdout (an old-style script that exits 0 with a residual is
|
||||
therefore still a FAIL)."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script.resolve())],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
passed = result.returncode == 0 and bool(_PASS_LINE_RE.search(result.stdout))
|
||||
return passed, output
|
||||
|
||||
|
||||
def _linear_commits(*, base_sha: str, branch_sha: str, root: str) -> "list[str]":
|
||||
if not _is_ancestor(base_sha=base_sha, branch_sha=branch_sha, root=root):
|
||||
raise ChainVerificationError(f"base {base_sha[:12]} is not an ancestor of branch {branch_sha[:12]}")
|
||||
commits = _git_output(["rev-list", "--reverse", f"{base_sha}..{branch_sha}"], root).split()
|
||||
if not commits:
|
||||
raise ChainVerificationError(f"no commits in {base_sha[:12]}..{branch_sha[:12]}")
|
||||
merges = [sha for sha in commits if len(_git_output(["rev-list", "--parents", "-n", "1", sha], root).split()) > 2]
|
||||
if merges:
|
||||
listing = ", ".join(sha[:9] for sha in merges)
|
||||
raise ChainVerificationError(f"the chain must be linear, but it contains merge commit(s): {listing}")
|
||||
return commits
|
||||
|
||||
|
||||
def _is_ancestor(*, base_sha: str, branch_sha: str, root: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "merge-base", "--is-ancestor", base_sha, branch_sha],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _rev_parse(ref: str, root: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", f"{ref}^{{commit}}"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ChainVerificationError(f"cannot resolve {ref!r}: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _git_output(args: "list[str]", root: str) -> str:
|
||||
result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=True)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _repo_root() -> str:
|
||||
return subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
+1359
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from generator_testlib import _git
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
_git(root, "init", "-q")
|
||||
_git(root, "config", "user.email", "test@example.com")
|
||||
_git(root, "config", "user.name", "test")
|
||||
_git(root, "config", "commit.gpgsign", "false")
|
||||
return root
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
|
||||
def _write(repo: Path, **files: str | None) -> None:
|
||||
for name, content in files.items():
|
||||
path = repo / name.replace("__", "/")
|
||||
if content is None:
|
||||
path.unlink()
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
|
||||
|
||||
def _commit(repo: Path, message: str) -> str:
|
||||
_git(repo, "add", "-A")
|
||||
_git(repo, "commit", "-q", "-m", message)
|
||||
return _git(repo, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def _method_onto_class(repo: Path) -> None:
|
||||
"""Stage a base + a 'move foo from M onto C, lower the caller' commit."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"class M:\n"
|
||||
" @staticmethod\n"
|
||||
" def foo(self, x):\n"
|
||||
" return x + 1\n"
|
||||
"\n"
|
||||
" def other(self):\n"
|
||||
" return 0\n"
|
||||
),
|
||||
"comp.py": "class C:\n def keep(self):\n return 1\n",
|
||||
"caller.py": (
|
||||
"class K:\n"
|
||||
" def run(self):\n"
|
||||
" from model import M\n"
|
||||
"\n"
|
||||
" return M.foo(self.c, 9)\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "class M:\n def other(self):\n return 0\n",
|
||||
"comp.py": (
|
||||
"class C:\n"
|
||||
" def keep(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
" def foo(self, x):\n"
|
||||
" return x + 1\n"
|
||||
),
|
||||
"caller.py": ("class K:\n def run(self):\n return self.c.foo(9)\n"),
|
||||
},
|
||||
)
|
||||
_commit(repo, "move foo onto C")
|
||||
|
||||
|
||||
def _free_function_move_with_module_level_caller(repo: Path) -> None:
|
||||
"""Stage a free function moved model.py -> util.py whose caller imports it at module
|
||||
level (so the repoint shows up in the symmetric module-level import diff)."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n\n\ndef resolve(m):\n return m\n",
|
||||
"util.py": "import os\n",
|
||||
"caller.py": ("from model import resolve\n\n\ndef run(m):\n return resolve(m)\n"),
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n",
|
||||
"util.py": "import os\n\n\ndef resolve(m):\n return m\n",
|
||||
"caller.py": ("from util import resolve\n\n\ndef run(m):\n return resolve(m)\n"),
|
||||
},
|
||||
)
|
||||
_commit(repo, "move resolve to util")
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from generator_testlib import _commit, _git, _write # noqa: F401
|
||||
from mechanical_refactor_proof_generator import build_repro, infer_recipe, recipe_to_script
|
||||
|
||||
|
||||
def test_infer_extract_function_with_returned_local(repo: Path) -> None:
|
||||
"""A block ending in ``pool = make(...)`` carved into a helper that returns ``pool`` infers
|
||||
an extract_function whose body is the verbatim block and whose return_text is authored.
|
||||
"""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def dispatch(self, n):\n"
|
||||
" base = self.setup()\n"
|
||||
" if self.flag:\n"
|
||||
" x = self.a\n"
|
||||
" y = x + n\n"
|
||||
" pool = make(\n"
|
||||
" a=x,\n"
|
||||
" b=y,\n"
|
||||
" )\n"
|
||||
" return pool\n"
|
||||
"\n"
|
||||
" def keep(self):\n"
|
||||
" return 0\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def dispatch(self, n):\n"
|
||||
" base = self.setup()\n"
|
||||
" if self.flag:\n"
|
||||
" pool = self._build_pool(n=n)\n"
|
||||
" return pool\n"
|
||||
"\n"
|
||||
" def _build_pool(self, *, n):\n"
|
||||
" x = self.a\n"
|
||||
" y = x + n\n"
|
||||
" pool = make(\n"
|
||||
" a=x,\n"
|
||||
" b=y,\n"
|
||||
" )\n"
|
||||
" return pool\n"
|
||||
"\n"
|
||||
" def keep(self):\n"
|
||||
" return 0\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
_commit(repo, "extract _build_pool from dispatch")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert recipe.supported
|
||||
assert recipe.moves == []
|
||||
assert len(recipe.extract_functions) == 1
|
||||
ex = recipe.extract_functions[0]
|
||||
assert ex["name"] == "_build_pool"
|
||||
assert ex["src"] == "kv.py" and ex["dst"] == "kv.py"
|
||||
assert ex["into_class"] == "C"
|
||||
assert ex["before"] == "keep"
|
||||
assert ex["body_indent"] == 12
|
||||
assert ex["body"] == (
|
||||
" x = self.a\n"
|
||||
" y = x + n\n"
|
||||
" pool = make(\n"
|
||||
" a=x,\n"
|
||||
" b=y,\n"
|
||||
" )\n"
|
||||
)
|
||||
assert ex["call"] == " pool = self._build_pool(n=n)\n"
|
||||
assert ex["return_text"] == " return pool"
|
||||
assert ex["signature"] == " def _build_pool(self, *, n):\n"
|
||||
|
||||
|
||||
def test_infer_extract_function_keeps_leading_comment_in_body(repo: Path) -> None:
|
||||
"""A block whose first line is a comment extracts with that comment in the body, not
|
||||
absorbed into the authored signature (which is the def header through its colon only).
|
||||
"""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def dispatch(self, n):\n"
|
||||
" if self.flag:\n"
|
||||
" # pick the pool class for this backend\n"
|
||||
" cls = PoolA\n"
|
||||
" pool = cls(n)\n"
|
||||
" return pool\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def dispatch(self, n):\n"
|
||||
" if self.flag:\n"
|
||||
" pool = self._build_pool(n=n)\n"
|
||||
" return pool\n"
|
||||
"\n"
|
||||
" def _build_pool(self, *, n):\n"
|
||||
" # pick the pool class for this backend\n"
|
||||
" cls = PoolA\n"
|
||||
" pool = cls(n)\n"
|
||||
" return pool\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "extract _build_pool with a leading comment")
|
||||
recipe = infer_recipe(commit, str(repo))
|
||||
assert len(recipe.extract_functions) == 1
|
||||
ex = recipe.extract_functions[0]
|
||||
assert ex["signature"] == " def _build_pool(self, *, n):\n"
|
||||
assert ex["body"].lstrip().startswith("# pick the pool class")
|
||||
assert build_repro(recipe, repo_root=str(repo)).run() == ""
|
||||
|
||||
|
||||
def test_infer_extract_function_no_return_text_when_body_is_whole_helper(
|
||||
repo: Path,
|
||||
) -> None:
|
||||
"""When the helper body reproduces the source block with no trailing return, return_text
|
||||
is None (the block is a side-effecting statement sequence, not a value producer)."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def run(self):\n"
|
||||
" self.pre()\n"
|
||||
" self.log(1)\n"
|
||||
" self.log(2)\n"
|
||||
" self.post()\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def run(self):\n"
|
||||
" self.pre()\n"
|
||||
" self._emit()\n"
|
||||
" self.post()\n"
|
||||
"\n"
|
||||
" def _emit(self):\n"
|
||||
" self.log(1)\n"
|
||||
" self.log(2)\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
_commit(repo, "extract _emit from run")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert recipe.supported
|
||||
assert len(recipe.extract_functions) == 1
|
||||
ex = recipe.extract_functions[0]
|
||||
assert ex["name"] == "_emit"
|
||||
assert ex["return_text"] is None
|
||||
assert ex["call"] == " self._emit()\n"
|
||||
|
||||
|
||||
def test_infer_extract_function_edited_body_does_not_pass(repo: Path) -> None:
|
||||
"""A helper whose body was edited (not a verbatim cut) never yields a false pass: the
|
||||
reproduction's byte-diff surfaces the bundled change as a non-empty residual."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def run(self):\n"
|
||||
" self.pre()\n"
|
||||
" self.log(1)\n"
|
||||
" self.post()\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def run(self):\n"
|
||||
" self.pre()\n"
|
||||
" self._emit()\n"
|
||||
" self.post()\n"
|
||||
"\n"
|
||||
" def _emit(self):\n"
|
||||
" self.log(2)\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "extract _emit but change the arg")
|
||||
recipe = infer_recipe(commit, str(repo))
|
||||
residual = build_repro(recipe, repo_root=str(repo)).run()
|
||||
assert residual != ""
|
||||
|
||||
|
||||
def test_infer_extract_function_when_block_and_call_share_closing_paren(
|
||||
repo: Path,
|
||||
) -> None:
|
||||
"""The removed block and its replacement call both end in a lone ``)``; the prefix/suffix
|
||||
split must not absorb that shared line, or the extracted body loses its final line.
|
||||
"""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def dispatch(self, n):\n"
|
||||
" if self.flag:\n"
|
||||
" pool = make_pool(\n"
|
||||
" a=n,\n"
|
||||
" b=self.b,\n"
|
||||
" )\n"
|
||||
" return pool\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def dispatch(self, n):\n"
|
||||
" if self.flag:\n"
|
||||
" pool = self._build_pool(\n"
|
||||
" n=n,\n"
|
||||
" )\n"
|
||||
" return pool\n"
|
||||
"\n"
|
||||
" def _build_pool(self, *, n):\n"
|
||||
" pool = make_pool(\n"
|
||||
" a=n,\n"
|
||||
" b=self.b,\n"
|
||||
" )\n"
|
||||
" return pool\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "extract _build_pool")
|
||||
recipe = infer_recipe(commit, str(repo))
|
||||
assert len(recipe.extract_functions) == 1
|
||||
ex = recipe.extract_functions[0]
|
||||
assert ex["body"].rstrip().endswith(")")
|
||||
assert ex["return_text"] == " return pool"
|
||||
assert build_repro(recipe, repo_root=str(repo)).run() == ""
|
||||
|
||||
|
||||
def test_emitted_script_passes_on_extract_function(repo: Path, tmp_path: Path) -> None:
|
||||
"""The recipe for an extract_function reproduces the commit byte-for-byte (bare repo, no
|
||||
formatter) so build_repro returns an empty residual."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def dispatch(self, n):\n"
|
||||
" base = self.setup()\n"
|
||||
" if self.flag:\n"
|
||||
" x = self.a\n"
|
||||
" pool = make(\n"
|
||||
" a=x,\n"
|
||||
" )\n"
|
||||
" return pool\n"
|
||||
"\n"
|
||||
" def keep(self):\n"
|
||||
" return 0\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def dispatch(self, n):\n"
|
||||
" base = self.setup()\n"
|
||||
" if self.flag:\n"
|
||||
" pool = self._build_pool(n=n)\n"
|
||||
" return pool\n"
|
||||
"\n"
|
||||
" def _build_pool(self, *, n):\n"
|
||||
" x = self.a\n"
|
||||
" pool = make(\n"
|
||||
" a=x,\n"
|
||||
" )\n"
|
||||
" return pool\n"
|
||||
"\n"
|
||||
" def keep(self):\n"
|
||||
" return 0\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "extract _build_pool from dispatch")
|
||||
recipe = infer_recipe(commit, str(repo))
|
||||
residual = build_repro(recipe, repo_root=str(repo)).run()
|
||||
assert residual == "", residual
|
||||
assert "extract_function" in recipe_to_script(recipe, "extract")
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from generator_testlib import ( # noqa: F401
|
||||
_commit,
|
||||
_free_function_move_with_module_level_caller,
|
||||
_git,
|
||||
_method_onto_class,
|
||||
_write,
|
||||
)
|
||||
from mechanical_refactor_proof_generator import infer_recipe, recipe_to_script
|
||||
|
||||
|
||||
def test_infer_recipe_new_file_extract_from_class_method_unsupported(
|
||||
repo: Path,
|
||||
) -> None:
|
||||
"""A method still inside the class cut straight into a new module cannot be cut as a
|
||||
top-level symbol, so the extract is reported unsupported (prep must lift it out first).
|
||||
"""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"class M:\n"
|
||||
" @staticmethod\n"
|
||||
" def foo(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
" def other(self):\n"
|
||||
" return 0\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "class M:\n def other(self):\n return 0\n",
|
||||
"newmod.py": "def foo():\n return 1\n",
|
||||
},
|
||||
)
|
||||
_commit(repo, "extract foo to a new module")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert recipe.supported is False
|
||||
assert any("not all top-level" in note for note in recipe.notes)
|
||||
|
||||
|
||||
def test_infer_recipe_new_file_extract_from_staged_tail(repo: Path) -> None:
|
||||
"""A staged trailing block (scaffolding + def at the source tail) cut into a new file
|
||||
infers an extract_to_new_module, prepending the future import."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"class M:\n"
|
||||
" def keep(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"import logging\n"
|
||||
"\n"
|
||||
"logger = logging.getLogger(__name__)\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def foo(x):\n"
|
||||
" return x + 1\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "class M:\n def keep(self):\n return 1\n",
|
||||
"newmod.py": (
|
||||
"from __future__ import annotations\n"
|
||||
"\n"
|
||||
"import logging\n"
|
||||
"\n"
|
||||
"logger = logging.getLogger(__name__)\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def foo(x):\n"
|
||||
" return x + 1\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "extract foo to a new module")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert recipe.supported
|
||||
assert recipe.moves == []
|
||||
assert recipe.extracts == [
|
||||
{
|
||||
"src": "model.py",
|
||||
"dst": "newmod.py",
|
||||
"symbols": ["foo"],
|
||||
"future_import": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_infer_recipe_scattered_new_module_extract(repo: Path) -> None:
|
||||
"""Scattered top-level defs cut into a new module (no staged trailing block) infer a scatter
|
||||
extract with the authored header and target order, not UNSUPPORTED."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"common.py": (
|
||||
"import os\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def keep():\n"
|
||||
" return 0\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def beta():\n"
|
||||
" return 2\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def stay():\n"
|
||||
" return 9\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def alpha():\n"
|
||||
" return 1\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"common.py": (
|
||||
"import os\n" "\n" "\n" "def keep():\n" " return 0\n" "\n" "\n" "def stay():\n" " return 9\n"
|
||||
),
|
||||
"alloc.py": (
|
||||
"from __future__ import annotations\n"
|
||||
"\n"
|
||||
"import logging\n"
|
||||
"\n"
|
||||
"logger = logging.getLogger(__name__)\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def alpha():\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def beta():\n"
|
||||
" return 2\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "extract alpha, beta to alloc.py")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert recipe.supported
|
||||
assert recipe.extracts == []
|
||||
assert recipe.moves == []
|
||||
assert len(recipe.scatter_extracts) == 1
|
||||
sx = recipe.scatter_extracts[0]
|
||||
assert sx["src"] == "common.py" and sx["dst"] == "alloc.py"
|
||||
assert sorted(sx["symbols"]) == ["alpha", "beta"]
|
||||
assert sx["order"] == ["alpha", "beta"]
|
||||
assert sx["header"].startswith("from __future__ import annotations\n")
|
||||
assert "logger = logging.getLogger(__name__)" in sx["header"]
|
||||
assert sx["drop_assigns"] == []
|
||||
script = recipe_to_script(recipe, "extract alpha, beta to alloc.py")
|
||||
assert "extract_symbols_to_new_module" in script
|
||||
|
||||
|
||||
def test_infer_recipe_scatter_extract_drops_relocated_constant(repo: Path) -> None:
|
||||
"""A module-level constant relocated into the new module is inferred as a drop_assign so the
|
||||
scatter extract removes it from the source too; a constant the source keeps is not.
|
||||
"""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"common.py": (
|
||||
"from u import is_hip\n"
|
||||
"\n"
|
||||
"_IS_HIP = is_hip()\n"
|
||||
"logger = 1\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def moved():\n"
|
||||
" return _IS_HIP\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def keep():\n"
|
||||
" return logger\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"common.py": ("logger = 1\n\n\ndef keep():\n return logger\n"),
|
||||
"alloc.py": (
|
||||
"from __future__ import annotations\n"
|
||||
"\n"
|
||||
"from u import is_hip\n"
|
||||
"\n"
|
||||
"_IS_HIP = is_hip()\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def moved():\n"
|
||||
" return _IS_HIP\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "extract moved to alloc.py")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert len(recipe.scatter_extracts) == 1
|
||||
assert recipe.scatter_extracts[0]["drop_assigns"] == ["_IS_HIP"]
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from generator_testlib import ( # noqa: F401
|
||||
_commit,
|
||||
_free_function_move_with_module_level_caller,
|
||||
_git,
|
||||
_method_onto_class,
|
||||
_write,
|
||||
)
|
||||
from mechanical_refactor_proof_generator import infer_recipe
|
||||
|
||||
|
||||
def test_infer_recipe_infers_added_module_imports(repo: Path) -> None:
|
||||
"""An import the destination module gains (the moved code needs it) is inferred."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"import gc\n"
|
||||
"\n"
|
||||
"class M:\n"
|
||||
" @staticmethod\n"
|
||||
" def foo(self):\n"
|
||||
" gc.collect()\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
" def other(self):\n"
|
||||
" return 0\n"
|
||||
),
|
||||
"comp.py": "class C:\n def keep(self):\n return 1\n",
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "class M:\n def other(self):\n return 0\n",
|
||||
"comp.py": (
|
||||
"import gc\n"
|
||||
"\n"
|
||||
"class C:\n"
|
||||
" def keep(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
" def foo(self):\n"
|
||||
" gc.collect()\n"
|
||||
" return 1\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "move foo onto C")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert {"path": "comp.py", "text": "import gc"} in recipe.import_additions
|
||||
|
||||
|
||||
def test_infer_recipe_module_level_import_repoint_realised_by_diff(repo: Path) -> None:
|
||||
"""A module-level consumer whose import is repointed old -> new yields a remove of the old
|
||||
name and an add of the new -- not a reliance on the formatter pruning a duplicate.
|
||||
"""
|
||||
_free_function_move_with_module_level_caller(repo)
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert recipe.repaths == []
|
||||
assert {
|
||||
"path": "caller.py",
|
||||
"module": "model",
|
||||
"name": "resolve",
|
||||
"asname": None,
|
||||
} in recipe.module_import_removals
|
||||
assert {"path": "caller.py", "text": "from util import resolve"} in (recipe.import_additions)
|
||||
|
||||
|
||||
def test_infer_recipe_removes_an_import_the_source_no_longer_uses(repo: Path) -> None:
|
||||
"""When the moved body took the source's only use of an import, the source's lost name is
|
||||
realised as a removal (deterministic, not left to the formatter)."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"import gc\n"
|
||||
"\n"
|
||||
"class M:\n"
|
||||
" @staticmethod\n"
|
||||
" def foo(self):\n"
|
||||
" gc.collect()\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
" def other(self):\n"
|
||||
" return 0\n"
|
||||
),
|
||||
"comp.py": "class C:\n def keep(self):\n return 1\n",
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "class M:\n def other(self):\n return 0\n",
|
||||
"comp.py": (
|
||||
"import gc\n"
|
||||
"\n"
|
||||
"class C:\n"
|
||||
" def keep(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
" def foo(self):\n"
|
||||
" gc.collect()\n"
|
||||
" return 1\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "move foo onto C")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert {
|
||||
"path": "model.py",
|
||||
"module": None,
|
||||
"name": "gc",
|
||||
"asname": None,
|
||||
} in recipe.module_import_removals
|
||||
|
||||
|
||||
def test_infer_recipe_adds_wholly_new_module_import_verbatim(repo: Path) -> None:
|
||||
"""An import gained from a module not present in base is captured as the target's verbatim
|
||||
statement (so an exploded/magic-comma wrapping is reproduced, not collapsed per-name).
|
||||
"""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n\n\ndef solve(x):\n return x\n",
|
||||
"util.py": "import os\n",
|
||||
"caller.py": ("from model import solve\n\n\ndef run():\n return solve(1)\n"),
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n",
|
||||
"util.py": "import os\n\n\ndef solve(x):\n return x\n",
|
||||
"caller.py": ("from util import (\n solve,\n)\n\n\ndef run():\n return solve(1)\n"),
|
||||
},
|
||||
)
|
||||
_commit(repo, "move solve to util")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
caller_adds = [a["text"] for a in recipe.import_additions if a["path"] == "caller.py"]
|
||||
assert "from util import (\n solve,\n)" in caller_adds
|
||||
assert {
|
||||
"path": "caller.py",
|
||||
"module": "model",
|
||||
"name": "solve",
|
||||
"asname": None,
|
||||
} in recipe.module_import_removals
|
||||
+525
@@ -0,0 +1,525 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from generator_testlib import ( # noqa: F401
|
||||
_commit,
|
||||
_free_function_move_with_module_level_caller,
|
||||
_git,
|
||||
_method_onto_class,
|
||||
_write,
|
||||
)
|
||||
from mechanical_refactor_proof_generator import infer_recipe, recipe_to_script
|
||||
|
||||
|
||||
def test_infer_recipe_method_onto_class(repo: Path) -> None:
|
||||
"""A method move onto a class infers the move, the call-site lowering, and the orphaned
|
||||
local import removal."""
|
||||
_method_onto_class(repo)
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert recipe.supported
|
||||
assert [(m["name"], m["src"], m["dst"], m["into_class"], m["dedent"]) for m in recipe.moves] == [
|
||||
("foo", "model.py", "comp.py", "C", 0)
|
||||
]
|
||||
assert recipe.lowerings == [{"name": "foo", "owner": "M", "path": "caller.py", "kind": "lower"}]
|
||||
assert recipe.import_removals == [{"path": "caller.py", "text": "from model import M", "in_function": "run"}]
|
||||
assert recipe.import_additions == []
|
||||
|
||||
|
||||
def test_infer_recipe_move_before_typechecking_uses_after_anchor(repo: Path) -> None:
|
||||
"""A module-level def relocated to land just above an ``if TYPE_CHECKING:`` guard cannot
|
||||
be anchored with before= (the next def sits past the guard), so the recipe anchors it with
|
||||
after=<the preceding assignment>."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": ("def keep():\n return 0\n\n\ndef helper(x):\n return x + 1\n"),
|
||||
"util.py": (
|
||||
"from u import is_hip\n"
|
||||
"\n"
|
||||
"_is_hip = is_hip()\n"
|
||||
"\n"
|
||||
"if TYPE_CHECKING:\n"
|
||||
" from m import Thing\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n",
|
||||
"util.py": (
|
||||
"from u import is_hip\n"
|
||||
"\n"
|
||||
"_is_hip = is_hip()\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def helper(x):\n"
|
||||
" return x + 1\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"if TYPE_CHECKING:\n"
|
||||
" from m import Thing\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "move helper above the TYPE_CHECKING guard")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert recipe.supported
|
||||
assert len(recipe.moves) == 1
|
||||
move = recipe.moves[0]
|
||||
assert move["name"] == "helper" and move["dst"] == "util.py"
|
||||
assert move["before"] is None
|
||||
assert move["after"] == "_is_hip"
|
||||
|
||||
|
||||
def test_infer_recipe_free_function_move_uses_requalify(repo: Path) -> None:
|
||||
"""A move to a module-level free function dedents and requalifies the call site
|
||||
(drops the qualifier), rather than lowering a receiver."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"class M:\n"
|
||||
" @staticmethod\n"
|
||||
" def foo(x):\n"
|
||||
" return x + 1\n"
|
||||
"\n"
|
||||
" def other(self):\n"
|
||||
" return 0\n"
|
||||
),
|
||||
"util.py": "import os\n",
|
||||
"caller.py": (
|
||||
"class K:\n" " def run(self):\n" " from model import M\n" "\n" " return M.foo(9)\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "class M:\n def other(self):\n return 0\n",
|
||||
"util.py": "import os\n\n\ndef foo(x):\n return x + 1\n",
|
||||
"caller.py": ("class K:\n def run(self):\n return foo(9)\n"),
|
||||
},
|
||||
)
|
||||
_commit(repo, "move foo to util as a free function")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert [(m["name"], m["into_class"], m["dedent"]) for m in recipe.moves] == [("foo", None, 4)]
|
||||
assert recipe.lowerings == [{"name": "foo", "owner": "M", "path": "caller.py", "kind": "requalify"}]
|
||||
|
||||
|
||||
def test_infer_recipe_excludes_the_moved_bodys_own_call(repo: Path) -> None:
|
||||
"""A same-named call on a different receiver inside the moved body is not a caller
|
||||
lowering (only `M.foo(...)` is, not `worker.foo(...)`)."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"class M:\n"
|
||||
" @staticmethod\n"
|
||||
" def foo(self, x):\n"
|
||||
" worker.foo(x)\n"
|
||||
" return x\n"
|
||||
"\n"
|
||||
" def other(self):\n"
|
||||
" return 0\n"
|
||||
),
|
||||
"comp.py": "class C:\n def keep(self):\n return 1\n",
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "class M:\n def other(self):\n return 0\n",
|
||||
"comp.py": (
|
||||
"class C:\n"
|
||||
" def keep(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
" def foo(self, x):\n"
|
||||
" worker.foo(x)\n"
|
||||
" return x\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "move foo onto C")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert recipe.lowerings == []
|
||||
|
||||
|
||||
def test_infer_recipe_skips_nested_functions(repo: Path) -> None:
|
||||
"""A def nested inside a moved method is not inferred as its own move."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"class M:\n"
|
||||
" def wrap(self):\n"
|
||||
" def inner(z):\n"
|
||||
" return z\n"
|
||||
" return inner\n"
|
||||
"\n"
|
||||
" def other(self):\n"
|
||||
" return 0\n"
|
||||
),
|
||||
"comp.py": "class C:\n def keep(self):\n return 1\n",
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "class M:\n def other(self):\n return 0\n",
|
||||
"comp.py": (
|
||||
"class C:\n"
|
||||
" def keep(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
" def wrap(self):\n"
|
||||
" def inner(z):\n"
|
||||
" return z\n"
|
||||
" return inner\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "move wrap onto C")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
names = [m["name"] for m in recipe.moves]
|
||||
assert names == ["wrap"]
|
||||
assert any("inner" in n for n in recipe.notes)
|
||||
|
||||
|
||||
def test_infer_recipe_free_function_source_move_repaths_caller(repo: Path) -> None:
|
||||
"""A free function moved to an existing module becomes a move_symbol with the call left
|
||||
bare; a caller's function-scoped import is repathed."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n\n\ndef resolve(m):\n return m\n",
|
||||
"util.py": "import os\n",
|
||||
"caller.py": (
|
||||
"class K:\n"
|
||||
" def run(self):\n"
|
||||
" from model import resolve\n"
|
||||
"\n"
|
||||
" return resolve(self.m)\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n",
|
||||
"util.py": "import os\n\n\ndef resolve(m):\n return m\n",
|
||||
"caller.py": (
|
||||
"class K:\n"
|
||||
" def run(self):\n"
|
||||
" from util import resolve\n"
|
||||
"\n"
|
||||
" return resolve(self.m)\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "move resolve to util")
|
||||
recipe = infer_recipe("HEAD", str(repo))
|
||||
assert recipe.supported
|
||||
assert [(m["name"], m["src"], m["dst"], m["into_class"]) for m in recipe.moves] == [
|
||||
("resolve", "model.py", "util.py", None)
|
||||
]
|
||||
assert recipe.lowerings == []
|
||||
assert recipe.repaths == [
|
||||
{
|
||||
"path": "caller.py",
|
||||
"old_module": "model",
|
||||
"new_module": "util",
|
||||
"name": "resolve",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_infer_recipe_survives_a_non_python_file_in_the_commit(repo: Path) -> None:
|
||||
"""A commit also touching a .md file infers the move and notes the non-Python path."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def foo():\n return 1\n\n\ndef keep():\n return 0\n",
|
||||
"util.py": "x = 1\n",
|
||||
"README.md": "hello\n",
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n",
|
||||
"util.py": "x = 1\n\n\ndef foo():\n return 1\n",
|
||||
"README.md": "hello world, this is plain markdown text\n",
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "move foo and touch docs")
|
||||
|
||||
recipe = infer_recipe(commit, str(repo))
|
||||
|
||||
assert [mv["name"] for mv in recipe.moves] == ["foo"]
|
||||
assert any("README.md" in note for note in recipe.notes)
|
||||
|
||||
|
||||
def test_infer_recipe_records_the_source_class_for_disambiguation(repo: Path) -> None:
|
||||
"""A method move carries from_class so the cut cannot hit a same-named other method."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"class M:\n"
|
||||
" def foo(self, x):\n"
|
||||
" return x + 1\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"class Other:\n"
|
||||
" def foo(self, x):\n"
|
||||
" return x + 2\n"
|
||||
),
|
||||
"comp.py": "class C:\n def keep(self):\n return 1\n",
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"class M:\n" " pass\n" "\n" "\n" "class Other:\n" " def foo(self, x):\n" " return x + 2\n"
|
||||
),
|
||||
"comp.py": (
|
||||
"class C:\n"
|
||||
" def keep(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
" def foo(self, x):\n"
|
||||
" return x + 1\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "move M.foo onto C")
|
||||
|
||||
recipe = infer_recipe(commit, str(repo))
|
||||
|
||||
assert [mv["from_class"] for mv in recipe.moves] == ["M"]
|
||||
script = recipe_to_script(recipe, "move M.foo onto C")
|
||||
assert "from_class='M'" in script
|
||||
|
||||
|
||||
def test_infer_recipe_module_level_def_shadowed_by_method_name(repo: Path) -> None:
|
||||
"""A column-0 cut resolves to the module-level def even when a method shares its name."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"def foo(*, x):\n"
|
||||
" return x + 1\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"class M:\n"
|
||||
" def foo(self):\n"
|
||||
" return foo(x=self.x)\n"
|
||||
),
|
||||
"util.py": "def keep():\n return 1\n",
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"from util import foo\n" "\n" "\n" "class M:\n" " def foo(self):\n" " return foo(x=self.x)\n"
|
||||
),
|
||||
"util.py": ("def keep():\n" " return 1\n" "\n" "\n" "def foo(*, x):\n" " return x + 1\n"),
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "move module-level foo to util")
|
||||
|
||||
recipe = infer_recipe(commit, str(repo))
|
||||
|
||||
assert recipe.supported
|
||||
assert [mv["name"] for mv in recipe.moves] == ["foo"]
|
||||
assert recipe.moves[0]["from_class"] is None
|
||||
assert recipe.moves[0]["into_class"] is None
|
||||
|
||||
|
||||
def test_infer_recipe_class_move_between_existing_files(repo: Path) -> None:
|
||||
"""A top-level class relocated to an existing module moves whole; its methods do not."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"class Payload:\n"
|
||||
" def get(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def stay():\n"
|
||||
" return 2\n"
|
||||
),
|
||||
"comp.py": "def keep():\n return 3\n",
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def stay():\n return 2\n",
|
||||
"comp.py": (
|
||||
"def keep():\n"
|
||||
" return 3\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"class Payload:\n"
|
||||
" def get(self):\n"
|
||||
" return 1\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "move Payload to comp")
|
||||
|
||||
recipe = infer_recipe(commit, str(repo))
|
||||
|
||||
assert recipe.supported
|
||||
assert [mv["name"] for mv in recipe.moves] == ["Payload"]
|
||||
assert recipe.moves[0]["from_class"] is None
|
||||
assert recipe.moves[0]["into_class"] is None
|
||||
|
||||
|
||||
def test_infer_recipe_move_leaving_a_forwarding_delegate(repo: Path) -> None:
|
||||
"""A same-named stub re-added to the source infers leave_delegate on the move."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": ("class M:\n" " def work(self, x):\n" " return x + 1\n"),
|
||||
"comp.py": "class C:\n def keep(self):\n return 1\n",
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": ("class M:\n" " def work(self, x):\n" " return self.comp.work(x)\n"),
|
||||
"comp.py": (
|
||||
"class C:\n"
|
||||
" def keep(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
" def work(self, x):\n"
|
||||
" return x + 1\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "move M.work onto C, leaving a delegate")
|
||||
|
||||
recipe = infer_recipe(commit, str(repo))
|
||||
|
||||
assert recipe.supported
|
||||
assert [mv["name"] for mv in recipe.moves] == ["work"]
|
||||
assert recipe.moves[0]["dst"] == "comp.py"
|
||||
assert recipe.moves[0]["leave_delegate"] == "comp"
|
||||
assert recipe.moves[0]["delegate_name"] is None
|
||||
script = recipe_to_script(recipe, "move with delegate")
|
||||
assert "leave_delegate='comp'" in script
|
||||
|
||||
|
||||
def test_infer_recipe_constant_relocated_with_the_move(repo: Path) -> None:
|
||||
"""A module constant that vanished from the source and appeared in the existing
|
||||
destination becomes a move_assign."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": (
|
||||
"RATIO = 3\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def work(x):\n"
|
||||
" return x * RATIO\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def stay():\n"
|
||||
" return 1\n"
|
||||
),
|
||||
"comp.py": "import os\n\n\ndef keep():\n return 2\n",
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def stay():\n return 1\n",
|
||||
"comp.py": (
|
||||
"import os\n"
|
||||
"\n"
|
||||
"RATIO = 3\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def keep():\n"
|
||||
" return 2\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def work(x):\n"
|
||||
" return x * RATIO\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "move work + RATIO to comp")
|
||||
|
||||
recipe = infer_recipe(commit, str(repo))
|
||||
|
||||
assert recipe.supported
|
||||
assert [am["name"] for am in recipe.assign_moves] == ["RATIO"]
|
||||
script = recipe_to_script(recipe, "move with constant")
|
||||
assert "move_assign" in script
|
||||
|
||||
|
||||
def test_infer_recipe_in_file_method_reorder(repo: Path) -> None:
|
||||
"""A method cut and re-inserted elsewhere in the same class (no other file gains it) infers
|
||||
an in-file move_symbol (src == dst) anchored above its new next sibling."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"m.py": (
|
||||
"class C:\n"
|
||||
" def a(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
" def b(self):\n"
|
||||
" return 2\n"
|
||||
"\n"
|
||||
" def c(self):\n"
|
||||
" return 3\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"m.py": (
|
||||
"class C:\n"
|
||||
" def c(self):\n"
|
||||
" return 3\n"
|
||||
"\n"
|
||||
" def a(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
" def b(self):\n"
|
||||
" return 2\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "move c above a")
|
||||
recipe = infer_recipe(commit, str(repo))
|
||||
assert recipe.supported
|
||||
assert len(recipe.moves) == 1
|
||||
mv = recipe.moves[0]
|
||||
assert mv["name"] == "c" and mv["src"] == "m.py" and mv["dst"] == "m.py"
|
||||
assert mv["into_class"] == "C" and mv["before"] == "a"
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from generator_testlib import _commit, _write # noqa: F401
|
||||
from mechanical_refactor_proof_generator import _main
|
||||
|
||||
|
||||
def _extract_function_commit(repo: Path) -> str:
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def dispatch(self, n):\n"
|
||||
" x = self.a\n"
|
||||
" y = x + n\n"
|
||||
" return y\n"
|
||||
"\n"
|
||||
" def keep(self):\n"
|
||||
" return 0\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"kv.py": (
|
||||
"class C:\n"
|
||||
" def dispatch(self, n):\n"
|
||||
" y = self._combine(n=n)\n"
|
||||
" return y\n"
|
||||
"\n"
|
||||
" def _combine(self, *, n):\n"
|
||||
" x = self.a\n"
|
||||
" y = x + n\n"
|
||||
" return y\n"
|
||||
"\n"
|
||||
" def keep(self):\n"
|
||||
" return 0\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
return _commit(repo, "extract _combine from dispatch")
|
||||
|
||||
|
||||
def test_single_commit_extract_function_reproduces_instead_of_unsupported(
|
||||
repo: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A pure intra-file extract_function commit run in single-commit mode reproduces (exit 0),
|
||||
not UNSUPPORTED -- the relocates check must count extract_functions like the range path.
|
||||
"""
|
||||
sha = _extract_function_commit(repo)
|
||||
monkeypatch.chdir(repo)
|
||||
assert _main([sha]) == 0
|
||||
|
||||
|
||||
def test_single_commit_pure_rename_is_unsupported(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A commit that relocates no definition (a bare rename) stays UNSUPPORTED with exit 1."""
|
||||
_write(repo, **{"m.py": "def foo():\n return 1\n"})
|
||||
_commit(repo, "base")
|
||||
_write(repo, **{"m.py": "def bar():\n return 1\n"})
|
||||
sha = _commit(repo, "rename foo to bar")
|
||||
monkeypatch.chdir(repo)
|
||||
assert _main([sha]) == 1
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from generator_testlib import ( # noqa: F401
|
||||
_commit,
|
||||
_free_function_move_with_module_level_caller,
|
||||
_git,
|
||||
_method_onto_class,
|
||||
_write,
|
||||
)
|
||||
from mechanical_refactor_proof_generator import infer_recipe, recipe_to_script
|
||||
|
||||
|
||||
def test_recipe_to_script_is_self_contained_and_ordered(repo: Path) -> None:
|
||||
"""The emitted script imports only the reproduce util and lowers before moving."""
|
||||
_method_onto_class(repo)
|
||||
script = recipe_to_script(infer_recipe("HEAD", str(repo)), "move foo onto C")
|
||||
assert "from mechanical_refactor_reproduction_utils import Repro" in script
|
||||
assert script.index("lower_call_sites") < script.index("move_symbol")
|
||||
assert "residual = r.run()" in script
|
||||
assert "sys.exit(1 if residual else 0)" in script
|
||||
# importing nothing else from the skill keeps the script auditable in isolation
|
||||
assert "mechanical_refactor_verify_utils" not in script
|
||||
assert "mechanical_refactor_proof_generator" not in script
|
||||
|
||||
|
||||
def test_recipe_to_script_orders_import_ops_after_moves(repo: Path) -> None:
|
||||
"""The emitted script applies module-level import add/remove AFTER the move, matching
|
||||
build_repro's run order so the script and the in-process verdict cannot diverge."""
|
||||
_free_function_move_with_module_level_caller(repo)
|
||||
script = recipe_to_script(infer_recipe("HEAD", str(repo)), "move resolve to util")
|
||||
assert script.index("move_symbol") < script.index("remove_imported_name")
|
||||
assert script.index("move_symbol") < script.index("add_import")
|
||||
|
||||
|
||||
def _emit_runnable_script(repo: Path, out: Path, commit: str, subject: str) -> Path:
|
||||
"""Write the emitted script plus its util dependency into a proof-folder layout."""
|
||||
scripts_dir = out / "repro_scripts"
|
||||
scripts_dir.mkdir(parents=True, exist_ok=True)
|
||||
utils_src = Path(__file__).resolve().parents[2] / ("mechanical_refactor_reproduction_utils.py")
|
||||
(out / "mechanical_refactor_reproduction_utils.py").write_text(utils_src.read_text())
|
||||
script = recipe_to_script(infer_recipe(commit, str(repo)), subject)
|
||||
script_path = scripts_dir / f"{commit[:9]}.py"
|
||||
script_path.write_text(script)
|
||||
return script_path
|
||||
|
||||
|
||||
def test_emitted_script_exits_zero_on_faithful_commit(repo: Path, tmp_path: Path) -> None:
|
||||
"""Running the emitted script on a clean move exits 0 and prints the PASS verdict."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n\n\ndef resolve(m):\n return m\n",
|
||||
"util.py": "import os\n",
|
||||
"caller.py": ("from model import resolve\n\n\ndef run(m):\n return resolve(m)\n"),
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
# The after-state is the primitives' exact output (this bare repo has no formatter
|
||||
# to absorb the cut's leftover blank lines, unlike a pre-commit-clean real repo).
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n\n\n",
|
||||
"util.py": "import os\n\ndef resolve(m):\n return m\n",
|
||||
"caller.py": ("from util import resolve\n\n\ndef run(m):\n return resolve(m)\n"),
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "move resolve to util")
|
||||
script_path = _emit_runnable_script(repo, tmp_path / "out", commit, "move")
|
||||
|
||||
result = subprocess.run([sys.executable, str(script_path)], cwd=repo, capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "PASS" in result.stdout
|
||||
|
||||
|
||||
def test_emitted_script_exits_nonzero_on_bundled_change(repo: Path, tmp_path: Path) -> None:
|
||||
"""A commit bundling a non-move change makes the emitted script exit non-zero."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n\n\ndef resolve(m):\n return m\n",
|
||||
"util.py": "import os\n",
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 99\n",
|
||||
"util.py": "import os\n\n\ndef resolve(m):\n return m\n",
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "move resolve AND change keep")
|
||||
script_path = _emit_runnable_script(repo, tmp_path / "out", commit, "dirty move")
|
||||
|
||||
result = subprocess.run([sys.executable, str(script_path)], cwd=repo, capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 1, result.stdout + result.stderr
|
||||
assert "RESIDUAL" in result.stdout
|
||||
|
||||
|
||||
def test_emitted_script_passes_on_move_above_typechecking_guard(repo: Path, tmp_path: Path) -> None:
|
||||
"""A module-level def relocated to just above an ``if TYPE_CHECKING:`` guard reproduces
|
||||
via an inferred after= anchor and the emitted script exits 0."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": ("def keep():\n return 0\n\n\ndef helper(x):\n return x + 1\n"),
|
||||
"util.py": (
|
||||
"from u import is_hip\n"
|
||||
"\n"
|
||||
"_is_hip = is_hip()\n"
|
||||
"\n"
|
||||
"if TYPE_CHECKING:\n"
|
||||
" from m import Thing\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
_commit(repo, "base")
|
||||
# After-state = the primitive's exact output (bare repo, no formatter to absorb blanks).
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n\n\n",
|
||||
"util.py": (
|
||||
"from u import is_hip\n"
|
||||
"\n"
|
||||
"_is_hip = is_hip()\n"
|
||||
"\n"
|
||||
"def helper(x):\n"
|
||||
" return x + 1\n"
|
||||
"\n"
|
||||
"if TYPE_CHECKING:\n"
|
||||
" from m import Thing\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
commit = _commit(repo, "move helper above the TYPE_CHECKING guard")
|
||||
script_path = _emit_runnable_script(repo, tmp_path / "out", commit, "after-anchor move")
|
||||
|
||||
result = subprocess.run([sys.executable, str(script_path)], cwd=repo, capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "PASS" in result.stdout
|
||||
assert "after='_is_hip'" in script_path.read_text()
|
||||
|
||||
|
||||
def test_per_file_diff_keeps_content_lines_starting_with_plus_signs(repo: Path) -> None:
|
||||
"""An added content line beginning with '++' is collected, not mistaken for a header."""
|
||||
from mechanical_refactor_proof_generator import _per_file_diff
|
||||
|
||||
_write(repo, **{"notes.py": "a = 1\n"})
|
||||
_commit(repo, "base")
|
||||
_write(repo, **{"notes.py": 'a = 1\nb = "++x"\n'})
|
||||
commit = _commit(repo, "add plus-plus line")
|
||||
|
||||
files = _per_file_diff(commit, str(repo))
|
||||
|
||||
assert files["notes.py"]["added"] == ['b = "++x"']
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
_PASSING_PROOF = "import sys\n" 'print("PASS: reproduces the commit byte-for-byte.")\n' "sys.exit(0)\n"
|
||||
_FAILING_PROOF = "import sys\n" 'print("RESIDUAL (2 lines):\\n+x\\n-y")\n' "sys.exit(1)\n"
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
|
||||
def _write(repo: Path, **files: "str | None") -> None:
|
||||
for name, content in files.items():
|
||||
path = repo / name.replace("__", "/")
|
||||
if content is None:
|
||||
path.unlink()
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
|
||||
|
||||
def _commit(repo: Path, message: str) -> str:
|
||||
_git(repo, "add", "-A")
|
||||
_git(repo, "commit", "-q", "-m", message)
|
||||
return _git(repo, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def _chain(repo: Path, messages: "list[str]") -> "tuple[str, list[str]]":
|
||||
"""A base commit plus one single-file commit per message, on a `chain` branch.
|
||||
Returns (base_sha, commit_shas)."""
|
||||
_write(repo, **{"seed.py": "SEED = 0\n"})
|
||||
base = _commit(repo, "base")
|
||||
_git(repo, "switch", "-q", "-c", "chain")
|
||||
shas: list[str] = []
|
||||
for i, message in enumerate(messages):
|
||||
_write(repo, **{f"file_{i}.py": f"VALUE = {i}\n"})
|
||||
shas.append(_commit(repo, message))
|
||||
return base, shas
|
||||
|
||||
|
||||
def _write_stub_proof(
|
||||
proof_dir: Path,
|
||||
sha: str,
|
||||
*,
|
||||
passing: bool = True,
|
||||
flat: bool = False,
|
||||
stem_len: int = 9,
|
||||
) -> Path:
|
||||
"""A stand-in proof script printing the arbiter's verdict line and exiting to match."""
|
||||
directory = proof_dir if flat else proof_dir / "repro_scripts"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{sha[:stem_len]}.py"
|
||||
path.write_text(_PASSING_PROOF if passing else _FAILING_PROOF)
|
||||
return path
|
||||
@@ -0,0 +1,20 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from cli_testlib import _git
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
_git(root, "init", "-q", "-b", "main")
|
||||
_git(root, "config", "user.email", "test@example.com")
|
||||
_git(root, "config", "user.name", "test")
|
||||
_git(root, "config", "commit.gpgsign", "false")
|
||||
return root
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from cli_testlib import _chain, _write_stub_proof
|
||||
from mechanical_refactor_reproduction_cli import (
|
||||
KIND_MECHANICAL,
|
||||
KIND_NON_MECHANICAL,
|
||||
VERDICT_AMBIGUOUS_KIND,
|
||||
VERDICT_HUMAN_REVIEW,
|
||||
VERDICT_PASS,
|
||||
VERDICT_UNCLASSIFIED,
|
||||
verify_chain,
|
||||
)
|
||||
|
||||
|
||||
def _single_verdict(repo: Path, tmp_path: Path, message: str, *, with_proof: bool):
|
||||
proof = tmp_path / "proof"
|
||||
proof.mkdir(exist_ok=True)
|
||||
base, shas = _chain(repo, [message])
|
||||
if with_proof:
|
||||
_write_stub_proof(proof, shas[0])
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
return result.verdicts[0]
|
||||
|
||||
|
||||
def test_mechanical_provable_word_classifies_the_commit_as_mechanical(repo: Path, tmp_path: Path) -> None:
|
||||
"""A message carrying mechanical_provable is classified mechanical and needs a proof."""
|
||||
verdict = _single_verdict(repo, tmp_path, "grp(step,mechanical_provable): move foo", with_proof=True)
|
||||
assert verdict.kind == KIND_MECHANICAL
|
||||
assert verdict.verdict == VERDICT_PASS
|
||||
|
||||
|
||||
def test_non_mechanical_provable_word_is_not_double_counted_as_the_bare_word(repo: Path, tmp_path: Path) -> None:
|
||||
"""non_mechanical_provable classifies as non-mechanical, not as both words at once."""
|
||||
verdict = _single_verdict(
|
||||
repo,
|
||||
tmp_path,
|
||||
"grp(step,non_mechanical_provable): rework foo",
|
||||
with_proof=False,
|
||||
)
|
||||
assert verdict.kind == KIND_NON_MECHANICAL
|
||||
assert verdict.verdict == VERDICT_HUMAN_REVIEW
|
||||
|
||||
|
||||
def test_message_without_either_word_is_unclassified_and_fails_the_chain(repo: Path, tmp_path: Path) -> None:
|
||||
"""A commit missing both words gets UNCLASSIFIED and the chain does not pass."""
|
||||
proof = tmp_path / "proof"
|
||||
proof.mkdir()
|
||||
base, _ = _chain(repo, ["plain subject with no kind word"])
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
assert result.verdicts[0].verdict == VERDICT_UNCLASSIFIED
|
||||
assert result.verdicts[0].kind is None
|
||||
assert not result.passed
|
||||
|
||||
|
||||
def test_message_with_both_words_is_ambiguous(repo: Path, tmp_path: Path) -> None:
|
||||
"""A commit declaring both kinds gets AMBIGUOUS_KIND and fails the chain."""
|
||||
verdict = _single_verdict(
|
||||
repo,
|
||||
tmp_path,
|
||||
"subject mechanical_provable\n\nbody also says non_mechanical_provable",
|
||||
with_proof=True,
|
||||
)
|
||||
assert verdict.verdict == VERDICT_AMBIGUOUS_KIND
|
||||
assert verdict.kind is None
|
||||
|
||||
|
||||
def test_kind_word_must_stand_alone_not_as_a_substring(repo: Path, tmp_path: Path) -> None:
|
||||
"""xmechanical_provable / mechanical_provable_x do not count as the standalone word."""
|
||||
verdict = _single_verdict(
|
||||
repo,
|
||||
tmp_path,
|
||||
"xmechanical_provable and mechanical_provable_x only",
|
||||
with_proof=False,
|
||||
)
|
||||
assert verdict.verdict == VERDICT_UNCLASSIFIED
|
||||
|
||||
|
||||
def test_kind_word_delimited_by_punctuation_counts(repo: Path, tmp_path: Path) -> None:
|
||||
"""The word inside punctuation, e.g. (step,mechanical_provable), is a valid match."""
|
||||
verdict = _single_verdict(repo, tmp_path, "grp(step,mechanical_provable): move", with_proof=True)
|
||||
assert verdict.kind == KIND_MECHANICAL
|
||||
|
||||
|
||||
def test_repeating_the_same_kind_word_is_accepted(repo: Path, tmp_path: Path) -> None:
|
||||
"""Multiple occurrences of one kind word still classify unambiguously."""
|
||||
verdict = _single_verdict(
|
||||
repo,
|
||||
tmp_path,
|
||||
"mechanical_provable move\n\nthis commit is mechanical_provable",
|
||||
with_proof=True,
|
||||
)
|
||||
assert verdict.kind == KIND_MECHANICAL
|
||||
assert verdict.verdict == VERDICT_PASS
|
||||
|
||||
|
||||
def test_kind_word_in_the_body_counts_when_subject_is_free_form(repo: Path, tmp_path: Path) -> None:
|
||||
"""Classification scans the whole message, so a body-only word is enough."""
|
||||
verdict = _single_verdict(
|
||||
repo,
|
||||
tmp_path,
|
||||
"Move resolve to util\n\nKind: non_mechanical_provable",
|
||||
with_proof=False,
|
||||
)
|
||||
assert verdict.kind == KIND_NON_MECHANICAL
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from cli_testlib import _chain, _write_stub_proof
|
||||
from mechanical_refactor_reproduction_cli import (
|
||||
VERDICT_AMBIGUOUS_PROOF,
|
||||
VERDICT_MISSING_PROOF,
|
||||
VERDICT_PASS,
|
||||
verify_chain,
|
||||
)
|
||||
|
||||
_MSG = "mechanical_provable: move foo"
|
||||
|
||||
|
||||
def _run_single(repo: Path, proof: Path):
|
||||
base, shas = _chain(repo, [_MSG])
|
||||
return shas[0], verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
|
||||
|
||||
def test_proof_is_found_under_repro_scripts_by_sha_prefix(repo: Path, tmp_path: Path) -> None:
|
||||
"""The generator layout repro_scripts/<sha9>.py resolves to the commit's proof."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, [_MSG])
|
||||
_write_stub_proof(proof, shas[0], stem_len=9)
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
assert result.verdicts[0].verdict == VERDICT_PASS
|
||||
|
||||
|
||||
def test_proof_is_found_flat_in_the_proof_folder_by_full_sha(repo: Path, tmp_path: Path) -> None:
|
||||
"""A flat <proof>/<full-sha>.py layout is also accepted."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, [_MSG])
|
||||
_write_stub_proof(proof, shas[0], flat=True, stem_len=40)
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
assert result.verdicts[0].verdict == VERDICT_PASS
|
||||
|
||||
|
||||
def test_provable_commit_without_a_proof_script_is_missing_proof(repo: Path, tmp_path: Path) -> None:
|
||||
"""A mechanical_provable commit with no matching script fails as MISSING_PROOF."""
|
||||
proof = tmp_path / "proof"
|
||||
proof.mkdir()
|
||||
sha, result = _run_single(repo, proof)
|
||||
assert result.verdicts[0].verdict == VERDICT_MISSING_PROOF
|
||||
assert not result.passed
|
||||
|
||||
|
||||
def test_unrelated_and_non_hex_scripts_do_not_match(repo: Path, tmp_path: Path) -> None:
|
||||
"""Scripts named for another sha or with a non-hex stem are not this commit's proof."""
|
||||
proof = tmp_path / "proof"
|
||||
scripts = proof / "repro_scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
(scripts / "0123456789abcdef.py").write_text("raise SystemExit(1)\n")
|
||||
(scripts / "not_a_sha.py").write_text("raise SystemExit(1)\n")
|
||||
sha, result = _run_single(repo, proof)
|
||||
assert result.verdicts[0].verdict == VERDICT_MISSING_PROOF
|
||||
|
||||
|
||||
def test_two_scripts_matching_one_commit_are_ambiguous(repo: Path, tmp_path: Path) -> None:
|
||||
"""A commit matched by both a nested and a flat script fails as AMBIGUOUS_PROOF."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, [_MSG])
|
||||
_write_stub_proof(proof, shas[0], stem_len=9)
|
||||
_write_stub_proof(proof, shas[0], flat=True, stem_len=12)
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
assert result.verdicts[0].verdict == VERDICT_AMBIGUOUS_PROOF
|
||||
assert not result.passed
|
||||
|
||||
|
||||
def test_short_hex_stem_below_minimum_length_is_ignored(repo: Path, tmp_path: Path) -> None:
|
||||
"""A 6-char hex stem is too short to name a commit and is not treated as a proof."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, [_MSG])
|
||||
_write_stub_proof(proof, shas[0], stem_len=6)
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
assert result.verdicts[0].verdict == VERDICT_MISSING_PROOF
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from cli_testlib import _chain, _write_stub_proof
|
||||
from mechanical_refactor_reproduction_cli import main, render_report, verify_chain
|
||||
|
||||
|
||||
def _mixed_chain_result(repo: Path, tmp_path: Path):
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(
|
||||
repo,
|
||||
[
|
||||
"mechanical_provable: move foo",
|
||||
"non_mechanical_provable: rework bar",
|
||||
"mechanical_provable: move baz",
|
||||
],
|
||||
)
|
||||
_write_stub_proof(proof, shas[0])
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
return proof, base, shas, result
|
||||
|
||||
|
||||
def test_report_has_header_table_row_per_commit_and_chain_verdict(repo: Path, tmp_path: Path) -> None:
|
||||
"""The report carries base/branch/proof, one table row per commit, and the verdict."""
|
||||
proof, base, shas, result = _mixed_chain_result(repo, tmp_path)
|
||||
report = render_report(result)
|
||||
|
||||
assert "# Mechanical refactor chain report" in report
|
||||
assert f"`{base[:12]}`" in report
|
||||
assert "chain verdict: **FAIL**" in report
|
||||
assert "3 total — 2 mechanical_provable, 1 non_mechanical_provable" in report
|
||||
for sha in shas:
|
||||
assert f"`{sha[:9]}`" in report
|
||||
assert "| mechanical_provable | PASS |" in report
|
||||
assert "| non_mechanical_provable | HUMAN_REVIEW |" in report
|
||||
assert "| mechanical_provable | MISSING_PROOF |" in report
|
||||
|
||||
|
||||
def test_report_lists_failure_details_for_each_non_ok_commit(repo: Path, tmp_path: Path) -> None:
|
||||
"""Every non-ok commit gets a failure-details section with its explanation."""
|
||||
proof, base, shas, result = _mixed_chain_result(repo, tmp_path)
|
||||
report = render_report(result)
|
||||
|
||||
assert "## Failure details" in report
|
||||
assert f"### `{shas[2][:9]}` — MISSING_PROOF" in report
|
||||
assert "no proof script found" in report
|
||||
|
||||
|
||||
def test_passing_report_has_no_failure_details_section(repo: Path, tmp_path: Path) -> None:
|
||||
"""A fully verified chain renders a PASS report without a failure section."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, ["mechanical_provable: move foo"])
|
||||
_write_stub_proof(proof, shas[0])
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
report = render_report(result)
|
||||
|
||||
assert "chain verdict: **PASS**" in report
|
||||
assert "proofs: 1/1 PASS" in report
|
||||
assert "## Failure details" not in report
|
||||
|
||||
|
||||
def test_main_writes_the_report_into_the_proof_folder_by_default(repo: Path, tmp_path: Path, capsys) -> None:
|
||||
"""main prints the report and writes <proof>/chain_report.md (or --report PATH)."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, ["mechanical_provable: move foo"])
|
||||
_write_stub_proof(proof, shas[0])
|
||||
args = [
|
||||
"--base",
|
||||
base,
|
||||
"--branch",
|
||||
"chain",
|
||||
"--proof",
|
||||
str(proof),
|
||||
"--repo-root",
|
||||
str(repo),
|
||||
]
|
||||
|
||||
assert main(args) == 0
|
||||
default_report = proof / "chain_report.md"
|
||||
assert "chain verdict: **PASS**" in default_report.read_text()
|
||||
assert "chain verdict: **PASS**" in capsys.readouterr().out
|
||||
|
||||
custom = tmp_path / "custom_report.md"
|
||||
assert main([*args, "--report", str(custom)]) == 0
|
||||
assert "chain verdict: **PASS**" in custom.read_text()
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from cli_testlib import _chain, _write_stub_proof
|
||||
from mechanical_refactor_reproduction_cli import VERDICT_FAIL, VERDICT_PASS, main, render_report, verify_chain
|
||||
|
||||
_MSG = "mechanical_provable: move foo"
|
||||
|
||||
|
||||
def _counting_proof(script: Path, counter: Path, *, passing: bool = True) -> None:
|
||||
"""Make the stub proof bump a run counter so re-execution is observable."""
|
||||
verdict = (
|
||||
'print("PASS: reproduces the commit byte-for-byte.")\nsys.exit(0)\n'
|
||||
if passing
|
||||
else 'print("RESIDUAL (1 lines):\\n+x")\nsys.exit(1)\n'
|
||||
)
|
||||
script.write_text(
|
||||
"import sys\n"
|
||||
f"counter = __import__('pathlib').Path({str(counter)!r})\n"
|
||||
"runs = int(counter.read_text()) if counter.exists() else 0\n"
|
||||
"counter.write_text(str(runs + 1))\n" + verdict
|
||||
)
|
||||
|
||||
|
||||
def _cache_file(repo: Path) -> Path:
|
||||
return repo / ".git" / "mechanical_refactor_passed_proofs.json"
|
||||
|
||||
|
||||
def test_skip_passed_reuses_an_unchanged_pass_without_rerunning(repo: Path, tmp_path: Path) -> None:
|
||||
"""A PASS recorded on the first run is reused: the proof does not execute again."""
|
||||
proof = tmp_path / "proof"
|
||||
counter = tmp_path / "runs"
|
||||
base, shas = _chain(repo, [_MSG])
|
||||
_counting_proof(_write_stub_proof(proof, shas[0]), counter)
|
||||
args = dict(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
|
||||
first = verify_chain(**args)
|
||||
assert first.verdicts[0].verdict == VERDICT_PASS
|
||||
assert counter.read_text() == "1"
|
||||
|
||||
second = verify_chain(**args, skip_passed=True)
|
||||
assert second.verdicts[0].verdict == VERDICT_PASS
|
||||
assert second.verdicts[0].cached
|
||||
assert counter.read_text() == "1"
|
||||
assert second.passed
|
||||
|
||||
|
||||
def test_without_the_flag_the_proof_always_reruns(repo: Path, tmp_path: Path) -> None:
|
||||
"""The cache is recorded on every run but consulted only under skip_passed."""
|
||||
proof = tmp_path / "proof"
|
||||
counter = tmp_path / "runs"
|
||||
base, shas = _chain(repo, [_MSG])
|
||||
_counting_proof(_write_stub_proof(proof, shas[0]), counter)
|
||||
args = dict(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
|
||||
verify_chain(**args)
|
||||
result = verify_chain(**args)
|
||||
|
||||
assert counter.read_text() == "2"
|
||||
assert not result.verdicts[0].cached
|
||||
|
||||
|
||||
def test_editing_the_proof_script_invalidates_the_cache(repo: Path, tmp_path: Path) -> None:
|
||||
"""A changed script hash misses the cache, so the edited (failing) proof reruns."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, [_MSG])
|
||||
script = _write_stub_proof(proof, shas[0])
|
||||
|
||||
verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
script.write_text('print("RESIDUAL (1 lines):\\n+x")\nraise SystemExit(1)\n')
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo), skip_passed=True)
|
||||
|
||||
assert result.verdicts[0].verdict == VERDICT_FAIL
|
||||
|
||||
|
||||
def test_editing_the_utils_copy_invalidates_the_cache(repo: Path, tmp_path: Path) -> None:
|
||||
"""The utils module next to the scripts is part of the key: editing it forces a rerun."""
|
||||
proof = tmp_path / "proof"
|
||||
counter = tmp_path / "runs"
|
||||
utils = proof / "mechanical_refactor_reproduction_utils.py"
|
||||
base, shas = _chain(repo, [_MSG])
|
||||
_counting_proof(_write_stub_proof(proof, shas[0]), counter)
|
||||
utils.parent.mkdir(parents=True, exist_ok=True)
|
||||
utils.write_text("ENGINE = 1\n")
|
||||
args = dict(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
|
||||
verify_chain(**args)
|
||||
utils.write_text("ENGINE = 2\n")
|
||||
result = verify_chain(**args, skip_passed=True)
|
||||
|
||||
assert counter.read_text() == "2"
|
||||
assert not result.verdicts[0].cached
|
||||
|
||||
|
||||
def test_a_fail_is_never_recorded_in_the_cache(repo: Path, tmp_path: Path) -> None:
|
||||
"""Only PASS verdicts enter the cache; a failing proof leaves no entry for its sha."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, [_MSG])
|
||||
_write_stub_proof(proof, shas[0], passing=False)
|
||||
|
||||
verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
|
||||
cache = _cache_file(repo)
|
||||
assert not cache.exists() or shas[0] not in json.loads(cache.read_text())["passed"]
|
||||
|
||||
|
||||
def test_corrupt_cache_file_is_treated_as_empty(repo: Path, tmp_path: Path) -> None:
|
||||
"""A garbage cache file never crashes the walk; the proof simply runs."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, [_MSG])
|
||||
_write_stub_proof(proof, shas[0])
|
||||
_cache_file(repo).write_text("{not json")
|
||||
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo), skip_passed=True)
|
||||
|
||||
assert result.verdicts[0].verdict == VERDICT_PASS
|
||||
assert not result.verdicts[0].cached
|
||||
|
||||
|
||||
def test_cache_lives_in_the_git_common_dir_and_records_the_pass(repo: Path, tmp_path: Path) -> None:
|
||||
"""A PASS writes the (sha, script hash, utils hash) entry under .git/."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, [_MSG])
|
||||
_write_stub_proof(proof, shas[0])
|
||||
|
||||
verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
|
||||
entry = json.loads(_cache_file(repo).read_text())["passed"][shas[0]]
|
||||
assert set(entry) == {"script_sha256", "utils_sha256"}
|
||||
assert len(entry["script_sha256"]) == 64
|
||||
|
||||
|
||||
def test_report_counts_reused_proofs_and_main_accepts_the_flag(repo: Path, tmp_path: Path) -> None:
|
||||
"""The report carries the reused count and --skip-passed works through main."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, [_MSG])
|
||||
_write_stub_proof(proof, shas[0])
|
||||
cli_args = [
|
||||
"--base",
|
||||
base,
|
||||
"--branch",
|
||||
"chain",
|
||||
"--proof",
|
||||
str(proof),
|
||||
"--repo-root",
|
||||
str(repo),
|
||||
]
|
||||
|
||||
assert main(cli_args) == 0
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo), skip_passed=True)
|
||||
report = render_report(result)
|
||||
|
||||
assert "reused from the passed-proof cache (--skip-passed): 1" in report
|
||||
assert main([*cli_args, "--skip-passed"]) == 0
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from cli_testlib import _chain, _commit, _git, _write, _write_stub_proof
|
||||
from mechanical_refactor_proof_generator import generate_range
|
||||
from mechanical_refactor_reproduction_cli import (
|
||||
VERDICT_FAIL,
|
||||
VERDICT_HUMAN_REVIEW,
|
||||
VERDICT_PASS,
|
||||
ChainVerificationError,
|
||||
main,
|
||||
verify_chain,
|
||||
)
|
||||
|
||||
|
||||
def test_chain_of_proved_and_declared_commits_passes(repo: Path, tmp_path: Path) -> None:
|
||||
"""A proved mechanical commit plus a declared non-mechanical one verifies as PASS."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(
|
||||
repo,
|
||||
["mechanical_provable: move foo", "non_mechanical_provable: rework bar"],
|
||||
)
|
||||
_write_stub_proof(proof, shas[0])
|
||||
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
|
||||
assert [v.verdict for v in result.verdicts] == [VERDICT_PASS, VERDICT_HUMAN_REVIEW]
|
||||
assert result.passed
|
||||
|
||||
|
||||
def test_failing_proof_fails_the_commit_and_the_chain(repo: Path, tmp_path: Path) -> None:
|
||||
"""A proof that exits non-zero yields FAIL with the output tail in the detail."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, ["mechanical_provable: move foo"])
|
||||
_write_stub_proof(proof, shas[0], passing=False)
|
||||
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
|
||||
assert result.verdicts[0].verdict == VERDICT_FAIL
|
||||
assert "RESIDUAL" in result.verdicts[0].detail
|
||||
assert not result.passed
|
||||
|
||||
|
||||
def test_proof_exiting_zero_without_a_pass_line_is_a_fail(repo: Path, tmp_path: Path) -> None:
|
||||
"""PASS needs exit 0 AND the PASS: verdict line, so a residual under exit 0 fails."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, ["mechanical_provable: move foo"])
|
||||
script = _write_stub_proof(proof, shas[0])
|
||||
script.write_text('print("RESIDUAL (1 lines):\\n+x")\n')
|
||||
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
|
||||
assert result.verdicts[0].verdict == VERDICT_FAIL
|
||||
|
||||
|
||||
def test_main_exit_codes_reflect_the_chain_verdict(repo: Path, tmp_path: Path) -> None:
|
||||
"""main returns 0 for a verified chain and 1 once an unverifiable commit appears."""
|
||||
proof = tmp_path / "proof"
|
||||
base, shas = _chain(repo, ["mechanical_provable: move"])
|
||||
_write_stub_proof(proof, shas[0])
|
||||
args = [
|
||||
"--base",
|
||||
base,
|
||||
"--branch",
|
||||
"chain",
|
||||
"--proof",
|
||||
str(proof),
|
||||
"--repo-root",
|
||||
str(repo),
|
||||
]
|
||||
|
||||
assert main(args) == 0
|
||||
|
||||
_git(repo, "commit", "-q", "--allow-empty", "-m", "plain subject with no kind word")
|
||||
assert main(args) == 1
|
||||
|
||||
|
||||
def test_unresolvable_refs_and_missing_proof_folder_are_setup_errors(repo: Path, tmp_path: Path) -> None:
|
||||
"""Bad --base/--branch/--proof inputs raise ChainVerificationError (exit code 2)."""
|
||||
proof = tmp_path / "proof"
|
||||
proof.mkdir()
|
||||
base, _ = _chain(repo, ["mechanical_provable: move"])
|
||||
|
||||
with pytest.raises(ChainVerificationError):
|
||||
verify_chain(base=base, branch="no-such-branch", proof=proof, repo_root=str(repo))
|
||||
with pytest.raises(ChainVerificationError):
|
||||
verify_chain(
|
||||
base=base,
|
||||
branch="chain",
|
||||
proof=tmp_path / "missing",
|
||||
repo_root=str(repo),
|
||||
)
|
||||
assert (
|
||||
main(
|
||||
[
|
||||
"--base",
|
||||
base,
|
||||
"--branch",
|
||||
"no-such-branch",
|
||||
"--proof",
|
||||
str(proof),
|
||||
"--repo-root",
|
||||
str(repo),
|
||||
]
|
||||
)
|
||||
== 2
|
||||
)
|
||||
|
||||
|
||||
def test_non_ancestor_base_and_empty_range_are_setup_errors(repo: Path, tmp_path: Path) -> None:
|
||||
"""A base off the branch or an empty base..branch range refuses to verify."""
|
||||
proof = tmp_path / "proof"
|
||||
proof.mkdir()
|
||||
base, shas = _chain(repo, ["mechanical_provable: move"])
|
||||
_git(repo, "switch", "-q", "main")
|
||||
_write(repo, **{"other.py": "OTHER = 1\n"})
|
||||
off_branch = _commit(repo, "unrelated main-side commit")
|
||||
|
||||
with pytest.raises(ChainVerificationError):
|
||||
verify_chain(base=off_branch, branch="chain", proof=proof, repo_root=str(repo))
|
||||
with pytest.raises(ChainVerificationError):
|
||||
verify_chain(base=shas[0], branch=shas[0], proof=proof, repo_root=str(repo))
|
||||
|
||||
|
||||
def test_merge_commit_in_the_chain_is_a_setup_error(repo: Path, tmp_path: Path) -> None:
|
||||
"""A non-linear chain (contains a merge commit) refuses to verify."""
|
||||
proof = tmp_path / "proof"
|
||||
proof.mkdir()
|
||||
base, _ = _chain(repo, ["mechanical_provable: move"])
|
||||
_git(repo, "switch", "-q", "main")
|
||||
_write(repo, **{"other.py": "OTHER = 1\n"})
|
||||
_commit(repo, "mechanical_provable: main-side")
|
||||
_git(repo, "switch", "-q", "chain")
|
||||
_git(repo, "merge", "-q", "--no-ff", "-m", "non_mechanical_provable: merge", "main")
|
||||
|
||||
with pytest.raises(ChainVerificationError):
|
||||
verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
|
||||
|
||||
def test_proofs_run_concurrently_up_to_jobs(repo: Path, tmp_path: Path) -> None:
|
||||
"""With jobs>=2 a proof that waits on a sibling proof's sentinel still completes."""
|
||||
proof = tmp_path / "proof"
|
||||
sentinel = tmp_path / "sentinel"
|
||||
base, shas = _chain(repo, ["mechanical_provable: move a", "mechanical_provable: move b"])
|
||||
waiter = _write_stub_proof(proof, shas[0])
|
||||
waiter.write_text(
|
||||
"import sys, time\n"
|
||||
f"deadline = time.monotonic() + 30\n"
|
||||
f"while not __import__('pathlib').Path({str(sentinel)!r}).exists():\n"
|
||||
" if time.monotonic() > deadline:\n"
|
||||
" sys.exit(1)\n"
|
||||
" time.sleep(0.05)\n"
|
||||
'print("PASS: reproduces the commit byte-for-byte.")\n'
|
||||
"sys.exit(0)\n"
|
||||
)
|
||||
creator = _write_stub_proof(proof, shas[1])
|
||||
creator.write_text(
|
||||
"import sys\n"
|
||||
f"__import__('pathlib').Path({str(sentinel)!r}).write_text('go')\n"
|
||||
'print("PASS: reproduces the commit byte-for-byte.")\n'
|
||||
"sys.exit(0)\n"
|
||||
)
|
||||
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo), jobs=2)
|
||||
|
||||
assert [v.verdict for v in result.verdicts] == [VERDICT_PASS, VERDICT_PASS]
|
||||
assert [v.sha for v in result.verdicts] == shas
|
||||
assert result.passed
|
||||
|
||||
|
||||
def test_end_to_end_with_a_generated_proof_folder(repo: Path, tmp_path: Path) -> None:
|
||||
"""A real move commit proved by generate_range verifies through the CLI end-to-end."""
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n\n\ndef resolve(m):\n return m\n",
|
||||
"util.py": "import os\n",
|
||||
},
|
||||
)
|
||||
base = _commit(repo, "base")
|
||||
_git(repo, "switch", "-q", "-c", "chain")
|
||||
# The after-state is the primitives' exact output (this bare repo has no formatter
|
||||
# to absorb the cut's leftover blank lines, unlike a pre-commit-clean real repo).
|
||||
_write(
|
||||
repo,
|
||||
**{
|
||||
"model.py": "def keep():\n return 0\n\n\n",
|
||||
"util.py": "import os\n\ndef resolve(m):\n return m\n",
|
||||
},
|
||||
)
|
||||
move_sha = _commit(repo, "mechanical_provable: move resolve to util")
|
||||
|
||||
proof = tmp_path / "proof"
|
||||
generate_range(f"{base}..chain", out_dir=str(proof), repo_root=str(repo))
|
||||
result = verify_chain(base=base, branch="chain", proof=proof, repo_root=str(repo))
|
||||
|
||||
assert result.verdicts[0].sha == move_sha
|
||||
assert result.verdicts[0].verdict == VERDICT_PASS
|
||||
assert result.passed
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from reproduction_testlib import _git
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
_git(root, "init", "-q")
|
||||
_git(root, "config", "user.email", "test@example.com")
|
||||
_git(root, "config", "user.name", "test")
|
||||
_git(root, "config", "commit.gpgsign", "false")
|
||||
return root
|
||||
|
||||
|
||||
# --- exec_command --------------------------------------------------------------
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
|
||||
|
||||
def _apply(repro: Repro, root: Path) -> None:
|
||||
"""Run a built Repro's recorded operations against a plain directory (no git)."""
|
||||
for op in repro.ops:
|
||||
op(root)
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
|
||||
def _write(repo: Path, **files: str | None) -> None:
|
||||
for name, content in files.items():
|
||||
path = repo / name.replace("__", "/")
|
||||
if content is None:
|
||||
path.unlink()
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
|
||||
|
||||
def _commit(repo: Path, message: str) -> str:
|
||||
_git(repo, "add", "-A")
|
||||
_git(repo, "commit", "-q", "-m", message)
|
||||
return _git(repo, "rev-parse", "HEAD")
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
from reproduction_testlib import _apply, _commit, _git, _write # noqa: F401
|
||||
|
||||
# --- add_import ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_import_appends_after_last_top_level_import(tmp_path: Path) -> None:
|
||||
"""A new import is inserted right after the last module-level import."""
|
||||
(tmp_path / "m.py").write_text("import os\nimport sys\n\nx = 1\n")
|
||||
r = Repro("b", "t").add_import("m.py", "from pkg import Thing")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "import os\nimport sys\nfrom pkg import Thing\n\nx = 1\n"
|
||||
|
||||
|
||||
# --- add_imported_name ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_imported_name_extends_a_single_line_import(tmp_path: Path) -> None:
|
||||
"""A new name is appended to an existing from-import on the same statement."""
|
||||
(tmp_path / "m.py").write_text("from pkg import a\n\nx = 1\n")
|
||||
r = Repro("b", "t").add_imported_name("m.py", module="pkg", name="b")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "from pkg import a, b\n\nx = 1\n"
|
||||
|
||||
|
||||
def test_add_imported_name_carries_an_asname(tmp_path: Path) -> None:
|
||||
"""The added name keeps its `as` alias."""
|
||||
(tmp_path / "m.py").write_text("from pkg import a\n")
|
||||
r = Repro("b", "t").add_imported_name("m.py", module="pkg", name="b", asname="c")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "from pkg import a, b as c\n"
|
||||
|
||||
|
||||
def test_add_imported_name_refuses_a_commented_import(tmp_path: Path) -> None:
|
||||
"""An import carrying comments is refused, since a rebuild would drop them."""
|
||||
(tmp_path / "m.py").write_text("from pkg import (\n a, # keep\n)\n")
|
||||
r = Repro("b", "t").add_imported_name("m.py", module="pkg", name="b")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_add_imported_name_rejects_a_name_already_present(tmp_path: Path) -> None:
|
||||
"""Adding a name the import already has fails loudly."""
|
||||
(tmp_path / "m.py").write_text("from pkg import a, b\n")
|
||||
r = Repro("b", "t").add_imported_name("m.py", module="pkg", name="b")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_add_imported_name_raises_without_a_matching_import(tmp_path: Path) -> None:
|
||||
"""A file lacking a `from module import` for the module fails loudly."""
|
||||
(tmp_path / "m.py").write_text("from other import a\n")
|
||||
r = Repro("b", "t").add_imported_name("m.py", module="pkg", name="b")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
# --- repath_import / add_typechecking_import -----------------------------------
|
||||
|
||||
|
||||
def test_add_typechecking_import_inserts_in_block(tmp_path: Path) -> None:
|
||||
"""The import is appended inside the existing TYPE_CHECKING block."""
|
||||
(tmp_path / "m.py").write_text(
|
||||
"from typing import TYPE_CHECKING\n"
|
||||
"\n"
|
||||
"if TYPE_CHECKING:\n"
|
||||
" from a import X\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def f():\n"
|
||||
" pass\n"
|
||||
)
|
||||
r = Repro("b", "t").add_typechecking_import("m.py", "from b import Y")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == (
|
||||
"from typing import TYPE_CHECKING\n"
|
||||
"\n"
|
||||
"if TYPE_CHECKING:\n"
|
||||
" from a import X\n"
|
||||
" from b import Y\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def f():\n"
|
||||
" pass\n"
|
||||
)
|
||||
|
||||
|
||||
def test_add_typechecking_import_creates_missing_block(tmp_path: Path) -> None:
|
||||
"""With no TYPE_CHECKING block, one is created after the trailing module import."""
|
||||
(tmp_path / "m.py").write_text(
|
||||
"from typing import TYPE_CHECKING\n" "\n" "from a import X\n" "\n" "\n" "def f():\n" " pass\n"
|
||||
)
|
||||
r = Repro("b", "t").add_typechecking_import("m.py", "from b import Y")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == (
|
||||
"from typing import TYPE_CHECKING\n"
|
||||
"\n"
|
||||
"from a import X\n"
|
||||
"\n"
|
||||
"if TYPE_CHECKING:\n"
|
||||
" from b import Y\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def f():\n"
|
||||
" pass\n"
|
||||
)
|
||||
|
||||
|
||||
def test_add_import_into_an_empty_file(tmp_path: Path) -> None:
|
||||
"""Adding an import to an empty file writes just the statement."""
|
||||
(tmp_path / "m.py").write_text("")
|
||||
r = Repro("b", "t").add_import("m.py", "import os")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "import os\n"
|
||||
|
||||
|
||||
def test_add_import_lands_below_a_module_docstring(tmp_path: Path) -> None:
|
||||
"""In a file with only a docstring, the new import must land below the docstring."""
|
||||
(tmp_path / "m.py").write_text('"""Module doc."""\n\nx = 1\n')
|
||||
r = Repro("b", "t").add_import("m.py", "import os")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text().startswith('"""Module doc."""')
|
||||
|
||||
|
||||
def test_add_typechecking_import_matches_qualified_typing_form(tmp_path: Path) -> None:
|
||||
"""A `if typing.TYPE_CHECKING:` block is recognized and receives the import."""
|
||||
(tmp_path / "m.py").write_text(
|
||||
"import typing\n" "\n" "if typing.TYPE_CHECKING:\n" " from a import X\n" "\n" "\n" "def f():\n" " pass\n"
|
||||
)
|
||||
r = Repro("b", "t").add_typechecking_import("m.py", "from b import Y")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == (
|
||||
"import typing\n"
|
||||
"\n"
|
||||
"if typing.TYPE_CHECKING:\n"
|
||||
" from a import X\n"
|
||||
" from b import Y\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def f():\n"
|
||||
" pass\n"
|
||||
)
|
||||
|
||||
|
||||
def test_add_typechecking_import_after_a_multiline_final_import(tmp_path: Path) -> None:
|
||||
"""The insert lands after the closing paren of a multi-line final guarded import."""
|
||||
(tmp_path / "m.py").write_text(
|
||||
"from typing import TYPE_CHECKING\n"
|
||||
"\n"
|
||||
"if TYPE_CHECKING:\n"
|
||||
" from a import (\n"
|
||||
" X,\n"
|
||||
" )\n"
|
||||
"\n"
|
||||
"x = 1\n"
|
||||
)
|
||||
r = Repro("b", "t").add_typechecking_import("m.py", "from b import Y")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == (
|
||||
"from typing import TYPE_CHECKING\n"
|
||||
"\n"
|
||||
"if TYPE_CHECKING:\n"
|
||||
" from a import (\n"
|
||||
" X,\n"
|
||||
" )\n"
|
||||
" from b import Y\n"
|
||||
"\n"
|
||||
"x = 1\n"
|
||||
)
|
||||
|
||||
|
||||
def test_add_typechecking_import_raises_without_imports(tmp_path: Path) -> None:
|
||||
"""A file with no imports cannot anchor a new TYPE_CHECKING block and fails loudly."""
|
||||
(tmp_path / "m.py").write_text("x = 1\n")
|
||||
r = Repro("b", "t").add_typechecking_import("m.py", "from b import Y")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_add_typechecking_import_drops_a_lone_pass_placeholder(tmp_path: Path) -> None:
|
||||
"""Populating a `pass`-only TYPE_CHECKING block replaces the placeholder."""
|
||||
(tmp_path / "m.py").write_text(
|
||||
"from typing import TYPE_CHECKING\n" "\n" "if TYPE_CHECKING:\n" " pass\n" "\n" "x = 1\n"
|
||||
)
|
||||
r = Repro("b", "t").add_typechecking_import("m.py", "from b import Y")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == (
|
||||
"from typing import TYPE_CHECKING\n" "\n" "if TYPE_CHECKING:\n" " from b import Y\n" "\n" "x = 1\n"
|
||||
)
|
||||
|
||||
|
||||
def test_add_typechecking_import_keeps_a_pass_that_is_not_alone(tmp_path: Path) -> None:
|
||||
"""A `pass` beside a real import is left untouched; only the new import is appended."""
|
||||
(tmp_path / "m.py").write_text(
|
||||
"from typing import TYPE_CHECKING\n"
|
||||
"\n"
|
||||
"if TYPE_CHECKING:\n"
|
||||
" from a import X\n"
|
||||
" pass\n"
|
||||
"\n"
|
||||
"x = 1\n"
|
||||
)
|
||||
r = Repro("b", "t").add_typechecking_import("m.py", "from b import Y")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == (
|
||||
"from typing import TYPE_CHECKING\n"
|
||||
"\n"
|
||||
"if TYPE_CHECKING:\n"
|
||||
" from a import X\n"
|
||||
" pass\n"
|
||||
" from b import Y\n"
|
||||
"\n"
|
||||
"x = 1\n"
|
||||
)
|
||||
|
||||
|
||||
# --- add_import(after=...) -----------------------------------------------------
|
||||
|
||||
|
||||
def test_add_import_after_anchors_into_a_split_import_block(tmp_path: Path) -> None:
|
||||
"""With `after`, the import lands right after the named import -- needed when a
|
||||
statement splits the imports into separate blocks and the default (after the last
|
||||
import) would land in the wrong block."""
|
||||
(tmp_path / "m.py").write_text("import os\n\n_flag = os.getpid()\n\nfrom pkg import a\n\nx = 1\n")
|
||||
r = Repro("b", "t").add_import("m.py", "from new import Thing", after="import os")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == (
|
||||
"import os\nfrom new import Thing\n\n_flag = os.getpid()\n\nfrom pkg import a\n\nx = 1\n"
|
||||
)
|
||||
|
||||
|
||||
def test_add_import_after_raises_when_anchor_absent(tmp_path: Path) -> None:
|
||||
"""An `after` substring that matches no top-level import raises."""
|
||||
(tmp_path / "m.py").write_text("import os\n\nx = 1\n")
|
||||
r = Repro("b", "t").add_import("m.py", "from new import Thing", after="import nope")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
from reproduction_testlib import _apply, _commit, _git, _write # noqa: F401
|
||||
|
||||
|
||||
def test_lowered_call_text_preserves_magic_trailing_comma(tmp_path: Path) -> None:
|
||||
"""A magic trailing comma in the original call survives the textual lowering."""
|
||||
(tmp_path / "m.py").write_text("x = Old.foo(\n self.r,\n a,\n b,\n)\n")
|
||||
r = Repro("b", "t").lower_call_sites("foo", "Old", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "x = self.r.foo(\n a,\n b,\n)\n"
|
||||
|
||||
|
||||
# --- lower_call_sites ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_lower_call_sites_moves_receiver_out_of_args(tmp_path: Path) -> None:
|
||||
"""Owner.foo(receiver, rest) becomes receiver.foo(rest)."""
|
||||
(tmp_path / "m.py").write_text("x = ModelRunner.foo(self.r, a, b)\n")
|
||||
r = Repro("b", "t").lower_call_sites("foo", "ModelRunner", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "x = self.r.foo(a, b)\n"
|
||||
|
||||
|
||||
def test_lower_call_sites_handles_only_receiver_arg(tmp_path: Path) -> None:
|
||||
"""Owner.foo(receiver) becomes receiver.foo() without re-lowering the result."""
|
||||
(tmp_path / "m.py").write_text("ModelRunner.foo(self.r)\n")
|
||||
r = Repro("b", "t").lower_call_sites("foo", "ModelRunner", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "self.r.foo()\n"
|
||||
|
||||
|
||||
def test_lower_call_sites_ignores_a_different_owner(tmp_path: Path) -> None:
|
||||
"""A same-named call on another receiver (e.g. the moved body's own call) is untouched."""
|
||||
(tmp_path / "m.py").write_text("worker.foo(zmq)\n")
|
||||
r = Repro("b", "t").lower_call_sites("foo", "ModelRunner", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "worker.foo(zmq)\n"
|
||||
|
||||
|
||||
def test_lower_call_sites_preserves_magic_trailing_comma(tmp_path: Path) -> None:
|
||||
"""A magic trailing comma is kept so the formatter re-explodes the lowered call."""
|
||||
(tmp_path / "m.py").write_text("ModelRunner.foo(\n self.r,\n a,\n)\n")
|
||||
r = Repro("b", "t").lower_call_sites("foo", "ModelRunner", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "self.r.foo(\n a,\n)\n"
|
||||
|
||||
|
||||
# --- requalify_call_sites ------------------------------------------------------
|
||||
|
||||
|
||||
# --- requalify_call_sites ------------------------------------------------------
|
||||
|
||||
|
||||
def test_requalify_call_sites_drops_the_qualifier(tmp_path: Path) -> None:
|
||||
"""Owner.bar(args) becomes bar(args) when bar moves to a free function."""
|
||||
(tmp_path / "m.py").write_text("y = ModelRunner.bar(a, b)\n")
|
||||
r = Repro("b", "t").requalify_call_sites("bar", "ModelRunner", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "y = bar(a, b)\n"
|
||||
|
||||
|
||||
def test_route_call_sites_through_field_inserts_the_field(tmp_path: Path) -> None:
|
||||
"""recv.bar(a) becomes recv.updater.bar(a) when bar moves onto a collaborator field."""
|
||||
(tmp_path / "m.py").write_text("y = self.worker.runner.bar(a)\n")
|
||||
r = Repro("b", "t").route_call_sites_through_field("bar", field="updater", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "y = self.worker.runner.updater.bar(a)\n"
|
||||
|
||||
|
||||
def test_route_call_sites_through_field_skips_an_already_routed_call(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A call already going through the field is left alone, so the pass converges."""
|
||||
(tmp_path / "m.py").write_text("y = self.runner.updater.bar(a)\n")
|
||||
r = Repro("b", "t").route_call_sites_through_field("bar", field="updater", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "y = self.runner.updater.bar(a)\n"
|
||||
|
||||
|
||||
def test_route_call_sites_through_field_honors_owner_filter(tmp_path: Path) -> None:
|
||||
"""With owner set, only calls on that exact receiver are routed through the field."""
|
||||
(tmp_path / "m.py").write_text("a = x.bar(1)\nb = y.bar(2)\n")
|
||||
r = Repro("b", "t").route_call_sites_through_field("bar", field="updater", paths=["m.py"], owner="x")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "a = x.updater.bar(1)\nb = y.bar(2)\n"
|
||||
|
||||
|
||||
# --- adversarial audit: call-site rewrites ---------------------------------------
|
||||
|
||||
|
||||
# --- adversarial audit: call-site rewrites ---------------------------------------
|
||||
|
||||
|
||||
def test_requalify_call_sites_matches_a_zero_argument_call(tmp_path: Path) -> None:
|
||||
"""Owner.bar() with no arguments is requalified to bar()."""
|
||||
(tmp_path / "m.py").write_text("y = Owner.bar()\n")
|
||||
r = Repro("b", "t").requalify_call_sites("bar", "Owner", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "y = bar()\n"
|
||||
|
||||
|
||||
def test_lower_call_sites_preserves_comments_inside_a_multiline_call(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A comment between arguments of the rewritten call must survive."""
|
||||
(tmp_path / "m.py").write_text("x = Old.foo(\n self.r,\n a, # keep me\n b,\n)\n")
|
||||
r = Repro("b", "t").lower_call_sites("foo", "Old", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert "# keep me" in (tmp_path / "m.py").read_text()
|
||||
|
||||
|
||||
def test_lower_call_sites_preserves_arg_literal_spelling(tmp_path: Path) -> None:
|
||||
"""Hex literals and quote styles inside the rewritten call must not be normalized."""
|
||||
(tmp_path / "m.py").write_text('x = Old.foo(self.r, 0x10, "s")\n')
|
||||
r = Repro("b", "t").lower_call_sites("foo", "Old", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == 'x = self.r.foo(0x10, "s")\n'
|
||||
|
||||
|
||||
def test_lower_call_sites_lowers_a_nested_matching_call_too(tmp_path: Path) -> None:
|
||||
"""A matching call nested inside another matching call is lowered as well."""
|
||||
(tmp_path / "m.py").write_text("x = Old.foo(self.r, Old.foo(self.q, 1))\n")
|
||||
r = Repro("b", "t").lower_call_sites("foo", "Old", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "x = self.r.foo(self.q.foo(1))\n"
|
||||
|
||||
|
||||
def test_lower_call_sites_magic_comma_with_sole_receiver_arg_stays_valid(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Lowering a magic-comma call whose only argument is the receiver stays valid Python."""
|
||||
(tmp_path / "m.py").write_text("Owner.foo(\n self.r,\n)\n")
|
||||
r = Repro("b", "t").lower_call_sites("foo", "Owner", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
out = (tmp_path / "m.py").read_text()
|
||||
compile(out, "m.py", "exec")
|
||||
|
||||
|
||||
def test_call_rewrite_is_column_accurate_on_non_ascii_lines(tmp_path: Path) -> None:
|
||||
"""A call after a non-ASCII string on the same line is rewritten at the right columns."""
|
||||
(tmp_path / "m.py").write_text('x = "中文"; y = Owner.foo(self.r, 1)\n')
|
||||
r = Repro("b", "t").lower_call_sites("foo", "Owner", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == 'x = "中文"; y = self.r.foo(1)\n'
|
||||
|
||||
|
||||
def test_call_rewrite_survives_a_form_feed_line_start(tmp_path: Path) -> None:
|
||||
"""A form feed at a line start must not shift the rewrite onto the wrong line."""
|
||||
(tmp_path / "m.py").write_text("a = 1\n\x0cb = 2\ny = Owner.foo(self.r, 1)\n")
|
||||
r = Repro("b", "t").lower_call_sites("foo", "Owner", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "a = 1\n\x0cb = 2\ny = self.r.foo(1)\n"
|
||||
|
||||
|
||||
def test_requalify_call_sites_preserves_redundant_parens_in_kwargs(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Redundant parentheses around a keyword value survive the requalification."""
|
||||
(tmp_path / "m.py").write_text("y = Old.bar(\n a=1,\n b=(2),\n)\n")
|
||||
r = Repro("b", "t").requalify_call_sites("bar", "Old", paths=["m.py"])
|
||||
_apply(r, tmp_path)
|
||||
assert "b=(2)" in (tmp_path / "m.py").read_text()
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
from reproduction_testlib import _apply, _commit, _git, _write # noqa: F401
|
||||
|
||||
|
||||
def test_delete_file_removes_emptied_source(tmp_path: Path) -> None:
|
||||
"""delete_file removes a source module left empty after its defs relocated."""
|
||||
(tmp_path / "gone.py").write_text("import os\n")
|
||||
r = Repro("b", "t").delete_file("gone.py")
|
||||
_apply(r, tmp_path)
|
||||
assert not (tmp_path / "gone.py").exists()
|
||||
|
||||
|
||||
def test_delete_file_refuses_a_file_with_remaining_definitions(tmp_path: Path) -> None:
|
||||
"""Deleting a module that still contains defs must fail loudly."""
|
||||
(tmp_path / "live.py").write_text("def still_used():\n return 42\n")
|
||||
r = Repro("b", "t").delete_file("live.py")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "live.py").exists()
|
||||
|
||||
|
||||
def test_delete_file_on_a_missing_path_is_a_no_op(tmp_path: Path) -> None:
|
||||
"""Deleting an already-absent file does nothing and raises nothing."""
|
||||
r = Repro("b", "t").delete_file("nope.py")
|
||||
_apply(r, tmp_path)
|
||||
assert not (tmp_path / "nope.py").exists()
|
||||
|
||||
|
||||
def test_delete_file_allows_a_bare_module_logger(tmp_path: Path) -> None:
|
||||
"""A leftover module holding only imports and a `logger` is deletable scaffolding."""
|
||||
(tmp_path / "gone.py").write_text("import logging\n\nlogger = logging.getLogger(__name__)\n")
|
||||
r = Repro("b", "t").delete_file("gone.py")
|
||||
_apply(r, tmp_path)
|
||||
assert not (tmp_path / "gone.py").exists()
|
||||
|
||||
|
||||
def test_delete_file_still_refuses_a_non_logger_assignment(tmp_path: Path) -> None:
|
||||
"""A leftover module-level assignment other than a logger blocks deletion."""
|
||||
(tmp_path / "live.py").write_text("CONFIG = {'a': 1}\n")
|
||||
r = Repro("b", "t").delete_file("live.py")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "live.py").exists()
|
||||
|
||||
|
||||
# --- adversarial audit: extract_function -----------------------------------------
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
from reproduction_testlib import _apply, _commit, _git, _write # noqa: F401
|
||||
|
||||
# --- extract_function ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_function_relocates_body_and_replaces_with_call(tmp_path: Path) -> None:
|
||||
"""An inline block is cut verbatim, re-indented under the new signature, and the call site
|
||||
replaced; the body lands at function-body indent."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Q:\n"
|
||||
" def run(self, n):\n"
|
||||
" total = 0\n"
|
||||
" for i in range(n):\n"
|
||||
" total += i * i\n"
|
||||
" return total\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("def existing():\n return 0\n")
|
||||
body = " total = 0\n for i in range(n):\n total += i * i\n"
|
||||
r = Repro("b", "t").extract_function(
|
||||
"src.py",
|
||||
"dst.py",
|
||||
name="sum_squares",
|
||||
signature="def sum_squares(n):",
|
||||
body=body,
|
||||
body_indent=8,
|
||||
call=" total = sum_squares(n)\n",
|
||||
return_text=" return total\n",
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
src_out = (tmp_path / "src.py").read_text()
|
||||
assert " total = sum_squares(n)\n" in src_out
|
||||
assert "for i in range(n)" not in src_out
|
||||
assert (
|
||||
"def sum_squares(n):\n"
|
||||
" total = 0\n"
|
||||
" for i in range(n):\n"
|
||||
" total += i * i\n"
|
||||
" return total\n"
|
||||
) in (tmp_path / "dst.py").read_text()
|
||||
|
||||
|
||||
def test_extract_function_inserts_before_named_sibling(tmp_path: Path) -> None:
|
||||
"""With before=, the new function lands immediately above that sibling at module level."""
|
||||
(tmp_path / "src.py").write_text("x = compute()\n")
|
||||
(tmp_path / "dst.py").write_text("def a():\n return 1\n\n\ndef c():\n return 3\n")
|
||||
r = Repro("b", "t").extract_function(
|
||||
"src.py",
|
||||
"dst.py",
|
||||
name="b",
|
||||
signature="def b():",
|
||||
body="x = compute()\n",
|
||||
body_indent=0,
|
||||
call="x = b()\n",
|
||||
return_text=" return x\n",
|
||||
before="c",
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
dst_out = (tmp_path / "dst.py").read_text()
|
||||
assert dst_out.index("def a") < dst_out.index("def b") < dst_out.index("def c")
|
||||
assert "x = b()\n" == (tmp_path / "src.py").read_text()
|
||||
|
||||
|
||||
def test_extract_function_asserts_block_not_unique(tmp_path: Path) -> None:
|
||||
"""A block that occurs more than once in the source raises, so the cut is unambiguous."""
|
||||
(tmp_path / "src.py").write_text("p = f()\np = f()\n")
|
||||
(tmp_path / "dst.py").write_text("def z():\n return 0\n")
|
||||
r = Repro("b", "t").extract_function(
|
||||
"src.py",
|
||||
"dst.py",
|
||||
name="g",
|
||||
signature="def g():",
|
||||
body="p = f()\n",
|
||||
body_indent=0,
|
||||
call="p = g()\n",
|
||||
)
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
# --- adversarial audit: module extraction ----------------------------------------
|
||||
|
||||
|
||||
# --- adversarial audit: extract_function -----------------------------------------
|
||||
|
||||
|
||||
def test_extract_function_does_not_pad_blank_lines_in_the_body(tmp_path: Path) -> None:
|
||||
"""Interior blank lines of the extracted body stay bare newlines, unpadded."""
|
||||
(tmp_path / "src.py").write_text(" a = 1\n\n b = 2\n")
|
||||
(tmp_path / "dst.py").write_text("def z():\n return 0\n")
|
||||
r = Repro("b", "t").extract_function(
|
||||
"src.py",
|
||||
"dst.py",
|
||||
name="g",
|
||||
signature="def g():",
|
||||
body=" a = 1\n\n b = 2\n",
|
||||
body_indent=8,
|
||||
call=" g()\n",
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "src.py").read_text() == " g()\n"
|
||||
assert (tmp_path / "dst.py").read_text() == ("def z():\n return 0\n\ndef g():\n a = 1\n\n b = 2\n")
|
||||
|
||||
|
||||
def test_extract_function_does_not_reindent_string_literal_interiors(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Triple-quoted string interior lines keep their exact bytes through the extraction."""
|
||||
(tmp_path / "src.py").write_text("TEMPLATE = '''\nliteral line\n'''\nx = TEMPLATE\n")
|
||||
(tmp_path / "dst.py").write_text("def existing():\n return 0\n")
|
||||
r = Repro("b", "t").extract_function(
|
||||
"src.py",
|
||||
"dst.py",
|
||||
name="make",
|
||||
signature="def make():",
|
||||
body="TEMPLATE = '''\nliteral line\n'''\nx = TEMPLATE\n",
|
||||
body_indent=0,
|
||||
call="x = make()\n",
|
||||
return_text=" return x\n",
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
assert "\nliteral line\n" in (tmp_path / "dst.py").read_text()
|
||||
|
||||
|
||||
def test_extract_function_rejects_a_mid_line_substring_match(tmp_path: Path) -> None:
|
||||
"""A body that only matches mid-line must fail loudly instead of splicing the call."""
|
||||
(tmp_path / "src.py").write_text("value = prefix_total = 0\n")
|
||||
(tmp_path / "dst.py").write_text("def z():\n return 0\n")
|
||||
r = Repro("b", "t").extract_function(
|
||||
"src.py",
|
||||
"dst.py",
|
||||
name="g",
|
||||
signature="def g():",
|
||||
body="total = 0\n",
|
||||
body_indent=0,
|
||||
call="total = g()\n",
|
||||
)
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_extract_function_into_class_indents_body_to_method_depth(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Extracting into a class must indent the relocated body to method depth."""
|
||||
(tmp_path / "src.py").write_text("val = compute_thing()\n")
|
||||
(tmp_path / "dst.py").write_text("class H:\n def last(self):\n return 0\n")
|
||||
r = Repro("b", "t").extract_function(
|
||||
"src.py",
|
||||
"dst.py",
|
||||
name="helper",
|
||||
signature=" def helper(self):",
|
||||
body="val = compute_thing()\n",
|
||||
body_indent=0,
|
||||
call="val = h.helper()\n",
|
||||
return_text=" return val\n",
|
||||
into_class="H",
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
out = (tmp_path / "dst.py").read_text()
|
||||
compile(out, "dst.py", "exec")
|
||||
assert " def helper(self):\n val = compute_thing()\n" in out
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
from reproduction_testlib import _apply, _commit, _git, _write # noqa: F401
|
||||
|
||||
# --- extract_symbols_to_new_module ---------------------------------------------
|
||||
|
||||
|
||||
def test_extract_symbols_to_new_module_gathers_scattered_defs(tmp_path: Path) -> None:
|
||||
"""Scattered top-level defs are cut from the source and assembled under the authored header
|
||||
in the given order; the source keeps everything else."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"import os\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def keep_a():\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def moved_b():\n"
|
||||
" return 2\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def keep_c():\n"
|
||||
" return 3\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def moved_a():\n"
|
||||
" return 4\n"
|
||||
)
|
||||
header = (
|
||||
"from __future__ import annotations\n" "\n" "import logging\n" "\n" "logger = logging.getLogger(__name__)\n"
|
||||
)
|
||||
r = Repro("b", "t").extract_symbols_to_new_module(
|
||||
"src.py",
|
||||
"new.py",
|
||||
symbols=["moved_b", "moved_a"],
|
||||
header=header,
|
||||
order=["moved_a", "moved_b"],
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
src_out = (tmp_path / "src.py").read_text()
|
||||
assert "def moved_a" not in src_out and "def moved_b" not in src_out
|
||||
assert "def keep_a" in src_out and "def keep_c" in src_out
|
||||
new_out = (tmp_path / "new.py").read_text()
|
||||
assert new_out.startswith("from __future__ import annotations\n")
|
||||
assert "logger = logging.getLogger(__name__)" in new_out
|
||||
assert new_out.index("def moved_a") < new_out.index("def moved_b")
|
||||
assert " return 4\n" in new_out and " return 2\n" in new_out
|
||||
|
||||
|
||||
def test_extract_symbols_to_new_module_asserts_order_permutes_symbols(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An order that is not a permutation of the symbols raises, so a wrong recipe fails."""
|
||||
(tmp_path / "src.py").write_text("def a():\n return 1\n\n\ndef b():\n return 2\n")
|
||||
r = Repro("b", "t").extract_symbols_to_new_module("src.py", "n.py", symbols=["a", "b"], header="", order=["a"])
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_extract_symbols_to_new_module_asserts_when_symbol_absent(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A symbol that is not a top-level def/class in the source raises."""
|
||||
(tmp_path / "src.py").write_text("def a():\n return 1\n")
|
||||
r = Repro("b", "t").extract_symbols_to_new_module(
|
||||
"src.py", "n.py", symbols=["a", "missing"], header="", order=["a", "missing"]
|
||||
)
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_extract_symbols_to_new_module_drops_relocated_assigns(tmp_path: Path) -> None:
|
||||
"""A module-level constant that moved into the new module's header is deleted from the
|
||||
source (its copy lives in the authored header); a kept assignment stays."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"import os\n" "\n" "_FLAG = os.cpu_count()\n" "stay = 1\n" "\n" "\n" "def moved():\n" " return _FLAG\n"
|
||||
)
|
||||
header = "from __future__ import annotations\n" "\n" "import os\n" "\n" "_FLAG = os.cpu_count()\n"
|
||||
r = Repro("b", "t").extract_symbols_to_new_module(
|
||||
"src.py",
|
||||
"new.py",
|
||||
symbols=["moved"],
|
||||
header=header,
|
||||
order=["moved"],
|
||||
drop_assigns=["_FLAG"],
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
src_out = (tmp_path / "src.py").read_text()
|
||||
assert "_FLAG = os.cpu_count()" not in src_out
|
||||
assert "stay = 1" in src_out
|
||||
assert "_FLAG = os.cpu_count()" in (tmp_path / "new.py").read_text()
|
||||
|
||||
|
||||
def test_extract_symbols_to_new_module_asserts_unknown_drop_assign(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A drop_assigns name that is not assigned at module level in the source raises."""
|
||||
(tmp_path / "src.py").write_text("X = 1\n\n\ndef m():\n return X\n")
|
||||
r = Repro("b", "t").extract_symbols_to_new_module(
|
||||
"src.py", "n.py", symbols=["m"], header="", order=["m"], drop_assigns=["Y"]
|
||||
)
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_extract_symbols_to_new_module_header_accepts_a_typechecking_block(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An authored header may carry an `if TYPE_CHECKING:` import block; the audit accepts it
|
||||
and the block is reproduced verbatim in the new module."""
|
||||
(tmp_path / "src.py").write_text("def moved(x):\n return x\n")
|
||||
header = (
|
||||
"from __future__ import annotations\n"
|
||||
"\n"
|
||||
"from typing import TYPE_CHECKING\n"
|
||||
"\n"
|
||||
"if TYPE_CHECKING:\n"
|
||||
" from other import Thing\n"
|
||||
"\n"
|
||||
)
|
||||
r = Repro("b", "t").extract_symbols_to_new_module(
|
||||
"src.py", "new.py", symbols=["moved"], header=header, order=["moved"]
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
new_out = (tmp_path / "new.py").read_text()
|
||||
assert "if TYPE_CHECKING:\n from other import Thing\n" in new_out
|
||||
assert "def moved(x):\n return x\n" in new_out
|
||||
|
||||
|
||||
# --- extract_function ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_symbols_to_new_module_joins_blocks_with_two_blank_lines(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Relocated blocks are joined with exactly two blank lines (the formatter's spacing)."""
|
||||
(tmp_path / "src.py").write_text("def moved_a():\n return 1\n\n\n\n\ndef moved_b():\n return 2\n")
|
||||
r = Repro("b", "t").extract_symbols_to_new_module(
|
||||
"src.py",
|
||||
"new.py",
|
||||
symbols=["moved_a", "moved_b"],
|
||||
header="",
|
||||
order=["moved_a", "moved_b"],
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "new.py").read_text() == ("def moved_a():\n return 1\n\n\ndef moved_b():\n return 2\n")
|
||||
|
||||
|
||||
def test_extract_symbols_to_new_module_leaves_a_comment_above_a_moved_def(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A section comment directly above a moved def stays behind in the source."""
|
||||
(tmp_path / "src.py").write_text("x = 1\n\n\n# --- movers ---\ndef moved():\n return 2\n")
|
||||
r = Repro("b", "t").extract_symbols_to_new_module(
|
||||
"src.py", "new.py", symbols=["moved"], header="", order=["moved"]
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "src.py").read_text() == "x = 1\n\n\n# --- movers ---\n"
|
||||
assert (tmp_path / "new.py").read_text() == "def moved():\n return 2\n"
|
||||
|
||||
|
||||
def test_extract_symbols_drop_assigns_preserves_other_targets_of_chained_assign(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Dropping A from `A = B = 1` must not delete B's binding from the source."""
|
||||
(tmp_path / "src.py").write_text("A = B = 1\n\n\ndef moved():\n return A\n")
|
||||
r = Repro("b", "t").extract_symbols_to_new_module(
|
||||
"src.py",
|
||||
"new.py",
|
||||
symbols=["moved"],
|
||||
header="A = 1\n",
|
||||
order=["moved"],
|
||||
drop_assigns=["A"],
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
assert "B" in (tmp_path / "src.py").read_text()
|
||||
|
||||
|
||||
def test_extract_symbols_to_new_module_allows_a_rederived_surviving_constant(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A header constant that also survives verbatim in the source (re-derived boilerplate,
|
||||
e.g. `_is_hip = is_hip()`) is allowed: it is provably not fiction because the same
|
||||
statement remains in the source."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"from pkg import is_hip\n" "\n" "_is_hip = is_hip()\n" "\n" "\n" "def moved():\n" " return _is_hip\n"
|
||||
)
|
||||
header = "from pkg import is_hip\n\n_is_hip = is_hip()\n"
|
||||
r = Repro("b", "t").extract_symbols_to_new_module(
|
||||
"src.py", "new.py", symbols=["moved"], header=header, order=["moved"]
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
assert "_is_hip = is_hip()" in (tmp_path / "src.py").read_text()
|
||||
assert "_is_hip = is_hip()" in (tmp_path / "new.py").read_text()
|
||||
|
||||
|
||||
def test_extract_symbols_to_new_module_rejects_a_fictional_header_constant(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A header constant that is neither dropped from nor surviving in the source is fiction
|
||||
and raises: the audit refuses code the extraction cannot vouch for."""
|
||||
(tmp_path / "src.py").write_text("def moved():\n return 1\n")
|
||||
r = Repro("b", "t").extract_symbols_to_new_module(
|
||||
"src.py",
|
||||
"new.py",
|
||||
symbols=["moved"],
|
||||
header="_fake = evil()\n",
|
||||
order=["moved"],
|
||||
)
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
from reproduction_testlib import _apply, _commit, _git, _write # noqa: F401
|
||||
|
||||
# --- extract_to_new_module -----------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_to_new_module_cuts_trailing_block(tmp_path: Path) -> None:
|
||||
"""Cuts the trailing scaffolding+def block into a new file, prepending the future import."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class M:\n"
|
||||
" def keep(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"import logging\n"
|
||||
"\n"
|
||||
"logger = logging.getLogger(__name__)\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def foo(x):\n"
|
||||
" return x + 1\n"
|
||||
)
|
||||
r = Repro("b", "t").extract_to_new_module("src.py", "new.py", symbols=["foo"], future_import=True)
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "src.py").read_text() == ("class M:\n def keep(self):\n return 1\n\n\n")
|
||||
assert (tmp_path / "new.py").read_text() == (
|
||||
"from __future__ import annotations\n"
|
||||
"import logging\n"
|
||||
"\n"
|
||||
"logger = logging.getLogger(__name__)\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def foo(x):\n"
|
||||
" return x + 1\n"
|
||||
)
|
||||
|
||||
|
||||
def test_extract_to_new_module_carries_a_trailing_class(tmp_path: Path) -> None:
|
||||
"""A class in the staged tail (not just a def) travels with the cut block."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class M:\n"
|
||||
" pass\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"from dataclasses import dataclass\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"@dataclass\n"
|
||||
"class Cfg:\n"
|
||||
" x: int\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def foo():\n"
|
||||
" return Cfg(1)\n"
|
||||
)
|
||||
r = Repro("b", "t").extract_to_new_module("src.py", "new.py", symbols=["Cfg", "foo"], future_import=False)
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "src.py").read_text() == "class M:\n pass\n\n\n"
|
||||
assert "class Cfg:" in (tmp_path / "new.py").read_text()
|
||||
assert "def foo():" in (tmp_path / "new.py").read_text()
|
||||
|
||||
|
||||
# --- extract_symbols_to_new_module ---------------------------------------------
|
||||
|
||||
|
||||
# --- adversarial audit: module extraction ----------------------------------------
|
||||
|
||||
|
||||
def test_extract_to_new_module_asserts_when_symbol_not_in_the_tail(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A wanted symbol above a non-scaffolding statement is not in the tail and raises."""
|
||||
(tmp_path / "src.py").write_text("def wanted():\n return 1\n\n\nprint('side effect')\n")
|
||||
r = Repro("b", "t").extract_to_new_module("src.py", "n.py", symbols=["wanted"])
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_extract_to_new_module_refuses_a_trailing_main_guard(tmp_path: Path) -> None:
|
||||
"""A trailing __main__ guard is executable code, not scaffolding: the tail cut raises."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Keep:\n"
|
||||
" pass\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def foo():\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
"\n"
|
||||
'if __name__ == "__main__":\n'
|
||||
" foo()\n"
|
||||
)
|
||||
r = Repro("b", "t").extract_to_new_module("src.py", "new.py", symbols=["foo"], future_import=False)
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
assert "__main__" in (tmp_path / "src.py").read_text()
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
import mechanical_refactor_reproduction_utils as rr
|
||||
from mechanical_refactor_reproduction_utils import (
|
||||
_def_span,
|
||||
_find_class,
|
||||
_find_def,
|
||||
_replace_span,
|
||||
_slice_span,
|
||||
dedent,
|
||||
exec_command,
|
||||
git_add_and_commit,
|
||||
)
|
||||
from reproduction_testlib import _apply, _commit, _git, _write # noqa: F401
|
||||
|
||||
# --- exec_command --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_exec_command_returns_stripped_stdout_on_success() -> None:
|
||||
"""A successful command returns its stdout with surrounding whitespace stripped."""
|
||||
assert exec_command("echo hello") == "hello"
|
||||
|
||||
|
||||
def test_exec_command_respects_cwd(tmp_path: Path) -> None:
|
||||
"""The command runs in the supplied working directory."""
|
||||
sub = tmp_path / "workdir"
|
||||
sub.mkdir()
|
||||
assert exec_command("pwd", cwd=str(sub)) == str(sub.resolve())
|
||||
|
||||
|
||||
def test_exec_command_check_true_raises_on_failure() -> None:
|
||||
"""With check=True a non-zero exit status raises RuntimeError with the command."""
|
||||
with pytest.raises(RuntimeError, match="exit 7"):
|
||||
exec_command("exit 7", check=True)
|
||||
|
||||
|
||||
def test_exec_command_check_false_returns_stdout_without_exiting() -> None:
|
||||
"""With check=False a failing command returns its stdout and does not exit."""
|
||||
assert exec_command("echo partial; exit 3", check=False) == "partial"
|
||||
|
||||
|
||||
# --- git_add_and_commit --------------------------------------------------------
|
||||
|
||||
|
||||
# --- git_add_and_commit --------------------------------------------------------
|
||||
|
||||
|
||||
def test_git_add_and_commit_stages_and_commits(repo: Path) -> None:
|
||||
"""It stages every change in the cwd and records a commit with the message."""
|
||||
_write(repo, **{"file.txt": "content\n"})
|
||||
git_add_and_commit("add file", cwd=str(repo))
|
||||
assert _git(repo, "log", "-1", "--format=%s") == "add file"
|
||||
assert _git(repo, "status", "--porcelain") == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
"subject with spaces",
|
||||
"has 'single' and \"double\" quotes",
|
||||
"shell $HOME && rm -rf / ; metacharacters",
|
||||
"trailing parens (a, b) and pipe | semicolon ;",
|
||||
],
|
||||
)
|
||||
def test_git_add_and_commit_message_round_trips_with_metacharacters(repo: Path, message: str) -> None:
|
||||
"""Messages with shell metacharacters are quoted safely and survive verbatim."""
|
||||
_write(repo, **{"file.txt": "content\n"})
|
||||
git_add_and_commit(message, cwd=str(repo))
|
||||
assert _git(repo, "log", "-1", "--format=%B") == message
|
||||
|
||||
|
||||
# --- dedent --------------------------------------------------------------------
|
||||
|
||||
|
||||
# --- dedent --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dedent_with_zero_leaves_text_unchanged() -> None:
|
||||
"""Dedenting by zero spaces returns the text untouched."""
|
||||
text = " indented\nplain\n"
|
||||
assert dedent(text, 0) == text
|
||||
|
||||
|
||||
def test_dedent_removes_exactly_n_leading_spaces() -> None:
|
||||
"""Exactly n leading spaces are removed from each qualifying line."""
|
||||
assert dedent(" four\n eight\n", 4) == "four\n eight\n"
|
||||
|
||||
|
||||
def test_dedent_leaves_lines_with_fewer_than_n_spaces_unchanged() -> None:
|
||||
"""A line with fewer than n leading spaces is not modified at all."""
|
||||
assert dedent(" four\n two\nzero\n", 4) == "four\n two\nzero\n"
|
||||
|
||||
|
||||
def test_dedent_does_not_strip_tabs() -> None:
|
||||
"""Tab characters are never treated as the spaces dedent removes."""
|
||||
assert dedent("\t\ttabbed\n", 2) == "\t\ttabbed\n"
|
||||
|
||||
|
||||
def test_dedent_preserves_blank_lines_and_trailing_newline() -> None:
|
||||
"""Blank lines and a final newline are preserved across line boundaries."""
|
||||
assert dedent(" a\n\n b\n", 4) == "a\n\nb\n"
|
||||
|
||||
|
||||
def test_dedent_preserves_absence_of_trailing_newline() -> None:
|
||||
"""A text without a trailing newline keeps it absent after dedenting."""
|
||||
assert dedent(" a\n b", 4) == "a\nb"
|
||||
|
||||
|
||||
# --- span / call helpers -------------------------------------------------------
|
||||
|
||||
|
||||
# --- span / call helpers -------------------------------------------------------
|
||||
|
||||
|
||||
def test_replace_span_single_line() -> None:
|
||||
"""A span within one line is replaced in place."""
|
||||
assert _replace_span("ab cd ef\n", 1, 3, 1, 5, "XY") == "ab XY ef\n"
|
||||
|
||||
|
||||
def test_replace_span_across_lines() -> None:
|
||||
"""A span crossing lines collapses to the replacement between the kept prefix/suffix."""
|
||||
text = "a = foo(\n x,\n) + 1\n"
|
||||
assert _replace_span(text, 1, 4, 3, 1, "bar()") == "a = bar() + 1\n"
|
||||
|
||||
|
||||
def test_slice_span_returns_the_overwritten_text() -> None:
|
||||
"""_slice_span returns exactly the region _replace_span would overwrite."""
|
||||
text = "a = foo(\n x,\n) + 1\n"
|
||||
assert _slice_span(text, 1, 4, 3, 1) == "foo(\n x,\n)"
|
||||
|
||||
|
||||
def test_find_def_span_includes_decorators() -> None:
|
||||
"""A def's span starts at its first decorator and ends at its last body line."""
|
||||
src = "class C:\n @staticmethod\n def foo(self):\n return 1\n"
|
||||
node = _find_def(rr.ast.parse(src), "foo")
|
||||
assert node is not None and _def_span(node) == (2, 4)
|
||||
|
||||
|
||||
def test_find_class_returns_named_class_or_none() -> None:
|
||||
"""_find_class locates a class by name and returns None when absent."""
|
||||
tree = rr.ast.parse("class A:\n pass\nclass B:\n pass\n")
|
||||
assert _find_class(tree, "B").name == "B"
|
||||
assert _find_class(tree, "Z") is None
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
from reproduction_testlib import _apply # noqa: F401
|
||||
|
||||
|
||||
def test_move_assign_relocates_a_module_constant(tmp_path: Path) -> None:
|
||||
"""The assignment is cut verbatim from the source and lands after the destination's imports."""
|
||||
(tmp_path / "src.py").write_text("import os\n\nLIMIT = 480 # seconds\n\n\ndef stay():\n return LIMIT\n")
|
||||
(tmp_path / "dst.py").write_text("import sys\n\n\ndef keep():\n return 1\n")
|
||||
r = Repro("b", "t").move_assign("LIMIT", src="src.py", dst="dst.py")
|
||||
_apply(r, tmp_path)
|
||||
assert "LIMIT" not in (tmp_path / "src.py").read_text().split("def stay")[0]
|
||||
assert (tmp_path / "dst.py").read_text() == (
|
||||
"import sys\n" "\n" "LIMIT = 480 # seconds\n" "\n" "\n" "def keep():\n" " return 1\n"
|
||||
)
|
||||
|
||||
|
||||
def test_move_assign_pastes_above_the_named_sibling(tmp_path: Path) -> None:
|
||||
"""With before=, the constant lands immediately above the named top-level statement."""
|
||||
(tmp_path / "src.py").write_text("RATIO = 3\n")
|
||||
(tmp_path / "dst.py").write_text("def first():\n return 1\n")
|
||||
r = Repro("b", "t").move_assign("RATIO", src="src.py", dst="dst.py", before="first")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "dst.py").read_text() == ("RATIO = 3\n\ndef first():\n return 1\n")
|
||||
|
||||
|
||||
def test_move_assign_relocates_an_annotated_constant(tmp_path: Path) -> None:
|
||||
"""An annotated module constant (AnnAssign) is cut verbatim with its annotation intact."""
|
||||
(tmp_path / "src.py").write_text("import os\n\nLIMIT: int = 480\n\n\ndef stay():\n return LIMIT\n")
|
||||
(tmp_path / "dst.py").write_text("import sys\n\n\ndef keep():\n return 1\n")
|
||||
r = Repro("b", "t").move_assign("LIMIT", src="src.py", dst="dst.py")
|
||||
_apply(r, tmp_path)
|
||||
assert "LIMIT" not in (tmp_path / "src.py").read_text().split("def stay")[0]
|
||||
assert (tmp_path / "dst.py").read_text() == (
|
||||
"import sys\n" "\n" "LIMIT: int = 480\n" "\n" "\n" "def keep():\n" " return 1\n"
|
||||
)
|
||||
|
||||
|
||||
def test_move_assign_missing_source_raises(tmp_path: Path) -> None:
|
||||
"""A name with no module-level assignment in the source fails loudly."""
|
||||
(tmp_path / "src.py").write_text("x = 1\n")
|
||||
(tmp_path / "dst.py").write_text("import os\n")
|
||||
r = Repro("b", "t").move_assign("MISSING", src="src.py", dst="dst.py")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
from reproduction_testlib import _apply, _commit, _git, _write # noqa: F401
|
||||
|
||||
|
||||
def test_move_symbol_drops_self_annotation_into_class(tmp_path: Path) -> None:
|
||||
"""Moving a `def foo(self: Target)` into Target drops the now-redundant annotation."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class M:\n" " @staticmethod\n" " def foo(self: Target, x):\n" " return self.y + x\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class Target:\n def keep(self):\n return 1\n")
|
||||
r = Repro("b", "t").move_symbol(
|
||||
"foo",
|
||||
src="src.py",
|
||||
dst="dst.py",
|
||||
into_class="Target",
|
||||
dedent=0,
|
||||
drop_self_annotation=True,
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
text = (tmp_path / "dst.py").read_text()
|
||||
assert "def foo(self, x):" in text
|
||||
assert "self: Target" not in text
|
||||
|
||||
|
||||
# --- adversarial audit: import primitives ----------------------------------------
|
||||
|
||||
|
||||
# --- move_symbol ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_move_symbol_into_class_drops_decorator_and_appends(tmp_path: Path) -> None:
|
||||
"""The def leaves the source, its @staticmethod is dropped, and it lands at the end of
|
||||
the destination class with its body verbatim."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Old:\n"
|
||||
" @staticmethod\n"
|
||||
" def foo(x):\n"
|
||||
" return x + 1\n"
|
||||
"\n"
|
||||
" def keep(self):\n"
|
||||
" return 0\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class New:\n def existing(self):\n return 1\n")
|
||||
r = Repro("b", "t").move_symbol("foo", src="src.py", dst="dst.py", into_class="New")
|
||||
_apply(r, tmp_path)
|
||||
src_out = (tmp_path / "src.py").read_text()
|
||||
dst_out = (tmp_path / "dst.py").read_text()
|
||||
assert "def foo" not in src_out and "def keep" in src_out
|
||||
assert "@staticmethod" not in dst_out
|
||||
assert dst_out.index("def existing") < dst_out.index("def foo")
|
||||
assert " return x + 1\n" in dst_out
|
||||
|
||||
|
||||
def test_move_symbol_to_module_level_with_dedent(tmp_path: Path) -> None:
|
||||
"""With into_class=None and a dedent, the def lands at module level, dedented."""
|
||||
(tmp_path / "src.py").write_text("class Old:\n @staticmethod\n def helper(x):\n return x * 2\n")
|
||||
(tmp_path / "dst.py").write_text("import os\n")
|
||||
r = Repro("b", "t").move_symbol("helper", src="src.py", dst="dst.py", into_class=None, dedent=4)
|
||||
_apply(r, tmp_path)
|
||||
assert "def helper(x):\n return x * 2\n" in (tmp_path / "dst.py").read_text()
|
||||
assert "def helper" not in (tmp_path / "src.py").read_text()
|
||||
|
||||
|
||||
def test_move_symbol_before_inserts_above_named_sibling(tmp_path: Path) -> None:
|
||||
"""With before=, the relocated def lands immediately above that sibling, not at the end."""
|
||||
(tmp_path / "src.py").write_text("class Old:\n @staticmethod\n def moved(self):\n return 1\n")
|
||||
(tmp_path / "dst.py").write_text(
|
||||
"class New:\n" " def first(self):\n return 0\n" "\n" " def last(self):\n return 2\n"
|
||||
)
|
||||
r = Repro("b", "t").move_symbol("moved", src="src.py", dst="dst.py", into_class="New", before="last")
|
||||
_apply(r, tmp_path)
|
||||
dst_out = (tmp_path / "dst.py").read_text()
|
||||
assert dst_out.index("def first") < dst_out.index("def moved") < dst_out.index("def last")
|
||||
|
||||
|
||||
# --- adversarial audit: move_symbol edge cases -----------------------------------
|
||||
|
||||
|
||||
def test_move_symbol_moves_an_async_def_verbatim(tmp_path: Path) -> None:
|
||||
"""An async def relocates with its `async` keyword and body byte-identical."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Old:\n"
|
||||
" async def foo(self):\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
" def keep(self):\n"
|
||||
" return 0\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class New:\n def e(self):\n return 1\n")
|
||||
r = Repro("b", "t").move_symbol("foo", src="src.py", dst="dst.py", into_class="New")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "src.py").read_text() == ("class Old:\n\n def keep(self):\n return 0\n")
|
||||
assert (tmp_path / "dst.py").read_text() == (
|
||||
"class New:\n" " def e(self):\n" " return 1\n" "\n" " async def foo(self):\n" " return 1\n"
|
||||
)
|
||||
|
||||
|
||||
def test_move_symbol_moves_a_def_within_the_same_file(tmp_path: Path) -> None:
|
||||
"""With src == dst the def is cut and re-inserted above its sibling in one file."""
|
||||
(tmp_path / "m.py").write_text("def a():\n return 1\n\n\ndef b():\n return 2\n")
|
||||
r = Repro("b", "t").move_symbol("b", src="m.py", dst="m.py", into_class=None, before="a")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == ("def b():\n return 2\n\ndef a():\n return 1\n\n\n")
|
||||
|
||||
|
||||
def test_move_symbol_prefers_a_module_level_def_over_an_earlier_class_method(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When a class method and a module-level def share a name, the module-level def moves."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class C:\n"
|
||||
" def foo(self):\n"
|
||||
" return 'method'\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def foo():\n"
|
||||
" return 'module'\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("x = 1\n")
|
||||
r = Repro("b", "t").move_symbol("foo", src="src.py", dst="dst.py", into_class=None)
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "src.py").read_text() == ("class C:\n def foo(self):\n return 'method'\n\n\n")
|
||||
assert (tmp_path / "dst.py").read_text() == ("x = 1\n\ndef foo():\n return 'module'\n")
|
||||
|
||||
|
||||
def test_move_symbol_keeps_a_real_decorator_while_dropping_classmethod(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""@classmethod is shed on the move but any other decorator travels verbatim."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"import functools\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"class Old:\n"
|
||||
" @classmethod\n"
|
||||
" @functools.lru_cache(maxsize=None)\n"
|
||||
" def foo(cls, x):\n"
|
||||
" return x + 1\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("def z():\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol("foo", src="src.py", dst="dst.py", into_class=None, dedent=4)
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "dst.py").read_text() == (
|
||||
"def z():\n"
|
||||
" return 0\n"
|
||||
"\n"
|
||||
"@functools.lru_cache(maxsize=None)\n"
|
||||
"def foo(cls, x):\n"
|
||||
" return x + 1\n"
|
||||
)
|
||||
|
||||
|
||||
def test_move_symbol_leaves_a_comment_above_the_def_in_the_source(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A comment above the def is not part of its span, so it stays behind in the source."""
|
||||
(tmp_path / "src.py").write_text("# explains foo\ndef foo():\n return 1\n\n\ndef keep():\n return 2\n")
|
||||
(tmp_path / "dst.py").write_text("x = 1\n")
|
||||
r = Repro("b", "t").move_symbol("foo", src="src.py", dst="dst.py", into_class=None)
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "src.py").read_text() == ("# explains foo\n\n\ndef keep():\n return 2\n")
|
||||
assert (tmp_path / "dst.py").read_text() == "x = 1\n\ndef foo():\n return 1\n"
|
||||
|
||||
|
||||
def test_move_symbol_without_trailing_newlines_keeps_moved_bytes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Files lacking a final newline lose no bytes of the moved def or the remainder."""
|
||||
(tmp_path / "src.py").write_text("def keep():\n return 0\n\n\ndef foo():\n return 1")
|
||||
(tmp_path / "dst.py").write_text("x = 1")
|
||||
r = Repro("b", "t").move_symbol("foo", src="src.py", dst="dst.py", into_class=None)
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "src.py").read_text() == "def keep():\n return 0\n\n\n"
|
||||
assert (tmp_path / "dst.py").read_text() == "x = 1\ndef foo():\n return 1"
|
||||
|
||||
|
||||
def test_move_symbol_dedent_leaves_string_literal_interior_lines(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Dedent only strips lines with exactly n leading spaces, so string interiors survive."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Old:\n"
|
||||
" class Deep:\n"
|
||||
" def foo(self):\n"
|
||||
" s = '''raw\n"
|
||||
" partial\n"
|
||||
"'''\n"
|
||||
" return s\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("import os\n")
|
||||
r = Repro("b", "t").move_symbol("foo", src="src.py", dst="dst.py", into_class=None, dedent=8)
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "dst.py").read_text() == (
|
||||
"import os\n" "\n" "def foo(self):\n" " s = '''raw\n" " partial\n" "'''\n" " return s\n"
|
||||
)
|
||||
|
||||
|
||||
def test_move_symbol_asserts_when_destination_class_missing(tmp_path: Path) -> None:
|
||||
"""Naming an into_class absent from the destination fails loudly."""
|
||||
(tmp_path / "src.py").write_text("def foo():\n return 1\n")
|
||||
(tmp_path / "dst.py").write_text("x = 1\n")
|
||||
r = Repro("b", "t").move_symbol("foo", src="src.py", dst="dst.py", into_class="Nope")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_move_symbol_preserves_staticmethod_inside_moved_body(tmp_path: Path) -> None:
|
||||
"""A @staticmethod on a nested def inside the moved body must survive the move."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Old:\n"
|
||||
" @staticmethod\n"
|
||||
" def foo(x):\n"
|
||||
" class Inner:\n"
|
||||
" @staticmethod\n"
|
||||
" def helper(y):\n"
|
||||
" return y\n"
|
||||
" return Inner.helper(x)\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class New:\n def keep(self):\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol("foo", src="src.py", dst="dst.py", into_class="New")
|
||||
_apply(r, tmp_path)
|
||||
dst_out = (tmp_path / "dst.py").read_text()
|
||||
assert " @staticmethod\n def helper(y):\n" in dst_out
|
||||
|
||||
|
||||
def test_move_symbol_rejects_ambiguous_duplicate_names(tmp_path: Path) -> None:
|
||||
"""Two same-named defs at equal depth must raise instead of silently picking one."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class A:\n"
|
||||
" def foo(self):\n"
|
||||
" return 'A'\n"
|
||||
"\n"
|
||||
"class B:\n"
|
||||
" def foo(self):\n"
|
||||
" return 'B'\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class New:\n def keep(self):\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol("foo", src="src.py", dst="dst.py", into_class="New")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_move_symbol_after_inserts_below_named_function(tmp_path: Path) -> None:
|
||||
"""With after=, the relocated def lands immediately below that sibling def."""
|
||||
(tmp_path / "src.py").write_text("def moved():\n return 1\n")
|
||||
(tmp_path / "dst.py").write_text("def first():\n return 0\n\n\ndef last():\n return 2\n")
|
||||
r = Repro("b", "t").move_symbol("moved", src="src.py", dst="dst.py", into_class=None, after="first")
|
||||
_apply(r, tmp_path)
|
||||
dst_out = (tmp_path / "dst.py").read_text()
|
||||
assert dst_out.index("def first") < dst_out.index("def moved") < dst_out.index("def last")
|
||||
|
||||
|
||||
def test_move_symbol_after_assign_lands_before_typechecking_guard(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""after= anchors on a module-level assignment target, landing the def just below it and
|
||||
above a following ``if TYPE_CHECKING:`` guard (which is not a nameable anchor)."""
|
||||
(tmp_path / "src.py").write_text("def helper(x):\n return x + 1\n")
|
||||
(tmp_path / "dst.py").write_text(
|
||||
"from u import is_hip\n" "\n" "_is_hip = is_hip()\n" "\n" "if TYPE_CHECKING:\n" " from m import Thing\n"
|
||||
)
|
||||
r = Repro("b", "t").move_symbol("helper", src="src.py", dst="dst.py", into_class=None, after="_is_hip")
|
||||
_apply(r, tmp_path)
|
||||
dst_out = (tmp_path / "dst.py").read_text()
|
||||
assert dst_out.index("_is_hip = is_hip()") < dst_out.index("def helper") < dst_out.index("if TYPE_CHECKING:")
|
||||
|
||||
|
||||
def test_move_symbol_before_and_after_are_mutually_exclusive(tmp_path: Path) -> None:
|
||||
"""Passing both before= and after= is rejected up front."""
|
||||
(tmp_path / "src.py").write_text("def moved():\n return 1\n")
|
||||
(tmp_path / "dst.py").write_text("def z():\n return 0\n")
|
||||
with pytest.raises(AssertionError):
|
||||
Repro("b", "t").move_symbol(
|
||||
"moved",
|
||||
src="src.py",
|
||||
dst="dst.py",
|
||||
into_class=None,
|
||||
before="z",
|
||||
after="z",
|
||||
)
|
||||
|
||||
|
||||
def test_move_symbol_asserts_when_after_symbol_missing(tmp_path: Path) -> None:
|
||||
"""An after= anchor absent from the destination must raise, not fall back to append."""
|
||||
(tmp_path / "src.py").write_text("def moved():\n return 1\n")
|
||||
(tmp_path / "dst.py").write_text("def z():\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol("moved", src="src.py", dst="dst.py", into_class=None, after="NO_SUCH_SYMBOL")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_move_symbol_asserts_when_before_sibling_missing(tmp_path: Path) -> None:
|
||||
"""A before= anchor absent from the destination must raise, not fall back to append."""
|
||||
(tmp_path / "src.py").write_text("def moved():\n return 1\n")
|
||||
(tmp_path / "dst.py").write_text("def z():\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol("moved", src="src.py", dst="dst.py", into_class=None, before="NO_SUCH_DEF")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_move_symbol_preserves_crlf_line_endings(tmp_path: Path) -> None:
|
||||
"""Moving a def in a CRLF file must keep every line ending CRLF."""
|
||||
(tmp_path / "src.py").write_bytes(b"class Old:\r\n def foo(self):\r\n return 1\r\n")
|
||||
(tmp_path / "dst.py").write_bytes(b"class New:\r\n def keep(self):\r\n return 0\r\n")
|
||||
r = Repro("b", "t").move_symbol("foo", src="src.py", dst="dst.py", into_class="New")
|
||||
_apply(r, tmp_path)
|
||||
dst_bytes = (tmp_path / "dst.py").read_bytes()
|
||||
assert dst_bytes.count(b"\n") == dst_bytes.count(b"\r\n")
|
||||
|
||||
|
||||
def test_move_symbol_negative_dedent_indents_into_the_class(tmp_path: Path) -> None:
|
||||
"""Moving a module-level def into a class with dedent=-4 must indent it as a method."""
|
||||
(tmp_path / "src.py").write_text("def helper(x):\n return x\n")
|
||||
(tmp_path / "dst.py").write_text("class New:\n def e(self):\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol("helper", src="src.py", dst="dst.py", into_class="New", dedent=-4)
|
||||
_apply(r, tmp_path)
|
||||
assert " def helper(x):\n return x\n" in (tmp_path / "dst.py").read_text()
|
||||
|
||||
|
||||
def test_move_symbol_relocates_a_top_level_class(tmp_path: Path) -> None:
|
||||
"""move_symbol relocates a whole top-level class (with its methods) verbatim."""
|
||||
(tmp_path / "src.py").write_text("x = 1\n\n\nclass Widget:\n def get(self, rank):\n return rank\n")
|
||||
(tmp_path / "dst.py").write_text("y = 2\n")
|
||||
r = Repro("b", "t").move_symbol("Widget", src="src.py", dst="dst.py", into_class=None)
|
||||
_apply(r, tmp_path)
|
||||
assert "class Widget:" not in (tmp_path / "src.py").read_text()
|
||||
assert "class Widget:\n def get(self, rank):\n return rank\n" in (tmp_path / "dst.py").read_text()
|
||||
|
||||
|
||||
# --- adversarial audit: leave_delegate stubs -------------------------------------
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
from reproduction_testlib import _apply, _commit, _git, _write # noqa: F401
|
||||
|
||||
|
||||
def test_move_symbol_leave_delegate_keeps_forwarding_stub(tmp_path: Path) -> None:
|
||||
"""With leave_delegate, the source keeps a forwarding stub through the named field and the
|
||||
destination gets the full method body."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Mixin:\n" " def compute(self, n: int) -> int:\n" " return n + self.cfg.base\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class Cfg:\n def existing(self):\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol(
|
||||
"compute",
|
||||
src="src.py",
|
||||
dst="dst.py",
|
||||
into_class="Cfg",
|
||||
leave_delegate="cfg",
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
src_out = (tmp_path / "src.py").read_text()
|
||||
dst_out = (tmp_path / "dst.py").read_text()
|
||||
assert "def compute(self, n: int) -> int:" in src_out
|
||||
assert "return self.cfg.compute(n)" in src_out
|
||||
assert "return n + self.cfg.base" not in src_out
|
||||
assert "return n + self.cfg.base" in dst_out
|
||||
|
||||
|
||||
def test_move_symbol_leave_delegate_does_not_absorb_leading_comments(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A leading comment before the first statement is not an AST node, so it must not be
|
||||
pulled into the forwarding stub -- the delegate is just the header plus the return.
|
||||
"""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Mixin:\n"
|
||||
" def compute(self, n: int) -> int:\n"
|
||||
" # explain the maths\n"
|
||||
" # second comment line\n"
|
||||
" return n + self.cfg.base\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class Cfg:\n def existing(self):\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol("compute", src="src.py", dst="dst.py", into_class="Cfg", leave_delegate="cfg")
|
||||
_apply(r, tmp_path)
|
||||
src_out = (tmp_path / "src.py").read_text()
|
||||
dst_out = (tmp_path / "dst.py").read_text()
|
||||
assert "# explain the maths" not in src_out
|
||||
assert src_out == "class Mixin:\n" " def compute(self, n: int) -> int:\n" " return self.cfg.compute(n)\n"
|
||||
assert "# explain the maths" in dst_out
|
||||
|
||||
|
||||
# --- adversarial audit: move_symbol edge cases -----------------------------------
|
||||
|
||||
|
||||
# --- adversarial audit: leave_delegate stubs -------------------------------------
|
||||
|
||||
|
||||
def test_move_symbol_leave_delegate_keeps_a_multiline_signature_verbatim(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A multi-line header is carried into the stub byte-for-byte via the bracket scan."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Mixin:\n"
|
||||
" def compute(\n"
|
||||
" self,\n"
|
||||
" n: int,\n"
|
||||
" *,\n"
|
||||
" scale: float = 1.0,\n"
|
||||
" ) -> int:\n"
|
||||
" return int(n * scale) + self.cfg.base\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class Cfg:\n def e(self):\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol("compute", src="src.py", dst="dst.py", into_class="Cfg", leave_delegate="cfg")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "src.py").read_text() == (
|
||||
"class Mixin:\n"
|
||||
" def compute(\n"
|
||||
" self,\n"
|
||||
" n: int,\n"
|
||||
" *,\n"
|
||||
" scale: float = 1.0,\n"
|
||||
" ) -> int:\n"
|
||||
" return self.cfg.compute(n, scale=scale)\n"
|
||||
)
|
||||
assert (tmp_path / "dst.py").read_text() == (
|
||||
"class Cfg:\n"
|
||||
" def e(self):\n"
|
||||
" return 0\n"
|
||||
"\n"
|
||||
" def compute(\n"
|
||||
" self,\n"
|
||||
" n: int,\n"
|
||||
" *,\n"
|
||||
" scale: float = 1.0,\n"
|
||||
" ) -> int:\n"
|
||||
" return int(n * scale) + self.cfg.base\n"
|
||||
)
|
||||
|
||||
|
||||
def test_move_symbol_leave_delegate_forwards_posonly_vararg_kwonly_kwargs(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Every parameter kind is forwarded correctly in the delegate's return call."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Mixin:\n" " def compute(self, a, /, b, *args, c, d=3, **kw):\n" " return a\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class Cfg:\n def keep(self):\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol("compute", src="src.py", dst="dst.py", into_class="Cfg", leave_delegate="cfg")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "src.py").read_text() == (
|
||||
"class Mixin:\n"
|
||||
" def compute(self, a, /, b, *args, c, d=3, **kw):\n"
|
||||
" return self.cfg.compute(a, b, *args, c=c, d=d, **kw)\n"
|
||||
)
|
||||
|
||||
|
||||
def test_move_symbol_leave_delegate_survives_paren_in_string_default(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A string default containing '(' must not break the delegate's header scan."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Mixin:\n"
|
||||
" def compute(\n"
|
||||
" self,\n"
|
||||
' sep: str = "(",\n'
|
||||
" n: int = 0,\n"
|
||||
" ) -> int:\n"
|
||||
" return n + self.cfg.base\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class Cfg:\n def keep(self):\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol("compute", src="src.py", dst="dst.py", into_class="Cfg", leave_delegate="cfg")
|
||||
_apply(r, tmp_path)
|
||||
src_out = (tmp_path / "src.py").read_text()
|
||||
compile(src_out, "src.py", "exec")
|
||||
assert "return self.cfg.compute(sep, n)" in src_out
|
||||
|
||||
|
||||
def test_move_symbol_async_leave_delegate_awaits_the_forwarded_call(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An async method's delegate stub must await the forwarded coroutine."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Mixin:\n" " async def compute(self, n):\n" " return n + self.cfg.base\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class Cfg:\n def e(self):\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol("compute", src="src.py", dst="dst.py", into_class="Cfg", leave_delegate="cfg")
|
||||
_apply(r, tmp_path)
|
||||
assert "return await self.cfg.compute(n)" in (tmp_path / "src.py").read_text()
|
||||
|
||||
|
||||
def test_move_symbol_leave_delegate_on_self_annotated_staticmethod(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A de-self'd staticmethod (self: Target) moves into Target; the stub drops the
|
||||
decorator and the self annotation."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Runner:\n"
|
||||
" @staticmethod\n"
|
||||
" def work(self: Comp, n: int) -> int:\n"
|
||||
" return n + self.base\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class Comp:\n def existing(self):\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol(
|
||||
"work",
|
||||
src="src.py",
|
||||
dst="dst.py",
|
||||
into_class="Comp",
|
||||
from_class="Runner",
|
||||
drop_self_annotation=True,
|
||||
leave_delegate="comp",
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "src.py").read_text() == (
|
||||
"class Runner:\n" " def work(self, n: int) -> int:\n" " return self.comp.work(n)\n"
|
||||
)
|
||||
assert (tmp_path / "dst.py").read_text() == (
|
||||
"class Comp:\n"
|
||||
" def existing(self):\n"
|
||||
" return 0\n"
|
||||
"\n"
|
||||
" def work(self, n: int) -> int:\n"
|
||||
" return n + self.base\n"
|
||||
)
|
||||
|
||||
|
||||
def test_move_symbol_leave_delegate_keeps_unrelated_self_annotation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A self annotation naming a class other than the destination survives in the stub."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Mixin:\n" " def work(self: Runner, n: int) -> int:\n" " return n + self.base\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class Comp:\n def existing(self):\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol(
|
||||
"work",
|
||||
src="src.py",
|
||||
dst="dst.py",
|
||||
into_class="Comp",
|
||||
from_class="Mixin",
|
||||
drop_self_annotation=True,
|
||||
leave_delegate="comp",
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "src.py").read_text() == (
|
||||
"class Mixin:\n" " def work(self: Runner, n: int) -> int:\n" " return self.comp.work(n)\n"
|
||||
)
|
||||
|
||||
|
||||
def test_move_symbol_delegate_name_forwards_to_the_renamed_collaborator_method(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""delegate_name makes the stub call a differently-named method on the collaborator."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Mixin:\n" " def compute(self, n: int) -> int:\n" " return n + self.cfg.base\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class Cfg:\n def existing(self):\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol(
|
||||
"compute",
|
||||
src="src.py",
|
||||
dst="dst.py",
|
||||
into_class="Cfg",
|
||||
leave_delegate="cfg",
|
||||
delegate_name="compute_impl",
|
||||
)
|
||||
_apply(r, tmp_path)
|
||||
assert "return self.cfg.compute_impl(n)" in (tmp_path / "src.py").read_text()
|
||||
assert "def compute(self, n: int) -> int:" in (tmp_path / "dst.py").read_text()
|
||||
|
||||
|
||||
def test_move_symbol_leave_delegate_on_unannotated_staticmethod_raises(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A staticmethod with no self: Target annotation has no receiver to forward through, so
|
||||
leave_delegate refuses rather than author a bogus self.<field>.<name>(...) stub."""
|
||||
(tmp_path / "src.py").write_text(
|
||||
"class Runner:\n" " @staticmethod\n" " def work(x: int) -> int:\n" " return x + 1\n"
|
||||
)
|
||||
(tmp_path / "dst.py").write_text("class Comp:\n def existing(self):\n return 0\n")
|
||||
r = Repro("b", "t").move_symbol(
|
||||
"work",
|
||||
src="src.py",
|
||||
dst="dst.py",
|
||||
into_class="Comp",
|
||||
leave_delegate="comp",
|
||||
)
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
from reproduction_testlib import _apply, _commit, _git, _write # noqa: F401
|
||||
|
||||
# --- remove_import -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_remove_import_scoped_leaves_module_level_same_text(tmp_path: Path) -> None:
|
||||
"""Scoped to a function, it removes the local import but not a same-text module-level
|
||||
one (e.g. a TYPE_CHECKING guard), and drops the import's trailing blank line."""
|
||||
(tmp_path / "m.py").write_text(
|
||||
"from typing import TYPE_CHECKING\n"
|
||||
"\n"
|
||||
"if TYPE_CHECKING:\n"
|
||||
" from pkg.mod import Thing\n"
|
||||
"\n"
|
||||
"def caller(self):\n"
|
||||
" from pkg.mod import Thing\n"
|
||||
"\n"
|
||||
" return Thing.go(self.x)\n"
|
||||
)
|
||||
r = Repro("b", "t").remove_import("m.py", "from pkg.mod import Thing", in_function="caller")
|
||||
_apply(r, tmp_path)
|
||||
out = (tmp_path / "m.py").read_text()
|
||||
assert out.count("from pkg.mod import Thing") == 1
|
||||
assert "if TYPE_CHECKING:\n from pkg.mod import Thing" in out
|
||||
assert "def caller(self):\n return Thing.go(self.x)\n" in out
|
||||
|
||||
|
||||
def test_remove_import_removes_every_occurrence_in_scope(tmp_path: Path) -> None:
|
||||
"""All matching local imports in the function are removed, not just the first."""
|
||||
(tmp_path / "m.py").write_text(
|
||||
"def caller(self):\n"
|
||||
" from pkg import M\n"
|
||||
"\n"
|
||||
" M.a(self.x)\n"
|
||||
" if cond:\n"
|
||||
" from pkg import M\n"
|
||||
"\n"
|
||||
" M.b(self.y)\n"
|
||||
)
|
||||
r = Repro("b", "t").remove_import("m.py", "from pkg import M", in_function="caller")
|
||||
_apply(r, tmp_path)
|
||||
assert "from pkg import M" not in (tmp_path / "m.py").read_text()
|
||||
|
||||
|
||||
# --- remove_imported_name ------------------------------------------------------
|
||||
|
||||
|
||||
# --- adversarial audit: import primitives ----------------------------------------
|
||||
|
||||
|
||||
def test_remove_import_unscoped_removes_module_level_import_and_blank(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Without in_function the matching module-level import and its trailing blank go."""
|
||||
(tmp_path / "m.py").write_text("import os\nfrom pkg import Thing\n\nx = Thing\n")
|
||||
r = Repro("b", "t").remove_import("m.py", "from pkg import Thing")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "import os\nx = Thing\n"
|
||||
|
||||
|
||||
def test_remove_import_keeps_a_code_line_directly_after_the_import(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Only a blank line after the import is absorbed; a code line stays untouched."""
|
||||
(tmp_path / "m.py").write_text("import os\nx = 1\n")
|
||||
r = Repro("b", "t").remove_import("m.py", "import os")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "x = 1\n"
|
||||
|
||||
|
||||
def test_remove_import_asserts_when_text_absent(tmp_path: Path) -> None:
|
||||
"""Removing an import text that matches nothing fails loudly."""
|
||||
(tmp_path / "m.py").write_text("import os\n")
|
||||
r = Repro("b", "t").remove_import("m.py", "from pkg import Q")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_remove_import_asserts_when_scope_function_missing(tmp_path: Path) -> None:
|
||||
"""Scoping to a function that does not exist fails loudly."""
|
||||
(tmp_path / "m.py").write_text("def f():\n import os\n")
|
||||
r = Repro("b", "t").remove_import("m.py", "import os", in_function="nope")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_remove_import_leaves_other_statements_on_a_semicolon_line(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Removing 'import os' from a semicolon-joined line must keep 'import sys'."""
|
||||
(tmp_path / "m.py").write_text("import os; import sys\nprint(sys.path)\n")
|
||||
r = Repro("b", "t").remove_import("m.py", "import os")
|
||||
_apply(r, tmp_path)
|
||||
out = (tmp_path / "m.py").read_text()
|
||||
assert "import sys" in out and "print(sys.path)" in out
|
||||
|
||||
|
||||
def test_remove_import_trailing_on_a_semicolon_line_leaves_no_dangling_separator(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Removing the trailing import on a semicolon-joined line drops the dangling ';' too
|
||||
(a trailing space may remain for the formatter to strip, but the separator is gone).
|
||||
"""
|
||||
(tmp_path / "m.py").write_text("import sys; import os\nprint(sys.path)\n")
|
||||
r = Repro("b", "t").remove_import("m.py", "import os")
|
||||
_apply(r, tmp_path)
|
||||
out = (tmp_path / "m.py").read_text()
|
||||
assert ";" not in out
|
||||
assert "import os" not in out
|
||||
assert "import sys" in out and "print(sys.path)" in out
|
||||
|
||||
|
||||
def test_remove_import_does_not_overmatch_a_submodule_import(tmp_path: Path) -> None:
|
||||
"""Removing 'import os' must not also remove 'import os.path'."""
|
||||
(tmp_path / "m.py").write_text("import os\nimport os.path\nprint(os.path.sep)\n")
|
||||
r = Repro("b", "t").remove_import("m.py", "import os")
|
||||
_apply(r, tmp_path)
|
||||
assert "import os.path\n" in (tmp_path / "m.py").read_text()
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
from reproduction_testlib import _apply, _commit, _git, _write # noqa: F401
|
||||
|
||||
# --- remove_imported_name ------------------------------------------------------
|
||||
|
||||
|
||||
def test_remove_imported_name_drops_one_name_from_a_multi_name_import(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""One name is dropped from a `from m import a, b, c`; the others stay on the line."""
|
||||
(tmp_path / "m.py").write_text("from pkg import a, moved, b\n\nx = a + b\n")
|
||||
r = Repro("b", "t").remove_imported_name("m.py", module="pkg", name="moved")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "from pkg import a, b\n\nx = a + b\n"
|
||||
|
||||
|
||||
def test_remove_imported_name_drops_whole_statement_when_sole_name(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Dropping the only name removes the whole `from` statement."""
|
||||
(tmp_path / "m.py").write_text("from pkg import moved\nimport os\n\nx = 1\n")
|
||||
r = Repro("b", "t").remove_imported_name("m.py", module="pkg", name="moved")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "import os\n\nx = 1\n"
|
||||
|
||||
|
||||
def test_remove_imported_name_drops_a_plain_import_with_module_none(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""With module=None a plain `import name` statement is removed."""
|
||||
(tmp_path / "m.py").write_text("import gc\nimport os\n\nx = 1\n")
|
||||
r = Repro("b", "t").remove_imported_name("m.py", module=None, name="gc")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "import os\n\nx = 1\n"
|
||||
|
||||
|
||||
def test_remove_imported_name_matches_an_asname(tmp_path: Path) -> None:
|
||||
"""The alias is matched on both the name and the asname, so `import numpy as np` is found."""
|
||||
(tmp_path / "m.py").write_text("import numpy as np\nimport os\n\nx = 1\n")
|
||||
r = Repro("b", "t").remove_imported_name("m.py", module=None, name="numpy", asname="np")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "import os\n\nx = 1\n"
|
||||
|
||||
|
||||
def test_remove_imported_name_asserts_when_absent(tmp_path: Path) -> None:
|
||||
"""Removing a name that is not imported raises, so a wrong recipe fails loudly."""
|
||||
(tmp_path / "m.py").write_text("from pkg import a, b\n")
|
||||
r = Repro("b", "t").remove_imported_name("m.py", module="pkg", name="missing")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
# --- add_import ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_remove_imported_name_preserves_the_multiline_form(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Pruning a name from an exploded import deletes only that line, so the parens and the
|
||||
magic trailing comma survive and the formatter keeps it multi-line (a flat rebuild would
|
||||
collapse an import the target left multi-line)."""
|
||||
(tmp_path / "m.py").write_text("from pkg import (\n a,\n moved,\n b,\n)\n\nx = a + b\n")
|
||||
r = Repro("b", "t").remove_imported_name("m.py", module="pkg", name="moved")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "from pkg import (\n a,\n b,\n)\n\nx = a + b\n"
|
||||
|
||||
|
||||
def test_remove_imported_name_multiline_down_to_one_collapses(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Pruning an exploded import down to a single surviving name collapses it to one line:
|
||||
the formatter does not keep a lone name exploded, so a preserved-multiline form would
|
||||
not match the target."""
|
||||
(tmp_path / "m.py").write_text("from pkg import (\n moved,\n a,\n)\n\nx = a\n")
|
||||
r = Repro("b", "t").remove_imported_name("m.py", module="pkg", name="moved")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "from pkg import a\n\nx = a\n"
|
||||
|
||||
|
||||
def test_remove_imported_name_down_to_one_with_a_comment_stays_exploded(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A lone survivor that carries a comment stays exploded (a rebuild would drop the
|
||||
comment); only its own line is deleted."""
|
||||
(tmp_path / "m.py").write_text("from pkg import (\n moved,\n a, # keep me\n)\n\nx = a\n")
|
||||
r = Repro("b", "t").remove_imported_name("m.py", module="pkg", name="moved")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "from pkg import (\n a, # keep me\n)\n\nx = a\n"
|
||||
|
||||
|
||||
def test_remove_imported_name_matches_a_relative_module(tmp_path: Path) -> None:
|
||||
"""A relative `from .pkg import` is matched via its level dots."""
|
||||
(tmp_path / "m.py").write_text("from .pkg import a, moved\n\nx = a\n")
|
||||
r = Repro("b", "t").remove_imported_name("m.py", module=".pkg", name="moved")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "from .pkg import a\n\nx = a\n"
|
||||
|
||||
|
||||
def test_remove_imported_name_preserves_comments_in_a_multiline_import(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Comments on surviving lines of a pruned parenthesized import must not vanish."""
|
||||
(tmp_path / "m.py").write_text(
|
||||
"from pkg import (\n" " a, # used by frobnicator\n" " moved,\n" " b,\n" ")\n" "\n" "x = a + b\n"
|
||||
)
|
||||
r = Repro("b", "t").remove_imported_name("m.py", module="pkg", name="moved")
|
||||
_apply(r, tmp_path)
|
||||
assert "# used by frobnicator" in (tmp_path / "m.py").read_text()
|
||||
|
||||
|
||||
def test_remove_imported_name_keep_exploded_holds_a_lone_survivor_multiline(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""With keep_exploded, pruning down to a single survivor deletes only the removed line,
|
||||
so the survivor keeps its magic trailing comma and the import stays multi-line (the
|
||||
author's choice, which the source cannot reveal). A regenerating impl would collapse.
|
||||
"""
|
||||
(tmp_path / "m.py").write_text("from pkg import (\n moved,\n a,\n)\n\nx = a\n")
|
||||
r = Repro("b", "t").remove_imported_name("m.py", module="pkg", name="moved", keep_exploded=True)
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "m.py").read_text() == "from pkg import (\n a,\n)\n\nx = a\n"
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from mechanical_refactor_reproduction_utils import Repro
|
||||
from reproduction_testlib import _apply, _commit, _git, _write # noqa: F401
|
||||
|
||||
# --- repath_import / add_typechecking_import -----------------------------------
|
||||
|
||||
|
||||
def test_repath_import_rewrites_nested_import(tmp_path: Path) -> None:
|
||||
"""A function-scoped import is repathed in place; the bare call is untouched."""
|
||||
(tmp_path / "c.py").write_text(
|
||||
"class K:\n" " def run(self):\n" " from old.mod import foo\n" "\n" " return foo(1)\n"
|
||||
)
|
||||
r = Repro("b", "t").repath_import("c.py", old_module="old.mod", new_module="new.mod", name="foo")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "c.py").read_text() == (
|
||||
"class K:\n" " def run(self):\n" " from new.mod import foo\n" "\n" " return foo(1)\n"
|
||||
)
|
||||
|
||||
|
||||
def test_repath_import_leaves_a_module_level_import(tmp_path: Path) -> None:
|
||||
"""Only nested imports are repathed; a module-level import is left to the sorter."""
|
||||
(tmp_path / "c.py").write_text("from old.mod import foo\n\n\nx = foo(1)\n")
|
||||
r = Repro("b", "t").repath_import("c.py", old_module="old.mod", new_module="new.mod", name="foo")
|
||||
with pytest.raises(AssertionError):
|
||||
_apply(r, tmp_path)
|
||||
|
||||
|
||||
def test_repath_import_repaths_a_multiline_aliased_nested_import(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A nested multi-line from-import with an alias is repathed on its first line."""
|
||||
(tmp_path / "c.py").write_text(
|
||||
"def run():\n" " from old.mod import (\n" " foo as f,\n" " )\n" "\n" " return f(1)\n"
|
||||
)
|
||||
r = Repro("b", "t").repath_import("c.py", old_module="old.mod", new_module="new.mod", name="foo")
|
||||
_apply(r, tmp_path)
|
||||
assert (tmp_path / "c.py").read_text() == (
|
||||
"def run():\n" " from new.mod import (\n" " foo as f,\n" " )\n" "\n" " return f(1)\n"
|
||||
)
|
||||
|
||||
|
||||
def test_repath_import_rewrites_a_relative_nested_import(tmp_path: Path) -> None:
|
||||
"""A nested `from .mod import` matched by module name must actually be repathed."""
|
||||
(tmp_path / "c.py").write_text("def run():\n from .mod import foo\n\n return foo(1)\n")
|
||||
r = Repro("b", "t").repath_import("c.py", old_module="mod", new_module="pkg.mod", name="foo")
|
||||
_apply(r, tmp_path)
|
||||
assert "from pkg.mod import foo" in (tmp_path / "c.py").read_text()
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
import mechanical_refactor_reproduction_utils as rr
|
||||
from mechanical_refactor_reproduction_utils import Repro, verify_mechanical_refactor
|
||||
from reproduction_testlib import _apply, _commit, _git, _write # noqa: F401
|
||||
|
||||
# --- verify_mechanical_refactor ------------------------------------------------
|
||||
|
||||
|
||||
def _silence_precommit(monkeypatch) -> None:
|
||||
real = rr.exec_command
|
||||
|
||||
def fake(cmd: str, cwd=None, check=True):
|
||||
if cmd.startswith("pre-commit"):
|
||||
return ""
|
||||
return real(cmd, cwd=cwd, check=check)
|
||||
|
||||
monkeypatch.setattr(rr, "exec_command", fake)
|
||||
|
||||
|
||||
def test_reproduce_passes_when_transform_matches_target(repo: Path, tmp_path: Path, monkeypatch, capsys) -> None:
|
||||
"""A transform that recreates the target tree reports PASS and does not exit."""
|
||||
_write(repo, **{"src.py": "line1\nline2\nline3\n"})
|
||||
base = _commit(repo, "base")
|
||||
_write(repo, **{"src.py": None, "a.py": "line1\nline2\n", "b.py": "line3\n"})
|
||||
target = _commit(repo, "split")
|
||||
|
||||
def transform(root: Path) -> None:
|
||||
lines = (root / "src.py").read_text().splitlines(keepends=True)
|
||||
(root / "a.py").write_text("".join(lines[0:2]))
|
||||
(root / "b.py").write_text("".join(lines[2:3]))
|
||||
(root / "src.py").unlink()
|
||||
rr.git_add_and_commit("split", cwd=str(root))
|
||||
|
||||
monkeypatch.chdir(repo)
|
||||
monkeypatch.setattr(rr.tempfile, "mkdtemp", lambda prefix="": str(tmp_path / "wt"))
|
||||
_silence_precommit(monkeypatch)
|
||||
|
||||
verify_mechanical_refactor(base, target, transform)
|
||||
assert "PASS" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_reproduce_exits_when_transform_diverges(repo: Path, tmp_path: Path, monkeypatch) -> None:
|
||||
"""A transform that produces a different tree fails with a non-zero exit."""
|
||||
_write(repo, **{"src.py": "line1\nline2\nline3\n"})
|
||||
base = _commit(repo, "base")
|
||||
_write(repo, **{"src.py": None, "a.py": "line1\nline2\n", "b.py": "line3\n"})
|
||||
target = _commit(repo, "split")
|
||||
|
||||
def wrong_transform(root: Path) -> None:
|
||||
(root / "a.py").write_text("WRONG\n")
|
||||
(root / "b.py").write_text("line3\n")
|
||||
(root / "src.py").unlink()
|
||||
rr.git_add_and_commit("split", cwd=str(root))
|
||||
|
||||
monkeypatch.chdir(repo)
|
||||
monkeypatch.setattr(rr.tempfile, "mkdtemp", lambda prefix="": str(tmp_path / "wt"))
|
||||
_silence_precommit(monkeypatch)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
verify_mechanical_refactor(base, target, wrong_transform)
|
||||
|
||||
|
||||
def test_reproduce_creates_verify_branch_on_pass(repo: Path, tmp_path: Path, monkeypatch, capsys) -> None:
|
||||
"""A PASS run leaves a verify-mechanical-<base[:8]> branch in the repo."""
|
||||
_write(repo, **{"src.py": "line1\nline2\nline3\n"})
|
||||
base = _commit(repo, "base")
|
||||
_write(repo, **{"src.py": None, "a.py": "line1\nline2\n", "b.py": "line3\n"})
|
||||
target = _commit(repo, "split")
|
||||
|
||||
def transform(root: Path) -> None:
|
||||
lines = (root / "src.py").read_text().splitlines(keepends=True)
|
||||
(root / "a.py").write_text("".join(lines[0:2]))
|
||||
(root / "b.py").write_text("".join(lines[2:3]))
|
||||
(root / "src.py").unlink()
|
||||
rr.git_add_and_commit("split", cwd=str(root))
|
||||
|
||||
monkeypatch.chdir(repo)
|
||||
monkeypatch.setattr(rr.tempfile, "mkdtemp", lambda prefix="": str(tmp_path / "wt"))
|
||||
_silence_precommit(monkeypatch)
|
||||
|
||||
verify_mechanical_refactor(base, target, transform)
|
||||
assert "PASS" in capsys.readouterr().out
|
||||
branch = f"verify-mechanical-{base[:8]}"
|
||||
assert _git(repo, "branch", "--list", branch).endswith(branch)
|
||||
|
||||
|
||||
def _precommit_writes_file(monkeypatch, filename: str, contents: str) -> None:
|
||||
real = rr.exec_command
|
||||
|
||||
def fake(cmd: str, cwd=None, check=True):
|
||||
if cmd.startswith("pre-commit"):
|
||||
(Path(cwd) / filename).write_text(contents)
|
||||
return ""
|
||||
return real(cmd, cwd=cwd, check=check)
|
||||
|
||||
monkeypatch.setattr(rr, "exec_command", fake)
|
||||
|
||||
|
||||
def test_reproduce_commits_pre_commit_fixes_when_tree_left_dirty(
|
||||
repo: Path, tmp_path: Path, monkeypatch, capsys
|
||||
) -> None:
|
||||
"""When pre-commit reformats and leaves the tree dirty, a 'pre-commit fixes' commit
|
||||
is created on top of the transform commit."""
|
||||
_write(repo, **{"src.py": "hello\n"})
|
||||
base = _commit(repo, "base")
|
||||
_write(repo, **{"src.py": "hello world\n", "formatted.py": "auto\n"})
|
||||
target = _commit(repo, "edit")
|
||||
|
||||
def transform(root: Path) -> None:
|
||||
(root / "src.py").write_text("hello world\n")
|
||||
rr.git_add_and_commit("transform", cwd=str(root))
|
||||
|
||||
monkeypatch.chdir(repo)
|
||||
monkeypatch.setattr(rr.tempfile, "mkdtemp", lambda prefix="": str(tmp_path / "wt"))
|
||||
_precommit_writes_file(monkeypatch, "formatted.py", "auto\n")
|
||||
|
||||
verify_mechanical_refactor(base, target, transform)
|
||||
assert "PASS" in capsys.readouterr().out
|
||||
branch = f"verify-mechanical-{base[:8]}"
|
||||
subjects = _git(repo, "log", "--format=%s", "-2", branch).splitlines()
|
||||
assert subjects == ["pre-commit fixes", "transform"]
|
||||
|
||||
|
||||
# --- Repro.run end-to-end ------------------------------------------------------
|
||||
|
||||
|
||||
def test_repro_run_passes_on_a_faithful_call_site_lowering(repo: Path, monkeypatch, capsys) -> None:
|
||||
"""End-to-end: a lowering reproduces the commit byte-for-byte (pre-commit stubbed)."""
|
||||
_write(repo, **{"c.py": "r = Old.foo(self.n, 5)\n"})
|
||||
base = _commit(repo, "base")
|
||||
_write(repo, **{"c.py": "r = self.n.foo(5)\n"})
|
||||
target = _commit(repo, "lower the call site")
|
||||
monkeypatch.chdir(repo)
|
||||
_silence_precommit(monkeypatch)
|
||||
|
||||
diff = Repro(base, target).lower_call_sites("foo", "Old", paths=["c.py"]).run()
|
||||
assert diff == ""
|
||||
assert "PASS" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_repro_run_reports_residual_when_a_change_is_bundled(repo: Path, monkeypatch, capsys) -> None:
|
||||
"""A bundled non-relocation change surfaces as a non-empty residual diff."""
|
||||
_write(repo, **{"c.py": "r = Old.foo(self.n, 5)\nUNRELATED = 1\n"})
|
||||
base = _commit(repo, "base")
|
||||
_write(repo, **{"c.py": "r = self.n.foo(5)\nUNRELATED = 2\n"})
|
||||
target = _commit(repo, "lower the call AND change a constant")
|
||||
monkeypatch.chdir(repo)
|
||||
_silence_precommit(monkeypatch)
|
||||
|
||||
diff = Repro(base, target).lower_call_sites("foo", "Old", paths=["c.py"]).run()
|
||||
assert "UNRELATED" in diff
|
||||
assert "RESIDUAL" in capsys.readouterr().out
|
||||
@@ -0,0 +1,153 @@
|
||||
# Reproduction CLI — chain verification specification (source of truth)
|
||||
|
||||
## 1. Scope
|
||||
|
||||
- Source of truth for `scripts/mechanical_refactor_reproduction_cli.py`: the
|
||||
**verified-chain property** (§2), the CLI contract (§3), the report (§4), and the exit
|
||||
codes (§5).
|
||||
- The single-commit clean-move property and the proof scripts themselves are specified in
|
||||
`spec-reproduction-utils.md`; this file only says how a whole chain of commits is
|
||||
checked against a folder of such proofs.
|
||||
- The CLI, its tests, and the guides defer to this file; on any disagreement, this file
|
||||
wins.
|
||||
|
||||
## 2. The property — a "verified chain"
|
||||
|
||||
> A branch is a **verified chain** over a base iff every commit in `base..branch` is
|
||||
> **classified** (§2.1) and every `mechanical_provable` commit has exactly one **proof**
|
||||
> in the proof folder whose run **PASSes** (§2.2).
|
||||
|
||||
### 2.1 Classification — the word rule
|
||||
|
||||
- Every commit message must contain **exactly one** of the two words:
|
||||
- `mechanical_provable` — the commit claims to be a machine-provable relocation;
|
||||
- `non_mechanical_provable` — the commit declares that **nothing in it** is
|
||||
expressible as the whitelisted relocations of `spec-reproduction-utils.md` §2 — it
|
||||
is the minimal unprovable residue, left to human review.
|
||||
- The declaration is an assertion, not an opt-out: labeling provable content
|
||||
`non_mechanical_provable` to dodge the verifier **violates the chain property**, even
|
||||
where no machine check catches it. A provable part hiding inside a semantic commit
|
||||
belongs in its own `mechanical_provable` commit with a proof
|
||||
(`guide-split.md` §2.2).
|
||||
- The rest of the message format is unconstrained **by the machine rule**: the word may
|
||||
appear anywhere in the subject or body, in any surrounding syntax. The authoring
|
||||
contract additionally fixes the subject format
|
||||
(`<group-id>(<commit-id>,<kind>): <message>`, `guide-split.md` §1.1), which satisfies
|
||||
this rule by construction; the verifier deliberately checks only the word, so a chain
|
||||
from a different convention still verifies.
|
||||
- A word counts only standalone: delimited by a non-`[0-9A-Za-z_]` character or the
|
||||
message boundary, lowercase, so `non_mechanical_provable` never also counts as the bare
|
||||
word, and `xmechanical_provable` counts as neither.
|
||||
- Repeating the same word is fine; the rule is about **which** of the two is declared:
|
||||
- neither word present → `UNCLASSIFIED`;
|
||||
- both words present → `AMBIGUOUS_KIND`.
|
||||
|
||||
### 2.2 The proof obligation
|
||||
|
||||
- Each `mechanical_provable` commit must resolve to exactly one proof script (§3.3);
|
||||
none is `MISSING_PROOF`, several is `AMBIGUOUS_PROOF`.
|
||||
- The proof must run to a PASS (§3.4): the commit reproduces byte-for-byte from its
|
||||
parent (`spec-reproduction-utils.md` §4). Anything else is `FAIL`.
|
||||
- A `non_mechanical_provable` commit has no machine obligation; its verdict is
|
||||
`HUMAN_REVIEW` — the report marks it for eyes, never certifies it. Whether its
|
||||
declaration is honest is the reviewer's duty to check (`guide-verify-proof.md` §1).
|
||||
- The chain verdict is PASS iff every commit's verdict is `PASS` or `HUMAN_REVIEW`.
|
||||
|
||||
## 3. The CLI contract
|
||||
|
||||
### 3.1 Invocation
|
||||
|
||||
```bash
|
||||
python3 .claude/skills/mechanical-refactor-verify/scripts/mechanical_refactor_reproduction_cli.py \
|
||||
--base <base-commit> --branch <pr-branch-name> --proof path/to/proof/folder
|
||||
```
|
||||
|
||||
- `--base` / `--branch`: any commit-ish; both must resolve, `base` must be an ancestor of
|
||||
`branch`.
|
||||
- `--proof`: the proof folder (must exist) — typically the generator's `--out` product
|
||||
(`guide-construct-proof.md` §1.2).
|
||||
- `--repo-root DIR`: run against that repo instead of the cwd's.
|
||||
- `--report PATH`: write the report there instead of `<proof>/chain_report.md`.
|
||||
- `--jobs N`: run up to N proofs concurrently (default 3).
|
||||
- `--skip-passed`: reuse this machine's own earlier PASS verdicts (§3.5).
|
||||
|
||||
### 3.2 The chain
|
||||
|
||||
- The commits are `git rev-list --reverse base..branch`, i.e. the whole chain in order.
|
||||
- The chain must be **linear**: a merge commit anywhere in it is a setup error — per-commit
|
||||
proofs are meaningless across a merge.
|
||||
- An empty range is a setup error, not a trivially-green chain.
|
||||
|
||||
### 3.3 Proof resolution
|
||||
|
||||
- A commit's proof is a `<sha-prefix>.py` whose stem is lowercase hex, at least 7
|
||||
characters, and a prefix of the commit's full sha.
|
||||
- Searched locations, in order, both always considered: `<proof>/repro_scripts/` (the
|
||||
generator layout) and `<proof>/` flat (the gist layout,
|
||||
`guide-construct-proof.md` §1.3.1).
|
||||
- Proofs are keyed by current shas: after a rebase the shas change, so the proofs must be
|
||||
regenerated for the rebased chain.
|
||||
|
||||
### 3.4 Proof execution and the PASS criterion
|
||||
|
||||
- Each proof runs as `python3 <script>` with the repo root as cwd (the run resolves the
|
||||
repo from the cwd, `guide-verify-proof.md` §2.1).
|
||||
- A proof PASSes iff **both**: exit code 0, **and** the arbiter's `PASS:` verdict line on
|
||||
stdout. Requiring the line keeps an old-style script that exits 0 while printing a
|
||||
residual from false-passing; requiring the exit code keeps a crash before any verdict
|
||||
from passing.
|
||||
- Proofs run **concurrently**, up to `--jobs` at a time (default 3). This is safe because
|
||||
each proof works in its own throwaway worktree with a unique branch name and never
|
||||
touches the checked-out tree; per-proof verdicts are independent. Classification and
|
||||
proof resolution stay sequential (they are cheap), and the report keeps chain order
|
||||
regardless of completion order. A completion line (`sha PASS/FAIL`) is printed as each
|
||||
proof finishes, so a long chain shows progress.
|
||||
|
||||
### 3.5 The passed-proof cache (`--skip-passed`)
|
||||
|
||||
- Purpose: incremental re-verification. Re-running a long chain repeats work for proofs
|
||||
whose commit and proof did not change; those earlier PASSes can be reused.
|
||||
- **What is recorded.** Every run (flag or not) records each proof that PASSed into the
|
||||
cache; a FAIL is **never** recorded. An entry's key is the triple:
|
||||
- the commit's **full sha** (a rebase changes the sha, so a rebased commit never
|
||||
hits);
|
||||
- the **sha256 of the proof script's bytes**;
|
||||
- the **sha256 of the `mechanical_refactor_reproduction_utils.py` bytes** sitting
|
||||
next to the script or one level up (the script's only dependency; `""` when
|
||||
absent) — an edited engine invalidates the cache.
|
||||
- **What is skipped.** Only with `--skip-passed`, and only on an exact triple match, is a
|
||||
pending proof skipped: its verdict is `PASS`, marked as reused (a
|
||||
`proof <sha> PASS (cached)` progress line, and a reused count in the report). Any
|
||||
mismatch — different sha, edited script, edited utils, no entry — runs the proof
|
||||
normally.
|
||||
- **Where the cache lives — and why that is trust-safe.** The cache file
|
||||
(`mechanical_refactor_passed_proofs.json`) sits in the repo's **git common dir**
|
||||
(`git rev-parse --git-common-dir`), shared across that repo's worktrees. It is
|
||||
machine-local state: it never travels with the proof folder, a gist, or the PR, so
|
||||
`--skip-passed` can only ever reuse verdicts **this machine's own runs** produced —
|
||||
the do-not-trust-the-PR rule (`guide-verify-proof.md` §0) is not weakened.
|
||||
- A missing, corrupt, or unreadable cache file is treated as empty; the cache is
|
||||
best-effort infrastructure and must never fail the chain walk.
|
||||
|
||||
## 4. The report
|
||||
|
||||
- The full report is markdown, printed to stdout **and** written to the report path
|
||||
(§3.1), so the folder stays self-describing.
|
||||
- It contains:
|
||||
- the resolved base / branch / proof folder and the **chain verdict**;
|
||||
- the commit counts per kind and the proof PASS count (plus, when any proof was
|
||||
skipped via `--skip-passed`, the reused count);
|
||||
- one table row per commit, in chain order: sha, kind, verdict, subject;
|
||||
- a **Failure details** section with one entry per non-ok commit — the missing-proof
|
||||
search locations, the classification rule broken, or the failing proof's output
|
||||
tail.
|
||||
- Verdict vocabulary: `PASS`, `HUMAN_REVIEW`, `FAIL`, `MISSING_PROOF`,
|
||||
`AMBIGUOUS_PROOF`, `UNCLASSIFIED`, `AMBIGUOUS_KIND` — the first two are the only ok
|
||||
verdicts.
|
||||
|
||||
## 5. Exit codes
|
||||
|
||||
- `0` — the chain verifies (§2).
|
||||
- `1` — the chain was walked but at least one commit does not verify.
|
||||
- `2` — setup error: unresolvable ref, base not an ancestor, empty range, merge commit in
|
||||
the chain, or a missing proof folder. Nothing was certified either way.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Reproduction utils — specification (source of truth)
|
||||
|
||||
## 1. Scope
|
||||
|
||||
- Source of truth for `scripts/mechanical_refactor_reproduction_utils.py`: the
|
||||
**clean-move property** its primitives implement (§2), each primitive's contract (§3),
|
||||
and the byte-diff arbiter's semantics (§4).
|
||||
- The module, its tests, and the guides defer to this file; on any disagreement, this
|
||||
file wins.
|
||||
- Elsewhere: commit splitting → `guide-split.md`; producing a proof →
|
||||
`guide-construct-proof.md`; reading one → `guide-verify-proof.md`.
|
||||
|
||||
## 2. The property — a "clean move"
|
||||
|
||||
> A commit is a **clean move** iff every change it makes is code **relocated in the same
|
||||
> order** — allowing one **uniform indentation shift** of the whole block — plus a small
|
||||
> fixed set of **move artifacts**, and nothing else.
|
||||
|
||||
- Equivalently: the commit is reproducible by composing only the primitives of §3.
|
||||
- The whitelist (§2.1) is exactly what they do; the not-allowed list (§2.2) is what they
|
||||
refuse, so it surfaces as a residual diff.
|
||||
|
||||
### 2.1 Allowed — the whole whitelist
|
||||
|
||||
- A line **relocated in order**, modulo one **uniform** leading-indentation shift of the
|
||||
whole block.
|
||||
- **Defs/classes gathered from scattered positions** into a **new module**, each cut
|
||||
verbatim, assembled under an **audited authored header**:
|
||||
- the byte diff certifies the bodies; the header is reproduced from the target;
|
||||
- the header audit accepts only: imports, a docstring, a TYPE_CHECKING import block,
|
||||
a `logging.getLogger(__name__)` logger, an unparse-equivalent copy of an assignment
|
||||
actually deleted from the source (`drop_assigns`), or an unparse-equivalent copy of a
|
||||
module constant that **survives verbatim in the source** — re-derived boilerplate such
|
||||
as `_is_hip = is_hip()`, provably not fiction because the same statement remains in the
|
||||
source;
|
||||
- every dropped assignment must reappear in the header — anything else raises instead
|
||||
of certifying.
|
||||
- The **body of an extracted function** — an inline block relocated verbatim into a new
|
||||
def; the `def` signature, an optional `return`, and the replacing `call` are authored.
|
||||
Faithful **only** when the body moves unchanged; a de-self, control-flow restructure, or
|
||||
bookkeeping consolidation is semantic and goes in its own commit first.
|
||||
- **Import statements** — added, removed, or repathed; single-line or parenthesised.
|
||||
Realised directly from the target (a wholly new module's statement verbatim, wrapping
|
||||
preserved); a new-module move may add `from __future__ import annotations`.
|
||||
- A one-sided **`@staticmethod` / `@classmethod`** — method ↔ free function.
|
||||
- A **`self` type annotation dropped** from the moved definition — relocating
|
||||
`@staticmethod def foo(self: Target)` into `Target` as `def foo(self)`.
|
||||
- A **call-site requalification** — `Owner.foo(x)` → `foo(x)`: same symbol, same argument
|
||||
bytes, only the qualifier dropped. (An `Old.foo(x)` → `New.foo(x)` owner swap is not a
|
||||
primitive; it surfaces as a residual.)
|
||||
- A **call-site lowering** — `Owner.method(receiver, rest)` → `receiver.method(rest)`:
|
||||
the receiver moves out of the argument list.
|
||||
- **Deleting a source file the relocation emptied** — nothing left beyond a docstring,
|
||||
imports, or a `TYPE_CHECKING` block (`delete_file` refuses anything else).
|
||||
- **Blank-line changes** — ignored (§2.3).
|
||||
|
||||
### 2.2 Not allowed — the commit is **not** a clean move
|
||||
|
||||
- A **reorder** of lines within the moved block.
|
||||
- A **statement-level reorder** that relocates no definition — it changes evaluation
|
||||
order: a reshape a human must confirm, not a certifiable relocation.
|
||||
- A **non-uniform** indentation change — it can change Python semantics.
|
||||
- A **trailing-whitespace** change, an internal-whitespace change, or a **line
|
||||
merge/split**.
|
||||
- A **changed argument** in an otherwise-requalified call.
|
||||
- A **call rewrite for a symbol that did not move** in this commit.
|
||||
- A **signature change** other than dropping the `self` annotation.
|
||||
- A **rename** of the moved symbol (even a privacy flip `_foo` → `foo`).
|
||||
- **Scaffolding or a constant authored into an existing module** — a logger, a module
|
||||
constant, a `TYPE_CHECKING` guard, a re-derived `_flag = compute_flag()`. (A *new*
|
||||
module's header is authored from the target, §2.1; an existing module's body is not a
|
||||
place to author fresh code. A constant *relocated* from the source is not authored —
|
||||
`move_assign` certifies it.)
|
||||
- A **changed body in an extracted function** — de-self, control-flow restructure, or a
|
||||
folded-in bookkeeping change: a semantic rewrite, not a relocation.
|
||||
|
||||
- Reshape work (rename, fresh scaffolding, statement reorder, changed extraction body)
|
||||
belongs in the prepare/postpare phases of `guide-split.md`.
|
||||
- The proof reports it as a residual — never certifies it.
|
||||
|
||||
### 2.3 Blank lines are ignored
|
||||
|
||||
- A blank line never changes Python behavior; PEP 8 separator blanks legitimately collapse
|
||||
on relocation.
|
||||
- The formatter normalises both the reproduced and target sides, so a blank-line-only
|
||||
difference cannot reach the byte diff.
|
||||
- Assumption: the **target commit is itself pre-commit-clean** (true for any commit that
|
||||
passed this repo's hooks); a target that skipped the formatter can show blank-line
|
||||
residuals.
|
||||
|
||||
## 3. The faithful relocation primitives
|
||||
|
||||
- Each primitive does only a relocation-faithful edit — AST-located, spliced as original
|
||||
source text, never regenerated.
|
||||
- Therefore a byte match after the formatter certifies the commit is *exactly* that
|
||||
relocation.
|
||||
|
||||
- `move_symbol(name, *, src, dst, into_class, from_class, dedent, drop_self_annotation,
|
||||
before, after, leave_delegate, delegate_name)`:
|
||||
- cuts a `def` or a whole `class` (with its methods) with its decorators; drops its own
|
||||
`@staticmethod`/`@classmethod`;
|
||||
- shifts indentation uniformly (negative `dedent` indents into a class);
|
||||
- pastes at a class end, at module level, above the named sibling `before`, or
|
||||
immediately below the top-level symbol `after` (a sibling def/class or a module-level
|
||||
assignment target — the anchor for landing a def just above a following
|
||||
`if TYPE_CHECKING:` guard, which has no nameable anchor of its own); `before` and
|
||||
`after` are mutually exclusive;
|
||||
- same-named defs need `from_class`; an ambiguous name or missing anchor raises;
|
||||
- `leave_delegate` **authors** a forwarding stub in the source (original header + one
|
||||
`return self.<attr>.<name>(...)`, `await`ed for async) — audit it like any header.
|
||||
- `extract_to_new_module(src, dst, *, symbols, future_import)`:
|
||||
- cuts the contiguous source tail: the moved defs/classes plus leading scaffolding
|
||||
(imports, TYPE_CHECKING guards, name-target assignments only);
|
||||
- an executable trailing statement stops the cut;
|
||||
- prepends `from __future__ import annotations` when the move adds it.
|
||||
- `extract_symbols_to_new_module(src, dst, *, symbols, header, order, drop_assigns)`:
|
||||
- cuts the named defs/classes from **scattered** positions; assembles the new module
|
||||
under the audited `header` (§2.1);
|
||||
- `drop_assigns` deletes a relocated module-level constant from the source; a chained
|
||||
`A = B = 1` keeps the surviving bindings;
|
||||
- the header audit also accepts an unparse-equivalent copy of a module constant that
|
||||
**survives** in the source (re-derived boilerplate, e.g. `_is_hip = is_hip()` kept in
|
||||
both modules) — provable because the same statement remains in the source.
|
||||
- `extract_function(src, dst, *, name, signature, body, body_indent, call, return_text,
|
||||
before, into_class)`:
|
||||
- cuts an inline `body` verbatim (must match at a line boundary);
|
||||
- re-indents under the authored `signature` — multi-line string interiors keep their
|
||||
exact bytes;
|
||||
- replaces the block with the authored `call`.
|
||||
- `move_assign(name, *, src, dst, before)` — cuts the module-level assignment binding
|
||||
`name` from `src` verbatim and pastes it at module level in `dst` (above the named
|
||||
sibling `before`, else after the trailing import) — a module constant relocated
|
||||
together with the code that reads it.
|
||||
- `lower_call_sites(name, owner, *, paths)` — `Owner.m(receiver, rest)` →
|
||||
`receiver.m(rest)` by splicing the original argument bytes (literal spelling, comments,
|
||||
magic trailing comma survive); nested matching calls are all rewritten.
|
||||
- `requalify_call_sites(name, owner, *, paths)` — `Owner.m(args)` → `m(args)`; only the
|
||||
qualifier span changes.
|
||||
- `route_call_sites_through_field(name, *, field, paths, owner)` — `recv.m(args)` →
|
||||
`recv.field.m(args)` when `m` moved onto a collaborator reached via `self.field`; the
|
||||
call-side dual of `move_symbol(leave_delegate=...)`. A call already routed through `field`
|
||||
is skipped so the pass converges; `owner` restricts to one exact receiver.
|
||||
- `remove_import(rel, import_text, *, in_function)` — function-scoped or module-level;
|
||||
whole-statement match with token boundaries (`import os` cannot hit `import os.path`);
|
||||
removes exactly the matched import even on a semicolon-joined line.
|
||||
- `remove_imported_name(rel, *, module, name, asname)` — drops one name from a
|
||||
`from m import a, b` (or a plain `import x`), realising a lost import directly (this
|
||||
repo's ruff has no F811). A name on its own line in an exploded, parenthesized import is
|
||||
deleted in place when **2+ names survive** (or the import carries comments): the parens,
|
||||
the magic trailing comma, and the comments are preserved and the formatter leaves it
|
||||
multi-line — a flat rebuild would drop the magic comma and collapse an import the target
|
||||
left multi-line. A **lone** surviving name with no comments collapses to a single line
|
||||
(the formatter does not keep one name exploded) **by default**; pass `keep_exploded=True`
|
||||
when the target left the sole survivor exploded (its magic comma preserved) — the choice
|
||||
is the commit author's and cannot be inferred from the source. A name sharing a line (a
|
||||
flat single-line import) is always rebuilt. Dropping the sole name removes the whole
|
||||
statement.
|
||||
- `add_imported_name(rel, *, module, name, asname)` — the dual of `remove_imported_name`:
|
||||
adds one name to an existing `from module import a, b`. Use it (over `add_import`) when the
|
||||
target extends an existing line rather than adding a fresh statement — the sorter will not
|
||||
merge a new statement across an intervening non-import (e.g. a module-level assignment
|
||||
between two import blocks). An import carrying comments is refused (a rebuild would drop
|
||||
them); a name already present fails loudly.
|
||||
- `add_import(rel, import_stmt, *, after)` — the import sorter places it; with no existing
|
||||
imports it lands below the module docstring. `after=<substr>` inserts it immediately below
|
||||
the top-level import statement whose text contains the substring — needed when a statement
|
||||
splits the imports into separate isort sections (e.g. `_is_hip = is_hip()` between two
|
||||
blocks) and the default (after the last import) would land in the wrong block; a substring
|
||||
matching no top-level import raises.
|
||||
- `add_typechecking_import(rel, import_stmt)` — appends inside the destination's
|
||||
`if TYPE_CHECKING:` block (creating the block after the trailing module import when
|
||||
absent); the sorter orders it. A lone `pass` placeholder (the block's
|
||||
only statement) is dropped, since populating an empty block makes its placeholder redundant.
|
||||
- `repath_import(rel, *, old_module, new_module, name)` — repaths a function-scoped
|
||||
`from old import … name …` (relative imports included) in place; module-level repaths
|
||||
fall out of add/remove + the sorter.
|
||||
- `delete_file(path)` — deletes a source module the relocation emptied; refuses anything
|
||||
beyond a docstring, imports, a `TYPE_CHECKING` block, or a bare module `logger`.
|
||||
|
||||
Cross-cutting guarantees:
|
||||
|
||||
- CRLF sources round-trip byte-for-byte; synthesized lines follow the file's newline
|
||||
style.
|
||||
- Column arithmetic is UTF-8-byte-accurate; non-ASCII text does not shift a rewrite.
|
||||
|
||||
## 4. The arbiter — reproduce and byte-diff
|
||||
|
||||
`Repro.run()` (and the lower-level `verify_mechanical_refactor`):
|
||||
|
||||
- checks out the base commit in a throwaway worktree;
|
||||
- replays the recorded primitives;
|
||||
- runs the repo's pre-commit hooks on the changed files;
|
||||
- byte-diffs against the target commit — an empty diff is the proof; a non-empty diff is
|
||||
returned as the residual, exactly what the relocation does not account for.
|
||||
|
||||
Properties:
|
||||
|
||||
- It runs the **real formatter**: a call split across an `= (` line, or a reflow leaving a
|
||||
closing bracket as context, reproduces exactly — no diff-shape heuristic to fool.
|
||||
- Explicit tradeoff: whatever the **pre-commit hooks auto-fix is absorbed** on both sides
|
||||
(e.g. ruff's F401 removing a now-unused import). A hook-introduced change rides under a
|
||||
byte match, so the hook set is part of the trusted base.
|
||||
Reference in New Issue
Block a user