#!/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, "", problem) return violations, None, 1 try: doc = parse_front_matter(fm_lines) except YamlError as exc: add(exc.line, "", f"invalid YAML: {exc.msg}") return violations, None, exc.line if doc.kind != "map": add(doc.line, "", "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// { 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: /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())