blob: 06b8ccebb48cdabf24ae3fbf28f2ad0c1b0c2e09 [file] [edit]
#!/usr/bin/env python3
# Copyright 2025 Marc-Antoine Ruel. All rights reserved.
# Use of this source code is governed under the Apache License, Version 2.0
# that can be found in the LICENSE file.
"""AGENTS.md index generation and first-line summary validation.
To opt-in a directory, add these two markers to its AGENTS.md:
<!-- BEGIN FILE INDEX -->
<!-- END FILE INDEX -->
The script auto-discovers all non-ignored AGENTS.md files that contain the
markers, generates a file index from first-line comments, and injects it between
the markers. Repository-relative glob patterns in .agents-index-ignore exclude
content that should not require source-file summaries. Directories without
markers, including the repository root, are left unchanged.
"""
import argparse
import fnmatch
import os
import re
import subprocess
import sys
SUMMARY_SCAN_LINES = 20
INDEX_SUMMARY_GUIDANCE = f"""\
Add a one-line index summary (the first meaningful comment line):
- Keep it under 120 characters; favor compact keywords over sentences.
- Do not wrap it: later lines are ignored.
- It must be within the first {SUMMARY_SCAN_LINES} lines.
- Do not group it in license notice. Add an empty line to disambiguate.
- Omit the filename and generic labels such as "Goal"."""
EXAMPLES = """\
Index summary examples:
CSS:
/* task-list panel, filters, task state */
body { color: #fff; }
Markdown:
---
# development setup, build, test workflow
title: Development
---
# User visible title
Python:
#!/usr/bin/env python3
# Copyright 2026 Example Corp. All rights reserved.
# trace parsing, bottleneck diagnostics, report output
'''Foo bar'''
Go:
// Copyright 2026 Example Corp. All rights reserved.
// API response cache, persistent storage, TTL
// Package cache manages persistent API response caching.
package cache"""
HELP_EPILOG = f"{INDEX_SUMMARY_GUIDANCE}\n\n{EXAMPLES}"
def get_git_files() -> list[str]:
"""Return tracked and non-ignored untracked files, or [] on failure."""
try:
result = subprocess.run(
["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
capture_output=True,
text=True,
check=True,
)
return [f for f in result.stdout.split("\0") if f and os.path.lexists(f)]
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print(f"Error listing git files: {e}", file=sys.stderr)
return []
def _py_docstring(lines, i):
"""Extract the description from a Python triple-quoted docstring starting at lines[i].
Returns the description string, or "" if none found.
"""
sline = lines[i].strip()
quote = sline[:3]
# Single-line docstring: """text"""
if sline.endswith(quote) and len(sline) > 6:
return sline[3:-3].strip()
# Multi-line: return the first content line.
content = sline[3:].strip()
if content:
return content
# Opening quotes on their own line; use next non-empty line.
for j in range(i + 1, len(lines)):
if lines[j] and lines[j].strip():
return lines[j].strip()
return ""
def _c_style_block_comment(lines: list[str], i: int) -> str:
"""Extract the first meaningful line from a C-style block comment."""
for line_index, line in enumerate(lines[i:]):
text = line.strip()
if line_index == 0:
text = text[2:].strip()
end = text.find("*/")
if end >= 0:
text = text[:end].strip()
text = text.lstrip("*").strip()
if text:
return text
if end >= 0:
break
return ""
def get_file_description(filepath):
"""Return the description for a file, or None if not applicable.
Returns None if the file type has no comment convention (or is explicitly
excluded). Returns "" if the file supports comments but has no description,
which is treated as an error by callers.
"""
# Glob patterns mapping filenames to supported comment styles. None skips the file.
comment_styles = {
"*.min.*": None,
"*.d.ts": None,
"pnpm-lock.yaml": None,
"*.c": ("//", "/*"),
"*.cc": ("//", "/*"),
"*.cjs": ("//", "/*"),
"*.cpp": ("//", "/*"),
"*.css": ("/*",),
"*.cxx": ("//", "/*"),
"*.go": ("//", "/*"),
"*.h": ("//", "/*"),
"*.hh": ("//", "/*"),
"*.hpp": ("//", "/*"),
"*.hxx": ("//", "/*"),
"*.js": ("//", "/*"),
"*.kt": ("//", "/*"),
"*.md": ("#",),
"*.mjs": ("//", "/*"),
"*.py": ("#",),
"*.service": ("#",),
"*.sh": ("#",),
"*.swift": ("//", "/*"),
"*.toml": ("#",),
"*.ts": ("//", "/*"),
"*.tsx": ("//", "/*"),
"*.yaml": ("#",),
"*.yml": ("#",),
"Dockerfile*": ("#",),
"Makefile": ("#",),
}
if os.path.islink(filepath):
return None
fname = os.path.basename(filepath)
styles = next(
(style for pat, style in comment_styles.items() if fnmatch.fnmatch(fname, pat)),
None,
)
if not styles:
return None # unrecognised extension or explicitly excluded pattern
prefix = styles[0]
with open(filepath, encoding="utf-8") as f:
lines = [f.readline() for _ in range(SUMMARY_SCAN_LINES)]
in_copyright = False
for i, line in enumerate(lines):
if not line:
break
sline = line.strip()
if not sline:
in_copyright = False
continue
if fname.endswith(".py") and sline.startswith(('"""', "'''")):
return _py_docstring(lines, i)
if "/*" in styles and sline.startswith("/*"):
return _c_style_block_comment(lines, i)
# Skip common directives/metadata that aren't descriptions.
if sline.startswith(f"{prefix}go:"):
continue
if sline.startswith(f"{prefix} +build"):
continue
if sline.startswith(f"{prefix} nolint"):
continue
if sline.startswith(f"{prefix} swift-tools-version:"):
continue
if sline.startswith(f"{prefix} ///"):
continue
# Skip YAML front-matter delimiters and shebangs.
if sline == "---" or sline.startswith("#!"):
continue
# Skip copyright headers and their continuation lines (until blank).
if in_copyright:
continue
if " copyright " in sline.lower():
in_copyright = True
continue
# Skip PEP 723 inline script metadata fields (requires-python, dependencies, etc.)
# that appear between # /// script and # /// markers.
if fname.endswith(".py") and sline.startswith(
(f"{prefix} requires-", f"{prefix} dependencies"),
):
continue
if sline.startswith(prefix):
comment = sline[len(prefix) :].strip()
if not comment:
continue
return comment
# Hit code before a comment.
return ""
return ""
def discover_configs(all_files):
"""Auto-discover workspace roots from AGENTS.md files that contain a file index marker.
Returns a dict mapping target AGENTS.md path to its set of excluded child directories.
"""
candidates = sorted(f for f in all_files if os.path.basename(f) == "AGENTS.md")
configs = {}
for f in candidates:
with open(f, encoding="utf-8") as fh:
if "<!-- BEGIN FILE INDEX -->" in fh.read():
configs[f] = set()
# For each config, find child workspaces and add them to exclude_dirs.
for target, exclude in configs.items():
root = os.path.dirname(target)
prefix = root + "/" if root else ""
for other_target in configs:
oroot = os.path.dirname(other_target)
if oroot == root:
continue
if prefix:
if not oroot.startswith(prefix):
continue
child_rel = oroot[len(prefix) :]
else:
child_rel = oroot
exclude.add(child_rel)
return configs
def get_index_ignores() -> list[str]:
"""Read repository-relative exclusion patterns for non-source content."""
path = ".agents-index-ignore"
if not os.path.exists(path):
return []
with open(path, encoding="utf-8") as f:
lines = (line.strip() for line in f)
return [line for line in lines if line and not line.startswith("#")]
def generate_index(target, exclude, all_files, all_configs, ignored):
"""Generate the file index for target, returning (content, missing) where
missing is a list of files that support comments but have no description."""
root_dir = os.path.dirname(target)
files_found = []
missing = []
for filepath in all_files:
if any(fnmatch.fnmatch(filepath, pattern) for pattern in ignored):
continue
# Skip own AGENTS.md.
if filepath == target:
continue
# Scope to root_dir.
if root_dir:
if not filepath.startswith(root_dir + "/"):
continue
relpath = filepath[len(root_dir) + 1 :]
else:
relpath = filepath
# Check excluded subdirectories, but let sub-workspace AGENTS.md through.
rel_parts = relpath.replace("\\", "/").split("/")
if "testdata" in rel_parts:
continue
# A file is excluded if its path starts with any excluded prefix.
excluded = any(relpath.startswith(ex + "/") or relpath == ex for ex in exclude)
if excluded and filepath not in all_configs:
continue
desc = get_file_description(filepath)
if desc is None:
continue # file type has no comment convention
if desc == "":
missing.append(relpath)
else:
files_found.append((relpath, desc))
desc = "Autogenerated from first-line comments. Run scripts/update_agents_file_index.py to refresh."
lines = ["## File Index", "", desc, ""]
for path, comment in sorted(files_found):
lines.append(f"- `{path}`: {comment}")
return "\n".join(lines), missing
def update_markdown(target_file: str, content: str, check: bool) -> bool:
"""Update or check the file index in target_file. Returns True if a change was made (or needed)."""
if not os.path.exists(target_file):
print(f"Warning: {target_file} not found, skipping.")
return False
start = "<!-- BEGIN FILE INDEX -->"
end = "<!-- END FILE INDEX -->"
with open(target_file, encoding="utf-8") as f:
original = f.read()
new_section = f"{start}\n{content}\n{end}"
if start in original and end in original:
pattern = re.compile(f"{re.escape(start)}.*?{re.escape(end)}", re.DOTALL)
updated = pattern.sub(new_section, original)
else:
if original.strip():
updated = original.rstrip() + "\n\n" + new_section + "\n"
else:
updated = new_section + "\n"
if updated == original:
return False
if check:
print(
f"Error: {target_file} file index is out of date. Run scripts/update_agents_file_index.py to fix.",
file=sys.stderr,
)
return True
with open(target_file, "w", encoding="utf-8") as f:
f.write(updated)
print(f"Updated: {target_file}")
return False
def report_missing_index_summaries(missing: list[str]) -> None:
"""Print coding-agent guidance for files missing an index summary."""
print("Error: the following files are missing an index summary:", file=sys.stderr)
for filepath in sorted(missing):
print(f" {filepath}", file=sys.stderr)
print(INDEX_SUMMARY_GUIDANCE, file=sys.stderr)
print(
"Run scripts/update_agents_file_index.py --help for examples.",
file=sys.stderr,
)
def main() -> int:
"""Parse arguments and update (or check) the file indexes."""
parser = argparse.ArgumentParser(
description=__doc__,
epilog=HELP_EPILOG,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--check",
action="store_true",
help="check that indexes are up to date without modifying files (exit 1 if not)",
)
args = parser.parse_args()
all_files = get_git_files()
if not all_files:
print("No files found in git repository.")
return 1
ret = 0
configs = discover_configs(all_files)
ignored = get_index_ignores()
all_missing = []
indexes_out_of_date = False
for target, exclude in configs.items():
content, missing = generate_index(target, exclude, all_files, configs, ignored)
if update_markdown(target, content, check=args.check):
ret = 1
indexes_out_of_date = True
all_missing.extend(missing)
if all_missing:
report_missing_index_summaries(all_missing)
ret = 1
elif args.check and indexes_out_of_date:
print(INDEX_SUMMARY_GUIDANCE, file=sys.stderr)
print(
"Run scripts/update_agents_file_index.py --help for examples.",
file=sys.stderr,
)
return ret
if __name__ == "__main__":
sys.exit(main())