From 552763d40f29a5ab599eaa6e83218a9151d236c2 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:34:33 +0000 Subject: [PATCH 1/2] shape_materialize.py: `render` puts every placeholder's value in in one pass and refuses only a placeholder the template holds with no value, so `--elected-by '{{SPEC_PATH}}'` is no longer recorded as `elected_by: "spec"`, and `{{PROJECT}}` or `{{NOPE}}` in a free-text value no longer refuses `template-unsubstituted` after the legs are pushed (#260) `render` replaced the keys one after another, each over the whole text, so the value an earlier key put in was searched by every later key. A token for a later key was expanded; one for an earlier key, or one no key names, was left in the output and refused as a defect in the tool, after `adopt-project.py execute` had pushed both legs, after the scaffold had created all three repositories, and after `family.py init` had created the holder's. Now one regular-expression substitution reads the template once and looks each placeholder up in the table, so a value is written as typed. In a YAML template the quoted form `"{{KEY}}"` is matched first and spelled by `double_quoted`, as #284 made it. The refusal asks the template which placeholders the table lacks, which is the defect its remediation names. `PLACEHOLDER_RE` now takes digits. `{{SPEC_TREE_SHA256}}` and the three other digest placeholders were substituted only because the old loop replaced every key by name; the single pass replaces what the pattern finds, and a digest placeholder a table lacked had been left in the output with nothing said. `tests/test_template_quoted_values.py` builds its tables with the same pattern and takes digits with it. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/shape_materialize.py | 52 ++- tests/test_template_quoted_values.py | 6 +- tests/test_template_values_single_pass.py | 401 ++++++++++++++++++++++ 3 files changed, 446 insertions(+), 13 deletions(-) create mode 100644 tests/test_template_values_single_pass.py diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index f6505665..a96ff16f 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -414,7 +414,18 @@ def neutral_pin_path(product: str) -> str: "scripts/siblings.py", ) -PLACEHOLDER_RE = re.compile(r"\{\{[A-Z_]+\}\}") +#: A `{{KEY}}` placeholder: capitals, digits and underscores between double +#: braces. The digits are what make `{{SPEC_TREE_SHA256}}` one, which the +#: pattern `[A-Z_]+` did not see (#260). A workflow's own `${{ github.x }}` +#: and a Python f-string's `{{commit}}` hold a space or a lower-case letter, +#: so neither is one. Every key of every substitution table is spelled this +#: way, because `render` replaces only what this pattern finds. +PLACEHOLDER_RE = re.compile(r"\{\{[A-Z0-9_]+\}\}") +#: The same placeholder, or one standing alone between a YAML template's +#: double quotes, `"{{ELECTED_BY}}"`, which is matched FIRST: group 1 is the +#: quoted placeholder, and a bare one leaves it None. +YAML_PLACEHOLDER_RE = re.compile( + '"(' + PLACEHOLDER_RE.pattern + ')"|' + PLACEHOLDER_RE.pattern) #: THE ONE READING OF THE LINE A FAMILY HOLDER'S README ENDS WITH. #: `templates/family-root/README.md` closes with ``Shape: `` @ @@ -1055,20 +1066,39 @@ def render(text: str, values: dict[str, str], source: str) -> str: The spelling is the value itself for any text without a quote, a backslash or a character `str.isprintable` refuses -- a commit, a digest, an ordinary name -- so no other template changes by a byte. + + ONE PASS, SO A VALUE IS NEVER READ AGAIN (#260). The keys used to be + replaced one after another, each over the whole text, so the value an + earlier key put in was scanned by every later key. `--elected-by + '{{SPEC_PATH}}'` was recorded as `elected_by: "spec"` under `adoption + verified`. `{{PROJECT}}`, a key that came earlier, or `{{NOPE}}`, which + no key names, was left in the output and refused as a defect in the + tool, after the legs were pushed. Now the template is read once, left to + right, and each placeholder is replaced by its value as it is met. The + text a value brings in is never searched, so it is written as typed. + + THE REFUSAL ASKS THE TEMPLATE, NOT THE OUTPUT. A placeholder the TEMPLATE + holds and the table has no value for is the defect the remediation + names, and it is refused before anything is written. The output is not + searched: it now holds the person's own text, which may hold `{{` and + is no defect of anybody's. """ - out = text - yaml_file = source.endswith((".yaml", ".yml")) - for key, value in values.items(): - if yaml_file: - out = out.replace('"{{' + key + '}}"', double_quoted(str(value))) - out = out.replace("{{" + key + "}}", str(value)) - left = PLACEHOLDER_RE.findall(out) - if left: + missing = sorted({token for token in PLACEHOLDER_RE.findall(text) + if token[2:-2] not in values}) + if missing: raise Refusal("template-unsubstituted", - f"{source}: no value for {sorted(set(left))}", + f"{source}: no value for {missing}", "Remediation: this is a defect in the substitution table " "of the tool you ran, not in your invocation.") - return out + + def value_of(placeholder: str) -> str: + return str(values[placeholder[2:-2]]) + + if not source.endswith((".yaml", ".yml")): + return PLACEHOLDER_RE.sub(lambda match: value_of(match[0]), text) + return YAML_PLACEHOLDER_RE.sub( + lambda match: (double_quoted(value_of(match[1])) if match[1] + else value_of(match[0])), text) def _local_strays(src: Path) -> frozenset[str]: diff --git a/tests/test_template_quoted_values.py b/tests/test_template_quoted_values.py index 7168df5e..05e0dece 100644 --- a/tests/test_template_quoted_values.py +++ b/tests/test_template_quoted_values.py @@ -33,8 +33,10 @@ from repo_shape import load_yaml, parse_yaml # noqa: E402 from shape_materialize import render # noqa: E402 -PLACEHOLDER = re.compile(r"\{\{([A-Z_]+)\}\}") -QUOTED_PLACEHOLDER = re.compile(r'"\{\{([A-Z_]+)\}\}"') +#: Digits too: `{{SHAPE_TREE_SHA256}}` is a placeholder, and `render` refuses +#: a table that has no value for it (#260). +PLACEHOLDER = re.compile(r"\{\{([A-Z0-9_]+)\}\}") +QUOTED_PLACEHOLDER = re.compile(r'"\{\{([A-Z0-9_]+)\}\}"') #: Every YAML template that quotes a placeholder. Found, not listed, so a #: template that gains a quoted field is covered without an edit here. diff --git a/tests/test_template_values_single_pass.py b/tests/test_template_values_single_pass.py new file mode 100644 index 00000000..9a52d7db --- /dev/null +++ b/tests/test_template_values_single_pass.py @@ -0,0 +1,401 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A free-text value is written as typed, whatever placeholder it holds (#260). + +`render` replaced the placeholders one key at a time, each over the whole +text, so the text an earlier key put in was searched by every later key. +`--elected-by`, `--reference`, the scaffold's `--name` and the family's +`--name` and `--created-by` are free text, so each could hold another +placeholder's token: + +- a token for a key that came LATER was expanded: `adopt-project.py + --elected-by '{{SPEC_PATH}}'` recorded `elected_by: "spec"` and said + `adoption verified`; +- a token for a key that came EARLIER, or one no key names, stayed in the + output, and `render` refused it as `template-unsubstituted`, "a defect in + the substitution table of the tool you ran", after the legs were pushed. + +`render` now reads the template once and puts each value in as it meets +its placeholder, so a value is never searched. It refuses only a placeholder +the TEMPLATE holds and the table has no value for. + +NO NETWORK AND NO GITHUB: templates rendered in memory, and every tool run +into local bare remotes (`--local-remote-dir`), with a `gh` on PATH that +records any call. What each run wrote is read back with `git show` from the +repository it pushed to, so no line ending a checkout chose is compared. +""" + +from __future__ import annotations + +import ast +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from conftest import ADOPT, ORG, REPO, SCAFFOLD, make_source_repo, run_script + +sys.path.insert(0, str(REPO / "scripts")) +from repo_shape import parse_yaml # noqa: E402 +from shape_materialize import Refusal, render # noqa: E402 + +FAMILY = REPO / "scripts" / "family.py" +TEMPLATES = REPO / "templates" + +#: A placeholder as this test spells one, independent of the module under +#: test: capitals, digits and underscores, starting with a capital. +KEY_TOKEN = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}") +KEY_NAME = re.compile(r"[A-Z][A-Z0-9_]*") + +REMEDIATION = ("Remediation: this is a defect in the substitution table of " + "the tool you ran, not in your invocation.") + + +def token(key: str) -> str: + return "{{" + key + "}}" + + +def template_files() -> dict[str, str]: + """Every text file under `templates/`, by its path from the repository.""" + found = {} + for path in sorted(TEMPLATES.rglob("*")): + if path.is_file(): + try: + found[path.relative_to(REPO).as_posix()] = path.read_text( + encoding="utf-8") + except UnicodeDecodeError: + continue + return found + + +TEMPLATE_TEXT = template_files() + + +def keys_named_by_the_templates() -> set[str]: + return {key for text in TEMPLATE_TEXT.values() + for key in KEY_TOKEN.findall(text)} + + +def keys_spelled_by(tool: Path) -> set[str]: + """Every capitals string key of a dict the tool's source spells. Its + substitution table is among them (`ORG` and the family's `FAMILY` + included, which no template names), and so are a few that are no key of + any table, which only makes a value hold more tokens.""" + tree = ast.parse(tool.read_text(encoding="utf-8")) + return {key.value for node in ast.walk(tree) if isinstance(node, ast.Dict) + for key in node.keys + if isinstance(key, ast.Constant) and isinstance(key.value, str) + and KEY_NAME.fullmatch(key.value)} + + +def every_key(*tools: Path) -> list[str]: + """Every key of each tool's table, and every key a template names.""" + return sorted(keys_named_by_the_templates().union( + *(keys_spelled_by(tool) for tool in tools))) + + +def holding_every_key(prefix: str, *tools: Path) -> str: + """One value holding every key's token, bare and between double quotes: + the quoted form is the one a YAML template's quoted field is matched by.""" + return " ".join([prefix] + [f'{token(key)} "{token(key)}"' + for key in every_key(*tools)]) + + +def stand_in(key: str) -> str: + """An ordinary value for `key`: no brace, no quote, no backslash.""" + if "SHA256" in key: + return "0123456789abcdef" * 4 + if "COMMIT" in key: + return "c0ffee" * 6 + "abcd" + if key.endswith("_ON"): + return "2026-09-02" + if key.endswith("_NAMING"): + return " naming:\n form: project-leg\n role: spec" + if key == "NEUTRAL_PRODUCT_PINS": + return "[]" + if key == "SHAPE_FILES": + return ' - path: Makefile\n sha256: "' + "ab" * 32 + '"' + return f"v-{key.lower()} é" + + +def table(text: str, free: dict[str, str]) -> dict[str, str]: + """`free` for the free-text keys, FIRST, then an ordinary stand-in for + every other key `text` names or a tool's table carries. In that order a + loop of replacements would search each free-text value with every other + key, as the tools' own tables did with most of theirs.""" + rest = sorted(set(KEY_TOKEN.findall(text)).union(ALL_TOOL_KEYS)) + return {**free, **{key: stand_in(key) for key in rest if key not in free}} + + +#: The YAML templates with free-text fields: the field, and the key that +#: fills it. +YAML_FREE_TEXT = { + "templates/assembly-root/project.yaml": { + "name": "PROJECT_NAME", "elected_by": "ELECTED_BY", + "reference": "REFERENCE"}, + "templates/family-root/family.yaml": { + "name": "FAMILY_NAME", "created_by": "CREATED_BY"}, +} +FREE_TEXT_KEYS = sorted({key for fields in YAML_FREE_TEXT.values() + for key in fields.values()}) +#: Every template a person reads rather than the YAML reader that carries a +#: free-text value: the READMEs and the AGENTS.md of each root and leg. +MARKDOWN_FREE_TEXT = sorted( + rel for rel, text in TEMPLATE_TEXT.items() + if not rel.endswith((".yaml", ".yml")) + and set(KEY_TOKEN.findall(text)) & set(FREE_TEXT_KEYS)) + +ALL_TOOL_KEYS = every_key(SCAFFOLD, ADOPT, FAMILY) + + +def test_the_templates_under_test_are_found(): + assert "templates/assembly-root/README.md" in MARKDOWN_FREE_TEXT + assert "templates/spec-root/AGENTS.md" in MARKDOWN_FREE_TEXT + assert "templates/family-root/README.md" in MARKDOWN_FREE_TEXT + assert {"PROJECT", "SPEC_PATH", "SPEC_TREE_SHA256", "ORG", + "FAMILY"} <= set(ALL_TOOL_KEYS) + + +# --- render ---------------------------------------------------------------- + +@pytest.mark.parametrize("spelling", ["bare", "quoted"]) +@pytest.mark.parametrize("key", [*ALL_TOOL_KEYS, "NOPE"]) +def test_a_value_holding_any_placeholder_is_written_as_typed(key, spelling): + """Each key's token, and one no key names, as every free-text value: + every YAML template reads it back unchanged, and every README and + AGENTS.md holds it exactly where it holds an ordinary value.""" + value = token(key) if spelling == "bare" else f'"{token(key)}"' + for rel, fields in YAML_FREE_TEXT.items(): + text = TEMPLATE_TEXT[rel] + written = parse_yaml(render(text, table(text, dict.fromkeys( + FREE_TEXT_KEYS, value)), rel)) + assert {field: written[field] for field in fields} == dict.fromkeys( + fields, value), rel + sentinel = "SENTINEL-VALUE-SENTINEL" + for rel in MARKDOWN_FREE_TEXT: + text = TEMPLATE_TEXT[rel] + ordinary = render(text, table(text, dict.fromkeys( + FREE_TEXT_KEYS, sentinel)), rel) + assert sentinel in ordinary, rel + assert render(text, table(text, dict.fromkeys( + FREE_TEXT_KEYS, value)), rel) == ordinary.replace(sentinel, value) + + +@pytest.mark.parametrize("source", ["README.md", "project.yaml"]) +def test_no_key_reads_what_another_key_wrote(source): + """Whichever key comes first in the table, neither reads the other's + value: each is put in where its own placeholder stood, and only there.""" + values = {"A": token("B"), "B": token("A")} + assert render("{{A}}|{{B}}", values, source) == "{{B}}|{{A}}" + assert render("{{B}}|{{A}}", values, source) == "{{A}}|{{B}}" + quoted = render('a: "{{A}}"\nb: "{{B}}"\n', values, source) + if source.endswith(".yaml"): + assert parse_yaml(quoted) == {"a": token("B"), "b": token("A")} + else: + assert quoted == 'a: "{{B}}"\nb: "{{A}}"\n' + + +@pytest.mark.parametrize("source, text, values, missing", [ + pytest.param("README.md", "By {{ELECTED_BY}}, for {{NOPE}}.\n", + {"ELECTED_BY": "{{ALSO_NOPE}} {{ELECTED_BY}} {{NOPE}}"}, + ["{{NOPE}}"], id="markdown"), + pytest.param("project.yaml", 'elected_by: "{{ELECTED_BY}}"\nx: {{NOPE}}\n', + {"ELECTED_BY": "{{ALSO_NOPE}} {{ELECTED_BY}}"}, + ["{{NOPE}}"], id="yaml"), + pytest.param("project.yaml", 'x: "{{NOPE}}"\ny: "{{ELECTED_BY}}"\n', + {"PROJECT": "Atlas", "ELECTED_BY": "{{PROJECT}}"}, + ["{{NOPE}}"], id="yaml-quoted-placeholder"), + pytest.param("contracts/spec-pin.yaml", + 'tree_sha256: "{{SPEC_TREE_SHA256}}"\n', {}, + ["{{SPEC_TREE_SHA256}}"], id="a-key-with-digits"), +]) +def test_the_refusal_names_what_the_template_lacks_and_nothing_a_value_holds( + source, text, values, missing): + """A placeholder the TEMPLATE holds and the table has no value for is + still refused, before anything is written, and the refusal names those + and only those: never a token that came in with a value. A key with + digits, `{{SPEC_TREE_SHA256}}`, is a placeholder like any other, where + it used to be left in the output with nothing said.""" + with pytest.raises(Refusal) as caught: + render(text, values, source) + assert caught.value.code == "template-unsubstituted" + assert caught.value.detail == f"{source}: no value for {missing}" + assert caught.value.remediation == REMEDIATION + + +def test_text_that_is_no_placeholder_is_left_alone(): + """A workflow's own `${{ github.x }}` and a Python f-string's doubled + braces hold a space or a lower-case letter: neither is a placeholder, + so neither is replaced nor refused. The value beside them, which holds + its own key's token, is written as typed.""" + text = ("run: echo ${{ github.repository_owner }}\n" + 'ref: f"{commit}^{{commit}}" {{ELECTED_BY}}\n') + value = "{{x}} {{ELECTED_BY}}" + assert render(text, {"ELECTED_BY": value}, "validate.yml") == ( + "run: echo ${{ github.repository_owner }}\n" + 'ref: f"{commit}^{{commit}}" ' + value + "\n") + + +@pytest.mark.parametrize("template", sorted( + rel for rel, text in TEMPLATE_TEXT.items() if KEY_TOKEN.search(text))) +def test_an_ordinary_value_renders_as_a_plain_substitution(template): + """For values with no brace, quote or backslash, one pass writes exactly + what replacing each key in turn wrote, in every template that holds a + placeholder, so no project's files change by a byte.""" + text = TEMPLATE_TEXT[template] + values = table(text, {}) + plain = text + for key, value in values.items(): + plain = plain.replace(token(key), value) + assert render(text, values, template) == plain + + +# --- the tools, end to end --------------------------------------------------- + +def shown(repository: Path, spec: str) -> str: + """`git show ` in `repository`, as UTF-8 text: the bytes the run + committed and pushed, not a checkout's copy of them.""" + proc = subprocess.run(["git", "show", spec], cwd=str(repository), + capture_output=True, check=False) + assert proc.returncode == 0, proc.stderr.decode("utf-8", "replace") + return proc.stdout.decode("utf-8") + + +@pytest.fixture +def gh_stub(tmp_path) -> tuple[dict, Path]: + """A `gh` first on PATH that records any call and fails. Every run here + is into local bare remotes, which calls no `gh`; the log says so. It is a + `#!` script, so only a POSIX host runs it; on Windows it is inert.""" + bin_dir, log = tmp_path / "bin", tmp_path / "gh.log" + bin_dir.mkdir() + stub = bin_dir / "gh" + stub.write_bytes(b'#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$GH_STUB_LOG"\n' + b"exit 1\n") + stub.chmod(0o755) + return ({"PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}", + "GH_STUB_LOG": str(log)}, log) + + +def assert_gh_never_ran(log: Path) -> None: + assert not log.exists(), log.read_bytes().decode("utf-8", "replace") + + +def free_text_values(kind: str, prefixes: tuple, tool: Path) -> list[str]: + if kind == "every-key": + return [holding_every_key(prefix, tool) for prefix in prefixes] + return [token("NOPE")] * len(prefixes) + + +KINDS = ["every-key", "nope"] + + +@pytest.mark.parametrize("kind", KINDS) +def test_the_scaffold_records_each_free_text_value_as_typed(kind, tmp_path, + gh_stub): + """`--name`, `--elected-by` and `--reference`, each holding every key's + token or `{{NOPE}}`: the scaffold exits 0, the manifest it pushed holds + each value as typed and passes its own validator, and the README and + both legs' AGENTS.md hold them too.""" + env, log = gh_stub + name, elected_by, reference = free_text_values( + kind, ("Name", "By", "Ref"), SCAFFOLD) + remotes, work = tmp_path / "remotes", tmp_path / "work" + result = run_script(SCAFFOLD, "--org", ORG, "--project", "Atlas", + "--name", name, "--elected-by", elected_by, + "--reference", reference, + "--elected-on", "2026-09-02", + "--local-remote-dir", str(remotes), + "--work-dir", str(work), env=env) + assert result.returncode == 0, result.stderr + result.stdout + assert_gh_never_ran(log) + + root = remotes / "Atlas.git" + manifest = parse_yaml(shown(root, "main:project.yaml")) + assert (manifest["name"], manifest["elected_by"], manifest["reference"]) \ + == (name, elected_by, reference) + readme = shown(root, "main:README.md").splitlines() + assert readme[0] == f"# {name}" + assert any(line.endswith(f". Elected by {elected_by} on 2026-09-02, " + "against") for line in readme), readme[:12] + assert f"`{reference}`." in readme + for leg in ("spec", "code"): + agents = shown(remotes / f"Atlas-{leg}.git", "main:AGENTS.md") + assert agents.startswith( + f"This is the **{leg} leg** of {name} (`atlas`): "), agents[:300] + + check = run_script(work / "Atlas" / "scripts" / "validate-manifest.py", + cwd=work / "Atlas") + assert check.returncode == 0, check.stderr + check.stdout + + +@pytest.mark.skipif(shutil.which("git-filter-repo") is None, + reason="git filter-repo is not installed: " + "`pip install git-filter-repo`") +@pytest.mark.parametrize("kind", KINDS) +def test_adopt_records_each_free_text_value_as_typed(kind, tmp_path, gh_stub): + """`--elected-by` and `--reference`, each holding every key's token or + `{{NOPE}}`: `plan` and `check` pass, `execute` exits 0 with `adoption + verified`, and the split commit's manifest and README hold each value + as typed.""" + env, log = gh_stub + elected_by, reference = free_text_values(kind, ("By", "Ref"), ADOPT) + source = make_source_repo( + tmp_path / "Thing", tree={"src/app.py": "print(1)\n", + "docs/spec.md": "# Spec\n"}, edits=()) + plan = tmp_path / "adoption-plan.yaml" + planned = run_script(ADOPT, "plan", "--source", str(source), + "--project", "Northwind", "--org", ORG, + "--elected-by", elected_by, "--reference", reference, + "--elected-on", "2026-09-02", "--out", str(plan), + env=env) + assert planned.returncode == 0, planned.stderr + planned.stdout + checked = run_script(ADOPT, "check", "--plan", str(plan), env=env) + assert checked.returncode == 0, checked.stderr + checked.stdout + result = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + "--local-remote-dir", str(tmp_path / "remotes"), + "--work-dir", str(tmp_path / "work"), env=env) + assert result.returncode == 0, result.stderr + result.stdout + assert "adoption verified" in result.stdout + assert_gh_never_ran(log) + + split = "adopt/three-repo-shape" + manifest = parse_yaml(shown(source, f"{split}:project.yaml")) + assert (manifest["elected_by"], manifest["reference"]) == (elected_by, + reference) + readme = shown(source, f"{split}:README.md").splitlines() + assert readme[0] == "# Northwind" + assert any(line.endswith(f". Elected by {elected_by} on 2026-09-02, " + "against") for line in readme), readme[:12] + assert f"`{reference}`." in readme + + +@pytest.mark.parametrize("kind", KINDS) +def test_a_family_records_its_creator_as_typed(kind, tmp_path, gh_stub): + """`family.py init --created-by`, holding every key's token or + `{{NOPE}}`: the holder it pushed holds the value as typed in + `family.yaml` and passes its own validator. + + `--name` is left to its default here. The holder's validator asks + `name` to classify as a family holder, one CamelCase token, so a free + text name is refused there whatever `render` does; the unit tests above + hold `{{FAMILY_NAME}}` in `family.yaml`, the README and AGENTS.md.""" + env, log = gh_stub + (created_by,) = free_text_values(kind, ("By",), FAMILY) + remotes, work = tmp_path / "remotes", tmp_path / "work" + result = run_script(FAMILY, "init", "--org", ORG, "--family", "Contoso", + "--created-by", created_by, + "--created-on", "2026-09-04", + "--local-remote-dir", str(remotes), + "--work-dir", str(work), env=env) + assert result.returncode == 0, result.stderr + result.stdout + assert_gh_never_ran(log) + + family = parse_yaml(shown(remotes / "Contoso.git", "main:family.yaml")) + assert (family["name"], family["created_by"]) == ("Contoso", created_by) + + check = run_script(work / "Contoso" / "scripts" / "validate-family.py", + cwd=work / "Contoso") + assert check.returncode == 0, check.stderr + check.stdout From 321e511b6de722c0752d7ba1f14660790b54cda0 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:18:28 +0000 Subject: [PATCH 2/2] test_template_values_single_pass.py: `render`'s `.yml` spelling, its sorted refusal list and an empty value as a value are each held by a test that fails on the mutant that breaks it (#260) The review of 552763d (N2) ran seven mutants of `shape_materialize.render`; three survived every test: - `.yml` dropped from the YAML test, so only `.yaml` got the quoted spelling. Latent, since no `.yml` template holds a placeholder today (the two `validate.yml` workflows hold none). A synthetic `workflow.yml` now renders a quoted placeholder with a value holding `"`, `\` and both, and the YAML reader takes back what was typed. It renders byte for byte as `.yaml` does and not as `.md` does. - The refusal's list unsorted, reversed or with every sighting kept. The template holds ZULU, ALPHA and MIKE in that order, ZULU and ALPHA twice, so neither the order met nor its reverse is the sorted one, and the refusal must name `['{{ALPHA}}', '{{MIKE}}', '{{ZULU}}']`, each once, as markdown and as YAML. - An empty value treated as missing. The leg commit and digest are `""` until the legs exist, so an empty string in the table is a value: it renders as empty text, bare or between a YAML template's quotes, in every template that holds a placeholder (19 of them), and the real spec pin reads back `commit: ""` and `tree_sha256: ""`. Each new test passes on this head and fails on its mutant: 3 failed on the `.yml` mutant, 2 on each of four refusal-list mutants (first-seen order, reversed sort, duplicates kept, unsorted with duplicates), 23 on each of two empty-value-is-missing mutants. The mutants were applied one at a time in a throwaway worktree, and no other test in the file or in test_template_quoted_values.py fails on any of them. Only tests change; no file is written, no git stdin is used, and nothing is skipped. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_template_values_single_pass.py | 82 +++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/test_template_values_single_pass.py b/tests/test_template_values_single_pass.py index 9a52d7db..3c113449 100644 --- a/tests/test_template_values_single_pass.py +++ b/tests/test_template_values_single_pass.py @@ -226,6 +226,88 @@ def test_the_refusal_names_what_the_template_lacks_and_nothing_a_value_holds( assert caught.value.remediation == REMEDIATION +@pytest.mark.parametrize("text, source", [ + pytest.param("{{ZULU}} {{HAVE}} {{ALPHA}} {{MIKE}} {{ZULU}} {{ALPHA}}\n", + "README.md", id="markdown"), + pytest.param('z: "{{ZULU}}"\nh: "{{HAVE}}"\na: {{ALPHA}}\n' + 'm: "{{MIKE}}"\nz2: {{ZULU}}\n', + "project.yaml", id="yaml"), +]) +def test_the_refusal_names_the_missing_placeholders_sorted_and_each_once( + text, source): + """The refusal's list is the SORTED set of what the template lacks. + Here the template holds ZULU, ALPHA and MIKE in that order, so neither + the order they are met in nor its reverse is the sorted one, and ZULU + and ALPHA stand twice, so a list that kept every sighting names them + twice. HAVE has a value and is not named.""" + with pytest.raises(Refusal) as caught: + render(text, {"HAVE": "have"}, source) + assert caught.value.code == "template-unsubstituted" + assert caught.value.detail == ( + f"{source}: no value for " + f"{[token('ALPHA'), token('MIKE'), token('ZULU')]}") + assert caught.value.remediation == REMEDIATION + + +#: What a person can type for `--elected-by` that the YAML reader takes back +#: only if it is double-quoted: a quote, a backslash, and both together. +QUOTE_AND_BACKSLASH = [ + pytest.param('Robert "Bob" Smith', id="double-quotes"), + pytest.param("C:\\new", id="backslash-letter"), + pytest.param('say "hi" to C:\\new\\', id="both-ending-in-a-backslash"), +] + + +@pytest.mark.parametrize("value", QUOTE_AND_BACKSLASH) +@pytest.mark.parametrize("source", ["project.yaml", "workflow.yml"]) +def test_a_quoted_placeholder_in_either_yaml_extension_reads_back_exactly( + source, value): + """`.yml` is spelled as `.yaml` is, though no `.yml` template holds a + placeholder today (the two `validate.yml` workflows hold none): a + synthetic source renders a quoted placeholder with a quote and a + backslash in its value, and the reader takes back what was typed.""" + text = 'elected_by: "{{ELECTED_BY}}"\nreference: "{{ELECTED_BY}}"\n' + written = render(text, {"ELECTED_BY": value}, source) + assert parse_yaml(written) == {"elected_by": value, "reference": value} + assert written == render(text, {"ELECTED_BY": value}, "project.yaml") + assert written != render(text, {"ELECTED_BY": value}, "README.md") + + +@pytest.mark.parametrize("source", ["README.md", "project.yaml", + "workflow.yml"]) +def test_an_empty_value_is_a_value_and_never_a_missing_one(source): + """The leg commit and the leg digest are `""` until the legs exist + (`adopt-project.py` fills them with `.get(role, "")`). An empty string in + the table is a value, written as empty text, bare or between a YAML + template's quotes, and never refused as `template-unsubstituted`.""" + text = 'commit: {{COMMIT}}\nby: "{{ELECTED_BY}}"\nother: {{OTHER}}\n' + values = {"COMMIT": "", "ELECTED_BY": "", "OTHER": "kept"} + assert render(text, values, source) == ( + 'commit: \nby: ""\nother: kept\n') + + +@pytest.mark.parametrize("template", sorted( + rel for rel, text in TEMPLATE_TEXT.items() if KEY_TOKEN.search(text))) +def test_every_template_renders_with_every_value_empty(template): + """Each template that holds a placeholder, with an empty value for every + key it names, renders as the template with its placeholders removed: the + quotes round a YAML placeholder stay, and no key is refused for being + empty.""" + text = TEMPLATE_TEXT[template] + values = dict.fromkeys(KEY_TOKEN.findall(text), "") + assert render(text, values, template) == KEY_TOKEN.sub("", text) + + +def test_a_pin_with_an_empty_commit_and_digest_reads_back_empty(): + """The real pin template: the quoted commit and digest are `""`.""" + rel = "templates/assembly-root/contracts/spec-pin.yaml" + text = TEMPLATE_TEXT[rel] + pin = parse_yaml(render(text, table(text, { + "SPEC_COMMIT": "", "SPEC_TREE_SHA256": ""}), rel)) + assert pin["commit"] == "" + assert pin["digests"] == {"tree_sha256": ""} + + def test_text_that_is_no_placeholder_is_left_alone(): """A workflow's own `${{ github.x }}` and a Python f-string's doubled braces hold a space or a lower-case letter: neither is a placeholder,