context-loader 1.3.2__tar.gz → 1.3.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: context-loader
3
- Version: 1.3.2
3
+ Version: 1.3.3
4
4
  Summary: Render deterministic local Git context for any executor
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -219,14 +219,14 @@ Python standard library.
219
219
 
220
220
  ## Open-source quick start
221
221
 
222
- Current stable release: **1.3.2**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
222
+ Current stable release: **1.3.3**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
223
223
 
224
224
  ```bash
225
- uv tool install 'context-loader==1.3.2'
225
+ uv tool install 'context-loader==1.3.3'
226
226
  ```
227
227
 
228
228
  ```bash
229
- pip install 'context-loader==1.3.2'
229
+ pip install 'context-loader==1.3.3'
230
230
  ```
231
231
 
232
232
  ```bash
@@ -239,21 +239,21 @@ project-context --repo /path/to/repo --format json
239
239
 
240
240
  ## Install
241
241
 
242
- Source version: `1.3.2`. Install its matching published release or an exact source commit.
242
+ Source version: `1.3.3`. Install its matching published release or an exact source commit.
243
243
 
244
244
  Install via `uv`:
245
245
 
246
246
  ```bash
247
- uv tool install 'context-loader==1.3.2'
247
+ uv tool install 'context-loader==1.3.3'
248
248
  ```
249
249
 
250
250
  Install via `pip`:
251
251
 
252
252
  ```bash
253
- pip install 'context-loader==1.3.2'
253
+ pip install 'context-loader==1.3.3'
254
254
  ```
255
255
 
256
- For development or source-based installs tracking current repository (`1.3.2`):
256
+ For development or source-based installs tracking current repository (`1.3.3`):
257
257
 
258
258
  ```bash
259
259
  uv tool install git+https://github.com/xuanheng-tech/context-loader.git
@@ -350,7 +350,7 @@ set differs, as described under “Compact model consumption”. The declared co
350
350
  "schema_version": 3,
351
351
  "tool": {
352
352
  "name": "context-loader",
353
- "version": "1.3.2"
353
+ "version": "1.3.3"
354
354
  },
355
355
  "repository": {
356
356
  "requested_path": "/canonical/requested/path",
@@ -397,7 +397,14 @@ context, in assembly order, after the existing newline normalization and truncat
397
397
  distinguishes `repository` from `global`; version 1.0.0's fixed root-file selection currently emits
398
398
  only `repository` sources and does not add any global-file discovery.
399
399
 
400
- `statuses` lists machine-readable collection and render conditions that previously appeared only inside `context` Markdown — plus the nested-context truncation flags, which exist only in machine form: skipped or absent sources, truncated sources, unreadable directory-tree entries, directory-tree entries whose directory holds more entries than one enumeration examines, sections omitted under the global output budget, truncated working-tree, declared-command or directory-tree listings, and truncated nested-`AGENTS.md` presence reports. A skipped or absent source reports the code of the condition the collector observed (`not_present`, `skipped_symlink`, `skipped_not_regular`, `skipped_encoding`, `skipped_unreadable`); the sentence in `context` is rendered from that same condition, so wording and code cannot drift apart. Each entry has stable `code`, `subject_kind`, and `subject` fields. The two `nested_agents_*` codes mirror the `nested_context` truncation booleans for consumers that branch only on `statuses`; the full `nested_context` object remains a first-class field, never folded into `statuses`. Callers can ignore `statuses` safely.
400
+ `statuses` lists skipped or absent sources, truncated sources, unreadable tree entries,
401
+ directories whose listing exceeds its retention cap, globally omitted sections, and truncated
402
+ working-tree, command, tree or nested-`AGENTS.md` listings. A skipped or absent source reports
403
+ the observed condition (`not_present`, `skipped_symlink`, `skipped_not_regular`,
404
+ `skipped_encoding`, `skipped_unreadable`); its sentence in `context` comes from that same
405
+ condition. Each entry has stable `code`, `subject_kind`, and `subject` fields. The two
406
+ `nested_agents_*` codes mirror the `nested_context` truncation booleans; the complete object
407
+ remains a separate field. Callers can ignore `statuses` safely.
401
408
 
402
409
  The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
403
410
  contain heading, heading level, and fixed selection reasons; it never contains the original focus or
@@ -417,7 +424,7 @@ separator, with the array's brackets charged to the first entry — so the emitt
417
424
  more than the budget it declares. Because quoting is counted, an array already near the cap can
418
425
  report one path fewer than an older release did, and then says so through `list_truncated`.
419
426
  `scan_truncated` means the depth (4) or directory-count (2,000) budget was reached, a directory
420
- held more entries than one enumeration examines (1,024), or a directory or entry could not be
427
+ held more entries than one listing retains (1,024), or a directory or entry could not be
421
428
  read, so absence of a path is not proof of absence of the file. Nested paths
422
429
  are sanitized with the same escaping the Markdown uses for repository-derived text, so a nested name
423
430
  the filesystem could not decode never breaks the document;
@@ -426,6 +433,16 @@ see “Limits” for the root paths that are emitted verbatim. The corresponding
426
433
  Presence is not instruction: whether a nested file applies, and its text, remain the caller's
427
434
  judgment; Markdown output is unchanged.
428
435
 
436
+ Escaped names remain reversible and distinct: a literal backslash becomes `\\`, a backtick
437
+ becomes `\x60`, ASCII controls and undecodable filesystem bytes become `\xNN`, and other
438
+ non-printable Unicode characters become `\uNNNN` or `\UNNNNNNNN`. Printable Unicode stays
439
+ unchanged. After JSON decoding, recover POSIX path bytes by reading escapes from left to right:
440
+ `\\` yields one backslash byte, `\xNN` yields that byte, and Unicode escapes and ordinary
441
+ characters yield their UTF-8 bytes. Thus a backtick, a literal `\x60`, an undecodable byte
442
+ `0x80`, and Unicode `U+0080` all have different representations. This applies to nested paths
443
+ and displayed tree/change/status names; the verbatim root/source paths retain their existing
444
+ semantics.
445
+
429
446
  ### Compact model consumption (`--format json-compact`)
430
447
 
431
448
  `--format json-compact` emits a `schema_version` 4 document: exactly the version-3 document with
@@ -453,7 +470,7 @@ although 1.1.0 added the top-level `statuses` key. From release 1.2.0 onward eac
453
470
  names exactly one key set, and a new number is issued whenever a key set would otherwise be reused.
454
471
 
455
472
  The JSON schema version and package version are independent: this build emits `schema_version` `3`
456
- for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.2`. Version 1
473
+ for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.3`. Version 1
457
474
  and 2 are the exact shapes already published in release 1.2.0 and are never reused: because
458
475
  `nested_context` changes the default document's key set, version 3 names the current full-document
459
476
  key set and version 4 names the compact projection.
@@ -527,10 +544,11 @@ bullet is a per-component, read or capture bound, as each one states:
527
544
  16 KiB, including a 4-KiB maximum small head
528
545
  - `README.md`: 16 KiB
529
546
  - Each entry file: 8 KiB
530
- - All entry-file bodies: 24 KiB
547
+ - All entry-file bodies: 24 KiB, including truncation markers. Room is reserved for markers
548
+ on later text sources, so exhausting the budget never hides that their bodies were cut.
531
549
  - Declared commands: 8 KiB
532
550
  - Directory tree: 12 KiB of listing body, 300 entries, and depth 2. Each directory is
533
- enumerated up to 512 entries, keeping the alphabetically first names so the listing never
551
+ retains and classifies up to 512 entries, keeping the alphabetically first names so the listing never
534
552
  depends on operating system order. A directory that holds more is reported by a single
535
553
  `Listing incomplete:` line stating the total, the limit and up to three names, and by at most
536
554
  eight `directory_listing_incomplete` status entries naming individual directories plus one
@@ -541,8 +559,10 @@ bullet is a per-component, read or capture bound, as each one states:
541
559
  alphabetical prefix and the entry budget kept it, so capped siblings displaced by the budget are
542
560
  disclosed as their parent's truncation rather than as separate numbers.
543
561
  Three limits act in sequence and a name survives only if all three leave room. The 512-entry
544
- enumeration limit decides which names a directory offers at all; above it only an alphabetical
545
- prefix is ever seen. The 300-entry item budget is then shared between a directory's
562
+ retention limit decides which names a directory offers at all; above it only an alphabetical
563
+ prefix is classified. All names must still be scanned to find that prefix, subject to the
564
+ separate hard name-scan bound below; the retention cap bounds memory and classification work.
565
+ The 300-entry item budget is then shared between a directory's
546
566
  subdirectories and its non-directories: each may take up to half of what remains before the other
547
567
  claims the surplus, which stops alphabetically-earlier directories from evicting every file, but
548
568
  it is a share rather than a floor -- with a single slot left the non-directories take it. The 12
@@ -561,8 +581,14 @@ bullet is a per-component, read or capture bound, as each one states:
561
581
  fence say so. The heading and the code-fence lines are not metered, so the whole Markdown
562
582
  section can exceed 12 KiB by that fixed few dozen bytes.
563
583
  - Nested `AGENTS.md` presence scan: depth 4, 2,000 directories, at most 32 reported paths, a
564
- 4 KiB budget metered on the emitted array, and at most 1,024 entries examined per directory;
565
- file contents are never read
584
+ 4 KiB budget metered on the emitted array, and at most 1,024 entries retained and classified
585
+ per directory. Names are scanned to select that prefix, subject to the separate hard
586
+ name-scan bound below; file contents are never read
587
+ - Directory name scans stop on the first entry beyond 65,536 names per directory. An
588
+ over-limit directory's entire partial prefix is discarded to keep results independent of
589
+ operating system order. The tree emits `Skipped: directory name scan limit reached.` and
590
+ a `directory_scan_limit_exceeded` status; the nested presence scan sets `scan_truncated`.
591
+ This bounds name iteration, not elapsed time on a blocked filesystem call.
566
592
  - Working-tree changes: 100 paths and 4 KiB
567
593
  - Recent commits: 8
568
594
  - JSON-only listings have no byte budget of their own: `statuses` subjects are escaped names bounded
@@ -570,6 +596,7 @@ bullet is a per-component, read or capture bound, as each one states:
570
596
  repository root the filesystem could not decode makes the machine formats exit 1 where Markdown
571
597
  still renders
572
598
  - Git subprocess output: 16 MiB (bounded while reading)
599
+ - Git status preflight: at most 128 repositories, including initialized nested submodules
573
600
  - Candidate file validation: 16 MiB hard read bound
574
601
 
575
602
  Truncation occurs only at complete UTF-8 and line boundaries and is marked explicitly.
@@ -593,6 +620,13 @@ the remote.
593
620
  this is normal after cloning an empty remote.
594
621
  - `Ahead / behind: not available` means the commit relationship cannot currently be computed.
595
622
  Counts are reported only when the configured upstream commit is available locally.
623
+ - Executable Git clean/process filters can run during `status`, even with optional locks
624
+ disabled. Before reading status, the loader checks effective repository configuration,
625
+ including includes, worktree configuration and initialized nested submodules. A non-empty
626
+ clean/process command causes a safe error with no stdout, even if no current attribute uses
627
+ it. Disabling a driver silently would change cleanliness semantics. Filter-dependent repositories
628
+ (including locally configured Git LFS drivers) therefore need ordinary Git inspection outside
629
+ this loader. Git protocols are also disabled to prevent lazy object retrieval.
596
630
 
597
631
  ## Exit Codes
598
632
 
@@ -605,7 +639,7 @@ or candidate-file content.
605
639
 
606
640
  ## Not Included
607
641
 
608
- Version 1.3.2 does not provide AI summaries, project-type detection, loading or transport of
642
+ Version 1.3.3 does not provide AI summaries, project-type detection, loading or transport of
609
643
  nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
610
644
  Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
611
645
  network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
@@ -618,6 +652,17 @@ Run the complete local check:
618
652
  just check
619
653
  ```
620
654
 
655
+ The lockfile uses public PyPI artifact URLs; no organization mirror or local Renovate preset
656
+ is needed. Renovate keeps scheduled, rate-limited updates and manual merges using a public
657
+ recommended preset plus the repository's explicit settings. Python 3.12 remains the tested
658
+ runtime; widening that range requires testing those interpreters first.
659
+
660
+ Git test fixtures discard inherited `GIT_*` routing/configuration before creating repositories.
661
+ Permission-sensitive tests must run as a non-root user. Gitea uses `just ci-check`: non-root
662
+ runners execute directly; root containers use `runuser` with an existing account named by
663
+ `CONTEXT_LOADER_CI_USER` (default `ubuntu`). That account must be able to execute the installed
664
+ `uv`, Python and `just`. No `sudo` dependency is required for this CI step.
665
+
621
666
  ## Version maintenance
622
667
 
623
668
  The versions in `pyproject.toml` and `context_loader/__init__.py`, the `tool_version` in
@@ -638,6 +683,11 @@ only verifies the public release, mirrors its exact formal tag object if missing
638
683
  the Gitea Release record. It builds no package and has no PyPI publishing credentials.
639
684
  No private endpoint or credential is needed on GitHub.
640
685
 
686
+ For environments that permit direct public reads but share an exhausted proxy quota,
687
+ set the Gitea Actions repository variable `RELEASE_PUBLIC_API_DIRECT=true`. The record
688
+ step appends only public GitHub API/PyPI hosts to the existing proxy bypass lists. Without
689
+ this explicit option, the workflow uses its inherited proxy settings.
690
+
641
691
  Release checks use Python 3.12, Git, and the runner's existing OpenSSL CLI. They compare downloaded
642
692
  PyPI file bytes, metadata and SHA-256, plus the publisher/tag/commit claims in PyPI's HTTPS-served
643
693
  provenance. This is an identity check, not an independent cryptographic Sigstore verifier.
@@ -8,14 +8,14 @@ Python standard library.
8
8
 
9
9
  ## Open-source quick start
10
10
 
11
- Current stable release: **1.3.2**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
11
+ Current stable release: **1.3.3**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
12
12
 
13
13
  ```bash
14
- uv tool install 'context-loader==1.3.2'
14
+ uv tool install 'context-loader==1.3.3'
15
15
  ```
16
16
 
17
17
  ```bash
18
- pip install 'context-loader==1.3.2'
18
+ pip install 'context-loader==1.3.3'
19
19
  ```
20
20
 
21
21
  ```bash
@@ -28,21 +28,21 @@ project-context --repo /path/to/repo --format json
28
28
 
29
29
  ## Install
30
30
 
31
- Source version: `1.3.2`. Install its matching published release or an exact source commit.
31
+ Source version: `1.3.3`. Install its matching published release or an exact source commit.
32
32
 
33
33
  Install via `uv`:
34
34
 
35
35
  ```bash
36
- uv tool install 'context-loader==1.3.2'
36
+ uv tool install 'context-loader==1.3.3'
37
37
  ```
38
38
 
39
39
  Install via `pip`:
40
40
 
41
41
  ```bash
42
- pip install 'context-loader==1.3.2'
42
+ pip install 'context-loader==1.3.3'
43
43
  ```
44
44
 
45
- For development or source-based installs tracking current repository (`1.3.2`):
45
+ For development or source-based installs tracking current repository (`1.3.3`):
46
46
 
47
47
  ```bash
48
48
  uv tool install git+https://github.com/xuanheng-tech/context-loader.git
@@ -139,7 +139,7 @@ set differs, as described under “Compact model consumption”. The declared co
139
139
  "schema_version": 3,
140
140
  "tool": {
141
141
  "name": "context-loader",
142
- "version": "1.3.2"
142
+ "version": "1.3.3"
143
143
  },
144
144
  "repository": {
145
145
  "requested_path": "/canonical/requested/path",
@@ -186,7 +186,14 @@ context, in assembly order, after the existing newline normalization and truncat
186
186
  distinguishes `repository` from `global`; version 1.0.0's fixed root-file selection currently emits
187
187
  only `repository` sources and does not add any global-file discovery.
188
188
 
189
- `statuses` lists machine-readable collection and render conditions that previously appeared only inside `context` Markdown — plus the nested-context truncation flags, which exist only in machine form: skipped or absent sources, truncated sources, unreadable directory-tree entries, directory-tree entries whose directory holds more entries than one enumeration examines, sections omitted under the global output budget, truncated working-tree, declared-command or directory-tree listings, and truncated nested-`AGENTS.md` presence reports. A skipped or absent source reports the code of the condition the collector observed (`not_present`, `skipped_symlink`, `skipped_not_regular`, `skipped_encoding`, `skipped_unreadable`); the sentence in `context` is rendered from that same condition, so wording and code cannot drift apart. Each entry has stable `code`, `subject_kind`, and `subject` fields. The two `nested_agents_*` codes mirror the `nested_context` truncation booleans for consumers that branch only on `statuses`; the full `nested_context` object remains a first-class field, never folded into `statuses`. Callers can ignore `statuses` safely.
189
+ `statuses` lists skipped or absent sources, truncated sources, unreadable tree entries,
190
+ directories whose listing exceeds its retention cap, globally omitted sections, and truncated
191
+ working-tree, command, tree or nested-`AGENTS.md` listings. A skipped or absent source reports
192
+ the observed condition (`not_present`, `skipped_symlink`, `skipped_not_regular`,
193
+ `skipped_encoding`, `skipped_unreadable`); its sentence in `context` comes from that same
194
+ condition. Each entry has stable `code`, `subject_kind`, and `subject` fields. The two
195
+ `nested_agents_*` codes mirror the `nested_context` truncation booleans; the complete object
196
+ remains a separate field. Callers can ignore `statuses` safely.
190
197
 
191
198
  The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
192
199
  contain heading, heading level, and fixed selection reasons; it never contains the original focus or
@@ -206,7 +213,7 @@ separator, with the array's brackets charged to the first entry — so the emitt
206
213
  more than the budget it declares. Because quoting is counted, an array already near the cap can
207
214
  report one path fewer than an older release did, and then says so through `list_truncated`.
208
215
  `scan_truncated` means the depth (4) or directory-count (2,000) budget was reached, a directory
209
- held more entries than one enumeration examines (1,024), or a directory or entry could not be
216
+ held more entries than one listing retains (1,024), or a directory or entry could not be
210
217
  read, so absence of a path is not proof of absence of the file. Nested paths
211
218
  are sanitized with the same escaping the Markdown uses for repository-derived text, so a nested name
212
219
  the filesystem could not decode never breaks the document;
@@ -215,6 +222,16 @@ see “Limits” for the root paths that are emitted verbatim. The corresponding
215
222
  Presence is not instruction: whether a nested file applies, and its text, remain the caller's
216
223
  judgment; Markdown output is unchanged.
217
224
 
225
+ Escaped names remain reversible and distinct: a literal backslash becomes `\\`, a backtick
226
+ becomes `\x60`, ASCII controls and undecodable filesystem bytes become `\xNN`, and other
227
+ non-printable Unicode characters become `\uNNNN` or `\UNNNNNNNN`. Printable Unicode stays
228
+ unchanged. After JSON decoding, recover POSIX path bytes by reading escapes from left to right:
229
+ `\\` yields one backslash byte, `\xNN` yields that byte, and Unicode escapes and ordinary
230
+ characters yield their UTF-8 bytes. Thus a backtick, a literal `\x60`, an undecodable byte
231
+ `0x80`, and Unicode `U+0080` all have different representations. This applies to nested paths
232
+ and displayed tree/change/status names; the verbatim root/source paths retain their existing
233
+ semantics.
234
+
218
235
  ### Compact model consumption (`--format json-compact`)
219
236
 
220
237
  `--format json-compact` emits a `schema_version` 4 document: exactly the version-3 document with
@@ -242,7 +259,7 @@ although 1.1.0 added the top-level `statuses` key. From release 1.2.0 onward eac
242
259
  names exactly one key set, and a new number is issued whenever a key set would otherwise be reused.
243
260
 
244
261
  The JSON schema version and package version are independent: this build emits `schema_version` `3`
245
- for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.2`. Version 1
262
+ for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.3`. Version 1
246
263
  and 2 are the exact shapes already published in release 1.2.0 and are never reused: because
247
264
  `nested_context` changes the default document's key set, version 3 names the current full-document
248
265
  key set and version 4 names the compact projection.
@@ -316,10 +333,11 @@ bullet is a per-component, read or capture bound, as each one states:
316
333
  16 KiB, including a 4-KiB maximum small head
317
334
  - `README.md`: 16 KiB
318
335
  - Each entry file: 8 KiB
319
- - All entry-file bodies: 24 KiB
336
+ - All entry-file bodies: 24 KiB, including truncation markers. Room is reserved for markers
337
+ on later text sources, so exhausting the budget never hides that their bodies were cut.
320
338
  - Declared commands: 8 KiB
321
339
  - Directory tree: 12 KiB of listing body, 300 entries, and depth 2. Each directory is
322
- enumerated up to 512 entries, keeping the alphabetically first names so the listing never
340
+ retains and classifies up to 512 entries, keeping the alphabetically first names so the listing never
323
341
  depends on operating system order. A directory that holds more is reported by a single
324
342
  `Listing incomplete:` line stating the total, the limit and up to three names, and by at most
325
343
  eight `directory_listing_incomplete` status entries naming individual directories plus one
@@ -330,8 +348,10 @@ bullet is a per-component, read or capture bound, as each one states:
330
348
  alphabetical prefix and the entry budget kept it, so capped siblings displaced by the budget are
331
349
  disclosed as their parent's truncation rather than as separate numbers.
332
350
  Three limits act in sequence and a name survives only if all three leave room. The 512-entry
333
- enumeration limit decides which names a directory offers at all; above it only an alphabetical
334
- prefix is ever seen. The 300-entry item budget is then shared between a directory's
351
+ retention limit decides which names a directory offers at all; above it only an alphabetical
352
+ prefix is classified. All names must still be scanned to find that prefix, subject to the
353
+ separate hard name-scan bound below; the retention cap bounds memory and classification work.
354
+ The 300-entry item budget is then shared between a directory's
335
355
  subdirectories and its non-directories: each may take up to half of what remains before the other
336
356
  claims the surplus, which stops alphabetically-earlier directories from evicting every file, but
337
357
  it is a share rather than a floor -- with a single slot left the non-directories take it. The 12
@@ -350,8 +370,14 @@ bullet is a per-component, read or capture bound, as each one states:
350
370
  fence say so. The heading and the code-fence lines are not metered, so the whole Markdown
351
371
  section can exceed 12 KiB by that fixed few dozen bytes.
352
372
  - Nested `AGENTS.md` presence scan: depth 4, 2,000 directories, at most 32 reported paths, a
353
- 4 KiB budget metered on the emitted array, and at most 1,024 entries examined per directory;
354
- file contents are never read
373
+ 4 KiB budget metered on the emitted array, and at most 1,024 entries retained and classified
374
+ per directory. Names are scanned to select that prefix, subject to the separate hard
375
+ name-scan bound below; file contents are never read
376
+ - Directory name scans stop on the first entry beyond 65,536 names per directory. An
377
+ over-limit directory's entire partial prefix is discarded to keep results independent of
378
+ operating system order. The tree emits `Skipped: directory name scan limit reached.` and
379
+ a `directory_scan_limit_exceeded` status; the nested presence scan sets `scan_truncated`.
380
+ This bounds name iteration, not elapsed time on a blocked filesystem call.
355
381
  - Working-tree changes: 100 paths and 4 KiB
356
382
  - Recent commits: 8
357
383
  - JSON-only listings have no byte budget of their own: `statuses` subjects are escaped names bounded
@@ -359,6 +385,7 @@ bullet is a per-component, read or capture bound, as each one states:
359
385
  repository root the filesystem could not decode makes the machine formats exit 1 where Markdown
360
386
  still renders
361
387
  - Git subprocess output: 16 MiB (bounded while reading)
388
+ - Git status preflight: at most 128 repositories, including initialized nested submodules
362
389
  - Candidate file validation: 16 MiB hard read bound
363
390
 
364
391
  Truncation occurs only at complete UTF-8 and line boundaries and is marked explicitly.
@@ -382,6 +409,13 @@ the remote.
382
409
  this is normal after cloning an empty remote.
383
410
  - `Ahead / behind: not available` means the commit relationship cannot currently be computed.
384
411
  Counts are reported only when the configured upstream commit is available locally.
412
+ - Executable Git clean/process filters can run during `status`, even with optional locks
413
+ disabled. Before reading status, the loader checks effective repository configuration,
414
+ including includes, worktree configuration and initialized nested submodules. A non-empty
415
+ clean/process command causes a safe error with no stdout, even if no current attribute uses
416
+ it. Disabling a driver silently would change cleanliness semantics. Filter-dependent repositories
417
+ (including locally configured Git LFS drivers) therefore need ordinary Git inspection outside
418
+ this loader. Git protocols are also disabled to prevent lazy object retrieval.
385
419
 
386
420
  ## Exit Codes
387
421
 
@@ -394,7 +428,7 @@ or candidate-file content.
394
428
 
395
429
  ## Not Included
396
430
 
397
- Version 1.3.2 does not provide AI summaries, project-type detection, loading or transport of
431
+ Version 1.3.3 does not provide AI summaries, project-type detection, loading or transport of
398
432
  nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
399
433
  Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
400
434
  network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
@@ -407,6 +441,17 @@ Run the complete local check:
407
441
  just check
408
442
  ```
409
443
 
444
+ The lockfile uses public PyPI artifact URLs; no organization mirror or local Renovate preset
445
+ is needed. Renovate keeps scheduled, rate-limited updates and manual merges using a public
446
+ recommended preset plus the repository's explicit settings. Python 3.12 remains the tested
447
+ runtime; widening that range requires testing those interpreters first.
448
+
449
+ Git test fixtures discard inherited `GIT_*` routing/configuration before creating repositories.
450
+ Permission-sensitive tests must run as a non-root user. Gitea uses `just ci-check`: non-root
451
+ runners execute directly; root containers use `runuser` with an existing account named by
452
+ `CONTEXT_LOADER_CI_USER` (default `ubuntu`). That account must be able to execute the installed
453
+ `uv`, Python and `just`. No `sudo` dependency is required for this CI step.
454
+
410
455
  ## Version maintenance
411
456
 
412
457
  The versions in `pyproject.toml` and `context_loader/__init__.py`, the `tool_version` in
@@ -427,6 +472,11 @@ only verifies the public release, mirrors its exact formal tag object if missing
427
472
  the Gitea Release record. It builds no package and has no PyPI publishing credentials.
428
473
  No private endpoint or credential is needed on GitHub.
429
474
 
475
+ For environments that permit direct public reads but share an exhausted proxy quota,
476
+ set the Gitea Actions repository variable `RELEASE_PUBLIC_API_DIRECT=true`. The record
477
+ step appends only public GitHub API/PyPI hosts to the existing proxy bypass lists. Without
478
+ this explicit option, the workflow uses its inherited proxy settings.
479
+
430
480
  Release checks use Python 3.12, Git, and the runner's existing OpenSSL CLI. They compare downloaded
431
481
  PyPI file bytes, metadata and SHA-256, plus the publisher/tag/commit claims in PyPI's HTTPS-served
432
482
  provenance. This is an identity check, not an independent cryptographic Sigstore verifier.
@@ -1,3 +1,3 @@
1
1
  """Project Context Loader."""
2
2
 
3
- __version__ = "1.3.2"
3
+ __version__ = "1.3.3"
@@ -26,7 +26,6 @@ from .model import (
26
26
  from .render import (
27
27
  RenderedSourceBody,
28
28
  render_markdown_with_details,
29
- rendered_source_contents,
30
29
  )
31
30
 
32
31
  # Each value names one exact document shape and is never reused: 1 and 2 are the
@@ -70,9 +69,7 @@ class ContextStatus:
70
69
  code: str
71
70
  subject_kind: str
72
71
  subject: str
73
- # The raw fact behind the displayed subject. Display escaping is not injective, so
74
- # deduplication must key on this and never on `subject`, or two different directories
75
- # could collapse into one claim. It is not serialized.
72
+ # Deduplicate raw facts independently of display formatting. Not serialized.
76
73
  identity: str = field(default="", compare=False)
77
74
 
78
75
 
@@ -191,7 +188,19 @@ def _build_statuses(
191
188
  for entry in project.directory_tree.entries:
192
189
  if entry.kind == "unreadable_directory":
193
190
  subject = entry.path if entry.path else "."
194
- statuses.append(_status("unreadable", "tree_entry", display_text(subject)))
191
+ statuses.append(
192
+ _status("unreadable", "tree_entry", display_text(subject), identity=subject)
193
+ )
194
+ elif entry.kind == "scan_limit_directory":
195
+ subject = entry.path if entry.path else "."
196
+ statuses.append(
197
+ _status(
198
+ "directory_scan_limit_exceeded",
199
+ "tree_entry",
200
+ display_text(subject),
201
+ identity=subject,
202
+ )
203
+ )
195
204
  tree = project.directory_tree
196
205
  named = list(dict.fromkeys(tree.incomplete_directories))
197
206
  for path in named:
@@ -212,7 +221,7 @@ def _build_statuses(
212
221
  "directory_listing_incomplete",
213
222
  "tree",
214
223
  f"{tree.incomplete_count} directories exceeded the "
215
- f"{tree.enumeration_limit}-entry per-directory enumeration limit; "
224
+ f"{tree.enumeration_limit}-entry per-directory retention limit; "
216
225
  f"{unnamed} further {stem} not named individually",
217
226
  identity=f"aggregate:{tree.incomplete_count}:{unnamed}",
218
227
  )
@@ -254,7 +263,7 @@ def load_project_context(
254
263
  raise ContextLoaderError(str(exc), exit_code=2) from None
255
264
  rendered = render_markdown_with_details(state, project)
256
265
  context = rendered.output.decode("utf-8")
257
- rendered_bodies = rendered_source_contents(project, rendered.included_sections)
266
+ rendered_bodies = rendered.source_bodies
258
267
  sources = _sources(state.repository, rendered_bodies)
259
268
  statuses = _build_statuses(
260
269
  project,
@@ -13,6 +13,7 @@ from dataclasses import dataclass, replace
13
13
  from pathlib import Path, PurePosixPath
14
14
 
15
15
  from .filesystem import (
16
+ DirectoryScanLimitError,
16
17
  enumerate_directory,
17
18
  open_bounded_regular_file,
18
19
  open_child_directory,
@@ -185,6 +186,7 @@ def _ordered_reasons(reasons: set[str] | frozenset[str]) -> tuple[str, ...]:
185
186
 
186
187
 
187
188
  _AUDIT_EMPTY_LIST_LINE = "- None."
189
+ _AUDIT_NO_INDEX_LINE = "- Omitted content has no available heading index; read the source path."
188
190
 
189
191
 
190
192
  def _audit_heading(heading: str) -> str:
@@ -198,6 +200,11 @@ def _audit_index_line(entry: AgentsSectionAuditEntry) -> str:
198
200
  return f"- H{entry.heading_level} {_audit_heading(entry.heading)}"
199
201
 
200
202
 
203
+ def _audit_selected_line(entry: AgentsSectionAuditEntry) -> str:
204
+ label = "Document head" if entry.heading_level == 0 else _audit_heading(entry.heading)
205
+ return f"- {label} — `{', '.join(entry.reasons)}`"
206
+
207
+
201
208
  def render_agents_selection_audit(audit: AgentsSelectionAudit) -> str:
202
209
  """Render the bounded, prompt-free AGENTS selection audit and fail-safe index."""
203
210
  lines = [
@@ -212,9 +219,7 @@ def render_agents_selection_audit(audit: AgentsSelectionAudit) -> str:
212
219
  ]
213
220
  if audit.selected_sections:
214
221
  for entry in audit.selected_sections:
215
- label = "Document head" if entry.heading_level == 0 else _audit_heading(entry.heading)
216
- reasons = ", ".join(entry.reasons)
217
- lines.append(f"- {label} — `{reasons}`")
222
+ lines.append(_audit_selected_line(entry))
218
223
  else:
219
224
  lines.append(_AUDIT_EMPTY_LIST_LINE)
220
225
  lines.extend(
@@ -229,7 +234,7 @@ def render_agents_selection_audit(audit: AgentsSelectionAudit) -> str:
229
234
  else:
230
235
  lines.append(_AUDIT_EMPTY_LIST_LINE)
231
236
  if audit.truncated and not audit.index_truncated:
232
- lines.append("- Omitted content has no available heading index; read the source path.")
237
+ lines.append(_AUDIT_NO_INDEX_LINE)
233
238
  if audit.index_truncated:
234
239
  lines.append("- Additional headings omitted from this index by the AGENTS budget.")
235
240
  if audit.parse_fallback:
@@ -329,7 +334,11 @@ def _small_head_end(content: str, sections: tuple[MarkdownSection, ...]) -> int:
329
334
  def _section_relevance(
330
335
  section: MarkdownSection, signals: _SelectionSignals
331
336
  ) -> tuple[int, frozenset[str]]:
332
- heading_tokens = _matching_tokens(section.heading, _MATCH_TOKEN_STOPWORDS)
337
+ heading_tokens = frozenset(
338
+ token
339
+ for token in section.normalized_heading.split()
340
+ if len(token) >= 3 and token not in _MATCH_TOKEN_STOPWORDS
341
+ )
333
342
  section_tokens = _matching_tokens(section.text, _MATCH_TOKEN_STOPWORDS)
334
343
  body_tokens = section_tokens - heading_tokens
335
344
  score = 0
@@ -343,9 +352,9 @@ def _section_relevance(
343
352
 
344
353
  path_heading = signals.path_tokens & heading_tokens
345
354
  path_body = signals.path_tokens & body_tokens
346
- normalized_section = _normalized_text(section.text)
347
355
  exact_path = bool(
348
- signals.normalized_path and _normalized_text(signals.normalized_path) in normalized_section
356
+ signals.normalized_path
357
+ and _normalized_text(signals.normalized_path) in _normalized_text(section.text)
349
358
  )
350
359
  if exact_path or path_heading or path_body:
351
360
  reasons.add("path_match")
@@ -522,50 +531,172 @@ def _select_agents_content(source: CollectedFile, signals: _SelectionSignals) ->
522
531
  return _bounded_head_fallback(source, parse_fallback=True)
523
532
 
524
533
  head_end = _small_head_end(source.content, sections)
525
- selected_indices: frozenset[int] = frozenset()
526
- reasons: dict[int, set[str]] = {}
534
+ selected_indices = {index for index, section in enumerate(sections) if section.end <= head_end}
535
+ reasons: dict[int, set[str]] = {index: {"head"} for index in selected_indices}
527
536
  ranked: list[tuple[int, int, frozenset[str]]] = []
528
537
  for index, section in enumerate(sections):
529
- score, section_reasons = _section_relevance(section, signals)
530
- if score >= 4:
531
- ranked.append((score, index, section_reasons))
538
+ if signals.focus_tokens or signals.path_tokens or signals.normalized_path:
539
+ score, section_reasons = _section_relevance(section, signals)
540
+ if score >= 4:
541
+ ranked.append((score, index, section_reasons))
532
542
  ranked.sort(key=lambda item: (-item[0], sections[item[1]].start))
533
543
 
544
+ # Sections are disjoint. Candidate admission therefore needs only the bytes,
545
+ # characters and audit lines changed by that section and its (at most six)
546
+ # parents, rather than rebuilding every section/index for every candidate.
547
+ extra_bytes = [
548
+ len(source.content[max(head_end, section.start) : section.end].encode("utf-8"))
549
+ if section.end > head_end
550
+ else 0
551
+ for section in sections
552
+ ]
553
+ index_sizes = [
554
+ len(
555
+ _audit_index_line(AgentsSectionAuditEntry(s.heading, s.heading_level, ())).encode(
556
+ "utf-8"
557
+ )
558
+ )
559
+ + 1
560
+ for s in sections
561
+ ]
562
+ selected_sizes = {
563
+ index: len(
564
+ _audit_selected_line(
565
+ AgentsSectionAuditEntry(
566
+ sections[index].heading, sections[index].heading_level, ("head",)
567
+ )
568
+ ).encode("utf-8")
569
+ )
570
+ + 1
571
+ for index in selected_indices
572
+ }
573
+ selected_lines = sum(selected_sizes.values())
574
+ document_head_end = sections[0].start if sections else len(source.content)
575
+ if document_head_end and head_end:
576
+ selected_lines += (
577
+ len(
578
+ _audit_selected_line(AgentsSectionAuditEntry("Document head", 0, ("head",))).encode(
579
+ "utf-8"
580
+ )
581
+ )
582
+ + 1
583
+ )
584
+ content_bytes = len(source.content[:head_end].encode("utf-8"))
585
+ chars_selected = head_end
586
+ index_count = len(sections) - len(selected_indices)
587
+ index_lines = sum(
588
+ size for index, size in enumerate(index_sizes) if index not in selected_indices
589
+ )
590
+ first_index = len(selected_indices)
591
+ placeholder_bytes = len(_AUDIT_EMPTY_LIST_LINE.encode("utf-8")) + 1
592
+ no_index_bytes = len(_AUDIT_NO_INDEX_LINE.encode("utf-8")) + 1
593
+ empty_sizes = {
594
+ (truncated, index_truncated): len(
595
+ render_agents_selection_audit(
596
+ AgentsSelectionAudit(
597
+ source="AGENTS.md",
598
+ selected_sections=(),
599
+ indexed_only_sections=(),
600
+ chars_selected=0,
601
+ chars_omitted=0,
602
+ truncated=truncated,
603
+ source_scan_truncated=source.truncated,
604
+ index_truncated=index_truncated,
605
+ )
606
+ ).encode("utf-8")
607
+ )
608
+ for truncated in (False, True)
609
+ for index_truncated in (False, True)
610
+ }
611
+
612
+ def minimum_cost(
613
+ body_bytes: int,
614
+ chars: int,
615
+ selected_line_bytes: int,
616
+ remaining_count: int,
617
+ remaining_lines: int,
618
+ first_remaining: int,
619
+ ) -> int:
620
+ omitted = max(0, source.source_characters - chars)
621
+ truncated = omitted > 0 or source.truncated
622
+ common = body_bytes + len(str(chars)) + len(str(omitted)) - 2
623
+ if selected_line_bytes:
624
+ common += selected_line_bytes - placeholder_bytes
625
+ full = common + empty_sizes[truncated, False]
626
+ if remaining_count:
627
+ full += remaining_lines - placeholder_bytes
628
+ if truncated:
629
+ full -= no_index_bytes
630
+ fitted = common + empty_sizes[truncated, True]
631
+ if remaining_count > 1:
632
+ # A very short first index line can cost less than the empty placeholder.
633
+ fitted += min(0, index_sizes[first_remaining] - placeholder_bytes)
634
+ return min(full, fitted)
635
+
534
636
  budget_fallback: set[int] = set()
535
637
  for _score, index, section_reasons in ranked:
536
- trial_indices = set(selected_indices)
537
- trial_reasons = {key: set(value) for key, value in reasons.items()}
538
- trial_indices.add(index)
539
- trial_reasons.setdefault(index, set()).update(section_reasons)
638
+ trial_reasons = {index: reasons.get(index, set()) | set(section_reasons)}
540
639
  parent = sections[index].parent_index
541
640
  while parent is not None:
542
- trial_indices.add(parent)
543
- trial_reasons.setdefault(parent, set()).add("parent_context")
641
+ trial_reasons[parent] = reasons.get(parent, set()) | {"parent_context"}
544
642
  parent = sections[parent].parent_index
545
- trial_content, trial_audit = _selection_state(
546
- source.content,
547
- sections,
548
- head_end,
549
- frozenset(trial_indices),
550
- trial_reasons,
551
- frozenset(budget_fallback),
552
- parse_fallback=False,
553
- source_scan_truncated=source.truncated,
554
- source_characters=source.source_characters,
643
+ new_indices = trial_reasons.keys() - selected_indices
644
+ trial_bytes = content_bytes + sum(extra_bytes[item] for item in new_indices)
645
+ trial_chars = chars_selected + sum(
646
+ sections[item].end - max(head_end, sections[item].start) for item in new_indices
555
647
  )
556
- try:
557
- _fit_agents_index(trial_content, trial_audit)
558
- except RuntimeError:
648
+ trial_sizes = {
649
+ item: len(
650
+ _audit_selected_line(
651
+ AgentsSectionAuditEntry(
652
+ sections[item].heading,
653
+ sections[item].heading_level,
654
+ _ordered_reasons(item_reasons),
655
+ )
656
+ ).encode("utf-8")
657
+ )
658
+ + 1
659
+ for item, item_reasons in trial_reasons.items()
660
+ }
661
+ trial_selected_lines = selected_lines + sum(
662
+ size - selected_sizes.get(item, 0) for item, size in trial_sizes.items()
663
+ )
664
+ trial_index_count = index_count - len(new_indices)
665
+ trial_index_lines = index_lines - sum(index_sizes[item] for item in new_indices)
666
+ trial_first = first_index
667
+ while trial_first < len(sections) and (
668
+ trial_first in selected_indices or trial_first in new_indices
669
+ ):
670
+ trial_first += 1
671
+ if (
672
+ minimum_cost(
673
+ trial_bytes,
674
+ trial_chars,
675
+ trial_selected_lines,
676
+ trial_index_count,
677
+ trial_index_lines,
678
+ trial_first,
679
+ )
680
+ > AGENTS_LIMIT_BYTES
681
+ ):
559
682
  budget_fallback.add(index)
560
683
  else:
561
- selected_indices = frozenset(trial_indices)
562
- reasons = trial_reasons
684
+ selected_indices.update(new_indices)
685
+ reasons.update(trial_reasons)
686
+ selected_sizes.update(trial_sizes)
687
+ content_bytes, chars_selected = trial_bytes, trial_chars
688
+ selected_lines = trial_selected_lines
689
+ index_count, index_lines, first_index = (
690
+ trial_index_count,
691
+ trial_index_lines,
692
+ trial_first,
693
+ )
563
694
 
564
695
  content, audit = _selection_state(
565
696
  source.content,
566
697
  sections,
567
698
  head_end,
568
- selected_indices,
699
+ frozenset(selected_indices),
569
700
  reasons,
570
701
  frozenset(budget_fallback),
571
702
  parse_fallback=False,
@@ -837,6 +968,9 @@ def _collect_directory_tree(root: Path) -> DirectoryTree:
837
968
  continue
838
969
  try:
839
970
  walk(child_descriptor, entry_path, depth + 1)
971
+ except DirectoryScanLimitError:
972
+ collected[index] = TreeEntry(entry_path, "scan_limit_directory")
973
+ truncated = True
840
974
  except OSError:
841
975
  mark_unreadable(index, entry_path)
842
976
  finally:
@@ -849,6 +983,9 @@ def _collect_directory_tree(root: Path) -> DirectoryTree:
849
983
  try:
850
984
  try:
851
985
  walk(root_descriptor, "", 0)
986
+ except DirectoryScanLimitError:
987
+ collected.append(TreeEntry("", "scan_limit_directory"))
988
+ truncated = True
852
989
  except OSError:
853
990
  if not collected:
854
991
  collected.append(TreeEntry("", "unreadable_directory"))
@@ -1,8 +1,8 @@
1
1
  """Safe, bounded filesystem primitives shared by every context collection scan.
2
2
 
3
3
  Everything here is descriptor-relative, never follows a symlink across a boundary it
4
- does not report, and caps its own work: this module reads directory entries and file
5
- bytes, and it reports what it observed as typed availability reasons rather than as
4
+ does not report, and bounds retained entries and file reads. It reports observations
5
+ as typed availability reasons rather than as
6
6
  prose, so callers cannot lose the difference between absence and a bounded claim.
7
7
  """
8
8
 
@@ -18,7 +18,7 @@ from dataclasses import dataclass
18
18
  from heapq import nsmallest
19
19
  from pathlib import Path
20
20
 
21
- from .model import FILE_SCAN_LIMIT_BYTES, Availability
21
+ from .model import DIRECTORY_NAME_SCAN_LIMIT_ENTRIES, FILE_SCAN_LIMIT_BYTES, Availability
22
22
 
23
23
  DIRECTORY_OPEN_FLAGS = os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)
24
24
  REGULAR_FILE_OPEN_FLAGS = os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | getattr(os, "O_NOFOLLOW", 0)
@@ -38,31 +38,37 @@ class DirectoryEntryFacts:
38
38
  class DirectoryListing:
39
39
  """A sorted, capped enumeration of one directory.
40
40
 
41
- ``capped`` is the honest claim that the directory holds more entries than were
42
- examined, so a caller can never present this listing as complete.
41
+ ``capped`` means more names were scanned than were retained and classified,
42
+ so a caller can never present this listing as complete.
43
43
  """
44
44
 
45
45
  entries: tuple[DirectoryEntryFacts, ...]
46
46
  capped: bool
47
47
 
48
48
 
49
+ class DirectoryScanLimitError(OSError):
50
+ """The name scan exceeded its hard cap; no alphabetical prefix is trustworthy."""
51
+
52
+
49
53
  def display_text(value: str) -> str:
50
54
  r"""Render one filesystem or Git string as printable, encodable ASCII-safe text.
51
55
 
52
- Backticks, lone surrogates produced by ``surrogateescape`` decoding, and every
53
- other non-printable codepoint become ``\xNN``/``\uNNNN``/``\UNNNNNNNN`` escapes so
54
- the character can cross the JSON and Markdown serialization boundaries intact.
56
+ Escape backslashes as well as backticks and non-printable codepoints so names
57
+ remain distinguishable. ``\x80`` through ``\xff`` represent undecodable bytes;
58
+ Unicode controls above ASCII use ``\uNNNN`` instead of sharing those escapes.
55
59
  """
56
60
  rendered: list[str] = []
57
61
  for character in value:
58
62
  codepoint = ord(character)
59
- if character == "`":
63
+ if character == "\\":
64
+ rendered.append(r"\\")
65
+ elif character == "`":
60
66
  rendered.append(r"\x60")
61
67
  elif 0xDC80 <= codepoint <= 0xDCFF:
62
68
  rendered.append(f"\\x{codepoint - 0xDC00:02x}")
63
69
  elif character.isprintable():
64
70
  rendered.append(character)
65
- elif codepoint <= 0xFF:
71
+ elif codepoint <= 0x7F:
66
72
  rendered.append(f"\\x{codepoint:02x}")
67
73
  elif codepoint <= 0xFFFF:
68
74
  rendered.append(f"\\u{codepoint:04x}")
@@ -93,6 +99,8 @@ def open_child_directory(parent_descriptor: int, name: str) -> int:
93
99
  def _counted(entries: Iterator[os.DirEntry[str]], counter: list[int]) -> Iterator[os.DirEntry[str]]:
94
100
  for entry in entries:
95
101
  counter[0] += 1
102
+ if counter[0] > DIRECTORY_NAME_SCAN_LIMIT_ENTRIES:
103
+ raise DirectoryScanLimitError("directory name scan exceeded the safety limit")
96
104
  yield entry
97
105
 
98
106
 
@@ -117,7 +125,11 @@ def enumerate_directory(directory_descriptor: int, limit: int) -> DirectoryListi
117
125
  rather than whatever the operating system happened to yield first, so a capped
118
126
  listing is reproducible. Memory stays bounded by ``limit`` because entries are
119
127
  selected while the scan iterator is consumed, and only the retained entries are
120
- classified. ``OSError`` propagates when the directory itself cannot be read, so
128
+ classified. All names must be scanned to establish that alphabetical prefix;
129
+ ``limit`` bounds retention/classification, not iteration or elapsed time. The
130
+ independent name-scan cap raises ``DirectoryScanLimitError`` and discards the
131
+ entire prefix rather than reporting an OS-order-dependent partial result.
132
+ ``OSError`` propagates when the directory itself cannot be read, so
121
133
  each caller keeps its own meaning for an unreadable directory.
122
134
  """
123
135
  if limit < 1:
@@ -15,6 +15,7 @@ GIT_EXECUTABLE = "/usr/bin/git"
15
15
  GIT_TIMEOUT_SECONDS = 30
16
16
  MAX_GIT_OUTPUT_BYTES = 16 * 1024 * 1024
17
17
  MAX_RECENT_COMMITS = 8
18
+ MAX_STATUS_REPOSITORIES = 128
18
19
 
19
20
  _OID_PATTERN = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z")
20
21
  _GIT_PREFIX = (
@@ -37,10 +38,13 @@ _GIT_PREFIX = (
37
38
  "i18n.logOutputEncoding=UTF-8",
38
39
  "-c",
39
40
  "log.showSignature=false",
41
+ "-c",
42
+ "protocol.allow=never",
40
43
  )
41
44
  _GIT_ENVIRONMENT = MappingProxyType(
42
45
  {
43
46
  "GIT_ATTR_NOSYSTEM": "1",
47
+ "GIT_ALLOW_PROTOCOL": "",
44
48
  "GIT_CONFIG_COUNT": "0",
45
49
  "GIT_CONFIG_GLOBAL": "/dev/null",
46
50
  "GIT_CONFIG_NOSYSTEM": "1",
@@ -314,7 +318,65 @@ def _upstream_state(repo: Path, branch_name: str | None) -> tuple[str, str]:
314
318
  return upstream, f"{counts[0].decode('ascii')} / {counts[1].decode('ascii')}"
315
319
 
316
320
 
321
+ def _ensure_safe_status(repo: Path) -> None:
322
+ """Reject executable conversions before status can refresh any index entry.
323
+
324
+ Attributes can select clean/process drivers even during a read-only status.
325
+ Ignoring those drivers would change Git's cleanliness semantics, so fail closed
326
+ instead. Status also inspects initialized submodules; check their own config
327
+ without invoking `submodule foreach` or any repository-supplied command.
328
+ """
329
+ pending = [repo]
330
+ seen: set[Path] = set()
331
+ while pending:
332
+ current = pending.pop()
333
+ identity = current.resolve()
334
+ if identity in seen:
335
+ raise ContextLoaderError("unable to safely inspect cyclic submodule state")
336
+ seen.add(identity)
337
+ if len(seen) > MAX_STATUS_REPOSITORIES:
338
+ raise ContextLoaderError("repository submodule inspection exceeded the safety limit")
339
+ filters = _run_git(
340
+ current,
341
+ ("config", "--null", "--get-regexp", r"^filter\..*\.(clean|process)$"),
342
+ check=False,
343
+ )
344
+ if filters.returncode not in (0, 1):
345
+ raise ContextLoaderError("unable to safely inspect repository Git filters")
346
+ filter_commands: dict[bytes, bytes] = {}
347
+ for record in filters.stdout.split(b"\0"):
348
+ if not record:
349
+ continue
350
+ key, separator, value = record.partition(b"\n")
351
+ if not key or not separator:
352
+ raise ContextLoaderError("unable to safely inspect repository Git filters")
353
+ filter_commands[key] = value
354
+ if any(filter_commands.values()):
355
+ raise ContextLoaderError(
356
+ "repository or submodule config enables Git clean/process filters; "
357
+ "unable to safely inspect working-tree state"
358
+ )
359
+ index = _run_git(current, ("ls-files", "--stage", "-z"))
360
+ children: set[Path] = set()
361
+ for record in index.stdout.split(b"\0"):
362
+ metadata, separator, raw_path = record.partition(b"\t")
363
+ if metadata.split(b" ", 1)[0] != b"160000":
364
+ continue
365
+ if not separator or not raw_path:
366
+ raise ContextLoaderError("unable to parse repository submodule state")
367
+ path = Path(_decode_path(raw_path))
368
+ if path.is_absolute() or ".." in path.parts:
369
+ raise ContextLoaderError("unable to safely inspect repository submodule state")
370
+ child = current / path
371
+ if (child / ".git").exists():
372
+ children.add(child)
373
+ pending.extend(sorted(children))
374
+ if len(seen) + len(pending) > MAX_STATUS_REPOSITORIES:
375
+ raise ContextLoaderError("repository submodule inspection exceeded the safety limit")
376
+
377
+
317
378
  def _working_tree_changes(repo: Path) -> tuple[WorkingTreeChange, ...]:
379
+ _ensure_safe_status(repo)
318
380
  result = _run_git(
319
381
  repo,
320
382
  (
@@ -22,9 +22,10 @@ DECLARED_COMMANDS_LIMIT_BYTES = 8 * 1024
22
22
  DIRECTORY_TREE_LIMIT_BYTES = 12 * 1024
23
23
  DIRECTORY_TREE_MAX_ITEMS = 300
24
24
  DIRECTORY_TREE_MAX_DEPTH = 2
25
+ DIRECTORY_NAME_SCAN_LIMIT_ENTRIES = 65_536
25
26
  # Deliberately above the item budget: which of a directory's own entries are shown must
26
27
  # be decided by the item budget and its root-file guarantee, not by enumeration order.
27
- # It is still a hard cap, so one huge directory can never be materialised in memory.
28
+ # This caps retention/classification; choosing the prefix still scans all names.
28
29
  DIRECTORY_TREE_MAX_ENTRIES_PER_DIRECTORY = 512
29
30
  # A capped directory is reported once as a number plus a bounded set of examples, so the
30
31
  # completeness claim cannot itself inflate the section it describes.
@@ -35,13 +36,12 @@ NESTED_AGENTS_MAX_DIRECTORIES = 2_000
35
36
  NESTED_AGENTS_MAX_FILES = 32
36
37
  NESTED_AGENTS_MAX_LIST_BYTES = 4 * 1024
37
38
  # The presence scan inspects names only and lists no directory entries at all, so it can
38
- # examine a wider prefix per directory than the tree does; exceeding it is scan truncation.
39
+ # retain a wider prefix per directory than the tree does; exceeding it is scan truncation.
39
40
  NESTED_AGENTS_MAX_ENTRIES_PER_DIRECTORY = 1_024
40
41
  NESTED_AGENTS_EXCLUDED_DIRECTORIES = frozenset(
41
42
  {".git", ".venv", "node_modules", "site-packages", "venv"}
42
43
  )
43
44
  FILE_SCAN_LIMIT_BYTES = 16 * 1024 * 1024
44
- MAX_FILE_SCAN_BYTES = FILE_SCAN_LIMIT_BYTES
45
45
  TRUNCATION_MARKER = "… truncated by context-loader …"
46
46
 
47
47
  ENTRY_FILE_SPECS = (
@@ -148,7 +148,7 @@ class DirectoryTree:
148
148
  """A bounded directory listing that never overstates what it examined.
149
149
 
150
150
  ``truncated`` claims that entries exist beyond this listing. A directory holding more
151
- entries than one enumeration examines is counted in ``incomplete_count`` against
151
+ entries than one listing retains is counted in ``incomplete_count`` against
152
152
  ``enumeration_limit``, and ``incomplete_directories`` retains only the first
153
153
  ``DIRECTORY_TREE_INCOMPLETE_STATUS_LIMIT`` of them, so a partial listing of a directory is
154
154
  never presented as a complete one and the claim stays bounded however many hit the cap.
@@ -41,6 +41,7 @@ class MarkdownRender:
41
41
  changes_truncated: bool
42
42
  commands_truncated: bool
43
43
  tree_truncated: bool
44
+ source_bodies: tuple[RenderedSourceBody, ...]
44
45
 
45
46
 
46
47
  @dataclass(frozen=True, slots=True)
@@ -125,11 +126,10 @@ def _file_body(source: CollectedFile, limit: int) -> tuple[str, bool] | None:
125
126
  return body, truncated
126
127
 
127
128
 
128
- def _file_payload(source: CollectedFile, limit: int) -> str:
129
- body = _file_body(source, limit)
129
+ def _file_payload(source: CollectedFile, body: RenderedSourceBody | None) -> str:
130
130
  if body is None:
131
131
  return _unavailable_sentence(source.reason)
132
- return _fenced(source.language, body[0])
132
+ return _fenced(source.language, body.body)
133
133
 
134
134
 
135
135
  def _change_lines(state: RepositoryState) -> tuple[list[str], bool]:
@@ -170,13 +170,15 @@ def _render_git_state(state: RepositoryState) -> str:
170
170
  return "\n".join(lines)
171
171
 
172
172
 
173
- def _render_source_section(title: str, source: CollectedFile, limit: int) -> str:
173
+ def _render_source_section(
174
+ title: str, source: CollectedFile, body: RenderedSourceBody | None
175
+ ) -> str:
174
176
  parts = [
175
177
  f"## {title}",
176
178
  "",
177
179
  f"Source: `{source.name}`",
178
180
  "",
179
- _file_payload(source, limit),
181
+ _file_payload(source, body),
180
182
  ]
181
183
  if source.selection is not None:
182
184
  parts.extend(("", render_agents_selection_audit(source.selection)))
@@ -226,30 +228,44 @@ def _entry_body(source: CollectedFile, remaining: int) -> tuple[str | None, int,
226
228
  if not source.is_text:
227
229
  return None, 0, False
228
230
  encoded_size = len(source.content.encode())
229
- aggregate_truncated = encoded_size > remaining
230
- needs_marker = source.truncated or aggregate_truncated
231
+ limit = max(0, min(remaining, ENTRY_FILE_LIMIT_BYTES))
232
+ needs_marker = source.truncated or encoded_size > limit
231
233
  marker_size = len(TRUNCATION_MARKER.encode()) if needs_marker else 0
232
- content_limit = min(remaining, ENTRY_FILE_LIMIT_BYTES - marker_size)
234
+ content_limit = limit - marker_size
233
235
  visible, was_cut = _truncate_at_line_boundary(source.content, max(0, content_limit))
234
236
  needs_marker = needs_marker or was_cut
235
- body = f"{visible}{TRUNCATION_MARKER}" if needs_marker else visible
236
- return body, len(visible.encode()), needs_marker
237
+ marker = TRUNCATION_MARKER if needs_marker and limit >= marker_size else ""
238
+ body = f"{visible}{marker}"
239
+ return body, len(body.encode()), needs_marker
237
240
 
238
241
 
239
- def _entry_payload(source: CollectedFile, remaining: int) -> tuple[str, int]:
240
- body, consumed, _truncated = _entry_body(source, remaining)
241
- if body is None:
242
- return _unavailable_sentence(source.reason), consumed
243
- return _fenced(source.language, body), consumed
242
+ def _entry_bodies(entry_files: tuple[CollectedFile, ...]) -> tuple[RenderedSourceBody, ...]:
243
+ """Meter actual bodies, reserving room for later sources' truncation markers."""
244
+ marker_size = len(TRUNCATION_MARKER.encode())
245
+ reserved = sum(
246
+ marker_size
247
+ for source in entry_files
248
+ if source.is_text and (source.content or source.truncated)
249
+ )
250
+ used = 0
251
+ rendered: list[RenderedSourceBody] = []
252
+ for source in entry_files:
253
+ if source.is_text and (source.content or source.truncated):
254
+ reserved -= marker_size
255
+ remaining = max(0, ENTRY_FILES_TOTAL_LIMIT_BYTES - used - reserved)
256
+ body, consumed, truncated = _entry_body(source, remaining)
257
+ used += consumed
258
+ if body is not None:
259
+ rendered.append(RenderedSourceBody(source, body, truncated))
260
+ return tuple(rendered)
244
261
 
245
262
 
246
- def _render_entry_files(entry_files: tuple[CollectedFile, ...]) -> str:
263
+ def _render_entry_files(
264
+ entry_files: tuple[CollectedFile, ...], bodies: dict[str, RenderedSourceBody]
265
+ ) -> str:
247
266
  parts = ["## Project Entry Files"]
248
- used_content = 0
249
267
  for source in entry_files:
250
- remaining = max(0, ENTRY_FILES_TOTAL_LIMIT_BYTES - used_content)
251
- payload, consumed = _entry_payload(source, remaining)
252
- used_content += consumed
268
+ payload = _file_payload(source, bodies.get(source.name))
253
269
  parts.extend(("", f"### `{source.name}`", "", payload))
254
270
  return "\n".join(parts)
255
271
 
@@ -286,6 +302,9 @@ def _tree_line(entry: TreeEntry) -> str:
286
302
  return f"{path}@"
287
303
  if entry.kind == "unreadable_directory":
288
304
  return f"{path}/ [Skipped: unreadable.]" if path else "Skipped: unreadable directory."
305
+ if entry.kind == "scan_limit_directory":
306
+ note = "Skipped: directory name scan limit reached."
307
+ return f"{path}/ [{note}]" if path else note
289
308
  return path
290
309
 
291
310
 
@@ -310,8 +329,8 @@ def _incomplete_directory_note(tree: DirectoryTree) -> str:
310
329
  limit = tree.enumeration_limit
311
330
  return (
312
331
  f"Listing incomplete: {tree.incomplete_count} {stem} held more entries than the "
313
- f"{limit}-entry per-directory enumeration limit, so only an alphabetically first "
314
- f"prefix of each was examined and the rest were neither examined nor listed; this "
332
+ f"{limit}-entry per-directory retention limit. All names were scanned to select an "
333
+ f"alphabetically first prefix; only that prefix was retained and classified. This "
315
334
  f"section also keeps only as many entries as its own entry and byte budgets allow. "
316
335
  f"Named here: {examples}."
317
336
  )
@@ -361,6 +380,8 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
361
380
  commands_section, commands_truncated = _render_commands(project.commands)
362
381
  changes_truncated = _change_lines(state)[1]
363
382
  tree_section, tree_truncated = _render_directory_tree(project.directory_tree)
383
+ all_bodies = _source_bodies(project)
384
+ bodies = {body.source.name: body for body in all_bodies}
364
385
  sections: list[tuple[str, str | None]] = [
365
386
  ("Git State", _render_git_state(state)),
366
387
  (
@@ -368,15 +389,17 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
368
389
  _render_source_section(
369
390
  "Development Instructions",
370
391
  project.instructions,
371
- AGENTS_LIMIT_BYTES,
392
+ bodies.get(project.instructions.name),
372
393
  ),
373
394
  ),
374
395
  (
375
396
  "Project Overview",
376
- _render_source_section("Project Overview", project.overview, README_LIMIT_BYTES),
397
+ _render_source_section(
398
+ "Project Overview", project.overview, bodies.get(project.overview.name)
399
+ ),
377
400
  ),
378
401
  ("Declared Commands", commands_section),
379
- ("Project Entry Files", _render_entry_files(project.entry_files)),
402
+ ("Project Entry Files", _render_entry_files(project.entry_files, bodies)),
380
403
  ("Recent Commits", _render_recent_commits(state.commits)),
381
404
  ("Directory Tree", tree_section),
382
405
  ]
@@ -417,34 +440,48 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
417
440
  changes_truncated=changes_truncated,
418
441
  tree_truncated=tree_truncated,
419
442
  commands_truncated=commands_truncated,
443
+ source_bodies=_included_source_bodies(all_bodies, tuple(included_sections)),
420
444
  )
421
445
 
422
446
 
423
- def rendered_source_contents(
424
- project: ProjectContext, included_sections: tuple[str, ...]
425
- ) -> tuple[RenderedSourceBody, ...]:
426
- """Return source-file bodies that actually appear in the final Markdown context."""
427
- included = frozenset(included_sections)
447
+ def _source_bodies(project: ProjectContext) -> tuple[RenderedSourceBody, ...]:
428
448
  rendered: list[RenderedSourceBody] = []
429
- if "Development Instructions" in included:
430
- body = _file_body(project.instructions, AGENTS_LIMIT_BYTES)
431
- if body is not None:
432
- rendered.append(RenderedSourceBody(project.instructions, body[0], body[1]))
433
- if "Project Overview" in included:
434
- body = _file_body(project.overview, README_LIMIT_BYTES)
449
+ for source, limit in (
450
+ (project.instructions, AGENTS_LIMIT_BYTES),
451
+ (project.overview, README_LIMIT_BYTES),
452
+ ):
453
+ body = _file_body(source, limit)
435
454
  if body is not None:
436
- rendered.append(RenderedSourceBody(project.overview, body[0], body[1]))
437
- if "Project Entry Files" in included:
438
- used_content = 0
439
- for source in project.entry_files:
440
- remaining = max(0, ENTRY_FILES_TOTAL_LIMIT_BYTES - used_content)
441
- body, consumed, truncated = _entry_body(source, remaining)
442
- used_content += consumed
443
- if body is not None:
444
- rendered.append(RenderedSourceBody(source, body, truncated))
455
+ rendered.append(RenderedSourceBody(source, body[0], body[1]))
456
+ rendered.extend(_entry_bodies(project.entry_files))
445
457
  return tuple(rendered)
446
458
 
447
459
 
460
+ def _included_source_bodies(
461
+ bodies: tuple[RenderedSourceBody, ...], included_sections: tuple[str, ...]
462
+ ) -> tuple[RenderedSourceBody, ...]:
463
+ included = frozenset(included_sections)
464
+ return tuple(
465
+ body
466
+ for body in bodies
467
+ if (
468
+ "Development Instructions"
469
+ if body.source.name == "AGENTS.md"
470
+ else "Project Overview"
471
+ if body.source.name == "README.md"
472
+ else "Project Entry Files"
473
+ )
474
+ in included
475
+ )
476
+
477
+
478
+ def rendered_source_contents(
479
+ project: ProjectContext, included_sections: tuple[str, ...]
480
+ ) -> tuple[RenderedSourceBody, ...]:
481
+ """Return source-file bodies under the same budgets used by the Markdown renderer."""
482
+ return _included_source_bodies(_source_bodies(project), included_sections)
483
+
484
+
448
485
  def render_markdown(state: RepositoryState, project: ProjectContext) -> bytes:
449
486
  """Render all sections in fixed order without exceeding the global byte limit."""
450
487
  return render_markdown_with_details(state, project).output
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "context-loader"
7
- version = "1.3.2"
7
+ version = "1.3.3"
8
8
  description = "Render deterministic local Git context for any executor"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12,<3.13"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "context-loader"
7
- version = "1.3.2"
7
+ version = "1.3.3"
8
8
  description = "Render deterministic local Git context for any executor"
9
9
  license = { file = "LICENSE" }
10
10
  readme = "README.md"
@@ -46,5 +46,5 @@
46
46
  ],
47
47
  "schema_version": 1,
48
48
  "tool_name": "context-loader",
49
- "tool_version": "1.3.2"
49
+ "tool_version": "1.3.3"
50
50
  }
File without changes