feat(chronology): regenerate chronology.md with v2 git-history classifier (closes 5-day desync gap)
This commit is contained in:
@@ -26,6 +26,7 @@ _METADATA_FIELD_PREFIXES = (
|
||||
"**Parent umbrella:**",
|
||||
"**Status:**",
|
||||
"**Confidence:**",
|
||||
"**Ancestors:**",
|
||||
)
|
||||
|
||||
|
||||
@@ -86,16 +87,26 @@ def run_quality_gate(rows: list[dict]) -> QualityGateResult:
|
||||
|
||||
def _load_rows_from_chronology(chronology_path: Path) -> list[dict]:
|
||||
from scripts.audit.generate_chronology import walk_track_folders, _repo_root
|
||||
root: Path = _repo_root(chronology_path.parent)
|
||||
return walk_track_folders(root)
|
||||
# chronology_path is conductor/chronology.md; walk_track_folders expects the conductor dir
|
||||
conductor_dir: Path = chronology_path.parent.resolve()
|
||||
if not (conductor_dir / "tracks").is_dir():
|
||||
# Fallback: resolve from repo root
|
||||
repo_root: Path = _repo_root(conductor_dir)
|
||||
conductor_dir = repo_root / "conductor"
|
||||
return walk_track_folders(conductor_dir)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Quality gate for conductor/chronology.md")
|
||||
parser.add_argument("--chronology", type=Path, default=Path("conductor/chronology.md"))
|
||||
parser.add_argument("--rows-json", type=Path, default=None, help="Load rows from JSON file instead of re-walking")
|
||||
parser.add_argument("--strict", action="store_true", help="Exit 1 on any violation")
|
||||
args = parser.parse_args()
|
||||
rows = _load_rows_from_chronology(args.chronology)
|
||||
if args.rows_json and args.rows_json.is_file():
|
||||
import json
|
||||
rows = json.loads(args.rows_json.read_text(encoding="utf-8"))
|
||||
else:
|
||||
rows = _load_rows_from_chronology(args.chronology)
|
||||
result = run_quality_gate(rows)
|
||||
print(f"Quality gate: {'PASS' if result.passed else 'FAIL'}")
|
||||
print(f" Total rows: {result.total_rows}")
|
||||
|
||||
@@ -38,12 +38,22 @@ _METADATA_FIELD_PREFIXES = (
|
||||
"**Parent umbrella:**",
|
||||
"**Status:**",
|
||||
"**Confidence:**",
|
||||
"**Ancestors:**",
|
||||
)
|
||||
|
||||
_WORK_COMMIT_PREFIXES = ("feat:", "fix:", "refactor:", "perf:", "test:", "docs(report):")
|
||||
_WORK_COMMIT_PREFIXES = ("feat", "fix", "refactor", "perf", "test", "docs(report)")
|
||||
_METADATA_COMMIT_PREFIXES = ("conductor(plan):", "conductor(state):", "conductor(track):", "docs(spec):", "docs(plan):")
|
||||
|
||||
|
||||
def _is_work_commit(msg: str) -> bool:
|
||||
"""Check if a commit message is a work commit (feat/fix/refactor/perf/test with optional scope)."""
|
||||
for prefix in _WORK_COMMIT_PREFIXES:
|
||||
if msg.startswith(prefix + ":") or msg.startswith(prefix + "("):
|
||||
if not any(msg.startswith(m) for m in _METADATA_COMMIT_PREFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_slug_date(folder_name: str) -> Optional[str]:
|
||||
m = _SLUG_DATE_RE.search(folder_name)
|
||||
if not m:
|
||||
@@ -123,6 +133,23 @@ def _git_log(folder_relpath: str, *args: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _git_log_multi(*folder_relpaths: str) -> str:
|
||||
"""Get git log for multiple paths in a single subprocess call."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "--oneline", "--", *folder_relpaths],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_GIT_TIMEOUT,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return ""
|
||||
|
||||
|
||||
def _git_first_line(folder_relpath: str, *args: str) -> str:
|
||||
out = _git_log(folder_relpath, *args)
|
||||
stripped = out.strip()
|
||||
@@ -188,16 +215,20 @@ def _last_commit_date(folder_relpath: str) -> str:
|
||||
return "never"
|
||||
|
||||
|
||||
def _count_work_commits(folder_relpath: str) -> int:
|
||||
log: str = _git_log(folder_relpath, "--oneline")
|
||||
def _count_work_commits_from_log(log: str) -> int:
|
||||
count: int = 0
|
||||
for line in log.splitlines():
|
||||
msg: str = line.split(" ", 1)[1] if " " in line else ""
|
||||
if msg.startswith(_WORK_COMMIT_PREFIXES) and not msg.startswith(_METADATA_COMMIT_PREFIXES):
|
||||
if _is_work_commit(msg):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _count_work_commits(folder_relpath: str) -> int:
|
||||
log: str = _git_log(folder_relpath, "--oneline")
|
||||
return _count_work_commits_from_log(log)
|
||||
|
||||
|
||||
def _has_report_matching(reports_dir: Path, track_id: str, prefix: str) -> bool:
|
||||
if not reports_dir.is_dir():
|
||||
return False
|
||||
@@ -215,6 +246,7 @@ def classify_status(
|
||||
reports_dir: Path,
|
||||
has_abort_report: bool = False,
|
||||
state_status: str = "",
|
||||
work_commits: int = -1,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Git-history evidence classifier returning (status, confidence, reason).
|
||||
|
||||
@@ -238,7 +270,8 @@ def classify_status(
|
||||
# 2. Git commit evidence
|
||||
is_archive = folder_link.startswith("conductor/archive/")
|
||||
is_tracks = folder_link.startswith("conductor/tracks/")
|
||||
work_commits: int = _count_work_commits(folder_link)
|
||||
if work_commits < 0:
|
||||
work_commits = _count_work_commits(folder_link)
|
||||
if work_commits >= 3:
|
||||
return ("Completed", "medium", f"{work_commits} work commits")
|
||||
if 1 <= work_commits <= 2 and is_tracks:
|
||||
@@ -273,11 +306,31 @@ def walk_track_folders(root: Path) -> list[dict]:
|
||||
folder_relpath = _to_posix(str(folder))
|
||||
track_id: str = folder.name
|
||||
slug_date = extract_slug_date(track_id)
|
||||
# Get the full oneline log once. For archive folders, include both the
|
||||
# archive path and the original tracks/ path (git mv preserves history
|
||||
# but git log -- <archive_path> alone may miss pre-move commits)
|
||||
if folder_relpath.startswith("conductor/archive/"):
|
||||
original_path: str = folder_relpath.replace("conductor/archive/", "conductor/tracks/", 1)
|
||||
oneline_log: str = _git_log_multi(folder_relpath, original_path)
|
||||
else:
|
||||
oneline_log = _git_log(folder_relpath, "--oneline")
|
||||
log_lines: list[str] = [l for l in oneline_log.splitlines() if l.strip()]
|
||||
commit_count: int = len(log_lines)
|
||||
if slug_date:
|
||||
date = slug_date
|
||||
else:
|
||||
elif log_lines:
|
||||
# First commit date via reverse log
|
||||
first_commit = _git_first_line(folder_relpath, "--reverse", "--format=%aI")
|
||||
date = first_commit[:10] if first_commit else ""
|
||||
else:
|
||||
date = ""
|
||||
# Derive init_sha and end_sha from the oneline log
|
||||
if log_lines:
|
||||
init_sha: str = log_lines[-1].split(" ", 1)[0] # oldest (last in reverse-chronological git log)
|
||||
end_sha: str = log_lines[0].split(" ", 1)[0] # newest (first in git log)
|
||||
else:
|
||||
init_sha = ""
|
||||
end_sha = ""
|
||||
metadata_path = folder / "metadata.json"
|
||||
meta_status: str = ""
|
||||
if metadata_path.is_file():
|
||||
@@ -287,6 +340,7 @@ def walk_track_folders(root: Path) -> list[dict]:
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
state_status: str = _parse_state_status(folder / "state.toml")
|
||||
work_commits: int = _count_work_commits_from_log(oneline_log)
|
||||
status, confidence, reason = classify_status(
|
||||
folder_link=folder_relpath,
|
||||
current=meta_status or default_status,
|
||||
@@ -294,16 +348,9 @@ def walk_track_folders(root: Path) -> list[dict]:
|
||||
repo_root=repo_root,
|
||||
reports_dir=reports_dir,
|
||||
state_status=state_status,
|
||||
work_commits=work_commits,
|
||||
)
|
||||
summary: str = extract_summary(folder)
|
||||
init_sha: str = _git_first_line(folder_relpath, "--reverse", "--format=%h")
|
||||
end_sha: str = _git_first_line(folder_relpath, "-1", "--format=%h")
|
||||
if init_sha and end_sha:
|
||||
range_log = _git_log(folder_relpath, "--oneline", f"{init_sha}..{end_sha}")
|
||||
commit_count: int = range_log.count("\n") + (1 if init_sha != end_sha else 0)
|
||||
else:
|
||||
fallback_log = _git_log(folder_relpath, "--oneline")
|
||||
commit_count = fallback_log.count("\n")
|
||||
try:
|
||||
folder_link = _to_posix(str(folder.relative_to(repo_root)))
|
||||
except ValueError:
|
||||
|
||||
Reference in New Issue
Block a user