Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 52 additions & 9 deletions python/lean_pool/rebase.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@
import sys
from pathlib import Path

import yaml

from lean_pool.exposition.source_text import code_view

logger = logging.getLogger(__name__)

REGISTRY = "LeanPool/projects.yml"
INDEX = "LeanPool.lean"
CARD_PREFIX = " - slug: "
# The only conflicts this module claims to resolve.
RESOLVABLE = frozenset({INDEX, REGISTRY})

Expand Down Expand Up @@ -78,22 +79,61 @@ def render_index(root: Path) -> str:
)


def _project_nodes(text: str) -> yaml.SequenceNode:
"""Read card boundaries from YAML rather than assuming a first key."""
root = yaml.compose(text, Loader=yaml.SafeLoader)
if not isinstance(root, yaml.MappingNode) or len(root.value) != 1:
raise ValueError("Expected a registry containing only projects")
key, projects = root.value[0]
if key.value != "projects" or not isinstance(projects, yaml.SequenceNode):
raise ValueError("Expected projects to be a sequence")
if projects.flow_style and projects.value:
raise ValueError("Project cards must use a block sequence")
if any(not isinstance(card, yaml.MappingNode) for card in projects.value):
raise ValueError("Each project card must be a mapping")
return projects


def _card_slug(card: yaml.MappingNode) -> str:
"""Require one unambiguous string slug, in any mapping position."""
slugs = [value for key, value in card.value if key.value == "slug"]
if (
len(slugs) != 1
or not isinstance(slugs[0], yaml.ScalarNode)
or slugs[0].tag != "tag:yaml.org,2002:str"
or not slugs[0].value.strip()
):
raise ValueError("Each project card must have exactly one nonempty string slug")
return slugs[0].value


def split_cards(text: str) -> tuple[str, list[tuple[str, str]]]:
"""Split a registry into its header and its cards, as verbatim text.

Returns ``(header, [(slug, block)])`` where concatenating the header and
every block reproduces ``text`` exactly.
"""
lines = text.splitlines(keepends=True)
starts = [i for i, line in enumerate(lines) if line.startswith(CARD_PREFIX)]
projects = _project_nodes(text)
nodes = projects.value
entries = [
token
for token in yaml.scan(text, Loader=yaml.SafeLoader)
if isinstance(token, yaml.tokens.BlockEntryToken)
and token.start_mark.column == projects.start_mark.column
]
starts = [text.rfind("\n", 0, token.start_mark.index) + 1 for token in entries]
if len(starts) != len(nodes):
raise ValueError("Expected one sequence item per project card")
if not starts:
return text, []
header = "".join(lines[: starts[0]])
header = text[: starts[0]]
cards: list[tuple[str, str]] = []
for index, start in enumerate(starts):
end = starts[index + 1] if index + 1 < len(starts) else len(lines)
slug = lines[start][len(CARD_PREFIX) :].strip()
cards.append((slug, "".join(lines[start:end])))
for index, (start, node) in enumerate(zip(starts, nodes, strict=True)):
end = starts[index + 1] if index + 1 < len(starts) else len(text)
slug = _card_slug(node)
if any(existing == slug for existing, _ in cards):
raise ValueError(f"Duplicate project slug: {slug}")
cards.append((slug, text[start:end]))
return header, cards


Expand Down Expand Up @@ -122,7 +162,10 @@ def merge_registry(base: str, ours: str, theirs: str) -> str:
# run into the first appended card.
if merged and not merged.endswith("\n"):
merged += "\n"
return merged + "".join(block for _, block in added)
result = merged + "".join(block for _, block in added)
# Reject incompatible layouts instead of pushing a malformed registry.
split_cards(result)
return result


def resolvable(conflicts: list[str]) -> bool:
Expand Down
78 changes: 78 additions & 0 deletions python/tests/test_rebase.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from pathlib import Path

import pytest
import yaml

from lean_pool.rebase import (
main,
Expand Down Expand Up @@ -155,6 +156,83 @@ def test_split_cards_round_trips() -> None:
assert header + "".join(block for _, block in cards) == OURS


@pytest.mark.parametrize("indent", ["", " ", " "])
def test_split_cards_uses_yaml_boundaries_and_decoded_slugs(indent: str) -> None:
"""Key order, quoting, comments and nested lists do not change card identity."""
text = (
"# Keep this header\nprojects:\n"
f'{indent}- title: "First: project"\n'
f"{indent} summary: |\n{indent} - slug: not-a-card\n"
f"{indent} tags:\n{indent} - slug: nested\n"
f"{indent} slug: 'alpha' # Keep this comment\n"
f'{indent}- slug: "beta"\n{indent} title: Beta\n'
)
header, cards = split_cards(text)
assert [slug for slug, _ in cards] == ["alpha", "beta"]
assert header + "".join(block for _, block in cards) == text


def test_merge_preserves_title_first_added_cards_verbatim() -> None:
"""Regression: the automatic updater must not silently drop a title-first card."""
added = " - title: Delta\n slug: 'delta' # quoted identity\n"
merged = merge_registry(BASE, OURS, BASE + added)
assert merged == OURS + added
assert [card["slug"] for card in yaml.safe_load(merged)["projects"]] == [
"alpha",
"beta",
"gamma",
"delta",
]


def test_merge_preserves_cards_with_keys_below_the_dash() -> None:
"""A standalone YAML sequence marker is a valid card boundary too."""
added = " - # project comment\n title: Delta\n slug: delta\n"
merged = merge_registry(BASE, OURS, BASE + added)
assert merged == OURS + added
assert yaml.safe_load(merged)["projects"][-1]["slug"] == "delta"


def test_merge_recognizes_title_first_base_and_main_cards() -> None:
"""Reordered keys and quoted slugs must not duplicate existing cards."""
base = "projects:\n - title: Alpha\n slug: 'alpha'\n"
ours = base + ' - title: Delta\n slug: "delta"\n'
theirs = "projects:\n - slug: alpha\n title: Alpha\n"
theirs += " - slug: delta\n title: Delta\n"
assert merge_registry(base, ours, theirs) == ours


@pytest.mark.parametrize(
"text",
[
"projects:\n - title: Missing slug\n",
"projects:\n - slug: alpha\n slug: beta\n",
"projects:\n - slug: alpha\n - slug: 'alpha'\n",
"projects:\n - slug: 123\n",
"projects:\n - slug: ''\n",
"projects: [{slug: alpha}, {slug: beta}]\n",
"projects:\n - &card {slug: alpha}\n - *card\n",
"projects:\n - not-a-mapping\n",
"projects: {}\n",
],
)
def test_split_cards_rejects_ambiguous_registries(text: str) -> None:
"""Malformed identities and unsupported layouts fail before anything is pushed."""
with pytest.raises(ValueError):
split_cards(text)


def test_split_cards_covers_every_real_project() -> None:
"""Every YAML project, including title-first entries, gets a verbatim block."""
root = Path(__file__).resolve().parents[2]
text = (root / "LeanPool/projects.yml").read_text(encoding="utf-8")
header, cards = split_cards(text)
assert [slug for slug, _ in cards] == [
card["slug"] for card in yaml.safe_load(text)["projects"]
]
assert header + "".join(block for _, block in cards) == text


def test_merge_keeps_both_additions() -> None:
"""The merged registry has the card main added and the card we added."""
merged = merge_registry(BASE, OURS, THEIRS)
Expand Down
Loading