|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Lint curatorial incident log entries. |
| 3 | +
|
| 4 | +Checks: |
| 5 | +- Directory exists: docs/incidents |
| 6 | +- Files follow naming: INC-XXXX-*.md (XXXX numeric, zero-padded) |
| 7 | +- IDs strictly sequential without gaps. |
| 8 | +- Each file contains required section headings: |
| 9 | + ## Context / Trigger |
| 10 | + ## Symptom |
| 11 | + ## Root Cause |
| 12 | + ## Resolution |
| 13 | + ## Prevention / Guardrail |
| 14 | + ## References |
| 15 | +- Status line present near top: **Status:** |
| 16 | +- Tags line present: **Tags:** |
| 17 | +- If Closed, must mention at least one commit hash pattern (7+ hex) in body. |
| 18 | +Exit non-zero on failure. |
| 19 | +""" |
| 20 | + |
| 21 | +from __future__ import annotations |
| 22 | +import re |
| 23 | +import sys |
| 24 | +from pathlib import Path |
| 25 | + |
| 26 | +RE_FILE = re.compile(r'^INC-(\d{4})-[a-z0-9-]+\.md$') |
| 27 | +RE_COMMIT = re.compile(r'\b[0-9a-f]{7,40}\b') |
| 28 | +RE_REQUIRED_SECTIONS = [ |
| 29 | + '## Context / Trigger', |
| 30 | + '## Symptom', |
| 31 | + '## Root Cause', |
| 32 | + '## Resolution', |
| 33 | + '## Prevention / Guardrail', |
| 34 | + '## References', |
| 35 | +] |
| 36 | + |
| 37 | +RE_STATUS = re.compile(r'^\*\*Status:\*\*\s*(.+)$', re.IGNORECASE) |
| 38 | +RE_TAGS = re.compile(r'^\*\*Tags:\*\*\s*(.+)$', re.IGNORECASE) |
| 39 | + |
| 40 | + |
| 41 | +def fail(msg: str): |
| 42 | + print(f'[INC-LINT] FAIL: {msg}', file=sys.stderr) |
| 43 | + raise SystemExit(1) |
| 44 | + |
| 45 | + |
| 46 | +def main() -> int: |
| 47 | + root = Path(__file__).resolve().parent.parent |
| 48 | + idir = root / 'docs' / 'incidents' |
| 49 | + if not idir.exists(): |
| 50 | + print('[INC-LINT] No incidents directory (skip).') |
| 51 | + return 0 |
| 52 | + files = [p for p in idir.glob('INC-*.md') if p.is_file() and p.name != 'TEMPLATE.md'] |
| 53 | + if not files: |
| 54 | + print('[INC-LINT] No incident files.') |
| 55 | + return 0 |
| 56 | + files.sort() |
| 57 | + ids = [] |
| 58 | + for p in files: |
| 59 | + m = RE_FILE.match(p.name) |
| 60 | + if not m: |
| 61 | + fail(f'Bad filename: {p.name} (expected INC-XXXX-kebab.md)') |
| 62 | + ids.append(int(m.group(1))) |
| 63 | + # Check sequential |
| 64 | + for previous, current in zip(ids, ids[1:]): |
| 65 | + if current != previous + 1: |
| 66 | + fail(f'ID gap between {previous:04d} and {current:04d}') |
| 67 | + # Lint content |
| 68 | + for p in files: |
| 69 | + text = p.read_text(encoding='utf-8') |
| 70 | + # Headings |
| 71 | + for sec in RE_REQUIRED_SECTIONS: |
| 72 | + if sec not in text: |
| 73 | + fail(f"Missing section '{sec}' in {p.name}") |
| 74 | + # Status & Tags near top (first 25 lines) |
| 75 | + lines = text.splitlines()[:25] |
| 76 | + status = any(RE_STATUS.search(line) for line in lines) |
| 77 | + tags = any(RE_TAGS.search(line) for line in lines) |
| 78 | + if not status: |
| 79 | + fail(f'Missing **Status:** line near top of {p.name}') |
| 80 | + if not tags: |
| 81 | + fail(f'Missing **Tags:** line near top of {p.name}') |
| 82 | + if '**Status:** Closed' in text: |
| 83 | + if not RE_COMMIT.search(text): |
| 84 | + fail(f'Closed incident {p.name} missing commit reference') |
| 85 | + print('[INC-LINT] OK: incidents lint passed.') |
| 86 | + return 0 |
| 87 | + |
| 88 | + |
| 89 | +if __name__ == '__main__': # pragma: no cover |
| 90 | + raise SystemExit(main()) |
0 commit comments