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
87 changes: 84 additions & 3 deletions templates/family-root/scripts/validate-family.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
terms.
- `shape`: a commit-and-digest pin of the openRepoShape revision this root
was cut from, `revision_kind: commit`, never a tag
- across the rows, that no two name ONE `project:`, in any case:
`members/IRRS` and `members/irrs` are one directory on macOS and Windows,
whatever `id:` each row records (#331). This one reads `family.yaml`
alone, so `--no-members` asks it too.
- EVERY MEMBER, three ways:
1. the GITLINK this repository records at `members/<Project>` equals
`members[].pin.commit` — the same lockstep rule an assembly root
Expand Down Expand Up @@ -222,15 +226,18 @@ def _member_project_name(row: dict, seen: dict, report: Report) -> str | None:
"""The row's own `project:`, or None when nothing else about this member
should run — it has none, or its name was already seen once in
`members:`. Split from `_check_member` for #137: the two ways this
member's checks stop before they start, in one place."""
member's checks stop before they start, in one place.

A name seen once already stops this row's checks and says nothing: the
finding for it is `_check_member_names_unique`'s (#331), made before any
row is checked so `--no-members` makes it too, and made once, not here as
well."""
project = row.get("project")
if not isinstance(project, str) or not project:
report.finding("member-project", f"a member row has no `project:`: "
f"{row!r}")
return None
if project in seen:
report.finding("member-duplicate",
f"{project} appears more than once in `members:`")
return None
seen[project] = True
return project
Expand Down Expand Up @@ -528,6 +535,79 @@ def _check_shape_pin(root: Path, manifest: dict, report: Report) -> None:
_check_shape_pin_files(root, pin, rel, report)


def _check_member_names_unique(members: list, report: Report) -> None:
"""No two rows of `members:` name ONE project, in any case — the
`member-duplicate` finding (#331).

It reads `members:` alone, so it runs under `--no-members` too, the run a
private family's CI falls back to when its members cannot be checked out,
and it runs before any row is checked, as `member-duplicate-id` does
(#206). `_member_project_name` used to make this finding, and only the
members-checked loop reaches that, so `--no-members` passed a
`family.yaml` that named one row twice. It is made HERE and once, in
every mode: `_member_project_name` still skips a row it has already seen,
and says nothing.

Two spellings that differ only in case are one row: `members/IRRS` and
`members/irrs` are ONE directory on a case-insensitive disk (macOS,
Windows), where a holder can mount only one of them, whatever `id:` each
row records. The rows' ids are no help here and `member-duplicate-id`'s
question is a different one: it asks whether one PROJECT is mounted
twice, this asks whether one DIRECTORY is named twice.

THE FOLD IS `str.casefold()`, as the standard's other same-path-in-any-case
checks use it (`shape_materialize.py`, `adopt-project.py`). `family.py add`
only writes ASCII names (`checked_value`: `[A-Za-z0-9._/@+~-]`), and a
members-checked run refuses any other as `member-repository`
(`QUALIFIED_RE`) or `member-repository-name`. Under `--no-members` neither
runs, so a hand edit can put ANY string in `project:`, and a disk folds
more than ASCII case: the long s (U+017F) folds to `s`, and a final sigma
(U+03C2) to a small sigma (U+03C3), so `IRRS` and `irrs` spelt with the
long s, or a name in capital sigmas and the same name in small ones, are
one directory there, and `lower()` keeps each pair apart. On every name
`family.py` can write the two folds agree, and `Family.member` in
`family.py` compares with `lower()` (#206), so the tool that writes the
rows and this gate cannot disagree about a row the tool wrote.
`casefold()` is no one disk's own fold either: NTFS's table does not fold
the German sharp s (U+00DF) to `ss`, so for such a pair this may name a
collision that disk does not make, and APFS also ignores normalization,
which no fold here handles. It errs on the side of a gate that says one
row has to go.

A row whose spelling an earlier row already has, to the byte, keeps the
finding it always had. A row that differs only in case, as a
case-insensitive disk folds it, names BOTH rows as written. Either way
each later row is one finding, against the first row that holds its name.
A row with no `project:` is `member-project`'s, and a row that is no
mapping is `member-row`'s: both are skipped here, as
`_check_member_ids_unique` skips them.
"""
spelled: set[str] = set()
first_folded: dict[str, str] = {}
for row in members:
if not isinstance(row, dict):
continue
project = row.get("project")
if not isinstance(project, str) or not project:
continue
if project in spelled:
report.finding("member-duplicate",
f"{project} appears more than once in `members:`")
continue
spelled.add(project)
first = first_folded.setdefault(project.casefold(), project)
if first != project:
report.finding(
"member-duplicate",
f"{first} and {project} are two rows of `members:` whose "
f"`project:` differs only in case, so `members/{first}` and "
f"`members/{project}` are one directory on a case-insensitive "
"disk (macOS, Windows), and a holder there can keep only one "
"of them. Whether the rows pin one project or two, one of "
"them has to go: keep the row whose `repository:` is the "
"member's and drop the other.")


def _check_member_ids_unique(root: Path, members: list,
report: Report) -> None:
"""No two rows record ONE `id:` — that is one project pinned twice (#206).
Expand Down Expand Up @@ -658,6 +738,7 @@ def _check_members(root: Path, members: list, no_members: bool,
`family.yaml`'s ids (#206) and `.gitmodules`'s paths (#300) — come
first, so `--no-members` asks them too.
"""
_check_member_names_unique(members, report)
_check_member_ids_unique(root, members, report)
_check_gitmodules_paths(root, report)
if no_members:
Expand Down
Loading
Loading