#!/usr/bin/env python3 """Lint Kandev specification files.""" import argparse import json import re import subprocess import sys from dataclasses import dataclass from pathlib import Path try: from spec_metadata import ( VALID_DESIGN_STATUSES, VALID_MIGRATION_STATUSES, VALID_REQUIREMENT_STATUSES, VALID_SYSTEM_STATUSES, classify_path, parse_frontmatter as parse_metadata_frontmatter, validate_metadata, ) except ModuleNotFoundError: from scripts.spec_metadata import ( VALID_DESIGN_STATUSES, VALID_MIGRATION_STATUSES, VALID_REQUIREMENT_STATUSES, VALID_SYSTEM_STATUSES, classify_path, parse_frontmatter as parse_metadata_frontmatter, validate_metadata, ) @dataclass(frozen=True) class Violation: rule: str path: Path line: int message: str SIZE_EXCEPTIONS_PATH = "docs/specs/spec-lint-exceptions.tsv" REQ_ID_PATTERN = r"REQ-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}" AC_ID_PATTERN = r"AC-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\.\d+" REQ_HEADING = re.compile(rf"^###\s+(?P{REQ_ID_PATTERN}):\s+\S") AC_DEFINITION = re.compile( rf"^\s*-\s+\*\*(?P{AC_ID_PATTERN}):\*\*\s+(?P\S.*)$" ) MIGRATED_SOURCE_HEADING = re.compile(r"^\s*##\s+Migrated source detail\s*$", re.IGNORECASE) GENERIC_AC_PATTERNS = ( re.compile( r"\b(?:all|any|every|remaining|other)\s+(?:the\s+)?" r"(?:behavior|behaviour|requirements?|details?|scenarios?)\b.*" r"\b(?:legacy|original|source|copied|elsewhere)\b", re.IGNORECASE, ), re.compile( r"\b(?:as|per)\s+(?:described|specified|documented)\b.*" r"\b(?:legacy|original|source|elsewhere|above)\b", re.IGNORECASE, ), re.compile( r"\b(?:see|refer(?:s)?|defer(?:s)?)\b.*" r"\b(?:legacy|original|source|copied|elsewhere)\b", re.IGNORECASE, ), ) def load_config(root: Path) -> dict: path = root / "docs/specs/spec-lint.json" return json.loads(path.read_text(encoding="utf-8")) def lint_specs( root: Path, config: dict | None = None, exceptions: dict[str, int] | None = None, migration_audit: bool = False, ) -> list[Violation]: config = config or load_config(root) validate_config(config) exceptions_provided_by_caller = exceptions is not None exception_violations: list[Violation] = [] if exceptions is None: exceptions, exception_violations = load_size_exceptions(root) previous_exceptions = ( None if exceptions_provided_by_caller else load_previous_size_exceptions(root) ) specs_root = root / "docs/specs" if not specs_root.exists(): return exception_violations paths = sorted(path for path in specs_root.rglob("*.md") if path.is_file()) system_migrations: dict[str, str] = {} for path in paths: inside = path.relative_to(specs_root).parts if ( len(inside) != 2 or inside[1] != "README.md" or inside[0] in {"guide", "product", "templates"} ): continue metadata, _, error = parse_frontmatter(path.read_text(encoding="utf-8")) if ( error is None and metadata is not None and metadata.get("specification_version") == "1" and metadata.get("migration") in VALID_MIGRATION_STATUSES ): system_migrations[inside[0]] = str(metadata["migration"]) violations: list[Violation] = [] requirement_definitions: dict[str, list[tuple[Path, int]]] = {} acceptance_definitions: dict[str, list[tuple[Path, int]]] = {} design_references: list[tuple[str, Path, int, str | None]] = [] systems_with_new_artifacts: set[str] = set() system_indexes: set[str] = set() for path in paths: relative = path.relative_to(root) kind, system = classify_path(relative) violations.extend(check_size(path, relative, kind, config, exceptions)) inside = relative.relative_to("docs/specs").parts if inside and system_migrations.get(inside[0]) == "complete" and kind == "legacy": violations.append( Violation( "system-layout", path, 1, "migrated system Markdown must be README.md, glossary.md, or under requirements/ or system-design/", ) ) if kind == "system-index" and path.name == "README.md": if system is not None: system_indexes.add(system) text = path.read_text(encoding="utf-8") metadata, metadata_line, metadata_error = parse_frontmatter(text) if metadata_error: violations.append(Violation("frontmatter", path, metadata_line, metadata_error)) else: assert metadata is not None violations.extend(check_system_index_metadata(path, metadata, system)) continue if kind not in {"requirement", "system-design"}: continue if system is not None: systems_with_new_artifacts.add(system) text = path.read_text(encoding="utf-8") migration = system_migrations.get(system) if system is not None else None if migration is not None: violations.extend( check_migration_content(path, text, migration, migration_audit) ) metadata, metadata_line, metadata_error = parse_frontmatter(text) if metadata_error: violations.append(Violation("frontmatter", path, metadata_line, metadata_error)) continue assert metadata is not None if kind == "requirement": violations.extend(check_requirement_metadata(path, metadata, system)) reqs, criteria = collect_requirement_ids(path, text) if not reqs: violations.append( Violation( "missing-requirement-id", path, 1, "requirement document must define at least one REQ-* heading", ) ) for req_id, line in reqs: requirement_definitions.setdefault(req_id, []).append((path, line)) local_requirements = {req_id for req_id, _ in reqs} local_acceptance_parents = {acceptance_parent(ac_id) for ac_id, _ in criteria} for req_id, line in reqs: if req_id not in local_acceptance_parents: violations.append( Violation( "missing-acceptance-criteria", path, line, f"{req_id} must have at least one matching AC-* criterion", ) ) for ac_id, line in criteria: acceptance_definitions.setdefault(ac_id, []).append((path, line)) parent = acceptance_parent(ac_id) if parent not in local_requirements: violations.append( Violation( "orphan-acceptance-id", path, line, f"{ac_id} has no matching {parent} in this document", ) ) else: violations.extend(check_design_metadata(path, metadata, system)) references = metadata.get("requirements", []) if isinstance(references, list): for reference in references: design_references.append((str(reference), path, 1, system)) for system in sorted(systems_with_new_artifacts - system_indexes): violations.append( Violation( "missing-system-index", specs_root / system, 1, "system requirements and designs must have a system README.md", ) ) violations.extend(check_duplicate_ids("requirement", requirement_definitions)) violations.extend(check_duplicate_ids("acceptance", acceptance_definitions)) known_requirements = set(requirement_definitions) for requirement, path, line, design_system in design_references: if requirement not in known_requirements: violations.append( Violation( "unknown-requirement-reference", path, line, f"system design references unknown requirement {requirement}", ) ) continue definition_path, _ = requirement_definitions[requirement][0] _, requirement_system = classify_path(definition_path.relative_to(root)) if requirement_system != design_system: violations.append( Violation( "cross-system-requirement-reference", path, line, f"system design in `{design_system}` cannot claim requirement {requirement} " f"owned by `{requirement_system}`; link to the owning system instead", ) ) violations.extend(check_size_exception_catalog(root, config, exceptions)) violations.extend(check_legacy_size_ratchet(root, exceptions, previous_exceptions)) violations.extend(exception_violations) return sorted(violations, key=lambda item: (item.path.as_posix(), item.line, item.rule)) def check_migration_content( path: Path, text: str, migration: str, migration_audit: bool ) -> list[Violation]: """Check canonical artifacts for migration-only content debt. In-progress systems are intentionally opt-in so existing migrations can be audited without making the normal specification lint fail. Completed systems treat the same findings as blocking content violations. """ if migration == "in_progress" and not migration_audit: return [] if migration not in VALID_MIGRATION_STATUSES: return [] rule = "migration-audit" if migration == "in_progress" else "migration-content" violations: list[Violation] = [] for line_number, line in enumerate(text.splitlines(), start=1): if MIGRATED_SOURCE_HEADING.match(line): violations.append( Violation( rule, path, line_number, "remove the `Migrated source detail` wrapper after migration; " "keep authoritative content in named requirement or design sections", ) ) acceptance_match = AC_DEFINITION.match(line) if acceptance_match and any( pattern.search(acceptance_match.group("body")) for pattern in GENERIC_AC_PATTERNS ): violations.append( Violation( rule, path, line_number, f"{acceptance_match.group('id')} must state observable behavior " "directly; do not defer to generic or copied legacy prose", ) ) return violations def validate_config(config: dict) -> None: if config.get("version") != 1: raise ValueError("specification lint configuration must use version 1") if "legacy_size_exceptions" in config: raise ValueError( "specification lint configuration must not contain legacy_size_exceptions; " f"register exceptions in {SIZE_EXCEPTIONS_PATH} instead" ) required_limits = { "guide", "legacy", "product", "requirement", "system-design", "system-index", "template", } limits = config.get("limits", {}) missing = sorted(required_limits - set(limits)) if missing: raise ValueError(f"specification lint configuration is missing limits: {', '.join(missing)}") unknown = sorted(set(limits) - required_limits) if unknown: raise ValueError(f"specification lint configuration has unknown limit keys: {', '.join(unknown)}") if any(not isinstance(limits[key], int) or limits[key] <= 0 for key in required_limits): raise ValueError("specification size limits must be positive integers") def parse_size_exceptions(text: str, source: Path) -> tuple[dict[str, int], list[Violation]]: """Parse `pathsize` records, one per line, flagging malformed or duplicate lines.""" exceptions: dict[str, int] = {} violations: list[Violation] = [] for line_number, raw_line in enumerate(text.splitlines(), start=1): if not raw_line.strip(): continue parts = raw_line.split("\t") if len(parts) != 2 or not parts[0] or not re.fullmatch(r"[0-9]+", parts[1]): violations.append( Violation( "malformed-size-exception", source, line_number, "expected `pathsize` with an ASCII decimal size", ) ) continue relative_text, size_text = parts if relative_text in exceptions: violations.append( Violation( "duplicate-size-exception", source, line_number, f"{relative_text} already has a frozen ceiling of {exceptions[relative_text]} bytes", ) ) continue exceptions[relative_text] = int(size_text) return exceptions, violations def load_size_exceptions(root: Path) -> tuple[dict[str, int], list[Violation]]: path = root / SIZE_EXCEPTIONS_PATH if not path.exists() and not path.is_symlink(): return {}, [] if not is_safe_regular_file(path, root): return {}, [ Violation( "invalid-size-exception-catalog", path, 1, "size exception catalog must be a regular file inside the repository", ) ] return parse_size_exceptions(path.read_text(encoding="utf-8"), path) def load_previous_size_exceptions(root: Path) -> dict[str, int] | None: """Load the nearest prior sidecar so legacy ceilings cannot increase silently.""" try: parents_output = subprocess.run( ["git", "rev-list", "--parents", "-n", "1", "HEAD"], cwd=root, check=True, capture_output=True, text=True, ).stdout.split() except (OSError, subprocess.CalledProcessError): return None parents = parents_output[1:] candidates = parents[:1] if len(parents) > 1: try: second_parent_output = subprocess.run( ["git", "rev-list", "--parents", "-n", "1", parents[1]], cwd=root, check=True, capture_output=True, text=True, ).stdout.split() except (OSError, subprocess.CalledProcessError): second_parent_output = [] if len(second_parent_output) > 1: candidates.append(second_parent_output[1]) for revision in candidates: try: exceptions_text = subprocess.run( ["git", "show", f"{revision}:{SIZE_EXCEPTIONS_PATH}"], cwd=root, check=True, capture_output=True, text=True, ).stdout except (OSError, subprocess.CalledProcessError): continue exceptions, _ = parse_size_exceptions(exceptions_text, root / SIZE_EXCEPTIONS_PATH) return exceptions return None def is_safe_regular_file(path: Path, root: Path) -> bool: if path.is_symlink() or not path.is_file(): return False try: path.resolve(strict=True).relative_to(root.resolve()) except (OSError, ValueError): return False return True def is_canonical_legacy_path(relative_text: str, relative: Path) -> bool: return ( "\x00" not in relative_text and not relative.is_absolute() and relative.as_posix() == relative_text and relative.parts[:2] == ("docs", "specs") and relative.suffix == ".md" and all(part not in {".", ".."} for part in relative.parts) ) def check_size( path: Path, relative: Path, kind: str, config: dict, exceptions: dict[str, int] ) -> list[Violation]: if kind == "unknown": return [] size = path.stat().st_size default_limit = config["limits"][kind] exception = exceptions.get(relative.as_posix()) limit = exception if kind == "legacy" and exception is not None else default_limit if size <= limit: return [] ceiling = f"frozen ceiling {limit}" if exception is not None else f"limit {limit}" return [ Violation( "file-size", path, 1, f"{kind} file is {size} bytes and exceeds its {ceiling} bytes", ) ] def check_size_exception_catalog( root: Path, config: dict, exceptions: dict[str, int] ) -> list[Violation]: violations = [] default_limit = config["limits"]["legacy"] for relative_text, ceiling in exceptions.items(): relative = Path(relative_text) if not is_canonical_legacy_path(relative_text, relative): violations.append( Violation( "invalid-size-exception", root / SIZE_EXCEPTIONS_PATH, 1, "size exceptions must reference canonical legacy Markdown files under " f"docs/specs (got `{relative_text}`)", ) ) continue path = root / relative if not path.exists(): violations.append( Violation( "missing-size-exception-file", path, 1, "legacy size exception references a missing file", ) ) continue if not is_safe_regular_file(path, root / "docs/specs"): violations.append( Violation( "invalid-size-exception", path, 1, "size exceptions must reference regular files inside docs/specs", ) ) continue kind, _ = classify_path(relative) if kind != "legacy": violations.append( Violation( "invalid-size-exception", path, 1, "size exceptions apply only to legacy specification files", ) ) continue size = path.stat().st_size if size < ceiling: violations.append( Violation( "stale-size-exception", path, 1, f"file is {size} bytes. Lower the frozen ceiling to {size} or remove the exception", ) ) elif ceiling <= default_limit: violations.append( Violation( "stale-size-exception", path, 1, f"frozen ceiling {ceiling} is at or below the default limit {default_limit}; remove the exception", ) ) return violations def check_legacy_size_ratchet( root: Path, exceptions: dict[str, int], previous_exceptions: dict[str, int] | None ) -> list[Violation]: if previous_exceptions is None: return [] violations = [] for relative_text, previous_ceiling in previous_exceptions.items(): current_ceiling = exceptions.get(relative_text) if current_ceiling is None: continue if current_ceiling > previous_ceiling: relative = Path(relative_text) violation_path = ( root / relative if is_canonical_legacy_path(relative_text, relative) else root / SIZE_EXCEPTIONS_PATH ) violations.append( Violation( "legacy-size-ratchet", violation_path, 1, f"frozen ceiling increased from {previous_ceiling} to {current_ceiling}; lower it or migrate the file", ) ) return violations def parse_frontmatter(text: str) -> tuple[dict | None, int, str | None]: parsed = parse_metadata_frontmatter(text, require=True) return parsed.metadata, parsed.line, parsed.error def metadata_violations( path: Path, kind: str, system: str | None, metadata: dict ) -> list[Violation]: return [ Violation(issue.code, path, 1, issue.message) for issue in validate_metadata(kind, system, metadata) ] def check_requirement_metadata( path: Path, metadata: dict, system: str | None = None ) -> list[Violation]: return metadata_violations(path, "requirement", system, metadata) def check_system_index_metadata( path: Path, metadata: dict, system: str | None ) -> list[Violation]: return metadata_violations(path, "system-index", system, metadata) def check_design_metadata( path: Path, metadata: dict, system: str | None = None ) -> list[Violation]: return metadata_violations(path, "system-design", system, metadata) def collect_requirement_ids(path: Path, text: str) -> tuple[list[tuple[str, int]], list[tuple[str, int]]]: del path requirements = [] criteria = [] for line_number, line in enumerate(text.splitlines(), start=1): requirement_match = REQ_HEADING.match(line) if requirement_match: requirements.append((requirement_match.group("id"), line_number)) acceptance_match = AC_DEFINITION.match(line) if acceptance_match: criteria.append((acceptance_match.group("id"), line_number)) return requirements, criteria def acceptance_parent(acceptance_id: str) -> str: return f"REQ-{acceptance_id.removeprefix('AC-').rsplit('.', 1)[0]}" def check_duplicate_ids(kind: str, definitions: dict[str, list[tuple[Path, int]]]) -> list[Violation]: rule = f"duplicate-{kind}-id" violations = [] for identifier, locations in definitions.items(): if len(locations) < 2: continue first_path, first_line = locations[0] for path, line in locations[1:]: violations.append( Violation( rule, path, line, f"{identifier} duplicates {first_path}:{first_line}", ) ) return violations def main() -> int: parser = argparse.ArgumentParser(description="Lint Kandev specification files.") parser.add_argument("--all", action="store_true", help="Lint all specification files") parser.add_argument( "--migration-audit", action="store_true", help="Report non-blocking content debt in in-progress migrations", ) args = parser.parse_args() if not args.all: parser.print_usage(sys.stderr) return 2 root = Path.cwd() violations = lint_specs(root, migration_audit=args.migration_audit) for violation in violations: relative = violation.path.relative_to(root) print(f"{relative}:{violation.line}: [{violation.rule}] {violation.message}") blocking = [violation for violation in violations if violation.rule != "migration-audit"] if blocking: print(f"\n{len(blocking)} specification lint violation(s) found.", file=sys.stderr) return 1 if violations: print( f"Specification files passed; {len(violations)} non-blocking migration " "audit finding(s) reported." ) return 0 print("All specification files passed.") return 0 if __name__ == "__main__": raise SystemExit(main())