context-loader 0.1.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- context_loader/__init__.py +3 -0
- context_loader/application.py +197 -0
- context_loader/cli.py +75 -0
- context_loader/collect.py +993 -0
- context_loader/git.py +348 -0
- context_loader/render.py +377 -0
- context_loader-0.1.8.dist-info/METADATA +555 -0
- context_loader-0.1.8.dist-info/RECORD +10 -0
- context_loader-0.1.8.dist-info/WHEEL +4 -0
- context_loader-0.1.8.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
"""Collect bounded root-file and directory context without executing repository code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import errno
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import stat
|
|
11
|
+
import tomllib
|
|
12
|
+
import unicodedata
|
|
13
|
+
from bisect import bisect_right
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
from pathlib import Path, PurePosixPath
|
|
16
|
+
|
|
17
|
+
AGENTS_LIMIT_BYTES = 16 * 1024
|
|
18
|
+
AGENTS_HEAD_LIMIT_BYTES = 4 * 1024
|
|
19
|
+
AGENTS_SCAN_LIMIT_BYTES = 256 * 1024
|
|
20
|
+
AGENTS_FOCUS_LIMIT_BYTES = 1024
|
|
21
|
+
AGENTS_PATH_LIMIT_BYTES = 1024
|
|
22
|
+
README_LIMIT_BYTES = 16 * 1024
|
|
23
|
+
ENTRY_FILE_LIMIT_BYTES = 8 * 1024
|
|
24
|
+
ENTRY_FILES_TOTAL_LIMIT_BYTES = 24 * 1024
|
|
25
|
+
DECLARED_COMMANDS_LIMIT_BYTES = 8 * 1024
|
|
26
|
+
DIRECTORY_TREE_LIMIT_BYTES = 12 * 1024
|
|
27
|
+
DIRECTORY_TREE_MAX_ITEMS = 300
|
|
28
|
+
DIRECTORY_TREE_MAX_DEPTH = 2
|
|
29
|
+
TRUNCATION_MARKER = "… truncated by context-loader …"
|
|
30
|
+
|
|
31
|
+
NOT_PRESENT = "Not present."
|
|
32
|
+
SKIPPED_SYMLINK = "Skipped: symlink."
|
|
33
|
+
SKIPPED_NOT_REGULAR = "Skipped: not a regular file."
|
|
34
|
+
SKIPPED_ENCODING = "Skipped: unsupported text encoding."
|
|
35
|
+
SKIPPED_UNREADABLE = "Skipped: unreadable."
|
|
36
|
+
|
|
37
|
+
ENTRY_FILE_SPECS = (
|
|
38
|
+
("pyproject.toml", "toml"),
|
|
39
|
+
("package.json", "json"),
|
|
40
|
+
("Makefile", "make"),
|
|
41
|
+
("Cargo.toml", "toml"),
|
|
42
|
+
("go.mod", "text"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_JUST_RECIPE_RE = re.compile(
|
|
46
|
+
r"^([A-Za-z][A-Za-z0-9_-]*)"
|
|
47
|
+
r"(?:[ \t]+(?:[+*]?[A-Za-z_][A-Za-z0-9_-]*"
|
|
48
|
+
r"(?:=(?:\"[^\"\r\n]*\"|'[^'\r\n]*'|[^ \t:#]+))?))*"
|
|
49
|
+
r"[ \t]*:(?!=)"
|
|
50
|
+
)
|
|
51
|
+
_JUST_ATTRIBUTE_RE = re.compile(r"^\[([^\]]+)\][ \t]*$")
|
|
52
|
+
_MAKE_TARGET_RE = re.compile(
|
|
53
|
+
r"^([A-Za-z0-9][A-Za-z0-9_.-]*"
|
|
54
|
+
r"(?:[ \t]+[A-Za-z0-9][A-Za-z0-9_.-]*)*)[ \t]*:(?![:=])"
|
|
55
|
+
)
|
|
56
|
+
_ATX_HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*)|[ \t]*)$")
|
|
57
|
+
_TOKEN_RE = re.compile(r"[^\W_]+", re.UNICODE)
|
|
58
|
+
_MATCH_TOKEN_STOPWORDS = frozenset(
|
|
59
|
+
{
|
|
60
|
+
"and",
|
|
61
|
+
"code",
|
|
62
|
+
"docs",
|
|
63
|
+
"file",
|
|
64
|
+
"files",
|
|
65
|
+
"for",
|
|
66
|
+
"from",
|
|
67
|
+
"into",
|
|
68
|
+
"must",
|
|
69
|
+
"path",
|
|
70
|
+
"project",
|
|
71
|
+
"section",
|
|
72
|
+
"should",
|
|
73
|
+
"source",
|
|
74
|
+
"task",
|
|
75
|
+
"test",
|
|
76
|
+
"tests",
|
|
77
|
+
"that",
|
|
78
|
+
"the",
|
|
79
|
+
"this",
|
|
80
|
+
"when",
|
|
81
|
+
"where",
|
|
82
|
+
"with",
|
|
83
|
+
"work",
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
_PATH_TOKEN_STOPWORDS = _MATCH_TOKEN_STOPWORDS | {
|
|
87
|
+
"app",
|
|
88
|
+
"apps",
|
|
89
|
+
"application",
|
|
90
|
+
"backend",
|
|
91
|
+
"bin",
|
|
92
|
+
"data",
|
|
93
|
+
"doc",
|
|
94
|
+
"frontend",
|
|
95
|
+
"ingestion",
|
|
96
|
+
"lib",
|
|
97
|
+
"package",
|
|
98
|
+
"packages",
|
|
99
|
+
"platform",
|
|
100
|
+
"provider",
|
|
101
|
+
"providers",
|
|
102
|
+
"py",
|
|
103
|
+
"python",
|
|
104
|
+
"quant",
|
|
105
|
+
"scripts",
|
|
106
|
+
"src",
|
|
107
|
+
}
|
|
108
|
+
_SELECTION_REASON_ORDER = (
|
|
109
|
+
"head",
|
|
110
|
+
"focus_match",
|
|
111
|
+
"path_match",
|
|
112
|
+
"parent_context",
|
|
113
|
+
"budget_fallback",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AgentsSelectionInputError(ValueError):
|
|
118
|
+
"""Raised when optional AGENTS selection inputs exceed the bounded contract."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class MarkdownSectionParseError(ValueError):
|
|
122
|
+
"""Raised when Markdown headings cannot be parsed safely."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True, slots=True)
|
|
126
|
+
class MarkdownSection:
|
|
127
|
+
heading: str
|
|
128
|
+
heading_level: int
|
|
129
|
+
start: int
|
|
130
|
+
end: int
|
|
131
|
+
text: str
|
|
132
|
+
normalized_heading: str
|
|
133
|
+
parent_index: int | None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class AgentsSectionAuditEntry:
|
|
138
|
+
heading: str
|
|
139
|
+
heading_level: int
|
|
140
|
+
reasons: tuple[str, ...]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, slots=True)
|
|
144
|
+
class AgentsSelectionAudit:
|
|
145
|
+
source: str
|
|
146
|
+
selected_sections: tuple[AgentsSectionAuditEntry, ...]
|
|
147
|
+
indexed_only_sections: tuple[AgentsSectionAuditEntry, ...]
|
|
148
|
+
chars_selected: int
|
|
149
|
+
chars_omitted: int
|
|
150
|
+
truncated: bool
|
|
151
|
+
parse_fallback: bool = False
|
|
152
|
+
source_scan_truncated: bool = False
|
|
153
|
+
index_truncated: bool = False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True, slots=True)
|
|
157
|
+
class CollectedFile:
|
|
158
|
+
name: str
|
|
159
|
+
language: str
|
|
160
|
+
status: str | None
|
|
161
|
+
content: str = ""
|
|
162
|
+
truncated: bool = False
|
|
163
|
+
selection: AgentsSelectionAudit | None = None
|
|
164
|
+
source_characters: int = 0
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def is_text(self) -> bool:
|
|
168
|
+
return self.status is None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True, slots=True)
|
|
172
|
+
class DeclaredCommand:
|
|
173
|
+
source: str
|
|
174
|
+
invocation: str | None = None
|
|
175
|
+
target: str | None = None
|
|
176
|
+
parse_error: bool = False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True, slots=True)
|
|
180
|
+
class TreeEntry:
|
|
181
|
+
path: str
|
|
182
|
+
kind: str
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True)
|
|
186
|
+
class DirectoryTree:
|
|
187
|
+
entries: tuple[TreeEntry, ...]
|
|
188
|
+
truncated: bool = False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True, slots=True)
|
|
192
|
+
class ProjectContext:
|
|
193
|
+
instructions: CollectedFile
|
|
194
|
+
overview: CollectedFile
|
|
195
|
+
entry_files: tuple[CollectedFile, ...]
|
|
196
|
+
commands: tuple[DeclaredCommand, ...]
|
|
197
|
+
directory_tree: DirectoryTree
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True, slots=True)
|
|
201
|
+
class _SelectionSignals:
|
|
202
|
+
focus_tokens: frozenset[str]
|
|
203
|
+
path_tokens: frozenset[str]
|
|
204
|
+
normalized_path: str | None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _normalized_text(value: str) -> str:
|
|
208
|
+
return unicodedata.normalize("NFKC", value).casefold()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _matching_tokens(value: str, stopwords: frozenset[str]) -> frozenset[str]:
|
|
212
|
+
return frozenset(
|
|
213
|
+
token
|
|
214
|
+
for token in _TOKEN_RE.findall(_normalized_text(value))
|
|
215
|
+
if len(token) >= 3 and token not in stopwords
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _bounded_optional_input(name: str, value: str | None, limit: int) -> str | None:
|
|
220
|
+
if value is None:
|
|
221
|
+
return None
|
|
222
|
+
if not isinstance(value, str):
|
|
223
|
+
raise AgentsSelectionInputError(f"{name} must be a string")
|
|
224
|
+
bounded = value.strip()
|
|
225
|
+
if not bounded:
|
|
226
|
+
return None
|
|
227
|
+
if len(bounded.encode("utf-8")) > limit:
|
|
228
|
+
raise AgentsSelectionInputError(f"{name} exceeds {limit} bytes")
|
|
229
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in bounded):
|
|
230
|
+
raise AgentsSelectionInputError(f"{name} contains control characters")
|
|
231
|
+
return bounded
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _selection_signals(focus: str | None, path: str | None) -> _SelectionSignals:
|
|
235
|
+
bounded_focus = _bounded_optional_input("focus", focus, AGENTS_FOCUS_LIMIT_BYTES)
|
|
236
|
+
bounded_path = _bounded_optional_input("path", path, AGENTS_PATH_LIMIT_BYTES)
|
|
237
|
+
normalized_path: str | None = None
|
|
238
|
+
path_tokens: frozenset[str] = frozenset()
|
|
239
|
+
if bounded_path is not None:
|
|
240
|
+
candidate = PurePosixPath(bounded_path)
|
|
241
|
+
if candidate.is_absolute() or ".." in candidate.parts:
|
|
242
|
+
raise AgentsSelectionInputError("path must be repository-relative without '..'")
|
|
243
|
+
normalized_path = candidate.as_posix()
|
|
244
|
+
if normalized_path == ".":
|
|
245
|
+
normalized_path = None
|
|
246
|
+
path_tokens = _matching_tokens(normalized_path or "", _PATH_TOKEN_STOPWORDS)
|
|
247
|
+
return _SelectionSignals(
|
|
248
|
+
focus_tokens=_matching_tokens(bounded_focus or "", _MATCH_TOKEN_STOPWORDS),
|
|
249
|
+
path_tokens=path_tokens,
|
|
250
|
+
normalized_path=normalized_path,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _ordered_reasons(reasons: set[str] | frozenset[str]) -> tuple[str, ...]:
|
|
255
|
+
return tuple(reason for reason in _SELECTION_REASON_ORDER if reason in reasons)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
_AUDIT_EMPTY_LIST_LINE = "- None."
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _audit_heading(heading: str) -> str:
|
|
262
|
+
safe = heading.replace("`", r"\x60").strip()
|
|
263
|
+
if len(safe) <= 160:
|
|
264
|
+
return safe
|
|
265
|
+
return f"{safe[:159]}…"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _audit_index_line(entry: AgentsSectionAuditEntry) -> str:
|
|
269
|
+
return f"- H{entry.heading_level} {_audit_heading(entry.heading)}"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def render_agents_selection_audit(audit: AgentsSelectionAudit) -> str:
|
|
273
|
+
"""Render the bounded, prompt-free AGENTS selection audit and fail-safe index."""
|
|
274
|
+
lines = [
|
|
275
|
+
"### AGENTS Selection Audit",
|
|
276
|
+
"",
|
|
277
|
+
f"- Source: `{audit.source}`",
|
|
278
|
+
f"- Selected source characters: `{audit.chars_selected}`",
|
|
279
|
+
f"- Omitted source characters: `{audit.chars_omitted}`",
|
|
280
|
+
f"- Selection truncated: `{'true' if audit.truncated else 'false'}`",
|
|
281
|
+
"",
|
|
282
|
+
"Selected AGENTS sections (rule text loaded):",
|
|
283
|
+
]
|
|
284
|
+
if audit.selected_sections:
|
|
285
|
+
for entry in audit.selected_sections:
|
|
286
|
+
label = "Document head" if entry.heading_level == 0 else _audit_heading(entry.heading)
|
|
287
|
+
reasons = ", ".join(entry.reasons)
|
|
288
|
+
lines.append(f"- {label} — `{reasons}`")
|
|
289
|
+
else:
|
|
290
|
+
lines.append(_AUDIT_EMPTY_LIST_LINE)
|
|
291
|
+
lines.extend(
|
|
292
|
+
(
|
|
293
|
+
"",
|
|
294
|
+
"Additional AGENTS sections not loaded (index only; rule text not loaded):",
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
if audit.indexed_only_sections:
|
|
298
|
+
for entry in audit.indexed_only_sections:
|
|
299
|
+
lines.append(_audit_index_line(entry))
|
|
300
|
+
else:
|
|
301
|
+
lines.append(_AUDIT_EMPTY_LIST_LINE)
|
|
302
|
+
if audit.truncated and not audit.index_truncated:
|
|
303
|
+
lines.append("- Omitted content has no available heading index; read the source path.")
|
|
304
|
+
if audit.index_truncated:
|
|
305
|
+
lines.append("- Additional headings omitted from this index by the AGENTS budget.")
|
|
306
|
+
if audit.parse_fallback:
|
|
307
|
+
lines.append("- Heading parsing was unsafe; use the source path for manual recovery.")
|
|
308
|
+
if audit.source_scan_truncated:
|
|
309
|
+
lines.append("- The bounded source scan ended before EOF; later headings may be absent.")
|
|
310
|
+
return "\n".join(lines)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _fence_marker(line: str) -> tuple[str, int, str] | None:
|
|
314
|
+
stripped = line.lstrip(" ")
|
|
315
|
+
if len(line) - len(stripped) > 3 or not stripped or stripped[0] not in {"`", "~"}:
|
|
316
|
+
return None
|
|
317
|
+
character = stripped[0]
|
|
318
|
+
length = len(stripped) - len(stripped.lstrip(character))
|
|
319
|
+
if length < 3:
|
|
320
|
+
return None
|
|
321
|
+
return character, length, stripped[length:]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _parse_markdown_sections(content: str) -> tuple[MarkdownSection, ...]:
|
|
325
|
+
headings: list[tuple[str, int, int]] = []
|
|
326
|
+
offset = 0
|
|
327
|
+
open_fence: tuple[str, int] | None = None
|
|
328
|
+
for line in content.splitlines(keepends=True):
|
|
329
|
+
visible = line.removesuffix("\n")
|
|
330
|
+
marker = _fence_marker(visible)
|
|
331
|
+
if open_fence is not None:
|
|
332
|
+
if marker is not None:
|
|
333
|
+
character, length, remainder = marker
|
|
334
|
+
if character == open_fence[0] and length >= open_fence[1] and not remainder.strip():
|
|
335
|
+
open_fence = None
|
|
336
|
+
offset += len(line)
|
|
337
|
+
continue
|
|
338
|
+
if marker is not None:
|
|
339
|
+
character, length, _remainder = marker
|
|
340
|
+
open_fence = (character, length)
|
|
341
|
+
offset += len(line)
|
|
342
|
+
continue
|
|
343
|
+
match = _ATX_HEADING_RE.fullmatch(visible)
|
|
344
|
+
if match is not None:
|
|
345
|
+
heading = (match.group(2) or "").rstrip()
|
|
346
|
+
heading = re.sub(r"[ \t]+#+[ \t]*$", "", heading).strip()
|
|
347
|
+
headings.append((heading, len(match.group(1)), offset))
|
|
348
|
+
offset += len(line)
|
|
349
|
+
if open_fence is not None:
|
|
350
|
+
raise MarkdownSectionParseError("unclosed fenced code block")
|
|
351
|
+
|
|
352
|
+
sections: list[MarkdownSection] = []
|
|
353
|
+
parents: list[int] = []
|
|
354
|
+
for index, (heading, level, start) in enumerate(headings):
|
|
355
|
+
while parents and sections[parents[-1]].heading_level >= level:
|
|
356
|
+
parents.pop()
|
|
357
|
+
parent_index = parents[-1] if parents else None
|
|
358
|
+
end = headings[index + 1][2] if index + 1 < len(headings) else len(content)
|
|
359
|
+
sections.append(
|
|
360
|
+
MarkdownSection(
|
|
361
|
+
heading=heading,
|
|
362
|
+
heading_level=level,
|
|
363
|
+
start=start,
|
|
364
|
+
end=end,
|
|
365
|
+
text=content[start:end],
|
|
366
|
+
normalized_heading=" ".join(_TOKEN_RE.findall(_normalized_text(heading))),
|
|
367
|
+
parent_index=parent_index,
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
parents.append(index)
|
|
371
|
+
return tuple(sections)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _line_safe_prefix_end(content: str, limit: int) -> int:
|
|
375
|
+
encoded = content.encode("utf-8")
|
|
376
|
+
if len(encoded) <= limit:
|
|
377
|
+
return len(content)
|
|
378
|
+
boundary = encoded.rfind(b"\n", 0, limit + 1)
|
|
379
|
+
if boundary < 0:
|
|
380
|
+
return 0
|
|
381
|
+
return len(encoded[: boundary + 1].decode("utf-8"))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _small_head_end(content: str, sections: tuple[MarkdownSection, ...]) -> int:
|
|
385
|
+
if len(content.encode("utf-8")) <= AGENTS_HEAD_LIMIT_BYTES:
|
|
386
|
+
return len(content)
|
|
387
|
+
document_head_end = sections[0].start if sections else len(content)
|
|
388
|
+
if len(content[:document_head_end].encode("utf-8")) > AGENTS_HEAD_LIMIT_BYTES:
|
|
389
|
+
return _line_safe_prefix_end(content[:document_head_end], AGENTS_HEAD_LIMIT_BYTES)
|
|
390
|
+
boundary = document_head_end
|
|
391
|
+
for section in sections:
|
|
392
|
+
if len(content[: section.end].encode("utf-8")) > AGENTS_HEAD_LIMIT_BYTES:
|
|
393
|
+
break
|
|
394
|
+
boundary = section.end
|
|
395
|
+
if boundary == 0:
|
|
396
|
+
return _line_safe_prefix_end(content, AGENTS_HEAD_LIMIT_BYTES)
|
|
397
|
+
return boundary
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _section_relevance(
|
|
401
|
+
section: MarkdownSection, signals: _SelectionSignals
|
|
402
|
+
) -> tuple[int, frozenset[str]]:
|
|
403
|
+
heading_tokens = _matching_tokens(section.heading, _MATCH_TOKEN_STOPWORDS)
|
|
404
|
+
section_tokens = _matching_tokens(section.text, _MATCH_TOKEN_STOPWORDS)
|
|
405
|
+
body_tokens = section_tokens - heading_tokens
|
|
406
|
+
score = 0
|
|
407
|
+
reasons: set[str] = set()
|
|
408
|
+
|
|
409
|
+
focus_heading = signals.focus_tokens & heading_tokens
|
|
410
|
+
focus_body = signals.focus_tokens & body_tokens
|
|
411
|
+
if focus_heading or focus_body:
|
|
412
|
+
reasons.add("focus_match")
|
|
413
|
+
score += 6 * len(focus_heading) + len(focus_body)
|
|
414
|
+
|
|
415
|
+
path_heading = signals.path_tokens & heading_tokens
|
|
416
|
+
path_body = signals.path_tokens & body_tokens
|
|
417
|
+
normalized_section = _normalized_text(section.text)
|
|
418
|
+
exact_path = bool(
|
|
419
|
+
signals.normalized_path and _normalized_text(signals.normalized_path) in normalized_section
|
|
420
|
+
)
|
|
421
|
+
if exact_path or path_heading or path_body:
|
|
422
|
+
reasons.add("path_match")
|
|
423
|
+
score += (10 if exact_path else 0) + 5 * len(path_heading) + 2 * len(path_body)
|
|
424
|
+
return score, frozenset(reasons)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _merge_intervals(intervals: list[tuple[int, int]]) -> tuple[tuple[int, int], ...]:
|
|
428
|
+
merged: list[tuple[int, int]] = []
|
|
429
|
+
for start, end in sorted(intervals):
|
|
430
|
+
if start >= end:
|
|
431
|
+
continue
|
|
432
|
+
if merged and start <= merged[-1][1]:
|
|
433
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
|
434
|
+
else:
|
|
435
|
+
merged.append((start, end))
|
|
436
|
+
return tuple(merged)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _interval_is_covered(
|
|
440
|
+
start: int,
|
|
441
|
+
end: int,
|
|
442
|
+
intervals: tuple[tuple[int, int], ...],
|
|
443
|
+
starts: tuple[int, ...],
|
|
444
|
+
) -> bool:
|
|
445
|
+
"""Whether one merged interval covers [start, end); intervals are disjoint and sorted."""
|
|
446
|
+
position = bisect_right(starts, start) - 1
|
|
447
|
+
return position >= 0 and end <= intervals[position][1]
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _selection_state(
|
|
451
|
+
content: str,
|
|
452
|
+
sections: tuple[MarkdownSection, ...],
|
|
453
|
+
head_end: int,
|
|
454
|
+
selected_indices: frozenset[int],
|
|
455
|
+
reasons: dict[int, set[str]],
|
|
456
|
+
budget_fallback: frozenset[int],
|
|
457
|
+
*,
|
|
458
|
+
parse_fallback: bool,
|
|
459
|
+
source_scan_truncated: bool,
|
|
460
|
+
source_characters: int,
|
|
461
|
+
) -> tuple[str, AgentsSelectionAudit]:
|
|
462
|
+
intervals = [(0, head_end)] if head_end else []
|
|
463
|
+
intervals.extend((sections[index].start, sections[index].end) for index in selected_indices)
|
|
464
|
+
merged = _merge_intervals(intervals)
|
|
465
|
+
merged_starts = tuple(start for start, _end in merged)
|
|
466
|
+
selected_content = "".join(content[start:end] for start, end in merged)
|
|
467
|
+
|
|
468
|
+
selected_entries: list[AgentsSectionAuditEntry] = []
|
|
469
|
+
document_head_end = sections[0].start if sections else len(content)
|
|
470
|
+
if document_head_end and _interval_is_covered(
|
|
471
|
+
0, min(document_head_end, head_end), merged, merged_starts
|
|
472
|
+
):
|
|
473
|
+
selected_entries.append(AgentsSectionAuditEntry("Document head", 0, ("head",)))
|
|
474
|
+
indexed_entries: list[AgentsSectionAuditEntry] = []
|
|
475
|
+
for index, section in enumerate(sections):
|
|
476
|
+
if _interval_is_covered(section.start, section.end, merged, merged_starts):
|
|
477
|
+
entry_reasons = set(reasons.get(index, set()))
|
|
478
|
+
if section.end <= head_end:
|
|
479
|
+
entry_reasons.add("head")
|
|
480
|
+
selected_entries.append(
|
|
481
|
+
AgentsSectionAuditEntry(
|
|
482
|
+
section.heading,
|
|
483
|
+
section.heading_level,
|
|
484
|
+
_ordered_reasons(entry_reasons),
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
else:
|
|
488
|
+
indexed_entries.append(
|
|
489
|
+
AgentsSectionAuditEntry(
|
|
490
|
+
section.heading,
|
|
491
|
+
section.heading_level,
|
|
492
|
+
("budget_fallback",) if index in budget_fallback else (),
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
chars_selected = sum(end - start for start, end in merged)
|
|
497
|
+
# The scanned prefix can be shorter than the file, so omission is measured
|
|
498
|
+
# against the whole normalized source rather than the captured prefix.
|
|
499
|
+
chars_omitted = max(0, source_characters - chars_selected)
|
|
500
|
+
audit = AgentsSelectionAudit(
|
|
501
|
+
source="AGENTS.md",
|
|
502
|
+
selected_sections=tuple(selected_entries),
|
|
503
|
+
indexed_only_sections=tuple(indexed_entries),
|
|
504
|
+
chars_selected=chars_selected,
|
|
505
|
+
chars_omitted=chars_omitted,
|
|
506
|
+
truncated=chars_omitted > 0 or source_scan_truncated,
|
|
507
|
+
parse_fallback=parse_fallback,
|
|
508
|
+
source_scan_truncated=source_scan_truncated,
|
|
509
|
+
)
|
|
510
|
+
return selected_content, audit
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _aggregate_agents_bytes(content: str, audit: AgentsSelectionAudit) -> int:
|
|
514
|
+
return len(content.encode("utf-8")) + len(render_agents_selection_audit(audit).encode("utf-8"))
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _fit_agents_index(content: str, audit: AgentsSelectionAudit) -> AgentsSelectionAudit:
|
|
518
|
+
"""Keep the longest leading index prefix that fits the AGENTS budget.
|
|
519
|
+
|
|
520
|
+
A truncated index renders one line per retained entry in place of the single
|
|
521
|
+
empty-list placeholder, so every candidate size follows from the placeholder
|
|
522
|
+
render plus a prefix sum of entry line lengths. Sizes grow strictly with the
|
|
523
|
+
retained count, which selects the same prefix the earlier per-step re-render
|
|
524
|
+
chose while rendering the audit a fixed number of times.
|
|
525
|
+
"""
|
|
526
|
+
if _aggregate_agents_bytes(content, audit) <= AGENTS_LIMIT_BYTES:
|
|
527
|
+
return audit
|
|
528
|
+
entries = audit.indexed_only_sections
|
|
529
|
+
without_index = replace(audit, indexed_only_sections=(), index_truncated=True)
|
|
530
|
+
without_index_bytes = _aggregate_agents_bytes(content, without_index)
|
|
531
|
+
fixed_bytes = without_index_bytes - len(_AUDIT_EMPTY_LIST_LINE.encode("utf-8")) - 1
|
|
532
|
+
retained = 0
|
|
533
|
+
entry_bytes = 0
|
|
534
|
+
for position, entry in enumerate(entries[:-1]):
|
|
535
|
+
entry_bytes += len(_audit_index_line(entry).encode("utf-8")) + 1
|
|
536
|
+
if fixed_bytes + entry_bytes > AGENTS_LIMIT_BYTES:
|
|
537
|
+
break
|
|
538
|
+
retained = position + 1
|
|
539
|
+
if retained:
|
|
540
|
+
return replace(audit, indexed_only_sections=entries[:retained], index_truncated=True)
|
|
541
|
+
if without_index_bytes > AGENTS_LIMIT_BYTES:
|
|
542
|
+
raise RuntimeError("AGENTS head and selection audit exceed the AGENTS budget")
|
|
543
|
+
return without_index
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _without_byte_order_mark(source: CollectedFile) -> CollectedFile:
|
|
547
|
+
"""Drop one leading UTF-8 BOM so a first-line heading still parses as a heading."""
|
|
548
|
+
if not source.content.startswith("\ufeff"):
|
|
549
|
+
return source
|
|
550
|
+
return replace(
|
|
551
|
+
source,
|
|
552
|
+
content=source.content[1:],
|
|
553
|
+
source_characters=max(0, source.source_characters - 1),
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _bounded_head_fallback(source: CollectedFile, *, parse_fallback: bool) -> CollectedFile:
|
|
558
|
+
"""Return a byte-bounded head with no section index.
|
|
559
|
+
|
|
560
|
+
The head never exceeds AGENTS_HEAD_LIMIT_BYTES and the audit carries no index
|
|
561
|
+
entries, so the result always fits the larger AGENTS budget.
|
|
562
|
+
"""
|
|
563
|
+
head_end = _line_safe_prefix_end(source.content, AGENTS_HEAD_LIMIT_BYTES)
|
|
564
|
+
content, audit = _selection_state(
|
|
565
|
+
source.content,
|
|
566
|
+
(),
|
|
567
|
+
head_end,
|
|
568
|
+
frozenset(),
|
|
569
|
+
{},
|
|
570
|
+
frozenset(),
|
|
571
|
+
parse_fallback=parse_fallback,
|
|
572
|
+
source_scan_truncated=source.truncated,
|
|
573
|
+
source_characters=source.source_characters,
|
|
574
|
+
)
|
|
575
|
+
audit = _fit_agents_index(content, audit)
|
|
576
|
+
return CollectedFile(
|
|
577
|
+
source.name,
|
|
578
|
+
source.language,
|
|
579
|
+
None,
|
|
580
|
+
content,
|
|
581
|
+
audit.truncated,
|
|
582
|
+
audit,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _select_agents_content(source: CollectedFile, signals: _SelectionSignals) -> CollectedFile:
|
|
587
|
+
if not source.is_text:
|
|
588
|
+
return source
|
|
589
|
+
source = _without_byte_order_mark(source)
|
|
590
|
+
try:
|
|
591
|
+
sections = _parse_markdown_sections(source.content)
|
|
592
|
+
except MarkdownSectionParseError:
|
|
593
|
+
return _bounded_head_fallback(source, parse_fallback=True)
|
|
594
|
+
|
|
595
|
+
head_end = _small_head_end(source.content, sections)
|
|
596
|
+
selected_indices: frozenset[int] = frozenset()
|
|
597
|
+
reasons: dict[int, set[str]] = {}
|
|
598
|
+
ranked: list[tuple[int, int, frozenset[str]]] = []
|
|
599
|
+
for index, section in enumerate(sections):
|
|
600
|
+
score, section_reasons = _section_relevance(section, signals)
|
|
601
|
+
if score >= 4:
|
|
602
|
+
ranked.append((score, index, section_reasons))
|
|
603
|
+
ranked.sort(key=lambda item: (-item[0], sections[item[1]].start))
|
|
604
|
+
|
|
605
|
+
budget_fallback: set[int] = set()
|
|
606
|
+
for _score, index, section_reasons in ranked:
|
|
607
|
+
trial_indices = set(selected_indices)
|
|
608
|
+
trial_reasons = {key: set(value) for key, value in reasons.items()}
|
|
609
|
+
trial_indices.add(index)
|
|
610
|
+
trial_reasons.setdefault(index, set()).update(section_reasons)
|
|
611
|
+
parent = sections[index].parent_index
|
|
612
|
+
while parent is not None:
|
|
613
|
+
trial_indices.add(parent)
|
|
614
|
+
trial_reasons.setdefault(parent, set()).add("parent_context")
|
|
615
|
+
parent = sections[parent].parent_index
|
|
616
|
+
trial_content, trial_audit = _selection_state(
|
|
617
|
+
source.content,
|
|
618
|
+
sections,
|
|
619
|
+
head_end,
|
|
620
|
+
frozenset(trial_indices),
|
|
621
|
+
trial_reasons,
|
|
622
|
+
frozenset(budget_fallback),
|
|
623
|
+
parse_fallback=False,
|
|
624
|
+
source_scan_truncated=source.truncated,
|
|
625
|
+
source_characters=source.source_characters,
|
|
626
|
+
)
|
|
627
|
+
try:
|
|
628
|
+
_fit_agents_index(trial_content, trial_audit)
|
|
629
|
+
except RuntimeError:
|
|
630
|
+
budget_fallback.add(index)
|
|
631
|
+
else:
|
|
632
|
+
selected_indices = frozenset(trial_indices)
|
|
633
|
+
reasons = trial_reasons
|
|
634
|
+
|
|
635
|
+
content, audit = _selection_state(
|
|
636
|
+
source.content,
|
|
637
|
+
sections,
|
|
638
|
+
head_end,
|
|
639
|
+
selected_indices,
|
|
640
|
+
reasons,
|
|
641
|
+
frozenset(budget_fallback),
|
|
642
|
+
parse_fallback=False,
|
|
643
|
+
source_scan_truncated=source.truncated,
|
|
644
|
+
source_characters=source.source_characters,
|
|
645
|
+
)
|
|
646
|
+
try:
|
|
647
|
+
audit = _fit_agents_index(content, audit)
|
|
648
|
+
except RuntimeError:
|
|
649
|
+
# A heading-dense head can outgrow the budget on its own; degrade this one
|
|
650
|
+
# source instead of failing the whole context collection.
|
|
651
|
+
return _bounded_head_fallback(source, parse_fallback=False)
|
|
652
|
+
return CollectedFile(
|
|
653
|
+
source.name,
|
|
654
|
+
source.language,
|
|
655
|
+
None,
|
|
656
|
+
content,
|
|
657
|
+
audit.truncated,
|
|
658
|
+
audit,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _append_normalized_character(
|
|
663
|
+
capture: bytearray,
|
|
664
|
+
character: str,
|
|
665
|
+
limit: int,
|
|
666
|
+
) -> bool:
|
|
667
|
+
encoded = character.encode()
|
|
668
|
+
if len(capture) + len(encoded) > limit:
|
|
669
|
+
return False
|
|
670
|
+
capture.extend(encoded)
|
|
671
|
+
return True
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _read_validated_text(file_descriptor: int, limit: int) -> tuple[str, bool, str | None, int]:
|
|
675
|
+
"""Capture a bounded normalized prefix and count the whole normalized source."""
|
|
676
|
+
decoder = codecs.getincrementaldecoder("utf-8")("strict")
|
|
677
|
+
capture = bytearray()
|
|
678
|
+
last_line_boundary = 0
|
|
679
|
+
overflow = False
|
|
680
|
+
pending_carriage_return = False
|
|
681
|
+
source_characters = 0
|
|
682
|
+
|
|
683
|
+
def append(character: str) -> None:
|
|
684
|
+
nonlocal last_line_boundary, overflow
|
|
685
|
+
if overflow:
|
|
686
|
+
return
|
|
687
|
+
if not _append_normalized_character(capture, character, limit):
|
|
688
|
+
overflow = True
|
|
689
|
+
return
|
|
690
|
+
if character == "\n":
|
|
691
|
+
last_line_boundary = len(capture)
|
|
692
|
+
|
|
693
|
+
def consume(decoded: str) -> None:
|
|
694
|
+
nonlocal pending_carriage_return, source_characters
|
|
695
|
+
for character in decoded:
|
|
696
|
+
if pending_carriage_return:
|
|
697
|
+
pending_carriage_return = False
|
|
698
|
+
source_characters += 1
|
|
699
|
+
append("\n")
|
|
700
|
+
if character == "\n":
|
|
701
|
+
continue
|
|
702
|
+
if character == "\r":
|
|
703
|
+
pending_carriage_return = True
|
|
704
|
+
continue
|
|
705
|
+
source_characters += 1
|
|
706
|
+
append(character)
|
|
707
|
+
|
|
708
|
+
try:
|
|
709
|
+
while True:
|
|
710
|
+
raw = os.read(file_descriptor, 64 * 1024)
|
|
711
|
+
if not raw:
|
|
712
|
+
break
|
|
713
|
+
if b"\0" in raw:
|
|
714
|
+
return "", False, SKIPPED_ENCODING, 0
|
|
715
|
+
consume(decoder.decode(raw, final=False))
|
|
716
|
+
consume(decoder.decode(b"", final=True))
|
|
717
|
+
if pending_carriage_return:
|
|
718
|
+
source_characters += 1
|
|
719
|
+
append("\n")
|
|
720
|
+
except UnicodeDecodeError:
|
|
721
|
+
return "", False, SKIPPED_ENCODING, 0
|
|
722
|
+
except OSError:
|
|
723
|
+
return "", False, SKIPPED_UNREADABLE, 0
|
|
724
|
+
|
|
725
|
+
if overflow:
|
|
726
|
+
del capture[last_line_boundary:]
|
|
727
|
+
return capture.decode("utf-8"), overflow, None, source_characters
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _collect_root_file(root: Path, name: str, language: str, limit: int) -> CollectedFile:
|
|
731
|
+
if len(Path(name).parts) != 1 or Path(name).name != name:
|
|
732
|
+
return CollectedFile(name, language, SKIPPED_UNREADABLE)
|
|
733
|
+
path = root / name
|
|
734
|
+
if path.parent != root:
|
|
735
|
+
return CollectedFile(name, language, SKIPPED_UNREADABLE)
|
|
736
|
+
try:
|
|
737
|
+
metadata = path.lstat()
|
|
738
|
+
except FileNotFoundError:
|
|
739
|
+
return CollectedFile(name, language, NOT_PRESENT)
|
|
740
|
+
except OSError:
|
|
741
|
+
return CollectedFile(name, language, SKIPPED_UNREADABLE)
|
|
742
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
743
|
+
return CollectedFile(name, language, SKIPPED_SYMLINK)
|
|
744
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
745
|
+
return CollectedFile(name, language, SKIPPED_NOT_REGULAR)
|
|
746
|
+
|
|
747
|
+
flags = os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | getattr(os, "O_NOFOLLOW", 0)
|
|
748
|
+
try:
|
|
749
|
+
file_descriptor = os.open(path, flags)
|
|
750
|
+
except OSError as exc:
|
|
751
|
+
if exc.errno == errno.ELOOP:
|
|
752
|
+
return CollectedFile(name, language, SKIPPED_SYMLINK)
|
|
753
|
+
return CollectedFile(name, language, SKIPPED_UNREADABLE)
|
|
754
|
+
try:
|
|
755
|
+
if not stat.S_ISREG(os.fstat(file_descriptor).st_mode):
|
|
756
|
+
return CollectedFile(name, language, SKIPPED_NOT_REGULAR)
|
|
757
|
+
content, truncated, error_status, source_characters = _read_validated_text(
|
|
758
|
+
file_descriptor, limit
|
|
759
|
+
)
|
|
760
|
+
except OSError:
|
|
761
|
+
return CollectedFile(name, language, SKIPPED_UNREADABLE)
|
|
762
|
+
finally:
|
|
763
|
+
os.close(file_descriptor)
|
|
764
|
+
if error_status is not None:
|
|
765
|
+
return CollectedFile(name, language, error_status)
|
|
766
|
+
return CollectedFile(
|
|
767
|
+
name, language, None, content, truncated, source_characters=source_characters
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _root_name_exists(root: Path, name: str) -> bool:
|
|
772
|
+
try:
|
|
773
|
+
(root / name).lstat()
|
|
774
|
+
except FileNotFoundError:
|
|
775
|
+
return False
|
|
776
|
+
except OSError:
|
|
777
|
+
return True
|
|
778
|
+
return True
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _selected_entry_specs(root: Path) -> tuple[tuple[str, str], ...]:
|
|
782
|
+
just_spec = (
|
|
783
|
+
("justfile", "make") if _root_name_exists(root, "justfile") else ("Justfile", "make")
|
|
784
|
+
)
|
|
785
|
+
return (ENTRY_FILE_SPECS[0], just_spec, *ENTRY_FILE_SPECS[1:])
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _pyproject_commands(source: CollectedFile) -> tuple[DeclaredCommand, ...]:
|
|
789
|
+
try:
|
|
790
|
+
document = tomllib.loads(source.content)
|
|
791
|
+
except (tomllib.TOMLDecodeError, ValueError):
|
|
792
|
+
return (DeclaredCommand(source.name, parse_error=True),)
|
|
793
|
+
project = document.get("project")
|
|
794
|
+
if not isinstance(project, dict):
|
|
795
|
+
return ()
|
|
796
|
+
declarations: list[tuple[str, int, str]] = []
|
|
797
|
+
for table_order, table_name in enumerate(("scripts", "gui-scripts")):
|
|
798
|
+
table = project.get(table_name)
|
|
799
|
+
if not isinstance(table, dict):
|
|
800
|
+
continue
|
|
801
|
+
declarations.extend(
|
|
802
|
+
(name, table_order, value)
|
|
803
|
+
for name, value in table.items()
|
|
804
|
+
if isinstance(name, str) and isinstance(value, str)
|
|
805
|
+
)
|
|
806
|
+
return tuple(
|
|
807
|
+
DeclaredCommand(source.name, name, target)
|
|
808
|
+
for name, _table_order, target in sorted(declarations, key=lambda item: (item[0], item[1]))
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _package_commands(source: CollectedFile) -> tuple[DeclaredCommand, ...]:
|
|
813
|
+
try:
|
|
814
|
+
document = json.loads(source.content)
|
|
815
|
+
except (json.JSONDecodeError, ValueError):
|
|
816
|
+
return (DeclaredCommand(source.name, parse_error=True),)
|
|
817
|
+
if not isinstance(document, dict) or not isinstance(document.get("scripts"), dict):
|
|
818
|
+
return ()
|
|
819
|
+
scripts = document["scripts"]
|
|
820
|
+
return tuple(
|
|
821
|
+
DeclaredCommand(source.name, f"npm run {name}", value)
|
|
822
|
+
for name, value in sorted(scripts.items())
|
|
823
|
+
if isinstance(name, str) and isinstance(value, str)
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _just_commands(source: CollectedFile) -> tuple[DeclaredCommand, ...]:
|
|
828
|
+
recipes: set[str] = set()
|
|
829
|
+
pending_private = False
|
|
830
|
+
for line in source.content.splitlines():
|
|
831
|
+
if not line or line.startswith((" ", "\t", "#")):
|
|
832
|
+
continue
|
|
833
|
+
attribute = _JUST_ATTRIBUTE_RE.fullmatch(line)
|
|
834
|
+
if attribute is not None:
|
|
835
|
+
names = {item.strip() for item in attribute.group(1).split(",")}
|
|
836
|
+
pending_private = "private" in names
|
|
837
|
+
continue
|
|
838
|
+
if re.match(r"^(?:export[ \t]+)?[A-Za-z_][A-Za-z0-9_-]*[ \t]*(?::=|\?=|\+=|=)", line):
|
|
839
|
+
pending_private = False
|
|
840
|
+
continue
|
|
841
|
+
match = _JUST_RECIPE_RE.match(line)
|
|
842
|
+
if match is not None and not pending_private:
|
|
843
|
+
recipes.add(match.group(1))
|
|
844
|
+
pending_private = False
|
|
845
|
+
return tuple(DeclaredCommand(source.name, f"just {name}") for name in sorted(recipes))
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _make_commands(source: CollectedFile) -> tuple[DeclaredCommand, ...]:
|
|
849
|
+
targets: set[str] = set()
|
|
850
|
+
for line in source.content.splitlines():
|
|
851
|
+
if not line or line.startswith((" ", "\t", "#", ".")):
|
|
852
|
+
continue
|
|
853
|
+
if re.match(
|
|
854
|
+
r"^(?:export[ \t]+)?[A-Za-z_][A-Za-z0-9_-]*[ \t]*(?::=|::=|\?=|\+=|!=|=)", line
|
|
855
|
+
):
|
|
856
|
+
continue
|
|
857
|
+
match = _MAKE_TARGET_RE.match(line)
|
|
858
|
+
if match is None or "%" in line or "$" in line or "=" in line[match.end() :]:
|
|
859
|
+
continue
|
|
860
|
+
targets.update(match.group(1).split())
|
|
861
|
+
return tuple(DeclaredCommand(source.name, f"make {name}") for name in sorted(targets))
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _collect_commands(entry_files: tuple[CollectedFile, ...]) -> tuple[DeclaredCommand, ...]:
|
|
865
|
+
commands: list[DeclaredCommand] = []
|
|
866
|
+
for source in entry_files:
|
|
867
|
+
if not source.is_text:
|
|
868
|
+
continue
|
|
869
|
+
if source.name == "pyproject.toml":
|
|
870
|
+
commands.extend(_pyproject_commands(source))
|
|
871
|
+
elif source.name == "package.json":
|
|
872
|
+
commands.extend(_package_commands(source))
|
|
873
|
+
elif source.name in {"justfile", "Justfile"}:
|
|
874
|
+
commands.extend(_just_commands(source))
|
|
875
|
+
elif source.name == "Makefile":
|
|
876
|
+
commands.extend(_make_commands(source))
|
|
877
|
+
return tuple(commands)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
class _TreeLimitReached(Exception):
|
|
881
|
+
pass
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _classify_entries(
|
|
885
|
+
file_descriptor: int,
|
|
886
|
+
) -> tuple[list[os.DirEntry[str]], list[os.DirEntry[str]]]:
|
|
887
|
+
with os.scandir(file_descriptor) as iterator:
|
|
888
|
+
scanned = list(iterator)
|
|
889
|
+
directories: list[os.DirEntry[str]] = []
|
|
890
|
+
others: list[os.DirEntry[str]] = []
|
|
891
|
+
for entry in scanned:
|
|
892
|
+
try:
|
|
893
|
+
is_directory = entry.is_dir(follow_symlinks=False) and not entry.is_symlink()
|
|
894
|
+
except OSError:
|
|
895
|
+
is_directory = False
|
|
896
|
+
(directories if is_directory else others).append(entry)
|
|
897
|
+
directories.sort(key=lambda entry: entry.name)
|
|
898
|
+
others.sort(key=lambda entry: entry.name)
|
|
899
|
+
return directories, others
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _collect_directory_tree(root: Path) -> DirectoryTree:
|
|
903
|
+
collected: list[TreeEntry] = []
|
|
904
|
+
truncated = False
|
|
905
|
+
|
|
906
|
+
def add(entry: TreeEntry) -> None:
|
|
907
|
+
nonlocal truncated
|
|
908
|
+
if len(collected) >= DIRECTORY_TREE_MAX_ITEMS:
|
|
909
|
+
truncated = True
|
|
910
|
+
raise _TreeLimitReached
|
|
911
|
+
collected.append(entry)
|
|
912
|
+
|
|
913
|
+
def relative(prefix: str, name: str) -> str:
|
|
914
|
+
return f"{prefix}/{name}" if prefix else name
|
|
915
|
+
|
|
916
|
+
def walk(file_descriptor: int, prefix: str, depth: int) -> None:
|
|
917
|
+
directories, others = _classify_entries(file_descriptor)
|
|
918
|
+
for entry in directories:
|
|
919
|
+
entry_path = relative(prefix, entry.name)
|
|
920
|
+
if depth == 0 and entry.name == ".git":
|
|
921
|
+
add(TreeEntry(entry_path, "directory"))
|
|
922
|
+
continue
|
|
923
|
+
if depth + 1 >= DIRECTORY_TREE_MAX_DEPTH:
|
|
924
|
+
add(TreeEntry(entry_path, "directory"))
|
|
925
|
+
continue
|
|
926
|
+
flags = os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)
|
|
927
|
+
try:
|
|
928
|
+
child_descriptor = os.open(entry.name, flags, dir_fd=file_descriptor)
|
|
929
|
+
except OSError:
|
|
930
|
+
add(TreeEntry(entry_path, "unreadable_directory"))
|
|
931
|
+
continue
|
|
932
|
+
try:
|
|
933
|
+
directory_index = len(collected)
|
|
934
|
+
add(TreeEntry(entry_path, "directory"))
|
|
935
|
+
try:
|
|
936
|
+
walk(child_descriptor, entry_path, depth + 1)
|
|
937
|
+
except OSError:
|
|
938
|
+
collected[directory_index] = TreeEntry(entry_path, "unreadable_directory")
|
|
939
|
+
finally:
|
|
940
|
+
os.close(child_descriptor)
|
|
941
|
+
for entry in others:
|
|
942
|
+
entry_path = relative(prefix, entry.name)
|
|
943
|
+
try:
|
|
944
|
+
kind = "symlink" if entry.is_symlink() else "file"
|
|
945
|
+
except OSError:
|
|
946
|
+
kind = "file"
|
|
947
|
+
add(TreeEntry(entry_path, kind))
|
|
948
|
+
|
|
949
|
+
flags = os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)
|
|
950
|
+
try:
|
|
951
|
+
root_descriptor = os.open(root, flags)
|
|
952
|
+
except OSError:
|
|
953
|
+
return DirectoryTree((TreeEntry("", "unreadable_directory"),))
|
|
954
|
+
try:
|
|
955
|
+
try:
|
|
956
|
+
walk(root_descriptor, "", 0)
|
|
957
|
+
except _TreeLimitReached:
|
|
958
|
+
pass
|
|
959
|
+
except OSError:
|
|
960
|
+
if not collected:
|
|
961
|
+
collected.append(TreeEntry("", "unreadable_directory"))
|
|
962
|
+
finally:
|
|
963
|
+
os.close(root_descriptor)
|
|
964
|
+
return DirectoryTree(tuple(collected), truncated)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def collect_project_context(
|
|
968
|
+
repository: Path,
|
|
969
|
+
*,
|
|
970
|
+
focus: str | None = None,
|
|
971
|
+
path: str | None = None,
|
|
972
|
+
) -> ProjectContext:
|
|
973
|
+
"""Collect fixed root candidates, selecting AGENTS sections from bounded signals."""
|
|
974
|
+
signals = _selection_signals(focus, path)
|
|
975
|
+
raw_instructions = _collect_root_file(
|
|
976
|
+
repository,
|
|
977
|
+
"AGENTS.md",
|
|
978
|
+
"markdown",
|
|
979
|
+
AGENTS_SCAN_LIMIT_BYTES,
|
|
980
|
+
)
|
|
981
|
+
instructions = _select_agents_content(raw_instructions, signals)
|
|
982
|
+
overview = _collect_root_file(repository, "README.md", "markdown", README_LIMIT_BYTES)
|
|
983
|
+
entry_files = tuple(
|
|
984
|
+
_collect_root_file(repository, name, language, ENTRY_FILE_LIMIT_BYTES)
|
|
985
|
+
for name, language in _selected_entry_specs(repository)
|
|
986
|
+
)
|
|
987
|
+
return ProjectContext(
|
|
988
|
+
instructions=instructions,
|
|
989
|
+
overview=overview,
|
|
990
|
+
entry_files=entry_files,
|
|
991
|
+
commands=_collect_commands(entry_files),
|
|
992
|
+
directory_tree=_collect_directory_tree(repository),
|
|
993
|
+
)
|