Skip to content

validate-repository-naming.py: a --link-source key is the longest chain link before an = that leaves a path after it, so the offer the gate prints for a link holding = verifies that link when typed as printed (#980) - #1044

Merged
brettheap merged 2 commits into
mainfrom
fix/980-link-source-equals
Oct 20, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

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

Closes #980.

Goal

For a declared-unverified chain link whose name holds =, the naming gate offers only sources that verify that link's tree when typed as printed. The gate offered open=y the source --link-source open=y=<path>, and read every --link-source key at the value's first =: the key was open, which no chain names, so the value was a bare PATH for the first link, open=y stayed declared-unverified under the same offer, exit 0.

What changed

The issue's direction (a), in scripts/validate-repository-naming.py. LinkSources._read_source reads a --link-source key the way _read_remedy reads a --link-remedy link since #949: the longest text before an = that names a link of a chain this run reads, by pin_product_key, and leaves a path after it. __init__ and no_path both take the key and the path from it, so the path refused as empty is the one after the key's own =. For a chain whose links hold no =, every value is read exactly as before: a text holding = can only name a link that holds one.

What (a) keeps from the documented rules, and how each case was checked (probes below, and one test row or more each):

One difference from --link-remedy, deliberately. --link-remedy open=y= is a NOTE (its link falls back to the first =, open). --link-source open=y= is refused, because the #442 rule refuses PRODUCT= for every link, and --link-remedy's own openXdox= refusal is made by argparse before the chains are known, which is why it cannot catch open=y=. _read_source's docstring says so.

Not (b). (a) makes the offer the gate already prints true, so the person who types it gets the verification; (b) would only stop offering it. Setup's step (4) is unchanged: it passes the gate its --pin-source as an absolute path, with a separator before every =, so no text before one names a link.

Docs. The module docstring (a new paragraph, "A KEY IS THE LONGEST LINK A VALUE NAMES"), the LinkSources, _link_remedy and _read_remedy docstrings, the --link-source help, and docs/cli.md regenerated from it. setup-project.py's as_the_plan_reads_it docstring stated the first-= rule, and now states this one. README, the handbook and AGENTS.md state no rule for how a --link-source key is read (README names --link-source PRODUCT=<path> only), so they are unchanged. The class docstring records that validate-pins.py still ends a --pin-source key at the first =; no scaffold writes a pin holding one (--pin names are [A-Za-z0-9_.-]), and it is left as its own change.

What a project sees (the upgrade row)

Measured from a root scaffolded at main 5b41d44f (local remotes, no network):

  • update-shape.py check --upstream <this checkout> --at c64e85fea lists upstream-changed scripts/validate-repository-naming.py, with upstream-changed 1 unchanged 10.
  • update-shape.py apply --yes --branch ... exits 0, commits contracts/shape-pin.yaml, project.yaml and the copy, and the project's validators print pins ok and manifest ok. A fresh check prints unchanged 11, and the root's own naming gate exits 0.
  • The new sha256 of scripts/validate-repository-naming.py: ec167fb0e259f90705db895a1a37f4adebd4cbf03ca2d5ae18f6d9a8df896548 (78664 bytes). No other pinned copy and no template changes.

Probes, before and after

Run from empty folders, <D> a fresh folder, trees as named (trees/y declares openInk, trees/x declares open=y; for the chain with open, trees/x2 declares open and trees/o declares open=y). The gate prints an arrow between links; it is written -> here.

Probe At main 5b41d44f At head c64e85fe
P1, the issue's row: --referent-chain openXdox,open=y,openInk --link-source open=y=<D>/trees/y link-source-unreadable ... could not answer for openXdox, "It was read as a PATH: open is not a link", and open=y declared-unverified with the same offer; exit 0 only openXdox declared-unverified (no source named it); no link-source-unreadable; exit 0
P2, P1 plus openXdox=<D>/trees/x, --explain NOTE: the value not used, read as a PATH; CHAIN openXdox -> open=y -> openInk [declared-unverified] [verified], nothing warned or noted
P3, chain openXdox,open,open=y,openInk, open=<o> then open=y=<y> link-source-unreadable: y=<D>/trees/y (--link-source open=...), NOTE open=<o> replaced; 2 links declared-unverified [verified], nothing warned or noted
P4, open=y= (chain without open) bare PATH open=y= asked for openXdox; exit 0 REFUSED link-source-no-path: --link-source open=y= names no path for open=y; exit 2
P5, open=y= (chain with open) y= (--link-source open=...) could not answer for open the same
P6, open=y=<D>/trees/a=b/y read as a PATH, [declared-unverified] [verified]
P7, open=y=x beside a directory open=y=x bare PATH, read as the directory for openXdox link-source-unreadable: x (--link-source open=y=...) could not answer for open=y, then "open=y=x is also a directory; --link-source ./open=y=x reads it as one."
P7b, ./open=y=x the directory, for openXdox the same
P8, chain with open: open=./y=dir, open=y=<y> open=y=<y> read for open, replacing open=./y=dir [verified]

Tests

Eleven new rows in tests/test_chain_remedy_callers.py, section "#980", reusing its helpers (naming, tree, gate_warnings, UNREACHED, GATE_OFFER, EQUALS), with no assert that joins conditions with and:

  • test_the_offer_for_a_link_holding_an_equals_verifies_it[as-printed, in-capitals, a-path-holding-an-equals]: reads the offer the gate prints for open=y, types it with a tree, and asserts the one WARNING left is openXdox's, with no link-source-unreadable and no NOTE.
  • test_each_link_reads_its_own_source_where_one_name_begins_another[open-first, open=y-first, a-path-that-begins-like-the-longer-link]
  • test_a_value_that_ends_at_a_link_holding_an_equals_names_no_path
  • test_a_shorter_link_that_leaves_a_path_is_the_key
  • test_a_link_holding_an_equals_wins_over_a_directory_spelled_like_it
  • test_a_key_is_read_whole[a-blank, an-owner]

Failing at main. The test file copied onto a git archive of 5b41d44f: 8 of the 11 fail, each on the defect (the offer row's open=y WARNING and link-source-unreadable, the prefix rows' [declared-unverified], open=y= exiting 0, the keyed reading not winning over the directory). The other 3 pass at main by design: they pin behaviour main already had (test_a_shorter_link_that_leaves_a_path_is_the_key, test_a_key_is_read_whole[a-blank] and [an-owner]), and each kills a mutant below.

Mutants, each applied alone to the fixed gate in a scratch repository, then test_chain_remedy_callers.py, test_link_source_chain_defaults.py, test_naming_policy.py, test_chain_link_fold.py and test_chain_pin_sources.py (400 passed, 4 skipped with no mutant). All 11 killed:

Mutant Failed Killed by (new rows unless named)
M1 back to typed.partition("=") 8 the offer rows, the prefix rows, open=y=, the directory row
M2 the shortest match instead of the longest 3 the prefix rows
M3 the "leaves a path after it" check dropped 1 test_a_shorter_link_that_leaves_a_path_is_the_key
M4a the ./PATH rule dropped (./ stripped from the key) 3 the directory row; test_a_labelled_source_holding_an_equals_is_not_said_to_be_a_path; test_naming_policy.py::test_a_link_named_before_the_equals_is_the_key_even_over_a_directory
M4b the key cut at its last /, as a remedy's link is 4 as M4a, and test_a_key_is_read_whole[an-owner]
M5 the empty-path fallback dropped 6 open=y=; test_a_value_that_names_no_path_is_spelled; 4 rows of test_link_source_chain_defaults.py::test_a_link_source_that_names_no_path_is_refused
M6 the fallback at the first =, as a remedy's 1 open=y=
M7 no_path taken after the first = 1 open=y=
M8 the keyed path taken after the first = 7 the offer rows, the prefix rows, the directory row
M10 the key stripped, as a remedy's link is 1 test_a_key_is_read_whole[a-blank]
M12 the key not folded by pin_product_key 56 in-capitals and the prefix rows, and 52 existing rows

Runs, all on head c64e85fe, /usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed, one reused basetemp per run, with EMAIL=service@opensoft.dev, a stub gh first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false and GIT_CONFIG_GLOBAL=/dev/null:

  • git 2.43.0 (/usr/bin/git), 31 files: the 25 that run the naming gate (grep -l validate-repository-naming tests/, the pinned-copy files test_pinned_copy_text.py, test_update_shape_kept_helper.py, test_update_shape_unreadable.py and test_adopt_reencoded_files.py among them, and test_repo_hygiene.py), the files that scan every script's source (test_git_output_decoding.py, test_shape_advisory.py, test_no_auto_maintenance.py, test_non_utf8_git_failures.py), and the trio's other two, test_agents_md_codes.py and test_cli_reference.py: 2964 passed, 5 skipped in 1749 s, exit 0.
  • git 2.47.1 first on PATH, the 25 naming-gate files: 2680 passed, 3 skipped in 1181 s, exit 0.

No template changes. grep of tests/ found no fixed digest or ^id:-style pattern over the gate: the tests that read a root's copy of it (test_shape_doctor.py, test_doctor_validator_verdicts.py) compare it at run time, and both are in the first run, as are the source scanners above and test_cli_reference.py, which holds docs/cli.md to the help.

Merge-trees

🤖 Generated with Claude Code

Summary by Sourcery

Fix --link-source parsing so printed verification offers work for chain links whose names contain =, while documenting and testing the updated behavior.

Bug Fixes:

  • Ensure --link-source values correctly verify chain links whose names contain = by selecting the longest matching link that leaves a non-empty path.
  • Refuse --link-source values that identify a matching link but provide no path.

Enhancements:

  • Clarify and align --link-source parsing behavior across the validator's documentation and setup guidance.
  • Add coverage for link-name prefixes, case normalization, paths containing =, directory ambiguity, empty paths, and literal key handling.

Documentation:

  • Update the CLI and validator documentation to describe longest-match --link-source key parsing and links containing =.

Tests:

  • Add regression tests covering --link-source verification offers and matching edge cases for chain links containing =.

…hain link before an `=` that leaves a path after it, so the `--link-source open=y=<path>` the gate offers a link holding `=` verifies that link when typed as printed, and `open=y=` is refused as naming no path (#980)

The gate offered a declared-unverified chain link whose name holds `=`
the source `--link-source open=y=<path>`, and read every `--link-source`
key at the value's first `=`. Typed as printed, the key was `open`, which
no chain names, so the whole value was a bare PATH for the chain's first
link: a `link-source-unreadable` WARNING, and `open=y` stayed
declared-unverified under the same offer, exit 0. In a chain naming `open`
and `open=y` both, `open=y=<path>` was a source for `open`, with the path
`y=<path>`, and it replaced `open=<path>`.

`LinkSources._read_source` now reads a key the way `_read_remedy` reads a
remedy's link since #949, the issue's direction (a): the longest text
before an `=` that names a chain link, by `pin_product_key`, and leaves a
path after it. The key is read whole, never stripped or cut at a `/`, so
`./PATH` still asks for a directory spelled like the whole value, the
keyed reading still wins over such a directory (with the `./` line), and
`<link>=./<path>` gives a link a path that begins like a longer link's
name. A value that ends at a link, `open=y=`, is that link's with no path,
and is refused as `link-source-no-path`, as `openXdox=` is (#442). For a
chain whose links hold no `=`, every value is read as before.

The module and class docstrings, the `--link-source` help and
docs/cli.md say so, and so does setup-project.py's note on why step (4)'s
absolute `--pin-source` is one path to the gate. `validate-pins.py` still
ends a `--pin-source` key at the first `=`; no scaffold writes a pin
holding one, and the docstring says it is left.

What a project sees: `update-shape.py check` lists `upstream-changed
scripts/validate-repository-naming.py` (sha256 ec167fb0e259f907...), and
`apply` re-pins it.

Closes #980

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 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 12 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap

Copy link
Copy Markdown
Contributor Author

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

@sourcery-ai

sourcery-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR fixes --link-source parsing so the gate's remediation offer remains valid for chain links whose names contain =, selecting the longest case-folded link key that leaves a path and correctly refusing empty keyed paths. Documentation and CLI help are synchronized, and extensive regression tests validate overlapping names, path edge cases, directory precedence, and the printed-offer workflow.

Sequence diagram for validating a printed source offer

sequenceDiagram
    participant Gate
    participant LinkSources
    participant ChainLink
    Gate->>LinkSources: _read_source(open=y=<path>)
    LinkSources->>LinkSources: Match longest key open=y
    LinkSources-->>Gate: link=open=y, path=<path>
    Gate->>ChainLink: Verify source tree
    ChainLink-->>Gate: verified
Loading

Flow diagram for --link-source key resolution

flowchart TD
    A["--link-source value"] --> B{"Find matching chain link before ="}
    B --> C["Select longest case-folded match that leaves a path"]
    C --> D["Use matched link and remaining text as path"]
    B --> E["No matching link"]
    E --> F["Treat entire value as bare PATH"]
    D --> G{"Path is empty?"}
    G -->|Yes| H["Refuse link-source-no-path"]
    G -->|No| I["Verify the matched link's tree"]
Loading

File-Level Changes

Change Details Files
Changed --link-source parsing to support chain-link names containing = while preserving path and directory disambiguation rules.
  • Added longest matching chain-link detection that leaves a non-empty path, using case-folded product keys.
  • Refused keyed sources whose selected path is empty, while retaining bare-path fallback behavior.
  • Kept source keys whole, without trimming or slash-based rewriting, and documented the distinction from --link-remedy and --pin-source.
  • Updated CLI help, module/class/function documentation, generated CLI reference, and setup-project planning text.
scripts/validate-repository-naming.py
docs/cli.md
setup-project.py
Added focused regression coverage for source offers, embedded equals signs, overlapping link names, empty paths, directory ambiguity, casing, and literal key handling.
  • Verified that the gate's printed --link-source open=y=<path> offer actually verifies open=y when reused.
  • Covered longest-match behavior for open versus open=y, including paths containing = and paths prefixed with ./.
  • Covered refusal of PRODUCT= and preservation of whole-key behavior for whitespace and owner-prefixed values.
tests/test_chain_remedy_callers.py

Assessment against linked issues

Issue Objective Addressed Explanation
#980 Make a --link-source value naming a chain link that contains = parse the longest matching link name that leaves a path, so the gate’s printed offer verifies that link when typed as printed. ✅
#980 Preserve and document the existing source-reading rules while supporting the new key parsing, including prefix-overlapping link names, paths containing =, the ./PATH directory escape, and refusing a keyed value with no path. ✅
#980 Add regression tests for the reported failure and relevant parsing edge cases, and update the CLI documentation and help text to describe the new behavior. ✅

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

@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-20T06:37:04.808913Z c64e85f 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. Chef's kiss.

Reviewed commit: c64e85fea2

ℹ️ 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 #1044 (#980) at c64e85fe, for lane openRepoShape-5: READY

Reviewed from 09:38Z to 09:59Z, read-only. The head is c64e85fea28ee9fb54581e93abb0e09abdba1d48, one commit on 5b41d44f. Its tree is 82780dc63d7f…, the same as the body says.

Findings, most severe first

There is no MUST and no SHOULD.

NIT-1 (test strength): four of my seven mutants survive the five closest test files (404 passed each time). Each one changes output, and three of them break the body's claim that a chain whose links hold no = is read exactly as before. The claim is true at the head: I checked it by probe (below). But no test holds it in these corners.

  • R10, at scripts/validate-repository-naming.py:439: at = typed.rfind("=", 0, at - 1) skips an = that sits right before the current one.
    • At the head, --link-source openXdox==x (chain openXdox,openYdox,openInk) gives link-source-unreadable: =x (--link-source openXdox=...), the same as main.
    • Under R10 it becomes a bare PATH, and the run adds a false line: "It was read as a PATH: openXdox is not a link".
  • R1, at line 437: typed[at + 1:].strip(). --link-source 'openXdox= ' stops being keyed to the path and becomes a bare PATH, with the same false "not a link" line.
  • R14, at line 385: no_path tests [1].strip(). The same value 'openXdox= ' is then refused, exit 2. At the head it exits 0, as on main.
  • R5, at line 438: return typed[:at], … spells the link as typed. With OPEN=Y=<tree> and the name brett-wip, the NOTE reads "reads OPEN=Y's own declaration" where the head prints open=y's.
  • Suggested fix: three rows would kill all four.
    • openXdox==x (or open=y==): the keyed path =.
    • 'openXdox= ': keyed to a blank path, and not refused.
    • A capitals-typed OPEN=Y=<tree> in a run that reads no declaration: the NOTE names open=y.

NIT-2 (docs, left with the follow-up): templates/assembly-root/scripts/validate-pins.py:1496-1497 still says "the two copied tools read [PRODUCT=]PATH one way over ASCII alone". Since #980 that holds only for names with no =. The naming gate's own class docstring now records the difference (lines 268-272). Fix this sentence in the same follow-up that changes validate-pins.py's first-= reader, not here: it is a pinned copy, so changing it would add a second upgrade row.

Rulings on the writer's open points:

  1. The "It was read as a PATH" line still names only the text before the first = (read_as_path, line 623). Ruling: NIT, follow-up only. Under the new reading the line is always true: a value whose first-= text names a link is always keyed, never bare. It can still mislead:

    • P15: open=z=<p> says "open is not a link".
    • PJ4: --link-source open=y, with no path, says "open is not a link … (links: openXdox, open=y, openInk)".

    Both print the same bytes on main, so this is not a regression. The list of links makes the mistake recoverable.

  2. validate-pins.py still ends a --pin-source key at the first = (line 1520). Ruling: fine as its own change. A scaffold never writes such a pin (PIN_ARG_RE is [A-Za-z0-9_.-], scripts/shape_materialize.py:3215), and the pin digest stops a wrong source from passing.

Checked and true, with numbers

Git Tree Files Result
2.43 head test_chain_remedy_callers, test_link_source_chain_defaults, test_naming_policy, test_chain_pin_sources, test_chain_link_fold, test_pinned_copy_text, and the trio 661 passed, exit 0
2.43 head test_setup_project_py, test_scaffold_referent_chain_links, test_update_shape_kept_helper, test_update_shape_unreadable 591 passed, exit 0
2.47.1 head test_chain_remedy_callers.py 63 passed, exit 0
2.43 merged tree test_chain_remedy_callers, test_cli_reference, test_setup_project_py, test_setup_project_repo_misdrawn, test_repo_hygiene 548 passed, exit 0
  • Merge. git merge-tree --write-tree of the head against a re-fetched origin/main dc7cd49b7f79 is clean, tree 2a117490cfbf. The merged setup-project.py holds both setup-project.py: the self-bootstrap refuses an $OPENREPOSHAPE_REPO holding U+202E or another character the launcher refuses, by code point and in the launcher's words, before it prints the value or runs git clone (#953) #1043's U+202E change and this PR's docstring.
  • Sonar-style checks:
    • _read_source has a cognitive complexity of 7 (loop 1, nested if 2, and 1, if 1, nested if 2).
    • __init__ is 12, plus at most 2 for the comprehension. That is lower than base, because the head removes two conditional expressions.
    • No duplicated block, and no assert joins conditions with and.
    • No changed line is over 79 characters (the gate's existing widest line is 93; the tests' widest stays 79).
  • Pinned copy. No import was added (pin_product_key was already imported), and test_pinned_copy_text.py passes.
  • Docs.
    • The module, LinkSources, _read_source, _read_remedy and _link_remedy docstrings, the --link-source help, docs/cli.md (test_cli_reference passes) and setup's as_the_plan_reads_it docstring all state the new rule truly.
    • README (lines 730-731), docs/handbook.html, AGENTS.md and the release notes state no first-= rule for --link-source. The one doc that still implies the old rule is NIT-2.

Could not verify

  • The body's full 31-file run (2964 passed) and its 25-file run on 2.47.1 (2680 passed). I ran the subsets in the table above.
  • The writer's other eight mutants.
  • The body's claim that none of the 9 open PRs touches these files: it needs gh, and the brief named only main.
  • Git 2.55, the version CI runs.

Checklist candidates

  • When a PR claims an unchanged reading, at least one test row should cover each corner of the old split: a path beginning with =, a blank path, and a key typed in another case. Today the claim rests on a probe, not a test.
  • When a pinned copy's reader changes, grep the other pinned copies for sentences saying the two tools "read one way" (NIT-2's pattern).

Reviewer: rv980, lane 5's independent reviewer, not the writer (w980).

Lane: openreposhape-5 (openRepoShape-5)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 82fc867 into main Oct 20, 2026
5 checks passed
@brettheap
brettheap deleted the fix/980-link-source-equals branch October 20, 2026 10:08
This was referenced Oct 20, 2026
brettheap added a commit that referenced this pull request Oct 20, 2026
…`--link-source` reading at the old first-`=` split's corners (a keyed path that begins with `=`, a keyed blank path, a key in capitals) (#1054) (#1058)

Three test rows hold what #980 (#1044) left unchanged in how `--link-source` reads a value (a keyed path that begins with `=`, a keyed blank path, a key typed in capitals), so each of rv980's four surviving mutants of `scripts/validate-repository-naming.py` fails a test. Fix round 1 (rv1058's NIT 1) adds a fifth mutant to what the rows kill.

#1044 said a chain whose links hold no `=` is read exactly as before. That is true (rv980 compared 800 runs on the base and the head), but it rested on that probe: `tests/test_chain_remedy_callers.py` held the #980 rows for the new reading and none for the corners of the old first-`=` split. Of rv980's seven mutants, R1, R5, R10 and R14 survived the five closest test files (404 passed under each).

Tests only. The one file changed is `tests/test_chain_remedy_callers.py` (72 lines added, none removed, over two commits), in the "#980" section after `test_a_key_is_read_whole`, reusing the file's `THREE`, `EQUALS`, `naming`, `tree`, `gate_warnings`, `UNREACHED` and `clear_ambient_pin_sources`. No assert joins conditions with `and` or `or`. No script, template or doc changes, so no pinned copy moves and a root's `update-shape.py check` gains no row.

Each pins bytes the gate already prints on main (the new parameter of row 3 passes on `8a057215`'s script, which is byte-identical to the one here).

1. `test_a_keyed_path_may_begin_with_an_equals`, two parameters: `openXdox==x` with `THREE` (`a-plain-link`), and `open=y==` with `EQUALS` (`a-link-holding-an-equals`, the issue's parenthetical). Exit 0; the WARNING `link-source-unreadable: =x (--link-source openXdox=...) could not answer for openXdox: no such file or directory` (and `= (--link-source open=y=...) ... for open=y`); no "read as a PATH" line. Kills R10.
2. `test_a_keyed_blank_path_is_one_blank_and_is_not_refused`: `'openXdox= '` with `THREE`. Exit 0; no `REFUSED`; the WARNING `link-source-unreadable:   (--link-source openXdox=...) could not answer for openXdox: no such file or directory` (the path is one blank); no "read as a PATH" line. Kills R1 and R14.
3. `test_a_key_typed_in_capitals_is_noted_as_the_chain_spells_it`, two parameters, with the name `brett-wip`, which reads no declaration. Exit 0, and the one `NOTE ` line is compared as a whole line: `NOTE --link-source <typed>: not used - no name classified here reads <link>'s own declaration.` (the em dash of the gate's text, as the file's other NOTE rows spell it).
   - `a-link-holding-an-equals`: `OPEN=Y=<tree>` with `EQUALS`, the NOTE ends `reads open=y's own declaration.`. Kills R5.
   - `a-capital-inside-a-link` (new in fix round 1, rv1058's NIT 1): `OPENXDOX=<tree>` with `THREE`, the NOTE ends `reads openXdox's own declaration.`. The first parameter's chain, `open=y`, is all lowercase, so it could not tell "the link as the chain spells it" from "the link lowercased"; `openXdox` has a capital X. Kills R5 as well, and M2 (`return link.lower(), ...`), which survived the first head.

Bytes against `5b41d44f`, the base of #1044 (checked with `git archive 5b41d44 contracts scripts` and the same values: stdout, stderr and exit code compared):
- Identical to main: `openXdox==x` (row 1), `'openXdox= '` (row 2) and `OPENXDOX=<tree>` (row 3, the `a-capital-inside-a-link` parameter).
- Different, because they are the new reading of a link holding `=`: `open=y==` with `EQUALS` (row 1's second parameter; at `5b41d44f` it is a bare PATH, `link-source-unreadable: open=y== (--link-source) ... for openXdox`, with "It was read as a PATH: `open` is not a link ...") and `OPEN=Y=<tree>` with `EQUALS` (row 3's first parameter; at `5b41d44f` its NOTE says `reads a chain link's own declaration`, with the same "read as a PATH" sentence).

Left alone, as the issue's "Optional" part says it may be: the "It was read as a PATH" line (`LinkSources.read_as_path`, which names only the text before the first `=`). It is a NIT for a follow-up and #1053's neighbour (`validate-pins.py` has the twin), and changing it touches the pinned `scripts/validate-repository-naming.py`.

Each applied alone to `scripts/validate-repository-naming.py` at the line the issue (or rv1058, for M2) names, in the worktree, restored with the saved copy and read back (sha256 `ec167fb0e259f90705db895a1a37f4adebd4cbf03ca2d5ae18f6d9a8df896548` after each; `git diff` of the script empty). R10's text is also at `:479` in `_read_remedy`, which is not mutated. The whole file (68 tests) is run with each, on git 2.43, at the fix round's head.

| Mutant | The one-line edit | What it prints for the row's value | Fails | Count |
|---|---|---|---|---|
| R10 `:439` | `typed.rfind("=", 0, at)` to `typed.rfind("=", 0, at - 1)` | `openXdox==x` is a bare PATH: `link-source-unreadable: openXdox==x (--link-source) ...` and "It was read as a PATH: `openXdox` is not a link ..." | row 1, both parameters, on the WARNING assert | 2 failed, 66 passed |
| R1 `:437` | `typed[at + 1:]` to `typed[at + 1:].strip()` in `if link is not None and ...` | `'openXdox= '` is a bare PATH: `openXdox=  (--link-source)`, with the same false line | row 2, on the WARNING assert | 1 failed, 67 passed |
| R14 `:385` | `self._read_source(typed)[1]` to `self._read_source(typed)[1].strip()` in `no_path` | `'openXdox= '` is `REFUSED link-source-no-path`, exit 2 (main: 0) | row 2, on `returncode == 0` | 1 failed, 67 passed |
| R5 `:438` | `return link, typed[at + 1:]` to `return typed[:at], typed[at + 1:]` | the NOTE says `reads OPEN=Y's own declaration` (and `OPENXDOX's`) | row 3, both parameters, on the NOTE assert | 2 failed, 66 passed |
| M2 `:438` (rv1058) | `return link, typed[at + 1:]` to `return link.lower(), typed[at + 1:]` | the NOTE says `reads openxdox's own declaration` | row 3, `a-capital-inside-a-link` only, on the NOTE assert | 1 failed, 67 passed |

Each mutant fails only the row it is named for, and the file passes (68) with the script restored. At the first head (`b775c739`, 67 tests) R10 was 2 failed, R1 1, R14 1 and R5 1, and M2 survived with 67 passed.

`EMAIL=service@opensoft.dev`, a stub `gh` first on PATH (it refuses every call), `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`; through `$Q/v110/run9.sh` (`-q -rs -p no:cacheprovider -o tmp_path_retention_policy=failed`, one reused basetemp each). Never the full suite.

- git 2.43.0 (`/usr/bin/git`), `tests/test_chain_remedy_callers.py` alone: 68 passed (63 before this PR, plus 5: row 1 has two parameters, row 3 has two, row 2 is one).
- git 2.43.0, that file with `tests/test_link_source_chain_defaults.py` and the trio (`tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`): 261 passed.
- git 2.47.1 (exactly `$Q/v115/w764/git247/bin/git`, first on PATH), `tests/test_chain_remedy_callers.py`: 68 passed.
- The five new test ids on `8a057215`'s tree (a scratch copy of its `git archive`, with this file copied in): 5 passed. The whole file there: 66 passed, 2 skipped (the two older-library rows need history the scratch copy lacks).
- `git diff --check` clean; no line over 79 characters.

`git merge-tree --write-tree origin/main HEAD`, origin/main `833af144` (fetched with `-c gc.auto=0 -c maintenance.auto=false`, and `git ls-remote` agrees; main moved from `8a057215` by #1041, which touches `adopt-project.py`, `scaffold-project.py` and device-name tests, none of this PR's files): clean, exit 0, tree `bd706fdacef2`. The branch is two commits ahead of the old base `8a057215`.

Review: rv1058, lane 5's independent reviewer, not the writer (w1054): READY at `b775c739` (issuecomment-6289571980) with no MUST or SHOULD; its two NITs were taken in one new commit, and the re-check is READY at `af390352` (issuecomment-6289945831), with no MUST or SHOULD and one optional docstring NIT. CI (Brett Heap's ruling 27): H0 is the head af39035 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 8a05721; and the local gate passed on its predicted squash tree 60843b9 over main 9e154d8: the chain_remedy_callers, repo_hygiene, agents_md_codes, cli_reference sets, 238 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 #1054.
Closes #1054.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 20, 2026
…fore 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)

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.

`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.

`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`.

- With `open=y` declared, `--pin-source open=y=<tree>` was a bare PATH that is not there: the `neutral-pin-source-unreadable` FINDING, exit 1.
- With `open` and `open=y` both declared, `open=y=<tree>` was `open`'s, with the path `y=<tree>`. `open=y` read neither value, and of `open=<a>` and `open=y=<b>` the later replaced the earlier.
- `open=y=` was a bare PATH, where `openGlass=` is refused `pin-source-no-path` (#442).

- **`PinSources._read_source(typed)`** (new, `validate-pins.py`), returning `(product, before, path)`. It reads a key as the naming gate's `LinkSources._read_source` reads a link: the LAST `=` whose text names a declared pin by `pin_product_key`, with a non-empty path after it, ends the key. The key is read whole (never stripped, never cut at a `/`) and the pin is 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. Anything else is a bare PATH, whole.
- **`__init__`, the no-path refusal and `read_as_path` all use it**, so nothing cuts at the first `=` on its own. `self.names` (was a local) is the table it reads against. `read_as_path` keeps its words (#1054's wording improvement is out of scope): the text it names is `_read_source`'s `before`, which for a bare value is the text before the first `=`.
- **Words made true:** the `PinSources` docstring (THE READING, the no-path paragraph, a new "THE TWO COPIED TOOLS READ `[PRODUCT=]PATH` ONE WAY" paragraph that now holds with no exception), the `--pin-source` help, and `docs/cli.md` regenerated from it by `scripts/render-cli-reference.py` (only that option's block changed).
- **Item 5, the gate's `LinkSources` docstring** (`scripts/validate-repository-naming.py`, the only change in that file): the "still ends a key at the first `=`" sentence is gone, and so is the `casefold()` passage (false since #712: both tools key by `pin_product_key`) and the "one way OVER ASCII ONLY (#444)" clause. Four lines say what is true, and name `PinSources._read_source` (#1053).
- **Tests, 17 new rows:** 14 in `tests/test_neutral_pin_validator.py` beside the #295 rows (section "#1053"), and 3 in `tests/test_link_source_chain_defaults.py` beside the #442 rows. A pin holding `=` cannot be scaffolded, so each row hand-writes the `neutral_product_pins:` entry and its pin file with the helper `declare_by_hand` (the link file imports it). The existing #442 row now builds its expected stderr through a shared `pin_source_no_path` helper, with the same assertion.

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.

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

    declared [open=y], value open=y=/p
      at 8a05721   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 8a05721   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 8a05721   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 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.

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`.

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.

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.

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

- `check --at 2e6dd9e` 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 -> 2e6dd9e`, 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...`).

`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.

Review: rv1067, lane 5's independent reviewer, not the writers (w1053 and f1053): READY at `2e6dd9e5` (issuecomment-6297358113) with no MUST; its SHOULD-1 was taken in one new commit, and the re-check is READY at `7ff61666` (issuecomment-6297678467), with no MUST or SHOULD. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 7ff6166 passed tests, tests-git-bash and SonarCloud; the head 5bc0d30 is a clean update-branch merge of main 6c86163, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 9a936b7 over main 6c86163: the link_source_chain_defaults, neutral_pin_validator, repo_hygiene, agents_md_codes, cli_reference sets, 235 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 #1053

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