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/-root/` — a comparison and not a gate, -because no leg file is pinned anywhere), **placement** (every tracked path of -every leg, against the path policy), **agent files**, and **machine**. A -FAMILY holder gets **family**, **manifest kinds**, **shape currency**, -**placement** (`n/a` — a holder has no legs of its own), **members** and -**machine**; a directory that is neither gets what it is CALLED under the -naming policy, what IS in it, and the way in — and a repository the advisory -is not silent about gets the **Triad** row, status `advisory`: the preference -said once and stepped over by the verdict (a leg gets its assembly root). +nothing validates is a finding by name rather than a silence, and so is a +`family.yaml` beside `project.yaml`), **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/-root/` +— a comparison and not a gate: no leg file is pinned anywhere), **placement** +(every tracked path of every leg, against the path policy), **agent files**, and +**machine**. A FAMILY holder gets **family**, **manifest kinds**, **shape +currency**, **placement** (`n/a` — a holder has no legs of its own), **members** +and **machine**; a directory that is neither gets what it is CALLED under the +naming policy, what IS in it, and the way in — and a repository the advisory is +not silent about gets the **Triad** row, status `advisory`: the preference said +once and stepped over by the verdict (a leg gets its assembly root). Every row is `ok`, `note`, `FINDING` or `n/a` — or `advisory`, the Triad row alone — and every one that is not `ok` names the exact command that fixes it, diff --git a/docs/handbook.html b/docs/handbook.html index ce3e2616..e5a3782a 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1416,7 +1416,8 @@

Is this repository compliant? openRepoShape --doctor

are the pinned copies, which is the point — else the template copies, and the row says which answered), manifest kinds (every kind:-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.

+ assistant’s “what you do tell them” added (#163); a + 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):