← Files note Workspace|ROGNALIAARCHIVED FILE

skills/note-source-log/scripts/validate_source_data.py

14.3 KB · Oct 2, 2026 · 00:33 UTC

↓ Download file

#!/usr/bin/env python3
"""Validate traceability and integrity of source data in one workspace."""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional

from source_common import (
    ARTICLE_ID_PATTERN,
    CARD_ID_PATTERN,
    ID_PATTERN,
    SCHEMA_VERSION,
    SourceDataError,
    build_context_pack_event,
    canonical_hash,
    current_cards,
    exclusive_locks,
    load_card_events,
    load_context_pack_events,
    load_log_records,
    parse_timestamp,
    read_context_body,
    read_context_metadata,
    render_context_pack,
    resolve_workspace,
    text_hash,
    validate_card_events,
    validate_context_pack_events,
    validate_log_records,
)


SHA256_PATTERN = re.compile(r"[0-9a-f]{64}")


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Validate note Workspace source data.")
    parser.add_argument("workspace", help="Absolute path to the selected workspace")
    return parser


def _validate_context_packs(workspace: Path, events: List[dict]) -> List[str]:
    errors: List[str] = []
    request_paths: Dict[str, Path] = {}
    try:
        registry_events = load_context_pack_events(workspace)
    except SourceDataError as exc:
        return [str(exc)]
    errors.extend(validate_context_pack_events(registry_events))
    registry_by_path = {
        event.get("path"): event
        for event in registry_events
        if isinstance(event.get("path"), str)
    }
    pack_paths = {
        str(path.relative_to(workspace))
        for path in (workspace / "context-packs").glob("*.md")
        if path.is_file() and not path.is_symlink()
    }
    for relative in sorted(set(registry_by_path) - pack_paths):
        errors.append(f"{relative}: context pack registry points to a missing pack")
    cards_by_revision = {
        (event.get("source_card_id"), event.get("revision")): event
        for event in events
        if isinstance(event.get("source_card_id"), str)
        and isinstance(event.get("revision"), int)
        and not isinstance(event.get("revision"), bool)
    }
    required = {
        "schema_version",
        "request_id",
        "payload_sha256",
        "body_sha256",
        "article_id",
        "created_at",
        "source_card_ids",
        "approved_for_article",
        "input",
        "source_cards",
    }
    for path in sorted((workspace / "context-packs").glob("*.md")):
        relative = str(path.relative_to(workspace))
        if path.is_symlink() or not path.is_file():
            errors.append(f"{relative}: context pack path is unsafe")
            continue
        try:
            metadata = read_context_metadata(path)
            body = read_context_body(path)
            full_text = path.read_text(encoding="utf-8")
        except (OSError, SourceDataError) as exc:
            errors.append(str(exc))
            continue
        if set(metadata) != required:
            errors.append(f"{relative}: metadata fields do not match schema")
            continue
        article_id = metadata.get("article_id")
        request_id = metadata.get("request_id")
        if not isinstance(article_id, str) or not ARTICLE_ID_PATTERN.fullmatch(article_id):
            errors.append(f"{relative}: invalid article_id")
        elif path.stem != article_id:
            errors.append(f"{relative}: article_id does not match filename")
        if not isinstance(request_id, str) or not ID_PATTERN.fullmatch(request_id):
            errors.append(f"{relative}: invalid request_id")
        elif request_id in request_paths:
            errors.append(f"{relative}: duplicate request_id {request_id}")
        else:
            request_paths[request_id] = path
        if metadata.get("schema_version") != SCHEMA_VERSION:
            errors.append(f"{relative}: unsupported schema_version")
        for hash_field in ("payload_sha256", "body_sha256"):
            value = metadata.get(hash_field)
            if not isinstance(value, str) or not SHA256_PATTERN.fullmatch(value):
                errors.append(f"{relative}: invalid {hash_field}")
        if metadata.get("body_sha256") != text_hash(body):
            errors.append(f"{relative}: body_sha256 mismatch")
        created_moment = None
        try:
            _, created_moment = parse_timestamp(metadata.get("created_at"))
        except (SourceDataError, AttributeError):
            errors.append(f"{relative}: invalid created_at")
        registry_event = registry_by_path.get(relative)
        if registry_event is None:
            errors.append(f"{relative}: context pack has no registry event")
        else:
            try:
                expected_registry_event = build_context_pack_event(full_text, relative)
                if registry_event != expected_registry_event:
                    errors.append(f"{relative}: context pack does not match registry event")
            except (KeyError, SourceDataError, TypeError) as exc:
                errors.append(f"{relative}: context pack registry binding is invalid: {exc}")
        selected = metadata.get("source_card_ids")
        approved = metadata.get("approved_for_article")
        if not isinstance(selected, list) or not selected:
            errors.append(f"{relative}: source_card_ids must not be empty")
            selected = []
        if not isinstance(approved, list):
            errors.append(f"{relative}: approved_for_article must be an array")
            approved = []
        if len(selected) > 100 or len(approved) > 100:
            errors.append(f"{relative}: card ID arrays must contain at most 100 items")
        selected_strings = [value for value in selected if isinstance(value, str)]
        approved_strings = [value for value in approved if isinstance(value, str)]
        if len(selected_strings) != len(selected) or len(approved_strings) != len(approved):
            errors.append(f"{relative}: card ID arrays must contain only strings")
        if (
            len(selected_strings) != len(set(selected_strings))
            or len(approved_strings) != len(set(approved_strings))
        ):
            errors.append(f"{relative}: card ID arrays must not contain duplicates")
        if not set(approved_strings).issubset(set(selected_strings)):
            errors.append(f"{relative}: approved cards must be selected")
        input_value = metadata.get("input")
        input_keys = {
            "schema_version",
            "request_id",
            "article_id",
            "topic",
            "purpose",
            "source_card_ids",
            "approved_for_article",
            "missing_information",
        }
        input_valid = isinstance(input_value, dict) and set(input_value) == input_keys
        if not input_valid:
            errors.append(f"{relative}: input metadata fields do not match schema")
        else:
            if input_value.get("schema_version") != SCHEMA_VERSION:
                errors.append(f"{relative}: input schema_version is unsupported")
            if input_value.get("request_id") != request_id:
                errors.append(f"{relative}: input request_id does not match")
            if input_value.get("article_id") != article_id:
                errors.append(f"{relative}: input article_id does not match")
            if input_value.get("source_card_ids") != selected:
                errors.append(f"{relative}: input source_card_ids do not match")
            if input_value.get("approved_for_article") != approved:
                errors.append(f"{relative}: input approved_for_article does not match")
            for field, maximum in (("topic", 300), ("purpose", 1000)):
                value = input_value.get(field)
                if not isinstance(value, str) or not value.strip() or len(value) > maximum:
                    errors.append(f"{relative}: input {field} is invalid")
            missing = input_value.get("missing_information")
            if (
                not isinstance(missing, list)
                or len(missing) > 100
                or any(
                    not isinstance(value, str)
                    or not value.strip()
                    or len(value) > 4000
                    for value in missing
                )
                or len(missing) != len(set(value for value in missing if isinstance(value, str)))
            ):
                errors.append(f"{relative}: input missing_information is invalid")

        source_refs = metadata.get("source_cards")
        refs_valid = isinstance(source_refs, list) and len(source_refs) == len(selected_strings)
        referenced_cards: Dict[str, dict] = {}
        if not refs_valid:
            errors.append(f"{relative}: source_cards metadata does not match selection")
            source_refs = []
        for index, reference in enumerate(source_refs):
            if not isinstance(reference, dict) or set(reference) != {
                "source_card_id",
                "revision",
                "event_sha256",
            }:
                errors.append(f"{relative}: source_cards[{index}] is invalid")
                refs_valid = False
                continue
            card_id = reference.get("source_card_id")
            revision = reference.get("revision")
            event_hash = reference.get("event_sha256")
            if index >= len(selected_strings) or card_id != selected_strings[index]:
                errors.append(f"{relative}: source_cards order does not match selection")
                refs_valid = False
                continue
            if isinstance(revision, bool) or not isinstance(revision, int) or revision < 1:
                errors.append(f"{relative}: source card revision is invalid")
                refs_valid = False
                continue
            if not isinstance(event_hash, str) or not SHA256_PATTERN.fullmatch(event_hash):
                errors.append(f"{relative}: source card event_sha256 is invalid")
                refs_valid = False
                continue
            card = cards_by_revision.get((card_id, revision))
            if card is None:
                errors.append(f"{relative}: referenced source card revision is missing")
                refs_valid = False
                continue
            if canonical_hash(card) != event_hash:
                errors.append(f"{relative}: source card event_sha256 mismatch")
                refs_valid = False
                continue
            if card.get("status") != "active":
                errors.append(f"{relative}: referenced source card revision is archived")
                refs_valid = False
                continue
            if created_moment is not None:
                eligible_revisions = []
                for candidate in events:
                    if candidate.get("source_card_id") != card_id:
                        continue
                    try:
                        _, updated_moment = parse_timestamp(candidate.get("updated_at"))
                    except (SourceDataError, AttributeError):
                        continue
                    candidate_revision = candidate.get("revision")
                    if (
                        updated_moment <= created_moment
                        and isinstance(candidate_revision, int)
                        and not isinstance(candidate_revision, bool)
                    ):
                        eligible_revisions.append(candidate_revision)
                if not eligible_revisions or revision != max(eligible_revisions):
                    errors.append(
                        f"{relative}: referenced source card was not the latest revision at creation"
                    )
                    refs_valid = False
                    continue
            referenced_cards[card_id] = card

        for card_id in selected:
            if not isinstance(card_id, str) or not CARD_ID_PATTERN.fullmatch(card_id):
                errors.append(f"{relative}: invalid source card ID")
        if input_valid and refs_valid and len(referenced_cards) == len(selected_strings):
            expected_payload_hash = canonical_hash(
                {"input": input_value, "source_cards": source_refs}
            )
            if metadata.get("payload_sha256") != expected_payload_hash:
                errors.append(f"{relative}: payload_sha256 mismatch")
            try:
                expected_content, rendered_payload_hash = render_context_pack(
                    input_value,
                    referenced_cards,
                    metadata.get("created_at"),
                )
                _, expected_body = expected_content.split("\n\n", 1)
                if rendered_payload_hash != expected_payload_hash:
                    errors.append(f"{relative}: rendered payload hash mismatch")
                if body != expected_body:
                    errors.append(f"{relative}: body does not match recorded inputs")
            except (KeyError, TypeError, ValueError) as exc:
                errors.append(f"{relative}: recorded inputs cannot rebuild body: {exc}")
    return errors


def main(argv: Optional[list[str]] = None) -> int:
    args = _parser().parse_args(argv)
    try:
        workspace = resolve_workspace(args.workspace)
        validated_at, _ = parse_timestamp(None)
        with exclusive_locks(
            [
                workspace / "primary-log/.append.lock",
                workspace / "source-cards/.cards.lock",
                workspace / "context-packs/.context-pack.lock",
            ],
            validated_at,
        ):
            logs = load_log_records(workspace)
            events = load_card_events(workspace)
            cards = current_cards(events)
            errors = validate_log_records(logs)
            errors += validate_card_events(events, logs)
            errors += _validate_context_packs(workspace, events)
        payload = {
            "status": "pass" if not errors else "fail",
            "counts": {
                "log_records": len(logs),
                "card_events": len(events),
                "current_cards": len(cards),
                "context_packs": len(list((workspace / "context-packs").glob("*.md"))),
            },
            "errors": errors,
        }
        print(json.dumps(payload, ensure_ascii=False, indent=2))
        return 0 if not errors else 1
    except (OSError, ValueError, json.JSONDecodeError) as exc:
        print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 396c08ee5f9ad9e485198d8fa506c6f00c90262904df8056551c61b3080a1296