447 lines
15 KiB
Python
447 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate the repository's bounded quality-debt allowlist.
|
|
|
|
The check covers four intentionally narrow inventories:
|
|
|
|
* ``binding_gap``: non-comment entries in ``check_bindings_gaps.txt``;
|
|
* ``todo``: explicit TODO/FIXME/HACK markers in repository source;
|
|
* ``soft_skip``: explicit ``soft-skip``/``soft skip`` markers;
|
|
* ``fallback``: explicit ``FALLBACK`` markers or fallback wording in comments.
|
|
|
|
Every inventory entry must match exactly one metadata scope with an owner,
|
|
issue, reason, expiry, and an explicit ``max_net_growth`` budget. The baseline
|
|
stores the count for each metadata entry. A normal check rejects unmatched
|
|
findings and any count increase above its budget; deleting old debt is allowed.
|
|
This keeps the gate bounded without requiring a one-shot cleanup of legacy
|
|
debt.
|
|
|
|
Usage::
|
|
|
|
python3 scripts/check_quality_metadata.py
|
|
python3 scripts/check_quality_metadata.py --json
|
|
python3 scripts/check_quality_metadata.py --write-baseline
|
|
|
|
``--write-baseline`` is an explicit maintenance operation. It is not used by
|
|
CI, because changing a baseline must remain visible in code review.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import fnmatch
|
|
import json
|
|
import re
|
|
import sys
|
|
from collections import Counter
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
DEFAULT_METADATA = ROOT / "scripts" / "quality_debt_allowlist.json"
|
|
DEFAULT_BASELINE = ROOT / "scripts" / "quality_debt_baseline.json"
|
|
DEFAULT_BINDING_GAPS = ROOT / "scripts" / "check_bindings_gaps.txt"
|
|
|
|
SCHEMA_VERSION = 1
|
|
KINDS = frozenset({"binding_gap", "todo", "soft_skip", "fallback"})
|
|
SOURCE_EXTENSIONS = frozenset(
|
|
{
|
|
".c",
|
|
".cc",
|
|
".cpp",
|
|
".cxx",
|
|
".h",
|
|
".hh",
|
|
".hpp",
|
|
".hxx",
|
|
".cmake",
|
|
".frag",
|
|
".glsl",
|
|
".nut",
|
|
".ps1",
|
|
".py",
|
|
".sh",
|
|
".toml",
|
|
".vert",
|
|
".yml",
|
|
".yaml",
|
|
}
|
|
)
|
|
EXCLUDED_DIRS = frozenset(
|
|
{
|
|
".git",
|
|
".codex",
|
|
".agents",
|
|
".opencode",
|
|
"build",
|
|
"docs",
|
|
"external",
|
|
"third-party",
|
|
"fixtures_quality_metadata",
|
|
}
|
|
)
|
|
EXCLUDED_FILES = frozenset(
|
|
{
|
|
"scripts/check_quality_metadata.py",
|
|
"scripts/profile_matrix.py",
|
|
"scripts/tests/test_quality_metadata.py",
|
|
}
|
|
)
|
|
CONTROLLED_ROOTS = (
|
|
"CMakeLists.txt",
|
|
"Makefile",
|
|
"cmake",
|
|
"scripts",
|
|
"src",
|
|
"test",
|
|
".github",
|
|
)
|
|
|
|
TODO_RE = re.compile(r"\b(?:TODO|FIXME|HACK)\b")
|
|
SOFT_SKIP_RE = re.compile(r"\bsoft[- ]skip\b", re.IGNORECASE)
|
|
FALLBACK_RE = re.compile(r"\bfallback\b", re.IGNORECASE)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Finding:
|
|
"""One inventory finding, identified by kind, path, and source line."""
|
|
|
|
kind: str
|
|
path: str
|
|
line: int
|
|
text: str
|
|
|
|
|
|
def _relative(root: Path, path: Path) -> str:
|
|
return path.relative_to(root).as_posix()
|
|
|
|
|
|
def _source_file(path: Path, relative: str) -> bool:
|
|
if path.name in {"Makefile", "CMakeLists.txt"}:
|
|
return True
|
|
return path.suffix.lower() in SOURCE_EXTENSIONS and relative not in EXCLUDED_FILES
|
|
|
|
|
|
def iter_source_files(root: Path) -> Iterable[tuple[Path, str]]:
|
|
"""Yield source/config files in the controlled scan area."""
|
|
|
|
candidates: list[Path] = []
|
|
for name in CONTROLLED_ROOTS:
|
|
candidate = root / name
|
|
if candidate.is_file():
|
|
candidates.append(candidate)
|
|
elif candidate.is_dir():
|
|
candidates.extend(candidate.rglob("*"))
|
|
for path in sorted(candidates):
|
|
if not path.is_file():
|
|
continue
|
|
relative = _relative(root, path)
|
|
if any(part in EXCLUDED_DIRS for part in Path(relative).parts):
|
|
continue
|
|
if _source_file(path, relative):
|
|
yield path, relative
|
|
|
|
|
|
def scan_source_findings(root: Path) -> list[Finding]:
|
|
findings: list[Finding] = []
|
|
for path, relative in iter_source_files(root):
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
if TODO_RE.search(line):
|
|
findings.append(Finding("todo", relative, line_number, line.strip()))
|
|
if SOFT_SKIP_RE.search(line):
|
|
findings.append(Finding("soft_skip", relative, line_number, line.strip()))
|
|
|
|
# A lowercase ``fallback`` in an ordinary identifier or parameter
|
|
# is not a debt finding. Uppercase FALLBACK is an explicit marker;
|
|
# otherwise require the term to occur in a comment line.
|
|
stripped = line.lstrip()
|
|
in_comment = stripped.startswith(("//", "#", "/*", "*"))
|
|
if "FALLBACK" in line or (in_comment and FALLBACK_RE.search(line)):
|
|
findings.append(Finding("fallback", relative, line_number, line.strip()))
|
|
return findings
|
|
|
|
|
|
def scan_binding_gaps(root: Path, binding_path: Path) -> list[Finding]:
|
|
if not binding_path.is_absolute():
|
|
binding_path = root / binding_path
|
|
if not binding_path.is_file():
|
|
return []
|
|
relative = _relative(root, binding_path)
|
|
findings: list[Finding] = []
|
|
for line_number, line in enumerate(
|
|
binding_path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1
|
|
):
|
|
stripped = line.strip()
|
|
if stripped and not stripped.startswith("#"):
|
|
findings.append(Finding("binding_gap", relative, line_number, stripped))
|
|
return findings
|
|
|
|
|
|
def discover_findings(root: Path, binding_path: Path) -> list[Finding]:
|
|
return scan_source_findings(root) + scan_binding_gaps(root, binding_path)
|
|
|
|
|
|
def _nonempty_string(value: Any) -> bool:
|
|
return isinstance(value, str) and bool(value.strip())
|
|
|
|
|
|
def validate_metadata(metadata: Any, today: date | None = None) -> list[str]:
|
|
today = today or date.today()
|
|
errors: list[str] = []
|
|
if not isinstance(metadata, Mapping):
|
|
return ["metadata root must be an object"]
|
|
if metadata.get("schema_version") != SCHEMA_VERSION:
|
|
errors.append(f"metadata schema_version must be {SCHEMA_VERSION}")
|
|
entries = metadata.get("entries")
|
|
if not isinstance(entries, list):
|
|
return errors + ["metadata entries must be an array"]
|
|
|
|
seen: set[str] = set()
|
|
required = {
|
|
"id",
|
|
"kind",
|
|
"scope",
|
|
"owner",
|
|
"issue",
|
|
"reason",
|
|
"expiry",
|
|
"max_net_growth",
|
|
}
|
|
for index, entry in enumerate(entries):
|
|
prefix = f"metadata entries[{index}]"
|
|
if not isinstance(entry, Mapping):
|
|
errors.append(f"{prefix} must be an object")
|
|
continue
|
|
missing = sorted(required - set(entry))
|
|
if missing:
|
|
errors.append(f"{prefix} missing: {', '.join(missing)}")
|
|
continue
|
|
for field in ("id", "scope", "owner", "issue", "reason"):
|
|
if not _nonempty_string(entry.get(field)):
|
|
errors.append(f"{prefix}.{field} must be a non-empty string")
|
|
entry_id = entry.get("id")
|
|
if isinstance(entry_id, str) and entry_id in seen:
|
|
errors.append(f"{prefix}.id duplicates '{entry_id}'")
|
|
if isinstance(entry_id, str):
|
|
seen.add(entry_id)
|
|
if entry.get("kind") not in KINDS:
|
|
errors.append(f"{prefix}.kind must be one of {', '.join(sorted(KINDS))}")
|
|
growth = entry.get("max_net_growth")
|
|
if isinstance(growth, bool) or not isinstance(growth, int) or growth < 0:
|
|
errors.append(f"{prefix}.max_net_growth must be a non-negative integer")
|
|
expiry = entry.get("expiry")
|
|
if not isinstance(expiry, str):
|
|
errors.append(f"{prefix}.expiry must be ISO date YYYY-MM-DD")
|
|
else:
|
|
try:
|
|
expiry_date = date.fromisoformat(expiry)
|
|
except ValueError:
|
|
errors.append(f"{prefix}.expiry is not an ISO date: {expiry}")
|
|
else:
|
|
if expiry_date < today:
|
|
errors.append(f"{prefix}.expiry has passed: {expiry}")
|
|
return errors
|
|
|
|
|
|
def validate_baseline(baseline: Any) -> list[str]:
|
|
if not isinstance(baseline, Mapping):
|
|
return ["baseline root must be an object"]
|
|
errors: list[str] = []
|
|
if baseline.get("schema_version") != SCHEMA_VERSION:
|
|
errors.append(f"baseline schema_version must be {SCHEMA_VERSION}")
|
|
counts = baseline.get("counts")
|
|
if not isinstance(counts, Mapping):
|
|
errors.append("baseline counts must be an object")
|
|
else:
|
|
for key, value in counts.items():
|
|
if not isinstance(key, str) or isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
errors.append(f"baseline count {key!r} must be a non-negative integer")
|
|
return errors
|
|
|
|
|
|
def _load_json(path: Path) -> Any:
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except FileNotFoundError:
|
|
raise ValueError(f"file not found: {path}")
|
|
except json.JSONDecodeError as error:
|
|
raise ValueError(f"invalid JSON in {path}: {error}")
|
|
|
|
|
|
def _entries(metadata: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
|
return [entry for entry in metadata.get("entries", []) if isinstance(entry, Mapping)]
|
|
|
|
|
|
def _matching_entries(finding: Finding, entries: list[Mapping[str, Any]]) -> list[Mapping[str, Any]]:
|
|
return [
|
|
entry
|
|
for entry in entries
|
|
if entry.get("kind") == finding.kind
|
|
and isinstance(entry.get("scope"), str)
|
|
and fnmatch.fnmatchcase(finding.path, entry["scope"])
|
|
]
|
|
|
|
|
|
def evaluate(
|
|
metadata: Mapping[str, Any],
|
|
baseline: Mapping[str, Any],
|
|
findings: list[Finding],
|
|
today: date | None = None,
|
|
) -> tuple[list[str], dict[str, int]]:
|
|
"""Return gate errors and the current count per metadata entry id."""
|
|
|
|
errors = validate_metadata(metadata, today=today)
|
|
errors.extend(validate_baseline(baseline))
|
|
entries = _entries(metadata)
|
|
counts: Counter[str] = Counter()
|
|
entry_ids = {entry.get("id") for entry in entries}
|
|
baseline_counts = baseline.get("counts", {})
|
|
if not isinstance(baseline_counts, Mapping):
|
|
baseline_counts = {}
|
|
|
|
for finding in findings:
|
|
matches = _matching_entries(finding, entries)
|
|
if not matches:
|
|
errors.append(
|
|
f"unallowlisted {finding.kind} at {finding.path}:{finding.line}: {finding.text}"
|
|
)
|
|
continue
|
|
if len(matches) > 1:
|
|
ids = ", ".join(str(entry.get("id")) for entry in matches)
|
|
errors.append(
|
|
f"ambiguous metadata for {finding.kind} at {finding.path}:{finding.line}: {ids}"
|
|
)
|
|
continue
|
|
entry_id = matches[0].get("id")
|
|
if isinstance(entry_id, str):
|
|
counts[entry_id] += 1
|
|
|
|
if isinstance(baseline_counts, Mapping):
|
|
unknown_baseline = sorted(set(baseline_counts) - entry_ids)
|
|
for entry_id in unknown_baseline:
|
|
errors.append(f"baseline has no metadata entry for '{entry_id}'")
|
|
|
|
for entry in entries:
|
|
entry_id = entry.get("id")
|
|
if not isinstance(entry_id, str):
|
|
continue
|
|
if entry_id not in baseline_counts:
|
|
errors.append(f"baseline has no count for metadata entry '{entry_id}'")
|
|
continue
|
|
current = counts.get(entry_id, 0)
|
|
previous = baseline_counts[entry_id]
|
|
if not isinstance(previous, int) or isinstance(previous, bool):
|
|
continue
|
|
delta = current - previous
|
|
budget = entry.get("max_net_growth", 0)
|
|
if isinstance(budget, int) and not isinstance(budget, bool) and delta > budget:
|
|
errors.append(
|
|
f"{entry_id}: net growth {delta} exceeds budget {budget} "
|
|
f"(baseline={previous}, current={current})"
|
|
)
|
|
return errors, dict(counts)
|
|
|
|
|
|
def write_baseline(path: Path, counts: Mapping[str, int]) -> None:
|
|
payload = {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"generated_at": date.today().isoformat(),
|
|
"counts": dict(sorted(counts.items())),
|
|
}
|
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def _report(
|
|
errors: list[str], findings: list[Finding], counts: Mapping[str, int], as_json: bool
|
|
) -> int:
|
|
if as_json:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"findings": [asdict(finding) for finding in findings],
|
|
"counts": dict(sorted(counts.items())),
|
|
"errors": errors,
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
)
|
|
else:
|
|
for error in errors:
|
|
print(f"FAIL {error}", file=sys.stderr)
|
|
print(
|
|
"quality metadata: "
|
|
+ ", ".join(f"{kind}={counts.get(kind, 0)}" for kind in sorted(counts))
|
|
+ f"; findings={len(findings)}"
|
|
)
|
|
return 1 if errors else 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--root", type=Path, default=ROOT)
|
|
parser.add_argument("--metadata", type=Path, default=DEFAULT_METADATA)
|
|
parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE)
|
|
parser.add_argument("--binding-gaps", type=Path, default=DEFAULT_BINDING_GAPS)
|
|
parser.add_argument("--json", action="store_true", help="emit machine-readable output")
|
|
parser.add_argument(
|
|
"--write-baseline",
|
|
action="store_true",
|
|
help="write current matched counts to --baseline (explicit maintenance operation)",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
try:
|
|
metadata = _load_json(args.metadata)
|
|
except ValueError as error:
|
|
print(f"FAIL {error}", file=sys.stderr)
|
|
return 1
|
|
if not isinstance(metadata, Mapping):
|
|
print("FAIL metadata root must be an object", file=sys.stderr)
|
|
return 1
|
|
|
|
binding_path = args.binding_gaps
|
|
if not binding_path.is_absolute():
|
|
binding_path = args.root / binding_path
|
|
findings = discover_findings(args.root, binding_path)
|
|
metadata_errors: list[str] = []
|
|
if not binding_path.is_file():
|
|
metadata_errors.append(f"binding gap file not found: {binding_path}")
|
|
|
|
if args.write_baseline:
|
|
metadata_errors.extend(validate_metadata(metadata))
|
|
entries = _entries(metadata)
|
|
counts: Counter[str] = Counter()
|
|
for finding in findings:
|
|
matches = _matching_entries(finding, entries)
|
|
if len(matches) != 1 or not isinstance(matches[0].get("id"), str):
|
|
metadata_errors.append(
|
|
f"cannot baseline unallowlisted/ambiguous finding: "
|
|
f"{finding.kind} at {finding.path}:{finding.line}"
|
|
)
|
|
else:
|
|
counts[matches[0]["id"]] += 1
|
|
if metadata_errors:
|
|
return _report(metadata_errors, findings, counts, args.json)
|
|
write_baseline(args.baseline, counts)
|
|
print(f"wrote quality debt baseline: {args.baseline}")
|
|
return 0
|
|
|
|
try:
|
|
baseline = _load_json(args.baseline)
|
|
except ValueError as error:
|
|
print(f"FAIL {error}", file=sys.stderr)
|
|
return 1
|
|
if not isinstance(baseline, Mapping):
|
|
print("FAIL baseline root must be an object", file=sys.stderr)
|
|
return 1
|
|
errors, counts = evaluate(metadata, baseline, findings)
|
|
errors = metadata_errors + errors
|
|
return _report(errors, findings, counts, args.json)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|