-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathframework_docs.py
More file actions
172 lines (141 loc) · 6.37 KB
/
Copy pathframework_docs.py
File metadata and controls
172 lines (141 loc) · 6.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
"""Framework docs awareness — detects and loads framework documentation into agent context."""
from __future__ import annotations
import logging
from pathlib import Path
log = logging.getLogger(__name__)
# Max chars to read from a single bundled doc file (avoid giant prompts)
_MAX_DOC_CHARS = 4000
# Max total chars for all bundled docs combined (used as default for _read_bundled_docs)
_MAX_TOTAL_BUNDLED = 12000
_SCAFFOLD_HINT = (
"No framework-specific docs found. "
"If you scaffold a new project, check for AGENTS.md afterwards."
)
class FrameworkDocsLoader:
"""Detects framework docs in a project workspace and returns context to inject into prompts.
Priority order:
1. AGENTS.md / CLAUDE.md — walked up from project_dir to filesystem root
2. Config-defined framework bundled docs or summaries
3. Scaffold hint (when framework_docs config is present but no layers matched)
Config schema::
{
"framework_docs": {
"check_agents_md": True, # optional, default True
"frameworks": [ # list of dicts
{
"name": "nextjs",
"detect": ["package.json"], # glob patterns; any match = detected
"summary": "Next.js ...", # included verbatim when detected
"bundled_docs_path": "node_modules/next/dist/docs" # optional
}
]
}
}
"""
def __init__(self, config: dict) -> None:
self._raw = config or {}
self._cfg: dict = self._raw.get("framework_docs") or {}
def load(self, project_dir: Path) -> str:
"""Return a context string to prepend to engineer prompts, or empty string if nothing found.
Collects ALL available context: AGENTS.md/CLAUDE.md (if present) AND any
config-driven framework docs. Both are returned together so project-specific
instructions and framework API docs are never mutually exclusive.
Args:
project_dir: Absolute path to the project workspace directory.
Returns:
Non-empty context string when anything is found, ``""`` when the
``framework_docs`` config key is absent entirely, or the scaffold hint
when the config key is present but neither layer produced content.
"""
if not self._raw.get("framework_docs"):
return ""
sections: list[str] = []
# Layer 1 — walk up directory tree for AGENTS.md / CLAUDE.md
if self._cfg.get("check_agents_md", True):
current = project_dir
found_agents = False
while not found_agents:
for filename in ("AGENTS.md", "CLAUDE.md"):
candidate = current / filename
if candidate.is_file():
try:
content = candidate.read_text(encoding="utf-8", errors="replace").strip()
except OSError as exc:
log.warning("Could not read %s: %s", candidate, exc)
continue
if content:
log.info("Loaded framework context from %s", candidate)
sections.append(
f"## Framework Instructions ({filename})\n\n{content}"
)
found_agents = True
break # AGENTS.md takes priority — only include one per level
if found_agents:
break
parent = current.parent
if parent == current: # reached filesystem root
break
current = parent
# Layer 2 — Config-driven framework detection
frameworks = self._cfg.get("frameworks", [])
for fw in frameworks:
name = fw.get("name", "")
detect_patterns: list[str] = fw.get("detect", [])
if not detect_patterns:
log.warning("Framework %r has no 'detect' patterns — skipping.", name)
continue
# Detect: any glob pattern under project_dir matches → framework present
matched = any(
True
for pattern in detect_patterns
for _ in project_dir.glob(pattern)
)
if not matched:
continue
log.info("Detected framework: %s", name)
summary = fw.get("summary", "")
bundled_path = fw.get("bundled_docs_path")
bundled_text = ""
if bundled_path:
bundled_text = self._read_bundled_docs(
project_dir / bundled_path, _MAX_TOTAL_BUNDLED
)
# always include summary
sections.append(f"## {name}\n\n{summary}")
# also include bundled docs if available
if bundled_text:
sections.append(bundled_text)
# Layer 3 — scaffold hint when config was present but nothing matched
if not sections:
return _SCAFFOLD_HINT
return "\n\n---\n\n".join(sections) + "\n\n---\n\n"
def _read_bundled_docs(self, path: Path, max_chars: int) -> str:
"""Read all ``*.md`` files recursively from *path*, capped at *max_chars* total.
The header ``### filename\\n\\n`` counts toward the cap alongside content.
Args:
path: Directory to search for markdown files.
max_chars: Maximum total characters to return across all files.
Returns:
Concatenated markdown content, or ``""`` if *path* does not exist.
"""
if not path.exists():
return ""
parts: list[str] = []
total = 0
for doc_file in sorted(path.rglob("*.md")):
remaining = max_chars - total
if remaining <= 0:
break
try:
text = doc_file.read_text(encoding="utf-8", errors="replace").strip()
except OSError:
continue
if not text:
continue
chunk = text[:_MAX_DOC_CHARS]
entry = f"### {doc_file.name}\n\n{chunk}\n\n"
if len(entry) > remaining:
entry = entry[:remaining]
parts.append(entry)
total += len(entry) # header + content count toward cap
return "".join(parts)