blob: eaf3ea384bec7a1ff459e5a6651c65990095c760 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Fail the build on new cross-file near-duplicate prose — the gate that
keeps the duplication `check-shared-blocks.py` and `skill-surface-hash.py`
removed from coming back. Designed for the whole `skills/` tree; wired,
for now, over only the setup-family surface that removal actually
touched — see "Landing scope vs. the whole duplication problem" below.
`check-shared-blocks.py` gives skills a way to carry shared prose once and
propagate it everywhere it is used. That mechanism only works if something
stops a contributor from doing the easy thing instead: pasting a paragraph
copied from another skill straight into a new one. Nothing about that paste
looks wrong in review — it reads fine in isolation, the diff is small, and
the reviewer would have to already know the other skill's wording by heart
to catch it. This script is that check: it scans every scoped file for
paragraphs that are near-duplicates of a paragraph in a *different* file and
fails the build when the overlap is high enough that it should have been a
declared block instead.
**What it measures.** Paragraphs of more than 25 words, normalised to
lowercase word tokens, compared across files as sets of 9-grams (nine
consecutive tokens), scored `|A ∩ B| / min(|A|, |B|)` — containment rather
than Jaccard, so a short paragraph fully quoted inside a much longer one
still scores high even though the longer paragraph carries plenty of text
the short one does not. The score is symmetric by construction (the
denominator does not depend on which paragraph is "A") and does not depend
on the order files are scanned in, since every unordered pair of paragraphs
from two different files is scored exactly once.
**What it must not see.** Generated regions — the auto pre-flight block
`check-shared-blocks.py` inserts into every non-`setup` skill, and any
declared block filled in from `tools/dev/blocks/<name>.md` — are duplicates
*by design*: 65 skills carry the same pre-flight block on purpose. This
script reuses `check-shared-blocks.py`'s own `PREFLIGHT_RE` / `DECLARED_RE`
marker regexes (not a second, driftable copy of what "a generated region"
looks like) to blank those spans out before paragraph-splitting — blanked
rather than deleted, specifically so that removing a generated region can
never fuse the paragraph before it and the paragraph after it into one. YAML
frontmatter and fenced code blocks are excluded the same way: frontmatter is
metadata, not prose, and a shared command sequence in a code fence is
legitimately identical across skills.
**Thresholds.** Chosen so the *wired* scope below (not the whole tree —
see "Landing scope" above for why those two differ) passes clean today,
with no allowlist:
* **Fail above 0.50.** Nothing in the *wired* scope reaches it today, so
the gate ships with no allowlist and no grandfathered debt there, while
still blocking a return to the 0.56-0.88 range the shared-block
extraction removed. The whole `skills/` tree is a different story —
see "Landing scope" below.
* **Report, without failing, everything in [0.30, 0.50].** That tail stays
visible in every run's output so it can be reduced deliberately, instead
of being hidden behind an ignore file that nobody revisits.
* **Say nothing below 0.30.** A quiet, high-bar report is the point: a
gate that cries wolf on ordinary shared vocabulary teaches the next
contributor to add an ignore entry instead of fixing the duplication, and
becomes decoration.
**Scope, as designed.** The whole `skills/` tree (recursively — a
multi-file skill's sibling detail files carry just as much prose as
`SKILL.md` itself) plus `tools/dev/blocks/*.md` and
`tools/dev/preflight-block.md` — the declared-block sources themselves are
in scope, so a paragraph pasted into a skill that already duplicates a
block's *source* text is caught too, which is exactly the case that should
have used the block instead of copying it. `docs/`, `.superpowers/`, and
eval fixtures are out of scope by design: fixtures repeat each other on
purpose, and this check is about the skill surface the shared-block
mechanism actually owns.
**Scope, as wired (`WIRED_SKILLS_ROOT` below).** Narrower than the design
above, on purpose — see "Landing scope vs. the whole duplication problem"
below for the measured numbers and why. The hook runs this check only
over `plugins/magpie-setup/skills/setup/*.md`, `tools/dev/blocks/*.md`,
and `tools/dev/preflight-block.md`: exactly the surfaces the shared-block
extraction this check was built to guard actually touched, where the tree
is clean today and the gate passes with no allowlist. `discover_targets`
still takes `skills_root` as a parameter — a future widening (see the
recommended sequence below) is a one-line change to `WIRED_SKILLS_ROOT`,
not a rewrite.
**Landing scope vs. the whole duplication problem.** A whole-tree run
(`discover_targets(skills_root=Path("skills"))`) finds far more than the
setup-family surface this effort covers: 4,970 paragraphs, **3,793** pairs
above `FAIL_THRESHOLD`, maximum score **1.00** — not near-duplicate,
byte-identical. Traced by hand (`grep`, independent of this script): the
`**Hard rule**: agents NEVER modify the snapshot under
`<adopter-repo>/.apache-magpie/`...` admonition is identical in **45**
`SKILL.md` files; an "Adopter overrides" preamble paragraph (only the
skill's own filename substituted) is near-identical in **56**; a
"Snapshot drift" paragraph follows the same pattern. None of the three is
wrapped in a `<!-- BEGIN MAGPIE BLOCK -->` / auto-preflight marker, so this
script's own `strip_generated_regions` cannot see them — and
`skills/write-skill/SKILL.md` documents all three as **the framework
preamble**: "every framework skill carries these; `init_skill.py`
scaffolds them." That is the tell. This is not organic copy-paste sprawl;
it is a *second*, older propagation mechanism (a one-time scaffold copy at
skill-creation time) that was never migrated to the modern one this file
already reuses (`check-shared-blocks.py`'s auto pre-flight block, inserted
into every non-`setup` skill from one source). The fix belongs in that
*auto*-propagation path, not in declared regions hand-placed into 56
files one at a time: a declared block only fills a region a target
already carries by hand, which is the multiplication problem all over
again at extraction time, whereas the auto block is inserted and kept in
sync by the tool itself. `check-shared-blocks.py` currently supports
exactly **one** auto block (the pre-flight block); carrying several
(pre-flight, `Adopter overrides`, `Snapshot drift`, …) is a mechanism
change to that script, not something this file can do on its own.
Recommended sequence for whoever picks this up: **(1)** extend
`check-shared-blocks.py`'s auto-block mechanism to carry more than one
named auto block, migrate `Adopter overrides` / `Snapshot drift` (and
likely the `Hard rule` admonition) onto it; **(2)** re-run this checker
whole-tree and confirm the fail-band count has actually dropped, not just
moved; **(3)** widen `WIRED_SKILLS_ROOT` below (or drop it in favour of
the full `skills/` tree) once the tree is clean at that scope too. None of
that is done here — the maintainer scoped it out of this landing
deliberately, and this file does not touch `check-shared-blocks.py`.
Update: the `Adopter overrides` preamble and its `Hard rule` admonition now
come from one declared block, `tools/dev/blocks/adopter-overrides.md`, filled
per skill (with a `{override_name}` parameter) by `check-shared-blocks.py`;
`Snapshot drift` was removed earlier. A whole-tree re-run (step 2) is still
to do.
Run standalone (`python3 tools/dev/check-duplication.py`) or as the
`check-duplication` prek hook, wired `pass_filenames: false` and
whole-scope: a diff-scoped run cannot see that a paragraph added in this
PR duplicates one that already exists in a file the PR never touched.
"""
from __future__ import annotations
import argparse
import importlib.util
import os
import re
import sys
from dataclasses import dataclass
from itertools import combinations
from pathlib import Path
from types import ModuleType
def _load_shared_blocks() -> ModuleType:
"""Load `check-shared-blocks.py` as a module so this script reuses its
exact `PREFLIGHT_RE` / `DECLARED_RE` marker regexes instead of keeping a
second, driftable definition of "a generated region" here."""
path = Path(__file__).resolve().parent / "check-shared-blocks.py"
spec = importlib.util.spec_from_file_location("check_shared_blocks", path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
_SHARED_BLOCKS = _load_shared_blocks()
# Re-exported, not redefined: the exact regex objects `check-shared-blocks.py`
# compiles, so a marker-format change there is automatically reflected here.
PREFLIGHT_RE = _SHARED_BLOCKS.PREFLIGHT_RE
DECLARED_RE = _SHARED_BLOCKS.DECLARED_RE
BLOCKS_DIR = Path("tools/dev/blocks")
PREFLIGHT_SOURCE = Path("tools/dev/preflight-block.md")
# The wired scope is deliberately narrower than the design's `skills/`
# tree — see the module docstring's "Landing scope vs. the whole
# duplication problem" section for the measured whole-tree numbers
# (4,970 paragraphs, 3,793 fail-band pairs, max score 1.00) and why. This
# is the ONLY root the design scans that is clean today: the setup family
# is exactly the surface tasks A-C's shared-block extraction touched.
# Widening this constant to the full `skills/` tree before the preamble
# duplication documented in the docstring is migrated onto the auto-block
# mechanism will immediately fail `prek run --all-files` on ~3800
# pre-existing pairs this script did not introduce and is not scoped to
# fix. Do not widen it without re-running the whole-tree scan first.
WIRED_SKILLS_ROOT = Path("plugins/magpie-setup/skills/setup")
WORD_FLOOR = 25 # a paragraph must have MORE than this many words to be scored
NGRAM_SIZE = 9
FAIL_THRESHOLD = 0.50 # score strictly greater than this fails the check
REPORT_THRESHOLD = 0.30 # score at/above this (and at/below FAIL_THRESHOLD) is reported, not failed
WORD_RE = re.compile(r"[A-Za-z0-9']+")
FRONTMATTER_RE = re.compile(r"^---\n.*?\n---\n", re.S)
CODE_FENCE_RE = re.compile(r"^[ \t]*```.*?\n.*?^[ \t]*```[ \t]*$\n?", re.M | re.S)
BLANK_LINE_RE = re.compile(r"\n[ \t]*\n+")
@dataclass(frozen=True)
class Paragraph:
path: Path
line: int
text: str
grams: frozenset[tuple[str, ...]]
@dataclass(frozen=True)
class DuplicatePair:
a: Paragraph
b: Paragraph
score: float
def _blank_out(pattern: re.Pattern[str], text: str) -> str:
"""Replace every match of `pattern` with a single blank line rather than
deleting it outright. A generated region or a code fence sits between
two ordinary paragraphs; deleting it outright can join the paragraph
before it directly onto the paragraph after it (no blank line survives
between them), silently fusing two unrelated paragraphs into one. A
blank-line replacement always leaves a paragraph boundary in its place,
so removal can only ever *drop* a paragraph, never merge two others."""
return pattern.sub("\n\n", text)
def strip_generated_regions(text: str) -> str:
"""Blank out the auto pre-flight block and every declared block, using
`check-shared-blocks.py`'s own marker regexes."""
text = _blank_out(PREFLIGHT_RE, text)
text = _blank_out(DECLARED_RE, text)
return text
def strip_frontmatter(text: str) -> str:
"""Blank out a leading YAML frontmatter block (`SKILL.md` files only —
sibling detail files and the declared-block sources carry none, so this
is a no-op for them)."""
return _blank_out(FRONTMATTER_RE, text) if FRONTMATTER_RE.match(text) else text
def strip_code_fences(text: str) -> str:
"""Blank out fenced code blocks — shared command sequences are
legitimately identical across skills and are not prose duplication."""
return _blank_out(CODE_FENCE_RE, text)
def _line_of(original: str, paragraph_text: str) -> int:
"""1-based line number of `paragraph_text` inside `original`. The
paragraph's own characters are never altered by the blanking passes
above — only the spans *around* it are — so its text (or at least its
first line) is always a literal substring of the untouched original
file, and `str.find` locates it directly."""
idx = original.find(paragraph_text)
if idx == -1:
idx = original.find(paragraph_text.split("\n", 1)[0])
if idx == -1:
return 1
return original.count("\n", 0, idx) + 1
def extract_paragraphs(path: Path, text: str | None = None) -> list[Paragraph]:
"""Every paragraph in `path` (or `text`, for tests) at or above the word
floor, with its 9-gram set and its line number in the original file."""
original = text if text is not None else path.read_text()
body = strip_generated_regions(original)
body = strip_frontmatter(body)
body = strip_code_fences(body)
paragraphs: list[Paragraph] = []
for raw in BLANK_LINE_RE.split(body):
para = raw.strip("\n")
if not para.strip():
continue
words = [w.lower() for w in WORD_RE.findall(para)]
if len(words) <= WORD_FLOOR:
continue
grams = frozenset(tuple(words[i : i + NGRAM_SIZE]) for i in range(len(words) - NGRAM_SIZE + 1))
paragraphs.append(Paragraph(path=path, line=_line_of(original, para), text=para, grams=grams))
return paragraphs
def discover_targets(
skills_root: Path = WIRED_SKILLS_ROOT,
blocks_dir: Path = BLOCKS_DIR,
preflight_source: Path = PREFLIGHT_SOURCE,
) -> list[Path]:
"""Every file in scope: `skills_root` recursively (symlink-aware — a
self-adopted `skills/<name>` is a symlink into `plugins/magpie-<family>/
skills/<name>`, which `Path.glob("**/...")` does not follow but
`os.walk(..., followlinks=True)` does — relevant when `skills_root` is
widened back to the full `skills/` tree; the wired default,
`WIRED_SKILLS_ROOT`, is a real directory, not a symlink), plus the
declared-block sources and the pre-flight source. Cache directories
(`__pycache__`, `.pytest_cache`, …) are skipped. Pass
`skills_root=Path("skills")` for the whole-tree scan described in the
module docstring's "Landing scope" section."""
targets: list[Path] = []
if skills_root.is_dir():
for root, dirs, files in os.walk(skills_root, followlinks=True):
dirs[:] = [d for d in dirs if d != "__pycache__" and not d.startswith(".")]
for name in files:
if name.endswith(".md"):
targets.append(Path(root) / name)
if blocks_dir.is_dir():
targets.extend(sorted(blocks_dir.glob("*.md")))
if preflight_source.is_file():
targets.append(preflight_source)
return sorted(set(targets))
def scan(paths: list[Path]) -> list[Paragraph]:
paragraphs: list[Paragraph] = []
for path in paths:
paragraphs.extend(extract_paragraphs(path))
return paragraphs
def find_pairs(paragraphs: list[Paragraph], min_score: float = REPORT_THRESHOLD) -> list[DuplicatePair]:
"""Cross-file near-duplicate pairs scored at or above `min_score`.
Same-file paragraph pairs are never compared — this gate polices
duplication *across* files; a skill quoting itself is not the failure
mode `check-shared-blocks.py` exists to prevent. The score
(`|A ∩ B| / min(|A|, |B|)`) is symmetric by construction, and comparing
every unordered pair exactly once (via `itertools.combinations`) makes
the result independent of the order `paragraphs` was built in."""
pairs: list[DuplicatePair] = []
for a, b in combinations(paragraphs, 2):
if a.path == b.path:
continue
overlap = len(a.grams & b.grams)
if not overlap:
continue
score = overlap / min(len(a.grams), len(b.grams))
if score >= min_score:
pairs.append(DuplicatePair(a=a, b=b, score=score))
pairs.sort(key=lambda pair: pair.score, reverse=True)
return pairs
def _snippet(text: str, limit: int = 160) -> str:
collapsed = " ".join(text.split())
if len(collapsed) <= limit:
return collapsed
return collapsed[:limit].rstrip() + "…"
def _format_pair(pair: DuplicatePair) -> str:
return f" {pair.a.path}:{pair.a.line} <-> {pair.b.path}:{pair.b.line} score {pair.score:.2f}"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.parse_args()
targets = discover_targets()
if not targets:
print(f"{WIRED_SKILLS_ROOT}: no target files found", file=sys.stderr)
return 1
paragraphs = scan(targets)
pairs = find_pairs(paragraphs)
fail_pairs = [pair for pair in pairs if pair.score > FAIL_THRESHOLD]
report_pairs = [pair for pair in pairs if pair.score <= FAIL_THRESHOLD]
if report_pairs:
print(
f"Near-duplicate prose in the report band "
f"(score {REPORT_THRESHOLD:.2f}-{FAIL_THRESHOLD:.2f}, not failing — worth reducing deliberately):"
)
for pair in report_pairs:
print(_format_pair(pair))
print()
if fail_pairs:
print(
f"Cross-file near-duplicate prose exceeds the fail threshold (score > {FAIL_THRESHOLD:.2f}):",
file=sys.stderr,
)
for pair in fail_pairs:
print(_format_pair(pair), file=sys.stderr)
print(f" {pair.a.path}:{pair.a.line}: {_snippet(pair.a.text)}", file=sys.stderr)
print(f" {pair.b.path}:{pair.b.line}: {_snippet(pair.b.text)}", file=sys.stderr)
print(
"\nMove the shared paragraph into tools/dev/blocks/<name>.md as a declared block "
"(see check-shared-blocks.py) and let it propagate to both files, or reword one side "
"so it is no longer a near-duplicate.",
file=sys.stderr,
)
return 1
max_score = pairs[0].score if pairs else 0.0
print(
f"check-duplication: {len(paragraphs)} paragraph(s) across {len(targets)} file(s) scanned; "
f"{len(report_pairs)} pair(s) in the report band, 0 above the fail threshold "
f"(highest score {max_score:.2f})."
)
return 0
if __name__ == "__main__":
sys.exit(main())