Skip to content

adopt-project.py: plan written to and cloning <repository> (read only) into name the caller's --out and --work-dir with _shown, as the other lines of the report name a path, so a bidi control, a line feed or a tab in either no longer reaches the terminal as it was typed (#1115) - #1154

Draft
brettheap wants to merge 1 commit into
mainfrom
fix/1115-plan-paths-spelled

Conversation

@brettheap

@brettheap brettheap commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What changes

adopt-project.py named two caller-typed paths in its output as they were typed. A path holding U+202E or U+2066 turned the line round, a line feed split it and a tab padded it. Both now go through the existing _shown, the helper the source line, the table and the follow-ups already use (#199, #900):

A path whose every character is printable prints exactly as before, unless _shown spells it by #900's rule: a path that ends in a space, or one that begins and ends with a backtick. The diff in adopt-project.py is 13 lines.

Behaviour change

A path that is not UTF-8 now prints as \udcff between backticks on the plan written to line, as _shown spells it in every other line, where it was written back as the raw 0xff byte before. tests/test_stdout_encoding.py pinned the raw byte for that one line, so its two surrogateescape rows now hold the spelled form, and one test is renamed to test_a_name_that_is_not_utf8_is_spelled_and_the_report_still_comes_out. tests/test_non_utf8_name_callers.py calls that test by name and is changed to match.

Rows

tests/test_adopt_plan_paths_spelled.py is new: 18 rows, ASCII only (every non-ASCII character is an escape in the source), POSIX only. It runs plan as a child with U+202E, U+2066, a line feed, a tab and all four together in --out (5 rows) and in --work-dir (5 rows), a raw-bidi-control check for each flag (2 rows each), a plain path for each flag that must print unchanged (1 row each), a ./ dot-segment --out that must print as it always did, and one run that clones and writes its plan with both flags at once. The --work-dir rows use a gh that refuses, because the line is said before gh runs.

  • On main's code (038dafb, with only this file added): 15 of the 18 rows fail. The 2 plain-path controls and the ./ row pass there, as they must.
  • On this head: 18 of 18 pass.

Mutants

7 of 7 are killed, each restored with its sha256 checked: the --out path raw again (M1), the --work-dir path raw again (M2), either path spelled with _spelled so a plain path gets backticks (M3, M4), --out spelled with _line_shown, the wrong helper (M5), the spelling put on the repository with the path left raw (M6), and --out spelled from args.out where the printed value is out (M7).

Gate

Not in this PR

The --plan path and --local-remote-dir are still printed raw. That is the follow-up #1152.

Landing

This PR lands only after the v1.2.0 tag.

Closes #1115

🤖 Generated with Claude Code
Lane: openreposhape-4 (openRepoShape-4)

Summary by Sourcery

Safely display caller-provided output and work-directory paths in adoption plans.

Bug Fixes:

  • Prevent caller-supplied --out and --work-dir paths containing terminal-control characters or invalid bytes from being emitted raw in plan output.

Enhancements:

  • Align path reporting for plan destinations and clone work directories with the existing safe path-display behavior.

Tests:

  • Add POSIX coverage for bidi controls, line feeds, tabs, combined unsafe characters, plain paths, dot segments, and both paths used together.
  • Update non-UTF-8 stdout coverage to verify invalid path bytes are escaped rather than written to the terminal.

…nly) into` name the caller's `--out` and `--work-dir` with `_shown`, as the other lines of the report name a path, so a bidi control, a line feed or a tab in either no longer reaches the terminal as it was typed (#1115)

`cmd_plan` printed `plan written to {out}` and `Source.clone` printed
`cloning {repository} (read only) into {path}` with the typed path as it was.
A path holding U+202E or U+2066 turned the line round, a line feed split it
and a tab padded it, where the `source` line, the table and the follow-ups
spell such a path with `_shown` (#199, #900). #1115 named the `--out` line;
RV1120's review of #1120 (its N4) found the `--work-dir` line, which is said
for an `org/repo` source by `plan`, `check` and `execute` alike.

A path whose every character is printable prints exactly as before.

The `--out` line also spells a byte that is not UTF-8 now, `\udcff` between
backticks where it was written back as 0xff, as `_shown` spells it in every
other line; `test_stdout_encoding.py` pinned the raw byte for that one line,
and its row now holds the spelled one.

tests/test_adopt_plan_paths_spelled.py runs `plan` as a child with U+202E,
U+2066, a line feed, a tab and all four in `--out` and in `--work-dir`, and
with a plain path that must print unchanged. The `--work-dir` rows use a `gh`
that refuses: the line is said before `gh` runs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 22, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens adopt-project.py report output by applying the existing _shown path formatter to --out and --work-dir messages, preventing bidi controls and whitespace or non-UTF-8 bytes from being emitted raw, with comprehensive regression and encoding coverage.

Sequence diagram for safe plan path reporting

sequenceDiagram
    participant Caller
    participant adopt_project as adopt-project.py
    participant Filesystem
    participant gh as gh

    Caller->>adopt_project: cmd_plan(args)
    adopt_project->>Filesystem: write plan to out
    adopt_project->>adopt_project: _shown(str(out))
    adopt_project-->>Caller: print plan written to shown out
    adopt_project->>adopt_project: Source.clone(repository, work_root)
    adopt_project->>adopt_project: _shown(str(path))
    adopt_project-->>Caller: print cloning repository into shown path
    adopt_project->>gh: repo clone repository path
Loading

File-Level Changes

Change Details Files
Sanitize both caller-provided paths when emitting plan progress and completion messages by routing them through the existing _shown formatter.
  • Format the resolved --work-dir clone destination in the read-only cloning message.
  • Format the resolved --out path in the plan-written message while preserving existing printable-path output behavior.
adopt-project.py
Add focused POSIX subprocess coverage for terminal-safe rendering of special path characters across both affected report lines.
  • Cover bidi controls, line feeds, tabs, combined characters, plain paths, dot segments, and simultaneous --out/--work-dir usage.
  • Use local repositories and refusing/cloning gh stubs to keep tests offline while verifying paths are still used correctly.
  • Assert that unsafe characters are escaped and no longer reach stdout raw.
tests/test_adopt_plan_paths_spelled.py
Update non-UTF-8 stdout expectations to reflect _shown escaping surrogate-escaped path bytes.
  • Rename and adjust the encoding test to expect \udcff inside backticks instead of the original raw byte.
  • Update the caller reference to the renamed test.
tests/test_stdout_encoding.py
tests/test_non_utf8_name_callers.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1115 Print the caller-supplied --out path on the plan written to line using _shown, so bidi controls, line feeds, tabs, and non-UTF-8 bytes are spelled rather than emitted raw. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sonarqubecloud

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

RV1115: independent review of #1154 at 8a782733: READY

Independent adversarial review for lane 4. No MUST, no SHOULD; two NITs for follow-ups, below.

Merge with current main

Probes (real runs of plan, check and execute; real gh unreachable)

--out with a local source; --work-dir with --source testorg/Thing and a gh that refuses (the line is said before gh runs). Non-ASCII written as escapes.

path holds main PR
U+202E, U+2066, ESC, CR, TAB raw on both lines spelled in backticks (\u202e, \u2066, \x1b, \r, \t) on both lines
a line feed the line split in two, both lines `.../pl\nan.yaml` one line, both lines
byte 0xff (stdout utf-8:surrogateescape) raw 0xff, both lines `.../pl\udcffan.yaml`, both lines
a space, a backtick inside, ", ', \, caf\u00e9, a leading - (--out=-plan.yaml, --work-dir=-wdir) as typed as typed (unchanged)
plain absolute, plain relative, ./plan.yaml, <dir>/./plan.yaml as typed (./ dropped by Path) identical to main
--out ending in a space (plan.yaml ) plan written to <B>/plan.yaml plan written to `<B>/plan.yaml `
relative --out `plan.yaml` plan written to `plan.yaml` plan written to `\x60plan.yaml\x60`
  • check --plan P --source testorg/Thing --work-dir <path> and execute ... --work-dir <path> print the same cloning line: spelled there too (check: U+202E, ESC, 0xff and a line feed; execute: U+202E), and a plain path is unchanged in both (measured).
  • The non-UTF-8 behaviour change is consistent. One plan over a source directory Th<0xff>ng holding caf<0xff>.md, --out <B>/b<0xff>d/plan.yaml: under both utf-8:surrogateescape and utf-8:strict the plan written to line, the source line, the table row and the question all write \udcff between backticks, and no raw 0xff reaches stdout. On main the plan written to line alone wrote the raw byte (surrogateescape) or a bare \udcff (strict).
  • No reader relies on the raw form. git grep 'plan written to' and '(read only) into': the readers are test_adopt_plan_out_checked.py (lines 485, 497), test_adopt_plan_flag_refusals_first.py:585, test_adopt_single_repository_record.py:222 and test_stdout_encoding.py:164, all with plain tmp_path paths that _shown returns unchanged; docs/cli.md names neither line; docs/release-notes/v1.1.1.md:226,266 say plan written to <out> as history. Nothing reads the cloning line but the new file.
  • The two changed tests keep their purpose. test_non_utf8_name_callers.py only follows the rename; its stdout-encoding row still asks the helper's skip/raise question (36 passed). The renamed test_stdout_encoding.py test still proves a non-UTF-8 --out leaves exit 0, no traceback and the whole report (with \u65e5\u672c.md escaped on cp1252) on both surrogateescape stdouts; the half it gave up, a surrogate written back as its byte, is still pinned by the handler's own unit rows (test_a_surrogateescape_stdout_keeps_its_raw_bytes_and_escapes_the_rest, test_the_handler_draws_each_run_at_the_range_of_the_bytes_it_escaped).

Gate, on 7a51704a6

Files Result
the new file 18 passed
test_stdout_encoding.py 47 passed, 1 skipped (Windows default stdout)
test_non_utf8_name_callers.py 36 passed
test_adopt_plan_escapes.py 59 passed
test_path_guard_hygiene.py (#1111's rule) 49 passed
test_repo_hygiene.py 100 passed
test_scaffold_leg_path_collisions.py 104 passed
test_shape_doctor.py, 448 ids in 8 shards of 56 (bash, mapfile, every list non-empty, the lists together hold every collected id once, timeout 900 s) 445 passed, 3 skipped (the PowerShell rows, Windows only); every shard passed

Mutants (mine; each on adopt-project.py, run against the new file, the file restored with git checkout and its sha256 checked after each)

Mutant Result (new file, 18 rows)
M0: main's adopt-project.py (both lines raw) 15 failed, 3 passed, the counts the body gives
R1: --out by isprintable() alone (no trailing-space or backtick rule) 0 failed (survives)
R2: --out spells every non-ASCII path, printable or not 0 failed (survives)
R3: --work-dir spells every non-ASCII path 0 failed (survives)
R4: --out printed as _shown(os.path.abspath(out)) 0 failed (survives)
R6: --out spells only Cf, LF and TAB (ESC, CR, a surrogate raw) 0 failed (survives)
R7: --work-dir spells only Cf, LF and TAB 0 failed (survives)
R12: cloning spelled only where the caller keeps a made list 0 failed (survives)

W1115's M1 to M7 (each line raw again, the wrong helper, the wrong value) are the ones that matter, and the 15 failures on main confirm the rows bite. Every survivor above leaves behaviour that the PR's code gets RIGHT unpinned (the probes measure it), so none is a defect: N1 and N2.

Merge-trees

git merge-tree --write-tree of the scratch merge against every open PR that touches adopt-project.py, test_stdout_encoding.py or test_non_utf8_name_callers.py: #1153, #1143, #1141, #1125, #1123, #1122, #1121, #1120, #1119, #1118, #1106, #1105. Exit 0 each time. (#1153 is newer than the body's list.)

Findings

  • N1 (NIT), the body's sentence. "A path whose every character is printable prints exactly as before" is not exact: an --out that ends in a space, or a relative --out that begins and ends with a backtick, is printable and is now spelled (the last two rows of the probe table). That is _shown's own adopt-project.py: a kept or dropped name reaches the split commit and the pull request body unspelled, and _spelled, _shown and plan's report print text that reads as another name, or raw #900 rule and the right output; correct the sentence, and add a row for each, since R1 (isprintable() alone) survives without them.
  • N2 (NIT), the rows. R2, R3, R4, R6, R7 and R12 survive the new file; the PR's code is right on each (probes above). Follow-up rows: a printable non-ASCII path (caf\u00e9) for each flag, which must print unchanged (R2, R3); ESC and CR in CHARACTERS, and a non-UTF-8 --work-dir (R6, R7; for --out, the non-UTF-8 row of test_stdout_encoding.py asserts the spelled byte, not run against R6 here); a relative --out (plan.yaml, ./plan.yaml), which must print as typed less the ./ (R4); and one check --source testorg/Thing --work-dir <a U+202E path> row, since the body says check and execute print the line too and only plan is run (R12).

Lane: openreposhape-4 (openRepoShape-4)

This was referenced Oct 22, 2026
@brettheap brettheap added this to the v1.2.1 milestone Oct 23, 2026
This was referenced Oct 23, 2026
This was referenced Nov 8, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant