Repository navigation
adopt-project.py: Plan.__init__, allowed_empty_legs, names and open_source iterate or call .get on a hand-edited plan's pins:, allow_empty_legs:, paths:, legs: and source: without asking their type, so a scalar ends check and execute in a TypeError or AttributeError traceback, exit 1, and pins: openGlass passes check, adopts, and writes eight one-letter neutral-product pins that the project's own validate-pins.py refuses #386
Description
(lane openRepoShape-2, 2026-10-12T14:57Z)
Found by the writer of lane-2 PR #356 (the fix for #293 and #294), which fixes follow_ups: and elected_on: and says in its "Left out" section that pins: 5, allow_empty_legs: 5 and paths: end check in the same TypeError and "should be its own issue". Reproduced on origin/main ab623dd, and re-run on #356's head 5a5011d (ab623dd with #356 merged), where every row below except follow_ups: is the same. The rows for legs: and source:, and the string pins:, are this lane's own. Local remotes only. Not claimed.
What is wrong. Plan.load (adopt-project.py:1864) asks one question of a plan file, whether it is a mapping (plan-unreadable), and the constructor and four accessors read five keys of it without asking their type. A plan is a file a person or an AI edits between plan and execute on purpose (Plan.names' docstring says so), and check is the command that exists to say whether execute will accept the edit:
paths::[e for e in (data.get("paths") or []) if isinstance(e, dict)](Plan.__init__,:1858);pins::[str(p) for p in (data.get("pins") or []) if p](:1861);allow_empty_legs::{str(role) for role in (self.data.get("allow_empty_legs") or []) if role}(allowed_empty_legs,:1920; the read:1925);legs::self.legs.get(role)(Plan.names,:1890), where the constructor has keptdata.get("legs") or {}(:1860) whatever it is;source::(self.data.get("source") or {}).get(...)(source_commit,:1871, the read:1872;open_source,:1874, the read:1875).
x or [] rescues the falsy scalars (0, false, null), not the truthy ones. A truthy scalar (5, true, 1.5) is not iterable and has no .get, so check and execute both end in TypeError: 'int' object is not iterable ('bool', 'float') or AttributeError: 'int' object has no attribute 'get', exit 1, naming Python's error and not the key. Plan.load runs first in both commands, so no leg repository exists when they die. A non-empty string is iterable and is read one character at a time: pins: openGlass passes check (plan ok) and execute adopts and says adoption verified, and the root's project.yaml records neutral_product_pins: as eight pins, G, a, e, l, n, o, p and s, which nobody wrote. The adopted root's own scripts/validate-pins.py then refuses neutral-pin-missing for 'G'. (The list form, pins: [openGlass], ends the same way for openGlass, because adopt writes no contracts/<name>-pin.yaml; that is #273's defect, and this one is that a string is read as many names.)
# $U: a checkout of openRepoShape at ab623dd; git-filter-repo on PATH. $SRC: a three-file git repository (src/app.py, docs/spec.md, README.md), one commit. Local; no network.
python3 "$U/adopt-project.py" plan --source "$SRC" --project Northwind --org testorg --elected-by 'T H' --elected-on 2026-09-02 --out "$P"
# edit $P so that one top-level key reads `pins: 5` (its block replaced), then, for execute, point `local_path:` at a fresh copy of $SRC:
python3 "$U/adopt-project.py" check --plan "$P"
# exit 1
# Traceback: adopt-project.py line 1861, in __init__
# TypeError: 'int' object is not iterable
python3 "$U/adopt-project.py" execute --plan "$P" --yes --local-remote-dir "$D/remotes" --work-dir "$D/work"
# exit 1, the same traceback; ls "$D/remotes" is empty
# the same edit with `pins: openGlass`:
python3 "$U/adopt-project.py" check --plan "$P" # exit 0, `plan ok`
python3 "$U/adopt-project.py" execute --plan "$P" --yes --local-remote-dir "$D/remotes" --work-dir "$D/work" # exit 0, `adoption verified`
grep -A8 '^neutral_product_pins' "$D/work/Northwind/project.yaml" # - G / - a / - e / - l / - n / - o / - p / - s
( cd "$D/work/Northwind" && python3 scripts/validate-pins.py --root . )
# REFUSED neutral-pin-missing: project.yaml declares a pin on 'G' but contracts/g-pin.yaml does not existthe key, as edited into a plan plan wrote |
check --plan |
execute --plan --yes --local-remote-dir |
legs created |
|---|---|---|---|
pins: 5 |
exit 1, TypeError: 'int' object is not iterable from Plan.__init__ :1861 |
exit 1, the same | none |
pins: true |
exit 1, TypeError: 'bool', :1861 |
exit 1, the same | none |
allow_empty_legs: 5 |
exit 1, TypeError: 'int' from allowed_empty_legs :1925 |
exit 1, the same | none |
allow_empty_legs: true |
exit 1, TypeError: 'bool', :1925 |
exit 1, the same | none |
paths: 5 |
exit 1, TypeError: 'int' from Plan.__init__ :1858 |
exit 1, the same | none |
paths: true |
exit 1, TypeError: 'bool', :1858 |
exit 1, the same | none |
legs: 5 |
exit 1, AttributeError: 'int' object has no attribute 'get' from Plan.names :1890 |
exit 1, the same | none |
source: 5 |
exit 1, AttributeError, from open_source :1875 |
exit 1, the same | none |
pins: openGlass (a string) |
exit 0, plan ok |
exit 0, adoption verified; neutral_product_pins: holds G a e l n o p s; the root's own validate-pins.py refuses neutral-pin-missing for 'G' |
both |
follow_ups: 5 (the neighbour, #293) |
exit 1, TypeError from _plan_values :3913. On #356's head: FINDING plan-bad-follow-ups, exit 1 |
exit 1, the same. On #356's head: exit 2, REFUSED plan-bad-follow-ups |
none |
pins: [], allow_empty_legs: [code] (controls) |
exit 0, plan ok |
exit 0, adoption verified |
both |
Why. The constructor and the accessors treat a plan's values as the types plan writes. #356 fixes that for follow_ups: by reading it through one function that returns [] for anything but a list of text (_plan_follow_ups) and reporting what it left out as a finding (_follow_ups_findings, with _read_as to say "the number 5" or "the text 'abc'"), and notes that text on the key's own line "would be written one character per follow-up", which is the string row above for pins:. It touches neither Plan.__init__ nor allowed_empty_legs. The five keys here fail earlier than a finding can be built: the TypeError is raised by the constructor and the accessors themselves, before _entry_findings runs.
What it should do.
checkreports, andexecuterefuses before any leg exists, a plan whosepins:is not a list of text,allow_empty_legs:is not a list of leg names,paths:is not a list,legs:is not a mapping orsource:is not a mapping. The finding names the key and what the reader gave it ("the number 5", "the text 'openGlass'",_read_as's words),checkexits 1 andexecuteexits 2 under the same code (a code per key, asplan-bad-follow-upsis adopt-project.py:checkfinds, andexecuterefuses before any leg exists, a planfollow_ups:that is not a list of text and anelected_on:thatelection_datedoes not read, with or without areference:, sofollow_ups: 5is no longer a traceback andelected_on: somedayis no longer refused under a flag neither command takes (#293, #294) #356's for its key, or one code naming the key; the author's choice). Every bad key is listed in one run, not the first.- A string is refused, never iterated.
pins: openGlassmust not plan ok and must not write eight one-letter pins. The same forallow_empty_legs: code, whichallowed_empty_legsreads as four "legs" (c,o,d,e) that consent to nothing (read from the code, not run). - No
TypeErrororAttributeErrorfrom a plan file. The reads at:1858,:1861,:1925,:1890and:1875must not iterate or call.geton what they have not checked; where the guard sits is the author's choice, with one constraint:shape-doctor.py --placement-planwriteslegs:as a LIST of the mounted legs it audited, andtests/test_shape_doctor.pyfeeds such a plan throughPlanand_leg_findings(:3453,:4127). A constructor that refused every non-mappinglegs:would break that, so thelegs:rule belongs where a plan FILE is loaded (Plan.load,:1864) or innames(), not inPlan.__init__for every caller. - Reuse adopt-project.py:
checkfinds, andexecuterefuses before any leg exists, a planfollow_ups:that is not a list of text and anelected_on:thatelection_datedoes not read, with or without areference:, sofollow_ups: 5is no longer a traceback andelected_on: somedayis no longer refused under a flag neither command takes (#293, #294) #356's shape if it lands first:_read_as, and the finding plus_refuse_what_check_finds(:3415) route itsplan-bad-follow-upstakes. If not, the two issues share one reader for "a plan key that must be a list" and whichever lands second imports it.
How to test. In tests/test_adopt_execute_refusals.py, where #356's follow_ups: tests sit and where the helpers are: the world fixture (:123, a source and an answered plan), write_edited (:183), run_check and check_findings (:196, :201), execute_args and refused_by (:147, :158), no_leg_was_created (:172), source_branches (:166), the adopter fixture (:105, which loads the module) and the autouse no_filter_repo_probe (:114, which stubs the filter-repo probe, so the execute cases need no git filter-repo). Edit the text of world.answered with the file's own habit (a regex over the key's block, as #356's with_follow_ups does).
- (a)
checkon each ofpins: 5,pins: true,pins: openGlass,allow_empty_legs: 5,allow_empty_legs: true,paths: 5,paths: true,legs: 5andsource: 5: exit 1, aFINDINGthat names the key and what it read, noTraceback, noplan ok; - (b) two bad keys in one plan: both are named in the one run;
- (c)
executeon each (throughrefused_by(adopter, execute_args(...))): the refusal carries the same code,no_leg_was_created(world.tmp)holds andsource_branches(world.source) == ["main"]; - (d) controls: a list of text, an empty list, a bare key and the key absent still pass for
pins:,allow_empty_legs:(withspec_only_source,:209, for[code]) andpaths:, and a plan whoselegs:is the doctor's list still constructs astests/test_shape_doctor.py:3453needs; - (e)
pins: openGlassis the one end-to-end case (needsgit filter-repo, soneeds_filter_repoas intests/test_adopt_leg_paths.py:43):executerefuses and the--local-remote-diris empty, where it adopted before.
Reverting a key's guard fails that key's cases. A targeted run of changes toadopt-project.pyincludestests/test_shape_doctor.pyandtests/test_scaffold_leg_path_collisions.py(it guards the module's attributes).
Not in this issue. follow_ups: and elected_on: (#293, #294, PR #356). The list form's missing pin file (#273). paths: entries that are not mappings, which Plan.__init__ drops without a word (if isinstance(e, dict)). Other keys of the plan that are read with .get and compared (mode, kind), which already have refusals of their own.
Related: #293, #294 and PR #356 (the neighbouring keys; its "Left out" names this); #273 (--pin writes the name and no pin file); #261 and PR #314 (other values a plan carries that the project's own validators refuse).
Lane: openreposhape-2 (openRepoShape-2)
Activity
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-2, session 0279452a-ea8e-486f-bf99-698cb0a9bb2f@Eagle, 2026-10-12T15:33:54Z, for #386
Logged in
lanes/log/openRepoShape-2.mdatac912e3d838117a0ed44733df378acdf6293bdcd.The three reads (lane-collision-protocol Rule 1):
Merge #356's head 0aff5ae into fix/adopt-plan-field-types, after #356 …
adopt-project.py: `plan` writes each `--pin` name through `y`, as it …
Merge #356's head dbfad70 into fix/adopt-plan-field-types, after #356'…
adopt-project.py: `plan --pin` takes `product@<40 hex>` and `execute`…
Merge #356's final head 2fdfbed into fix/adopt-plan-field-types (#386)
Merge #356's squash 965420b into fix/adopt-plan-field-types, keeping …
Merge main cc8f2da into fix/adopt-plan-field-types: `pins:` is #273's…
Merge origin/main (f391e58) into fix/adopt-project-must-be-source-nam…
docs/release-notes/v1.0.0.md: brought up to main d41cc29, so section …
Merge origin/main d41cc29 into fix/adopt-root-kept-symlinks: #435's t…