Files
ai-resume/tools/validate_content.py
Sam Rolfe 0eb151117e Phase 1a: content schema validator (freeze the schema)
Implements AGENTS.md section 7 front-matter schema as a single-file,
standard-library-only Python validator; no third-party YAML dependency
and no framework. Adds the `make validate` gate, a 20-case fixture set
and docs/content-schema.md.

DoD satisfied:
- make validate runs and exits 0 on an empty/clean content/ tree
  (missing dir and empty dir both exit 0; fixture ok-empty covers it).
- Every violation class has a fixture under test/fixtures/fail-*; each
  exits non-zero with one line per violation (file:line: field: rule).
  `make test` runs all 20 cases: 20/20 pass.
- Dangling artefact reference is fatal (fail-dangling-artefact;
  artefacts[].url must resolve to an existing in-repo L3 stub or asset).
- Unknown front-matter field is fatal (fail-unknown-field), including
  unknown keys nested inside artefacts[]/metrics[].
- docs/content-schema.md committed; states the frozen schema, links to
  AGENTS.md section 7, and records that any later schema change is a
  breaking change requiring Sam's approval.
- Scope respected: no content authored, no rendering, no site code.

Files: Makefile, tools/validate_content.py, test/fixtures/**,
docs/content-schema.md, .gitignore (__pycache__).
2026-10-26 17:26:00 +11:00

802 lines
30 KiB
Python

#!/usr/bin/env python3
"""Validate content/** front-matter against the frozen schema (AGENTS.md section 7).
Single file, Python standard library only. No PyYAML, no framework, no other
dependency: the front-matter dialect is a deliberately small YAML subset and the
parser below is part of this file. See docs/content-schema.md for the schema.
Exit codes
0 clean tree (or nothing to validate)
1 at least one violation
2 usage / internal error
Usage
python3 tools/validate_content.py [--root DIR] [--content DIR] [--quiet]
python3 tools/validate_content.py --self-test
`make validate` is the supported entry point; CI and deploy.sh call the same
command.
"""
from __future__ import annotations
import argparse
import datetime
import os
import re
import sys
# ---------------------------------------------------------------------------
# Frozen schema (AGENTS.md section 7). Changing anything here is a breaking
# change to every file in content/ and requires Sam's written approval.
# ---------------------------------------------------------------------------
AREAS = ("ai", "platform", "edge", "products", "history", "about")
LEVELS = (1, 2, 3)
CONFIDENCE = ("delivered", "running", "in-progress", "abandoned", "historic")
ARTEFACT_TYPES = ("repo", "image", "video", "doc")
REQUIRED_FIELDS = (
"id",
"area",
"level",
"title",
"claim",
"confidence",
"period",
"tech",
"metrics",
"artefacts",
"public",
"last_verified",
"tags",
)
ALLOWED_FIELDS = frozenset(REQUIRED_FIELDS)
METRIC_FIELDS = ("label", "value")
ARTEFACT_FIELDS = ("type", "label", "url")
ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# ---------------------------------------------------------------------------
# Minimal YAML-subset parser for front-matter
#
# Supported: block mappings, block sequences, flow sequences/mappings,
# single and double quoted scalars, plain scalars, literal (|) and folded (>)
# block scalars, comments. Not supported (and not needed by the schema):
# anchors, aliases, tags, multi-document streams, complex keys.
# ---------------------------------------------------------------------------
class YamlError(Exception):
def __init__(self, line: int, msg: str) -> None:
self.line = line
self.msg = msg
super().__init__(f"line {line}: {msg}")
class Node:
"""A parsed front-matter node. `line` is the 1-based file line."""
__slots__ = ("kind", "value", "line", "keylines")
def __init__(self, kind, value, line, keylines=None):
self.kind = kind # "map" | "seq" | "scalar" | "null"
self.value = value
self.line = line
# NB: an empty dict is falsy, so it must not be replaced by `or`.
self.keylines = {} if keylines is None else keylines
def line_of(self, key):
return self.keylines.get(key) or self.line or 1
def _is_seq_item(text: str) -> bool:
stripped = text.strip()
return stripped == "-" or stripped.startswith("- ")
def _strip_comment(text: str) -> str:
"""Remove a trailing '#' comment that is not inside a quoted scalar."""
out = []
quote = None
i = 0
while i < len(text):
ch = text[i]
if quote:
if quote == '"' and ch == "\\":
out.append(ch)
i += 1
if i < len(text):
out.append(text[i])
i += 1
continue
if ch == quote:
if quote == "'" and i + 1 < len(text) and text[i + 1] == "'":
out.append("''")
i += 2
continue
quote = None
out.append(ch)
else:
if ch in "\"'" and (i == 0 or text[i - 1] in " \t:[{,-"):
quote = ch
out.append(ch)
elif ch == "#" and (i == 0 or text[i - 1] in " \t"):
break
else:
out.append(ch)
i += 1
return "".join(out).rstrip()
def _skip_ws(s: str, i: int) -> int:
while i < len(s) and s[i] in " \t":
i += 1
return i
_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "0": "\0", '"': '"', "\\": "\\", "/": "/"}
def _read_quoted(s: str, i: int, lineno: int):
quote = s[i]
j = i + 1
buf = []
while j < len(s):
ch = s[j]
if quote == '"' and ch == "\\":
if j + 1 >= len(s):
raise YamlError(lineno, "unterminated escape in double-quoted string")
buf.append(_ESCAPES.get(s[j + 1], s[j + 1]))
j += 2
continue
if ch == quote:
if quote == "'" and j + 1 < len(s) and s[j + 1] == "'":
buf.append("'")
j += 2
continue
return Node("scalar", "".join(buf), lineno), j + 1
buf.append(ch)
j += 1
raise YamlError(lineno, "unterminated quoted string")
def _flow_scalar(s: str, i: int, lineno: int, in_key: bool = False):
i = _skip_ws(s, i)
if i >= len(s):
raise YamlError(lineno, "unexpected end of flow collection")
if s[i] in "\"'":
return _read_quoted(s, i, lineno)
j = i
while j < len(s):
ch = s[j]
if ch in ",]}":
break
if ch == ":" and in_key:
break
j += 1
return Node("scalar", s[i:j].strip(), lineno), j
def _flow_value(s: str, i: int, lineno: int):
i = _skip_ws(s, i)
if i >= len(s):
raise YamlError(lineno, "unexpected end of flow collection")
ch = s[i]
if ch == "[":
i += 1
items = []
node = Node("seq", items, lineno)
while True:
i = _skip_ws(s, i)
if i >= len(s):
raise YamlError(lineno, "unterminated flow sequence '['")
if s[i] == "]":
i += 1
return node, i
value, i = _flow_value(s, i, lineno)
items.append(value)
i = _skip_ws(s, i)
if i < len(s) and s[i] == ",":
i += 1
continue
if i < len(s) and s[i] == "]":
i += 1
return node, i
raise YamlError(lineno, "expected ',' or ']' in flow sequence")
if ch == "{":
i += 1
mapping = {}
keylines = {}
node = Node("map", mapping, lineno, keylines)
while True:
i = _skip_ws(s, i)
if i >= len(s):
raise YamlError(lineno, "unterminated flow mapping '{'")
if s[i] == "}":
i += 1
return node, i
key_node, i = _flow_scalar(s, i, lineno, in_key=True)
key = key_node.value
i = _skip_ws(s, i)
if i >= len(s) or s[i] != ":":
raise YamlError(lineno, f"expected ':' after key {key!r} in flow mapping")
i += 1
value, i = _flow_value(s, i, lineno)
if key in mapping:
raise YamlError(lineno, f"duplicate key {key!r}")
mapping[key] = value
keylines[key] = lineno
i = _skip_ws(s, i)
if i < len(s) and s[i] == ",":
i += 1
continue
if i < len(s) and s[i] == "}":
i += 1
return node, i
raise YamlError(lineno, "expected ',' or '}' in flow mapping")
return _flow_scalar(s, i, lineno)
_KEY_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9_.\-]*)\s*:(?:\s+(.*))?$")
_MAP_START_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.\-]*\s*:\s*(\S.*)?$")
class _BlockParser:
def __init__(self, lines):
# lines: list of (lineno, text)
self.lines = list(lines)
self.i = 0
# -- helpers ----------------------------------------------------------
def _skip(self):
while self.i < len(self.lines):
text = self.lines[self.i][1]
if text.strip() == "" or text.strip().startswith("#"):
self.i += 1
else:
break
def _indent(self, text, lineno):
head = text[: len(text) - len(text.lstrip(" \t"))]
if "\t" in head:
raise YamlError(lineno, "tab indentation is not allowed; use spaces")
return len(head)
def _last_line(self):
return self.lines[-1][0] if self.lines else 1
# -- entry point ------------------------------------------------------
def parse_document(self):
self._skip()
if self.i >= len(self.lines):
return Node("map", {}, self._last_line())
lineno, text = self.lines[self.i]
return self.parse_block(self._indent(text, lineno))
def parse_block(self, indent):
self._skip()
if self.i >= len(self.lines):
return Node("map", {}, self._last_line())
lineno, text = self.lines[self.i]
if _is_seq_item(text):
return self.parse_seq(self._indent(text, lineno))
return self.parse_map(self._indent(text, lineno))
# -- mappings ---------------------------------------------------------
def parse_map(self, indent):
first_line = self.lines[self.i][0]
mapping = {}
keylines = {}
node = Node("map", mapping, first_line, keylines)
while True:
self._skip()
if self.i >= len(self.lines):
break
lineno, text = self.lines[self.i]
ind = self._indent(text, lineno)
if ind < indent:
break
if ind > indent:
raise YamlError(lineno, "unexpected indentation")
content = _strip_comment(text.strip())
if not content:
self.i += 1
continue
if _is_seq_item(content):
raise YamlError(lineno, "unexpected sequence item, expected 'key: value'")
match = _KEY_RE.match(content)
if not match:
raise YamlError(lineno, "expected 'key: value'")
key = match.group(1)
rest = (match.group(2) or "").strip()
if key in mapping:
raise YamlError(lineno, f"duplicate key {key!r}")
self.i += 1
if rest == "":
child = self._value_block(indent, lineno)
elif rest[0] in "[{":
child, end = _flow_value(rest, 0, lineno)
trailing = _strip_comment(rest[end:].strip())
if trailing:
raise YamlError(lineno, f"trailing characters after value: {trailing!r}")
elif rest[0] in "|>":
child = self._block_scalar(rest, indent, lineno)
elif rest[0] in "\"'":
child, end = _read_quoted(rest, 0, lineno)
trailing = _strip_comment(rest[end:].strip())
if trailing:
raise YamlError(lineno, f"trailing characters after quoted value: {trailing!r}")
else:
child = Node("scalar", rest, lineno)
mapping[key] = child
keylines[key] = child.line or lineno
return node
def _value_block(self, indent, lineno):
self._skip()
if self.i < len(self.lines):
next_lineno, next_text = self.lines[self.i]
next_ind = self._indent(next_text, next_lineno)
if next_ind > indent:
return self.parse_block(next_ind)
if next_ind == indent and _is_seq_item(next_text):
return self.parse_seq(next_ind)
return Node("null", None, lineno)
# -- sequences --------------------------------------------------------
def parse_seq(self, indent):
first_line = self.lines[self.i][0]
items = []
node = Node("seq", items, first_line)
while True:
self._skip()
if self.i >= len(self.lines):
break
lineno, text = self.lines[self.i]
ind = self._indent(text, lineno)
if ind < indent:
break
if ind > indent:
raise YamlError(lineno, "unexpected indentation")
content = _strip_comment(text.strip())
if not content:
self.i += 1
continue
if not _is_seq_item(content):
break
rest = content[1:].strip()
if rest == "":
self.i += 1
self._skip()
if self.i < len(self.lines):
next_lineno, next_text = self.lines[self.i]
if self._indent(next_text, next_lineno) > indent:
items.append(self.parse_block(self._indent(next_text, next_lineno)))
continue
items.append(Node("null", None, lineno))
elif rest[0] in "[{":
value, end = _flow_value(rest, 0, lineno)
trailing = _strip_comment(rest[end:].strip())
if trailing:
raise YamlError(lineno, f"trailing characters after value: {trailing!r}")
self.i += 1
items.append(value)
elif rest[0] in "\"'":
value, end = _read_quoted(rest, 0, lineno)
trailing = _strip_comment(rest[end:].strip())
if trailing:
raise YamlError(lineno, f"trailing characters after quoted value: {trailing!r}")
self.i += 1
items.append(value)
elif rest[0] in "|>":
self.i += 1
items.append(self._block_scalar(rest, indent, lineno))
elif _MAP_START_RE.match(rest):
# Inline first key of a mapping item: rewrite the line at the
# item's child indent and let the mapping parser consume it.
self.lines[self.i] = (lineno, " " * (indent + 2) + rest)
items.append(self.parse_map(indent + 2))
else:
self.i += 1
items.append(Node("scalar", rest, lineno))
return node
# -- block scalars ----------------------------------------------------
def _block_scalar(self, header, indent, lineno):
style = header[0]
collected = []
while self.i < len(self.lines):
lno, text = self.lines[self.i]
if text.strip() == "":
collected.append("")
self.i += 1
continue
ind = self._indent(text, lno)
if ind <= indent:
break
collected.append(text)
self.i += 1
widths = [len(t) - len(t.lstrip(" ")) for t in collected if t.strip()]
cut = min(widths) if widths else 0
rows = [t[cut:] if t.strip() else "" for t in collected]
while rows and rows[-1] == "":
rows.pop()
if style == "|":
text = "\n".join(rows)
else:
folded = []
for row in rows:
if row == "":
folded.append("\n")
else:
folded.append(row.strip() + " ")
text = "".join(folded).strip()
return Node("scalar", text, lineno)
def parse_front_matter(fm_lines):
return _BlockParser(fm_lines).parse_document()
def split_front_matter(path):
"""Return (fm_lines, body, problem). fm_lines are (lineno, text) tuples."""
try:
with open(path, "r", encoding="utf-8-sig") as handle:
raw = handle.read()
except OSError as exc:
return None, None, f"cannot read file: {exc.strerror}"
lines = raw.splitlines()
if not lines or lines[0].strip() != "---":
return None, None, "missing front-matter block (the file must start with '---')"
end = None
for index in range(1, len(lines)):
if lines[index].strip() in ("---", "..."):
end = index
break
if end is None:
return None, None, "unterminated front-matter block (no closing '---')"
fm_lines = [(index + 1, lines[index]) for index in range(1, end)]
body = "\n".join(lines[end + 1:])
return fm_lines, body, None
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def _is_empty_value(node):
if node is None:
return True
if node.kind == "null":
return True
if node.kind == "scalar":
return (node.value or "").strip() == ""
return False
def validate_document(root, path, rel):
"""Validate one document. Returns (violations, doc_id, doc_id_line)."""
violations = []
def add(line, field, rule):
violations.append(f"{rel}:{int(line or 1)}: {field}: {rule}")
fm_lines, _body, problem = split_front_matter(path)
if problem:
add(1, "<front-matter>", problem)
return violations, None, 1
try:
doc = parse_front_matter(fm_lines)
except YamlError as exc:
add(exc.line, "<front-matter>", f"invalid YAML: {exc.msg}")
return violations, None, exc.line
if doc.kind != "map":
add(doc.line, "<front-matter>", "must be a mapping of fields")
return violations, None, doc.line
fields = doc.value
# --- unknown fields (frozen schema) ---------------------------------
for key in sorted(fields, key=lambda k: (doc.line_of(k), k)):
if key not in ALLOWED_FIELDS:
add(doc.line_of(key), key, "unknown field (the schema is frozen; see docs/content-schema.md)")
# --- required fields ------------------------------------------------
for key in REQUIRED_FIELDS:
if key not in fields:
add(1, key, "missing required field")
# --- scalar helpers -------------------------------------------------
def scalar(key, allow_empty=False):
node = fields.get(key)
if node is None:
return None
if node.kind != "scalar":
add(node.line, key, "must be a string")
return None
value = (node.value or "").strip()
if not allow_empty and value == "":
add(node.line, key, "must be a non-empty string")
return None
return value
def sequence(key):
node = fields.get(key)
if node is None:
return None
if node.kind != "seq":
add(node.line, key, "must be a list")
return None
return node
# --- id -------------------------------------------------------------
doc_id = scalar("id")
if doc_id is not None and not ID_PATTERN.match(doc_id):
add(fields["id"].line, "id", "must be lowercase kebab-case (a-z, 0-9, single hyphens)")
# --- area / level / confidence / public -----------------------------
area = scalar("area")
if area is not None and area not in AREAS:
add(fields["area"].line, "area", "must be one of " + ", ".join(AREAS))
level_node = fields.get("level")
if level_node is not None:
if level_node.kind != "scalar":
add(level_node.line, "level", "must be the integer 1, 2 or 3")
else:
raw = (level_node.value or "").strip()
if not re.fullmatch(r"[0-9]+", raw or ""):
add(level_node.line, "level", "must be the integer 1, 2 or 3")
elif int(raw) not in LEVELS:
add(level_node.line, "level", "must be the integer 1, 2 or 3")
confidence = scalar("confidence")
if confidence is not None and confidence not in CONFIDENCE:
add(fields["confidence"].line, "confidence", "must be one of " + ", ".join(CONFIDENCE))
public_node = fields.get("public")
if public_node is not None:
if public_node.kind != "scalar" or (public_node.value or "").strip().lower() not in ("true", "false"):
add(public_node.line, "public", "must be true or false")
# --- title / claim / period -----------------------------------------
scalar("title")
scalar("claim")
scalar("period")
# --- last_verified ---------------------------------------------------
verified = scalar("last_verified")
if verified is not None:
node = fields["last_verified"]
if not DATE_PATTERN.match(verified):
add(node.line, "last_verified", "must be a date written as YYYY-MM-DD")
else:
try:
datetime.date.fromisoformat(verified)
except ValueError:
add(node.line, "last_verified", "must be a real calendar date (YYYY-MM-DD)")
# --- tech / tags: list of non-empty strings --------------------------
for key in ("tech", "tags"):
seq = sequence(key)
if seq is None:
continue
for index, item in enumerate(seq.value):
if item.kind != "scalar" or (item.value or "").strip() == "":
add(item.line or seq.line, f"{key}[{index}]", "must be a non-empty string")
# --- metrics: list of {label, value} --------------------------------
metrics = sequence("metrics")
if metrics is not None:
for index, item in enumerate(metrics.value):
label = f"metrics[{index}]"
if item.kind != "map":
add(item.line or metrics.line, label, "must be a mapping with 'label' and 'value'")
continue
for key in item.value:
if key not in METRIC_FIELDS:
add(item.line_of(key), f"{label}.{key}", "unknown field (metrics allow only 'label' and 'value')")
for key in METRIC_FIELDS:
if key not in item.value:
add(item.line or metrics.line, f"{label}.{key}", "missing required field")
continue
if _is_empty_value(item.value[key]):
add(item.value[key].line or item.line, f"{label}.{key}", "must be non-empty")
# --- artefacts: list of {type, label, url} --------------------------
artefacts = sequence("artefacts")
if artefacts is not None:
for index, item in enumerate(artefacts.value):
label = f"artefacts[{index}]"
if item.kind != "map":
add(item.line or artefacts.line, label, "must be a mapping with 'type', 'label' and 'url'")
continue
for key in item.value:
if key not in ARTEFACT_FIELDS:
add(item.line_of(key), f"{label}.{key}", "unknown field (artefacts allow only 'type', 'label' and 'url')")
for key in ARTEFACT_FIELDS:
if key not in item.value:
add(item.line or artefacts.line, f"{label}.{key}", "missing required field")
type_node = item.value.get("type")
if type_node is not None:
value = (type_node.value or "").strip() if type_node.kind == "scalar" else None
if value not in ARTEFACT_TYPES:
add(type_node.line or item.line, f"{label}.type", "must be one of " + ", ".join(ARTEFACT_TYPES))
label_node = item.value.get("label")
if label_node is not None and _is_empty_value(label_node):
add(label_node.line or item.line, f"{label}.label", "must be non-empty")
url_node = item.value.get("url")
if url_node is not None:
if _is_empty_value(url_node):
add(url_node.line or item.line, f"{label}.url", "must be non-empty")
else:
problem_text = resolve_artefact_url(root, path, (url_node.value or "").strip())
if problem_text:
add(url_node.line or item.line, f"{label}.url", problem_text)
# --- id matches file name -------------------------------------------
if doc_id is not None:
stem = os.path.splitext(os.path.basename(path))[0]
if doc_id != stem:
add(fields["id"].line, "id", f"must match the file name (expected '{stem}', got '{doc_id}')")
return violations, doc_id, doc.line_of("id")
def resolve_artefact_url(root, doc_path, url):
"""Return None when `url` resolves to an existing in-repo file, else a rule text."""
if "://" in url or url.startswith("//"):
return "must be an in-repo path to an L3 stub or asset, not an external URL"
if url.startswith("/"):
return "must be relative (repo-root or document-relative), not root-absolute"
if url.startswith("./") or url.startswith("../"):
base = os.path.dirname(doc_path)
else:
base = root
target = os.path.normpath(os.path.join(base, url))
root_norm = os.path.normpath(root)
if target != root_norm and not target.startswith(root_norm + os.sep):
return f"resolves outside the repo ({url})"
if not os.path.exists(target):
return f"dangling reference: '{url}' does not exist (create the L3 stub or asset)"
if os.path.isdir(target):
return f"dangling reference: '{url}' is a directory, not a document or asset"
return None
def iter_documents(content_dir):
found = []
for dirpath, dirnames, filenames in os.walk(content_dir):
dirnames.sort()
for name in sorted(filenames):
if name.endswith(".md"):
found.append(os.path.join(dirpath, name))
return found
def validate_tree(root, content_dir, quiet=False):
"""Validate every document under content_dir. Returns (violations, count)."""
root = os.path.abspath(root)
content_dir = os.path.abspath(content_dir)
violations = []
documents = []
if os.path.isdir(content_dir):
for path in iter_documents(content_dir):
rel = os.path.relpath(path, root)
doc_violations, doc_id, id_line = validate_document(root, path, rel)
violations.extend(doc_violations)
documents.append((rel, doc_id, id_line))
elif not quiet:
print(f"note: {os.path.relpath(content_dir, root)}/ does not exist - nothing to validate")
# --- cross-document rules: id uniqueness ----------------------------
seen = {}
for rel, doc_id, id_line in documents:
if doc_id is None:
continue
if doc_id in seen:
violations.append(
f"{rel}:{int(id_line or 1)}: id: duplicate id '{doc_id}' (already used by {seen[doc_id]})"
)
else:
seen[doc_id] = rel
if not quiet:
for line in violations:
print(line)
if violations:
print(f"FAIL: {len(violations)} violation(s) in {len(documents)} document(s)")
else:
print(f"OK: {len(documents)} document(s) validated, 0 violations")
return violations, len(documents)
# ---------------------------------------------------------------------------
# Self-test: fixtures under test/fixtures/<case>/ { content/, expect.txt }
# Cases named ok-* must exit 0; cases named fail-* must exit non-zero and the
# output must contain every line of expect.txt.
# ---------------------------------------------------------------------------
def run_self_test(root):
fixtures_dir = os.path.join(root, "test", "fixtures")
if not os.path.isdir(fixtures_dir):
print(f"FAIL: fixtures directory not found: {fixtures_dir}")
return 2
cases = sorted(entry for entry in os.listdir(fixtures_dir)
if os.path.isdir(os.path.join(fixtures_dir, entry)))
if not cases:
print("FAIL: no fixture cases found")
return 2
failed = 0
for case in cases:
case_dir = os.path.join(fixtures_dir, case)
content_dir = os.path.join(case_dir, "content")
violations, documents = validate_tree(case_dir, content_dir, quiet=True)
problems = []
if case.startswith("fail-"):
if not violations:
problems.append("expected a non-zero exit, got a clean result")
elif not violations:
pass
else:
problems.append(f"expected a clean result, got {len(violations)} violation(s)")
expect_path = os.path.join(case_dir, "expect.txt")
if os.path.isfile(expect_path):
with open(expect_path, "r", encoding="utf-8") as handle:
expected = [line.strip() for line in handle if line.strip()]
else:
expected = []
for needle in expected:
if not any(needle in violation for violation in violations):
problems.append(f"missing expected output: {needle!r}")
if problems:
failed += 1
print(f"FAIL {case}")
for problem in problems:
print(f" - {problem}")
for violation in violations:
print(f" got: {violation}")
else:
note = "exit 0" if not violations else f"{len(violations)} violation(s)"
print(f"PASS {case} ({documents} document(s), {note})")
print(f"\n{len(cases) - failed}/{len(cases)} fixture case(s) passed")
return 0 if failed == 0 else 1
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main(argv=None):
default_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
parser = argparse.ArgumentParser(
description="Validate content/** front-matter against the frozen schema (AGENTS.md section 7).",
)
parser.add_argument("--root", default=default_root, help="repo root (default: parent of tools/)")
parser.add_argument("--content", default=None, help="content directory (default: <root>/content)")
parser.add_argument("--quiet", action="store_true", help="print nothing except violations")
parser.add_argument("--self-test", action="store_true", help="run the fixture self-test")
args = parser.parse_args(argv)
root = os.path.abspath(args.root)
if args.self_test:
return run_self_test(root)
content_dir = os.path.abspath(args.content) if args.content else os.path.join(root, "content")
violations, _documents = validate_tree(root, content_dir, quiet=args.quiet)
return 1 if violations else 0
if __name__ == "__main__":
sys.exit(main())