From 09697e9c52f35c9aadbafdebb88e47950105f474 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 16:19:34 +0000 Subject: [PATCH 1/5] scripts/repo_shape.py: `NamingPolicy` reads a name with `fullmatch` at four sites, so a name that ends in a line feed matches no family and `validate-repository-naming.py` refuses it, and `repository-naming.yaml` says a pattern is read whole (#1202) Every pattern of `contracts/repository-naming.yaml` is `^...$`, and `NamingPolicy` read a name against it with `.match` in `descendant_referents` (the family's pattern, then its `split_pattern`) and in `matches` (a role's pattern, then a family's). In Python `$` also matches just before a trailing line feed, so `Atlas\n` was classified as `Atlas`, and `validate-repository-naming.py --project project.yaml` over a leg `repository: "testorg/Atlas\n"`, or over the argument `$'Atlas\n'`, exited 0. The four sites ask `fullmatch` now. Every shipped pattern begins with `^` and ends with `$`, so no name without a line feed changes its answer. A fork's pattern with no `$` read as a prefix of the name under `.match`; the contract's header now says a pattern is read against the whole name, which is the shorter of the two ways the issue offers. `topic_pattern` has no reader outside `tests/test_naming_policy.py`, so a note beside it says a reader must use `fullmatch`, and no row can fail on main for it. `tests/test_naming_policy_line_break.py` reads its names from the data, so a family or a role added to the contract is covered, and holds a fork's policy whose one pattern has no `$` for each site: the two sites of `descendant_referents` stand in series, and the shipped `split_pattern` takes the family pattern's language, so on the shipped data either alone makes the other redundant. Closes #1202 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- contracts/repository-naming.yaml | 8 + scripts/repo_shape.py | 9 +- tests/test_naming_policy_line_break.py | 242 +++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 tests/test_naming_policy_line_break.py diff --git a/contracts/repository-naming.yaml b/contracts/repository-naming.yaml index a1ec4466..6d856df8 100644 --- a/contracts/repository-naming.yaml +++ b/contracts/repository-naming.yaml @@ -116,6 +116,14 @@ kind: repository-naming-policy # where an unsuffixed name lands. What it does NOT admit is a name with a # hyphen, an underscore, a dot or a space — those match nothing and are # refused, which is the check that actually earns its keep. +# +# A PATTERN IS READ AGAINST THE WHOLE NAME. The reader asks every `pattern:` +# and `split_pattern:` below with `fullmatch` (#1202), as a reader of the +# `topic:` block's must, so a name that ends in a line feed matches none of +# them, where `$` alone would take it as the name without it. The patterns +# here begin with `^` and end with `$`, which `fullmatch` does not need and +# does not mind. A fork's pattern with no `$` is read as the whole name too, +# not as a prefix of it: write the pattern for the entire name. # =========================================================================== families: diff --git a/scripts/repo_shape.py b/scripts/repo_shape.py index 00759f1e..852d272e 100644 --- a/scripts/repo_shape.py +++ b/scripts/repo_shape.py @@ -2228,6 +2228,7 @@ def __init__(self, data: dict): templates += list(referent.get("also_accepted") or []) family["_referent_templates"] = tuple(templates) topic = data.get("topic") or {} + # No tool reads it yet; one that does must use `fullmatch`, for `$` takes a final line feed (#1202). self.topic_pattern = re.compile(topic.get("pattern", r"^xf-project-[a-z0-9-]+$")) self.topic_template = topic.get("template", "xf-project-{id}") @@ -2264,9 +2265,9 @@ def descendant_referents(self, name: str) -> tuple[str, ...]: first entry is canonical and is what a manifest records. """ family = self.family("domain-descendant") - if family is None or not family["_re"].match(name): + if family is None or not family["_re"].fullmatch(name): return () - match = family["_split_re"].match(name) + match = family["_split_re"].fullmatch(name) if not match: return () product = match.groupdict().get("product") @@ -2432,10 +2433,10 @@ def matches(self, name: str, roles = family.get("roles") or [] hit = False for role in roles: - if role["_re"].match(name): + if role["_re"].fullmatch(name): found.append((family["id"], role["id"])) hit = True - if not hit and family["_re"].match(name): + if not hit and family["_re"].fullmatch(name): found.append((family["id"], None)) return found diff --git a/tests/test_naming_policy_line_break.py b/tests/test_naming_policy_line_break.py new file mode 100644 index 00000000..b6a75d9b --- /dev/null +++ b/tests/test_naming_policy_line_break.py @@ -0,0 +1,242 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A repository name that ends in a line feed is the name of no family +(#1202, the follow-up of #1139 and #1166). + +THE DEFECT, measured on main 5926cccab (Python 3.12.3). The patterns of +`contracts/repository-naming.yaml` are every one `^...$`, and `NamingPolicy` +read a name against them with `.match` at four sites: `descendant_referents` +(the family's pattern, then its `split_pattern`), and `matches` (a role's +pattern, then a family's). In Python `$` also matches just before a trailing +line feed, so a name that ends in one was classified as the name without it: + +- `matches("Atlas\\n")` was `[("project-leg", "assembly")]`, as for `Atlas`; + so for `Atlas-spec`, `Atlas-code`, `openFoo`, `Thing-Install`, `user-wip`, + `InkxRouter` and `MedxScribe`, and `descendant_referents` of the last two + gave `openRouter`/`openxRouter` and `openScribe`/`openxScribe`; +- `validate-repository-naming.py --project project.yaml` over a manifest whose + leg `repository:` is `"testorg/Atlas\\n"` (and the `-spec` and `-code` + legs) exited 0, and so did the same name as an argument. The root template + runs it at `Makefile:48` and `validate.yml:310`. + +THE RULE NOW. The four sites ask `fullmatch`, so a name is read against the +whole of every pattern, and `contracts/repository-naming.yaml`'s header says +so for a fork's pattern: one with no `$` read as a prefix under `.match`, and +reads as the whole name now. Every shipped pattern begins with `^` and ends +with `$`, so no name without a line feed changes its answer +(`tests/test_naming_policy.py` passes as it was). `topic_pattern` has no +reader outside `tests/test_naming_policy.py`, so a row cannot fail on main +for it; a note beside it says a reader must use `fullmatch`. + +THE ROWS. The names are read from the data, so a family or a role added to +the contract is covered without a line here. The two sites of +`descendant_referents` stand in series, and the shipped `split_pattern` takes +the language of the family's `pattern`, so either alone makes the other +redundant on the shipped data: a fork's policy, whose one pattern has no `$`, +is what asks each of them by itself. + +No network and no GitHub: the policy is read in process and the project is +the offline one of `conftest.py`. +""" + +from __future__ import annotations + +import sys + +import pytest + +from conftest import REPO, run_script +from test_value_pattern_line_break import findings, rewritten + +sys.path.insert(0, str(REPO / "scripts")) +from repo_shape import NamingPolicy, parse_yaml # noqa: E402 + +POLICY_PATH = REPO / "contracts" / "repository-naming.yaml" +VALIDATOR = REPO / "scripts" / "validate-repository-naming.py" +#: A line feed, as the YAML reader reads a double-quoted `\n`. +LF = chr(10) + + +def policy_of_the_data() -> NamingPolicy: + return NamingPolicy.load(POLICY_PATH) + + +def examples_of_the_data() -> tuple[dict, dict]: + """`({(family id, name): the role to declare}, {(family id, role id, + name): the same})`: every example the data gives a family and every one + it gives a role, so a family or a role added to the contract gets its rows + without a line here. A `declared_only` family is reported only where it is + asked for by its own id.""" + families, roles = {}, {} + for family in policy_of_the_data().families: + declared = family["id"] if family.get("declared_only") else None + for name in family.get("examples") or []: + families[(family["id"], name)] = declared + for role in family.get("roles") or []: + for name in role.get("examples") or []: + roles[(family["id"], role["id"], name)] = declared + return families, roles + + +FAMILIES, ROLES = examples_of_the_data() +FAMILY_ROWS = [pytest.param(family_id, name, declared, + id=f"{family_id}-{name}") + for (family_id, name), declared in FAMILIES.items()] +ROLE_ROWS = [pytest.param(family_id, role_id, name, declared, + id=f"{family_id}-{role_id}-{name}") + for (family_id, role_id, name), declared in ROLES.items()] +#: Every name of the data once, with the role it is asked under. +EVERY_NAME = [pytest.param(name, declared, id=f"{family_id}-{name}") + for (family_id, name), declared in { + **FAMILIES, **{(f, n): d for (f, _r, n), d in ROLES.items()} + }.items()] + + +@pytest.fixture(scope="module") +def policy() -> NamingPolicy: + return policy_of_the_data() + + +# --- the four sites, over the shipped data ----------------------------------- + +@pytest.mark.parametrize("family_id, name, declared", FAMILY_ROWS) +def test_a_name_of_a_family_is_still_matched_without_the_line_feed( + policy, family_id, name, declared): + """The baseline of every row below: what main answered, and still does.""" + matched = [family for family, _role in policy.matches(name, declared)] + assert family_id in matched + + +@pytest.mark.parametrize("family_id, role_id, name, declared", ROLE_ROWS) +def test_a_name_of_a_role_is_still_matched_without_the_line_feed( + policy, family_id, role_id, name, declared): + assert (family_id, role_id) in policy.matches(name, declared) + + +@pytest.mark.parametrize("name, declared", EVERY_NAME) +def test_a_name_that_ends_in_a_line_feed_matches_no_family( + policy, name, declared): + """THE ISSUE'S ROW, for `matches` (a role's pattern, then a family's), for + `classify`, which reads `matches`, and for `descendant_referents`: on main + each was the answer for the name without the line feed.""" + assert policy.matches(name + LF, declared) == [] + assert policy.classify(name + LF, declared) is None + assert policy.descendant_referents(name + LF) == () + + +#: The names that claim a descent: the data's examples, and the one it gives of +#: a claim with no referent. +DESCENDANTS = [pytest.param(name, id=name) for name in ( + policy_of_the_data().family("domain-descendant")["examples"] + + policy_of_the_data().family( + "domain-descendant")["claim_without_referent_examples"])] + + +@pytest.mark.parametrize("name", DESCENDANTS) +def test_a_descendant_name_still_has_its_referents(policy, name): + """The baseline of the row below.""" + assert policy.descendant_referents(name) != () + + +@pytest.mark.parametrize("name", DESCENDANTS) +def test_a_descendant_name_that_ends_in_a_line_feed_has_no_referent( + policy, name): + """On main `InkxRouter\\n` gave `openRouter` and `openxRouter`, as + `InkxRouter` does.""" + assert policy.descendant_referents(name + LF) == () + assert policy.descendant_referent(name + LF) is None + + +# --- a fork's pattern with no `$` -------------------------------------------- + +def fork_policy(family_id: str, key: str, pattern: str, + role_id: str | None = None) -> NamingPolicy: + """The shipped policy with ONE pattern of a fork's own: the family's + `pattern`, a role's, or the referent's `split_pattern`.""" + data = parse_yaml(POLICY_PATH.read_text(encoding="utf-8")) + family = next(f for f in data["families"] if f["id"] == family_id) + if key == "split_pattern": + family["referent"]["split_pattern"] = pattern + elif role_id is not None: + next(r for r in family["roles"] if r["id"] == role_id)[key] = pattern + else: + family[key] = pattern + return NamingPolicy(data) + + +#: The two sites of `descendant_referents` stand in series, and the shipped +#: `split_pattern` takes the family pattern's language, so each is asked by +#: itself only where the fork's two differ: the pattern of one has no `$`. +#: `MedxS` is a whole name of either pattern; `MedxScribe` is a name of the +#: shipped other one, and a prefix-match of the fork's. +@pytest.mark.parametrize("key, pattern", [ + pytest.param("pattern", "^[A-Za-z][A-Za-z0-9]*x[A-Z]", + id="family-pattern"), + pytest.param("split_pattern", "^(?P[A-Za-z]+)x(?P[A-Z])", + id="split-pattern"), +]) +def test_a_forks_descendant_pattern_with_no_dollar_is_read_as_the_whole_name( + key, pattern): + """`.match` read the pattern as a prefix of the name; the header of the + contract says a pattern is read against the whole of it.""" + fork = fork_policy("domain-descendant", key, pattern) + assert fork.descendant_referents("MedxS") == ("openS", "openxS") + assert fork.descendant_referents("MedxScribe") == () + + +#: The same for the two sites of `matches`. `Atlas` is a whole name of the +#: fork's pattern, as of the shipped one; `Atlas_x` is a prefix-match of it only. +@pytest.mark.parametrize("role_id", [ + pytest.param("assembly", id="role-pattern"), + pytest.param(None, id="family-pattern"), +]) +def test_a_forks_leg_pattern_with_no_dollar_is_read_as_the_whole_name(role_id): + fork = fork_policy("project-leg", "pattern", "^[A-Za-z][A-Za-z0-9]*", + role_id) + assert fork.matches("Atlas") == [("project-leg", "assembly")] + assert fork.matches("Atlas_x") == [] + + +# --- the gate ------------------------------------------------------------------ + +@pytest.mark.parametrize("repository", [ + pytest.param("testorg/Atlas", id="assembly"), + pytest.param("testorg/Atlas-spec", id="spec"), + pytest.param("testorg/Atlas-code", id="code"), +]) +def test_the_naming_gate_refuses_a_leg_repository_that_ends_in_a_line_feed( + project, repository): + """On main `validate-repository-naming.py --project` exited 0 over each + of the three legs, with the name and its family on two lines.""" + name = repository.split("/")[1] + rewritten(project / "project.yaml", f" repository: {repository}\n", + f' repository: "{repository}\\n"\n') + result = run_script(VALIDATOR, "--project", str(project / "project.yaml")) + assert result.returncode == 1, result.stdout + result.stderr + assert findings(result.stderr) == [f"FINDING naming-unclassified: {name}"] + + +def test_the_naming_gate_takes_a_leg_repository_double_quoted(project): + """The baseline of the row above: the quotes are not what is refused.""" + rewritten(project / "project.yaml", " repository: testorg/Atlas\n", + ' repository: "testorg/Atlas"\n') + result = run_script(VALIDATOR, "--project", str(project / "project.yaml")) + assert result.returncode == 0, result.stdout + result.stderr + + +ARGV_NAMES = ["Atlas", "Atlas-spec", "Atlas-code"] + + +@pytest.mark.parametrize("name", ARGV_NAMES) +def test_the_naming_gate_takes_an_argv_name(name): + """The baseline of the row below.""" + result = run_script(VALIDATOR, name) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("name", ARGV_NAMES) +def test_the_naming_gate_refuses_an_argv_name_that_ends_in_a_line_feed(name): + """A name from the command line is not stripped (a name from stdin is): + on main each was classified, its name and family on two lines.""" + result = run_script(VALIDATOR, name + LF) + assert result.returncode == 1, result.stdout + result.stderr + assert findings(result.stderr) == [f"FINDING naming-unclassified: {name}"] From 4b1653d8be69223969ca5c5eec4dfba20dc0d5c5 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 17:39:50 +0000 Subject: [PATCH 2/5] validate-repository-naming.py: every name the gate tables or refuses prints through `spelled()`, so a name with a line feed or an ESC byte is one row and one `naming-unclassified` name, with its column worked out from the spelled form (#1267) With #1266 (#1202) a name that ends in a line feed is refused `naming-unclassified`, but the gate printed it raw: the table row split in two, and `FINDING naming-unclassified: Atlas` was followed by a blank line, which reads as the finding for a valid `Atlas`. A name with an inner line feed split the same way and an ESC byte reached the terminal. Ten print sites now spell the name (`repo_shape.spelled`, in every tag since v1.0.0, so the import is not skewed): the plain listing, whose label column is worked out from the spelled name; the `--explain` NO FAMILY and classified headers, CLAIM note, WARNING and OVERLAP lines; the stderr `WARNING : ` line; the role finding and the broken-chain finding of `--project`, whose sentence quotes the name, so the whole line is spelled; and the `naming-unclassified` finding. No code, flag or exit status changes, and a name without a control prints byte for byte as it did. tests/test_naming_gate_spelled.py holds the rows (31 of 32 fail on #1266's head). tests/test_naming_policy_line_break.py's two rows that asserted the raw finding now assert the spelled one. Closes #1267 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- scripts/validate-repository-naming.py | 34 ++- tests/test_naming_gate_spelled.py | 334 +++++++++++++++++++++++++ tests/test_naming_policy_line_break.py | 13 +- 3 files changed, 365 insertions(+), 16 deletions(-) create mode 100644 tests/test_naming_gate_spelled.py diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index 4f3d7977..5d2afda9 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -101,6 +101,15 @@ character in it is not printable (`_shown`). A caller's FLAG and the words it offers for a link, a chain link's name, and the `--project` and `--policy` paths are printed as given. +Every repository name this prints is `repo_shape.spelled` (#1267): in the +plain listing, in `--explain`, in the `naming-unclassified` and role +findings, and in a WARNING, a broken-chain finding or an `--explain` line +whose sentence quotes it. A line feed, an ESC or another control in a name is +written as the escape Python spells it (`Atlas\\n`), so a name stays on its +line, a listing holds one row per name with its columns where a plain name's +are, and a finding for `Atlas` plus a line feed cannot read as one for +`Atlas`. A name with no such character prints as it did. `spelled` is in +every `scripts/repo_shape.py` this file can meet, unlike `shown_path`. EXIT CODES, the same three in every validator this standard ships: 0 every name classified (and, with `--project`, every declared role and @@ -147,7 +156,7 @@ CHAIN_RECORD_FIELD, LINK_SOURCE_ABSENT, LINK_SOURCE_NO_MANIFEST, UNAMBIGUOUS_FORMS, Classification, NamingPolicy, Refusal, link_pins_from_trees, load_yaml, pin_product_key, - pin_source_env_collisions, repo_basename, + pin_source_env_collisions, repo_basename, spelled, ) DEFAULT_POLICY = Path(__file__).resolve().parents[1] / "contracts" / "repository-naming.yaml" @@ -788,7 +797,7 @@ def _no_family_lines(policy: NamingPolicy, name: str) -> list[str]: one question, answered once, so the per-family row below never has to guard against a `found` that does not exist. """ - lines = [f" {name}: NO FAMILY"] + lines = [f" {spelled(name)}: NO FAMILY"] lines.append(f" the {len(policy.families)} families are: " + ", ".join( f["id"] for f in policy.families)) for family in policy.families: @@ -829,7 +838,7 @@ def _referent_claim_note(policy: NamingPolicy, family: dict, name: str) -> str: (2026-09-05). """ return (" [a CLAIM: needs a declared pin on " + " or ".join( - policy.descendant_referents(name)) + map(spelled, policy.descendant_referents(name))) + ", or a declared chain (`" + str(policy.chain_rule().get("record_field") or CHAIN_RECORD_FIELD) @@ -932,9 +941,9 @@ def _referent_lines(target: Target, matches: list, lines.append(" CHAIN " + " → ".join(target.chain) + f" [{found.referent.status}]") for warning in found.referent.warnings: - lines.append(" WARNING " + warning) + lines.append(" WARNING " + spelled(warning)) if len(matches) > 1: - lines.append(" OVERLAP " + found.reason) + lines.append(" OVERLAP " + spelled(found.reason)) lines.append(" also_matches: " + ", ".join(found.also_matches)) return lines @@ -952,7 +961,8 @@ def _describe(policy: NamingPolicy, target: Target, link_pins: dict) -> list[str if not matches: return _no_family_lines(policy, name) found = policy.classify(name, role, pins, target.chain, link_pins) - lines = [f" {name}: {found[0]}" + (f" / {found[1]}" if found[1] else "")] + lines = [f" {spelled(name)}: {found[0]}" + + (f" / {found[1]}" if found[1] else "")] for family in policy.families: lines.append(_family_row(policy, family, matches, role, name, found)) lines.extend(_referent_lines(target, matches, found)) @@ -1109,14 +1119,14 @@ def _leg_target(policy: NamingPolicy, path: Path, leg, root: Path, # true by declaring it. if found and found[0] == "project-leg" and declared and found[1] != declared: findings.append( - f"{name}: declared role {declared!r} but the name is the " - f"{found[1]!r} form of the project-leg family" + f"{spelled(name)}: declared role {declared!r} but the name is " + f"the {found[1]!r} form of the project-leg family" ) # A RECORDED chain that does not hold is drift in the record, and the # finding names the link. Unverified is not this: it is a warning, # printed by the caller, and never an exit code. if found and found.referent.status == "broken": - findings.append(f"{name}: {found.referent.reason}") + findings.append(spelled(f"{name}: {found.referent.reason}")) return target, findings @@ -1294,7 +1304,7 @@ def _print_classification(name: str, found: Classification | None) -> None: label = found[0] + role_suffix also = f" also_matches {','.join(found.also_matches)}" \ if found and found.also_matches else "" - print(f" {name:<32} {label}{also}") + print(f" {spelled(name):<32} {label}{also}") def _classify_targets(policy: NamingPolicy, targets: list, args, @@ -1318,7 +1328,7 @@ def _classify_targets(policy: NamingPolicy, targets: list, args, elif not args.quiet: _print_classification(name, found) if found: - warnings.extend(f"{name}: {text}" + warnings.extend(spelled(f"{name}: {text}") for text in found.referent.warnings) else: unclassified.append(name) @@ -1355,7 +1365,7 @@ def _emit_reports(args, unclassified: list[str], warnings: list[str], if unclassified: print( "FINDING naming-unclassified: " - + ", ".join(unclassified) + + ", ".join(map(spelled, unclassified)) + "\n These names match none of the families in " + str(args.policy) + ".\n A project's three repositories are ``, " diff --git a/tests/test_naming_gate_spelled.py b/tests/test_naming_gate_spelled.py new file mode 100644 index 00000000..fc9fbbb1 --- /dev/null +++ b/tests/test_naming_gate_spelled.py @@ -0,0 +1,334 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The naming gate prints every name through `repo_shape.spelled` (#1267, the +follow-up of #1202's NIT 2). + +THE DEFECT, measured on #1266's head 09697e9c5 (Python 3.12.3, git 2.43.0). +With #1266 a name that ends in a line feed is refused `naming-unclassified`, +but `validate-repository-naming.py` printed the name RAW: + +- stdout: `' Atlas'`, then `' NO FAMILY'` on the next + line, so the row of the table split in two; a name with an inner line feed + split the same way, and a name with an ESC byte put the byte on the + terminal; +- stderr: `FINDING naming-unclassified: Atlas`, then a blank line, which reads + exactly as the finding for a valid `Atlas`. + +THE RULE NOW. A name the gate prints is `repo_shape.spelled`: a line feed is +`\\n`, an ESC is `\\x1b`, and a name without such a character prints as it +did. Ten places print a name or a sentence that quotes it, and each has a row: + +- the plain listing (`_print_classification`), whose column is worked out from + the SPELLED name, so every row keeps its label where a plain name's is; +- `--explain`: the NO FAMILY header, the header of a classified name, the + `[a CLAIM: ...]` note (the referents it derives from the name), and the + WARNING and OVERLAP sentences, which quote the name; +- the `WARNING : ` line on stderr; +- the role finding and the broken-chain finding of `--project`, whose + sentence quotes the name, so the WHOLE line is spelled; +- the `naming-unclassified` finding. + +THE ROWS. The shipped contract classifies none of the three names (#1202), so +the table, the `--explain` header and the finding are asked of it. The other +sites print a CLASSIFIED name, which only a fork whose patterns take an ESC +and a line feed can have, so those rows read the shipped contract with its +character classes widened (`loosened`). A descendant name ends in its product, +which the direct pin reads, so the control is in its stem; the three kinds are +asked wherever the name's end is free. + +No network and no GitHub: the policy is read in process by a script that runs +offline. +""" + +from __future__ import annotations + +import pytest + +from conftest import REPO, run_script +from test_value_pattern_line_break import findings + +VALIDATOR = REPO / "scripts" / "validate-repository-naming.py" +POLICY_PATH = REPO / "contracts" / "repository-naming.yaml" +#: A line feed and an ESC, as the shell would hand them to argv. +LF = chr(10) +ESC = chr(27) +#: The arrow the chain sentences join links with. +ARROW = chr(0x2192) + +#: `(raw name, how the gate must print it)`: the three names of the issue. +ENDS_IN_LF = ("Atlas" + LF, "Atlas\\n") +INNER_LF = ("At" + LF + "las", "At\\nlas") +WITH_ESC = ("At" + ESC + "[31mlas", "At\\x1b[31mlas") +NAMES = [pytest.param(*ENDS_IN_LF, id="ends-in-lf"), + pytest.param(*INNER_LF, id="inner-lf"), + pytest.param(*WITH_ESC, id="esc")] + + +def spelled_of(raw: str) -> str: + """How `raw` must be printed, worked out by hand and not by the library + under test: the two controls of the rows are written as Python spells + them.""" + return raw.replace(LF, "\\n").replace(ESC, "\\x1b") + + +def lines_after_the_header(stdout: str) -> list[str]: + """What `--explain` prints after a name's header line; each starts with + four spaces. A name that split its header would leave a line that does + not.""" + return stdout.splitlines()[1:] + + +def not_indented(lines: list[str]) -> list[str]: + return [line for line in lines if not line.startswith(" ")] + + +@pytest.fixture(scope="module") +def unclassified_tail() -> list[str]: + """The two lines after the finding, as a plain unclassified name gets + them.""" + return run_script(VALIDATOR, "Atlas_spec").stderr.splitlines()[1:] + + +@pytest.fixture(scope="module") +def plain_explain() -> list[str]: + """The lines `--explain` prints for a plain name that matches no family + after its header.""" + return lines_after_the_header( + run_script(VALIDATOR, "--explain", "Atlas_spec").stdout) + + +def loosened() -> str: + """The shipped contract with every character class `[A-Za-z0-9]` widened + by an ESC, a line feed and `[` (a YAML double-quoted `\\\\x1b` is the + regex `\\x1b`), so a fork's pattern takes the names of the rows.""" + text = POLICY_PATH.read_text(encoding="utf-8") + assert text.count("A-Za-z0-9]") == 12, "fixture drift: the contract moved" + return text.replace("A-Za-z0-9]", r"A-Za-z0-9\\x1b\\n\\[]") + + +@pytest.fixture(scope="module") +def loose(tmp_path_factory) -> str: + """The path of that fork's policy, as the `--policy` value.""" + path = tmp_path_factory.mktemp("loose") / "repository-naming.yaml" + path.write_text(loosened(), encoding="utf-8") + return str(path) + + +# --- the shipped contract: the table, the header and the finding -------------- + +@pytest.mark.parametrize("raw, said", NAMES) +def test_the_finding_prints_the_spelled_name_on_one_line(raw, said, + unclassified_tail): + """THE ISSUE'S ROW: on 09697e9c5 `Atlas` plus a line feed gave a blank + line after `FINDING naming-unclassified: Atlas`, and an ESC reached the + terminal.""" + result = run_script(VALIDATOR, raw) + assert result.returncode == 1, result.stdout + result.stderr + assert result.stderr.splitlines() == [ + f"FINDING naming-unclassified: {said}", *unclassified_tail] + + +@pytest.mark.parametrize("raw, said", NAMES) +def test_the_table_has_one_row_for_the_name_with_its_label_in_place(raw, said): + """The row is ` NO FAMILY`, the width worked out + from the SPELLED name: the escape is longer than the byte it writes, so a + width taken from the raw name leaves the label one column (or three) off + the plain rows'.""" + result = run_script(VALIDATOR, raw) + assert result.stdout == f" {said:<32} NO FAMILY\n" + + +def test_a_run_of_all_three_and_a_plain_name_has_four_rows_in_line(): + """The table keeps one row per name, and the labels stand in one column.""" + raws = [ENDS_IN_LF[0], INNER_LF[0], WITH_ESC[0]] + result = run_script(VALIDATOR, *raws, "Atlas") + assert result.returncode == 1, result.stdout + result.stderr + assert result.stdout.splitlines() == [ + f" {ENDS_IN_LF[1]:<32} NO FAMILY", + f" {INNER_LF[1]:<32} NO FAMILY", + f" {WITH_ESC[1]:<32} NO FAMILY", + f" {'Atlas':<32} project-leg/assembly"] + assert findings(result.stderr) == [ + "FINDING naming-unclassified: " + f"{ENDS_IN_LF[1]}, {INNER_LF[1]}, {WITH_ESC[1]}"] + + +def test_a_plain_valid_name_prints_exactly_as_it_did(): + """The control: no name without a control changes a byte. Fixed here as + the text the table has always had.""" + result = run_script(VALIDATOR, "Atlas", "Atlas-spec", "Atlas-code", + "openChart", "Hermes-Install") + assert result.returncode == 0, result.stdout + result.stderr + assert result.stderr == "" + assert result.stdout.splitlines() == [ + f" {'Atlas':<32} project-leg/assembly", + f" {'Atlas-spec':<32} project-leg/spec", + f" {'Atlas-code':<32} project-leg/code", + f" {'openChart':<32} neutral-product " + "also_matches project-leg/assembly", + f" {'Hermes-Install':<32} install"] + + +@pytest.mark.parametrize("raw, said", NAMES) +def test_explain_names_the_spelled_name_in_the_no_family_header( + raw, said, plain_explain): + result = run_script(VALIDATOR, "--explain", raw) + assert result.returncode == 1, result.stdout + result.stderr + assert result.stdout.splitlines()[0] == f" {said}: NO FAMILY" + assert lines_after_the_header(result.stdout) == plain_explain + + +def test_a_name_on_stdin_is_spelled_too(unclassified_tail): + """Stdin strips a line, so it can hold the ESC and not the line feed.""" + raw, said = WITH_ESC + result = run_script(VALIDATOR, input=raw + LF) + assert result.returncode == 1, result.stdout + result.stderr + assert result.stdout == f" {said:<32} NO FAMILY\n" + assert result.stderr.splitlines() == [ + f"FINDING naming-unclassified: {said}", *unclassified_tail] + + +# --- a fork whose patterns take the names: the sites of a classified name ----- + +@pytest.mark.parametrize("raw, said", NAMES) +def test_a_classified_name_has_one_row_in_the_listing(raw, said, loose): + """The same print site as the table above, for a name a fork classifies: + on 09697e9c5 the row split after the name, and the ESC was raw.""" + result = run_script(VALIDATOR, "--policy", loose, raw) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout == f" {said:<32} project-leg/assembly\n" + + +@pytest.mark.parametrize("raw, said", NAMES) +def test_explain_names_the_spelled_name_in_the_header_of_a_classified_name( + raw, said, loose): + result = run_script(VALIDATOR, "--policy", loose, "--explain", raw) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.splitlines()[0] == f" {said}: project-leg / assembly" + assert not_indented(lines_after_the_header(result.stdout)) == [] + + +#: The product of a descendant name is where its referents come from, so the +#: three kinds are asked there. +PRODUCTS = [ + pytest.param("InkxRouter" + LF, "openRouter\\n", "openxRouter\\n", + id="ends-in-lf"), + pytest.param("InkxRou" + LF + "ter", "openRou\\nter", "openxRou\\nter", + id="inner-lf"), + pytest.param("InkxRou" + ESC + "[31mter", "openRou\\x1b[31mter", + "openxRou\\x1b[31mter", id="esc"), +] + + +@pytest.mark.parametrize("raw, direct, wide", PRODUCTS) +def test_the_claim_note_spells_the_referents_it_derives_from_the_name( + raw, direct, wide, loose): + """`[a CLAIM: needs a declared pin on openRouter or openxRouter ...]` + quotes two names made from the product, and the product holds the + control.""" + result = run_script(VALIDATOR, "--policy", loose, "--explain", "--role", + "assembly", raw) + assert result.returncode == 0, result.stdout + result.stderr + claim = [line for line in result.stdout.splitlines() if "a CLAIM" in line] + assert len(claim) == 1, result.stdout + assert (f"[a CLAIM: needs a declared pin on {direct} or {wide}, or a " + "declared chain (`naming.referent_chain`)") in claim[0] + assert not_indented(lines_after_the_header(result.stdout)) == [] + + +def broken_said(said: str, held: str, tail: str = "") -> str: + """The sentence `resolve_referent` gives a chain from the pin `held` that + ends at `openQ`, for the name `said`: it quotes the name.""" + return (f"the declared chain {held} {ARROW} openQ is broken (it ends at " + f"openQ, but {said} would need openDox or openxDox){tail}") + + +DIRECT = "; the direct pin on openDox is what classifies this name" +PINS = ("--pins", "openDox", "--referent-chain", "openDox,openQ") +NO_DIRECT_PINS = ("--pins", "openAdox", "--referent-chain", "openAdox,openQ") + + +STEMS = [ + pytest.param("co" + LF + "dexDox", id="inner-lf"), + pytest.param("co" + ESC + "[31mdexDox", id="esc"), +] + + +@pytest.mark.parametrize("raw", STEMS) +def test_the_warning_on_stderr_spells_the_name_it_starts_with_and_quotes( + raw, loose): + """`WARNING : `, and the sentence says the name again + (`but would need`): spelling the first leaves the second raw.""" + said = spelled_of(raw) + result = run_script(VALIDATOR, "--policy", loose, *PINS, raw) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stderr == ( + f"WARNING {said}: {broken_said(said, 'openDox', DIRECT)}\n") + assert result.stdout == (f" {said:<32} domain-descendant " + "also_matches project-leg/assembly\n") + + +@pytest.mark.parametrize("raw", STEMS) +def test_explain_spells_the_warning_that_quotes_the_name(raw, loose): + said = spelled_of(raw) + result = run_script(VALIDATOR, "--policy", loose, "--explain", *PINS, raw) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.splitlines()[0] == f" {said}: domain-descendant" + assert (f" WARNING {broken_said(said, 'openDox', DIRECT)}" + in result.stdout.splitlines()), result.stdout + assert not_indented(lines_after_the_header(result.stdout)) == [] + + +@pytest.mark.parametrize("raw", STEMS) +def test_explain_spells_the_overlap_sentence_that_quotes_the_name(raw, loose): + """No direct pin: the chain is broken, the name is a project leg, and the + OVERLAP line quotes the broken chain's sentence, name included.""" + said = spelled_of(raw) + result = run_script(VALIDATOR, "--policy", loose, "--explain", + *NO_DIRECT_PINS, raw) + assert result.returncode == 0, result.stdout + result.stderr + overlap = [line for line in result.stdout.splitlines() + if line.startswith(" OVERLAP ")] + assert overlap == [ + f" OVERLAP descendant form, {broken_said(said, 'openAdox')} " + f"{ARROW} assembly root by its residual project-leg form"] + + +def write_manifest(tmp_path, raw: str, role: str, naming: str = "", + pins: str = "") -> str: + """A manifest with ONE leg named `raw`, as a person's editor writes the + name: a double-quoted scalar with a YAML escape.""" + path = tmp_path / "project.yaml" + path.write_text( + "id: atlas\n" + pins + "legs:\n" + f" - role: {role}\n" + f' repository: "testorg/{spelled_of(raw)}"\n' + naming, + encoding="utf-8") + return str(path) + + +@pytest.mark.parametrize("raw, said", NAMES) +def test_the_role_finding_spells_the_name(raw, said, loose, tmp_path): + """`: declared role 'code' but the name is the 'assembly' form of + the project-leg family`: the leg is declared `code` and named like an + assembly root.""" + manifest = write_manifest(tmp_path, raw, "code") + result = run_script(VALIDATOR, "--policy", loose, "--project", manifest) + assert result.returncode == 1, result.stdout + result.stderr + assert result.stderr == ( + f"FINDING {said}: declared role 'code' but the name is the " + "'assembly' form of the project-leg family\n") + assert result.stdout == f" {said:<32} project-leg/assembly\n" + + +@pytest.mark.parametrize("raw", STEMS) +def test_the_broken_chain_finding_spells_the_name_it_starts_with_and_quotes( + raw, loose, tmp_path): + said = spelled_of(raw) + manifest = write_manifest( + tmp_path, raw, "assembly", + naming=" naming:\n referent_chain: [openAdox, openQ]\n", + pins="neutral_product_pins:\n - openAdox\n") + result = run_script(VALIDATOR, "--policy", loose, "--project", manifest) + assert result.returncode == 1, result.stdout + result.stderr + assert result.stderr == ( + f"FINDING {said}: {broken_said(said, 'openAdox')}\n") diff --git a/tests/test_naming_policy_line_break.py b/tests/test_naming_policy_line_break.py index b6a75d9b..5885627f 100644 --- a/tests/test_naming_policy_line_break.py +++ b/tests/test_naming_policy_line_break.py @@ -206,13 +206,16 @@ def test_a_forks_leg_pattern_with_no_dollar_is_read_as_the_whole_name(role_id): def test_the_naming_gate_refuses_a_leg_repository_that_ends_in_a_line_feed( project, repository): """On main `validate-repository-naming.py --project` exited 0 over each - of the three legs, with the name and its family on two lines.""" + of the three legs, with the name and its family on two lines. The finding + names it as `repo_shape.spelled` does, `Atlas\\n` (#1267): printed raw it + read as the finding for `Atlas`, with a blank line after it.""" name = repository.split("/")[1] rewritten(project / "project.yaml", f" repository: {repository}\n", f' repository: "{repository}\\n"\n') result = run_script(VALIDATOR, "--project", str(project / "project.yaml")) assert result.returncode == 1, result.stdout + result.stderr - assert findings(result.stderr) == [f"FINDING naming-unclassified: {name}"] + assert findings(result.stderr) == [ + f"FINDING naming-unclassified: {name}\\n"] def test_the_naming_gate_takes_a_leg_repository_double_quoted(project): @@ -236,7 +239,9 @@ def test_the_naming_gate_takes_an_argv_name(name): @pytest.mark.parametrize("name", ARGV_NAMES) def test_the_naming_gate_refuses_an_argv_name_that_ends_in_a_line_feed(name): """A name from the command line is not stripped (a name from stdin is): - on main each was classified, its name and family on two lines.""" + on main each was classified, its name and family on two lines. The + finding names it spelled, `Atlas\\n` (#1267).""" result = run_script(VALIDATOR, name + LF) assert result.returncode == 1, result.stdout + result.stderr - assert findings(result.stderr) == [f"FINDING naming-unclassified: {name}"] + assert findings(result.stderr) == [ + f"FINDING naming-unclassified: {name}\\n"] From 4fa9f78a644c515a4c20dc33f80de9d6b67d037f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 19:50:00 +0000 Subject: [PATCH 3/5] validate-repository-naming.py and test_naming_gate_spelled.py: the docstring says every name the gate classifies or refuses is spelled and a sentence that quotes one is spelled whole, and rows with a CR and a backslash pin `spelled` itself (#1267) Review round on #1271 (rv1271, NITs 1 and 2). NIT 1. The module docstring said a chain link and a caller's words were "printed as given" in one paragraph, and that every repository name was spelled in the next. A sentence that quotes a name is spelled whole (a WARNING, a broken-chain finding, an `--explain` line), so a chain link or a caller's `--link-remedy` words inside it are spelled too, while the CHAIN line, `link-source-unreadable` and the NOTEs print a chain link as given. The docstring now says that and nothing else in the script changes: its syntax tree outside the docstring is equal to 4b1653d8b's. NIT 2. No row pinned that the gate calls `spelled` and not another escaper: one that escaped only a line feed and an ESC, or one built on `repr`, passed every row. A name ending in a CR (spelled `\r`) and a name holding a backslash and a line feed (a backslash prints as it is, so it is not doubled) are now asked of the finding and the table on the shipped contract: the first fails under the narrow escaper and the second under `repr`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- scripts/validate-repository-naming.py | 23 +++++++++-------- tests/test_naming_gate_spelled.py | 36 +++++++++++++++++++-------- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index 5d2afda9..ff33b440 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -98,18 +98,21 @@ Every `--link-source` and `--link-remedy` value and path this prints, and the path of a `SHAPE_PIN_SOURCE_` or of a checkout beside the project it names, is named as it is, or as Python's `repr` spells it where a -character in it is not printable (`_shown`). A caller's FLAG and the words -it offers for a link, a chain link's name, and the `--project` and -`--policy` paths are printed as given. -Every repository name this prints is `repo_shape.spelled` (#1267): in the +character in it is not printable (`_shown`). A caller's FLAG and the +`--project` and `--policy` paths are printed as given. +Every name this classifies or refuses is `repo_shape.spelled` (#1267): in the plain listing, in `--explain`, in the `naming-unclassified` and role findings, and in a WARNING, a broken-chain finding or an `--explain` line -whose sentence quotes it. A line feed, an ESC or another control in a name is -written as the escape Python spells it (`Atlas\\n`), so a name stays on its -line, a listing holds one row per name with its columns where a plain name's -are, and a finding for `Atlas` plus a line feed cannot read as one for -`Atlas`. A name with no such character prints as it did. `spelled` is in -every `scripts/repo_shape.py` this file can meet, unlike `shown_path`. +whose sentence quotes it. A sentence that quotes a name is spelled WHOLE, so +a chain link or the words a caller offers for one (`--link-remedy`) inside it +is spelled too. A chain link that stands outside such a sentence is printed +as given: in the `CHAIN` line, in `link-source-unreadable` and in the NOTEs +that name one. A line feed, an ESC or another control in a name is written as +the escape Python spells it (`Atlas\\n`), so a name stays on its line, a +listing holds one row per name with its columns where a plain name's are, and +a finding for `Atlas` plus a line feed cannot read as one for `Atlas`. A name +with no such character prints as it did. `spelled` is in every +`scripts/repo_shape.py` this file can meet, unlike `shown_path`. EXIT CODES, the same three in every validator this standard ships: 0 every name classified (and, with `--project`, every declared role and diff --git a/tests/test_naming_gate_spelled.py b/tests/test_naming_gate_spelled.py index fc9fbbb1..83f49c67 100644 --- a/tests/test_naming_gate_spelled.py +++ b/tests/test_naming_gate_spelled.py @@ -28,12 +28,16 @@ - the `naming-unclassified` finding. THE ROWS. The shipped contract classifies none of the three names (#1202), so -the table, the `--explain` header and the finding are asked of it. The other -sites print a CLASSIFIED name, which only a fork whose patterns take an ESC -and a line feed can have, so those rows read the shipped contract with its -character classes widened (`loosened`). A descendant name ends in its product, -which the direct pin reads, so the control is in its stem; the three kinds are -asked wherever the name's end is free. +the table, the `--explain` header and the finding are asked of it. Two more +names, asked of the table and the finding, pin `spelled` ITSELF and not only +that the gate calls it: a name with a CR, which a gate that escaped just a line +feed and an ESC would print raw, and a name with a backslash, which prints as +it is and which `repr` would double. The other sites print a CLASSIFIED name, +which only a fork whose patterns take an ESC and a line feed can have, so +those rows read the shipped contract with its character classes widened +(`loosened`). A descendant name ends in its product, which the direct pin +reads, so the control is in its stem; the three kinds are asked wherever the +name's end is free. No network and no GitHub: the policy is read in process by a script that runs offline. @@ -48,9 +52,10 @@ VALIDATOR = REPO / "scripts" / "validate-repository-naming.py" POLICY_PATH = REPO / "contracts" / "repository-naming.yaml" -#: A line feed and an ESC, as the shell would hand them to argv. +#: A line feed, an ESC and a CR, as the shell would hand them to argv. LF = chr(10) ESC = chr(27) +CR = chr(13) #: The arrow the chain sentences join links with. ARROW = chr(0x2192) @@ -62,6 +67,16 @@ pytest.param(*INNER_LF, id="inner-lf"), pytest.param(*WITH_ESC, id="esc")] +#: Two names that pin `spelled` itself. A gate that escaped only a line feed +#: and an ESC would print the CR raw, and one that spelled with `repr` would +#: double the backslash: `spelled` writes a CR as `\r` and leaves a backslash +#: as it is (the line feed beside it is still `\n`), so the name holding it +#: is not doubled. +ENDS_IN_CR = ("Atlas" + CR, "Atlas\\r") +WITH_BACKSLASH = ("At\\las" + LF, "At\\las\\n") +SPELLED_ITSELF = [pytest.param(*ENDS_IN_CR, id="ends-in-cr"), + pytest.param(*WITH_BACKSLASH, id="backslash")] + def spelled_of(raw: str) -> str: """How `raw` must be printed, worked out by hand and not by the library @@ -115,19 +130,20 @@ def loose(tmp_path_factory) -> str: # --- the shipped contract: the table, the header and the finding -------------- -@pytest.mark.parametrize("raw, said", NAMES) +@pytest.mark.parametrize("raw, said", NAMES + SPELLED_ITSELF) def test_the_finding_prints_the_spelled_name_on_one_line(raw, said, unclassified_tail): """THE ISSUE'S ROW: on 09697e9c5 `Atlas` plus a line feed gave a blank line after `FINDING naming-unclassified: Atlas`, and an ESC reached the - terminal.""" + terminal. The last two names pin what `spelled` writes: `\\r`, and a + backslash as it is.""" result = run_script(VALIDATOR, raw) assert result.returncode == 1, result.stdout + result.stderr assert result.stderr.splitlines() == [ f"FINDING naming-unclassified: {said}", *unclassified_tail] -@pytest.mark.parametrize("raw, said", NAMES) +@pytest.mark.parametrize("raw, said", NAMES + SPELLED_ITSELF) def test_the_table_has_one_row_for_the_name_with_its_label_in_place(raw, said): """The row is ` NO FAMILY`, the width worked out from the SPELLED name: the escape is longer than the byte it writes, so a From 76e99ec862fd6f39f1cfeb8834c8528d52646c22 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 10 Nov 2026 04:18:45 +0000 Subject: [PATCH 4/5] validate-repository-naming.py and test_naming_gate_spelled.py: the docstring names the exception to `_shown` (the words a `--link-remedy` offers, spelled in a WARNING), and rows with an RLO and a ZWJ name pin `spelled` against five rivals (#1267) Review round on #1271 (rv1271b, NITs 1 and 2). NIT 1. Five rival escapers passed every row, because no name held a character outside LF, CR, ESC and a backslash: one that escaped the C0 controls only, one that escaped the category Cc only, one that left the bidirectional controls out, one built on `str.isprintable` (which `spelled`'s own docstring says is not the test), and one that wrote a table of LF, CR and ESC. A name with U+202E (RLO), asked of the finding and of the table row on the shipped contract, is printed raw by the first, second, third and fifth. A name with U+200D (ZWJ), asked of the finding only (it takes no column, so a table padded by display width would not keep its label in the plain rows' column), is written as `\u200d` by the fourth. Three new ids: the finding for each, and the table row for the RLO name. NIT 2. The first paragraph of the module docstring said every `--link-remedy` value this prints is named as it is or by `_shown`. The words a `declared-unverified` WARNING offers stand inside its sentence and are spelled with it, without quotes (`run\tmake \x1b[31mfetch`). The sentence now names that one exception. Docstring only: the script's syntax tree outside the docstring is equal to 4fa9f78a6's. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- scripts/validate-repository-naming.py | 7 ++-- tests/test_naming_gate_spelled.py | 52 ++++++++++++++++++++------- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index ff33b440..04378f48 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -98,8 +98,11 @@ Every `--link-source` and `--link-remedy` value and path this prints, and the path of a `SHAPE_PIN_SOURCE_` or of a checkout beside the project it names, is named as it is, or as Python's `repr` spells it where a -character in it is not printable (`_shown`). A caller's FLAG and the -`--project` and `--policy` paths are printed as given. +character in it is not printable (`_shown`). The one exception is the WORDS of +a `--link-remedy` where a `declared-unverified` WARNING offers them: they stand +inside its sentence and are spelled with it, as the next paragraph says, so a +TAB or an ESC in them is `\\t` or `\\x1b`, without quotes. A caller's FLAG and +the `--project` and `--policy` paths are printed as given. Every name this classifies or refuses is `repo_shape.spelled` (#1267): in the plain listing, in `--explain`, in the `naming-unclassified` and role findings, and in a WARNING, a broken-chain finding or an `--explain` line diff --git a/tests/test_naming_gate_spelled.py b/tests/test_naming_gate_spelled.py index 83f49c67..2eb05b08 100644 --- a/tests/test_naming_gate_spelled.py +++ b/tests/test_naming_gate_spelled.py @@ -28,11 +28,19 @@ - the `naming-unclassified` finding. THE ROWS. The shipped contract classifies none of the three names (#1202), so -the table, the `--explain` header and the finding are asked of it. Two more -names, asked of the table and the finding, pin `spelled` ITSELF and not only -that the gate calls it: a name with a CR, which a gate that escaped just a line -feed and an ESC would print raw, and a name with a backslash, which prints as -it is and which `repr` would double. The other sites print a CLASSIFIED name, +the table, the `--explain` header and the finding are asked of it. Four more +names pin `spelled` ITSELF and not only that the gate calls it, so that a +rival escaper fails a row: a name with a CR, which a gate that escaped just a +line feed and an ESC would print raw; a name with a backslash, which prints as +it is and which `repr` would double; a name with U+202E (a right-to-left +override, a bidirectional control that is no C0 or C1 control, which a gate +that escaped controls only, or a table of LF, CR and ESC, would print raw); and +a name with U+200D (a zero-width joiner, which `spelled` leaves as it is and +which a gate that escaped every character `str.isprintable` refuses would +write as `\\u200d`). The first three are asked of the table and the finding. +The joiner is asked of the finding only: it takes no column, so a table that +pads by display width would not keep its label where a plain name's is. The +other sites print a CLASSIFIED name, which only a fork whose patterns take an ESC and a line feed can have, so those rows read the shipped contract with its character classes widened (`loosened`). A descendant name ends in its product, which the direct pin @@ -67,15 +75,32 @@ pytest.param(*INNER_LF, id="inner-lf"), pytest.param(*WITH_ESC, id="esc")] -#: Two names that pin `spelled` itself. A gate that escaped only a line feed -#: and an ESC would print the CR raw, and one that spelled with `repr` would -#: double the backslash: `spelled` writes a CR as `\r` and leaves a backslash -#: as it is (the line feed beside it is still `\n`), so the name holding it +#: Names that pin `spelled` itself. A gate that escaped only a line feed and +#: an ESC would print the CR raw, and one that spelled with `repr` would +#: double the backslash: `spelled` writes a CR as `\\r` and leaves a backslash +#: as it is (the line feed beside it is still `\\n`), so the name holding it #: is not doubled. ENDS_IN_CR = ("Atlas" + CR, "Atlas\\r") WITH_BACKSLASH = ("At\\las" + LF, "At\\las\\n") +#: U+202E (RLO) is a bidirectional control that reorders the text around it, so +#: a name holding it can read as another name. `spelled` writes it as +#: `\\u202e`, and a rival that escaped only the C0 controls, only the category +#: Cc, or only LF, CR and ESC would print it raw. It is ASCII once spelled, so +#: the table row can hold it too. +RLO = chr(0x202E) +WITH_RLO = ("At" + RLO + "las", "At\\u202elas") SPELLED_ITSELF = [pytest.param(*ENDS_IN_CR, id="ends-in-cr"), - pytest.param(*WITH_BACKSLASH, id="backslash")] + pytest.param(*WITH_BACKSLASH, id="backslash"), + pytest.param(*WITH_RLO, id="rlo")] + +#: U+200D (ZWJ) is a format character `spelled` leaves as it is, so a person's +#: emoji sequence or Indic name reads as typed; a rival built on +#: `str.isprintable` would write it as `\\u200d`. It takes no column, so only +#: the finding is asked of it, never the table (a table padded by display width +#: would not keep its label in the plain rows' column). +ZWJ = chr(0x200D) +KEPT_AS_IT_IS = [pytest.param("At" + ZWJ + "las", "At" + ZWJ + "las", + id="zwj")] def spelled_of(raw: str) -> str: @@ -130,13 +155,14 @@ def loose(tmp_path_factory) -> str: # --- the shipped contract: the table, the header and the finding -------------- -@pytest.mark.parametrize("raw, said", NAMES + SPELLED_ITSELF) +@pytest.mark.parametrize("raw, said", NAMES + SPELLED_ITSELF + KEPT_AS_IT_IS) def test_the_finding_prints_the_spelled_name_on_one_line(raw, said, unclassified_tail): """THE ISSUE'S ROW: on 09697e9c5 `Atlas` plus a line feed gave a blank line after `FINDING naming-unclassified: Atlas`, and an ESC reached the - terminal. The last two names pin what `spelled` writes: `\\r`, and a - backslash as it is.""" + terminal. The last four names pin what `spelled` writes: `\\r`, a + backslash as it is, a right-to-left override as `\\u202e`, and a + zero-width joiner as it is.""" result = run_script(VALIDATOR, raw) assert result.returncode == 1, result.stdout + result.stderr assert result.stderr.splitlines() == [ From c56753ba79790e6e7a21673d3d440e2a61506e70 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 10 Nov 2026 05:00:22 +0000 Subject: [PATCH 5/5] tests/test_naming_gate_spelled.py: four `#:` comments write a line feed, a CR, U+202E and U+200D with one backslash, as a comment reads them, and not two; comments only, no row, code or docstring changes rv1271c's delta check of #1271 (NIT 1) found that the round of 76e99ec86 wrote the escapes in four `#:` comments with a doubled backslash. A doubled backslash is right in a string literal or a docstring, where Python reads it as one, and wrong in a comment, where it reads literally. The comments are the lines that name `\r`, `\n`, `\u202e` and `\u200d` (lines 80, 81, 87 and 98 of the file). Lines 80 and 81 read `\r` and `\n` at 4fa9f78a6 and were doubled while the paragraph was re-wrapped; they read as they did there again. Lines 87 and 98 are the new rows' comments and are fixed the same way. Comments only. The syntax tree of the file (ast.dump, include_attributes=False) is equal before and after, a tokenize comparison shows 4 differing tokens and all 4 are COMMENT, and the diff is four `#` lines. No comment holds a doubled backslash now. No code, row, docstring or string literal changed, so no row's outcome can change; the file's 39 rows are re-run on git 2.43.0 and 2.47.1. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_naming_gate_spelled.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_naming_gate_spelled.py b/tests/test_naming_gate_spelled.py index 2eb05b08..8bf3cb69 100644 --- a/tests/test_naming_gate_spelled.py +++ b/tests/test_naming_gate_spelled.py @@ -77,14 +77,14 @@ #: Names that pin `spelled` itself. A gate that escaped only a line feed and #: an ESC would print the CR raw, and one that spelled with `repr` would -#: double the backslash: `spelled` writes a CR as `\\r` and leaves a backslash -#: as it is (the line feed beside it is still `\\n`), so the name holding it +#: double the backslash: `spelled` writes a CR as `\r` and leaves a backslash +#: as it is (the line feed beside it is still `\n`), so the name holding it #: is not doubled. ENDS_IN_CR = ("Atlas" + CR, "Atlas\\r") WITH_BACKSLASH = ("At\\las" + LF, "At\\las\\n") #: U+202E (RLO) is a bidirectional control that reorders the text around it, so #: a name holding it can read as another name. `spelled` writes it as -#: `\\u202e`, and a rival that escaped only the C0 controls, only the category +#: `\u202e`, and a rival that escaped only the C0 controls, only the category #: Cc, or only LF, CR and ESC would print it raw. It is ASCII once spelled, so #: the table row can hold it too. RLO = chr(0x202E) @@ -95,7 +95,7 @@ #: U+200D (ZWJ) is a format character `spelled` leaves as it is, so a person's #: emoji sequence or Indic name reads as typed; a rival built on -#: `str.isprintable` would write it as `\\u200d`. It takes no column, so only +#: `str.isprintable` would write it as `\u200d`. It takes no column, so only #: the finding is asked of it, never the table (a table padded by display width #: would not keep its label in the plain rows' column). ZWJ = chr(0x200D)