#!/usr/bin/env python3 """Lint prompt template files (Markdown with simple front matter). Usage: python3 scripts/lint_prompts.py [PATH ...] [--strict] [--require KEY ...] [--max-chars N] [--ext .md --ext .txt] PATH Files or directories. Defaults to ./prompts. --strict Treat warnings as failures. --require Front matter keys every prompt must have. Defaults to: name, description. --max-chars Warn when a prompt body is longer than N characters (default 20000). --ext File extensions to lint (default: .md). Expected file shape: --- name: support-reply description: Drafts a reply to a customer support ticket. variables: [customer_name, ticket_text] --- Prompt body using {{customer_name}} and {{ticket_text}}. Front matter is parsed as flat "key: value" lines only. Lists may be written as [a, b] or a, b. Nested YAML is not supported by this linter. Rules: E001 missing front matter block E002 required front matter key missing or empty E003 {{variable}} used in the body but not declared in "variables" E004 XML-style tag opened but not closed (or closed but not opened) E005 the same "name" is used by more than one prompt file W101 variable declared but never used W102 trailing whitespace W103 invisible character (zero-width space, BOM, non-breaking space) W104 body longer than --max-chars Tags and variables inside fenced code blocks are ignored. Requirements: Python 3.8+, no packages, no environment variables. Exit codes: 0 no errors (and no warnings when --strict) 1 lint errors found (or warnings with --strict) 2 usage error (path not found, no prompt files) """ import argparse import os import re import sys VAR = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}") TAG = re.compile(r"<(/?)([a-z_][a-z0-9_\-]*)(\s[^<>]*)?(/?)>") FENCE = re.compile(r"^\s{0,3}(```|~~~)") INVISIBLE = {"​": "zero-width space", "‌": "zero-width non-joiner", "‍": "zero-width joiner", "": "byte order mark", " ": "non-breaking space"} VOID_TAGS = {"br", "hr", "img", "input", "meta", "link"} def parse_front_matter(lines): if not lines or lines[0].strip() != "---": return None, 0 data = {} for index in range(1, len(lines)): line = lines[index] if line.strip() == "---": return data, index + 1 if ":" in line and not line.startswith((" ", "\t", "#")): key, value = line.split(":", 1) data[key.strip()] = value.strip().strip("\"'") return None, 0 def parse_list(value): value = value.strip() if value.startswith("[") and value.endswith("]"): value = value[1:-1] return [item.strip().strip("\"'") for item in value.split(",") if item.strip()] def lint_file(path, args, names): problems = [] def add(code, line, message): problems.append((code, line, message)) with open(path, encoding="utf-8") as handle: text = handle.read() lines = text.splitlines() meta, body_start = parse_front_matter(lines) if meta is None: add("E001", 1, "missing front matter block (--- ... ---) at top of file") meta = {} for key in args.require: if not meta.get(key): add("E002", 1, "front matter key %r is missing or empty" % key) if meta.get("name"): names.setdefault(meta["name"], []).append(path) declared = set(parse_list(meta.get("variables", ""))) used = {} stack = [] in_fence = None body_chars = 0 for number in range(body_start, len(lines)): line = lines[number] lineno = number + 1 body_chars += len(line) + 1 if line != line.rstrip(): add("W102", lineno, "trailing whitespace") for char, label in INVISIBLE.items(): if char in line: add("W103", lineno, "contains %s (U+%04X)" % (label, ord(char))) fence = FENCE.match(line) if fence: if in_fence is None: in_fence = fence.group(1) elif in_fence == fence.group(1): in_fence = None continue if in_fence: continue for match in VAR.finditer(line): used.setdefault(match.group(1), lineno) for match in TAG.finditer(line): closing, name, _, self_closing = match.groups() if self_closing or name in VOID_TAGS: continue if not closing: stack.append((name, lineno)) elif stack and stack[-1][0] == name: stack.pop() elif any(open_name == name for open_name, _ in stack): while stack and stack[-1][0] != name: open_name, open_line = stack.pop() add("E004", open_line, "<%s> is never closed" % open_name) stack.pop() else: add("E004", lineno, " has no matching opening tag" % name) for open_name, open_line in stack: add("E004", open_line, "<%s> is never closed" % open_name) for name, lineno in sorted(used.items(), key=lambda item: item[1]): if name not in declared: add("E003", lineno, "variable {{%s}} is not declared in front matter 'variables'" % name) for name in sorted(declared - set(used)): add("W101", 1, "variable %r is declared but never used" % name) if body_chars > args.max_chars: add("W104", body_start + 1, "body is %d characters (limit %d)" % (body_chars, args.max_chars)) return problems def collect(paths, exts): files = [] for root in paths: if os.path.isfile(root): files.append(root) elif os.path.isdir(root): for dirpath, dirnames, filenames in os.walk(root): dirnames.sort() for name in sorted(filenames): if os.path.splitext(name)[1].lower() in exts and name.lower() != "readme.md": files.append(os.path.join(dirpath, name)) else: print("error: path not found: %s" % root, file=sys.stderr) sys.exit(2) return [os.path.relpath(f).replace(os.sep, "/") for f in files] def main(): parser = argparse.ArgumentParser(description="Lint prompt template files.") parser.add_argument("paths", nargs="*", default=["prompts"]) parser.add_argument("--strict", action="store_true") parser.add_argument("--require", action="append", default=None, metavar="KEY") parser.add_argument("--max-chars", type=int, default=20000) parser.add_argument("--ext", action="append", default=None) args = parser.parse_args() args.require = args.require or ["name", "description"] exts = {e.lower() if e.startswith(".") else "." + e.lower() for e in (args.ext or [".md"])} files = collect(args.paths, exts) if not files: print("error: no prompt files found", file=sys.stderr) return 2 names = {} errors = warnings = 0 for path in files: try: problems = lint_file(path, args, names) except UnicodeDecodeError: problems = [("E001", 1, "file is not valid UTF-8")] for code, line, message in problems: print("%s:%d: %s %s" % (path, line, code, message)) if code.startswith("E"): errors += 1 else: warnings += 1 for name, paths in names.items(): if len(paths) > 1: print("%s:1: E005 name %r is also used by %s" % (paths[0], name, ", ".join(paths[1:]))) errors += 1 print("linted %d file(s): %d error(s), %d warning(s)" % (len(files), errors, warnings)) if errors or (args.strict and warnings): return 1 return 0 if __name__ == "__main__": sys.exit(main())