← Files NovelistARCHIVED FILE

skills/novel-story-telling/scripts/check_continuity.py

18.1 KB · Oct 4, 2026 · 12:29 UTC

↓ Download file

#!/usr/bin/env python3
"""Run deterministic structural continuity checks over the novel sources."""

from __future__ import annotations

import argparse
import re
import sys
from collections import Counter
from datetime import date
from pathlib import Path
from typing import Any

from story_io import ID_PREFIXES, LINK_FIELDS, as_list, dump_json, load_records, split_h2_sections


REQUIRED = {"id", "type", "status", "tags", "created", "updated"}
VALID_TYPES = {
    "project",
    "documentation",
    "character",
    "material",
    "macguffin",
    "plot",
    "outline",
    "world",
    "style_guide",
    "chapter",
    "publishing_note",
}
VALID_STATUSES = {"seed", "outline", "draft", "revision", "final", "archived"}
CHAPTER_NAME = re.compile(r"^(\d{3})\.([a-z0-9]+(?:-[a-z0-9]+)*)\.md$")
DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
CHAPTER_REQUIRED = {
    "id",
    "type",
    "number",
    "title",
    "slug",
    "status",
    "pov",
    "timeline",
    "setting",
    "word_target",
    "characters",
    "materials",
    "macguffins",
    "plot_threads",
    "outline",
    "published",
    "created",
    "updated",
    "tags",
}
CHAPTER_LIST_FIELDS = ("characters", "materials", "macguffins", "plot_threads", "tags")
CHAPTER_STRING_FIELDS = ("id", "type", "title", "slug", "status", "pov", "timeline", "setting", "outline")
CHAPTER_SECTIONS = ("Synopsis", "Draft", "Revision Notes")
TEMPLATE_PLACEHOLDERS = (
    "Copy this template to a filename like",
    "One or two paragraphs describing what changes in this chapter.",
    "Write the chapter prose here.",
)
LEGACY_SCENE_BREAK = re.compile(
    r"^(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,})$"
)
DIALOGUE_SPAN = re.compile(r"(?<!\*)\*“([^“”]+)”\*(?!\*)")
DIALOGUE_ONLY_PARAGRAPH = re.compile(r"^\*“[^“”]+”\*$", re.DOTALL)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--project-root", type=Path, default=Path.cwd())
    parser.add_argument("--format", choices=("markdown", "json"), default="markdown")
    parser.add_argument("--strict", action="store_true", help="Treat warnings as a failing exit status")
    return parser.parse_args()


def issue(level: str, code: str, path: str, message: str) -> dict[str, str]:
    return {"level": level, "code": code, "source": path, "message": message}


def draft_markup_issues(draft: str) -> list[tuple[str, int, str]]:
    """Return deterministic Markdown conflicts inside publishable prose."""
    findings: list[tuple[str, int, str]] = []
    lines = draft.splitlines()

    offset = 0
    dialogue_only_run: list[int] = []
    for paragraph in re.split(r"\n[ \t]*\n", draft):
        line_number = draft[:offset].count("\n") + 1
        offset += len(paragraph) + 2
        stripped_paragraph = paragraph.strip()
        if not stripped_paragraph:
            continue
        if DIALOGUE_ONLY_PARAGRAPH.fullmatch(stripped_paragraph):
            dialogue_only_run.append(line_number)
            if len(dialogue_only_run) == 5:
                findings.append(
                    (
                        "chapter-dialogue-attribution",
                        dialogue_only_run[0],
                        "Five consecutive dialogue-only paragraphs require a speaker anchor, meaningful action, or narration beat before the fifth paragraph.",
                    )
                )
        else:
            dialogue_only_run.clear()
        visible = re.sub(r"`[^`\n]*`", "", stripped_paragraph)
        visible = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", visible)
        if "“" in visible or "”" in visible:
            unclassified = DIALOGUE_SPAN.sub("", visible)
            if "“" in unclassified or "”" in unclassified:
                findings.append(
                    (
                        "chapter-dialogue-format",
                        line_number,
                        "Wrap each spoken range as *“…”*; narration may remain in the same Markdown paragraph.",
                    )
                )
        if re.search(r'"[^"\n]+"', visible):
            findings.append(
                (
                    "chapter-dialogue-quotes",
                    line_number,
                    "Use curly double quotes for spoken dialogue and curly single quotes for mentioned text.",
                )
            )

    for index, line in enumerate(lines):
        line_number = index + 1
        stripped = line.strip()
        if not stripped:
            continue
        if LEGACY_SCENE_BREAK.fullmatch(stripped) and stripped != "---":
            findings.append(
                (
                    "chapter-scene-break",
                    line_number,
                    "Use a standalone --- for a scene break; spaced asterisks can render as a list.",
                )
            )
            continue
        if stripped == "---":
            before_blank = index == 0 or not lines[index - 1].strip()
            after_blank = index == len(lines) - 1 or not lines[index + 1].strip()
            if not (before_blank and after_blank):
                findings.append(
                    (
                        "chapter-scene-break-spacing",
                        line_number,
                        "Put a blank line before and after a standalone --- scene break.",
                    )
                )
            continue
        if re.match(r"^(?:[-+*]|[0-9]+\.)[ \t]+", stripped):
            findings.append(
                (
                    "chapter-prose-list",
                    line_number,
                    "Do not use list markers to format narration or dialogue in Draft prose.",
                )
            )
        if stripped.startswith(">"):
            findings.append(
                (
                    "chapter-prose-blockquote",
                    line_number,
                    "Do not use Markdown blockquotes to format spoken dialogue.",
                )
            )
        if re.match(r"^(`{3,}|~{3,})", stripped):
            findings.append(
                (
                    "chapter-prose-code-block",
                    line_number,
                    "Use inline backticks for machine, UI, or log literals; fenced blocks are not prose.",
                )
            )

        unescaped = re.sub(r"\\.", "", stripped)
        if unescaped.count("`") % 2:
            findings.append(
                (
                    "chapter-inline-code",
                    line_number,
                    "Inline backtick markers must be balanced on the same line.",
                )
            )
        without_code = re.sub(r"`[^`]*`", "", unescaped)
        if without_code.count("**") % 2:
            findings.append(
                (
                    "chapter-strong-emphasis",
                    line_number,
                    "Strong-emphasis markers must be balanced on the same line.",
                )
            )
        without_strong = without_code.replace("**", "")
        if without_strong.count("*") % 2:
            findings.append(
                (
                    "chapter-italics",
                    line_number,
                    "Italic markers must be balanced on the same line.",
                )
            )
    return findings


def audit(root: Path) -> dict[str, Any]:
    records = load_records(root)
    issues: list[dict[str, str]] = []
    chapters_dir = root / "chapters"
    if chapters_dir.is_dir():
        for path in sorted(chapters_dir.glob("**/*.md")):
            if path.parent != chapters_dir:
                issues.append(
                    issue(
                        "error",
                        "nested-chapter",
                        path.relative_to(root).as_posix(),
                        "Chapter Markdown files must live directly under chapters/.",
                    )
                )
    ids = [record.identifier for record in records if record.identifier]
    id_set = set(ids)
    for identifier, count in Counter(ids).items():
        if count > 1:
            paths = [record.relpath for record in records if record.identifier == identifier]
            issues.append(issue("error", "duplicate-id", ", ".join(paths), f"ID {identifier!r} appears {count} times."))

    chapter_numbers: list[int] = []
    for record in records:
        meta = record.metadata
        missing = sorted(REQUIRED - set(meta))
        if not (meta.get("title") or meta.get("name")):
            missing.append("title-or-name")
        if missing:
            issues.append(issue("error", "missing-frontmatter", record.relpath, f"Missing: {', '.join(missing)}."))
        if meta.get("type") not in VALID_TYPES:
            issues.append(issue("error", "invalid-type", record.relpath, f"Invalid type: {meta.get('type')!r}."))
        if meta.get("status") not in VALID_STATUSES:
            issues.append(issue("error", "invalid-status", record.relpath, f"Invalid status: {meta.get('status')!r}."))
        for date_field in ("created", "updated"):
            if date_field in meta and not DATE.fullmatch(str(meta[date_field])):
                issues.append(issue("error", "invalid-date", record.relpath, f"{date_field} must use YYYY-MM-DD."))
            elif date_field in meta:
                try:
                    date.fromisoformat(str(meta[date_field]))
                except ValueError:
                    issues.append(issue("error", "invalid-date", record.relpath, f"{date_field} is not a real date."))

        is_chapter_path = record.path.parent.name == "chapters"
        if is_chapter_path and record.kind != "chapter":
            issues.append(issue("error", "chapter-type", record.relpath, "Files in chapters/ must use type: chapter."))

        if is_chapter_path:
            chapter_missing = sorted(CHAPTER_REQUIRED - set(meta))
            if chapter_missing:
                issues.append(
                    issue("error", "chapter-frontmatter", record.relpath, f"Missing: {', '.join(chapter_missing)}.")
                )
            match = CHAPTER_NAME.fullmatch(record.path.name)
            if not match:
                issues.append(issue("error", "chapter-filename", record.relpath, "Filename does not match NNN.ascii-slug.md."))
            else:
                number = int(match.group(1))
                slug = match.group(2)
                chapter_numbers.append(number)
                if record.chapter_number != number:
                    issues.append(issue("error", "chapter-number", record.relpath, "Frontmatter number does not match filename."))
                if meta.get("id") != f"chapter-{number:03d}":
                    issues.append(issue("error", "chapter-id", record.relpath, f"Expected id chapter-{number:03d}."))
                if meta.get("slug") != slug:
                    issues.append(issue("error", "chapter-slug", record.relpath, f"Expected slug {slug!r}."))

            for field in CHAPTER_LIST_FIELDS:
                if field in meta and not isinstance(meta[field], list):
                    issues.append(issue("error", "chapter-list-field", record.relpath, f"{field} must be a YAML array."))
            for field in CHAPTER_STRING_FIELDS:
                if field in meta and not isinstance(meta[field], str):
                    issues.append(issue("error", "chapter-string-field", record.relpath, f"{field} must be a string."))
            chapter_number = meta.get("number")
            if "number" in meta and (
                isinstance(chapter_number, bool) or not isinstance(chapter_number, int) or not 1 <= chapter_number <= 999
            ):
                issues.append(issue("error", "chapter-number-type", record.relpath, "number must be an integer from 1 through 999."))
            word_target = meta.get("word_target")
            if "word_target" in meta and (
                isinstance(word_target, bool) or not isinstance(word_target, int) or word_target <= 0
            ):
                issues.append(issue("error", "chapter-word-target", record.relpath, "word_target must be a positive integer."))
            if "published" in meta and not isinstance(meta.get("published"), bool):
                issues.append(issue("error", "chapter-published", record.relpath, "published must be a boolean."))

            headings = re.findall(r"^(#{1,6})\s+(.+?)\s*$", record.body, flags=re.MULTILINE)
            h1_titles = [title for level, title in headings if level == "#"]
            if len(h1_titles) != 1:
                issues.append(issue("error", "chapter-h1", record.relpath, "Chapter body must contain exactly one H1."))
            elif h1_titles[0] != str(meta.get("title", "")):
                issues.append(issue("error", "chapter-title", record.relpath, "H1 must exactly match frontmatter title."))
            first_line = record.body.splitlines()[0].strip() if record.body else ""
            if first_line != f"# {meta.get('title', '')}":
                issues.append(issue("error", "chapter-first-heading", record.relpath, "The first body line must be the title H1."))
            h2_sections = split_h2_sections(record.body)
            h2_titles = [title for title, _content in h2_sections]
            for section in CHAPTER_SECTIONS:
                if h2_titles.count(section) != 1:
                    issues.append(
                        issue("error", "chapter-section", record.relpath, f"Chapter must contain exactly one ## {section} section.")
                    )
            if h2_titles != list(CHAPTER_SECTIONS):
                issues.append(
                    issue(
                        "error",
                        "chapter-section-layout",
                        record.relpath,
                        "Chapter H2 sections must be exactly Synopsis, Draft, then Revision Notes; use H3 through H6 for manuscript subheadings.",
                    )
                )
            if all(h2_titles.count(section) == 1 for section in CHAPTER_SECTIONS):
                positions = [h2_titles.index(section) for section in CHAPTER_SECTIONS]
                if positions != sorted(positions):
                    issues.append(
                        issue(
                            "error",
                            "chapter-section-order",
                            record.relpath,
                            "Required sections must appear as Synopsis, Draft, then Revision Notes.",
                        )
                    )
            section_content = {
                title: content
                for title, content in h2_sections
                if h2_titles.count(title) == 1
            }
            if meta.get("status") in {"draft", "revision", "final"} and not section_content.get("Synopsis", "").strip():
                issues.append(issue("error", "chapter-synopsis", record.relpath, "Synopsis section must not be empty."))
            draft = section_content.get("Draft", "")
            if meta.get("status") in {"draft", "revision", "final"} and not draft.strip():
                issues.append(issue("error", "chapter-draft", record.relpath, "Draft section must not be empty."))
            for code, line_number, message in draft_markup_issues(draft):
                issues.append(
                    issue("error", code, record.relpath, f"Draft line {line_number}: {message}")
                )
            for placeholder in TEMPLATE_PLACEHOLDERS:
                if placeholder in record.body:
                    issues.append(
                        issue("error", "chapter-placeholder", record.relpath, f"Remove template placeholder: {placeholder!r}.")
                    )

        for field in LINK_FIELDS:
            for value in as_list(meta.get(field)):
                reference = str(value).strip()
                if reference.startswith(ID_PREFIXES) and reference not in id_set:
                    issues.append(issue("warning", "dangling-reference", record.relpath, f"{field} references missing ID {reference!r}."))

    if chapter_numbers:
        for number, count in Counter(chapter_numbers).items():
            if count > 1:
                issues.append(
                    issue("error", "duplicate-chapter-number", "chapters/", f"Chapter number {number:03d} appears {count} times.")
                )
        ordered = sorted(set(chapter_numbers))
        expected = set(range(ordered[0], ordered[-1] + 1))
        for number in sorted(expected - set(ordered)):
            issues.append(issue("warning", "chapter-gap", "chapters/", f"Chapter {number:03d} is missing."))

    return {
        "project_root": str(root),
        "files_checked": len(records),
        "errors": sum(item["level"] == "error" for item in issues),
        "warnings": sum(item["level"] == "warning" for item in issues),
        "issues": issues,
        "semantic_review": [
            "Compare the proposed chapter against every linked source's Continuity section.",
            "Verify character knowledge, possessions, injuries, relationships, and location at scene entry and exit.",
            "Verify setup, clue, secret, MacGuffin, and plot-thread states before changing or paying them off.",
            "Verify timeline order, elapsed time, travel constraints, and world-rule costs.",
            "Treat silence as uncertainty; do not convert absent evidence into canon.",
        ],
    }


def render_markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Continuity Audit",
        "",
        f"- Files checked: {report['files_checked']}",
        f"- Errors: {report['errors']}",
        f"- Warnings: {report['warnings']}",
        "",
        "## Structural Findings",
        "",
    ]
    if report["issues"]:
        for item in report["issues"]:
            lines.append(f"- [{item['level'].upper()}] `{item['code']}` in `{item['source']}`: {item['message']}")
    else:
        lines.append("- No structural continuity issues found.")
    lines.extend(["", "## Semantic Review Gate", ""])
    lines.extend(f"- {entry}" for entry in report["semantic_review"])
    return "\n".join(lines) + "\n"


def main() -> int:
    args = parse_args()
    report = audit(args.project_root.resolve())
    output = dump_json(report) + "\n" if args.format == "json" else render_markdown(report)
    sys.stdout.write(output)
    if report["errors"] or (args.strict and report["warnings"]):
        return 1
    return 0


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

SHA-256: b32c88da50a0de7ea3264a2192df11132cf0af604fa6b25c0b0e81f328413e5d