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.
@@ -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
+ )