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)