Skip to content

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

Closed

Description

@brettheap

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-tree clean against main: nothing in rows A to F. It adds pin_product_key, so row G's last line is True, and it uses it in link_pins_from_trees and _chain_link_findings for 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-tree clean 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 adds LinkSources to validate-repository-naming.py, which keys by link.casefold() again (:155, :156, :169, :172, :173, :214, :217, :219, :232 on that head). Row D shows it: the chain names two links and W says NOTE --link-source openß=<T>/trees/openß: not used — the later --link-source openss=<T>/trees/openss replaced it and NOTE --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-tree of 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 its resolve_link_source(...) call), none of them the found[name.casefold()] line or any other casefold() 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.

  1. Key every chain identity by pin_product_key (Q's lower()): the twelve calls above, resolve_link_source's keyed lookup, and the keyed table validate-repository-naming.py:406 (and W's LinkSources) builds. Then C and E are broken and F is declared-unverified, D reads as two links, and link_pins_from_trees keeps two entries. The ASCII answers do not move: over ASCII lower() and casefold() make the same groups, and Q's re-review checked that lower() only ever splits a group casefold() 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 (openXdox and openxdox are one product and one pin file, contracts/openxdox-pin.yaml).
  2. Or refuse the names instead of choosing the fold. validate-manifest.py finds a neutral_product_pins: entry or a referent_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.
  3. 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 by lower(), 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.

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

  1. added this to the v1.1.0 milestone on Oct 15, 2026
  2. brettheap commented on Oct 15, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-4, session 11c6c0ef-495a-4a05-94cf-811b7b0a1012@Eagle, 2026-10-15T23:10:59Z, for #444

    Logged in lanes/log/openRepoShape-4.md at 17f9e6c7f5a3f3099c47172cd2b0288f046f8a6f.

    The three reads (lane-collision-protocol Rule 1):

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#444:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#444"`, kept where the row or its body names `#444`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `444` as a whole token:
       none
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions