Repository navigation
scripts/repo_shape.py: the chain readers identify a link, a declared pin and a --link-source key by casefold() (twelve calls, :1207 to :2428), where #275's fix (PR #409, 6494714) makes a product's identity pin_product_key (lower(), the pin file's own fold), so openß and openss are two products to the pin validator and one link to the chain: a holder's tree that declares openss verifies a chain that says openß, a project that pins openss verifies a chain that begins at openß, a --link-source openss= answers for the link openß, link_pins_from_trees keeps one entry for the pair, and a chain that names both is refused as a cycle #444
Description
Lane: openreposhape-5 (openRepoShape-5)
(lane openRepoShape-5, 2026-10-12T18:06:06Z)
Found by lane 5's review of fix/pin-source-env-collisions (PR #409) at 6494714 (its re-verify, under "The chain readers"), which noted that link_pins_from_trees stores found[name.casefold()] and _chain_link_findings reads available.get(holder.casefold()), "so two hand-written non-ASCII links such as openß and openss share one entry, and one holder's declarations can be read from the other's tree", and that "it needs a hand-written non-ASCII chain". It is pre-existing: the reproduction below gives the same answers on origin/main cd1fc79 as on a merge of 6494714 with it, and on a merge of lane 5's fix/link-source-values (4b21263, #338, not yet a PR) with it. The review's route does not reach a false pass by itself, and this is said before the rest: two links that share the entry cannot both be in one chain that gets as far as _chain_link_findings, because _chain_shape_problem runs first, folds with the same casefold() and refuses a chain that names both as "it repeats a link" (row D below). The shared entry is real (row G) and masked. What does reach a verdict is the same identity choice made at three other places, rows C, E and F. Not claimed.
What is wrong. The chain readers of scripts/repo_shape.py ask "is this the same repository?" with str.casefold(). #275's fix asks it of a pin with lower(), on purpose: its pin_product_key is "what makes two declarations ONE product: the name its pin file carries, contracts/<product lowercased>-pin.yaml", and its docstring says openß and openss "are two pin files that a casefold took for one product". The two folds agree over ASCII and part company outside it (casefold("openß") == "openss", lower("openß") == "openß"; likewise ſ/s, fi/fi, ς/σ). So for a hand-written manifest or chain that holds a non-ASCII name, the repository gives two answers to one question:
| where | the question | its fold |
|---|---|---|
validate-pins.py, pin_source_env_collisions, link_pins_from_trees' shared-variable guard, _chain_link_findings' alone test (on 6494714) |
are two declarations one product? | lower(): two |
_pinned_basenames :1207, _referent_in :1224, _link_declarations :1236, _chain_link_findings :1256 and :1267 (twice), resolve_referent :1574, _chain_shape_problem :1602, :1605, :1609, resolve_link_source :2391, link_pins_from_trees :2428, and validate-repository-naming.py:406 |
is this the chain's link, or the holder's declared pin? | casefold(): one |
On 6494714 one function, link_pins_from_trees, uses both: pin_product_key(name) in shared for the variable and found[name.casefold()] = pins for the table.
# usage: python3 chain-fold.py "$U" ($U: a checkout of openRepoShape)
# Local files only: no network, no `gh`. Names are written as escapes so this file stays ASCII.
import os, subprocess, sys, tempfile
from pathlib import Path
U = Path(sys.argv[1]).resolve()
SHARP, SS = "open\u00df", "openss" # "open\u00df" is open + the German sharp s; casefold() turns it into "openss"
X, INK = "openXdox", "openInk"
print(f"casefold({SHARP}) == {SS}: {SHARP.casefold() == SS}; lower({SHARP}) == {SS}: {SHARP.lower() == SS}")
def case(label, held, chain, declares, sources):
"""`declares`: {link: the pins its tree declares}; `sources`: the `--link-source NAME=` keys given, each pointing at the
tree named by the value, so a source may be filed under one name and hold another link's tree."""
with tempfile.TemporaryDirectory(prefix="chain-fold-") as t:
base = Path(t)
for name, pins in declares.items():
d = base / "trees" / name
d.mkdir(parents=True)
(d / "project.yaml").write_bytes(("neutral_product_pins:\n" + "".join(f" - {p}\n" for p in pins)).encode())
proj = base / "work" / "codexInk"
proj.mkdir(parents=True)
(proj / "project.yaml").write_bytes((
"neutral_product_pins:\n" + "".join(f" - {p}\n" for p in held) +
"legs:\n - repository: testorg/codexInk\n path: \".\"\n role: assembly\n naming:\n"
" referent_chain: [" + ", ".join(chain) + "]\n").encode())
args = [sys.executable, "-I", str(U / "scripts" / "validate-repository-naming.py"), "--explain", "--project", "project.yaml"]
for key, tree_of in sources:
args += ["--link-source", f"{key}={base / 'trees' / tree_of}"]
env = {k: v for k, v in os.environ.items() if not k.startswith("SHAPE_PIN_SOURCE_")}
p = subprocess.run(args, cwd=proj, capture_output=True, env={**env, "PYTHONIOENCODING": "utf-8"})
out = (p.stdout + p.stderr).decode("utf-8")
print(f"{label}\n exit {p.returncode}")
for line in out.splitlines():
if line.strip().startswith(("CHAIN", "FINDING", "WARNING", "--link-source")) or "link-source" in line:
print(" ", line.strip().replace(str(base), "<T>")[:230])
case("A (control, ASCII): openXdox declares openYdox, which declares openInk",
[X], [X, "openYdox", INK], {X: ["openYdox"], "openYdox": [INK]}, [(X, X), ("openYdox", "openYdox")])
case(f"B (control): the chain names {SHARP}, and each tree says so",
[X], [X, SHARP, INK], {X: [SHARP], SHARP: [INK]}, [(X, X), (SHARP, SHARP)])
case(f"C. the chain names {SHARP}; openXdox's tree declares {SS} instead",
[X], [X, SHARP, INK], {X: [SS], SHARP: [INK]}, [(X, X), (SHARP, SHARP)])
case(f"D. the chain names both {SHARP} and {SS}, each with a tree of its own",
[X], [X, SHARP, SS, INK], {X: [SHARP], SHARP: [SS], SS: [INK]}, [(X, X), (SHARP, SHARP), (SS, SS)])
case(f"E. the project pins {SS} and the chain begins at {SHARP}",
[SS], [SHARP, INK], {SHARP: [INK]}, [(SHARP, SHARP)])
case(f"F. no tree is given for {SHARP}; one is given as `--link-source {SS}=`",
[X], [X, SHARP, INK], {X: [SHARP], SS: [INK]}, [(X, X), (SS, SS)])
sys.path.insert(0, str(U / "scripts"))
import repo_shape # noqa: E402
with tempfile.TemporaryDirectory(prefix="chain-fold-") as t:
work = Path(t)
(work / "codexInk").mkdir()
for name, pin in ((SHARP, "openAAA"), (SS, "openBBB")):
(work / name).mkdir()
(work / name / "project.yaml").write_bytes(f"neutral_product_pins:\n - {pin}\n".encode())
found = repo_shape.link_pins_from_trees([SHARP, SS], work / "codexInk") # a sibling checkout for each
print(f"G. link_pins_from_trees([{SHARP}, {SS}]) with a checkout of each beside the project")
print(" table:", {k.encode("unicode_escape").decode(): sorted(v) for k, v in found.items()})
key = getattr(repo_shape, "pin_product_key", None)
print(" pin_product_key tells the two apart:", None if key is None else key(SHARP) != key(SS))Observed on origin/main cd1fc79 (Linux, Python 3.12.3; <T> is the temporary directory), and unchanged on 45e01f4 (an added tests/test_agents_md_codes.py) and on 3ef059c (#314's scaffold-project.py and adopt-project.py changes, which this script does not reach):
casefold(openß) == openss: True; lower(openß) == openss: False
A (control, ASCII): openXdox declares openYdox, which declares openInk
exit 0
CHAIN openXdox → openYdox → openInk [verified]
B (control): the chain names openß, and each tree says so
exit 0
CHAIN openXdox → openß → openInk [verified]
C. the chain names openß; openXdox's tree declares openss instead
exit 0
CHAIN openXdox → openß → openInk [verified]
D. the chain names both openß and openss, each with a tree of its own
exit 1
CHAIN openXdox → openß → openss → openInk [broken]
FINDING codexInk: the declared chain openXdox → openß → openss → openInk is broken (it repeats a link, so it is a cycle rather than a chain)
E. the project pins openss and the chain begins at openß
exit 0
CHAIN openß → openInk [verified]
F. no tree is given for openß; one is given as `--link-source openss=`
exit 0
CHAIN openXdox → openß → openInk [verified]
G. link_pins_from_trees([openß, openss]) with a checkout of each beside the project
table: {'openss': ['openBBB']}
pin_product_key tells the two apart: None
(None on the last line: main has no pin_product_key.) The same script on the other trees, each a throwaway merge with origin/main cd1fc79 built with git merge-tree --write-tree and commit-tree, never pushed. The last column is measured, not predicted: the thirteen casefold() sites named above (twelve in scripts/repo_shape.py, one at validate-repository-naming.py:406) changed to pin_product_key in a throwaway copy of Q's merge, nothing committed:
| row | main cd1fc79 | main + 6494714 (Q) | main + 4b21263 (W) | Q's merge with those calls keyed by pin_product_key |
|---|---|---|---|---|
C: holder declares openss, chain says openß |
[verified] |
[verified] |
[verified] |
[broken]: "openXdox declares openss, not openß" |
E: project pins openss, chain begins at openß |
[verified] |
[verified] |
[verified] |
[broken]: "it begins at openß, which is not in this project's neutral_product_pins:" |
F: --link-source openss= for the link openß |
[verified] |
[verified] |
[verified], and no note: W counts the key as used |
[declared-unverified], a WARNING that names openß's own tree as not reachable, exit 0 |
| D: both names in one chain | [broken], "repeats a link" |
the same | the same, plus three NOTE --link-source lines (below) |
two links, each read from its own tree: [verified], exit 0 |
| G: the table | one entry, openss: the last tree wins |
the same; pin_product_key tells them apart (True) |
the same | two entries, open\xdf and openss |
On that patched tree tests/test_naming_policy.py, tests/test_scaffold_referent_chain_links.py and tests/test_manifest_validator.py pass (245 passed in 28 s, stub gh, identity blanked), so no existing test pins the casefold identity of a non-ASCII name.
What Q and W change about the reproduction:
- Q (6494714),
git merge-treeclean against main: nothing in rows A to F. It addspin_product_key, so row G's last line isTrue, and it uses it inlink_pins_from_treesand_chain_link_findingsfor the shared-variable guard only (pin_product_key(name) in shared,pin_product_key(holder) not in shared). That is why this fix depends on Q: the function the fix would key by is Q's. - W (4b21263),
git merge-treeclean against main: nothing in A, B, C, E, F, G. It gates every read on the chain's shape (NamingPolicy.reads_link_declarations), so a chain that repeats a link reads no tree at all, and it addsLinkSourcestovalidate-repository-naming.py, which keys bylink.casefold()again (:155,:156,:169,:172,:173,:214,:217,:219,:232on that head). Row D shows it: the chain names two links and W saysNOTE --link-source openß=<T>/trees/openß: not used — the later --link-source openss=<T>/trees/openss replaced itandNOTE --link-source openss=<T>/trees/openss: not used — no name classified here reads openß's own declaration, which is the one entry for the pair showing up in W's own bookkeeping. The fix for this issue has to change those sites too, so it lands after W or with it. - Q + W,
git merge-treeof the two branch heads: one file,scripts/repo_shape.py, four conflicting hunks (resolve_link_source's signature and its docstring,link_pins_from_trees' docstring and itsresolve_link_source(...)call), none of them thefound[name.casefold()]line or any othercasefold()call. Both branches edit the functions this fix edits, so the fix is written after both land.
Why. Rows C and E are false passes for a hand-written manifest, by the standard it set itself: a chain is "verified" only when each holder's own tree declares the next link, and the declaration the tree makes (openss) is a different pin file from the link the chain names (openß). Row F reads one product's tree as the answer for another, the thing #275 refused for the variable (SHAPE_PIN_SOURCE_OPENSS is shared by openß and openss, so it is read for neither), and the same thing a keyed --link-source still does. A declaration is not content-addressed the way a pin's digest is (the review's reason this is worse than the pin side, where a wrong source either lacks the pinned commit or holds the same bytes).
Reachability, so the decision is made with it in view. Only a hand-written manifest or chain: scaffold-project.py's --pin takes [A-Za-z0-9_.-] (PIN_ARG_RE, scaffold-project.py:86), adopt-project.py's pins the same (PIN_NAME_RE, :160), the naming policy's neutral form is ^open(?:[A-Z]|x[A-Z])[A-Za-z0-9]*$, and no GitHub repository name holds ß. Nothing a tool writes reaches it. It is the same reach as the review's other non-ASCII findings (#275's N2), and no more.
What it should do. The author decides; the one thing that cannot stand is two folds for one question.
- Key every chain identity by
pin_product_key(Q'slower()): the twelve calls above,resolve_link_source's keyed lookup, and the keyed tablevalidate-repository-naming.py:406(and W'sLinkSources) builds. Then C and E are broken and F isdeclared-unverified, D reads as two links, andlink_pins_from_treeskeeps two entries. The ASCII answers do not move: over ASCIIlower()andcasefold()make the same groups, and Q's re-review checked thatlower()only ever splits a groupcasefold()made (casefold(lower(c)) == casefold(c)for every code point, 0 exceptions). A chain whose link differs from its holder's declaration by ASCII case alone stays verified, as now (openXdoxandopenxdoxare one product and one pin file,contracts/openxdox-pin.yaml). - Or refuse the names instead of choosing the fold.
validate-manifest.pyfinds aneutral_product_pins:entry or areferent_chain:link that is not[A-Za-z0-9_.-]+(the set every tool in the repository writes, and a superset of what GitHub allows), so a non-ASCII name never reaches a reader. That removes the class for chains, pins and sources together, and moves the error to the project's own gate. - Or say it. State in the module docstring and the chain section of AGENTS.md that chain links are identified by
casefold()and pin files bylower(), and that the two agree over ASCII only. Cheapest and leaves rows C, E and F as they are; not what the review would accept for the pin side, which is why Q made the change.
How to test. In tests/test_naming_policy.py, beside the chain tests (a tmp_path project, the names written as \u00df escapes so the file is ASCII, trees written as bytes, no git and no gh), parametrize the table above by the chosen rule: each of C, E and F asserts the chain is not verified (and F's openß is declared-unverified), D asserts two links and [verified] under option 1 (or the finding under option 2), and A and B stay verified. An in-process test of link_pins_from_trees over [openß, openss] with a checkout beside the project for each asserts two entries (the table in row G). A control row holds that openXdox and openxdox keep their answers. Run it from a directory of its own, as the neighbouring tests do, so no checkout beside it answers. Reverting the fix fails C, E, F and G.
Not in this issue.
- validate-pins.py: a
--pin-sourcevalue isPRODUCT=PATHonly when the text before its first=names a declared neutral-product pin, so a bare stand-in undera=b/recomputes the digest instead of falling togh apiand askip, an explicit source that does not exist or is not a directory is theneutral-pin-source-unreadablefinding instead of aFileNotFoundErrortraceback, and a value no pin used is noted (#295) #312'sPinSourcesintemplates/assembly-root/scripts/validate-pins.py(:391,:405,:408,:409,:454) keys--pin-source PRODUCT=PATHbycasefold():--pin-source openß=/src/A --pin-source openss=/src/Bgives both pins/src/B, andopenß=/src/Ais "replaced" (the review's RV-N1, run in-process). It is the same fold choice, so the decision above settles it, but it is a different file, a different reader, and a pin's digest (which a wrong source cannot pass), so it is its own change and scaffold-project.py, validate-pins.py, repo_shape.py:open-glassandopen_glassshare oneSHAPE_PIN_SOURCE_<PRODUCT>, so--pinrefuses the pair andvalidate-pins.pynotes a declared one and reads the variable for neither (#275) #409's note's advice ("the keyed--pin-source <product>=<path>") is what it qualifies. pin_source_env_name'supper()(ßbecomesSS): that is what makes the two shareSHAPE_PIN_SOURCE_OPENSS, which scripts/repo_shape.py:pin_source_env_nameturns every character outside[A-Z0-9_]into_, so the neutral productsopen-glass,open_glassandopen.glassshare oneSHAPE_PIN_SOURCE_OPEN_GLASS, and a project that pins two of them gets aneutral-pin-source-unreadableFINDING (exit 1) for whichever one the variable does not name #275 reports and scaffold-project.py, validate-pins.py, repo_shape.py:open-glassandopen_glassshare oneSHAPE_PIN_SOURCE_<PRODUCT>, so--pinrefuses the pair andvalidate-pins.pynotes a declared one and reads the variable for neither (#275) #409 guards.validate-family.py'scasefold()of mount paths (:598), a different question (the same path in any case) with its own rationale.- Whether
ßandssshould be one product. The standard says what a product is by its pin file's name (scaffold-project.py, validate-pins.py, repo_shape.py:open-glassandopen_glassshare oneSHAPE_PIN_SOURCE_<PRODUCT>, so--pinrefuses the pair andvalidate-pins.pynotes a declared one and reads the variable for neither (#275) #409); this issue asks only that the chain readers say the same.
Related. #275 and PR #409 (pin_product_key; the review's N2 and RV-N1). #338 and fix/link-source-values (4b21263: LinkSources, reads_link_declarations, the NOTE --link-source lines). #312 (PinSources). #135 (the split of resolve_referent that made these helpers). #441 and #442, the other follow-ups from the review of fix/link-source-values (a bare --link-source in a chain of three or more links; --link-source openXdox= with an empty path): both edit resolve_link_source or the --link-source table at validate-repository-naming.py:406 that this issue also changes, so the three are written one after another.
Activity
brettheap commentedon Oct 15, 2026
CLAIMED — lane openRepoShape-4, session 11c6c0ef-495a-4a05-94cf-811b7b0a1012@Eagle, 2026-10-15T23:10:59Z, for #444
Logged in
lanes/log/openRepoShape-4.mdat17f9e6c7f5a3f3099c47172cd2b0288f046f8a6f.The three reads (lane-collision-protocol Rule 1):
docs/release-notes/v1.1.0.md: refreshed at main 4cf5fb0 (49 pull requ…
chain-link fold: #638 fix round 1: a chain that ends at another pin f…
validate-repository-naming.py, validate-manifest.py and validate-pins…
repo_shape.py and validate-repository-naming.py: a chain link, a decl…
Merge main a524542 (#638, #635, #640) into fix/610-pinned-text: remed…
Merge main 95780dd (#638, #635, #640, #623, #637) into fix/610-pinned…
Pinned shape copies: one bump, so AGENTS-shape.md is true of every ro…
validate-pins.py: a `--pin-source` key is the longest declared pin be…