mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-10 22:18:59 +00:00
307 lines
9.7 KiB
Python
307 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Check the size of directory-scoped AGENTS.md instruction chains."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import Iterable, Mapping, Sequence
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Literal, NamedTuple
|
|
|
|
ROOT_SOFT = 16 * 1024
|
|
ROOT_HARD = 20 * 1024
|
|
MODULE_SOFT = 28 * 1024
|
|
MODULE_HARD = 32 * 1024
|
|
LOCAL_SOFT = 40 * 1024
|
|
LOCAL_HARD = 48 * 1024
|
|
CHAIN_SOFT = 80 * 1024
|
|
CHAIN_HARD = 96 * 1024
|
|
|
|
|
|
class Finding(NamedTuple):
|
|
severity: Literal["error", "warning"]
|
|
code: str
|
|
path: PurePosixPath
|
|
message: str
|
|
|
|
|
|
def normalized_utf8_size(text: str) -> int:
|
|
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
return len(normalized.encode("utf-8"))
|
|
|
|
|
|
def agent_budget(path: PurePosixPath) -> tuple[int, int]:
|
|
if path == PurePosixPath("AGENTS.md"):
|
|
return ROOT_SOFT, ROOT_HARD
|
|
if len(path.parts) == 2:
|
|
return MODULE_SOFT, MODULE_HARD
|
|
return LOCAL_SOFT, LOCAL_HARD
|
|
|
|
|
|
def guidance_paths(paths: Iterable[PurePosixPath]) -> set[PurePosixPath]:
|
|
return {path for path in paths if path.name == "AGENTS.md"}
|
|
|
|
|
|
def _is_descendant_or_same(path: PurePosixPath, parent: PurePosixPath) -> bool:
|
|
try:
|
|
path.relative_to(parent)
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _ancestor_agents(
|
|
target: PurePosixPath,
|
|
candidates: Iterable[PurePosixPath],
|
|
) -> list[PurePosixPath]:
|
|
target_dir = target.parent
|
|
ancestors = [
|
|
candidate
|
|
for candidate in candidates
|
|
if _is_descendant_or_same(target_dir, candidate.parent)
|
|
]
|
|
return sorted(ancestors, key=lambda item: (len(item.parts), item.as_posix()))
|
|
|
|
|
|
def _relevant_change(
|
|
paths: Iterable[PurePosixPath],
|
|
changed_paths: set[PurePosixPath] | None,
|
|
) -> bool:
|
|
return changed_paths is None or any(path in changed_paths for path in paths)
|
|
|
|
|
|
def _budget_finding(
|
|
*,
|
|
code: str,
|
|
path: PurePosixPath,
|
|
actual: int,
|
|
soft: int,
|
|
hard: int,
|
|
base_actual: int | None,
|
|
relevant_change: bool,
|
|
label: str,
|
|
) -> Finding | None:
|
|
if actual > hard:
|
|
if base_actual is None or actual > base_actual:
|
|
return Finding(
|
|
"error",
|
|
code,
|
|
path,
|
|
f"{label} is {actual} bytes; hard limit is {hard}. Remove inherited or local instructions.",
|
|
)
|
|
if relevant_change:
|
|
return Finding(
|
|
"warning",
|
|
code,
|
|
path,
|
|
f"{label} remains above {hard} bytes at {actual}, but did not grow from {base_actual}.",
|
|
)
|
|
return None
|
|
if actual > soft and relevant_change:
|
|
return Finding(
|
|
"warning",
|
|
code,
|
|
path,
|
|
f"{label} is {actual} bytes; soft limit is {soft} and hard limit is {hard}.",
|
|
)
|
|
return None
|
|
|
|
|
|
def analyze(
|
|
repo_root: Path,
|
|
head_files: Mapping[PurePosixPath, str],
|
|
*,
|
|
base_files: Mapping[PurePosixPath, str] | None = None,
|
|
changed_paths: set[PurePosixPath] | None = None,
|
|
) -> list[Finding]:
|
|
"""Analyze AGENTS instructions. ``repo_root`` is kept for a stable test API."""
|
|
|
|
del repo_root
|
|
findings: list[Finding] = []
|
|
agents = guidance_paths(head_files)
|
|
base_files = base_files or {}
|
|
base_agents = guidance_paths(base_files)
|
|
|
|
for path in sorted(agents):
|
|
actual = normalized_utf8_size(head_files[path])
|
|
soft, hard = agent_budget(path)
|
|
base_actual = normalized_utf8_size(base_files[path]) if path in base_files else None
|
|
file_finding = _budget_finding(
|
|
code="AG001",
|
|
path=path,
|
|
actual=actual,
|
|
soft=soft,
|
|
hard=hard,
|
|
base_actual=base_actual,
|
|
relevant_change=_relevant_change([path], changed_paths),
|
|
label="AGENTS.md",
|
|
)
|
|
if file_finding:
|
|
findings.append(file_finding)
|
|
|
|
chain = _ancestor_agents(path, agents)
|
|
chain_actual = sum(normalized_utf8_size(head_files[item]) for item in chain)
|
|
base_chain = _ancestor_agents(path, base_agents)
|
|
base_chain_actual = (
|
|
sum(normalized_utf8_size(base_files[item]) for item in base_chain)
|
|
if base_files
|
|
else None
|
|
)
|
|
chain_finding = _budget_finding(
|
|
code="AG002",
|
|
path=path,
|
|
actual=chain_actual,
|
|
soft=CHAIN_SOFT,
|
|
hard=CHAIN_HARD,
|
|
base_actual=base_chain_actual,
|
|
relevant_change=_relevant_change(chain, changed_paths),
|
|
label="Effective AGENTS.md chain",
|
|
)
|
|
if chain_finding:
|
|
findings.append(chain_finding)
|
|
|
|
return sorted(findings, key=lambda finding: (finding.path.as_posix(), finding.code))
|
|
|
|
|
|
def _run_git(repo_root: Path, args: Sequence[str]) -> bytes:
|
|
result = subprocess.run(
|
|
["git", *args],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
detail = result.stderr.decode("utf-8", errors="replace").strip()
|
|
raise RuntimeError(f"git {' '.join(args)} failed: {detail}")
|
|
return result.stdout
|
|
|
|
|
|
def _parse_paths(output: bytes) -> set[PurePosixPath]:
|
|
return {
|
|
PurePosixPath(item.decode("utf-8", errors="surrogateescape"))
|
|
for item in output.split(b"\0")
|
|
if item
|
|
}
|
|
|
|
|
|
def _worktree_paths(repo_root: Path) -> set[PurePosixPath]:
|
|
return _parse_paths(
|
|
_run_git(repo_root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"])
|
|
)
|
|
|
|
|
|
def _load_worktree_agents(repo_root: Path) -> dict[PurePosixPath, str]:
|
|
files: dict[PurePosixPath, str] = {}
|
|
for path in guidance_paths(_worktree_paths(repo_root)):
|
|
local_path = repo_root / Path(path.as_posix())
|
|
if local_path.is_file():
|
|
files[path] = local_path.read_text(encoding="utf-8")
|
|
return files
|
|
|
|
|
|
def _load_ref_agents(repo_root: Path, ref: str | None) -> dict[PurePosixPath, str]:
|
|
if not ref or set(ref) == {"0"}:
|
|
return {}
|
|
paths = guidance_paths(
|
|
_parse_paths(_run_git(repo_root, ["ls-tree", "-r", "--name-only", "-z", ref]))
|
|
)
|
|
return {
|
|
path: _run_git(repo_root, ["show", f"{ref}:{path.as_posix()}"]).decode("utf-8")
|
|
for path in paths
|
|
}
|
|
|
|
|
|
def _changed_paths(
|
|
repo_root: Path,
|
|
base_ref: str | None,
|
|
head_ref: str | None,
|
|
*,
|
|
use_merge_base: bool,
|
|
) -> set[PurePosixPath]:
|
|
if base_ref and head_ref:
|
|
if set(base_ref) == {"0"}:
|
|
return set(_load_ref_agents(repo_root, head_ref))
|
|
revision_range = (
|
|
f"{base_ref}...{head_ref}" if use_merge_base else f"{base_ref}..{head_ref}"
|
|
)
|
|
return guidance_paths(
|
|
_parse_paths(
|
|
_run_git(repo_root, ["diff", "--name-only", "-z", revision_range, "--"])
|
|
)
|
|
)
|
|
tracked = _parse_paths(_run_git(repo_root, ["diff", "--name-only", "-z", "HEAD", "--"]))
|
|
untracked = _parse_paths(
|
|
_run_git(repo_root, ["ls-files", "--others", "--exclude-standard", "-z"])
|
|
)
|
|
return guidance_paths(tracked | untracked)
|
|
|
|
|
|
def _print_finding(finding: Finding, github_annotations: bool) -> None:
|
|
if github_annotations:
|
|
level = "error" if finding.severity == "error" else "warning"
|
|
print(f"::{level} file={finding.path.as_posix()},line=1,title={finding.code}::{finding.message}")
|
|
print(
|
|
f"{finding.severity.upper()} {finding.code} {finding.path.as_posix()}:1 — {finding.message}"
|
|
)
|
|
|
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1])
|
|
parser.add_argument("--base-ref")
|
|
parser.add_argument("--head-ref")
|
|
parser.add_argument("--before")
|
|
parser.add_argument("--after")
|
|
parser.add_argument("--github-annotations", action="store_true")
|
|
parser.add_argument("--strict-warnings", action="store_true")
|
|
return parser
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
args = _build_parser().parse_args(argv)
|
|
if bool(args.base_ref) != bool(args.head_ref):
|
|
raise SystemExit("--base-ref and --head-ref must be provided together")
|
|
if bool(args.before) != bool(args.after):
|
|
raise SystemExit("--before and --after must be provided together")
|
|
if args.base_ref and args.before:
|
|
raise SystemExit("choose either --base-ref/--head-ref or --before/--after")
|
|
|
|
repo_root = args.repo_root.resolve()
|
|
base_ref = args.base_ref or args.before
|
|
head_ref = args.head_ref or args.after
|
|
try:
|
|
if head_ref:
|
|
head_files = _load_ref_agents(repo_root, head_ref)
|
|
base_files = _load_ref_agents(repo_root, base_ref)
|
|
else:
|
|
head_files = _load_worktree_agents(repo_root)
|
|
base_files = {}
|
|
changed = _changed_paths(
|
|
repo_root,
|
|
base_ref,
|
|
head_ref,
|
|
use_merge_base=bool(args.base_ref),
|
|
)
|
|
findings = analyze(
|
|
repo_root,
|
|
head_files,
|
|
base_files=base_files if base_ref else None,
|
|
changed_paths=changed,
|
|
)
|
|
except (OSError, RuntimeError, UnicodeError) as exc:
|
|
print(f"ERROR AG000 {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
for finding in findings:
|
|
_print_finding(finding, args.github_annotations)
|
|
errors = sum(finding.severity == "error" for finding in findings)
|
|
warnings = sum(finding.severity == "warning" for finding in findings)
|
|
print(f"Agent guidance check: {len(head_files)} AGENTS.md, {errors} errors, {warnings} warnings.")
|
|
return 1 if errors or (warnings and args.strict_warnings) else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|