mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 14:00:35 +00:00
* docs: turn README into an index and move reference detail to docs/guide The README had grown to 393 KB because every change appended its notes there. The text moves verbatim into 18 topic guides under docs/guide/; only headings are re-leveled and relative links re-pointed. The README keeps the introduction, a quick start and a guide index. Contributor test notes move to CONTRIBUTING.md. * docs: send user docs to topic guides and budget the root README The rule 'update README.md for user-facing changes' is what grew the README. Guidance now points to docs/guide/, and check_agent_guidance.py fails when the root README exceeds 16 KiB (AG003). * chore: remove unreferenced screenshots and finished plan notes pr-build/ holds two PR screenshots (3 MB) from #1986. plans/ holds the plan for the merged #4049. docs/SKILL_NAME_CONFLICT_FIX.md is a February change note. Nothing references them. * docs: route always-loaded AGENTS.md files to scoped guides * docs(agents): cut scoped guides to scope, verify, traps, decisions Each AGENTS.md keeps four kinds of content: the scope of the directory, the verification commands, traps a local test run does not show, and decisions the code cannot state. Values, field lists, function inventories, test names, issue numbers and history are deleted, because the code, the tests and git state them. Search and fetch providers share one community guide. Four guides with no remaining content are removed. The size check now allows 6 KiB per file (hard limit 8 KiB) and 24 KiB per chain (hard limit 32 KiB). * style: format frontend guide table and guidance test imports --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
323 lines
11 KiB
Python
323 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Check the size of directory-scoped AGENTS.md instruction chains and the root README."""
|
|
|
|
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
|
|
|
|
# Agents read every AGENTS.md from the edited directory up to the root on each
|
|
# task, so one file and the whole chain each have a budget. Codex stops loading
|
|
# the chain at 32 KiB.
|
|
AGENT_SOFT = 6 * 1024
|
|
AGENT_HARD = 8 * 1024
|
|
CHAIN_SOFT = 24 * 1024
|
|
CHAIN_HARD = 32 * 1024
|
|
# The root README is an index into docs/guide/; reference detail belongs in a guide.
|
|
README_PATH = PurePosixPath("README.md")
|
|
README_SOFT = 12 * 1024
|
|
README_HARD = 16 * 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 guidance_paths(paths: Iterable[PurePosixPath]) -> set[PurePosixPath]:
|
|
return {path for path in paths if path.name == "AGENTS.md"}
|
|
|
|
|
|
def budgeted_paths(paths: Iterable[PurePosixPath]) -> set[PurePosixPath]:
|
|
return {path for path in paths if path.name == "AGENTS.md" or path == README_PATH}
|
|
|
|
|
|
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,
|
|
remedy: str = "Delete what the code, the tests or git already state.",
|
|
) -> 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}. {remedy}",
|
|
)
|
|
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])
|
|
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=AGENT_SOFT,
|
|
hard=AGENT_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)
|
|
|
|
if README_PATH in head_files:
|
|
readme_finding = _budget_finding(
|
|
code="AG003",
|
|
path=README_PATH,
|
|
actual=normalized_utf8_size(head_files[README_PATH]),
|
|
soft=README_SOFT,
|
|
hard=README_HARD,
|
|
base_actual=(
|
|
normalized_utf8_size(base_files[README_PATH]) if README_PATH in base_files else None
|
|
),
|
|
relevant_change=_relevant_change([README_PATH], changed_paths),
|
|
label="Root README.md",
|
|
remedy="Move reference detail to a topic guide under docs/guide/ and link it.",
|
|
)
|
|
if readme_finding:
|
|
findings.append(readme_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 budgeted_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 = budgeted_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 budgeted_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 budgeted_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(guidance_paths(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())
|