"""Derive the published dataset and summary for the AGENTS.md study.

Reads the raw collection written by collect.py (which stays local: it holds
the files' text, and the files belong to their repositories) and writes:

  1. A per-repository CSV of METRICS only (no file text), published with
     the report as its dataset.
  2. A generated TypeScript module with every number the report states.
     The report reads its figures from that module rather than restating
     them, and tests/unit/research-agents-md-study.test.ts recomputes the
     module's numbers from the CSV, so a figure cannot drift from the data.

Every classification rule below is published verbatim in the report's
methodology page, so a reader can re-run it.

Usage:
  python analyze.py --raw DIR --csv OUT.csv --ts OUT.ts
"""
from __future__ import annotations

import argparse
import csv
import hashlib
import json
import re
import statistics
import sys
from collections import Counter
from pathlib import Path

SYMLINK_MODE = 0o120000  # 40960: git's mode for a symbolic link

# Root instruction files, matched case-insensitively against the root tree.
ROOT_FLAGS = {
    "agents_md": "AGENTS.md",
    "claude_md": "CLAUDE.md",
    "gemini_md": "GEMINI.md",
    "agent_md": "AGENT.md",
}
FILE_OR_DIR = {
    # flag -> (legacy root file name, collect.py key for the rules directory)
    "cursor_rules": (".cursorrules", "cursor_rules_dir"),
    "windsurf_rules": (".windsurfrules", "windsurf_rules_dir"),
}

# Documented limits the report measures files against.
# Codex: project_doc_max_bytes, 32 KiB by default; a file over the remaining
# budget is truncated (openai/codex codex-rs/config/defaults.toml and
# codex-rs/core/src/agents_md.rs).
CODEX_MAX_BYTES = 32_768
# Claude Code: "target under 200 lines per CLAUDE.md file"
# (code.claude.com/docs/en/memory).
CLAUDE_TARGET_LINES = 200

# ------------------------------------------------------------------ text

# --- rules: content (published verbatim in the methodology) ---
FENCE = re.compile(r"^\s*(```|~~~)")
HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$")
INLINE_CODE = re.compile(r"`([^`\n]+)`")

# Heading categories. A level-2-or-deeper heading counts toward every
# category whose pattern it matches; the level-1 heading is the document's
# title and is not classified.
SECTIONS: list[tuple[str, str]] = [
    ("Commands", r"\b(commands?|scripts?|common tasks|tasks|makefile|how to run|running)\b"),
    ("Testing", r"\b(tests?|testing|verif(y|ication)|validation)\b"),
    ("Build", r"\b(build|building|compile|compilation)\b"),
    ("Setup", r"\b(setup|set up|install|installation|getting started|prerequisites|environment|requirements|quick ?start)\b"),
    ("Code style and conventions", r"\b(style|conventions?|standards?|formatting|lint|linting|naming|best practices|code quality|patterns?)\b"),
    ("Project structure", r"\b(structure|layout|architecture|organi[sz]ation|director(y|ies)|folders?|codebase|modules?|components?|map)\b"),
    ("Project overview", r"\b(overview|about|introduction|summary|purpose|context|background|(tech|technology|technical) stack)\b"),
    ("Git and pull requests", r"\b(git|commits?|pull requests?|prs?|branch(es|ing)?|reviews?|contributing|contributions?)\b"),
    ("Security", r"\b(security|secrets?|credentials?|safety|permissions?|sensitive)\b"),
    ("Documentation", r"\b(documentation|docs|comments|changelogs?)\b"),
    ("Dependencies", r"\b(dependenc(y|ies)|packages?|libraries)\b"),
    ("Troubleshooting", r"\b(troubleshooting|debug(ging)?|common (issues|mistakes|pitfalls)|gotchas?|pitfalls?|known issues|caveats)\b"),
    ("CI and deployment", r"\b(ci|continuous integration|deploy(ing|ment)?|releases?|releasing|publishing)\b"),
]
SECTION_RES = [(name, re.compile(rx, re.I)) for name, rx in SECTIONS]

# Commands, matched inside fenced code blocks and inline code spans only, so
# prose that merely mentions testing does not count as a command.
# A package-manager script: "npm test", "pnpm run test:unit", "yarn --cwd web test".
PM = r"\b(?:npm|pnpm|yarn|bun)\b[^\n]*?\s(?:run\s+)?"
TEST_CMD = re.compile(
    PM + r"test(?=[:\s]|$)"
    r"|\bpytest\b|\bgo\s+test\b|\bcargo\s+(?:test|nextest)\b|\bmvnw?\b[^\n]*\btest\b|\bgradlew?\b[^\n]*\btest\b"
    r"|\bmake\s+\S*(?:test|check)\S*|\bdotnet\s+test\b|\brspec\b|\bjest\b|\bvitest\b|\bphpunit\b|\bpest\b|\bctest\b"
    r"|\btox\b|\bnox\b|\bswift\s+test\b|\bmix\s+test\b|\bbazel\s+test\b|\bplaywright\s+test\b|\bdeno\s+test\b"
    r"|\bjust\s+test\b|\bcomposer\s+(?:run-script\s+|run\s+)?test\b|\brake\s+(?:test|spec)\b|\brails\s+test\b"
    r"|\bsbt\b[^\n]*\btest\b|\blein\s+test\b|\b(?:flutter|dart)\s+test\b|\bxcodebuild\b[^\n]*\btest\b"
    r"|\bmeson\s+test\b|\b(?:nx|turbo)\s+(?:run\s+)?test\b|\bzig\s+build\s+test\b|\bR\s+CMD\s+check\b"
    r"|\./run[-_]?tests?\b|\b(?:scripts?|bin|hack|tools)/[\w./-]*test[\w.-]*",
    re.I,
)
BUILD_CMD = re.compile(
    PM + r"build(?=[:\s]|$)"
    r"|\bcargo\s+build\b|\bgo\s+build\b|\bgradlew?\b[^\n]*\b(?:build|assemble)\b"
    r"|\bmvnw?\b[^\n]*\b(?:package|install|compile|verify)\b|\bdotnet\s+build\b|\bbazel\s+build\b|\bcmake\b"
    r"|\bmake\b|\bnext\s+build\b|\bvite\s+build\b|\bswift\s+build\b|\bjust\s+build\b|\bmeson\b|\bninja\b"
    r"|\b(?:flutter|docker)\s+build\b|\bxcodebuild\b|\bsbt\b[^\n]*\b(?:compile|package|assembly)\b"
    r"|\b(?:nx|turbo)\s+(?:run\s+)?build\b|\bzig\s+build\b|\bdart\s+compile\b",
    re.I,
)
LINT_CMD = re.compile(
    PM + r"(?:lint|format|fmt|typecheck|type-check|check-types)(?=[:\s]|$)"
    r"|\beslint\b|\bprettier\b|\bruff\b|\bblack\b|\bisort\b|\bflake8\b|\bpylint\b|\bmypy\b|\bpyright\b"
    r"|\bgolangci-lint\b|\bgofmt\b|\bgo\s+(?:vet|fmt)\b|\bstaticcheck\b|\bclippy\b|\brustfmt\b|\bcargo\s+fmt\b"
    r"|\bbiome\b|\bpre-commit\b|\bstylelint\b|\bshellcheck\b|\bktlint\b|\bdetekt\b|\bspotless\b|\bcheckstyle\b"
    r"|\bdotnet\s+format\b|\brubocop\b|\bstandardrb\b|\bphpstan\b|\bpsalm\b|\bphpcs\b|\bswiftlint\b|\bswiftformat\b"
    r"|\bclang-(?:format|tidy)\b|\bhadolint\b|\bmarkdownlint\b|\byamllint\b|\bdeno\s+(?:lint|fmt)\b|\btsc\b",
    re.I,
)
# Prose (outside code).
SECURITY = re.compile(
    r"\b(?:secrets?|credentials?|api[ _-]?keys?|access tokens?|auth tokens?|passwords?|private keys?|"
    r"security|vulnerabilit(?:y|ies)|sensitive data|pii|personal data)\b|\.env\b",
    re.I,
)
# A rule against leaking secrets: in one sentence, a prohibition followed by
# a verb, and a named secret. A bare "token" is not enough: in these files it
# is as often a language-model token or a design token as a credential.
SECRET_RULE = re.compile(
    r"(?=.*\b(?:never|do not|don't|must not|avoid|no)\b.*?"
    r"\b(?:commit(?:s|ted|ting)?|log(?:s|ged|ging)?|print(?:s|ed|ing)?|expos(?:e|es|ed|ing)|"
    r"shar(?:e|es|ed|ing)|hard-?cod(?:e|es|ed|ing)|check(?:s|ed|ing)? in|push(?:es|ed|ing)?|"
    r"leak(?:s|ed|ing)?|past(?:e|es|ed|ing)|put(?:s|ting)?|pass(?:es|ed|ing)?|includ(?:e|es|ed|ing)|"
    r"stor(?:e|es|ed|ing)|contain(?:s|ed|ing)?|add(?:s|ed|ing)?)\b)"
    r"(?=.*(?:\b(?:secrets?|api[ _-]?keys?|credentials?|passwords?|private keys?|keystores?|"
    r"(?:access|auth|bearer|api|oauth|refresh|session|secret|github|npm|jwt|bot|deploy|client|signing|"
    r"personal access)[ _-]?tokens?)\b|\.env\b))",
    re.I,
)
SENTENCE_END = re.compile(r"(?<=[.!?;])\s+")
RULE = re.compile(r"\b(?:never|always|must|do not|don't|avoid|should not|shouldn't|make sure|ensure)\b", re.I)
AGENT_NAMES = re.compile(
    r"\b(?:claude|codex|cursor|copilot|gemini|windsurf|cline|aider|jules|junie|amp|devin|kiro)\b", re.I
)
# A Claude Code import of AGENTS.md: "@AGENTS.md", "@./AGENTS.md", or from
# .claude/CLAUDE.md "@../AGENTS.md". Searched in prose only, because Claude
# Code's "import parsing skips Markdown code spans and fenced code blocks".
IMPORT_AGENTS = re.compile(r"(?<![\w@/.])@(?:\.{1,2}/)*AGENTS\.md\b", re.I)
MENTIONS_AGENTS = re.compile(r"agents\.md", re.I)
# Words are counted by splitting on whitespace, which undercounts Chinese,
# Japanese and Korean. A file whose letters are more than 30% CJK is flagged
# so the length figures can be checked without it.
CJK = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]")

# Template signatures. Codex CLI's /init prompt (openai/codex,
# codex-rs/tui/assets/prompt_for_init_command.md) tells the model to 'Title
# the document "Repository Guidelines"' and says "200-400 words is optimal".
CODEX_INIT_TITLE = "repository guidelines"
# A boilerplate first sentence many CLAUDE.md files share. Counted as text
# only: we found no official source for where it comes from.
CLAUDE_BOILERPLATE = re.compile(r"this file provides guidance to claude code", re.I)
# --- rules: content end ---


def split_markdown(text: str) -> tuple[list[tuple[int, str]], str, str]:
    """Return (headings as (level, text), code text, prose text)."""
    headings, code, prose = [], [], []
    in_fence = False
    for line in text.splitlines():
        if FENCE.match(line):
            in_fence = not in_fence
            continue
        if in_fence:
            code.append(line)
            continue
        m = HEADING.match(line)
        if m:
            headings.append((len(m.group(1)), m.group(2)))
        code.extend(INLINE_CODE.findall(line))
        prose.append(INLINE_CODE.sub(" ", line))
    return headings, "\n".join(code), "\n".join(prose)


def line_count(text: str) -> int:
    if not text:
        return 0
    return text.count("\n") + (0 if text.endswith("\n") else 1)


def cjk_heavy(text: str) -> bool:
    letters = [ch for ch in text if ch.isalpha()]
    return bool(letters) and sum(1 for ch in letters if CJK.match(ch)) / len(letters) > 0.3


def content_metrics(text: str) -> dict:
    headings, code, prose = split_markdown(text)
    sub = [h for lvl, h in headings if lvl >= 2]
    sections = sorted({name for name, rx in SECTION_RES if any(rx.search(h) for h in sub)})
    first_h1 = next((h for lvl, h in headings if lvl == 1), "")
    return {
        "codex_title": first_h1.strip().lower() == CODEX_INIT_TITLE,
        "claude_boilerplate": bool(CLAUDE_BOILERPLATE.search(text)),
        "bytes": len(text.encode("utf-8")),
        "lines": line_count(text),
        "words": len(text.split()),
        "cjk_heavy": cjk_heavy(text),
        "headings": len(headings),
        "sections": sections,
        "test_cmd": bool(TEST_CMD.search(code)),
        "build_cmd": bool(BUILD_CMD.search(code)),
        "lint_cmd": bool(LINT_CMD.search(code)),
        "security": bool(SECURITY.search(prose)),
        "secret_rule": any(SECRET_RULE.search(x) for l in prose.splitlines() for x in SENTENCE_END.split(l)),
        "rules": sum(1 for l in prose.splitlines() if RULE.search(l)),
        "names_agents": bool(AGENT_NAMES.search(prose)),
    }


# ------------------------------------------------------------------ files

def root_entry(rec: dict, name: str) -> dict | None:
    for e in ((rec.get("root") or {}).get("entries") or []):
        if e["name"].lower() == name.lower():
            return e
    return None


def blob_text(rec: dict, key: str, variants: dict, full: str) -> str | None:
    """Text of a root AGENTS.md / CLAUDE.md, from the standard-case fetch or
    the case-variant fetch."""
    blob = rec.get(key)
    if blob and blob.get("text") is not None:
        return blob["text"]
    v = variants.get(f"{full}::{key}")
    if v and v.get("blob") and v["blob"].get("text") is not None:
        return v["blob"]["text"]
    return None


def chain_of(link: dict | None) -> list[str]:
    """The paths a followed symlink passed through after itself, ending at
    the regular file it resolved to."""
    if not link:
        return []
    return list(link.get("hops", [])[1:]) + ([link["path"]] if link.get("path") else [])


def root_file(full: str, rec: dict, key: str, variants: dict, extras: dict) -> dict | None:
    """A root AGENTS.md or CLAUDE.md: its name, whether it is a symlink, the
    path chain the link resolves through, and the text of the file it
    finally is (the link's target for a symlink)."""
    e = root_entry(rec, ROOT_FLAGS[key])
    if e is None:
        return None
    link = e.get("mode") == SYMLINK_MODE
    if link:
        followed = extras.get(f"{full}::{key}::link")
        text, chain = (followed or {}).get("text"), chain_of(followed)
    else:
        text, chain = blob_text(rec, key, variants, full), []
    return {"path": e["name"], "exact": e["name"] == ROOT_FLAGS[key], "link": link, "chain": chain, "text": text}


def dot_claude_file(full: str, extras: dict) -> dict | None:
    """.claude/CLAUDE.md, the other place Claude Code reads a project's
    CLAUDE.md from."""
    d = extras.get(f"{full}::dotclaude")
    if not d:
        return None
    e = next((x for x in d.get("entries") or [] if x["name"].lower() == "claude.md"), None)
    if e is None:
        return None
    link = e.get("mode") == SYMLINK_MODE
    followed = extras.get(f"{full}::dotclaude::link")
    text = (followed or {}).get("text") if followed is not None else (d.get("claude_md") or {}).get("text")
    return {"path": ".claude/" + e["name"], "exact": e["name"] == "CLAUDE.md", "link": link,
            "chain": chain_of(followed) if link else [], "text": text}


def norm(text: str) -> str:
    return re.sub(r"\s+", " ", text).strip()


# --- rules: relation (published verbatim in the methodology) ---
# How a CLAUDE.md relates to the repository's AGENTS.md. The first six carry
# AGENTS.md's content into what Claude Code loads; "points" leaves it to the
# model to open the file; "separate" does neither.
LOADS_AGENTS = (
    "claude_symlinks_to_agents",
    "agents_symlinks_to_claude",
    "both_link_to_same_file",
    "claude_imports_agents",
    "identical",
    "claude_contains_agents",
)


def relation_for(c: dict, a: dict) -> str:
    c_chain = [p.lower() for p in c["chain"]]
    a_chain = [p.lower() for p in a["chain"]]
    if c["link"] and a["path"].lower() in c_chain:
        return "claude_symlinks_to_agents"
    if a["link"] and c["path"].lower() in a_chain:
        return "agents_symlinks_to_claude"
    if c["link"] and a["link"] and c_chain and a_chain and c_chain[-1] == a_chain[-1]:
        return "both_link_to_same_file"
    ct, at = c["text"], a["text"]
    if ct is not None and IMPORT_AGENTS.search(split_markdown(ct)[2]):
        return "claude_imports_agents"
    if ct is not None and at is not None:
        nc, na = norm(ct), norm(at)
        if nc == na:
            return "identical"
        if len(na) >= 200 and na in nc:
            return "claude_contains_agents"
    if ct is not None and len(ct.encode("utf-8")) < 600 and MENTIONS_AGENTS.search(ct):
        return "claude_points_to_agents"
    return "separate"


def strength(relation: str) -> int:
    return 2 if relation in LOADS_AGENTS else 1 if relation == "claude_points_to_agents" else 0
# --- rules: relation end ---


def classify_repo(full: str, rec: dict, variants: dict, extras: dict) -> dict:
    out: dict = {}
    agents = root_file(full, rec, "agents_md", variants, extras)
    claude_root = root_file(full, rec, "claude_md", variants, extras)
    claude_dot = dot_claude_file(full, extras)
    claude_files = [f for f in (claude_root, claude_dot) if f]
    out["agents_md"] = agents is not None
    out["claude_md"] = bool(claude_files)
    out["claude_md_location"] = (
        "both" if claude_root and claude_dot else "root" if claude_root else ".claude" if claude_dot else "")
    out["gemini_md"] = root_entry(rec, "GEMINI.md") is not None
    out["agent_md"] = root_entry(rec, "AGENT.md") is not None
    out["copilot_instructions"] = bool(rec.get("copilot_instructions"))
    out["junie_guidelines"] = bool(rec.get("junie_guidelines"))
    out["cline_rules"] = root_entry(rec, ".clinerules") is not None
    for flag, (legacy_file, dir_key) in FILE_OR_DIR.items():
        # The legacy single file at the root, or the current rules directory.
        out[flag] = root_entry(rec, legacy_file) is not None or bool(rec.get(dir_key))
    out["any_instruction_file"] = any(
        out[k] for k in ("agents_md", "claude_md", "gemini_md", "agent_md", "copilot_instructions",
                         "cursor_rules", "windsurf_rules", "cline_rules", "junie_guidelines"))

    # CLAUDE.md content and history describe the root file when there is
    # one, else .claude/CLAUDE.md.
    claude = claude_root or claude_dot
    relation = ""
    if claude_files:
        if not agents:
            relation = "claude_only"
        else:
            # Either CLAUDE.md can carry AGENTS.md; take the strongest link,
            # the root file's on a tie.
            relation = max((relation_for(c, agents) for c in claude_files), key=strength)
    out["claude_md_relation"] = relation
    out["claude_md_mentions_agents"] = bool(
        agents and any(c["text"] and MENTIONS_AGENTS.search(c["text"]) for c in claude_files))
    out["_agents"] = agents
    out["_claude"] = claude
    out["_claude_history_key"] = (
        f"{full}::claude_md" if claude_root else f"{full}::dotclaude::history" if claude_dot else None)
    return out


# ------------------------------------------------------------------ helpers

def pct(n: int, d: int) -> float:
    return round(100.0 * n / d, 1) if d else 0.0


def median_int(values: list[int]) -> int:
    return int(statistics.median(values)) if values else 0


def percentile(values: list[int], p: float) -> int:
    """Nearest-rank on the sorted values (index round(p * (n - 1)))."""
    if not values:
        return 0
    s = sorted(values)
    k = max(0, min(len(s) - 1, int(round(p * (len(s) - 1)))))
    return s[k]


def band(stars: int) -> str:
    if stars >= 50_000:
        return "50,000+"
    if stars >= 20_000:
        return "20,000–49,999"
    if stars >= 10_000:
        return "10,000–19,999"
    return "5,000–9,999"


BANDS = ["5,000–9,999", "10,000–19,999", "20,000–49,999", "50,000+"]


def quarter(iso: str) -> str:
    y, m = int(iso[:4]), int(iso[5:7])
    return f"{y}-Q{(m - 1) // 3 + 1}"


FLAGS = ("agents_md", "claude_md", "gemini_md", "agent_md", "copilot_instructions", "cursor_rules",
         "windsurf_rules", "cline_rules", "junie_guidelines", "any_instruction_file")
METRIC_INTS = ("bytes", "lines", "words", "headings", "rules")
METRIC_FLAGS = ("test_cmd", "build_cmd", "lint_cmd", "security", "secret_rule", "names_agents", "cjk_heavy")


# ------------------------------------------------------------------ main

def main() -> None:
    sys.stdout.reconfigure(encoding="utf-8")
    ap = argparse.ArgumentParser()
    ap.add_argument("--raw", required=True)
    ap.add_argument("--csv", required=True)
    ap.add_argument("--ts", required=True)
    args = ap.parse_args()
    raw = Path(args.raw)

    def load(name: str) -> dict:
        p = raw / name
        return json.loads(p.read_text(encoding="utf-8")) if p.exists() else {}

    pop = load("population.json")
    files = load("files.json")
    variants = load("variants.json")
    history = load("history.json")
    extras = load("extras.json")
    if not extras:
        sys.exit("extras.json is missing: run collect.py --stages extras first")

    rows, unreadable = [], []
    for repo in pop["repos"]:
        full = repo["full_name"]
        rec = files.get(full)
        if not rec or "error" in rec or rec.get("root") is None:
            unreadable.append(full)
            continue
        c = classify_repo(full, rec, variants, extras)
        row = {
            "repo": full,
            "stars": repo["stars"],
            "language": (rec.get("primaryLanguage") or {}).get("name") or repo.get("language") or "",
            "pushed_at": (repo.get("pushed_at") or "")[:10],
        }
        for k in FLAGS:
            row[k] = int(bool(c[k]))
        row["claude_md_location"] = c["claude_md_location"]
        row["claude_md_relation"] = c["claude_md_relation"]
        row["claude_md_mentions_agents"] = int(c["claude_md_mentions_agents"]) if c["claude_md_relation"] else ""
        for prefix, f, hkey in (("agents_md", c["_agents"], f"{full}::agents_md"),
                                ("claude_md", c["_claude"], c["_claude_history_key"])):
            row[f"{prefix}_case_variant"] = int(not f["exact"]) if f else ""
            row[f"{prefix}_symlink"] = int(f["link"]) if f else ""
            m = content_metrics(f["text"]) if (f and f["text"] is not None) else None
            for key in METRIC_INTS:
                row[f"{prefix}_{key}"] = m[key] if m else ""
            row[f"{prefix}_sections"] = ";".join(m["sections"]) if m else ""
            for key in METRIC_FLAGS:
                row[f"{prefix}_{key}"] = int(m[key]) if m else ""
            signature = "codex_title" if prefix == "agents_md" else "claude_boilerplate"
            row[f"{prefix}_{signature}"] = int(m[signature]) if m else ""
            h = (history.get(hkey) or extras.get(hkey)) if (f and hkey) else None
            total = (h or {}).get("total")
            dates = (h or {}).get("dates") or []
            row[f"{prefix}_commits"] = total if total is not None else ""
            # The first commit is known only when every commit's date was
            # fetched (collect.py fetches up to 100).
            row[f"{prefix}_first_commit"] = min(dates)[:10] if (dates and total is not None and total <= len(dates)) else ""
        rows.append(row)

    # ---------------------------------------------------------- CSV
    fields = list(rows[0].keys())
    with open(args.csv, "w", newline="", encoding="utf-8") as fh:
        w = csv.DictWriter(fh, fieldnames=fields, lineterminator="\n")
        w.writeheader()
        w.writerows(rows)
    csv_bytes = Path(args.csv).read_bytes()
    csv_sha = hashlib.sha256(csv_bytes).hexdigest()

    # ---------------------------------------------------------- summary
    n = len(rows)

    def adoption(key: str) -> dict:
        k = sum(r[key] for r in rows)
        return {"count": k, "pct": pct(k, n)}

    def content(prefix: str) -> dict:
        present = [r for r in rows if r[prefix]]
        fs = [r for r in present if r[f"{prefix}_lines"] != ""]
        m = len(fs)
        sig = "codex_title" if prefix == "agents_md" else "claude_boilerplate"
        sec = Counter(s for r in fs for s in (r[f"{prefix}_sections"].split(";") if r[f"{prefix}_sections"] else []))
        words = [r[f"{prefix}_words"] for r in fs]
        lines = [r[f"{prefix}_lines"] for r in fs]
        # Edit history is about the file's own path, so a symlink's history
        # (usually the one commit that created the link) is left out.
        own = [r for r in fs if not r[f"{prefix}_symlink"]]
        commits = [r[f"{prefix}_commits"] for r in own if r[f"{prefix}_commits"] != ""]
        firsts = [r[f"{prefix}_first_commit"] for r in present if r[f"{prefix}_first_commit"]]
        by_q = Counter(quarter(d) for d in firsts)
        return {
            "files": len(present),
            "measured": m,
            "symlinks": sum(1 for r in present if r[f"{prefix}_symlink"] == 1),
            "caseVariants": sum(1 for r in present if r[f"{prefix}_case_variant"] == 1),
            "medianLines": median_int(lines),
            "p90Lines": percentile(lines, 0.9),
            "medianWords": median_int(words),
            "cjkHeavy": sum(1 for r in fs if r[f"{prefix}_cjk_heavy"]),
            "medianWordsNonCjk": median_int([r[f"{prefix}_words"] for r in fs if not r[f"{prefix}_cjk_heavy"]]),
            "p90Words": percentile(words, 0.9),
            "medianHeadings": median_int([r[f"{prefix}_headings"] for r in fs]),
            "medianRules": median_int([r[f"{prefix}_rules"] for r in fs]),
            "testCmdPct": pct(sum(r[f"{prefix}_test_cmd"] for r in fs), m),
            "buildCmdPct": pct(sum(r[f"{prefix}_build_cmd"] for r in fs), m),
            "lintCmdPct": pct(sum(r[f"{prefix}_lint_cmd"] for r in fs), m),
            "anyCmdPct": pct(sum(1 for r in fs if r[f"{prefix}_test_cmd"] or r[f"{prefix}_build_cmd"] or r[f"{prefix}_lint_cmd"]), m),
            "securityPct": pct(sum(r[f"{prefix}_security"] for r in fs), m),
            "secretRulePct": pct(sum(r[f"{prefix}_secret_rule"] for r in fs), m),
            "namesAgentPct": pct(sum(r[f"{prefix}_names_agents"] for r in fs), m),
            "words200to400Pct": pct(sum(1 for x in words if 200 <= x <= 400), m),
            "wordsOver1000Pct": pct(sum(1 for x in words if x > 1000), m),
            "wordsUnder100Pct": pct(sum(1 for x in words if x < 100), m),
            "overCodexCap": sum(1 for r in fs if r[f"{prefix}_bytes"] > CODEX_MAX_BYTES),
            "overCodexCapPct": pct(sum(1 for r in fs if r[f"{prefix}_bytes"] > CODEX_MAX_BYTES), m),
            "atLeastClaudeTargetLines": sum(1 for x in lines if x >= CLAUDE_TARGET_LINES),
            "atLeastClaudeTargetLinesPct": pct(sum(1 for x in lines if x >= CLAUDE_TARGET_LINES), m),
            "signaturePct": pct(sum(r[f"{prefix}_{sig}"] for r in fs), m),
            "sections": [{"name": name, "pct": pct(sec.get(name, 0), m)} for name, _ in SECTIONS],
            "withHistory": len(commits),
            "medianCommits": median_int(commits),
            "editedMoreThanOncePct": pct(sum(1 for x in commits if x > 1), len(commits)),
            "firstCommitDated": len(firsts),
            "firstCommitByQuarter": [{"quarter": q, "count": by_q[q]} for q in sorted(by_q)],
        }

    by_band = []
    for b in BANDS:
        rs = [r for r in rows if band(r["stars"]) == b]
        by_band.append({
            "band": b,
            "repos": len(rs),
            "agentsMdPct": pct(sum(r["agents_md"] for r in rs), len(rs)),
            "claudeMdPct": pct(sum(r["claude_md"] for r in rs), len(rs)),
            "anyPct": pct(sum(r["any_instruction_file"] for r in rs), len(rs)),
        })

    langs = Counter(r["language"] for r in rows if r["language"])
    by_lang = []
    for lang, cnt in sorted(langs.items(), key=lambda kv: (-kv[1], kv[0])):
        if cnt < 150:
            continue
        rs = [r for r in rows if r["language"] == lang]
        by_lang.append({
            "language": lang,
            "repos": cnt,
            "agentsMdPct": pct(sum(r["agents_md"] for r in rs), cnt),
            "claudeMdPct": pct(sum(r["claude_md"] for r in rs), cnt),
            "anyPct": pct(sum(r["any_instruction_file"] for r in rs), cnt),
        })

    rel = Counter(r["claude_md_relation"] for r in rows if r["claude_md_relation"])
    both_rows = [r for r in rows if r["agents_md"] and r["claude_md"]]
    both = len(both_rows)
    # Claude Code, by default, reads CLAUDE.md and not AGENTS.md when a
    # repository has both (code.claude.com/docs/en/memory, "When Claude Code
    # reads AGENTS.md", checked 2026-09-27). What matters is whether CLAUDE.md
    # carries AGENTS.md's content (LOADS_AGENTS), only points at it, or
    # neither.
    loads = sum(rel.get(k, 0) for k in LOADS_AGENTS)
    points = rel.get("claude_points_to_agents", 0)
    separate = rel.get("separate", 0)
    imports = rel.get("claude_imports_agents", 0)
    # A committed symlink checks out on Windows as a one-line text file
    # unless git's core.symlinks is enabled (code.claude.com/docs/en/memory).
    symlinked = sum(rel.get(k, 0) for k in ("claude_symlinks_to_agents", "agents_symlinks_to_claude",
                                            "both_link_to_same_file"))
    agents_count = sum(r["agents_md"] for r in rows)
    claude_count = sum(r["claude_md"] for r in rows)
    separate_mentions = sum(1 for r in both_rows if r["claude_md_relation"] == "separate" and r["claude_md_mentions_agents"] == 1)
    loc = Counter(r["claude_md_location"] for r in rows if r["claude_md"])
    largest = sorted((r for r in rows if r["agents_md"]), key=lambda r: (-r["stars"], r["repo"]))[:12]

    study = {
        "snapshot": pop["snapshot"],
        "query": pop["query"],
        "population": {
            "searchTotal": pop["search_total_count"],
            "enumerated": len(pop["repos"]),
            "analysed": n,
            "unreadable": len(unreadable),
        },
        "adoption": {k: adoption(k) for k in FLAGS},
        "claudeLocation": {"root": loc.get("root", 0), "dotClaude": loc.get(".claude", 0), "both": loc.get("both", 0)},
        "both": {
            "count": both,
            "pct": pct(both, n),
            "ofAgentsPct": pct(both, agents_count),
            "ofClaudePct": pct(both, claude_count),
            "imports": imports,
            "importsPctOfBoth": pct(imports, both),
            "symlinked": symlinked,
            "symlinkedPctOfBoth": pct(symlinked, both),
            "notLoaded": points + separate,
            "notLoadedPctOfBoth": pct(points + separate, both),
            "loadsAgents": loads,
            "loadsAgentsPctOfBoth": pct(loads, both),
            "pointsOnly": points,
            "pointsOnlyPctOfBoth": pct(points, both),
            "separate": separate,
            "separatePctOfBoth": pct(separate, both),
            "separateMentionsAgents": separate_mentions,
        },
        "claudeRelation": dict(sorted(rel.items())),
        "byStars": by_band,
        "byLanguage": by_lang,
        "content": {"agents_md": content("agents_md"), "claude_md": content("claude_md")},
        "limits": {"codexMaxBytes": CODEX_MAX_BYTES, "claudeTargetLines": CLAUDE_TARGET_LINES},
        "mostStarredWithAgentsMd": [
            {"repo": r["repo"], "stars": r["stars"], "lines": r["agents_md_lines"], "words": r["agents_md_words"],
             "claudeRelation": r["claude_md_relation"]} for r in largest
        ],
        "csv": {"rows": n, "sha256": csv_sha, "bytes": len(csv_bytes)},
    }

    ts = [
        "/**",
        " * GENERATED by seo/research/agents-md-2026/analyze.py from the raw",
        " * collection of " + pop["snapshot"] + ". Do not edit by hand: re-run the script.",
        " * tests/unit/research-agents-md-study.test.ts recomputes these numbers",
        " * from the published CSV and fails if they disagree.",
        " */",
        "",
        "export const AGENTS_MD_STUDY = " + json.dumps(study, indent=2, ensure_ascii=False) + " as const;",
        "",
    ]
    Path(args.ts).write_text("\n".join(ts), encoding="utf-8")
    print(json.dumps({k: study[k] for k in ("population", "claudeLocation", "both", "claudeRelation")}, indent=1))
    print("adoption:", {k: v["pct"] for k, v in study["adoption"].items()})
    print(f"csv {n} rows, sha256 {csv_sha[:12]}…, {len(csv_bytes):,} bytes")


if __name__ == "__main__":
    main()
