Skip to content

validate-pins.py: a --pin-source key is the longest declared pin before an = that leaves a path, as a --link-source key is since #980, so open=y=<path> verifies the pin open=y and open=y= is refused as naming no path (#1053) - #1067

Merged
brettheap merged 3 commits into
mainfrom
fix/1053-pin-source-key
Oct 20, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-5 (openRepoShape-5)
Claim: #1053 (comment)

Found by lane 5's independent review of #1044 (#980), rv980, and filed as #1053. Two commits on origin/main 6476c170 (made on 8a057215; the rebase was clean and docs/cli.md needed no regeneration): 2e6dd9e5, the change, and 7ff61666, fix round 1 (rv1067's SHOULD-1: one more test row, no code). Five files change (+396, -52): templates/assembly-root/scripts/validate-pins.py, scripts/validate-repository-naming.py, docs/cli.md, tests/test_neutral_pin_validator.py and tests/test_link_source_chain_defaults.py.

Both scripts are pinned copies (COPIED_VERBATIM and COPIED_FROM_SHAPE), so every existing root takes two upstream-changed rows, scripts/validate-pins.py and scripts/validate-repository-naming.py, and re-pins. No digest of either file is kept anywhere in tests/: the texts that pin the help and the docstrings are held by test_cli_reference.py (regenerated) and test_pinned_copy_text.py (runtime), and both pass.

The Goal

validate-pins.py reads a --pin-source key as the naming gate has read a --link-source key since #980 (the longest declared pin before an = that leaves a path after it), so a pin whose name holds = can be a key, and the sentence saying the two copied tools read [PRODUCT=]PATH one way is true.

This is consistency, not a live failure. No scaffold writes a pin holding = (PIN_ARG_RE admits [A-Za-z0-9_.-]), only a hand-edited project.yaml declares one, and a pin's digest stops a wrong source from passing: a wrong tree is a finding and never a pass.

What was wrong

PinSources.__init__ cut every value at its FIRST = (typed.partition("=")), and two more places cut at the first = on their own: the no-path refusal and read_as_path.

What changed

The rows:

  • a declared pin open=y verified by --pin-source open=y=<tree>, as declared and in capitals;
  • open and open=y both declared, each given its own tree, in either order, and with a path that begins like the longer pin (open=./y=dir): both verify, neither replaces the other;
  • open=y= with open declared too is open's, with the path y=, never refused;
  • open=y= and OPEN=Y= with only open=y declared are refused pin-source-no-path, exit 2, in openGlass='s words;
  • a value naming no declared pin (a leading blank, an owner prefix, open=z=x) is a bare PATH, whole, and so is one that ENDS in an = no declared pin names (open=z=, rv1067's SHOULD-1), never refused and never read as a pin's;
  • the note for a bare value names the text before its first =;
  • a keyed OPEN=Y=<tree> names the pin as the manifest spells it, open=y, where the digest was never recomputed;
  • the old split's corners, openGlass==x and openGlass= , are still openGlass's.

The probe, before and after

PinSources(values, declared), read at 8a057215 and at the head (probe-base.log, probe-head.log):

declared [open=y], value open=y=/p
  at 8a057215   reading {'open=y=/p': None}  (a bare PATH)
  with this     reading {'open=y=/p': 'open=y'}, keyed to open=y with the path /p
declared [open, open=y], values open=y=/p then open=/q
  at 8a057215   both 'open'; keyed {'open': ('open=/q', '/q')}, open=y read nothing
  with this     {'open=y=/p': 'open=y', 'open=/q': 'open'}, each pin its own
declared [open=y], value open=y=
  at 8a057215   a bare PATH, no refusal
  with this     REFUSED pin-source-no-path
controls, the same at both: openGlass= REFUSED pin-source-no-path; openss= with a declared
  open + sharp s is a bare PATH; ' open=y=x', 'opensoft/open=y=x' and 'open=z=x' with open=y
  declared are bare PATHs, whole.

The tests that fail at main

The final test files copied onto a clone of 8a057215, the 17 new rows: 11 failed, 6 passed. Each failure is on the defect (the source read as a bare PATH, or open=y read none). The 6 that pass pin readings that did not change, by design: the four bare-PATH rows (a leading blank, an owner prefix, no declared pin, and a value that ends at no declared pin) and the two old-split corners.

Unchanged where no declared pin holds =

The base and the head copies of validate-pins.py were run with --root, over the same root and directory, comparing stdout, stderr and exit code: 7 pin lists, none holding = (one pin, two, none, capitals, a sharp s, one variable, a scalar), and 40 values singly plus 182 ordered pairs of 14 values (capitals, a leading blank, an owner prefix, ./openGlass=x/openGlass, the #295 rows' values). 1554 runs, 0 differing; the head's exit codes were 0 (502), 1 (1037) and 2 (15). The rebase did not touch validate-pins.py.

Mutants

Each applied alone to a scratch tree's copy of the change, run against the three files that exercise PinSources (test_neutral_pin_validator.py, test_link_source_chain_defaults.py, test_validate_pins_named_and_spelled.py), through run9.sh. 17 of 18 killed; the 18th is equivalent. The counts are the passes and fails of 408 tests (the files as of 2e6dd9e5); R5, and M1, M2 and M16a rerun on the final files (409 tests), are noted under the table.

mutant result failed / passed killed by (a few)
M1 back to typed.partition("=") killed 11 / 397 the own-key, shorter-pin and both-declared rows
M2 the shortest match instead of the longest killed 5 / 403 the shorter-pin and both-declared rows
M3 the "leaves a path after it" check dropped killed 1 / 407 the shorter-pin row
M4 the key stripped killed 1 / 407 the bare-PATH row (a leading blank)
M5 the key not folded by pin_product_key killed 21 / 387 the capitals rows, the #295 rows
M6 the no-path refusal back to its own first-= cut killed 2 / 406 the two open=y= refusal rows
M7 read_as_path back to typed.partition("=")[0] survived 0 / 408 none: equivalent, below
M8 before the longest text tried, not the shortest killed 8 / 400 the note row, the corners row
M9 the empty-path fallback dropped killed 10 / 398 the refusal rows
M10 the fallback keyed at the first = killed 2 / 406 the two open=y= refusal rows
M11 the key cut at its last / killed 2 / 406 the owner-prefix row, the directory row
M12 the keyed path read after the first = killed 8 / 400 the both-declared and own-key rows
M13 the scan skips an adjacent = killed 1 / 407 the openGlass==x corner
M14 a blank path names none killed 1 / 407 the openGlass= corner
M15 the no-path refusal strips the path killed 1 / 407 the openGlass= corner
M16a the keyed pin spelled as typed killed 2 / 406 the manifest-spelling row
M16b the no-path pin spelled as typed killed 2 / 406 the capitals refusal rows
R5 (rv1067's) the if product is not None: guard dropped from the empty-path fallback killed, after the fix; it survived all 408 tests at 2e6dd9e5 1 / 408 the bare-PATH row ends-at-no-declared-pin

R5 and the fix round. rv1067's mutant R5 returns product, typed[:-1], "" for every value ending in =, so --pin-source open=z= with open=y declared (or x=, ../build= with openGlass) ended in an AttributeError traceback in _shown(None). It survived because no row held a bare PATH ending in =. 7ff61666 adds the fourth value open=z= (id ends-at-no-declared-pin) to test_a_value_naming_no_declared_pin_is_a_bare_path_whole: it passes at 8a057215 and at the head, by design, and fails under R5 (1 failed, 408 passed). Rerun on the final files (409 tests): M1 killed 11 / 398, M2 killed 5 / 404, M16a killed 2 / 407. The code is unchanged by the round.

M7 is equivalent. read_as_path says anything only for a value no declared pin is keyed to, and for such a value the scan's last turn is at the value's first =, so _read_source's before IS the text before the first =: the two spellings print the same words for every value. Not a gap.

Runs

At the committed head 2e6dd9e5 (the 88-file shards below; 7ff61666 changes one parameter of one test, run next), through run9.sh (gh stub first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null, no LANES_* variable):

  • git 2.43.0 (/usr/bin/git): the 88 test files that name validate-pins or validate-repository-naming, which include the three mutant files, test_chain_remedy_callers.py, test_pinned_copy_text.py and the trio (test_repo_hygiene.py, test_agents_md_codes.py, test_cli_reference.py), in four shards: 1376 passed, 4 skipped; 1702 passed; 1963 passed, 2 skipped; 1812 passed, 6 skipped. 6853 passed, 12 skipped, 0 failed.
  • git 2.47.1 (exactly $Q/v115/w764/git247/bin/git, first on PATH): the three mutant files: 408 passed.
  • At 7ff61666: the three mutant files, 409 passed on git 2.43.0 and 409 passed on git 2.47.1.
  • The rest of the suite is left to CI's tests job.

The upgrade rows

From a root scaffolded at 8a057215 (scaffold-project.py with local remotes, cloned with submodules), update-shape.py at the head:

  • check --at 2e6dd9e5 lists upstream-changed for scripts/validate-pins.py and scripts/validate-repository-naming.py (upstream-changed 2, unchanged 9);
  • apply --yes --branch shape/update-2e6dd9e56eb8 exits 0, copies both files, re-pins 8a0572157723 -> 2e6dd9e56eb8, and the root's own validators print pins ok and manifest ok: Atlas (atlas), 3 legs;
  • a fresh check prints unchanged 11 and nothing to do; the root's two copies are byte for byte the head's (sha256 adc1f7d8... and fc5c9f81...).

Merge checks

git merge-tree --write-tree of the head 7ff61666 against: origin/main 9cbba9d5 clean (tree d776b7d7), and the one open PR that still touches one of these files, #1062 (fix/1040-windows-start-errors, 957ba038, which edits validate-pins.py around _git_runs, not the PinSources region) clean (tree aa6fc31b). #1063 (d996ebf1, docs/cli.md) is no longer in the open list. At 2e6dd9e5 the same three were clean against 00f6d7f7, #1062 01a8b759 and #1063. Since the rebase main has changed docs/cli.md (8 lines added, 2 removed, outside the --pin-source block) and none of the other four files, and the merge-tree against it is clean, so the branch needs no rebase.

Closes #1053

🤖 Generated with Claude Code

Summary by Sourcery

Make pin-source resolution consistently support declared pin names containing = while preserving existing path handling.

Bug Fixes:

  • Make --pin-source correctly recognize declared pin names containing = and reject values that name no path.

Enhancements:

  • Align pin-source parsing with link-source parsing by selecting the longest declared key that leaves a path.
  • Clarify pin-source behavior in CLI help and validator documentation.

Documentation:

  • Update the generated CLI reference and validator documentation for pin-source keys containing =.

Tests:

  • Add coverage for equals-containing pin names, overlapping keys, empty paths, bare paths, spelling normalization, and legacy parsing cases.

…fore an `=` that leaves a path after it, as a `--link-source` key has been since #980, so `open=y=<path>` verifies a pin `open=y`, `open=y=` is refused as naming no path, and the two copied tools read `[PRODUCT=]PATH` one way

`PinSources` cut every `--pin-source` value at its first `=`. A declared pin whose name holds `=` was never a key: with `open=y` declared, `open=y=<tree>`, typed as `openGlass=<tree>` is, was a bare PATH that is not there, the `neutral-pin-source-unreadable` FINDING; with `open` and `open=y` both declared it was `open`'s, with the path `y=<tree>`, so `open=y` read neither value; and `open=y=` was a bare PATH where `openGlass=` is refused. The class docstring and `validate-repository-naming.py`'s `LinkSources` docstring both said the two copied tools read `[PRODUCT=]PATH` one way, and the gate's still said this file keys by `casefold()`, false since #712.

`PinSources._read_source` now reads a key as the naming gate's `LinkSources._read_source` does: the LAST `=` whose text names a declared pin by `pin_product_key` and leaves a non-empty path after it ends the key, read whole (never stripped, never cut at a `/`) and spelled as `project.yaml` spells it. A value that ends at a declared pin with no shorter one leaving a path is that pin's with an empty path, refused `pin-source-no-path`, exit 2, in `openGlass=`'s words. Anything else is a bare PATH, whole. `__init__`, the no-path refusal and `read_as_path` all take the key and the path from it, so nothing cuts at the first `=` on its own; `read_as_path` keeps its words (the text before the first `=`, the shortest text the scan tried). Where no declared pin holds `=`, every value reads as before: a text holding `=` can only name a pin that holds one.

Docs: the `PinSources` docstring (THE READING, the no-path paragraph, and the one-way paragraph, which now holds with no exception), the `--pin-source` help and `docs/cli.md` regenerated from it, and the gate's `LinkSources` docstring, whose "still ends a key at the first `=`", "OVER ASCII ONLY" and `casefold()` sentences go. Both scripts are pinned copies, so an existing root takes two `upstream-changed` rows.

Tests: 16 new rows, 13 in `test_neutral_pin_validator.py` beside the #295 rows and 3 in `test_link_source_chain_defaults.py` beside the #442 rows. Each hand-writes the `neutral_product_pins:` entry and its pin file, since no scaffold writes a pin holding `=`. 11 of the 16 fail on the base `8a057215`, each on the defect; the other 5 pin readings that did not change. With no declared pin holding `=`, 1554 runs of the base and the head over a table of values and pin lists printed the same stdout, stderr and exit code in every one.

Closes #1053

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 1 day and 2 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates pin-source parsing to consistently select the longest declared pin key that leaves a path, enabling pins containing = while preserving bare-path and no-path behavior; documentation and copied-validator guidance are updated, with comprehensive regression tests for overlapping keys, spelling, and edge cases.

Sequence diagram for longest declared pin-source resolution

sequenceDiagram
    participant CLI
    participant PinSources
    participant Manifest
    participant Filesystem
    CLI->>PinSources: __init__(values, declared)
    PinSources->>Manifest: pin_product_key(before)
    Manifest-->>PinSources: declared pin spelling
    PinSources->>PinSources: _read_source(typed)
    alt longest declared key leaves a path
        PinSources->>Filesystem: read_as_path(path)
        Filesystem-->>PinSources: source tree
        PinSources-->>CLI: verify keyed pin
    else value ends at declared pin
        PinSources-->>CLI: Refusal pin-source-no-path
    else no declared key matches
        PinSources-->>CLI: use bare PATH
    end
Loading

Flow diagram for pin-source key resolution

flowchart TD
    A["--pin-source value"] --> B["Scan = separators from right to left"]
    B --> C{"Declared pin key leaves a path?"}
    C -->|Yes| D["Use longest matching key"]
    D --> E["Read remaining text as path"]
    C -->|No| F{"Value ends at a declared pin?"}
    F -->|Yes| G["Refuse pin-source-no-path"]
    F -->|No| H["Treat whole value as bare PATH"]
Loading

File-Level Changes

Change Details Files
Align --pin-source parsing with --link-source by resolving the longest declared pin name before an = that leaves a non-empty path, including pin names containing =.
  • Added PinSources._read_source to scan candidate separators from right to left using pin_product_key, preserving manifest spelling and bare-path behavior.
  • Updated initialization, no-path refusal, and path-note generation to use the shared parser.
  • Preserved empty-path refusal while allowing a shorter declared pin to claim a remaining path, such as open=y= resolving to open when both pins exist.
templates/assembly-root/scripts/validate-pins.py
Refresh user-facing documentation and copied-validator guidance to describe the unified longest-key parsing semantics.
  • Updated --pin-source help text and regenerated CLI documentation.
  • Revised the naming validator docstring to remove obsolete first-= and case-folding claims and reference the shared semantics.
docs/cli.md
scripts/validate-repository-naming.py
Add regression coverage for pins containing = and parser edge cases.
  • Added hand-declared pin fixtures and tests for exact and case-insensitive keyed sources, overlapping pins, path-like values, empty paths, bare paths, manifest spelling, and legacy split corners.
  • Refactored shared no-path stderr construction while retaining existing behavior checks.
tests/test_neutral_pin_validator.py
tests/test_link_source_chain_defaults.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1053 Parse each --pin-source using the longest declared pin name before an = that leaves a non-empty path, while treating a value ending at a declared pin as a no-path refusal when no shorter declared pin leaves a path. ✅
#1053 Use the same parsed reading for source classification, no-path refusal, and read_as_path, preserving the existing behavior for values where no declared pin name contains =. ✅
#1053 Update the copied tools’ help and documentation to describe their consistent reading, and correct the naming-gate docstring’s stale claims about first-= parsing and casefold(). ✅

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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-20T17:20Z) @codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T17:25:13.129760Z 2e6dd9e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 2e6dd9e56e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@brettheap

Copy link
Copy Markdown
Contributor Author

Independent review of #1067 (#1053) at 2e6dd9e5, for lane openRepoShape-5: READY

There is no MUST. There is one SHOULD: a mutant of mine survives, and one more parameter row would kill it. There is one NIT. Every number in the body that I re-measured came out the same.

Findings

SHOULD-1. No row holds a bare PATH that ends in =, so my mutant R5 survives.

  • Where: templates/assembly-root/scripts/validate-pins.py:1600-1603 (the empty-path fallback in _read_source) and tests/test_neutral_pin_validator.py:654-656.
  • The mutant: R5 drops the if product is not None: guard from the fallback, so it reads return product, typed[:-1], "" for every value that ends in =.
  • Result: 408 passed, 0 failed ($Q/v118/rv1067/mut-R5.log).
  • The failure it hides: under R5, --pin-source x=, --pin-source ../build= (with openGlass declared) and --pin-source open=z= (with open=y declared) each end in a traceback, AttributeError: 'NoneType' object has no attribute 'isprintable' in _shown(None) at __init__:1548. At the head, each is the bare PATH x=, ../build= or open=z=, which I probed.
  • Why it matters: the issue says "Any other value is a bare PATH, whole". The three bare-PATH rows (" open=y=x", "opensoft/open=y=x", "open=z=x") do not end in =, and nothing else in the three files passes a bare value that does.
  • The fix: add a fourth parameter, for example "open=z=" with id ends-at-no-declared-pin, to test_a_value_naming_no_declared_pin_is_a_bare_path_whole. The row's standin_at(product, tmp_path / typed) already makes that directory. The row would pass at base and at head, by design, and fail under R5.
  • The code at the head is right: my probes (below) cover such values with 0 differences. This is a gap in the tests only.

NIT-1. The two readers scan alike, but their name tables still differ. The new "THE TWO COPIED TOOLS READ [PRODUCT=]PATH ONE WAY" paragraph (validate-pins.py:1508-1514) and the gate's LinkSources docstring (validate-repository-naming.py:266-271) do not say so.

  • Line by line, PinSources._read_source is LinkSources._read_source plus a before value:
    • the same rfind loop;
    • the same pin_product_key lookup;
    • the same non-empty-path test;
    • the same endswith("=") fallback;
    • the same (None, typed) for a bare PATH.
  • Measured in process over 1,610 (names, value) pairs: they part on 33. Every one comes from a name table, not from the scan:
    • 15 pairs: two declared spellings fold to one key (openGlass, OPENGLASS). The pins table keeps the last spelling, because its dict comprehension is unchanged since before this PR. The gate keeps the first.
    • 18 pairs: names holding /. The gate's names are repo_basenames, so a declared opensoft/openGlass or openGlass/ is a key in validate-pins.py and not in the gate.
  • On every name list both tools build alike, including lists that hold =, they part on 0.
  • Both differences existed before this PR, both need a hand-edited manifest, and neither changes which key or path a value reads for names the two tables share. No change is needed. A clause or a follow-up is optional.

Checked and true

  1. The reader matches the issue.

    • It takes the longest declared pin before an = that leaves a non-empty path, reads the key whole and spells the pin as project.yaml does.
    • open=y= with only open=y declared is refused pin-source-no-path, exit 2, in openGlass='s words.
    • Everything else is a bare PATH, whole.
    • __init__, the no-path refusal and read_as_path all use _read_source.
    • I reproduced the body's before/after probe exactly at 8a057215 and at the head: {'open=y=/p': None} became 'open=y'; the both-declared case became each pin with its own source; open=y= went from a bare PATH to refused; the controls (openGlass= refused, openss= with openß declared a bare PATH, the blank, owner and open=z=x values bare) are the same at both.
  2. Nothing changes where no declared pin holds =.

  3. The 16 new rows (13 in test_neutral_pin_validator.py, 3 in test_link_source_chain_defaults.py).

    • With the head's two test files copied onto a worktree at 8a057215: 11 failed, 5 passed.
    • Each failure is on the defect: a bare PATH that is not there, open=y reading nothing, or exit 1 where exit 2 is due.
    • The 5 that pass are the bare_path_whole[a-blank, an-owner, no-declared-pin] rows and the two old_split_s_corners rows.
    • No added assert joins conditions with and or or. Every added line is at most 79 characters, within the files' own widths of 80 to 93.
    • test_link_source_chain_defaults.py still collects alone with its new import of declare_by_hand (26 tests).
  4. Mutants, each alone in a scratch worktree, over the 3 files (408 tests), through run9.sh.

    • The writers' five, all killed with the body's counts: M1 11/397, M2 5/403, M5 21/387, M12 8/400, M16a 2/406.
    • M7 is equivalent, and I agree. For a value read as a bare PATH, the scan's last turn is at the value's first =, and a hit in the endswith fallback would make the value keyed, not bare. So before is typed.partition("=")[0]. Measured: 1,291 bare values holding =, over pin lists with and without =, 0 where the two differ.
    • My own six:
Mutant What it changes Result
R1 the fold dropped from the no-path fallback only killed, 4
R2 the first declared pin in manifest order instead of the longest killed, 4
R3 the no-path reading tried before any shorter pin killed, 1 (the shorter-pin row)
R4 a bare value's before is the whole value killed, 3
R5 the fallback guard dropped survived (SHOULD-1)
R6 the new reader keyed by casefold() again killed, 2
  • Each tree was checked byte for byte against the head's file after its lane.
  1. docs/cli.md.

    • scripts/render-cli-reference.py --check prints [ok] docs/cli.md is what the generator produces, and tests/test_cli_reference.py passed.
    • Only the --pin-source block changed. Its help is true, including the bare-PATH and ./ clauses.
    • scripts/validate-repository-naming.py changes only in its docstring. validate-pins.py adds no import, so no older pinned repo_shape.py is at risk. test_pinned_copy_text.py passed.
  2. Upgrade rows. The body matches the logs under $Q/v118/w1053/upg/ (check1.log, apply.log, check2.log, pins.log, manifest.log):

    • the first check lists upstream-changed 2 unchanged 9;
    • apply copies both files, re-pins 8a0572157723 -> 2e6dd9e56eb8 and prints pins ok and manifest ok: Atlas (atlas), 3 legs;
    • the second check prints unchanged 11 and nothing to do;
    • the head's two files hash to adc1f7d8… and fc5c9f81… from the git objects, which I recomputed;
    • neither digest appears anywhere in tests/.
  3. Runs at 2e6dd9e5 through run9.sh, with EMAIL, GIT_SSH_COMMAND=false and GIT_CONFIG_GLOBAL=/dev/null exported:

    • git 2.43 (/usr/bin/git): the 3 mutant files, test_cli_reference.py, test_repo_hygiene.py, test_agents_md_codes.py and test_pinned_copy_text.py. 665 passed, 0 skipped.
    • git 2.47.1 (exactly $Q/v115/w764/git247/bin/git; git --version printed 2.47.1 under run9.sh's PATH): the 3 mutant files. 408 passed.
  4. Merges. I fetched again at 17:29Z. origin/main is 00f6d7f7.

Against Head merge-tree Tree
origin/main 00f6d7f7 clean e52427f3
#1062 01a8b759 clean d2fb528d
#1063 d996ebf1 clean 88c9721d
  • All three trees are the body's.
  • Main's move from 6476c170 touches only templates/family-root/scripts/bootstrap.py and its test.
  1. Title and body.

Could not verify

  • Body numbers I did not re-run (the logs are there):
    • The writers' 1,554-run, 0-differing table. I ran my own: 1,376 program runs plus 45,792 in-process comparisons.
    • Mutants M3, M4, M6, M8 to M11, M13 to M15 and M16b.
    • The 88-file shard totals: 6,853 passed, 12 skipped. I read them from $Q/v118/f1053/head-s1.log to head-s4.log, which match the body and record head 2e6dd9e5. I did not re-run them.
  • Not my own run: the upgrade run itself. I read its logs rather than running upg.sh again.
  • Not checked: f1053's 2.47.1 log does not print git --version. My own 2.47.1 run proves that version for the same 408 tests.
  • No Windows or macOS run. CI is Linux-only since ci: run the test suite on Linux only #597.

Checklist candidates

  • When a reader gains a fallback with a guard, add a row where the guard's other branch matters. Here that is a bare value ending in = that names no declared pin.
  • When two pinned copies claim to read "one way", compare their name tables as well as their scans: which spelling of a fold-duplicate wins, and whether names are basenamed.

Scratch I leave

  • Notes, scripts and logs in /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/rv1067/:
    • logs: at-main.log, h243.log, g247.log, subcmp.log, mutants-A.log, mutants-B.log, and one mut-*.log per mutant;
    • scripts: inproc.py, inproc2.py, subcmp.py, mut.py;
    • the cmp/ probe root, the vp-* and vrn-* copies, and a __pycache__.
  • Basetemps:
    • /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v110/bt/rv1067-h243
    • /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v110/bt/rv1067-g247
    • /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v110/bt/rv1067-main
    • /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v110/bt/rv1067-mA
    • /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v110/bt/rv1067-mB
  • Removed: all four scratch worktrees (rv1067-head, -main, -mutA, -mutB), each clean and taken out with git worktree remove. Deleted: the refs refs/l5rv/rv1067-pr1067, -pr1062 and -pr1063.
  • Nothing of mine is running. I ran no rm, no git config and no real gh in a run; my only gh call was one read of gh pr view 1067. I pushed, posted and filed nothing.

… a bare PATH, whole, for `validate-pins.py`'s `--pin-source` (rv1067's SHOULD-1)

rv1067's mutant R5 drops the `if product is not None:` guard from `PinSources._read_source`'s empty-path fallback, so every value that ends in `=` read as a declared pin's, whatever its text names. It survived all 408 tests, because no row held a bare PATH ending in `=`: the three bare-PATH rows (` open=y=x`, `opensoft/open=y=x`, `open=z=x`) end in `x`. Under R5, `--pin-source open=z=` with `open=y` declared, `x=` and `../build=` with `openGlass` declared each end in an `AttributeError` traceback in `_shown(None)`, where the head reads each as the bare PATH it is.

`test_a_value_naming_no_declared_pin_is_a_bare_path_whole` takes a fourth value, `open=z=` (id `ends-at-no-declared-pin`), so it passes the stand-in directory of that name and expects the run to succeed with the whole value as the path: exit 0, no refusal, no note. It passes on the base `8a057215` and on the head, by design, and fails under R5. The code is unchanged; this is a gap in the tests only.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

Re-check of #1067 (#1053) at 7ff61666, for lane openRepoShape-5: READY

SHOULD-1 is fixed: the new row kills my mutant R5, and no test file changed outside that one row. NIT-1 stays open by agreement and needs no change. There are no new findings.

  1. The new commit changes only that test file.

    • git diff --stat 2e6dd9e5 7ff61666 gives tests/test_neutral_pin_validator.py | 13 ++++++++----- (+8/-5), and 7ff61666^ is 2e6dd9e5.
    • The commit adds "open=z=" with the id ends-at-no-declared-pin to test_a_value_naming_no_declared_pin_is_a_bare_path_whole, and rewords that test's docstring.
    • validate-pins.py at the new head is byte for byte the copy I reviewed at 2e6dd9e5 (checked with cmp).
    • No added assert joins conditions with and or or, and the widest added line is 77 characters.
  2. R5 is killed by the new row alone.

Run Result
R5, new row alone 1 failed
R5, all 409 tests of the three files killed, 1 failed / 408 passed; the only failure is [ends-at-no-declared-pin]
The row unmutated, at the head passes (in both 409-test runs below)
The row at 8a057215 (head's test files copied onto a worktree there) passes, by design
  1. The three mutant files pass at 7ff61666.

    • git 2.43.0: 409 passed.
    • git 2.47.1, exactly $Q/v115/w764/git247/bin/git (it printed git version 2.47.1): 409 passed.
  2. The body's changed numbers are true.

    • 17 new rows: 14 in test_neutral_pin_validator.py and 3 in test_link_source_chain_defaults.py.
    • At 8a057215, 11 failed and 6 passed. The 6 are the four bare-PATH rows (now including ends-at-no-declared-pin) and the two old-split corners.
    • Line counts: five files, +396/-52 from 6476c170.
    • Mutant table:
      • The R5 row says 1 / 408. I measured that.
      • M1 11/398, M2 5/404 and M16a 2/407 on the final files: I re-ran these three on the new row alone, and it passes under each. My earlier full runs on the 408 tests that did not change gave 11, 5 and 2 killed. Together that gives the body's counts.
      • The other rows are unchanged from 2e6dd9e5: M1, M2, M5, M12 and M16a I measured then; the rest I did not re-run.
      • "17 of 18 killed; the 18th is equivalent" holds: M7 is still the equivalent one.
    • Closing keyword: the only one is Closes #1053. The title is 236 characters, and GitHub's closing references are [1053] (one gh read).
    • Main's docs/cli.md change: 6476c170..9cbba9d5 changes docs/cli.md +8/-2, in one hunk at line 959 in the family.py block, outside the --pin-source block. Main changed none of the other four files.
  3. Merges (fetched again at 17:58Z):

Against Head merge-tree Tree
origin/main 9cbba9d5 clean d776b7d7
#1062 957ba038 clean aa6fc31b
  • Both trees are the ones the body names.
  • The predicted merge still matches the generator. I wrote tree d776b7d7 into a scratch worktree at 9cbba9d5 with read-tree -u --reset. There, scripts/render-cli-reference.py --check prints [ok] docs/cli.md is what the generator produces, exit 0.

Not verified: the body's other mutant rows (M3, M4, M6, M8 to M11, M13 to M15, M16b), the 88-file shard totals and the upgrade rows are unchanged from 2e6dd9e5. I checked those at the first review from the writers' logs, not here.

Scratch I leave:

  • New logs and a script in /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/rv1067/:
    • logs: re-h243.log, re-g247.log, re-at-main.log, re-mutants-R5.log, re-mut-one.log, re-live-body.md, mut-R5-at-2e6dd9e5.log;
    • script: mut_one.py.
  • Basetemps reused under $Q/v110/bt/rv1067-*.
  • Removed: the worktrees rv1067-head2, -main2, -mut2 and -merge, each clean, with git worktree remove. Deleted: the refs refs/l5rv/rv1067-pr1067 and -pr1062. No rv1067 worktree, ref or process is left.
  • I ran no rm, no git config and no real gh in a run, and pushed, posted and filed nothing.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 4a6b9af into main Oct 20, 2026
5 checks passed
@brettheap
brettheap deleted the fix/1053-pin-source-key branch October 20, 2026 21:07
brettheap added a commit that referenced this pull request Oct 21, 2026
… fetch that carry it run in bootstrap.py's `untraced()` environment, so `make siblings` and `family.py siblings` write the token to no trace the caller asks git for (#1055) (#1075)

`make siblings` and `family.py siblings` now write the token in `SHAPE_LEGS_TOKEN` or `SHAPE_LEGS_APP_TOKEN` to no trace the caller asked git for. #996 (#1045) did the same for `make bootstrap`.

Lane 5 found this in its independent review of #1045 (rv996, #1045 issuecomment-6288783192) and measured it in #1055.

`templates/family-root/scripts/siblings.py` gives the credential's `-c url.https://x-access-token:<token>@github.com/.insteadOf=...` rewrite, in argv, to two gits:
- the clone in `place`, for a member with no clone beside the holder;
- the fetch in `place`, for a member whose clone is there.

Both ran in the caller's own environment. So every trace2 target the caller named wrote the token in the `start` event of each, one line per member. `scrub` kept it out of the output, and only that part held.

Now:
- **`rewrite_environment(token)`**, a new function in `siblings.py`:
  - with a token in use, it returns `untraced()` from the `bootstrap.py` beside it. That is every `GIT_TRACE*` variable taken out, and `GIT_TRACE2`, `GIT_TRACE2_EVENT` and `GIT_TRACE2_PERF` set to `0`, which also turns off a target the global or the system config names;
  - with no token, it returns None, so the two gits keep the caller's environment and stay traced where the caller asked, as before.
- **The clone and the fetch** each run with `env=rewrite_environment(token)`. Nothing else in the file changes environment.
- **The member's own `scripts/bootstrap.py`**, which `place` runs after a clone, is given no token in its argv, and no `env`, so it keeps the caller's environment and its traces. That environment holds `SHAPE_LEGS_TOKEN` and `SHAPE_LEGS_APP_TOKEN` as before; the shipped member bootstrap (`templates/assembly-root/scripts/bootstrap.py`) reads neither. A test row holds this, and so does `tests/test_validator_child_encoding.py`, which reads that call as handing no `env`.
- **#1023's jobserver handling is untouched**: `dispatch` still runs each member's make with `member_make_environment(make)`, and no `MAKEFLAGS` reading changed.
- **The docstrings** now say the promise in full: `git_prefix` says the token is "never written into `.git/config`, never persisted, never printed, and never traced". `place`'s ABSENT paragraph in the module docstring says the same, and `rewrite_environment` says what was measured.

**One thing the issue did not ask for.** `GIT_TRACE_PERFORMANCE` writes a git's whole command line, `-c` included, on git 2.43.0 and 2.47.1 alike. So at main it held the token too, two lines per run, and the rows below set it. `untraced()` already takes it out: every `GIT_TRACE*` variable goes.

This follows #1045's `untraced()` (#996): the clone and the fetch run in the very environment `make bootstrap` gives every git it hands the token.

`siblings.py` imports `untraced` from the `bootstrap.py` beside it, next to `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path` and `members_of`.

The issue left this to the writer, because a holder may keep a `bootstrap.py` older than its `siblings.py` (`update-shape.py apply --accept-local`), and an import of a name that copy lacks would stop `make siblings`, `park` and `resume` with an `ImportError`. The issue's text was written against main `1a47ed07`, where `siblings.py` still kept its own copy of the `MAKEFLAGS` readings. Since then #1023 (`9e154d87`, lane 4's) has decided the question for this pair of files:
- it replaced that copy with an import of `member_make_environment`, because a copy in each script failed SonarCloud's duplication gate;
- it named the price, in the import's comment and its commit: a holder that keeps its own `bootstrap.py` while `apply` writes `siblings.py` is refused, `update-accept-local-missing-name` (#536, #604), before a byte is written, and merges `bootstrap.py` by hand first.

`untraced` takes the same road for the same reasons:
- **One rule.** Which traces a command carrying the token is kept out of has one answer, for `make bootstrap` and `make siblings` alike. A copy here could drift from `bootstrap.py`'s, and the drift would be a token in a file.
- **A kept old `bootstrap.py` is refused in the one-step case, and not in a two-step one.** The guard reads names. `untraced` is new with #996 (#1045), so a kept `bootstrap.py` from before it is refused by that name, where `--accept-local` names it in the same `apply`. Measured with `update-shape.py`'s own readers, in process:
  - `siblings.py` at this head takes `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path`, `members_of` and `untraced` from `bootstrap`;
  - the holder's `bootstrap.py` at main `00f6d7f7` lacks none of them;
  - the holder's `bootstrap.py` at `6476c170` (main just before #1045) lacks `untraced` alone, which is the line the refusal names.
- **The one-step case, measured end to end (rv1075).** A holder cut at `6476c170`, and `apply --at c3cf515 --accept-local scripts/bootstrap.py`: exit 2, `scripts/bootstrap.py (kept) lacks untraced, which scripts/siblings.py imports from it`, and the holder's files, HEAD and status unchanged.
- **Two applies in a row are not refused (rv1075's SHOULD 1, filed as #1082).** A holder whose earlier apply kept a `bootstrap.py` without `untraced` (rv1075 applied `--at 00f6d7f --accept-local scripts/bootstrap.py`: that target's `siblings.py` imports no `untraced`, so there was nothing to refuse), and then an apply at this head without `--accept-local`, gets `siblings.py` copied. `scripts/siblings.py` then fails with `ImportError: cannot import name 'untraced' from 'bootstrap'`, and so do `make siblings`, `park` and `resume`. The cause is the guard in `update-shape.py` (`:4288-4289`): it checks only the files `--accept-local` names in the same run. That is the gap of #536's guard, and it is on main already for #1023's `member_make_environment`; this change puts `untraced` into it and does not widen it. The text above this paragraph said no `ImportError` could reach a holder, which was too strong; #1082 is the follow-up, and it is not done here.
- **No copy, so no test to hold two copies to one answer.** `tests/test_update_shape_kept_helper.py`'s `test_what_siblings_takes_from_the_holders_bootstrap_is_found_defined` already holds that every name `siblings.py` takes is defined in the holder's `bootstrap.py`. Its docstring now says six names, not five.

**Setup:**
- git 2.43.0 (`/usr/bin/git`) and git 2.47.1, Python 3.12.3;
- the family of `tests/test_family.py` (two members, local bare remotes, the file transport allowed through `GIT_CONFIG_COUNT`), laid out as `<tmp>/InkRouter/InkRouter`;
- offline: a stub `gh` first on PATH, a bogus `GH_TOKEN`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`;
- the holder's own `scripts/siblings.py` was main's (sha256 `86bf57e0...`) or this change's;
- the fake token `w1055-a-token-no-forge-takes`.

**Each case was run three ways:**
- `make siblings`, which clones both members;
- `make siblings` again, which fetches both;
- `family.py siblings --family-root <holder>` in a fresh layout, which clones both.

**Traces were asked for in one of three ways:**
- the seven variables `GIT_TRACE`, `GIT_TRACE2`, `GIT_TRACE2_EVENT`, `GIT_TRACE2_PERF`, `GIT_TRACE_PACKET`, `GIT_TRACE_CURL` and `GIT_TRACE_PERFORMANCE`, each set to a file under the run's TMPDIR;
- `trace2.eventTarget`, `normalTarget` and `perfTarget` in a scratch `GIT_CONFIG_GLOBAL`;
- the same three in a scratch `GIT_CONFIG_SYSTEM`.

Each way was run with `SHAPE_LEGS_TOKEN`, with `SHAPE_LEGS_APP_TOKEN`, and with no token.

That is 108 runs at main and at the change, 54 for each git. Each run's TMPDIR and the whole family folder (the clones' `.git/config` included) were searched for the token.

| traces asked by | main, a token set (both variables, both gits) | this change, a token set | no token, either |
|---|---|---|---|
| the seven variables | `trace2-event.json`, `trace2-normal.txt`, `trace2-perf.txt`, `trace-performance.txt`: 2 lines each, in the clone, the fetch and `family.py` alike | none | no token anywhere; the same files written |
| the global config | `cfg-event.json`, `cfg-normal.txt`, `cfg-perf.txt`: 2 lines each | none | the same |
| the system config | `sys-event.json`, `sys-normal.txt`, `sys-perf.txt`: 2 lines each | none | the same |

**What the table rests on:**
- Every one of the 108 runs (and the 54 runs again on this head's final bytes, sha256 `b1e7bb21...`) exited 0 and ended `siblings ok: 2 member(s) beside the holder in ...`, with the token in neither stdout nor stderr.
- The 2 lines in each file are the `start` (or `performance:`) line of the git given the `-c`, one for each member. Each carries the token twice, once for each spelling the rewrite covers.
- With every member a local path, `GIT_TRACE`'s file was written and held no token, and `GIT_TRACE_CURL` wrote nothing, with no http remote. An https member is a different case, measured below.
- With the change and a token set:
  - every trace file but `GIT_TRACE_PACKET`'s was still written, by the run's other gits and the member's own bootstrap;
  - the packet trace was not written, since only the clone and the fetch write one here.

**An https remote, measured offline (rv1075, then the row of Round 2).** The rewrite reaches only `github.com`, and the probes above are offline, with local paths. A member whose `.gitmodules` url is `https://github.com/<org>/<repo>.git` is reached by it, and can be tried offline behind a dead proxy, `http.proxy=http://127.0.0.1:9`, so that nothing leaves the host:
- rv1075 set one member's url so, with `make siblings`, a fake `SHAPE_LEGS_TOKEN`, and the seven variables set to files, on git 2.43.0 and 2.47.1. Main's `siblings.py` (`86bf57e0`) held the token in `trace.txt`, `trace2-event.json`, `trace2-normal.txt`, `trace2-perf.txt` and `trace-performance.txt`; this head held it in no file and no output. The clone failed cleanly, `git clone exited 128`, and `make siblings` exited 2.
- `GIT_TRACE`'s file holds it too, in the `run_command: git remote-https origin https://x-access-token:<token>@github.com/...` lines: 3 lines on git 2.43.0, 4 on 2.47.1 (rv1075).
- Round 2 below holds this in a test row.

**Still not measured:** a github.com clone or fetch that succeeds, since no run here reaches the network.

`tests/test_family_siblings_untraced.py` is new. It uses `tests/test_family.py`'s `family` and `estate`, `offline` from `tests/test_family_add_refusals.py`, `files_holding` and `CREDENTIALS` from `tests/test_family_bootstrap_stops_named.py`, and `NO_CREDENTIAL` and `needs_make` from `tests/test_siblings_family_name.py`. No assert joins conditions with `and`. The token is the fake `w1055-a-token-no-forge-takes`.

**The rows.** Each entry point is crossed with each way traces are asked for:
- entry points: `make siblings` cloning, `make siblings` again fetching (after a first run with no token and no trace), and `family.py siblings` cloning;
- ways: the seven variables, the three targets in a scratch global config (a copy of the session's, which keeps git's automatic maintenance off), and the three targets in a scratch system config.

**Three tests** (the third is round 2's):
- **`test_with_a_token_in_use_the_token_is_in_no_trace`**: 18 rows (3 entries x 3 ways x 2 variables). Each asserts:
  - exit 0 and `siblings ok: 2 member(s) ...`;
  - the token in neither stdout nor stderr;
  - the token in no file under the test's directory: TMPDIR, every trace and config file, and the clones and their `.git/config`;
  - every trace file the way asks for written: 5 for the variables, 3 for each config;
  - the member's own bootstrap traced where a member was cloned: a git traced in a leg of each clone, which only that bootstrap runs. Where nothing was cloned, nothing ran there.
- **`test_with_no_token_the_clone_and_the_fetch_are_traced_as_asked`**: 9 controls (3 entries x 3 ways). Each asserts exit 0, `siblings ok` and the same trace files written. It also asserts the trace2 event file holds `siblings.py`'s own git at each member, read from the events: `git clone -q --no-local ...` to each member's slot, or `git fetch --quiet origin` in each member's clone.

**Fails on main.** The file was run against main's `siblings.py` (sha256 `86bf57e0...`, put in place and then restored, sha256 checked):
- **18 failed, 9 passed**: every row with a token failed, and the 9 controls passed;
- the variables rows found the token in `trace-performance.txt`, `trace2-event.json`, `trace2-normal.txt` and `trace2-perf.txt`;
- the global-config rows found it in `cfg-event.json`, `cfg-normal.txt` and `cfg-perf.txt`;
- the system-config rows found it in `sys-event.json`, `sys-normal.txt` and `sys-perf.txt`.

- **`test_an_https_member_with_a_token_in_use_is_in_no_trace`**: 3 rows, one for each of `CALLERS_CONFIG` (round 2 and round 3, below).

Each mutant was applied alone to the worktree's file and the new test file run (git 2.43.0). The bytes were restored and checked by sha256 after each:
- `siblings.py` sha256 `b776c69b...`: this head's code; it differs from the head only in two docstring lines;
- `bootstrap.py` sha256 `50a16751...`, main's, unchanged here.

All are **killed**.

| mutant | result |
|---|---|
| M1 the token condition dropped: `untraced()`'s environment always | killed, 9 failed (every control) |
| M1b the condition inverted | killed, 27 failed |
| M1c the clone untraced always, the fetch as it is | killed, 6 failed (the clone controls) |
| M2 the clone left in the caller's environment | killed, 12 failed (every clone row with a token) |
| M3 the fetch left in the caller's environment | killed, 6 failed (every fetch row with a token) |
| M4a `GIT_TRACE2_EVENT` not set to `0` (`TRACE2_OFF`) | killed, 12 failed (the global- and system-config rows) |
| M4b `GIT_TRACE2` not set to `0` | killed, 12 failed (the same) |
| M4c `GIT_TRACE2_PERF` not set to `0` | killed, 12 failed (the same) |
| M5 the `GIT_TRACE*` removal dropped (`env = dict(os.environ)`) | killed, 6 failed (the variables rows, by `GIT_TRACE_PERFORMANCE`) |
| M6 the prefix narrowed to `GIT_TRACE2` | killed, 6 failed (the same) |
| M7 the member's own bootstrap run untraced too | killed, 12 failed (the clone rows' bootstrap check) |

M5 and M6 are killed only because the variables rows set `GIT_TRACE_PERFORMANCE`. With a local-path remote, of the issue's six variables none but trace2's writes the `-c`, and trace2's three are set to `0` whatever the prefix does. With an https remote `GIT_TRACE` writes it too, in the `run_command: git remote-https` lines; that is what the row of Round 2 holds, and the mutant R4 below, which keeps the caller's `GIT_TRACE`, passed all 27 earlier rows.

**How every run was made:**
- through lane 5's runner: `/usr/bin/python3 -m pytest -q -rs -p no:cacheprovider -o tmp_path_retention_policy=failed`, one basetemp per run;
- `LANES_*` cleared, a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`;
- `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null` at the start of the session.

| git | files | result |
|---|---|---|
| 2.43.0 | `test_family_siblings_untraced.py`, on this change before the docstring fix | 27 passed |
| 2.43.0 | the same file against main's `siblings.py` | 18 failed, 9 passed |
| 2.43.0 | gate shard A: 19 of the 41 files below | 1623 passed, 4 skipped, in 19:51 |
| 2.43.0 | gate shard B: the other 22 of the 41 files | 2415 passed, 8 skipped, in 15:03 |
| 2.47.1 | `test_family_siblings_untraced.py` | 27 passed, in 1:14 |

**What the 41 gate files are:**
- the new file;
- `test_family_member_make_jobserver.py`;
- every file `grep -l siblings tests/` names;
- the pinned-copy and upgrade-row files: `test_pinned_copy_text.py`, `test_update_shape.py`, `test_update_shape_add.py`, `test_update_shape_kept_helper.py`, `test_no_auto_maintenance.py`, `test_template_text_placeholders.py`, `test_bootstrap_spelling.py`;
- the trio: `test_repo_hygiene.py`, `test_agents_md_codes.py` and `test_cli_reference.py`.

**The gate runs** (both shards at once, then the 2.47.1 run) were made on this change as it is committed: `siblings.py` sha256 `b1e7bb21...` and the new test file sha256 `7ba7a416...`, at main `00f6d7f7` (this branch's base; no rebase was needed, see Merges). The runner's `START` line read `dirty=2`, the two tracked files this commit changes, and a sha256 check after the runs found `siblings.py` unchanged (neither it nor the test files were modified after the runs started). The first pair of shards was stopped part way and re-run whole; only the re-runs count here.

**The 41 files, by shard:**
- shard A (19): `test_shape_doctor.py`, `test_validator_child_encoding.py`, `test_family_py_unreadable.py`, `test_pinned_copy_text.py`, `test_siblings_inputs.py`, `test_siblings_family_name.py`, `test_family_member_make_jobserver.py`, `test_update_shape_add.py`, `test_adopt_printed_sentences.py`, `test_family_printed_quoting.py`, `test_family_makefile_siblings.py`, `test_template_text_placeholders.py`, `test_park_resume_targets.py`, `test_windows_paths.py`, `test_doctor_gitmodules_not_utf8.py`, `test_local_remote_dir_urls.py`, `test_family_siblings_untraced.py`, `test_setup_family_folder.py`, `test_bootstrap_refusal_exit.py`;
- shard B (22): `test_repo_hygiene.py`, `test_doctor_legs_members_next.py`, `test_update_shape.py`, `test_family.py`, `test_update_shape_kept_helper.py`, `test_yaml_reader_spaces.py`, `test_bootstrap_spelling.py`, `test_agents_md_codes.py`, `test_family_siblings_placement.py`, `test_no_auto_maintenance.py`, `test_cli_reference.py`, `test_family_siblings_bootstrap_unreadable.py`, `test_bootstrap_branch_bytes.py`, `test_family_init_asks_every_value.py`, `test_validate_workflow_gitmodules_urls.py`, `test_adopt_contracts_dir.py`, `test_doctor_unfinished_merge.py`, `test_family_members_not_a_list.py`, `test_family_init_landing.py`, `test_family_docs_one_spelling.py`, `test_doctor_members_spelled.py`, `test_pins_child_kind_and_siblings_root_spelling.py`.

**The skips are the environment's, each named by its reason:**
- shard A: 3 in `test_shape_doctor.py` (the PowerShell line is printed, and `powershell.exe` exists, only on a Windows host) and 1 in `test_family_member_make_jobserver.py` (`make -j2` here writes `--jobserver-auth=3,4`, not GNU make 4.4's named pipe);
- shard B: 2 in `test_doctor_legs_members_next.py` (this git crashes on `submodule init`, exit 139, as git 2.43 does on a bare `branch`), 4 in the same file (this git dies on a `shallow` of -2**31, as git 2.43 does), and 2 in `test_no_auto_maintenance.py` (the session began with `GIT_CONFIG_GLOBAL` set, and its file includes that one);
- the 2.47.1 run of the new file skipped none.

The full suite was not run.

**Static checks:**
- flake8 finds in `siblings.py` only the three lines it finds at main (two E501, one E127, each moved down by the added lines);
- in the new test file it finds only the F811s every fixture-importing file has;
- both files are ASCII, apart from the dashes and the CJK example already in `siblings.py`.

Fetched with `-c gc.auto=0 -c maintenance.auto=false`, on 2026-10-20 at about 21:03Z. Round 1, `git merge-tree --write-tree` of the first head `c3cf5154` against (round 2's are in its own section):
- **origin/main `6c861635`**: clean, exit 0, tree `225b2a99`.
  - Main has moved two commits past this branch's base `00f6d7f7`: `9cbba9d5` (#1063: `scripts/family.py`, `scripts/shape_materialize.py`, `docs/cli.md` and two tests) and `6c861635` (#1065: `adopt-project.py` and three tests).
  - Neither touches `siblings.py`, `bootstrap.py`, `tests/test_update_shape_kept_helper.py` or the new test file, so there was no rebase and the gate runs above stand.
- **#1062** (`fix/1040-windows-start-errors`, head `957ba038`), the one open PR that touches a family-root script this change reads (`templates/family-root/scripts/bootstrap.py`, with the assembly root's and `validate-family.py`, `validate-pins.py` and three tests): clean, exit 0, tree `84d4b54a`.
  - Its diff does not touch `untraced()`.
  - In the merged tree `bootstrap.py` still defines `untraced` (and the five other names `siblings.py` takes from it), and `siblings.py` is byte-identical to this head's (sha256 `b1e7bb21...`).
- **The other 9 open PRs** (#1073, #1072, #1071, #1070, #1069, #1068, #1067, #1064, #1031): none touches `siblings.py`, `tests/test_update_shape_kept_helper.py`, or a file the new test imports from (`conftest.py`, `test_family.py`, `test_family_add_refusals.py`, `test_family_bootstrap_stops_named.py`, `test_siblings_family_name.py`), read from `gh pr list --state open --json number,headRefName,headRefOid,files`.

`templates/family-root/scripts/siblings.py` is in `FAMILY_COPIED_VERBATIM`, so each holder gets one `upstream-changed` row for it, as #1045 gave one for `bootstrap.py`:
- a holder that takes it with `bootstrap.py` from #1045 or later needs nothing more;
- a holder that keeps an older `bootstrap.py` with `--accept-local` in the same apply is refused, `update-accept-local-missing-name`, naming `untraced`, before anything is written;
- a holder whose earlier apply kept a `bootstrap.py` without `untraced`, and that then applies this change without `--accept-local`, is not refused: `siblings.py` is copied, and `make siblings`, `park` and `resume` meet an `ImportError`. That is the gap of #536's guard, which `member_make_environment` has on main already, and it is #1082's.

No Windows or macOS run was made, since CI is Linux only (#597).

rv1075, lane 5's independent reviewer, found `c3cf5154` READY (#1075 issuecomment-6302090874): no MUST, two SHOULDs and a NIT. One commit on top of it, `041f7831`, with no rebase: main has touched none of this PR's files.

**The three findings:**
- **SHOULD 1** (the body said no `ImportError` reaches a holder, and two applies in a row make one): answered in the body above, in "Where the helper comes from" and "The pinned copy". The gap is #536's guard's, it is on main for `member_make_environment` already, and it is #1082's; this PR does not change `update-shape.py`.
- **SHOULD 2** (every row used a local file remote, so a leak through `GIT_TRACE` on an https member had no test, and rv1075's mutant R4 passed all 27 rows): answered by the new row below.
- **NIT** (three places said the member's own bootstrap "is given no token"): `siblings.py`'s module docstring, `rewrite_environment`'s docstring and the test file's docstring now say it is given no token in its argv. It inherits `SHAPE_LEGS_TOKEN` and `SHAPE_LEGS_APP_TOKEN` in its environment, because `run_member_bootstrap` passes no `env`, and the shipped one reads neither. `rewrite_environment`'s docstring also names the https measurement.

**The code of `siblings.py` is unchanged.** Its syntax tree with the docstrings taken out is the same at `c3cf5154` and at `041f7831` (compared with `ast`); the file's sha256 moved from `b1e7bb21...` to `19107ad4...` over comments and docstrings only. So round 1's two gate shards (1623 passed, 4 skipped; 2415 passed, 8 skipped) were not re-run; the targeted runs below are.

**The new row,** `test_an_https_member_with_a_token_in_use_is_in_no_trace` (the file has 28 rows now). Its setup:
- the estate of `tests/test_family.py`, with `IRRS`'s url in the holder's `.gitmodules` given as `https://github.com/InkRouter/IRRS.git`, and `IRSS` still a local path;
- `http.proxy=http://127.0.0.1:9`, a dead proxy, in git's command scope after the entries the session holds, and `NO_PROXY` and `no_proxy` empty, so nothing leaves the host;
- a fake `SHAPE_LEGS_TOKEN` and the seven trace variables set to files;
- `make siblings`, cloning.

It asserts:
- the proxy is set where the row sets it: `http.proxy=http://127.0.0.1:9` is in the command scope the run's git reads (`command_scope_at`, which runs `git config --show-origin --list` and writes nothing);
- exit 2 and `IRRS: git clone exited 128:` on stderr, which is how the head fails (rv1075 measured the same);
- `IRSS` was cloned and `IRRS` was not;
- the token is in neither stdout nor stderr, and in no file under the test's directory;
- the five trace files the variables ask for are written, by the run's other gits.

No assert names the proxy in git's message. The git 2.47.1 this lane builds has no `git-remote-https` (`git: 'remote-https' is not a git command`, `fatal: remote helper 'https' aborted session`), so there the clone fails at once without reaching the proxy, still with 128. `GIT_TRACE` has written the `run_command: git remote-https origin https://x-access-token:<token>@github.com/...` line before that, so the row finds the leak on both gits. A first local version asserted the proxy's message and failed on 2.47.1 for that reason; the assert was dropped, and the commit amended, before anything was pushed.

**It fails on main.** The file was run against main's `siblings.py` (sha256 `86bf57e0...`, put in place and then restored, sha256 checked each time):
- git 2.43.0, the whole file: **19 failed, 9 passed**, the 18 rows of round 1 and the new row. The new row fails at `assert files_holding(tmp_path, TOKEN) == []`, with 5 files holding the token: `temp/trace-performance.txt`, `temp/trace.txt`, `temp/trace2-event.json`, `temp/trace2-normal.txt` and `temp/trace2-perf.txt`. The round 1 rows find 4 of those, since a local path leaves `temp/trace.txt` clean;
- git 2.47.1, the row alone: **1 failed**, the same 5 files.

**It kills R4.** R4 is rv1075's mutant, in `rewrite_environment`: `return {**untraced(), **{k: v for k, v in os.environ.items() if k == "GIT_TRACE"}} if token else None`, which keeps the caller's `GIT_TRACE` for the clone and the fetch. Applied alone, then restored and sha256-checked:
- git 2.43.0, the whole file: **1 failed, 27 passed**: the new row alone, with `temp/trace.txt` the one file holding the token;
- git 2.47.1, the row alone: **1 failed**, the same one file.

**The runs on the pushed head `041f7831`** (the runner's `START` line read `dirty=0`), through the same runner as round 1:

| git | files | result |
|---|---|---|
| 2.43.0 | the new file, `test_update_shape_kept_helper.py`, `test_family_makefile_siblings.py`, `test_family_member_make_jobserver.py`, `test_pinned_copy_text.py`, and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) | 589 passed, 1 skipped (the jobserver file's named-pipe row, as in round 1), in 5:09 |
| 2.47.1 (`git version 2.47.1`, printed from the exact path first on PATH) | the new file | 28 passed, in 2:13 |
| 2.43.0 | the new file against main's `siblings.py` | 19 failed, 9 passed |
| 2.47.1 | the new row against main's `siblings.py` | 1 failed |
| 2.43.0 | the new file with R4 | 1 failed, 27 passed |
| 2.47.1 | the new row with R4 | 1 failed |
| 2.43.0 | on a predicted merge with main (below): the new file, `test_update_shape_kept_helper.py`, `test_pinned_copy_text.py`, `test_family_makefile_siblings.py` | 305 passed, in 2:50 |

The full suite was not run. flake8 finds in `siblings.py` the same three lines as at round 1, and in the test file only F811s, from the fixtures it imports. Both files are ASCII, apart from the dashes and the CJK example already in `siblings.py`.

**The merges of round 2.** Fetched with `-c gc.auto=0 -c maintenance.auto=false` at about 23:00Z; `git merge-tree --write-tree` of `041f7831` against:
- **origin/main `c13c3985`** (#1076's squash, which touches `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_stops_named.py`, among others): clean, exit 0, tree `8ae10d0c`. None of the three files this PR changes is among the files main changed since the base `00f6d7f7`, so there is no rebase. In that tree `siblings.py` is byte-identical to this head's, `untraced()` has the same syntax tree as at the base, and `CREDENTIALS` and `files_holding`, which the new file imports from `test_family_bootstrap_stops_named.py`, are still defined. The 305-passed run above is on that tree;
- **#1062** (`fix/1040-windows-start-errors`, head `957ba038`): clean, exit 0, tree `e6ed1ba2`. Its `untraced()` has the same syntax tree as main's;
- **the other open PRs** (#1031, #1069, #1070, #1072, #1073, #1081, #1084): none touches `siblings.py`, `tests/test_update_shape_kept_helper.py`, `bootstrap.py`, or a file the new test imports from, read from `gh pr list --state open --json number,headRefName,headRefOid,files` at about 23:00Z. #1076 (`fix/1056-signal-anywhere`, head `73906948`) was open when the runs were made, and merged clean with this head then (tree `6ae3e65f`); it has landed since as `c13c3985`, above.

**The failure.** Lane 5's landing gate (log `c1075.log`, #1075 issuecomment-6304830183) failed the https row of round 2 at `041f7831`: 1 failed, 356 passed. The change in `siblings.py` was not at fault; the row depended on the caller's git config.
- The gate ran with neither `GIT_CONFIG_GLOBAL=/dev/null` nor `GIT_SSH_COMMAND=false`, which the writers' runs and round 2's runs had.
- `conftest.py`'s session global file includes the caller's global config (or `~/.gitconfig`, where `GIT_CONFIG_GLOBAL` is unset). The operator's `~/.gitconfig` has `url.git@github.com:.insteadOf=https://github.com/`.
- Git keeps the first of two `insteadOf` prefixes of the same length, and the config files are read before the command line, so that rewrite beat the token's `-c` rewrite of the same prefix. Measured with scratch files and a stand-in token, on git 2.43.0: under a global file with that rewrite, `git -c url.https://x-access-token:T@github.com/.insteadOf=https://github.com/ ls-remote --get-url https://github.com/InkRouter/IRRS.git` printed `git@github.com:InkRouter/IRRS.git`.
- So the row's `https://github.com/InkRouter/IRRS.git` went over ssh to `InkRouter/IRRS`, a real private repository. The dead proxy never applied, the clone succeeded, `make siblings` exited 0 and the placed members were both there, and the row failed `assert result.returncode == 2`.

**The fix** is in `tests/test_family_siblings_untraced.py` only. No other file changed in this round, and `siblings.py` is byte-identical to `041f7831`'s (sha256 `19107ad4...`).
- **`hermetic_config`** gives the https row's git a global file of its own, `session_global_config("")` (`conftest.py`'s own function: automatic maintenance off, and no include of the caller's file), `GIT_CONFIG_NOSYSTEM=1` and `GIT_SSH_COMMAND=false`.
  - It is the least invasive way: only the one row with an https url sets it, `conftest.py` and every other row stay as they were, and the other rows' remotes are local paths, which no rewrite of GitHub's url reaches.
  - `GIT_CONFIG_GLOBAL` set takes git's two user-level files out of the reading, and `GIT_CONFIG_NOSYSTEM` the system file whatever `GIT_CONFIG_SYSTEM` says (checked on git 2.43.0 and 2.47.1 with scratch files).
  - `GIT_SSH_COMMAND=false` is a second wall: a url somehow sent over ssh runs `false`. No mutant can show it needed, since the runner exports it too.
- **`url_after_config`** is a new assert before the clone: `git ls-remote --get-url <url>` (it applies the config's `insteadOf` and asks nothing of the network) must give the url unrewritten under the row's environment. A config that reaches the row now fails by name, before any clone.
- **The row is parametrized by `CALLERS_CONFIG`**: `none` (round 2's row), and `global` and `system`, each a stand-in for a caller's config that would send the url somewhere reachable. `as_the_callers_config` writes a scratch file of the test's own, `url.<file URI of the family's own IRRS remote>.insteadOf=https://github.com/InkRouter/IRRS.git`, into this process's `GIT_CONFIG_GLOBAL` (the session file includes it) or `GIT_CONFIG_SYSTEM`. It is built from tmp files only: no network, and never the real `~/.gitconfig`. The file now has 30 rows.
- With the fix, in each of the three, the clone fails at the proxy with git's 128, `make siblings` exits 2, `IRSS` is placed and `IRRS` is not, and the token is in no output and in no file under tmp_path.

**The variants fail without the fix.** Each was applied alone to the worktree's test file (or `siblings.py`) and run through the runner, with the file's sha256 checked after it was restored:
- **`041f7831`'s file as pushed, under a caller's global config.** Lane 5's runner now honours `L5_GIT_CONFIG_GLOBAL=<scratch file>` as the caller's global config, with ssh impossible. The scratch file rewrote `https://github.com/InkRouter/IRRS.git` to the run's own IRRS remote (a `file://` url under the run's basetemp). The whole file, git 2.43.0: **1 failed, 27 passed**, the https row at `assert 0 == 2`, the gate's failure reproduced with no network.
- **T2, `041f7831`'s row plus the variants:** the new file with `hermetic_config` and the `url_after_config` assert taken out. The https rows, git 2.43.0: **2 failed, 1 passed**, `[global]` and `[system]` at `assert 0 == 2` (the clone went to the local remote and exited 0), and `[none]` passes. On git 2.47.1 the same.
- **T1,** the row's global file dropped and the assert kept: `[global]` fails at the assert, which sees `file:///...IRRS.git` for the url; the other two pass.
- **T3,** `GIT_CONFIG_NOSYSTEM` dropped and the assert kept: `[system]` fails at the assert; the other two pass.

**R4 stays killed.** rv1075's R4 (`rewrite_environment` keeps the caller's `GIT_TRACE`), git 2.43.0, the whole file: **3 failed, 27 passed**, the three https rows.

**The runs on the pushed head `962ae43c`** (`START` read `dirty=0`), through the same runner as before:

| git | files | result |
|---|---|---|
| 2.43.0 | the new file, `test_update_shape_kept_helper.py`, and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) | 359 passed, in 3:05 |
| 2.47.1 (`git version 2.47.1`, printed from the exact path first on PATH) | the new file | 30 passed, in 1:51 |
| 2.43.0 | the new file, with `L5_GIT_CONFIG_GLOBAL` naming that scratch file | 30 passed, in 2:21 |
| 2.43.0 | `041f7831`'s file, with the same | 1 failed, 27 passed |
| 2.43.0 / 2.47.1 | T2, the https rows | 2 failed, 1 passed / 2 failed, 1 passed |
| 2.43.0 | T1, T3, the https rows | 1 failed, 2 passed each |
| 2.43.0 | R4, the whole file | 3 failed, 27 passed |

The full suite was not run. flake8 finds in the test file only F811s from the fixtures it imports, and nothing over 79 columns; the file is ASCII.

**The merge-tree of round 3.** Fetched with `-c gc.auto=0 -c maintenance.auto=false` at about 10:53Z; `git merge-tree --write-tree` of `962ae43c` against:
- **origin/main `2a119f28`** (#1092): clean, exit 0, tree `b5ea0114`. Main has landed #1076 (`c13c3985`) and #1062 (`ff0c72eb`) since the last round, and has changed `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_stops_named.py` among others, but none of this PR's three files. In that tree `siblings.py` is byte-identical to this head's, `untraced()` has the same syntax tree as at the base `00f6d7f7`, and `CREDENTIALS` and `files_holding` are still defined where the new file imports them from.
- **#1088** (`fix/1085-nit-texts`, head `88af1b7b`), which touches the same two files of main's: clean, exit 0, tree `365df092`, with the same findings.
- **The other open PRs** (#1087, #1090, #1091, #1097): none touches `siblings.py`, `bootstrap.py`, `tests/test_update_shape_kept_helper.py` or a file the new test imports from, read from `gh pr list --state open --json number,headRefName,headRefOid,files` at about 10:53Z.

Lane: openreposhape-5 (openRepoShape-5)

Review: rv1075, lane 5's independent reviewer, not the writers (w1055 and f1055): READY at `c3cf5154` (issuecomment-6302090874) with no MUST; its two SHOULDs and its NIT were taken in one new commit, and the re-check is READY at `041f7831` (issuecomment-6303900180), with no MUST or SHOULD; its one NIT, a merge-tree number in the body, is fixed in the body. CI (Brett Heap's ruling 27): H0 is the head 962ae43 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 00f6d7f; and the local gate passed on its predicted squash tree fc2afb3 over main 16f9a0e: the family_siblings_untraced, update_shape_kept_helper, repo_hygiene, agents_md_codes, cli_reference sets, 359 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #1055

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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