diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index 4bfafa63..c568e33b 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -98,9 +98,24 @@ 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. +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 +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 @@ -148,7 +163,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" @@ -862,7 +877,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: @@ -904,7 +919,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) @@ -1008,9 +1023,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 @@ -1028,7 +1043,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)) @@ -1185,14 +1201,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 @@ -1372,7 +1388,7 @@ def _print_classification(name: str, found: Classification | None) -> None: if found and found.also_matches else "" # Padded by the columns the name takes as stdout writes it (#1217), so # a wide or combining name's label starts under every other row's. - print(f" {_padded(_printed(name), 32)} {label}{also}") + print(f" {_padded(_printed(spelled(name)), 32)} {label}{also}") def _classify_targets(policy: NamingPolicy, targets: list, args, @@ -1396,7 +1412,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) @@ -1433,7 +1449,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..8bf3cb69 --- /dev/null +++ b/tests/test_naming_gate_spelled.py @@ -0,0 +1,376 @@ +# 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. 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 +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, 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) + +#: `(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")] + +#: 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_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: + """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 + 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 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() == [ + f"FINDING naming-unclassified: {said}", *unclassified_tail] + + +@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 + 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"]