blob: 59c817985d8c625a66c8885f8091705fc368ee31 [file]
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# https://www.apache.org/licenses/LICENSE-2.0
"""Keep every shared prose block present and identical between its one
source and every place that carries a copy.
This generalises `check-skill-preflight.py` (retired — this script replaces
it rather than sitting beside it) from *one* shared block to *any number*.
The reasoning that motivated the original script carries over unchanged:
Every framework skill has to answer the same question before it does
anything: *has this project actually been set up for the framework version
now installed?* That check cannot be a hook — on most harnesses **no code
executes when a plugin is installed or upgraded** (Agent Plugins 1.0 defines
no hook component at all, and Claude Code's `SessionStart` hook is wired
only into `magpie-setup`) — so it has to be *agentic*, instructions the
agent reads when the skill is invoked, which means it has to live in the
skill body. It also cannot be an include: a family plugin contains
`plugins/magpie-<family>/skills/<skill>` symlinked to `skills/<skill>`, and
Agent Plugins 1.0 forbids a symlink whose final target escapes the plugin
root, so a shared file at `skills/_shared/` would be unreachable from the
install shape most adopters use. Every skill needs its own copy of the
text. So: **one source, many generated copies**, with a pre-commit hook
that runs this script with `--fix` so editing the source is the whole
workflow and drift is repaired rather than merely reported.
That single-block story does not scale by hand to a second, third, and
fourth repeated paragraph (the git-repo + main-checkout pre-check shared by
`install.md`/`uninstall.md`, the ASF-detection step shared by
`upgrade.md`/`verify.md`, worktree enumeration, the sandbox-allowlist
helper chain, …) without either duplicating this whole script per block or
inventing a second propagation mechanism — which would just be the
duplication this repository exists to remove, one layer up. This script is
the one mechanism for both shapes:
* **The auto block** (`preflight`) is inserted by this tool into every
eligible target that lacks one and removed from every target that has
become exempt — `check-skill-preflight.py`'s exact historical behaviour:
same source (`tools/dev/preflight-block.md`), same delimiter comment
text, same insertion point (immediately after the first body-level `#`
heading), same exemption list read from live `family:` frontmatter. The
source deliberately stays at its historical path and the delimiter text
is byte-for-byte what `check-skill-preflight.py` emitted: every one of
the 65 propagated copies is unaffected by this script replacing that one,
and `skill-surface-hash.py`'s exclusion of the block from a skill's
reconciliation fingerprint needs no matching update.
* **Declared blocks** are any number of additional named blocks, sourced
from `tools/dev/blocks/<name>.md`. A target opts in by already carrying a
delimited region for that name — `<!-- BEGIN MAGPIE BLOCK: <name> —
generated from tools/dev/blocks/<name>.md --> ... <!-- END MAGPIE BLOCK:
<name> -->` — empty or already filled. This script only ever *fills* that
region; it never inserts one, because a declared block's anchor point
(which paragraph, in which file) is a per-block editorial decision the
extraction that adds the block makes once, not something this generic
tool should guess at. A target that names a block with no matching
`tools/dev/blocks/<name>.md` is an error, never a silent skip — including
when the source existed at some point and was since removed while a
target still declares it: the stale text is left exactly as it was and
the run fails, rather than quietly blanking or quietly ignoring it.
Declared-block targets are restricted to the `skills/` tree (the same
root `skill-surface-hash.py` walks) — a region discovered outside it is
rejected rather than filled, so the mechanism cannot be used to
propagate prose into arbitrary documentation by accident.
* **Per-target parameter.** A declared block's source may contain the
placeholder `{override_name}`; it is substituted, per target, with the
name of the `skills/<name>` entry (in this repo, a symlink into
`plugins/magpie-<family>/skills/<dir>/`) that resolves to the target's
skill directory — e.g. `skills/issue-triage` for
`plugins/magpie-issue/skills/triage/`. That is the name an adopter's
override file carries (`.apache-magpie-overrides/issue-triage.md`), which
the plugin directory name alone does not give. A target whose directory
no `skills/<name>` entry resolves to — or more than one does — is an
error, never a silent skip: the region is left as it was and the run
fails. A source without the placeholder is rendered exactly as before.
The two marker shapes are a deliberate, documented asymmetry — not a gap to
close reflexively. Unifying them (moving `preflight-block.md` under
`tools/dev/blocks/` and reformatting its delimiter) is worth doing
opportunistically, the day a later task's own churn already touches all 65
propagated copies for an unrelated reason, rather than as a standalone
change whose entire diff would be that rewrite.
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from pathlib import Path
SKILLS = Path("skills")
ALLOWED_ROOTS: tuple[Path, ...] = (SKILLS,)
BLOCKS_DIR = Path("tools/dev/blocks")
# The one per-target parameter a declared block source may carry. See the
# module docstring's "Per-target parameter" bullet.
OVERRIDE_NAME_PLACEHOLDER = "{override_name}"
# --- the auto block: preflight ----------------------------------------------------
#
# Kept byte-for-byte identical to `check-skill-preflight.py`'s constants and
# logic: same source path, same delimiter text, same insertion rule. None of
# the 65 propagated copies change because this script replaces that one.
PREFLIGHT_SOURCE = Path("tools/dev/preflight-block.md")
PREFLIGHT_BEGIN = "<!-- BEGIN MAGPIE PREFLIGHT — generated from tools/dev/preflight-block.md -->"
PREFLIGHT_END = "<!-- END MAGPIE PREFLIGHT -->"
# Matches the whole delimited region including a trailing blank line, so a
# repeated --fix neither duplicates the block nor accumulates whitespace.
PREFLIGHT_RE = re.compile(
re.escape(PREFLIGHT_BEGIN) + r".*?" + re.escape(PREFLIGHT_END) + r"\n*",
re.S,
)
# The `setup` family is exempt, and the exemption is the point rather than an
# oversight: these are the skills that *perform* the setup. A pre-flight
# telling the agent to run `/magpie-setup` before running `/magpie-setup` is
# a loop, and `setup-isolated-setup-install` in particular has to work on a
# repo that has deliberately adopted nothing yet. Membership is read from the
# live `family:` frontmatter, so the exemption cannot drift from the family
# it names.
EXEMPT_FAMILIES = frozenset({"setup"})
FRONTMATTER_RE = re.compile(r"^---\n.*?\n---\n", re.S)
HEADING_RE = re.compile(r"^# .*$", re.M)
FAMILY_RE = re.compile(r"^family:[ \t]*(\S+)[ \t]*$", re.M)
def family_of(text: str) -> str | None:
match = FAMILY_RE.search(text)
return match.group(1) if match else None
DOCTOC_RE = re.compile(
r"^<!-- START doctoc.*?<!-- END doctoc[^>]*-->\n+",
re.S,
)
def _strip_licence_header(raw: str) -> str:
"""Drop a source file's own licence header, if it is the very first
thing in the file — each target already carries one of its own, and a
second would render inside the target's body.
A leading doctoc region goes the same way, and for a sharper reason: it
is a table of contents for the *source* file, which is not the file any
target is. One rode into all 65 propagated copies until the source was
added to the doctoc hook's exclude list; stripping it here means a
future source that picks one up again cannot repeat that."""
stripped = DOCTOC_RE.sub("", raw)
return re.sub(r"^<!--\s*SPDX-License-Identifier.*?-->\n+", "", stripped, flags=re.S)
def preflight_block_text(source: Path = PREFLIGHT_SOURCE) -> str:
"""The generated auto-block region: delimiters around the source
file's body, exactly as `check-skill-preflight.py`'s `block_text()`
produced it."""
raw = source.read_text()
body = _strip_licence_header(raw)
return f"{PREFLIGHT_BEGIN}\n\n{body.strip()}\n\n{PREFLIGHT_END}\n"
def apply_preflight(path: Path, block: str) -> tuple[bool, str | None]:
"""Return (changed, error). Rewrites `path` only when it differs.
Identical to `check-skill-preflight.py`'s `apply()`."""
text = path.read_text()
stripped = PREFLIGHT_RE.sub("", text)
match = FRONTMATTER_RE.match(stripped)
if not match:
return False, f"{path}: no YAML frontmatter"
heading = HEADING_RE.search(stripped, match.end())
if not heading:
return False, f"{path}: no body-level '# ' heading to anchor the block to"
cut = heading.end()
# Exactly one blank line between the heading and the block.
rest = stripped[cut:].lstrip("\n")
updated = f"{stripped[:cut]}\n\n{block}\n{rest}"
if updated == text:
return False, None
path.write_text(updated)
return True, None
# --- declared blocks ----------------------------------------------------------------
_BLOCK_NAME = r"[a-z][a-z0-9-]*"
# Discovers a declared-block region by name, wherever it appears — the name
# in BEGIN and END must match (backreference), so a truncated or mismatched
# pair is never silently treated as a region. `(?P<indent>[ \t]*)`, anchored
# with `^` (needs `re.M`), captures whatever leading whitespace the BEGIN
# line carries — a region nested inside a list item's continuation is
# indented to stay part of that item; a region at the top of a document is
# not. The same backreference-on-name trick applies to indent implicitly:
# both markers are captured from the *same* match, so BEGIN and END are
# never treated as a pair unless they share both name and column.
DECLARED_RE = re.compile(
r"^(?P<indent>[ \t]*)<!-- BEGIN MAGPIE BLOCK: (?P<name>" + _BLOCK_NAME + r") — generated from \S+ -->\n"
r"(?P<body>.*?)"
r"^(?P=indent)<!-- END MAGPIE BLOCK: (?P=name) -->\n",
re.M | re.S,
)
def declared_block_source(name: str, blocks_dir: Path = BLOCKS_DIR) -> Path:
return blocks_dir / f"{name}.md"
def _indent_body(body: str, indent: str) -> str:
"""Apply `indent` to every non-blank line of `body`. Blank lines are
left *exactly* blank — never `indent` alone — because the
`trailing-whitespace` prek hook strips whitespace-only lines on every
run. If this function indented a blank line, `--fix` would write it back
with trailing whitespace, `trailing-whitespace` would strip it again on
the next hook in the same chain, and the two would disagree forever:
`process_declared`'s `new_text == text` comparison would never converge,
so `--fix` would report a change on every single run."""
if not indent:
return body
return "\n".join(f"{indent}{line}" if line.strip() else "" for line in body.split("\n"))
class UnresolvedParameterError(Exception):
"""A declared block needs `{override_name}` but the target has no
single `skills/<name>` entry resolving to its directory."""
def resolve_override_name(path: Path, skills_root: Path = SKILLS) -> tuple[str | None, str | None]:
"""Return `(name, error)` for the `skills/<name>` entry whose resolved
location is `path`'s skill directory.
Exactly one match yields `(name, None)`. None, or more than one, yields
`(None, reason)` — an ambiguous mapping would silently pick one
adopter-facing override filename over another, so it is refused the
same way a missing one is."""
try:
target_dir = path.parent.resolve()
except OSError as exc:
return None, f"cannot resolve {path.parent}: {exc}"
matches: list[str] = []
if skills_root.is_dir():
for entry in sorted(skills_root.iterdir()):
try:
if entry.is_dir() and entry.resolve() == target_dir:
matches.append(entry.name)
except OSError:
continue
if len(matches) == 1:
return matches[0], None
if not matches:
return None, f"no {skills_root.as_posix()}/<name> entry resolves to {target_dir}"
return (
None,
f"several {skills_root.as_posix()}/<name> entries resolve to {target_dir}: {', '.join(matches)}",
)
def declared_block_text(
name: str,
blocks_dir: Path = BLOCKS_DIR,
indent: str = "",
override_name: str | None = None,
) -> str:
"""The generated region for a declared block, indented by `indent` (the
column the host's own BEGIN marker was found at — see `DECLARED_RE`'s
`indent` group). Raises `FileNotFoundError` when the named block has no
source — callers turn that into a reported error rather than letting it
propagate as a crash.
Block *sources* under `tools/dev/blocks/` are always written flush left
(column 0) — indentation is never baked into the source file, because
`body.strip()` below trims only the string's true start/end, not each
line's own leading whitespace, so a source pre-indented to "look right"
in one host would silently lose that indent on its very first line (and
keep it on every other) the moment it landed anywhere else. Applying
`indent` here, once, after `.strip()`, is what lets one flat source
render correctly whether it lands flush left (`upgrade.md`'s top-level
`Procedure:` list) or nested three spaces under a list item
(`install.md`'s equivalent, nested under `2. **Propagate ...**`)."""
source = declared_block_source(name, blocks_dir)
if not source.is_file():
raise FileNotFoundError(source)
raw = source.read_text()
body = _strip_licence_header(raw).strip()
if OVERRIDE_NAME_PLACEHOLDER in body:
if override_name is None:
raise UnresolvedParameterError(name)
body = body.replace(OVERRIDE_NAME_PLACEHOLDER, override_name)
indented_body = _indent_body(body, indent)
begin = f"{indent}<!-- BEGIN MAGPIE BLOCK: {name} — generated from {source.as_posix()} -->"
end = f"{indent}<!-- END MAGPIE BLOCK: {name} -->"
return f"{begin}\n\n{indented_body}\n\n{end}\n"
def strip_generated_regions(text: str) -> str:
"""Remove every generated region — the auto preflight block *and* any
declared block — from `text`.
This is the one place both marker shapes are combined for exclusion.
`skill-surface-hash.py` loads this module and calls this exact
function rather than keeping a second, driftable copy of what "a
generated region" looks like: a `PREFLIGHT_RE`-only strip was correct
only while zero declared blocks existed, and the moment one lands in a
shared detail file, its headings must disappear from the fingerprint
the same way the pre-flight block's always have — editing shared
framework text is a framework change, not a project-specific
reconciliation event. See `skill-surface-hash.py`'s module docstring
for the stated consequence of that exclusion.
"""
text = PREFLIGHT_RE.sub("", text)
text = DECLARED_RE.sub("", text)
return text
def fill_declared(
text: str,
blocks_dir: Path = BLOCKS_DIR,
override_name: str | None = None,
override_name_error: str | None = None,
) -> tuple[str, list[str]]:
"""Fill every declared-block region found in `text` from `blocks_dir`.
Returns `(new_text, errors)`. A region naming a block with no matching
source file is left exactly as it was in `text` and reported as an
error — never silently dropped, never silently left stale without
comment. The same holds for a block whose source carries
`{override_name}` when `override_name` is `None`;
`override_name_error` is the reason reported in that case.
"""
errors: list[str] = []
def _replace(match: re.Match[str]) -> str:
name = match.group("name")
indent = match.group("indent")
try:
return declared_block_text(name, blocks_dir, indent=indent, override_name=override_name)
except FileNotFoundError as exc:
errors.append(f"declares unknown block '{name}' — {exc.args[0]} does not exist")
return match.group(0)
except UnresolvedParameterError:
reason = override_name_error or "no override name was supplied"
errors.append(f"block '{name}' needs {OVERRIDE_NAME_PLACEHOLDER} but {reason}")
return match.group(0)
new_text = DECLARED_RE.sub(_replace, text)
return new_text, errors
def is_allowed_target(path: Path, roots: tuple[Path, ...] = ALLOWED_ROOTS) -> bool:
"""A declared-block region may only be honoured inside `roots` — the
same tree `skill-surface-hash.py` walks. This is deliberately checked
per-file (not just enforced by what `main()` happens to glob), so a
future caller cannot accidentally propagate shared prose into arbitrary
documentation.
Deliberately `.absolute()` (normalised, not resolved), never
`.resolve()`: this framework's own repo self-adopts itself, and every
`skills/<name>/` entry there is a symlink into
`plugins/<family>/skills/<name>/` (see `skills/setup/agents.md` → the
canonical-plus-relay model). `.resolve()` follows that symlink to its
real location outside `skills/`, which would reject every legitimate
target in this repo's own tree. `.absolute()` only prepends the cwd to a
relative path — it never follows symlinks — so containment is judged on
the path `main()` actually globbed (always under `skills/` by
construction), not on where a symlinked skill happens to physically
live.
`.absolute()` alone is not enough, though: it does not collapse `..` /
`.` segments, so a path such as `skills/../docs/notes.md` would pass a
naive `relative_to` check by literal string prefix even though it walks
straight back out of `skills/`. `os.path.normpath` collapses those
segments on the already-symlink-preserving absolute path, closing that
gap without reintroducing the symlink-following `.resolve()` was
rejected for. A path outside `roots` to begin with (the case this check
exists to catch) is still rejected the same way."""
try:
resolved = Path(os.path.normpath(str(path.absolute())))
except OSError:
return False
for root in roots:
try:
resolved.relative_to(Path(os.path.normpath(str(root.absolute()))))
return True
except ValueError:
continue
return False
def process_declared(
path: Path,
*,
blocks_dir: Path = BLOCKS_DIR,
roots: tuple[Path, ...] = ALLOWED_ROOTS,
skills_root: Path = SKILLS,
fix: bool = False,
) -> tuple[bool, list[str]]:
"""Process one candidate target file for declared-block regions.
Returns `(changed, errors)`. `changed` reports drift regardless of
`--fix` — only `--fix` actually writes. A file with no declared-block
marker at all is a no-op: `(False, [])`.
"""
text = path.read_text()
if not DECLARED_RE.search(text):
return False, []
if not is_allowed_target(path, roots):
return False, [
f"{path}: carries a declared block region but is outside the allowed roots {tuple(str(r) for r in roots)}"
]
override_name, override_name_error = resolve_override_name(path, skills_root)
new_text, fill_errors = fill_declared(
text, blocks_dir, override_name=override_name, override_name_error=override_name_error
)
errors = [f"{path}: {message}" for message in fill_errors]
if new_text == text:
return False, errors
if fix:
path.write_text(new_text)
return True, errors
errors.append(f"{path}: declared block(s) differ from source")
return True, errors
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--fix",
action="store_true",
help="write every shared block into its targets instead of only reporting",
)
args = parser.parse_args()
if not PREFLIGHT_SOURCE.is_file():
print(f"{PREFLIGHT_SOURCE}: missing — it is the only source of the pre-flight block", file=sys.stderr)
return 1
skills = sorted(SKILLS.glob("*/SKILL.md"))
if not skills:
print(f"{SKILLS}: no SKILL.md files found", file=sys.stderr)
return 1
errors: list[str] = []
changed: list[Path] = []
exempt: list[Path] = []
# --- the auto block ---
block = preflight_block_text()
for path in skills:
text = path.read_text()
if family_of(text) in EXEMPT_FAMILIES:
exempt.append(path)
# An exempt skill must not carry a stale block from before it
# was exempted, so removing one is part of keeping the set in
# sync.
if PREFLIGHT_RE.search(text):
if args.fix:
path.write_text(PREFLIGHT_RE.sub("", text))
changed.append(path)
else:
errors.append(f"{path}: carries the pre-flight block but its family is exempt")
continue
if args.fix:
did, err = apply_preflight(path, block)
if err:
errors.append(err)
elif did:
changed.append(path)
else:
found = PREFLIGHT_RE.search(text)
if not found:
errors.append(f"{path}: missing the shared pre-flight block")
elif found.group(0).rstrip("\n") != block.rstrip("\n"):
errors.append(f"{path}: pre-flight block differs from {PREFLIGHT_SOURCE}")
# --- declared blocks: every *.md directly inside a skills/<name>/ dir ---
declared_targets = sorted(SKILLS.glob("*/*.md"))
declared_seen = 0
declared_changed: list[Path] = []
for path in declared_targets:
# Counts *regions*, not files: a single detail file can (and does —
# `install.md` carries three) hold more than one declared-block
# marker, and the maintainer's only confirmation that propagation
# actually happened is this count. `process_declared` returns
# `(False, [])` both for "no marker at all" and for "marker(s)
# present, nothing to do", so that return value alone cannot tell
# the two apart — count independently via `finditer`.
region_count = len(list(DECLARED_RE.finditer(path.read_text())))
did_change, target_errors = process_declared(path, fix=args.fix)
declared_seen += region_count
errors.extend(target_errors)
if did_change and args.fix:
declared_changed.append(path)
if errors:
print("Shared blocks are out of sync:", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
if not args.fix:
print(
f"\nRun `python3 {Path(__file__).name} --fix` — or edit the source: "
f"{PREFLIGHT_SOURCE} for the pre-flight block, {BLOCKS_DIR}/<name>.md for a "
"declared one. Those are the only places the wording should change.",
file=sys.stderr,
)
return 1
total_changed = changed + declared_changed
if total_changed:
print(f"Updated shared blocks in {len(total_changed)} file(s):")
for path in total_changed:
print(f" - {path}")
return 1
print(
f"Pre-flight block in sync across {len(skills) - len(exempt)} skills "
f"({len(exempt)} exempt: {', '.join(sorted(p.parent.name for p in exempt))}); "
f"{declared_seen} declared block region(s) in sync."
)
return 0
if __name__ == "__main__":
sys.exit(main())