initial version
This commit is contained in:
398
tools/kb_linter.py
Normal file
398
tools/kb_linter.py
Normal file
@@ -0,0 +1,398 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OCI Deal Accelerator — KB Linter
|
||||
|
||||
Validates KB files against governance rules:
|
||||
- Contributor blocks on findings and matrix entries
|
||||
- Confidence decay against governance config
|
||||
- Tag validation against taxonomy
|
||||
- Domain owner review cadence
|
||||
- Freshness checks on all KB files
|
||||
|
||||
Usage:
|
||||
python kb_linter.py # Run all checks
|
||||
python kb_linter.py --show-decay # Show confidence decay status
|
||||
python kb_linter.py --check-tags # Validate tags only
|
||||
python kb_linter.py --check-owners # Check domain owner review cadence
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
# =============================================================================
|
||||
# Constants
|
||||
# =============================================================================
|
||||
|
||||
PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
|
||||
GOVERNANCE_PATH = os.path.join(PROJECT_ROOT, "config", "kb-governance.yaml")
|
||||
OWNERS_PATH = os.path.join(PROJECT_ROOT, "config", "kb-owners.yaml")
|
||||
TAGS_PATH = os.path.join(PROJECT_ROOT, "config", "kb-tags.yaml")
|
||||
TRACKER_PATH = os.path.join(PROJECT_ROOT, "kb", "field-findings", "tracker.yaml")
|
||||
MATRIX_PATH = os.path.join(PROJECT_ROOT, "kb", "compatibility", "adb-feature-matrix.yaml")
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_GREEN = "\033[32m"
|
||||
_YELLOW = "\033[33m"
|
||||
_RED = "\033[31m"
|
||||
|
||||
|
||||
def _supports_color() -> bool:
|
||||
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
||||
|
||||
|
||||
def _c(text: str, color: str) -> str:
|
||||
if not _supports_color():
|
||||
return text
|
||||
return f"{color}{text}{_RESET}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Loaders
|
||||
# =============================================================================
|
||||
|
||||
def _load_yaml(path: str) -> Optional[Dict[str, Any]]:
|
||||
resolved = os.path.realpath(path)
|
||||
if not os.path.isfile(resolved):
|
||||
return None
|
||||
with open(resolved, "r", encoding="utf-8") as fh:
|
||||
return yaml.safe_load(fh)
|
||||
|
||||
|
||||
def _load_governance() -> Dict[str, Any]:
|
||||
data = _load_yaml(GOVERNANCE_PATH)
|
||||
return data if data else {}
|
||||
|
||||
|
||||
def _load_taxonomy() -> List[str]:
|
||||
data = _load_yaml(TAGS_PATH)
|
||||
if not data or "taxonomy" not in data:
|
||||
return []
|
||||
all_tags = []
|
||||
for category_tags in data["taxonomy"].values():
|
||||
if isinstance(category_tags, list):
|
||||
all_tags.extend(str(t) for t in category_tags)
|
||||
return all_tags
|
||||
|
||||
|
||||
def _parse_date(d: Any) -> Optional[date]:
|
||||
if isinstance(d, date) and not isinstance(d, datetime):
|
||||
return d
|
||||
if isinstance(d, datetime):
|
||||
return d.date()
|
||||
if isinstance(d, str):
|
||||
try:
|
||||
return datetime.strptime(d, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _days_ago(d: Any) -> Optional[int]:
|
||||
parsed = _parse_date(d)
|
||||
if parsed is None:
|
||||
return None
|
||||
return (date.today() - parsed).days
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Check: Contributor blocks
|
||||
# =============================================================================
|
||||
|
||||
def check_contributor_blocks(tracker_path: str = TRACKER_PATH) -> List[str]:
|
||||
"""Verify all findings have proper contributor blocks."""
|
||||
issues = []
|
||||
data = _load_yaml(tracker_path)
|
||||
if not data or "findings" not in data:
|
||||
issues.append("Cannot load tracker file.")
|
||||
return issues
|
||||
|
||||
governance = _load_governance()
|
||||
required = governance.get("contribution", {}).get("required_fields", ["name", "team", "date", "confidence"])
|
||||
valid_confidence = governance.get("contribution", {}).get("confidence_levels",
|
||||
["validated", "observed", "reported", "inferred"])
|
||||
|
||||
for f in data["findings"]:
|
||||
fid = f.get("id", "???")
|
||||
contributor = f.get("contributor")
|
||||
if not isinstance(contributor, dict):
|
||||
if f.get("reported_by"):
|
||||
issues.append(f"{fid}: uses legacy 'reported_by' instead of contributor block")
|
||||
else:
|
||||
issues.append(f"{fid}: missing contributor block")
|
||||
continue
|
||||
for field in required:
|
||||
if not contributor.get(field):
|
||||
issues.append(f"{fid}: contributor missing required field '{field}'")
|
||||
conf = contributor.get("confidence", "")
|
||||
if conf and conf not in valid_confidence:
|
||||
issues.append(f"{fid}: invalid confidence level '{conf}'")
|
||||
return issues
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Check: Confidence decay
|
||||
# =============================================================================
|
||||
|
||||
def check_confidence_decay(tracker_path: str = TRACKER_PATH) -> List[Dict[str, Any]]:
|
||||
"""Check confidence decay for all findings. Returns list of decay reports."""
|
||||
governance = _load_governance()
|
||||
decay_config = governance.get("confidence_decay", {})
|
||||
data = _load_yaml(tracker_path)
|
||||
if not data or "findings" not in data:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for f in data["findings"]:
|
||||
fid = f.get("id", "???")
|
||||
contributor = f.get("contributor", {})
|
||||
confidence = contributor.get("confidence", "validated") if isinstance(contributor, dict) else "validated"
|
||||
finding_date = contributor.get("date", f.get("date")) if isinstance(contributor, dict) else f.get("date")
|
||||
age = _days_ago(finding_date)
|
||||
if age is None:
|
||||
results.append({"id": fid, "status": "UNKNOWN", "confidence": confidence, "age_days": None})
|
||||
continue
|
||||
|
||||
thresholds = decay_config.get(confidence, {"fresh": 180, "stale": 365, "expired": 730})
|
||||
if age <= thresholds.get("fresh", 180):
|
||||
decay_status = "FRESH"
|
||||
elif age <= thresholds.get("stale", 365):
|
||||
decay_status = "STALE"
|
||||
else:
|
||||
decay_status = "EXPIRED"
|
||||
|
||||
results.append({
|
||||
"id": fid,
|
||||
"status": decay_status,
|
||||
"confidence": confidence,
|
||||
"age_days": age,
|
||||
"summary": f.get("summary", ""),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Check: Tag validation
|
||||
# =============================================================================
|
||||
|
||||
def check_tags(tracker_path: str = TRACKER_PATH) -> List[str]:
|
||||
"""Validate all tags against the taxonomy."""
|
||||
taxonomy = _load_taxonomy()
|
||||
if not taxonomy:
|
||||
return ["No taxonomy loaded — skipping tag validation."]
|
||||
data = _load_yaml(tracker_path)
|
||||
if not data or "findings" not in data:
|
||||
return ["Cannot load tracker file."]
|
||||
|
||||
issues = []
|
||||
for f in data["findings"]:
|
||||
fid = f.get("id", "???")
|
||||
for tag in f.get("tags", []):
|
||||
if str(tag) not in taxonomy:
|
||||
issues.append(f"{fid}: unknown tag '{tag}'")
|
||||
return issues
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Check: Domain owners
|
||||
# =============================================================================
|
||||
|
||||
def check_owners() -> List[str]:
|
||||
"""Check if domain owners need to review their areas."""
|
||||
owners_data = _load_yaml(OWNERS_PATH)
|
||||
if not owners_data or "domains" not in owners_data:
|
||||
return ["No owners config found."]
|
||||
|
||||
issues = []
|
||||
for domain in owners_data["domains"]:
|
||||
area = domain.get("area", "")
|
||||
owner = domain.get("owner", {})
|
||||
if owner.get("name") == "TBD":
|
||||
issues.append(f"{area}: no owner assigned (TBD)")
|
||||
return issues
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Check: File freshness
|
||||
# =============================================================================
|
||||
|
||||
def check_freshness() -> List[Dict[str, Any]]:
|
||||
"""Check freshness of all YAML files in kb/."""
|
||||
governance = _load_governance()
|
||||
warning_days = governance.get("freshness", {}).get("warning_days", 180)
|
||||
stale_days = governance.get("freshness", {}).get("stale_days", 365)
|
||||
|
||||
kb_root = os.path.join(PROJECT_ROOT, "kb")
|
||||
results = []
|
||||
|
||||
for dirpath, _dirnames, filenames in os.walk(kb_root):
|
||||
for filename in filenames:
|
||||
if not filename.endswith((".yaml", ".yml")):
|
||||
continue
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
rel_path = os.path.relpath(filepath, PROJECT_ROOT)
|
||||
data = _load_yaml(filepath)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
last_verified = data.get("last_verified") or data.get("last_updated")
|
||||
if last_verified:
|
||||
age = _days_ago(last_verified)
|
||||
if age is not None:
|
||||
if age > stale_days:
|
||||
status = "STALE"
|
||||
elif age > warning_days:
|
||||
status = "WARNING"
|
||||
else:
|
||||
status = "FRESH"
|
||||
results.append({"file": rel_path, "status": status, "age_days": age})
|
||||
continue
|
||||
|
||||
# Use file modification time as fallback
|
||||
mtime = os.path.getmtime(filepath)
|
||||
mdate = datetime.fromtimestamp(mtime).date()
|
||||
age = (date.today() - mdate).days
|
||||
if age > stale_days:
|
||||
status = "STALE"
|
||||
elif age > warning_days:
|
||||
status = "WARNING"
|
||||
else:
|
||||
status = "FRESH"
|
||||
results.append({"file": rel_path, "status": status, "age_days": age})
|
||||
return results
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Check: Contribution stats
|
||||
# =============================================================================
|
||||
|
||||
def contribution_stats(tracker_path: str = TRACKER_PATH) -> Dict[str, int]:
|
||||
"""Count contributions per person across findings."""
|
||||
data = _load_yaml(tracker_path)
|
||||
if not data or "findings" not in data:
|
||||
return {}
|
||||
counts: Dict[str, int] = {}
|
||||
for f in data["findings"]:
|
||||
contributor = f.get("contributor", {})
|
||||
if isinstance(contributor, dict):
|
||||
name = contributor.get("name", "Unknown")
|
||||
else:
|
||||
name = str(f.get("reported_by", "Unknown"))
|
||||
counts[name] = counts.get(name, 0) + 1
|
||||
for c in f.get("confirmations", []):
|
||||
cname = c.get("name", "Unknown")
|
||||
counts[cname] = counts.get(cname, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
def cmd_lint(args: argparse.Namespace) -> None:
|
||||
"""Run all lint checks."""
|
||||
total_issues = 0
|
||||
|
||||
# Contributor blocks
|
||||
print("Checking contributor blocks...")
|
||||
contributor_issues = check_contributor_blocks()
|
||||
if contributor_issues:
|
||||
for issue in contributor_issues:
|
||||
print(f" {_c('ISSUE', _YELLOW)}: {issue}")
|
||||
total_issues += len(contributor_issues)
|
||||
else:
|
||||
print(f" {_c('OK', _GREEN)}: All findings have valid contributor blocks.")
|
||||
|
||||
# Tags
|
||||
if args.check_tags or not (args.show_decay or args.check_owners):
|
||||
print("\nChecking tags against taxonomy...")
|
||||
tag_issues = check_tags()
|
||||
if tag_issues:
|
||||
for issue in tag_issues:
|
||||
print(f" {_c('ISSUE', _YELLOW)}: {issue}")
|
||||
total_issues += len(tag_issues)
|
||||
else:
|
||||
print(f" {_c('OK', _GREEN)}: All tags are valid.")
|
||||
|
||||
# Domain owners
|
||||
if args.check_owners or not (args.show_decay or args.check_tags):
|
||||
print("\nChecking domain owners...")
|
||||
owner_issues = check_owners()
|
||||
if owner_issues:
|
||||
for issue in owner_issues:
|
||||
print(f" {_c('ISSUE', _YELLOW)}: {issue}")
|
||||
total_issues += len(owner_issues)
|
||||
else:
|
||||
print(f" {_c('OK', _GREEN)}: All domains have assigned owners.")
|
||||
|
||||
# Freshness
|
||||
if not (args.show_decay or args.check_tags or args.check_owners):
|
||||
print("\nChecking file freshness...")
|
||||
freshness = check_freshness()
|
||||
stale = [f for f in freshness if f["status"] in ("STALE", "WARNING")]
|
||||
if stale:
|
||||
for f in stale:
|
||||
status_color = _RED if f["status"] == "STALE" else _YELLOW
|
||||
print(f" {_c(f['status'], status_color)}: {f['file']} ({f['age_days']} days old)")
|
||||
total_issues += len(stale)
|
||||
else:
|
||||
print(f" {_c('OK', _GREEN)}: All files are fresh.")
|
||||
|
||||
# Confidence decay
|
||||
if args.show_decay or not (args.check_tags or args.check_owners):
|
||||
print("\nConfidence decay status...")
|
||||
decay = check_confidence_decay()
|
||||
for d in decay:
|
||||
if d["status"] == "FRESH":
|
||||
status_display = _c("FRESH", _GREEN)
|
||||
elif d["status"] == "STALE":
|
||||
status_display = _c("STALE", _YELLOW)
|
||||
total_issues += 1
|
||||
elif d["status"] == "EXPIRED":
|
||||
status_display = _c("EXPIRED", _RED)
|
||||
total_issues += 1
|
||||
else:
|
||||
status_display = d["status"]
|
||||
age_str = f"{d['age_days']} days ago" if d["age_days"] is not None else "unknown age"
|
||||
summary = d.get("summary", "")[:50]
|
||||
print(f" {d['id']} {status_display} {summary} ({d['confidence']}, {age_str})")
|
||||
|
||||
# Contribution stats
|
||||
if not (args.show_decay or args.check_tags or args.check_owners):
|
||||
print("\nContribution stats:")
|
||||
cstats = contribution_stats()
|
||||
for name, count in sorted(cstats.items(), key=lambda x: -x[1]):
|
||||
print(f" {name:<30} {count} contributions")
|
||||
|
||||
print(f"\n{'=' * 40}")
|
||||
if total_issues:
|
||||
print(f"{_c(f'{total_issues} issue(s) found.', _YELLOW)}")
|
||||
else:
|
||||
print(f"{_c('All checks passed.', _GREEN)}")
|
||||
|
||||
sys.exit(1 if total_issues > 0 else 0)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="kb_linter",
|
||||
description="Validate KB files against governance rules.",
|
||||
)
|
||||
parser.add_argument("--show-decay", action="store_true", help="Show confidence decay status.")
|
||||
parser.add_argument("--check-tags", action="store_true", help="Validate tags only.")
|
||||
parser.add_argument("--check-owners", action="store_true", help="Check domain owner assignments.")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
cmd_lint(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user