|
| 1 | +"""Commitment identity extraction and related validation.""" |
| 2 | + |
| 3 | +import re |
| 4 | +from dataclasses import dataclass |
| 5 | +from pathlib import Path |
| 6 | +from typing import Dict, List, Optional |
| 7 | + |
| 8 | +from .paths import CONTENT_ROOT, should_skip |
| 9 | + |
| 10 | +IDENTIFIER_PATTERN = re.compile(r"^`([^`]+)`\s*$") |
| 11 | +FILENAME_PATTERN = re.compile(r"^(?P<quarter>\d{4}q[1-4])-(?P<slug>.+)$") |
| 12 | + |
| 13 | + |
| 14 | +@dataclass |
| 15 | +class CommitmentIdentity: |
| 16 | + unit: str |
| 17 | + quarter: str |
| 18 | + area: str |
| 19 | + slug: str |
| 20 | + expected_tags: List[str] |
| 21 | + expected_identifier: str |
| 22 | + expected_base: str |
| 23 | + |
| 24 | + |
| 25 | +def derive_identity(path: Path) -> Optional[CommitmentIdentity]: |
| 26 | + """Infer roadmap identity metadata from the file path.""" |
| 27 | + try: |
| 28 | + relative = path.resolve().relative_to(CONTENT_ROOT) |
| 29 | + except ValueError: |
| 30 | + return None |
| 31 | + if len(relative.parts) < 3 or should_skip(path): |
| 32 | + return None |
| 33 | + |
| 34 | + unit, area = relative.parts[0], relative.parts[1] |
| 35 | + match = FILENAME_PATTERN.match(path.stem) |
| 36 | + if not match: |
| 37 | + return None |
| 38 | + |
| 39 | + quarter = match.group("quarter") |
| 40 | + slug = match.group("slug") |
| 41 | + expected_base = f"vac:{unit}:{area}:{quarter}-{slug}" |
| 42 | + expected_tags = [quarter, unit, area] |
| 43 | + expected_identifier = expected_base |
| 44 | + return CommitmentIdentity( |
| 45 | + unit=unit, |
| 46 | + quarter=quarter, |
| 47 | + area=area, |
| 48 | + slug=slug, |
| 49 | + expected_tags=expected_tags, |
| 50 | + expected_identifier=expected_identifier, |
| 51 | + expected_base=expected_base, |
| 52 | + ) |
| 53 | + |
| 54 | + |
| 55 | +def validate_identity( |
| 56 | + path: Path, |
| 57 | + front_matter: Dict[str, object], |
| 58 | + lines: List[str], |
| 59 | + body_start: int, |
| 60 | + identity: CommitmentIdentity, |
| 61 | +) -> List[str]: |
| 62 | + """Validate tags and inline identifier for a commitment.""" |
| 63 | + issues: List[str] = [] |
| 64 | + |
| 65 | + tags = front_matter.get("tags") |
| 66 | + if isinstance(tags, (list, tuple)): |
| 67 | + tag_values = [str(tag) for tag in tags] |
| 68 | + if tag_values != identity.expected_tags: |
| 69 | + issues.append( |
| 70 | + f"{path}: tags should be {identity.expected_tags!r} (found {tag_values!r})" |
| 71 | + ) |
| 72 | + else: |
| 73 | + issues.append( |
| 74 | + f"{path}: tags must be a list matching {identity.expected_tags!r}" |
| 75 | + ) |
| 76 | + |
| 77 | + identifier_value: Optional[str] = None |
| 78 | + identifier_line: Optional[int] = None |
| 79 | + for idx in range(body_start, len(lines)): |
| 80 | + stripped = lines[idx].strip() |
| 81 | + match = IDENTIFIER_PATTERN.match(stripped) |
| 82 | + if match: |
| 83 | + identifier_value = match.group(1) |
| 84 | + identifier_line = idx + 1 |
| 85 | + break |
| 86 | + |
| 87 | + if identifier_value is None: |
| 88 | + issues.append( |
| 89 | + f"{path}:{body_start + 1}: missing commitment identifier `" |
| 90 | + f"{identity.expected_identifier}`" |
| 91 | + ) |
| 92 | + elif identifier_value != identity.expected_identifier: |
| 93 | + issues.append( |
| 94 | + f"{path}:{identifier_line}: identifier should be `" |
| 95 | + f"{identity.expected_identifier}` (found `{identifier_value}`)" |
| 96 | + ) |
| 97 | + |
| 98 | + return issues |
| 99 | + |
| 100 | + |
| 101 | +__all__ = ["CommitmentIdentity", "derive_identity", "validate_identity"] |
0 commit comments