Files
FabledGrimoire/scripts/measure_duplication.py
bvandeusenandClaude Opus 5.5 067aa58fbc
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 34s
docs(6210): acceptance sweep — src, scripts and frontend leftovers described in the present tense
The acceptance greps found what the per-area passes missed:

- src/scribe: "Phase 7/8" removal notes in projects, settings and
  user_profile; a paragraph promising an auto-summary side effect that no
  longer exists; user_profile's claim that "upcoming MCP profile tools" read
  the profile, which nothing does; an instance count and instance rule
  titles in plugin_context; "MILESTONE 323 DECIDED" in rulebooks.
- scripts/: check_plugin's header and inline comments were a history of
  the incidents behind each guard, with issue numbers in two failure
  messages; check_dangling_styles named this app's classes as its examples;
  check_design_tokens, measure_duplication and mint_plugin_version cited
  issues and checklists.
- frontend/src: forty-odd comments with four-digit issue numbers that the
  earlier frontend scan filtered out as hex colours, plus "used to" and
  "Phase N" notes.

Kept: illustrative ids (`#4181` as a bare-id example, `PR #198`), migration
Create Date headers and one migration's dated data floor, and the plugin
skills' worked examples of how to cite a record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:54:38 -04:00

272 lines
11 KiB
Python

#!/usr/bin/env python3
"""Measure near-verbatim duplication in a tree, and what a change added to it.
WHY THIS EXISTS
A DRY audit made of several passes ends with a question no single pass can
answer: did the audit as a whole reduce duplication, and did it create any? The
second half is the one that matters. A pass that shortens two statements can
leave them byte-identical to a third, and the new copy is invisible to that
pass's own scan because it did not exist when the scan ran. This script is
the close-out measure the DRY Pass process names, so the measure is a tool and
not something each audit improvises.
WHAT IT MEASURES
Each file is reduced to its significant lines: blank lines, comment-only lines
and lines of bare punctuation (`}`, `});`, `],`) are dropped; whitespace is
collapsed; string literals are folded to "S", so two copies that differ only in
a message or a key still match. A *window* is N consecutive significant lines
(default 6). A window whose text occurs at two or more places in the same group
is duplicated, and every line it covers is a duplicated line.
Per group it reports significant lines, duplicated windows (distinct texts),
duplicated lines and their share. With `--before REV` it measures that revision
too and lists the duplicated windows that exist ONLY after — not duplicated
before, either because the text was absent or because it occurred once. Those
are grouped by the set of files they appear in, which is the list to read.
WHAT IT DOES NOT SEE
Structural or semantic copies — two components with the same shape and
different names, the same predicate written two ways. It catches near-verbatim
text only, so a fold of structural copies shows here less than it counts. Read
the numbers as a floor and the after-only list as the finding.
USAGE
measure_duplication.py [--repo DIR] [--before REV] [--after REV]
[--group NAME=GLOB[,GLOB...]]... [--exclude GLOB]...
[--window N] [--show N] [--json]
The after tree is the working tree's tracked files unless `--after REV` names a
revision. Revisions are read through `git archive`, so nothing is checked out.
Globs are fnmatch patterns over repo-relative paths, where `*` crosses `/`.
Groups are tried in order and the first match claims a file, so list
`--group 'go tests=*_test.go'` before `--group 'go=*.go'`. With no `--group`,
files are grouped by extension among common source types.
Stdlib only, so it runs from a scratch copy in any project:
`get_snippet` the recorded snippet, write its code to a file, run it there.
"""
from __future__ import annotations
import argparse
import fnmatch
import hashlib
import io
import json
import re
import subprocess
import sys
import tarfile
from collections import defaultdict
from pathlib import Path
DEFAULT_EXTS = {
"c", "cc", "cpp", "cs", "css", "dart", "go", "h", "hpp", "java", "js",
"jsx", "kt", "kts", "php", "py", "rb", "rs", "scss", "sh", "sql",
"svelte", "swift", "ts", "tsx", "vue",
}
_COMMENT = re.compile(r"^(//|/\*|\*|--|<!--|#(\s|!|$))")
_PUNCT_ONLY = re.compile(r"^[\s{}()\[\];,]*$")
_STRING = re.compile(r'"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|`(?:\\.|[^`\\])*`')
_SPACE = re.compile(r"\s+")
def significant_lines(text: str) -> list[tuple[int, str]]:
"""(1-based line number, normalized text) for each line that carries code."""
out = []
for number, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line or _COMMENT.match(line) or _PUNCT_ONLY.match(line):
continue
out.append((number, _SPACE.sub(" ", _STRING.sub('"S"', line))))
return out
def _git(repo: Path, *args: str) -> bytes:
return subprocess.run(
["git", "-C", str(repo), *args], check=True, capture_output=True,
).stdout
def read_working_tree(repo: Path):
"""Tracked files as they are on disk — what the next commit would hold."""
for rel in _git(repo, "ls-files", "-z").decode().split("\0"):
path = repo / rel
if rel and path.is_file():
yield rel, path.read_bytes()
def read_revision(repo: Path, rev: str):
"""Every file at `rev`, streamed out of `git archive` without a checkout."""
stream = io.BytesIO(_git(repo, "archive", "--format=tar", rev))
with tarfile.open(fileobj=stream, mode="r:") as archive:
for member in archive:
if member.isfile():
yield member.name, archive.extractfile(member).read()
def group_of(rel: str, groups: list[tuple[str, list[str]]]) -> str | None:
if not groups:
ext = rel.rsplit(".", 1)[-1] if "." in Path(rel).name else ""
return ext if ext in DEFAULT_EXTS else None
for name, patterns in groups:
if any(fnmatch.fnmatch(rel, p) for p in patterns):
return name
return None
def measure(files, groups, excludes, window: int) -> dict:
"""Per group: line counts, and every duplicated window with where it occurs."""
by_group: dict[str, dict] = defaultdict(
lambda: {"files": {}, "index": defaultdict(list)},
)
for rel, data in files:
if any(fnmatch.fnmatch(rel, p) for p in excludes):
continue
name = group_of(rel, groups)
if name is None:
continue
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
continue
lines = significant_lines(text)
g = by_group[name]
g["files"][rel] = lines
for i in range(len(lines) - window + 1):
body = "\n".join(t for _, t in lines[i:i + window])
key = hashlib.sha1(body.encode()).hexdigest()
g["index"][key].append((rel, i))
result = {}
for name, g in sorted(by_group.items()):
dup = {k: occ for k, occ in g["index"].items() if len(occ) > 1}
covered = set()
for occ in dup.values():
for rel, i in occ:
covered.update((rel, j) for j in range(i, i + window))
total = sum(len(lines) for lines in g["files"].values())
result[name] = {
"lines": total,
"dup_windows": len(dup),
"dup_lines": len(covered),
"share": len(covered) / total if total else 0.0,
"_dup": dup,
"_files": g["files"],
}
return result
def only_after(before: dict, after: dict) -> dict[str, list[dict]]:
"""Duplicated windows in `after` that were not duplicated in `before`,
grouped by the set of files they occur in."""
out = {}
for name, a in after.items():
was = before.get(name, {}).get("_dup", {})
sets: dict[tuple, dict] = {}
for key, occ in a["_dup"].items():
if key in was:
continue
fileset = tuple(sorted({rel for rel, _ in occ}))
entry = sets.setdefault(fileset, {"files": list(fileset), "windows": 0, "at": []})
entry["windows"] += 1
if not entry["at"]:
entry["at"] = [
f"{rel}:{a['_files'][rel][i][0]}" for rel, i in sorted(occ)
]
if sets:
out[name] = sorted(sets.values(), key=lambda e: -e["windows"])
return out
def _public(result: dict) -> dict:
return {
name: {k: v for k, v in g.items() if not k.startswith("_")}
for name, g in result.items()
}
def _pct(share: float) -> str:
return f"{share * 100:.1f}%"
def report(before: dict | None, after: dict, fresh: dict, show: int) -> str:
rows = []
for name in sorted(set(after) | set(before or {})):
a = after.get(name, {"lines": 0, "dup_windows": 0, "dup_lines": 0, "share": 0.0})
if before is None:
rows.append((name, f"{a['lines']:,}", str(a["dup_windows"]),
str(a["dup_lines"]), _pct(a["share"])))
continue
b = before.get(name, {"lines": 0, "dup_windows": 0, "dup_lines": 0, "share": 0.0})
rows.append((
name,
f"{b['lines']:,} → {a['lines']:,}",
f"{b['dup_windows']} → {a['dup_windows']}",
f"{b['dup_lines']} → {a['dup_lines']}",
f"{_pct(b['share'])} → {_pct(a['share'])}",
))
header = ("group", "lines", "dup windows", "dup lines", "share")
widths = [max(len(r[c]) for r in [header, *rows]) for c in range(len(header))]
lines = [" ".join(cell.ljust(w) for cell, w in zip(r, widths)).rstrip()
for r in [header, *rows]]
if before is not None:
total = sum(len(v) for v in fresh.values())
lines += ["", f"Duplicated only after: {total} file set(s). Read each one —"
" a copy a pass's own shortening exposed, or a deliberate repeat."]
for name, sets in fresh.items():
for entry in sets[:show]:
lines.append(f" [{name}] {entry['windows']} window(s): "
+ ", ".join(entry["at"]))
if len(sets) > show:
lines.append(f" [{name}] … {len(sets) - show} more (--show)")
return "\n".join(lines)
def _parse_group(spec: str) -> tuple[str, list[str]]:
name, sep, globs = spec.partition("=")
if not sep or not name or not globs:
raise argparse.ArgumentTypeError(f"--group wants NAME=GLOB[,GLOB...], got {spec!r}")
return name, [g for g in globs.split(",") if g]
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--repo", type=Path, default=Path("."))
parser.add_argument("--before", help="revision to compare against (e.g. the sha before the first pass)")
parser.add_argument("--after", help="revision to measure (default: the working tree's tracked files)")
parser.add_argument("--group", action="append", type=_parse_group, default=[])
parser.add_argument("--exclude", action="append", default=[])
parser.add_argument("--window", type=int, default=6)
parser.add_argument("--show", type=int, default=20)
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
repo = args.repo.resolve()
after_files = read_revision(repo, args.after) if args.after else read_working_tree(repo)
after = measure(after_files, args.group, args.exclude, args.window)
before = fresh = None
if args.before:
before = measure(read_revision(repo, args.before), args.group, args.exclude, args.window)
fresh = only_after(before, after)
if args.json:
json.dump({
"window": args.window,
"before": _public(before) if before is not None else None,
"after": _public(after),
"only_after": fresh,
}, sys.stdout, indent=2)
print()
else:
print(report(before, after, fresh or {}, args.show))
return 0
if __name__ == "__main__":
sys.exit(main())