Repository navigation
update-shape.py read_pin and the pinned validate-pins.py and validate-family.py: iterate pin.get("files") or [] without asking for a list, so a shape pin with files: 5 or files: true ends shape-doctor.py, update-shape.py check and apply and the project's own validator in TypeError: ... is not iterable, exit 1, with no refusal naming the pin #290
Description
(lane openRepoShape-2, 2026-10-12T00:11:24Z)
Found by this lane's adversarial review of PR #267 at 092a5f9 (the fix for #237), its follow-up FU1. It is the same on main d94d155, where it was reproduced again today on a scaffolded project and on a family holder: #267 neither causes it nor makes it worse, and its new except Refusal cannot see a TypeError. Not claimed.
What is wrong. Every reader of contracts/shape-pin.yaml takes pin.get("files") or [] and loops over it. or [] rescues the falsy values (0, false, null, an empty list), which then reach the no files refusal or finding. A truthy scalar is not rescued: files: 5, files: true and files: 1.5 are iterated, and Python raises TypeError: 'int' object is not iterable (or 'bool', 'float'). The run ends in a traceback, exit 1, with no refusal that names the pin. shape-doctor.py prints nothing on stdout, so the operator gets no report at all, and the doctor's one job is to say what is wrong.
# $U: a checkout of openRepoShape at d94d155. gh is a PATH shim that logs its argv and exits 97 (the doctor asks it only `--version` and `auth status`). Every repository is local; no network.
python3 "$U/scaffold-project.py" --org testorg --project Atlas --elected-by 'Test Human' --elected-on 2026-09-02 \
--local-remote-dir "$R/remotes" --work-dir "$R/work"
git -c protocol.file.allow=always clone -q --recurse-submodules "$R/remotes/Atlas.git" "$C"
python3 "$U/shape-doctor.py" --root "$C" # exit 0 COMPLIANT (all four tools below are exit 0 here)
# `files:` is the last key of the pin; replace its rows with a scalar and commit
python3 - <<'PY'
p = "contracts/shape-pin.yaml"; t = open(p, encoding="utf-8").read(); i = t.index("\nfiles:\n") + 1
open(p, "w", encoding="utf-8", newline="\n").write(t[:i] + "files: 5\n")
PY
git -C "$C" commit -q -m 'files: 5' -- contracts/shape-pin.yaml
python3 "$U/shape-doctor.py" --root "$C" # exit 1, stdout empty
python3 "$U/update-shape.py" check --root "$C" --upstream "$U" # exit 1
python3 "$U/update-shape.py" apply --root "$C" --upstream "$U" # exit 1
python3 "$C/scripts/validate-pins.py" --root "$C" # exit 1
# each: TypeError: 'int' object is not iterable
# doctor / check / apply: update-shape.py line 406, in read_pin for raw in pin.get("files") or []:
# (via prepare, line 1379; the doctor's frame is shape-doctor.py line 1455, in check_shape_currency)
# validate-pins.py: line 346, in _check_shape_pin for row in files:the pin's files: |
update-shape.py check / apply |
shape-doctor.py | the root's validate-pins.py |
|---|---|---|---|
5 |
exit 1, TypeError: 'int' |
exit 1, TypeError: 'int', no report |
exit 1, TypeError: 'int' |
true |
exit 1, TypeError: 'bool' |
exit 1, TypeError: 'bool', no report |
exit 1, TypeError: 'bool' |
1.5 |
exit 1, TypeError: 'float' |
exit 1, TypeError: 'float', no report |
exit 1, TypeError: 'float' |
0, [] (control) |
exit 2, REFUSED update-pin-no-files |
exit 3, CANNOT ANSWER, a report |
exit 1, a finding, no traceback |
abc (control) |
exit 2, REFUSED update-pin-row (each character is not a mapping) |
exit 3, CANNOT ANSWER, a report |
exit 1, a finding, no traceback |
The same pin in a family holder (family.py init --org testorg --family Fam --created-by 'Test Human' --created-on 2026-09-04 --local-remote-dir "$R/remotes" --work-dir "$R/fam", then files: 5 committed) ends scripts/validate-family.py --root . in TypeError: 'int' object is not iterable at its :506 (_check_shape_pin_files, via :522, :636, :668), exit 1, and shape-doctor.py --root . and update-shape.py check in the read_pin traceback above. (Exit 3 for the controls is #237's verdict for a refused root pin, not part of this issue.)
Why. Four places read the files: key as a list without asking whether it is one.
update-shape.py:406, inread_pin(:375-418):for raw in pin.get("files") or []:.read_pinis reached fromprepare(:1379), so fromcheck,applyandshape-doctor.py'scheck_shape_currency(:1455), whose handler catchesRefusalonly (:1456).templates/assembly-root/scripts/validate-pins.py:341-346, in_check_shape_pin:files = pin.get("files") or [],if not files:,for row in files:. This is a pinned template copy (scripts/validate-pins.py,COPIED_VERBATIM,scripts/shape_materialize.py:296) that every scaffolded and adopted assembly root carries.templates/family-root/scripts/validate-family.py:501-506, in_check_shape_pin_files: the same three lines. A pinned template copy (FAMILY_COPIED_VERBATIM,:382) that every family holder carries.shape-doctor.py:2788, inpinned_paths(called at:2805fromcheck_agent_files, only when an agent file is missing):{str(row.get("path")) for row in (pin.get("files") or []) ...}. Calling it directly on thefiles: 5tree raises the sameTypeError. It is masked on a run today, becauseshape currencycrashes first; onceread_pinrefuses, a root with a scalar pin and noAGENTS.mdorCLAUDE.mdwould reach it.
A search of the non-test Python for get("files") and ["files"] finds only these. adopt-project.py:3497 checks isinstance(rows, list) and is fine.
What it should do.
read_pinrefusesupdate-pin-rowwhenfiles:is present and is not a list, naming the pin and the key (files: 5is not a list ofpath:/sha256:rows). The empty and missing cases keepupdate-pin-no-files. Whetherfiles: 0andfiles: falsemove fromupdate-pin-no-filesto the new refusal is the author's call; today they are refused, which is all this asks.validate-pins.pyandvalidate-family.pyreport a finding the way they already reportshape-pin-rowfor a row that is not a mapping (validate-pins.py:311,validate-family.py:476), exit 1, with no traceback.pinned_pathsreturns the empty set for afiles:that is not a list, as it already does for a pin that is not a mapping.
Cost, so the PR can say it. Item 2 touches two pinned template copies, and a change to either changes the bytes a fresh scaffold, adoption or family.py init records. The sha256 rows for scripts/validate-pins.py and scripts/validate-family.py in each project's contracts/shape-pin.yaml therefore move with the templates: a new project records the new digest, and every project already cut reads the file as upstream-changed in update-shape.py check until its owner runs apply. A search of tests/ for a literal 64-hex digest finds none, so no fixture hardcodes the old one. update-shape.py and shape-doctor.py are not pinned copies. If the fix puts a shared helper in scripts/repo_shape.py, that file is a pinned copy too (COPIED_FROM_SHAPE), and the same applies.
How to test. Parametrise over files: 5, files: true and files: 1.5, each committed over a clean scaffold (tests/conftest.py's project fixture):
update-shape.py checkexits 2 withREFUSED update-pin-row, noTraceback;shape-doctor.pyprints a verdict line and has noTracebackin stderr (assert the row, not the verdict, since shape-doctor.pycheck_shape_currencytakes every refusalupdate-shape.py prepareraises over the ROOT's owncontracts/shape-pin.yaml(a merge conflict, acommit:that is not 40 hex, a deleted file) for this checkout failing to ask, so the verdict is CANNOT ANSWER, exit 3, outranking INVALID, and its NEXTgit -C <standard> fetch --allblames the standard for a fault in the repository #237's verdict for a refused root pin is separate);- the project's
scripts/validate-pins.pyexits 1 with a finding for the pin and noTraceback; - the family holder's
scripts/validate-family.py(tests/test_family.py'sfamilyfixture), the same; pinned_pathson the scalar pin returnsset().
When #267 lands, add files: 5 to its ROOT_PIN_FAULTS. Reverting any one of the four guards fails its own case.
Related: #237 and PR #267 (the doctor's handling of a root pin that does not read, whose table of faults this extends); #132 (the per-row split in validate-pins.py that this belongs beside).
🤖 Generated with Claude Code
Activity
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-2, session 5b7b5f3d-dce7-452d-a33d-ab8537d91db3@Eagle, 2026-10-12T11:00:14Z, for #290
Logged in
lanes/log/openRepoShape-2.mdatb60603c40e5f433d4ce17dc190c682b464f4d69b.The three reads (lane-collision-protocol Rule 1):
update-shape.py: `read_pin`'s #290 comment says what a string or a ma…
update-shape.py: the `update-pin-row` remediation spells its `git log…
tests/test_shape_pin_files_not_a_list.py: a `files:` that is a string…
update-shape.py: `pin_rows_remediation`, `cmd_apply` and `_awaits_its…
Merge main 580d7df (with #267's decff87) into fix/pin-files-not-a-lis…
Merge #343's final head 91a4a4f into fix/validate-pins-workflow-not-u…
Merge #343's squash 437a360 (main) into fix/validate-pins-workflow-no…
Merge main 437a360 into fix/doctor-manifest-kinds-unreadable-pin: #343'…
docs/release-notes/v1.0.0.md: brought up to main 437a360, so the note…
Merge origin/main 437a360 into fix/adopt-root-kept-symlinks: #329's `…