jsonhtl_lint.py

Python module implementing the JSONHTL linter. Primary entry point: validate(doc) returns a list of issue strings; empty list means valid. Lives at ~/jsonhtl_lint.py on the DAP AWS instance.

Spec: gdata-server/linter. Schema: JSONHTL_SCHEMA. CLI: gdata-server/linter/cli.

The schema is embedded in SCHEMA and must be kept in sync with the JSONHTL_SCHEMA note. The CLI can pass an override schema fetched from the store at runtime.

"""
jsonhtl_lint.py -- JSONHTL document validator.

Primary entry point: validate(doc) -> list[str]
Returns a list of issue strings. Empty list means the document is valid.

The schema is embedded here and must be kept in sync with the JSONHTL_SCHEMA
note in the notes store. The CLI (lint_jsonhtl.py) can pass an override schema
fetched from the store at runtime.

Spec: gdata-server/linter  Schema: JSONHTL_SCHEMA
"""

import re
import jsonschema

SPEC_REF = (
    "Where did you get this syntax from? "
    "The authoritative spec is JSONHTL_SCHEMA (note key) and gdata-server/linter. "
    "Update your source of information before writing further notes."
)

KNOWN_BLOCKS = {"para", "heading", "codeblock", "list", "table"}
KNOWN_INLINE = {"link", "code", "bold"}
KNOWN_TOP_LEVEL = {"title", "version", "updated", "created", "tags", "runnable", "content"}
CODEBLOCK_FIELD_ALIASES = {"text": "body", "language": "lang"}
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")

SCHEMA = {
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "JSONHTL Document",
    "type": "object",
    "required": ["content"],
    "additionalProperties": True,
    "properties": {
        "title":    {"type": "string"},
        "version":  {"type": "integer"},
        "updated":  {"type": "string", "pattern": r"^\d{4}-\d{2}-\d{2}$"},
        "created":  {"type": "string", "pattern": r"^\d{4}-\d{2}-\d{2}$"},
        "tags":     {"type": "array", "items": {"type": "string"}},
        "runnable": {"type": "boolean"},
        "content": {
            "oneOf": [
                {"type": "string"},
                {"type": "array", "items": {"$ref": "#/definitions/block"}},
            ]
        },
    },
    "definitions": {
        "inline_link": {
            "type": "object", "required": ["link"], "additionalProperties": False,
            "properties": {
                "link": {
                    "type": "object", "required": ["href", "text"], "additionalProperties": False,
                    "properties": {"href": {"type": "string"}, "text": {"type": "string"}},
                }
            },
        },
        "inline_code": {
            "type": "object", "required": ["code"], "additionalProperties": False,
            "properties": {"code": {"type": "string"}},
        },
        "inline_bold": {
            "type": "object", "required": ["bold"], "additionalProperties": False,
            "properties": {"bold": {"type": "string"}},
        },
        "inline_element": {
            "oneOf": [
                {"type": "string"},
                {"$ref": "#/definitions/inline_link"},
                {"$ref": "#/definitions/inline_code"},
                {"$ref": "#/definitions/inline_bold"},
            ]
        },
        "para_content": {
            "oneOf": [
                {"type": "string"},
                {"type": "array", "items": {"$ref": "#/definitions/inline_element"}},
            ]
        },
        "block_para": {
            "type": "object", "required": ["para"], "additionalProperties": False,
            "properties": {"para": {"$ref": "#/definitions/para_content"}},
        },
        "block_heading": {
            "type": "object", "required": ["heading"], "additionalProperties": False,
            "properties": {
                "heading": {
                    "type": "object", "required": ["level", "text"], "additionalProperties": False,
                    "properties": {
                        "level": {"type": "integer", "minimum": 1, "maximum": 6},
                        "text":  {"type": "string"},
                    },
                }
            },
        },
        "block_codeblock": {
            "type": "object", "required": ["codeblock"], "additionalProperties": False,
            "properties": {
                "codeblock": {
                    "type": "object", "required": ["lang", "body"], "additionalProperties": False,
                    "properties": {
                        "lang": {"type": "string"},
                        "body": {"type": "string"},
                        "exec": {"type": "boolean"},
                        "name": {"type": "string"},
                    },
                }
            },
        },
        "block_list": {
            "type": "object", "required": ["list"], "additionalProperties": False,
            "properties": {
                "list": {
                    "type": "object", "required": ["items"], "additionalProperties": False,
                    "properties": {
                        "label":   {"type": "string"},
                        "ordered": {"type": "boolean"},
                        "items":   {"type": "array"},
                    },
                }
            },
        },
        "block_table": {
            "type": "object", "required": ["table"], "additionalProperties": False,
            "properties": {
                "table": {
                    "type": "object", "required": ["columns", "rows"], "additionalProperties": False,
                    "properties": {
                        "columns": {"type": "array", "items": {"type": "string"}},
                        "rows":    {"type": "array", "items": {"type": "array"}},
                    },
                }
            },
        },
        "block": {
            "oneOf": [
                {"$ref": "#/definitions/block_para"},
                {"$ref": "#/definitions/block_heading"},
                {"$ref": "#/definitions/block_codeblock"},
                {"$ref": "#/definitions/block_list"},
                {"$ref": "#/definitions/block_table"},
            ]
        },
    },
}


def validate(doc, schema=None):
    if not isinstance(doc, dict):
        return ["ERROR: document is not a JSON object"]
    issues = []
    _check_top_level(doc, issues)
    _check_content(doc, issues)
    _check_schema(doc, schema or SCHEMA, issues)
    return issues


def _check_top_level(doc, issues):
    for key in doc:
        if key not in KNOWN_TOP_LEVEL:
            issues.append(
                f"WARNING top-level key '{key}' is not a recognised metadata field. "
                f"Known keys: {', '.join(sorted(KNOWN_TOP_LEVEL))}. "
                f"See JSONHTL_SCHEMA."
            )
    v = doc.get("version")
    if v is not None and not isinstance(v, int):
        issues.append(f"ERROR 'version' must be an integer, got {type(v).__name__}.")
    for date_field in ("updated", "created"):
        val = doc.get(date_field)
        if val is not None and not DATE_RE.match(str(val)):
            issues.append(
                f"ERROR '{date_field}' must be a date in YYYY-MM-DD format, got '{val}'."
            )


def _check_content(doc, issues):
    content = doc.get("content")
    if content is None:
        return
    if isinstance(content, str):
        issues.append(
            "WARNING 'content' is a bare string (shorthand). "
            "Canonical form is a list of block objects. See JSONHTL_SPEC."
        )
        return
    if not isinstance(content, list):
        issues.append("ERROR 'content' must be a string or a list of blocks.")
        return
    runnable = doc.get("runnable", False)
    names_seen = set()
    for i, block in enumerate(content):
        _check_block(block, i, runnable, names_seen, issues)


def _check_block(block, idx, runnable, names_seen, issues):
    loc = f"block {idx}"
    if not isinstance(block, dict):
        issues.append(f"ERROR {loc}: block must be a JSON object, got {type(block).__name__}.")
        return
    keys = set(block.keys())
    if "codeblock" in keys or any(a in keys for a in CODEBLOCK_FIELD_ALIASES):
        _check_codeblock_aliases(block, loc, runnable, names_seen, issues)
    block_type = keys & KNOWN_BLOCKS
    unknown = keys - KNOWN_BLOCKS
    if not block_type and not (keys & set(CODEBLOCK_FIELD_ALIASES)):
        for uk in unknown:
            issues.append(
                f"ERROR {loc}: unknown block type '{uk}'. "
                f"Valid types are: {', '.join(sorted(KNOWN_BLOCKS))}. "
                f"{SPEC_REF}"
            )
        return
    if len(block_type) > 1:
        issues.append(f"ERROR {loc}: block has multiple type keys: {block_type}.")
        return
    if unknown - set(CODEBLOCK_FIELD_ALIASES):
        for uk in unknown - set(CODEBLOCK_FIELD_ALIASES):
            issues.append(f"ERROR {loc}: unexpected extra key '{uk}' alongside block type.")
    if "para" in block_type:
        _check_para(block["para"], loc, issues)
    elif "heading" in block_type:
        _check_heading(block["heading"], loc, issues)
    elif "table" in block_type:
        _check_table(block["table"], loc, issues)


def _check_codeblock_aliases(block, loc, runnable, names_seen, issues):
    cb = block.get("codeblock") or {}
    if not isinstance(cb, dict):
        return
    for alias, correct in CODEBLOCK_FIELD_ALIASES.items():
        if alias in cb:
            issues.append(
                f"ERROR {loc} (codeblock): field '{alias}' is not valid -- "
                f"the correct field name is '{correct}'. {SPEC_REF}"
            )
    if not runnable:
        if "exec" in cb:
            issues.append(
                f"ERROR {loc} (codeblock): field 'exec' is only valid when "
                f"the document has 'runnable: true' at the top level."
            )
        if "name" in cb:
            issues.append(
                f"ERROR {loc} (codeblock): field 'name' is only valid when "
                f"the document has 'runnable: true' at the top level."
            )
    if "name" in cb and isinstance(cb["name"], str):
        name = cb["name"]
        if name in names_seen:
            issues.append(
                f"ERROR {loc} (codeblock): duplicate cell name '{name}'. "
                f"Cell names must be unique within the document."
            )
        names_seen.add(name)


def _check_para(value, loc, issues):
    if isinstance(value, str):
        issues.append(
            f"WARNING {loc} (para): value is a bare string (shorthand). "
            f"Canonical form is a list of inline elements. See JSONHTL_SPEC."
        )
        return
    if not isinstance(value, list):
        issues.append(f"ERROR {loc} (para): value must be a string or list.")
        return
    for j, item in enumerate(value):
        _check_inline(item, f"{loc} para[{j}]", issues)


def _check_inline(item, loc, issues):
    if isinstance(item, str):
        return
    if not isinstance(item, dict):
        issues.append(f"ERROR {loc}: inline element must be a string or object.")
        return
    keys = set(item.keys())
    if "link" in keys:
        lnk = item["link"]
        if not isinstance(lnk, dict):
            issues.append(f"ERROR {loc} (link): value must be an object.")
        else:
            for required in ("href", "text"):
                if required not in lnk:
                    issues.append(f"ERROR {loc} (link): missing required field '{required}'.")
            for k in lnk:
                if k not in ("href", "text"):
                    issues.append(f"ERROR {loc} (link): unexpected field '{k}'.")
    elif "code" in keys:
        if not isinstance(item["code"], str):
            issues.append(f"ERROR {loc} (code): value must be a string.")
    elif "bold" in keys:
        if not isinstance(item["bold"], str):
            issues.append(f"ERROR {loc} (bold): value must be a string.")
    else:
        issues.append(
            f"ERROR {loc}: unknown inline element type (keys: {sorted(keys)}). "
            f"Valid types: string, link, code, bold. {SPEC_REF}"
        )


def _check_heading(value, loc, issues):
    if not isinstance(value, dict):
        issues.append(f"ERROR {loc} (heading): value must be an object.")
        return
    if "level" not in value:
        issues.append(f"ERROR {loc} (heading): missing required field 'level'.")
    elif not isinstance(value["level"], int) or not (1 <= value["level"] <= 6):
        issues.append(f"ERROR {loc} (heading): 'level' must be an integer 1-6.")
    if "text" not in value:
        issues.append(f"ERROR {loc} (heading): missing required field 'text'.")
    elif not isinstance(value["text"], str):
        issues.append(f"ERROR {loc} (heading): 'text' must be a string.")
    for k in value:
        if k not in ("level", "text"):
            issues.append(f"ERROR {loc} (heading): unexpected field '{k}'.")


def _check_table(value, loc, issues):
    if not isinstance(value, dict):
        issues.append(f"ERROR {loc} (table): value must be an object.")
        return
    cols = value.get("columns", [])
    rows = value.get("rows", [])
    if not isinstance(cols, list):
        issues.append(f"ERROR {loc} (table): 'columns' must be a list.")
        return
    n = len(cols)
    if not isinstance(rows, list):
        issues.append(f"ERROR {loc} (table): 'rows' must be a list.")
        return
    for r, row in enumerate(rows):
        if not isinstance(row, list):
            issues.append(f"ERROR {loc} (table): row {r} must be a list.")
        elif len(row) != n:
            issues.append(
                f"ERROR {loc} (table): row {r} has {len(row)} cells but "
                f"there are {n} columns."
            )


def _check_schema(doc, schema, issues):
    validator = jsonschema.Draft7Validator(schema)
    already_reported = set()
    for error in validator.iter_errors(doc):
        path = " > ".join(str(p) for p in error.absolute_path) or "document"
        msg = f"SCHEMA {path}: {error.message}"
        if msg not in already_reported:
            if "is not valid under any of the given schemas" not in error.message:
                issues.append(msg)
                already_reported.add(msg)
version 1  ·  updated 2026-07-06