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
11 changes: 6 additions & 5 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,12 @@ options:
the CHAIN of neutral-product pins this project reaches
its referent through, e.g. --referent-chain
openXdox,openDox. Its first entry must be one of the
--pin products (the pin this project actually holds)
and its last must be the `open<Product>` the name
claims; the manifest RECORDS it, which is what makes a
descendant that pins a LAYER a descendant
(2026-09-05).
--pin products (the pin this project actually holds),
its last must be the `open<Product>` the name claims,
and every link between them must be a neutral
product's name (`open<Product>`, e.g. openXdox); the
manifest RECORDS it, which is what makes a descendant
that pins a LAYER a descendant (2026-09-05).
--pin-source PIN_SOURCE
a local clone to compute --pin digests from, instead
of reading the forge with `gh api` (the TEST path; no
Expand Down
98 changes: 90 additions & 8 deletions scaffold-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
repositories and a rename. A leg path that is, holds or lies inside a
path the shape writes into the assembly root (`contracts`,
`project.yaml`, a `--pin`'s `contracts/<product>-pin.yaml`) is refused
just as early, for the same reason (#197).
just as early, for the same reason (#197), and so is a `--referent-chain`
link between the first and the last that is not a neutral product's
name (#251).
2. Creates the three remotes: `gh repo create` normally, or three BARE
repositories in a directory with `--local-remote-dir`, which is the test
path and touches no network.
Expand Down Expand Up @@ -54,7 +56,7 @@
free_plan_secret_hint,
COMMIT_RE, NEUTRAL_PRODUCT_OWNER, PROJECT_ID_RE, TREE_DIGEST_DEFINITION,
VISIBILITY_CHOICES, NamingPolicy, Refusal, accepts_role, checked_value,
git_out, link_pins_from_trees, tree_digest,
git_out, link_pins_from_trees, repo_basename, tree_digest,
)
from shape_materialize import ( # noqa: E402
RULESET_HINT, SHAPE_REPOSITORY, CommandFailed,
Expand All @@ -79,6 +81,12 @@
#: rather than from a clone on this machine.
GH_TREE_API = "repos/{repo}/git/trees/{commit}?recursive=1"

#: The naming-policy form every link between a chain's first and last must
#: classify as (#251). Each such link is an entry of the link before its
#: `neutral_product_pins:`, the key a link is verified by
#: (`referent.chain.link_declared_by`), so it is a neutral product's name.
CHAIN_LINK_FORM = "neutral-product"


def parse_pin(raw: str, owner: str = NEUTRAL_PRODUCT_OWNER) -> tuple[str, str, str]:
"""`openGlass@<sha>` -> (product, `<owner>/openGlass`, commit).
Expand Down Expand Up @@ -300,11 +308,13 @@ def main(argv: list[str] | None = None) -> int:
"reaches its referent through, e.g. "
"--referent-chain openXdox,openDox. Its first "
"entry must be one of the --pin products (the "
"pin this project actually holds) and its last "
"must be the `open<Product>` the name claims; "
"the manifest RECORDS it, which is what makes a "
"descendant that pins a LAYER a descendant "
"(2026-09-05).")
"pin this project actually holds), its last "
"must be the `open<Product>` the name claims, "
"and every link between them must be a neutral "
"product's name (`open<Product>`, e.g. "
"openXdox); the manifest RECORDS it, which is "
"what makes a descendant that pins a LAYER a "
"descendant (2026-09-05).")
parser.add_argument("--pin-source", type=Path, default=None,
help="a local clone to compute --pin digests from, "
"instead of reading the forge with `gh api` (the "
Expand Down Expand Up @@ -519,6 +529,73 @@ def _identity(args, project: str) -> tuple[str, str, str, str, str]:
return project_id, display, elected_on, reference, _elector(args)


def referent_chain_links(raw: str) -> tuple:
"""`--referent-chain`'s links: split on commas, each stripped, an empty
one dropped. ONE reading, for `_declaration` and for the check below, so
the links that are judged are the links that are recorded."""
return tuple(link.strip() for link in raw.split(",") if link.strip())


def _refuse_malformed_chain_links(policy, chain: tuple) -> None:
"""Every link between the chain's first and last is a neutral product's
name, or this refuses (#251).

`naming_block` writes the chain into `project.yaml` as a plain flow
sequence, `[openXdox, openDox]`, and nothing else looked at a MIDDLE
link: `_chain_shape_problem` asks the length, a repeat, the first link and
the last, and a link whose tree is not on the disk is declared-unverified,
a warning. On main through 150c41d, `--referent-chain openXdox,a #b,openDox`
created all three repositories, pushed both legs and exited 0 with a
manifest the project's own `validate-manifest.py` refused as
`yaml-unparsable` (the `#` began a comment), and `it's`, `a]b`, `null` and
`007` were each recorded as a different chain than the one typed.

THE NAMING POLICY ALREADY SAYS WHAT A LINK IS. Each link past the first is
read out of the link before its `neutral_product_pins:`, so it is the
name of a neutral product, and `CHAIN_LINK_FORM` is that family. A name of
that form is one plain YAML word, so the manifest stays plain and reads
back as typed. The link is judged at its basename, as `resolve_referent`
reads it and `naming_block` records it.

The FIRST and LAST links are left to the checks that already anchor them:
the first must be a `--pin` product and the last the referent the name
claims, both compared casefolded because a pin names WHICH repository, not
how it was typed. Each must casefold to a name this run already holds
(`[A-Za-z0-9_.-]`, or `open<Product>`), so neither can carry a `#`, a
quote, a bracket, a comma or a space.

Asked before `_declaration`, so before `gh api` reads a pin's tree and
before `--dry-run` returns: a dry run refuses exactly as the real run
does, and nothing has been created.

The chain and each bad link are quoted with `repr`, as `--pin` and `--id`
are in their refusals: a link is the operator's own argument, and one
that holds a newline, a carriage return or an escape byte prints as
`\\n`, `\\r` or `\\x1b`, so it cannot end the refusal's line and start
another that reads as a refusal of its own.
"""
malformed = [link for link in chain[1:-1]
if CHAIN_LINK_FORM not in {family for family, _role
in policy.matches(
repo_basename(link))}]
if not malformed:
return
form = (policy.family(CHAIN_LINK_FORM) or {}).get("pattern", "")
raise Refusal(
"chain-link-malformed",
f"--referent-chain {','.join(chain)!r}: "
+ ", ".join(repr(link) for link in malformed)
+ (" is not" if len(malformed) == 1 else " are not")
+ " the name of a neutral product. Every link between the first and "
"the last is a product the link before it pins, and the manifest "
"records each one as a name.",
"Remediation: nothing was created. Spell each link between the first "
"and the last as the neutral product's repository name, "
f"`open<Product>` (the naming policy's `{form}`, e.g. openXdox). "
"`validate-repository-naming.py --explain <link>` says which form a "
"name is.")


def _declaration(args) -> tuple[dict, set, tuple, dict]:
"""(c) What the project DECLARES about itself: `(pins, declared_pins,
chain, link_pins)`.
Expand All @@ -533,7 +610,7 @@ def _declaration(args) -> tuple[dict, set, tuple, dict]:
"""
pins = declared_pin_values(args)
declared_pins: set[str] = set(pins)
chain = tuple(c.strip() for c in args.referent_chain.split(",") if c.strip())
chain = referent_chain_links(args.referent_chain)
link_pins = link_pins_from_trees(chain, None, {}, args.pin_source) \
if chain else {}
return pins, declared_pins, chain, link_pins
Expand Down Expand Up @@ -992,6 +1069,11 @@ def _scaffold(args) -> int:
# satisfies no leg form at all, offered as ANY leg; and either of the two
# CamelCase forms offered as the `spec` or `code` leg, because those names
# carry the lowercase suffix and are ordinary project legs.
#
# A chain's middle links are NAMES, judged before `_declaration` asks
# `gh api` or git for any pin's tree (#251).
_refuse_malformed_chain_links(policy,
referent_chain_links(args.referent_chain))
pins, declared_pins, chain, link_pins = _declaration(args)
_check_declared_names(policy, names, declared_pins, chain, link_pins)
topic = policy.topic_for(project_id)
Expand Down
Loading
Loading