diff --git a/README.md b/README.md
index 095a1b9e..20bb4a3e 100644
--- a/README.md
+++ b/README.md
@@ -1114,19 +1114,19 @@ OWN `scripts/validate-manifest.py` and `scripts/validate-pins.py` when it
carries them — they are the pinned copies, which is the point — else the
template copies, and the row says which answered), **manifest kinds** (every
`kind:`-bearing manifest in the root against a registry, so a `contracts/*.yaml`
-nothing validates is a finding by name rather than a silence), **shape
-currency** (`update-shape.py check`'s per-file verdicts, summarised),
-**legs** (each one mounted, and its checked-out commit against the pin, with
-no fetch), **leg shape files** (each leg's `AGENTS.md`, `CLAUDE.md` and
-`.gitignore` against `templates/Is this repository compliant?
are the pinned copies, which is the point — else the template copies, and the row says
which answered), manifest kinds (every openRepoShape --doctorkind:-bearing manifest
against a registry, so a contracts/*.yaml nothing validates is a finding by name
- rather than a silence), shape currency
+ rather than a silence, and so is a family.yaml beside project.yaml),
+ shape currency
(update-shape.py check, summarised), legs
(each mounted, and its checked-out commit against the pin, with no fetch), leg shape
files, placement (every tracked path of every leg, against the path
@@ -1777,10 +1778,12 @@ For AI assistants
templates/workspace-root/ row, the fifth template, added (#154); the Triad as
the preferred shape, where the advisory is said and where it is silent, the optional
single-repository.yaml, the doctor’s advisory row and the
- assistant’s “what you do tell them” added (#163) — a page
- cannot name the commit that contains it, and the blob below is the fact a test checks.
- README.md blob 095a1b9e60743fc8f8d26bd11247572956a3f144. Every command, flag, exit code and
- file name on this page is quoted from that README and from AGENTS.md.
family.yaml beside project.yaml as a manifest kinds
+ finding added (#211) — a page cannot name the commit that contains it, and the blob
+ below is the fact a test checks. README.md blob 20bb4a3e884c3232380407bda345af6e29d428c6.
+ Every command, flag, exit code and file name on this page is quoted from that README and
+ from AGENTS.md.
diff --git a/shape-doctor.py b/shape-doctor.py
index e5d9541f..d06fe2a2 100755
--- a/shape-doctor.py
+++ b/shape-doctor.py
@@ -960,6 +960,124 @@ def manifest_candidates(root: Path) -> list[Path]:
LABEL_MANIFEST_KINDS = "manifest kinds"
+def gitlinks_under(root: Path, directory: str) -> list[str]:
+ """Every gitlink the index records under `directory/`, at stage 0.
+
+ THE INDEX, the listing `recorded_gitlink` asks first, and `-z` so a name
+ git would otherwise quote comes back as itself. A conflicted path is
+ skipped here for the reason that helper skips it: stages 1, 2 and 3 are
+ nobody's next commit. A `directory` that is empty, absolute -- in
+ either platform's spelling -- or climbs with `..` lists nothing, rather
+ than every gitlink in the root -- the two legs included -- under a
+ members directory that is not one.
+ """
+ clean = directory.strip().rstrip("/")
+ if (not clean or clean.startswith(("/", "\\"))
+ or re.match(r"[A-Za-z]:[\\/]", clean)
+ or any(part in ("", ".", "..") for part in clean.split("/"))):
+ return []
+ try:
+ listing = git_out(["ls-files", "-s", "-z", "--", clean], cwd=root)
+ except (Refusal, OSError, ValueError):
+ return []
+ found = []
+ for record in listing.split("\0"):
+ meta, _, name = record.partition("\t")
+ fields = meta.split()
+ if (len(fields) == 3 and fields[0] == GITLINK_MODE
+ and fields[2] == "0" and name.startswith(clean + "/")):
+ found.append(name)
+ return found
+
+
+def family_beside_project(ctx: Context, data: dict) -> tuple[str, dict]:
+ """A `family.yaml` an ASSEMBLY ROOT carries: the sentence, and the facts.
+
+ #211. `Context` reads ONE manifest per root and `project.yaml` wins, so
+ a `family.yaml` committed beside it -- hand-copied or mis-merged; no tool
+ here writes both -- was listed by `check_manifest_kinds` as a manifest
+ whose kind "has a registered validator". It has one, `validate-family.py`,
+ and NOTHING RUNS IT HERE: the `family` check applies to a holder, and an
+ assembly root's own gate is `validate-manifest.py` and `validate-pins.py`.
+ A family holder is not a project -- no `project.yaml`, no legs (README,
+ "Families") -- so a root declaring both is a manifest travelling with no
+ gate behind it, which is the very case this row exists to report. That
+ is why it is a FINDING even when every member below is in lockstep.
+
+ AND THE MEMBERS ARE ASKED WHAT THE FAMILY'S OWN TWO GATES ASK, with
+ their helper rather than a second reader. Each row it pins is looked up
+ with `recorded_gitlink` -- the function `validate-family.py` refuses a
+ row by (`member-gitlink-absent`, `member-gitlink-mismatch`) -- and each
+ gitlink under the members directory that no row names is the state
+ `family.py add` refuses to mount over (`member-path-taken`). That gate
+ stops at the first absent gitlink; this names every one, because a
+ reader told about IRRS and not IRSS fixes one and runs it again.
+
+ Rows and paths default exactly as `member_state` defaults them, so this
+ and the holder's `members` row cannot disagree about where a member is.
+ """
+ members = data.get("members")
+ rows = ([row for row in members if isinstance(row, dict)]
+ if isinstance(members, list) else [])
+ members_dir = str(data.get("members_dir") or "members")
+ entries: list[dict] = []
+ problems: list[str] = []
+ for row in rows:
+ project = str(row.get("project") or "?")
+ rel = str(row.get("path") or f"{members_dir}/{project}")
+ pin = row.get("pin") if isinstance(row.get("pin"), dict) else {}
+ pinned = str(pin.get("commit") or "").lower()
+ pinned = pinned if COMMIT_RE.match(pinned) else None
+ try:
+ gitlink = recorded_gitlink(ctx.root, rel)
+ except (OSError, ValueError):
+ gitlink = None
+ if gitlink is None:
+ state = "no-gitlink"
+ problems.append(f"{rel} has no gitlink (mode {GITLINK_MODE})")
+ elif pinned is None:
+ state = "no-pin"
+ problems.append(f"{project}'s row carries no 40-hex "
+ "`pin.commit` to compare against")
+ elif gitlink != pinned:
+ state = "off-pin"
+ problems.append(f"{rel} records gitlink {gitlink[:12]} where "
+ f"{FAMILY_MANIFEST} pins {pinned[:12]}")
+ else:
+ state = "at-pin"
+ entries.append({"project": project, "path": rel, "pin": pinned,
+ "gitlink": gitlink, "state": state})
+ declared = {entry["path"] for entry in entries}
+ undeclared = [name for name in gitlinks_under(ctx.root, members_dir)
+ if name not in declared]
+ problems += [f"{name} is a gitlink no {FAMILY_MANIFEST} row names"
+ for name in undeclared]
+ sentence = (f"{FAMILY_MANIFEST} (family-manifest) sits beside "
+ f"{PROJECT_MANIFEST} (project-manifest), and validate-family"
+ ".py, the validator that kind is registered to, never runs "
+ "on an assembly root -- a family holder is not a project -- "
+ f"so nothing checks the {len(entries)} member(s) it pins")
+ if problems:
+ sentence += ": " + ", ".join(problems)
+ elif entries:
+ sentence += ", though each is recorded at its pin today"
+ return sentence, {"manifest": FAMILY_MANIFEST, "members": entries,
+ "undeclared_gitlinks": undeclared}
+
+
+#: The `manifest kinds` row's next command for #211, named once so the row
+#: and `tests/test_shape_doctor.py` spell it the same way. NOT `family.py
+#: add`, which is what `validate-family.py`'s own refusal offers: run here,
+#: it would mount the members INSIDE a project, which is the one shape the
+#: family ruling rules out. Which of the two exits is right is a fact about
+#: the repository, so the line offers both and decides neither.
+FAMILY_BESIDE_FIX = (
+ "an assembly root is not a family holder: take family.yaml out of this "
+ "root, or move the membership to a holder of its own (`family.py init`, "
+ "then `family.py add` there), as the human decides -- never `family.py "
+ "add` here, which would mount the members inside a project")
+
+
def check_manifest_kinds(ctx: Context) -> Row:
"""Every `kind:`-bearing manifest in this root, against the registry.
@@ -969,10 +1087,16 @@ def check_manifest_kinds(ctx: Context) -> Row:
reporting on somebody else's data. What is reported is the other case --
a file that DOES declare a kind that nothing here validates, which is a
manifest travelling in a shape root with no gate behind it.
+
+ A REGISTERED KIND WHOSE GATE THIS ROOT NEVER RUNS IS THAT CASE TOO
+ (#211): a `family.yaml` declaring `family-manifest` in an assembly root.
+ It is not counted `validated`; `family_beside_project` says why, and
+ checks the members it pins against the gitlinks.
"""
known: list[str] = []
unknown: list[str] = []
unreadable: list[str] = []
+ beside: tuple[str, dict] | None = None
for path in manifest_candidates(ctx.root):
data = Context._read(path)
if not isinstance(data, dict):
@@ -982,23 +1106,36 @@ def check_manifest_kinds(ctx: Context) -> Row:
if not isinstance(kind, str) or not kind:
continue
rel = path.relative_to(ctx.root).as_posix()
+ if (ctx.kind == PROJECT and rel == FAMILY_MANIFEST
+ and kind == "family-manifest"):
+ beside = family_beside_project(ctx, data)
+ continue
entry = MANIFEST_VALIDATORS.get(kind)
(known if entry else unknown).append(f"{rel} ({kind})")
detail = {"validated": known, "unregistered": unknown,
"unreadable": unreadable,
"registered_kinds": sorted(MANIFEST_VALIDATORS)}
- if unknown or unreadable:
+ if beside is not None:
+ # ONLY WHEN THERE IS ONE, so every root without it reports the same
+ # row, byte for byte, as it did before #211.
+ detail["family_beside"] = beside[1]
+ if unknown or unreadable or beside is not None:
parts = []
+ fixes = []
if unknown:
parts.append("no validator registered for kind "
+ ", ".join(unknown))
if unreadable:
parts.append("unreadable as YAML: " + ", ".join(unreadable))
+ if unknown or unreadable:
+ fixes.append("register the kind in shape-doctor.py's "
+ "MANIFEST_VALIDATORS, beside the check that "
+ "validates it, or remove the file from the root")
+ if beside is not None:
+ parts.append(beside[0])
+ fixes.append(FAMILY_BESIDE_FIX)
return Row("manifest-kinds", LABEL_MANIFEST_KINDS, FINDING,
- "; ".join(parts),
- "register the kind in shape-doctor.py's "
- "MANIFEST_VALIDATORS, beside the check that validates it, "
- "or remove the file from the root", detail)
+ "; ".join(parts), "; ".join(fixes), detail)
return Row("manifest-kinds", LABEL_MANIFEST_KINDS, OK,
f"{len(known)} manifest(s), every declared kind has a "
"registered validator", None, detail)
diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py
index ac50577e..ace79299 100644
--- a/tests/test_shape_doctor.py
+++ b/tests/test_shape_doctor.py
@@ -478,6 +478,351 @@ def test_a_family_holder_gets_the_family_rows(standard, holder):
[FAMILY_MEMBER]
+# --- a family.yaml beside project.yaml (#211) -------------------------------
+#
+# `Context` reads ONE manifest per root and `project.yaml` wins, so a
+# `family.yaml` committed beside it -- hand-copied or mis-merged; no tool here
+# writes both -- was listed among the manifests whose kind "has a registered
+# validator", and the root was COMPLIANT, exit 0. That validator,
+# `validate-family.py`, never runs on an assembly root, and run there by hand
+# it refuses the same tree.
+
+
+#: The words the `manifest kinds` row opens with for a holder's one-member
+#: `family.yaml` beside an assembly root's `project.yaml`, spelt out here and
+#: not imported from the doctor: a test that read the wording back from the
+#: thing under test would pass whatever it said.
+FAMILY_BESIDE_OPENING = (
+ "family.yaml (family-manifest) sits beside project.yaml "
+ "(project-manifest), and validate-family.py, the validator that kind is "
+ "registered to, never runs on an assembly root -- a family holder is not "
+ "a project -- so nothing checks the 1 member(s) it pins")
+
+#: The row's next command. NOT `family.py add`, which is what
+#: `validate-family.py`'s own refusal offers: run in an assembly root it would
+#: mount the members inside a project.
+FAMILY_BESIDE_NEXT = (
+ "an assembly root is not a family holder: take family.yaml out of this "
+ "root, or move the membership to a holder of its own (`family.py init`, "
+ "then `family.py add` there), as the human decides -- never `family.py "
+ "add` here, which would mount the members inside a project")
+
+#: The `manifest kinds` row's `--json` keys before #211, which every root
+#: without a family.yaml beside its project.yaml must still get exactly.
+MANIFEST_KINDS_DETAIL_KEYS = {"validated", "unregistered", "unreadable",
+ "registered_kinds"}
+
+#: The next command an unregistered kind has always carried, byte for byte.
+UNREGISTERED_NEXT = ("register the kind in shape-doctor.py's "
+ "MANIFEST_VALIDATORS, beside the check that validates "
+ "it, or remove the file from the root")
+
+
+def family_beside(project: Path, holder: dict) -> str:
+ """Commit the holder's `family.yaml` into an assembly root, beside its
+ `project.yaml` -- the hand copy #211 was filed about -- and return the
+ 40-hex commit it pins its one member at, read out of the file itself."""
+ shutil.copy2(holder["root"] / "family.yaml", project / "family.yaml")
+ commit_all(project, "A hand-copied family.yaml")
+ [row] = repo_shape.load_yaml(project / "family.yaml")["members"]
+ assert row["project"] == FAMILY_MEMBER, row
+ return str(row["pin"]["commit"])
+
+
+def record_gitlink(root: Path, path: str, commit: str) -> None:
+ """A gitlink at `path` in the root's INDEX, at `commit`, and nothing else.
+
+ `stage_gitlink` below does this in a leg at the leg's own HEAD; here the
+ commit is the argument, because whether it equals the pin is the question.
+ The index is what `recorded_gitlink` asks first and what `ls-files` lists,
+ so no commit and no working tree under it are needed.
+ """
+ git("update-index", "--add", "--cacheinfo", f"160000,{commit},{path}",
+ cwd=root)
+
+
+def beside_row(result) -> dict:
+ """The `manifest kinds` row of a `--json` run, asserted to be the #211
+ finding and to be the only row that is."""
+ rows = rows_of(result)
+ row = rows["manifest-kinds"]
+ assert row["status"] == "FINDING", row
+ assert row["reason"].startswith(FAMILY_BESIDE_OPENING), row["reason"]
+ assert row["next"] == FAMILY_BESIDE_NEXT, row["next"]
+ assert set(row["detail"]) == MANIFEST_KINDS_DETAIL_KEYS | {
+ "family_beside"}, row["detail"]
+ validated = row["detail"]["validated"]
+ assert "family.yaml (family-manifest)" not in validated, (
+ "a manifest whose gate never runs here is not counted validated",
+ validated)
+ others = {name: other["status"] for name, other in rows.items()
+ if other["status"] == "FINDING" and name != "manifest-kinds"}
+ assert others == {}, others
+ return row
+
+
+def test_a_family_yaml_whose_members_are_all_at_their_pins_is_still_invalid(
+ standard, project, holder):
+ """DELIBERATELY A FINDING, even with every member recorded at its pin.
+
+ A family holder is not a project -- no `project.yaml`, no legs (README,
+ "Families"; AGENTS.md, "Creating a family") -- and the gate registered
+ for `family-manifest`, `validate-family.py`, is run by the `family` check,
+ which applies to a holder and to nothing else. So a root declaring both
+ is a manifest travelling with no gate behind it: the case
+ `check_manifest_kinds` exists to report, whether or not its rows happen
+ to agree with the gitlinks today. The issue asked for no verdict better
+ than INVALID, and this is it: the row says each member is at its pin,
+ and still names the exit.
+ """
+ pinned = family_beside(project, holder)
+ record_gitlink(project, f"members/{FAMILY_MEMBER}", pinned)
+ result = doctor(standard, project, "--json")
+ assert result.returncode == 1, result.stdout + result.stderr
+ payload = json.loads(result.stdout)
+ assert payload["kind"] == "project"
+ assert payload["verdict"] == "INVALID (manifest-kinds)", payload["verdict"]
+ row = beside_row(result)
+ assert row["reason"] == (FAMILY_BESIDE_OPENING
+ + ", though each is recorded at its pin today")
+ assert row["detail"]["family_beside"] == {
+ "manifest": "family.yaml",
+ "members": [{"project": FAMILY_MEMBER,
+ "path": f"members/{FAMILY_MEMBER}", "pin": pinned,
+ "gitlink": pinned, "state": "at-pin"}],
+ "undeclared_gitlinks": []}
+ text = doctor(standard, project)
+ assert text.returncode == 1, text.stdout + text.stderr
+ assert verdict_line(text).startswith(
+ "INVALID (manifest-kinds) (exit 1)"), text.stdout
+
+
+def test_a_family_yaml_whose_member_has_no_gitlink_names_it(standard,
+ project, holder):
+ """THE DEFECT #211 WAS FILED FOR: a member pinned and not mounted.
+
+ `validate-family.py` refuses this tree `member-gitlink-absent`, exit 2;
+ the doctor said COMPLIANT, exit 0, and listed `family.yaml` among the
+ manifests whose kind has a registered validator. It names the member and
+ the mode now, and the verdict is INVALID.
+ """
+ pinned = family_beside(project, holder)
+ result = doctor(standard, project, "--json")
+ assert result.returncode == 1, result.stdout + result.stderr
+ assert json.loads(result.stdout)["verdict"] == "INVALID (manifest-kinds)"
+ row = beside_row(result)
+ assert row["reason"] == (
+ f"{FAMILY_BESIDE_OPENING}: members/{FAMILY_MEMBER} has no gitlink "
+ "(mode 160000)"), row["reason"]
+ [entry] = row["detail"]["family_beside"]["members"]
+ assert entry == {"project": FAMILY_MEMBER,
+ "path": f"members/{FAMILY_MEMBER}", "pin": pinned,
+ "gitlink": None, "state": "no-gitlink"}, entry
+
+
+def test_a_family_yaml_whose_member_is_off_its_pin_names_both_commits(
+ standard, project, holder):
+ """A gitlink at a commit the row does not pin: `validate-family.py`'s
+ `member-gitlink-mismatch`, said here with both commits."""
+ pinned = family_beside(project, holder)
+ elsewhere = git("rev-parse", "HEAD", cwd=project).stdout.strip().lower()
+ assert elsewhere != pinned, "fixture: a commit that is not the pin"
+ record_gitlink(project, f"members/{FAMILY_MEMBER}", elsewhere)
+ result = doctor(standard, project, "--json")
+ assert result.returncode == 1, result.stdout + result.stderr
+ assert json.loads(result.stdout)["verdict"] == "INVALID (manifest-kinds)"
+ row = beside_row(result)
+ assert row["reason"] == (
+ f"{FAMILY_BESIDE_OPENING}: members/{FAMILY_MEMBER} records gitlink "
+ f"{elsewhere[:12]} where family.yaml pins {pinned[:12]}"), \
+ row["reason"]
+ [entry] = row["detail"]["family_beside"]["members"]
+ assert (entry["gitlink"], entry["pin"], entry["state"]) == (
+ elsewhere, pinned, "off-pin"), entry
+
+
+def test_a_family_yaml_whose_pin_is_not_40_hex_says_so(standard, project,
+ holder):
+ """A `pin.commit` that is a branch name is not a commit to compare a
+ gitlink against, so the row says the pin is missing rather than calling
+ the member off it -- the reading `member_state` gives a holder's row."""
+ pinned = family_beside(project, holder)
+ manifest = project / "family.yaml"
+ manifest.write_text(manifest.read_text(encoding="utf-8").replace(
+ pinned, "main"), encoding="utf-8")
+ commit_all(project, "A pin that is a branch name")
+ # AFTER the commit: `git add -A` would stage a gitlink with no working
+ # tree under it as a deletion.
+ record_gitlink(project, f"members/{FAMILY_MEMBER}", pinned)
+ result = doctor(standard, project, "--json")
+ assert result.returncode == 1, result.stdout + result.stderr
+ assert json.loads(result.stdout)["verdict"] == "INVALID (manifest-kinds)"
+ row = beside_row(result)
+ assert row["reason"] == (
+ f"{FAMILY_BESIDE_OPENING}: {FAMILY_MEMBER}'s row carries no 40-hex "
+ "`pin.commit` to compare against"), row["reason"]
+ [entry] = row["detail"]["family_beside"]["members"]
+ assert (entry["gitlink"], entry["pin"], entry["state"]) == (
+ pinned, None, "no-pin"), entry
+
+
+def test_a_gitlink_under_members_that_no_row_names_is_named(standard,
+ project, holder):
+ """The state `family.py add` refuses to mount over (`member-path-taken`):
+ a submodule under the members directory that the manifest says nothing
+ about. The member the row DOES name is at its pin, so the stray is the
+ whole of what the sentence lists."""
+ pinned = family_beside(project, holder)
+ record_gitlink(project, f"members/{FAMILY_MEMBER}", pinned)
+ record_gitlink(project, "members/Stray", pinned)
+ result = doctor(standard, project, "--json")
+ assert result.returncode == 1, result.stdout + result.stderr
+ assert json.loads(result.stdout)["verdict"] == "INVALID (manifest-kinds)"
+ row = beside_row(result)
+ assert row["reason"] == (
+ f"{FAMILY_BESIDE_OPENING}: members/Stray is a gitlink no family.yaml "
+ "row names"), row["reason"]
+ beside = row["detail"]["family_beside"]
+ assert beside["undeclared_gitlinks"] == ["members/Stray"], beside
+ assert [entry["state"] for entry in beside["members"]] == ["at-pin"]
+
+
+def test_the_text_report_and_json_carry_the_same_family_beside_row(
+ standard, project, holder):
+ """`--json` is the same report as one object: the row's reason and its
+ next command are printed verbatim in the table, under the same label,
+ and the verdict line is the JSON's verdict."""
+ family_beside(project, holder)
+ result = doctor(standard, project, "--json")
+ payload = json.loads(result.stdout)
+ row = beside_row(result)
+ text = doctor(standard, project)
+ assert text.returncode == payload["exit"] == 1
+ lines = text.stdout.splitlines()
+ [at] = [index for index, line in enumerate(lines)
+ if "manifest kinds" in line]
+ assert lines[at].split()[:3] == ["FINDING", "manifest", "kinds"], \
+ lines[at]
+ assert lines[at].endswith(" " + row["reason"]), lines[at]
+ assert lines[at + 1].split()[0] == "NEXT", lines[at + 1]
+ assert lines[at + 1].endswith(" " + row["next"]), lines[at + 1]
+ assert verdict_line(text) == f"{payload['verdict']} (exit 1)"
+
+
+def test_a_family_yaml_beside_an_unregistered_kind_says_both(
+ standard, project, holder):
+ """Two reasons in one row, each with its own exit, in the order the row
+ has always listed them: the unregistered kind first."""
+ family_beside(project, holder)
+ (project / "contracts" / "inventory.yaml").write_text(
+ "schema_version: 1\nkind: service-inventory\nservices: []\n",
+ encoding="utf-8")
+ result = doctor(standard, project, "--json")
+ assert result.returncode == 1, result.stdout + result.stderr
+ row = rows_of(result)["manifest-kinds"]
+ assert row["status"] == "FINDING", row
+ assert row["reason"] == (
+ "no validator registered for kind contracts/inventory.yaml "
+ f"(service-inventory); {FAMILY_BESIDE_OPENING}: "
+ f"members/{FAMILY_MEMBER} has no gitlink (mode 160000)"), \
+ row["reason"]
+ assert row["next"] == f"{UNREGISTERED_NEXT}; {FAMILY_BESIDE_NEXT}", \
+ row["next"]
+ assert row["detail"]["unregistered"] == [
+ "contracts/inventory.yaml (service-inventory)"]
+
+
+def test_a_root_with_no_family_yaml_beside_gets_the_row_it_always_got(
+ standard, project, holder):
+ """NOTHING CHANGES WITHOUT A family.yaml BESIDE project.yaml, byte for
+ byte: an assembly root, the same root with an unregistered kind, and a
+ family holder -- whose own `family.yaml` is the manifest its `family`
+ row validates, and stays `validated` here. The row's detail carries
+ exactly the keys it carried, and the sentences are the ones it printed.
+ """
+ row = rows_of(doctor(standard, project, "--json"))["manifest-kinds"]
+ assert row["status"] == "ok", row
+ assert set(row["detail"]) == MANIFEST_KINDS_DETAIL_KEYS, row["detail"]
+ assert row["reason"] == (
+ f"{len(row['detail']['validated'])} manifest(s), every declared kind "
+ "has a registered validator"), row["reason"]
+ assert row["next"] is None
+ assert "family" not in json.dumps(row["detail"]["validated"]), row
+
+ (project / "contracts" / "inventory.yaml").write_text(
+ "schema_version: 1\nkind: service-inventory\nservices: []\n",
+ encoding="utf-8")
+ row = rows_of(doctor(standard, project, "--json"))["manifest-kinds"]
+ assert row["status"] == "FINDING", row
+ assert set(row["detail"]) == MANIFEST_KINDS_DETAIL_KEYS, row["detail"]
+ assert row["reason"] == ("no validator registered for kind "
+ "contracts/inventory.yaml (service-inventory)")
+ assert row["next"] == UNREGISTERED_NEXT, row["next"]
+
+ result = doctor(standard, holder["root"], "--json")
+ assert result.returncode == 0, result.stdout + result.stderr
+ row = rows_of(result)["manifest-kinds"]
+ assert row["status"] == "ok", row
+ assert set(row["detail"]) == MANIFEST_KINDS_DETAIL_KEYS, row["detail"]
+ assert "family.yaml (family-manifest)" in row["detail"]["validated"], row
+
+
+def test_gitlinks_under_lists_one_directory_and_nothing_above_it(project):
+ """`gitlinks_under` is asked for the members directory a hand-edited
+ manifest names, so an empty one, an absolute one and one that climbs
+ list nothing -- never every gitlink in the root, the two legs included."""
+ module = doctor_module(REPO)
+ commit = git("rev-parse", "HEAD", cwd=project).stdout.strip()
+ record_gitlink(project, "members/One", commit)
+ record_gitlink(project, "members/sub/Two", commit)
+ assert module.gitlinks_under(project, "members") == [
+ "members/One", "members/sub/Two"]
+ assert module.gitlinks_under(project, "members/") == [
+ "members/One", "members/sub/Two"]
+ assert module.gitlinks_under(project, "members/sub") == [
+ "members/sub/Two"]
+ for directory in ("", ".", "/", "/members", "\\members", "..",
+ "members/..", "../members", "C:/members",
+ str(project / "members")):
+ assert module.gitlinks_under(project, directory) == [], directory
+ # A leg is a gitlink AT its directory, not under it.
+ assert module.gitlinks_under(project, "spec") == []
+
+
+def test_gitlinks_under_reads_names_unquoted_and_stage_0_only(project):
+ """`-z` and stage 0, the two things `gitlinks_under` says it reads by.
+
+ A member whose name is not ASCII is listed ONCE, as itself: without
+ `-z`, `ls-files` prints it quoted and octal-escaped (`core.quotePath`),
+ and a row naming it would then report it as a gitlink no row names. A
+ gitlink left CONFLICTED under the directory -- stages 1, 2 and 3 and no
+ stage 0, which is what a merge that disagreed about a submodule leaves
+ -- is nobody's next commit and is not listed, rather than listed three
+ times. Non-ASCII is the one character git quotes that every platform's
+ index takes.
+
+ Both are written with `update-index --index-info` from BYTES: a
+ text-mode pipe on Windows turns each LF into CR LF and git stages the
+ path with a CR on it (see `stage_unmerged`), and bytes also keep the
+ name off a command line whose encoding differs by platform.
+ """
+ module = doctor_module(REPO)
+ commit = git("rev-parse", "HEAD", cwd=project).stdout.strip()
+ unquoted = "members/Ünï"
+ lines = f"160000 {commit} 0\t{unquoted}\n" + "".join(
+ f"160000 {commit} {stage}\tmembers/Conflicted\n"
+ for stage in (1, 2, 3))
+ subprocess.run(["git", "update-index", "--index-info"],
+ cwd=str(project), input=lines.encode("utf-8"),
+ capture_output=True, check=True)
+ staged = subprocess.run(["git", "ls-files", "-s", "-z", "--", "members"],
+ cwd=str(project), capture_output=True,
+ check=True).stdout.decode("utf-8")
+ assert sorted(record.split()[2] for record in staged.split("\0")
+ if record) == ["0", "1", "2", "3"], staged
+ assert module.gitlinks_under(project, "members") == [unquoted]
+
+
# --- not a shape root -------------------------------------------------------
def test_a_git_repository_with_no_manifest_says_adopt(standard, tmp_path):