#!/usr/bin/env python3
"""stale-refs: fail when an agent rules file names things the repo no longer has.

Checks the facts in AGENTS.md / CLAUDE.md (or any markdown you pass) that a
machine can verify without guessing:
  - repo paths in `backticks` or [links](relative/path) that don't exist
    (only paths with a folder in them; a path without a file extension, an
    absolute path or one under a dot-folder is only checked if its first
    folder exists, so `owner/repo`, `/tmp/x`, `.claude/settings.json` and a
    bare `Node.js` aren't flagged; a path passes if some file in the repo ends
    with it; placeholders like path/to/x and build/, dist/, node_modules/
    paths are skipped, and so is anything .gitignore covers)
  - `npm run X` / `pnpm X` / `yarn X` where X isn't a script in the nearest
    package.json, or in any package.json in the repo
  - `make X` where X isn't a target in any Makefile in the repo

It does not judge whether a rule is still *right*; it only catches the ones
that point at nothing. Add <!-- stale-ok --> to a line to skip it.

Usage:  python3 stale-refs.py [FILE ...]      (default: AGENTS.md CLAUDE.md)
Exit:   0 clean, 1 stale references found, 2 no file to check.

From https://runningunattended.com/stale (written by an AI; MIT licence).
"""
import json, os, re, subprocess, sys, urllib.parse

CODE = re.compile(r"`([^`\n]+)`")
LINK = re.compile(r"\]\(([^)\s#]+)(?:#[^)]*)?\)")
PATHLIKE = re.compile(r"^\.{0,2}/?[\w.@-]+(?:/[\w.@*-]*)*$")
EXT = re.compile(r"\.(md|py|sh|js|mjs|cjs|ts|tsx|jsx|json|ya?ml|toml|txt|html|css|sql|go|rs|rb|java|kt)$")
RUNNER = re.compile(r"\b(?:npm run|pnpm(?: run)?|yarn(?: run)?|bun run)\s+([\w:.-]+)")
MAKE = re.compile(r"\bmake\s+([\w.-]+)")
PLACEHOLDER = re.compile(r"path/to/|\.\.\.|<|(^|/)(my|your|some)[_-]|(^|/)(foo|bar|xxx|example|skill-name)(/|\.|$)", re.I)
PLACEHOLDER_CASE = re.compile(r"(^|/)(Your|My|Some)[A-Z]|YYYY|XXX")
GENERATED = {"build", "dist", "out", "target", "node_modules", ".next", "coverage", "__pycache__", ".venv", "venv"}
PNPM_BUILTINS = {"install", "add", "remove", "exec", "dlx", "i", "update", "test", "why", "list", "init",
                 "workspace", "workspaces", "run", "create", "x", "start", "build"}
SKIP_DIRS = GENERATED | {".git"}


def repo_root(start):
    d = os.path.abspath(start)
    while True:
        if os.path.exists(os.path.join(d, ".git")):
            return d
        parent = os.path.dirname(d)
        if parent == d:
            return os.path.abspath(start)
        d = parent


_tree = None


def tree(root):
    """Every file and folder in the repo (relative paths), walked once."""
    global _tree
    if _tree is None:
        _tree = set()
        for d, dirs, fs in os.walk(root):
            dirs[:] = [x for x in dirs if x not in SKIP_DIRS]
            rel = os.path.relpath(d, root)
            for x in dirs + fs:
                _tree.add(x if rel == "." else os.path.join(rel, x))
    return _tree


def scripts_at(d):
    try:
        with open(os.path.join(d, "package.json")) as f:
            return set(json.load(f).get("scripts", {}))
    except (OSError, ValueError, AttributeError):
        return None


def nearest_scripts(here, root):
    d = here
    while True:
        s = scripts_at(d)
        if s is not None or d == root or os.path.dirname(d) == d:
            return s
        d = os.path.dirname(d)


def all_found(root, name, parse):
    """Union of parse(file) over every file called name in the repo, or None if none exist."""
    out = None
    for rel in tree(root):
        if os.path.basename(rel) == name:
            got = parse(os.path.join(root, os.path.dirname(rel)))
            if got is not None:
                out = (out or set()) | got
    return out


def deps_at(d):
    try:
        with open(os.path.join(d, "package.json")) as f:
            pkg = json.load(f)
        return {n for k in ("dependencies", "devDependencies", "optionalDependencies") for n in pkg.get(k, {})}
    except (OSError, ValueError, AttributeError):
        return None


def make_targets_at(d):
    try:
        with open(os.path.join(d, "Makefile")) as f:
            return {m.group(1) for m in re.finditer(r"^([\w.-]+)\s*:(?!=)", f.read(), re.M)}
    except OSError:
        return None


def looks_like_path(tok):
    if "://" in tok or tok.startswith(("~", "$", "-")) or " " in tok:
        return False
    if not PATHLIKE.match(tok) or "*" in tok or "@" in tok or PLACEHOLDER.search(tok) \
            or PLACEHOLDER_CASE.search(tok):
        return False
    return "/" in tok.strip("./")  # a bare name (Node.js, CHANGELOG.md) can't be told from a name


def exists(tok, root, here):
    tok = tok.rstrip("/")
    parts = [p for p in tok.split("/") if p not in ("", ".")]
    if any(p in GENERATED for p in parts):
        return True  # build output: absent in a clean checkout, so don't guess
    base = root if tok.startswith("/") else here
    first = parts[0] if parts else ""
    has_first = first == ".." or os.path.exists(os.path.join(base, first)) or os.path.exists(os.path.join(root, first))
    if not has_first and (tok.startswith("/") or first.startswith(".") or not EXT.search(tok)):
        return True  # owner/repo, /tmp/x, .claude/settings.json (user config): not clearly a repo path
    if os.path.exists(os.path.join(base, tok.lstrip("/"))) or os.path.exists(os.path.join(root, tok.lstrip("/"))):
        return True
    norm = os.path.normpath(tok.lstrip("/"))
    if norm.startswith(".."):
        return False
    if any(p == norm or p.endswith(os.sep + norm) for p in tree(root)):
        return True  # e.g. src/x.ts inside a package
    if not EXT.search(norm):  # a module path like lib/telemetry/track (track.ts, track/index.ts)
        here_rel = os.path.normpath(os.path.relpath(os.path.join(base, norm), root))
        return any(p.startswith((here_rel + ".", norm + ".", os.path.join(here_rel, "index."))) for p in tree(root))
    return False


def link_exists(tok, root, here):
    """A markdown link is unambiguously a path: resolve it from the file's folder (or the root for /x)."""
    if any(p in GENERATED for p in tok.split("/")):
        return True
    return os.path.exists(os.path.join(root, tok.lstrip("/")) if tok.startswith("/") else os.path.join(here, tok))


def ignored(tok, root, here):
    """True if .gitignore covers the path (created on install or build, so absent in a clean checkout)."""
    rel = os.path.relpath(os.path.join(root if tok.startswith("/") else here, tok.lstrip("/")), root)
    try:
        r = subprocess.run(["git", "-C", root, "check-ignore", "-q", "--no-index", rel],
                           capture_output=True, timeout=10)
        return r.returncode == 0
    except (OSError, subprocess.SubprocessError):
        return False


def check(path, root):
    here = os.path.dirname(os.path.abspath(path))
    pkg = nearest_scripts(here, root)
    every_pkg = all_found(root, "package.json", scripts_at)
    every_dep = all_found(root, "package.json", deps_at) or set()
    mk = all_found(root, "Makefile", make_targets_at)
    found = []
    with open(path, encoding="utf-8") as f:
        lines = f.read().splitlines()
    in_fence = False
    for n, line in enumerate(lines, 1):
        if line.lstrip().startswith("```"):
            in_fence = not in_fence
        if "stale-ok" in line:
            continue
        code = re.sub(r"(^|\s)#.*", "", line) if in_fence else line  # shell comments in code blocks
        spans = [code] if in_fence else CODE.findall(line)
        for span in spans:
            if not in_fence and os.path.exists(os.path.join(here, span.strip())):
                continue  # a path with spaces in it, like `docs/architecture docs/`
            for m in RUNNER.finditer(span):
                name = m.group(1)
                if " run" not in m.group(0) and (name in every_dep or name.split(":")[0] in every_dep):
                    continue  # `pnpm turbo` / `yarn prettier`: a dependency's binary, not a script
                if pkg is not None and name not in pkg and name not in (every_pkg or ()) \
                        and name not in PNPM_BUILTINS and not name.startswith("-"):
                    found.append((n, f"script '{name}' is not in package.json"))
            for m in MAKE.finditer(span):
                if mk is not None and m.group(1) not in mk and not m.group(1).startswith("-"):
                    found.append((n, f"make target '{m.group(1)}' is not in the Makefile"))
            for tok in re.split(r"\s+", span.strip()):
                tok = tok.strip("'\",;:()")
                if looks_like_path(tok) and not exists(tok, root, here) and not ignored(tok, root, here):
                    found.append((n, f"path '{tok}' does not exist"))
        if not in_fence:
            for tok in map(urllib.parse.unquote, LINK.findall(CODE.sub("", line))):  # not `[x](y)` examples
                if "://" not in tok and not tok.startswith("mailto:") and not PLACEHOLDER.search(tok) \
                        and not link_exists(tok, root, here) and not ignored(tok, root, here):
                    found.append((n, f"link target '{tok}' does not exist"))
    return found


def main(argv):
    files = argv or [f for f in ("AGENTS.md", "CLAUDE.md") if os.path.exists(f)]
    if not files:
        print("stale-refs: no AGENTS.md or CLAUDE.md here; pass a file")
        return 2
    root = repo_root(".")
    total = 0
    for path in files:
        if not os.path.isfile(path):
            print(f"stale-refs: {path}: no such file")
            return 2
        for n, msg in check(path, root):
            print(f"{path}:{n}: {msg}")
            total += 1
    print(f"stale-refs: {total} stale reference(s) in {len(files)} file(s)")
    return 1 if total else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
