"""Check a persona doc for evidence-traced trait/pain-point claims.
Extends persona-methodology.md's own "Validation Criteria" (Internal
Validation § Data Check: "Are quotes from real users?"; Red Flags:
"Assumptions labeled as data" / "No real research") and SKILL.md's house
rule #1 — every trait traces to evidence: interviews, analytics, support
tickets, or user behavior — into an actual check.
Usage: check_persona_evidence.py <persona.md> [more.md ...]
Scans for trait/behavior/pain-point/goal claim lines (bullets and
sentences describing what the persona does, needs, wants, or struggles
with) and flags any with no nearby data-source tag:
[SOURCE: ...] e.g. [SOURCE: interview #4]
[DATA: ...] e.g. [DATA: survey]
[ASSUMPTION] explicit, honest placeholder for ungrounded claims
Exit 0 if every claim carries a tag. Exit 1 with line-level messages
# Trait/behavior/pain-point/goal claim lines — bullets are the common
# persona shape (see references/example-personas.md), so a bulleted line
# under a persona-ish heading counts as a claim needing evidence.
BULLET_RE = re.compile(r"^\s*[-*]\s+(.+)$")
# Section headings that scope "claim" lines — demographics/quote lines are
# exempt (see persona-methodology.md § Component Depth Guide: age ranges
# and titles aren't traits needing a citation).
CLAIM_SECTION_RE = re.compile(
r"^#+\s*(goals?|needs?|frustrations?|pain\s*points?|behaviors?|"
r"motivations?|psychographics?|scenarios?|characteristics?)\b",
EXEMPT_SECTION_RE = re.compile(
r"^#+\s*(demographics?|quote|tagline|name|data\s*points?)\b",
TAG_RE = re.compile(r"\[(?:SOURCE|DATA|ASSUMPTION)\b[^\]]*\]", re.IGNORECASE)
CONTEXT_WINDOW = 1 # lines above/below to search for a nearby tag
def check_file(path: str) -> list[str]:
lines = open(path, encoding="utf-8").read().splitlines()
for i, line in enumerate(lines):
if CLAIM_SECTION_RE.match(line):
if EXEMPT_SECTION_RE.match(line):
if re.match(r"^#+\s", line):
# Any other heading — stay conservative, keep prior state only
# for sub-headings; a new top-level section without a claim
# keyword is treated as non-claim.
if not CLAIM_SECTION_RE.match(line):
m = BULLET_RE.match(line)
claim_text = m.group(1).strip()
window_start = max(0, i - CONTEXT_WINDOW)
window_end = min(len(lines), i + CONTEXT_WINDOW + 1)
window = "\n".join(lines[window_start:window_end])
if not TAG_RE.search(window):
snippet = snippet[:97] + "..."
f"{path}:{i + 1}: untraced persona claim — {snippet!r} "
f"(tag with [SOURCE: ...], [DATA: ...], or explicit [ASSUMPTION])"
print("Usage: check_persona_evidence.py <persona.md> [more.md ...]",
for path in sys.argv[1:]:
all_errors.extend(check_file(path))
except FileNotFoundError:
print(f"ERROR: file not found: {path}", file=sys.stderr)
print(f"\n{len(all_errors)} untraced claim(s) found.")
print("✅ every persona trait/behavior/pain-point traces to a data source")
if __name__ == "__main__":