mirror of
https://github.com/AgriciDaniel/claude-blog.git
synced 2026-09-19 03:32:21 +08:00
1c9cdc6927
Systemic (skills/blog/SKILL.md + 12 references): - S1: reframe Dec 2025 -> May 2026 Core as latest; GEO/AEO = SEO (one discipline). - S2: FAQ rich results retired 2026-05-07 -> FAQPage is an entity/AI-citation signal only, never a Google rich result; Article/Person/Organization/Breadcrumb is the 2026 priority. - S3: removed the +28% sponsored stat + qualified quarantined/single-source GEO stats. - S4: reconciled answer-first length, question-heading target (40-60%), furthermore/moreover conflict, and the news word floor. Image (B2): default model gemini-3.1-flash-image-preview (shut down 2026-06-25) -> GA gemini-3.1-flash-image + gemini-3-pro-image; Imagen 4 marked deprecated; validator warns on deprecated/preview models. Scripts (B1,B3,B4 + review bugs): pagespeed_check.py KeyError (audit_details init); ask_question.py import os; content_decay NaN/missing-metric/markdown-escape; quality_gate symlink bypass + UTF-8; ai_citation_score UTF-8; style_learn unicode + empty-corpus. +11 tests. Docs: broken template filenames (how-to-guide/faq-knowledge), ARCHITECTURE counts (31/206), CLAUDE.md stale annotations. 217 tests green (was 206), lint clean, plugin validates. Authored by Codex gpt-5.5/xhigh + chair; verified by the orchestrator. Local only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
236 lines
7.2 KiB
Python
236 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Pre-commit quality gate for blog posts.
|
|
|
|
The gate filters incoming Markdown and MDX paths to likely blog posts, analyzes
|
|
those posts with the existing analyzer, and fails when a score is below the
|
|
configured threshold.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
|
|
import analyze_blog # noqa: E402
|
|
|
|
DEFAULT_THRESHOLD = 70
|
|
EXCLUDED_DIRS = {"skills", "docs", "references", "templates", "tests"}
|
|
BLOG_SUFFIXES = {".md", ".mdx"}
|
|
|
|
|
|
def _path_for_policy(path: Path, root: Path | None = None) -> Path:
|
|
"""Return a lexical path relative to root when possible for directory checks."""
|
|
root = Path(os.path.abspath(os.fspath(root or Path.cwd())))
|
|
absolute_path = Path(os.path.abspath(os.fspath(path)))
|
|
try:
|
|
return absolute_path.relative_to(root)
|
|
except ValueError:
|
|
return absolute_path
|
|
|
|
|
|
def is_excluded_path(file_path: str | Path, root: Path | None = None) -> bool:
|
|
"""Return True when the path is inside a non-post content tree."""
|
|
policy_path = _path_for_policy(Path(file_path), root)
|
|
parent_parts = {part.lower() for part in policy_path.parts[:-1]}
|
|
return bool(parent_parts & EXCLUDED_DIRS)
|
|
|
|
|
|
def has_blog_frontmatter(file_path: str | Path) -> bool:
|
|
"""Return True when a Markdown file has blog post frontmatter keys."""
|
|
path = Path(file_path)
|
|
content = path.read_text(encoding="utf-8")
|
|
|
|
frontmatter = analyze_blog.extract_frontmatter(content)
|
|
normalized = {key.lower(): value for key, value in frontmatter.items()}
|
|
return bool(
|
|
normalized.get("title")
|
|
and (normalized.get("description") or normalized.get("date"))
|
|
)
|
|
|
|
|
|
def is_blog_post(file_path: str | Path, root: Path | None = None) -> bool:
|
|
"""Return True when a path should be gated as a blog post."""
|
|
path = Path(file_path)
|
|
if path.suffix.lower() not in BLOG_SUFFIXES:
|
|
return False
|
|
if is_excluded_path(path, root):
|
|
return False
|
|
if not path.is_file():
|
|
return False
|
|
try:
|
|
return has_blog_frontmatter(path)
|
|
except (OSError, UnicodeDecodeError):
|
|
return True
|
|
|
|
|
|
def _staged_files() -> list[str]:
|
|
"""Read staged file names from Git."""
|
|
result = subprocess.run(
|
|
["git", "diff", "--cached", "--name-only"],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
message = result.stderr.strip() or "Unable to read staged files"
|
|
raise RuntimeError(message)
|
|
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
|
|
|
|
def _dedupe(paths: list[str]) -> list[str]:
|
|
"""Preserve the first occurrence of each path."""
|
|
seen: set[str] = set()
|
|
deduped: list[str] = []
|
|
for path in paths:
|
|
key = str(Path(path).resolve(strict=False))
|
|
if key not in seen:
|
|
seen.add(key)
|
|
deduped.append(path)
|
|
return deduped
|
|
|
|
|
|
def _top_high_severity_issues(
|
|
issues: list[dict[str, Any]], limit: int = 3
|
|
) -> list[dict[str, Any]]:
|
|
return [issue for issue in issues if issue.get("severity") == "high"][:limit]
|
|
|
|
|
|
def _safe_analyze_file(path: Path) -> dict[str, Any]:
|
|
"""Run the shared analyzer and convert file read failures into JSON errors."""
|
|
try:
|
|
return analyze_blog.analyze_file(str(path))
|
|
except (OSError, UnicodeDecodeError) as exc:
|
|
return {"error": f"Could not analyze {path}: {exc}"}
|
|
|
|
|
|
def analyze_post(file_path: str | Path, threshold: int) -> dict[str, Any]:
|
|
"""Analyze a selected post and return gate-oriented result data."""
|
|
path = Path(file_path)
|
|
result = _safe_analyze_file(path)
|
|
error_message = result.get("error")
|
|
if "error" in result:
|
|
score = 0
|
|
issues = [
|
|
{
|
|
"category": "analyzer",
|
|
"severity": "high",
|
|
"issue": error_message,
|
|
}
|
|
]
|
|
else:
|
|
score = result["score"]["total"]
|
|
issues = result["score"].get("issues", [])
|
|
|
|
output = {
|
|
"file": str(path),
|
|
"score": score,
|
|
"passed": score >= threshold,
|
|
"issues": _top_high_severity_issues(issues),
|
|
}
|
|
if error_message:
|
|
output["error"] = error_message
|
|
return output
|
|
|
|
|
|
def _format_text(results: list[dict[str, Any]], failures: list[dict[str, Any]], threshold: int) -> str:
|
|
if not failures:
|
|
if results:
|
|
return f"Blog quality gate passed: {len(results)} post(s) checked."
|
|
return "Blog quality gate passed: no blog posts to check."
|
|
|
|
lines = [
|
|
f"Blog quality gate failed: {len(failures)} post(s) below score {threshold}.",
|
|
]
|
|
for failure in failures:
|
|
lines.append(f"{failure['file']}: score {failure['score']}/100")
|
|
if failure["issues"]:
|
|
for issue in failure["issues"]:
|
|
lines.append(
|
|
f" [{issue.get('severity', 'high')}] "
|
|
f"{issue.get('category', 'general')}: {issue.get('issue', '')}"
|
|
)
|
|
else:
|
|
lines.append(" No high severity issues reported.")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _format_json(
|
|
results: list[dict[str, Any]], failures: list[dict[str, Any]], threshold: int
|
|
) -> str:
|
|
errors = [result["error"] for result in results if result.get("error")]
|
|
payload = {
|
|
"threshold": threshold,
|
|
"checked": results,
|
|
"failures": failures,
|
|
}
|
|
if errors:
|
|
payload["error"] = "; ".join(errors)
|
|
return json.dumps(payload, indent=2, sort_keys=True)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
"""Build the command line parser."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Block commits when selected blog posts score below threshold."
|
|
)
|
|
parser.add_argument("files", nargs="*", help="Markdown or MDX files to check")
|
|
parser.add_argument(
|
|
"--threshold",
|
|
type=int,
|
|
default=DEFAULT_THRESHOLD,
|
|
help="Minimum accepted analyzer score",
|
|
)
|
|
parser.add_argument(
|
|
"--format",
|
|
choices=("text", "json"),
|
|
default="text",
|
|
help="Output format",
|
|
)
|
|
parser.add_argument(
|
|
"--staged",
|
|
action="store_true",
|
|
help="Also check Markdown and MDX files staged in Git",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Run the quality gate and return a process exit code."""
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
|
|
paths = list(args.files)
|
|
if args.staged:
|
|
try:
|
|
paths.extend(_staged_files())
|
|
except RuntimeError as exc:
|
|
print(f"Blog quality gate error: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
selected = [
|
|
path
|
|
for path in _dedupe(paths)
|
|
if is_blog_post(path, root=Path.cwd())
|
|
]
|
|
results = [analyze_post(path, args.threshold) for path in selected]
|
|
failures = [result for result in results if not result["passed"]]
|
|
|
|
if args.format == "json":
|
|
print(_format_json(results, failures, args.threshold))
|
|
else:
|
|
print(_format_text(results, failures, args.threshold))
|
|
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|