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
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<role>-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/<role>-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,
Expand Down
13 changes: 8 additions & 5 deletions docs/handbook.html
Original file line number Diff line number Diff line change
Expand Up @@ -1416,7 +1416,8 @@ <h2>Is this repository compliant? <code>openRepoShape --doctor</code></h2>
are the pinned copies, which is the point &mdash; else the template copies, and the row says
which answered), <strong>manifest kinds</strong> (every <code>kind:</code>-bearing manifest
against a registry, so a <code>contracts/*.yaml</code> nothing validates is a finding by name
rather than a silence), <strong>shape currency</strong>
rather than a silence, and so is a <code>family.yaml</code> beside <code>project.yaml</code>),
<strong>shape currency</strong>
(<a href="#current"><code>update-shape.py check</code></a>, summarised), <strong>legs</strong>
(each mounted, and its checked-out commit against the pin, with no fetch), <strong>leg shape
files</strong>, <strong>placement</strong> (every tracked path of every leg, against the path
Expand Down Expand Up @@ -1777,10 +1778,12 @@ <h2>For AI assistants</h2>
<code>templates/workspace-root/</code> row, the fifth template, added (#154); the Triad as
the preferred shape, where the advisory is said and where it is silent, the optional
<code>single-repository.yaml</code>, the doctor&rsquo;s <code>advisory</code> row and the
assistant&rsquo;s &ldquo;what you do tell them&rdquo; added (#163) &mdash; 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.</p>
assistant&rsquo;s &ldquo;what you do tell them&rdquo; added (#163); a
<code>family.yaml</code> beside <code>project.yaml</code> as a <strong>manifest kinds</strong>
finding added (#211) &mdash; 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.</p>
</footer>
</main>
</div>
Expand Down
147 changes: 142 additions & 5 deletions shape-doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -960,7 +960,125 @@
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]:

Check failure on line 993 in shape-doctor.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=opensoft_openRepoShape&issues=AaEsU7xDQHyfEi5K955q&open=AaEsU7xDQHyfEi5K955q&pullRequest=220
"""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:

Check failure on line 1081 in shape-doctor.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 23 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=opensoft_openRepoShape&issues=AaEsU7xDQHyfEi5K955r&open=AaEsU7xDQHyfEi5K955r&pullRequest=220
"""Every `kind:`-bearing manifest in this root, against the registry.

A YAML FILE WITH NO `kind:` IS NOT A MANIFEST and is skipped in silence:
Expand All @@ -969,10 +1087,16 @@
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):
Expand All @@ -982,23 +1106,36 @@
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)
Expand Down
Loading
Loading