context-loader 0.1.8__tar.gz → 1.1.0__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.
- {context_loader-0.1.8 → context_loader-1.1.0}/PKG-INFO +33 -27
- {context_loader-0.1.8 → context_loader-1.1.0}/README.md +31 -25
- context_loader-1.1.0/context_loader/__init__.py +3 -0
- {context_loader-0.1.8 → context_loader-1.1.0}/context_loader/application.py +117 -1
- {context_loader-0.1.8 → context_loader-1.1.0}/context_loader/cli.py +2 -2
- {context_loader-0.1.8 → context_loader-1.1.0}/context_loader/collect.py +12 -1
- {context_loader-0.1.8 → context_loader-1.1.0}/context_loader/git.py +67 -9
- {context_loader-0.1.8 → context_loader-1.1.0}/context_loader/render.py +26 -15
- {context_loader-0.1.8 → context_loader-1.1.0}/pyproject.toml +3 -3
- {context_loader-0.1.8 → context_loader-1.1.0}/pyproject.toml.orig +3 -3
- {context_loader-0.1.8 → context_loader-1.1.0}/tool_cli_contract.json +3 -3
- context_loader-0.1.8/context_loader/__init__.py +0 -3
- {context_loader-0.1.8 → context_loader-1.1.0}/LICENSE +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: context-loader
|
|
3
|
-
Version:
|
|
4
|
-
Summary: Render deterministic local Git context for
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Render deterministic local Git context for any executor
|
|
5
5
|
License: Apache License
|
|
6
6
|
Version 2.0, January 2004
|
|
7
7
|
http://www.apache.org/licenses/
|
|
@@ -231,16 +231,16 @@ pip install context-loader
|
|
|
231
231
|
```
|
|
232
232
|
|
|
233
233
|
```bash
|
|
234
|
-
|
|
234
|
+
project-context --repo /path/to/repo
|
|
235
235
|
```
|
|
236
236
|
|
|
237
237
|
```bash
|
|
238
|
-
|
|
238
|
+
project-context --repo /path/to/repo --format json
|
|
239
239
|
```
|
|
240
240
|
|
|
241
241
|
## Install
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
Source version: `1.1.0`. Install its matching published release or an exact source commit.
|
|
244
244
|
|
|
245
245
|
Install via `uv`:
|
|
246
246
|
|
|
@@ -254,25 +254,24 @@ Install via `pip`:
|
|
|
254
254
|
pip install context-loader
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
-
For development or source-based installs tracking current repository (`
|
|
257
|
+
For development or source-based installs tracking current repository (`1.1.0`):
|
|
258
258
|
|
|
259
259
|
```bash
|
|
260
260
|
uv tool install git+https://github.com/xuanheng-tech/context-loader.git
|
|
261
261
|
```
|
|
262
262
|
|
|
263
|
-
The repository also retains `./
|
|
263
|
+
The repository also retains `./project-context` as a direct development entry point.
|
|
264
264
|
|
|
265
265
|
### Distributions
|
|
266
266
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
267
|
+
The sole distribution is **`context-loader`**, containing the `context_loader` runtime
|
|
268
|
+
and the `project-context` console script. Version `1.0.0` breaks the CLI name and Markdown
|
|
269
|
+
heading contract. The JSON schema is unchanged. Terminal and any executor call this same
|
|
270
|
+
entry point with explicit repository and focus arguments; no provider adapter or private
|
|
271
|
+
session state is involved.
|
|
270
272
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
so installing either name yields exactly one implementation and one CLI. Existing pins keep
|
|
274
|
-
working, and releases `0.1.5`-`0.1.7` of the old name are unchanged. New integrations should
|
|
275
|
-
depend on `context-loader`.
|
|
273
|
+
Previously published packages and historical release records remain unchanged. The new
|
|
274
|
+
release publishes no compatibility distribution or legacy executable alias.
|
|
276
275
|
|
|
277
276
|
The wheel installs the runtime package only. The source distribution additionally carries
|
|
278
277
|
`tool_cli_contract.json`, so a package-only consumer can pin the declared public CLI contract
|
|
@@ -302,11 +301,11 @@ automatic redaction**. Inspect the output before sharing it with a person or ext
|
|
|
302
301
|
## Usage
|
|
303
302
|
|
|
304
303
|
```bash
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
304
|
+
project-context --version
|
|
305
|
+
project-context --repo /home/user/projects/example
|
|
306
|
+
project-context --repo /home/user/projects/example --format markdown
|
|
307
|
+
project-context --repo /home/user/projects/example --format json
|
|
308
|
+
project-context --repo /home/user/projects/example \
|
|
310
309
|
--focus "Authentication and session management" \
|
|
311
310
|
--path auth/session.py
|
|
312
311
|
```
|
|
@@ -346,7 +345,7 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
|
|
|
346
345
|
"schema_version": 1,
|
|
347
346
|
"tool": {
|
|
348
347
|
"name": "context-loader",
|
|
349
|
-
"version": "
|
|
348
|
+
"version": "1.1.0"
|
|
350
349
|
},
|
|
351
350
|
"repository": {
|
|
352
351
|
"requested_path": "/canonical/requested/path",
|
|
@@ -375,6 +374,7 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
|
|
|
375
374
|
],
|
|
376
375
|
"context": "the same assembled Markdown context",
|
|
377
376
|
"context_sha256": "sha256-hex",
|
|
377
|
+
"statuses": [],
|
|
378
378
|
"warnings": []
|
|
379
379
|
}
|
|
380
380
|
```
|
|
@@ -382,15 +382,17 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
|
|
|
382
382
|
`context_sha256` hashes the UTF-8 bytes of `context`; each `content_sha256` does the same for that
|
|
383
383
|
source's `content`. `sources` contains only file bodies that actually enter the final context, in
|
|
384
384
|
assembly order, after the existing newline normalization and truncation rules. `scope` distinguishes
|
|
385
|
-
`repository` from `global`; version 0.
|
|
385
|
+
`repository` from `global`; version 1.0.0's fixed root-file selection currently emits only
|
|
386
386
|
`repository` sources and does not add any global-file discovery.
|
|
387
387
|
|
|
388
|
+
`statuses` lists machine-readable collection and render conditions that previously appeared only inside `context` Markdown: skipped or absent sources, truncated sources, unreadable directory-tree entries, sections omitted under the global output budget, and truncated working-tree or declared-command listings. Each entry has stable `code`, `subject_kind`, and `subject` fields. `sources`, `context`, and `schema_version` remain unchanged; callers can ignore `statuses` safely.
|
|
389
|
+
|
|
388
390
|
The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
|
|
389
391
|
contain heading, heading level, and fixed selection reasons; it never contains the original focus or
|
|
390
392
|
target path. Existing source fields and schema version 1 remain unchanged.
|
|
391
393
|
|
|
392
394
|
The JSON schema version and package version are independent: `schema_version` is currently the
|
|
393
|
-
integer `1`, while `tool.version` is `
|
|
395
|
+
integer `1`, while `tool.version` is `1.1.0`. Callers must depend only on fields declared above.
|
|
394
396
|
The document contains no generated time or random identifier, so unchanged input produces identical
|
|
395
397
|
JSON bytes. On failure, stdout remains empty and stderr contains only a short diagnostic.
|
|
396
398
|
|
|
@@ -447,11 +449,15 @@ scan that ends before EOF still reports the characters it could not select.
|
|
|
447
449
|
- Directory tree: 12 KiB, 300 entries, and depth 2
|
|
448
450
|
- Working-tree changes: 100 paths and 4 KiB
|
|
449
451
|
- Recent commits: 8
|
|
452
|
+
- Git subprocess output: 16 MiB (bounded while reading)
|
|
453
|
+
- Candidate file validation: 16 MiB hard read bound
|
|
450
454
|
|
|
451
455
|
Truncation occurs only at complete UTF-8 and line boundaries and is marked explicitly.
|
|
452
|
-
These are output/capture limits. Eligible regular files are
|
|
453
|
-
and reject NUL bytes, including beyond the captured prefix
|
|
454
|
-
|
|
456
|
+
These are output/capture limits. Eligible regular files are streamed up to a hard safety limit of
|
|
457
|
+
16 MiB to validate UTF-8 and reject NUL bytes, including beyond the captured prefix. A file exceeding
|
|
458
|
+
this limit or failing validation fails closed and is skipped (`Skipped: unreadable.` /
|
|
459
|
+
`Skipped: unsupported text encoding.`); pathological files cannot require unbounded scanning.
|
|
460
|
+
Symlinked candidate files are skipped, and directory symlinks are not traversed.
|
|
455
461
|
|
|
456
462
|
## Git State Semantics
|
|
457
463
|
|
|
@@ -479,7 +485,7 @@ or candidate-file content.
|
|
|
479
485
|
|
|
480
486
|
## Not Included
|
|
481
487
|
|
|
482
|
-
Version 0.
|
|
488
|
+
Version 1.0.0 does not provide AI summaries, project-type detection, nested `AGENTS.md` handling,
|
|
483
489
|
Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
|
|
484
490
|
network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
|
|
485
491
|
|
|
@@ -20,16 +20,16 @@ pip install context-loader
|
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
|
|
23
|
+
project-context --repo /path/to/repo
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
|
|
27
|
+
project-context --repo /path/to/repo --format json
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
## Install
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Source version: `1.1.0`. Install its matching published release or an exact source commit.
|
|
33
33
|
|
|
34
34
|
Install via `uv`:
|
|
35
35
|
|
|
@@ -43,25 +43,24 @@ Install via `pip`:
|
|
|
43
43
|
pip install context-loader
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
For development or source-based installs tracking current repository (`
|
|
46
|
+
For development or source-based installs tracking current repository (`1.1.0`):
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
49
|
uv tool install git+https://github.com/xuanheng-tech/context-loader.git
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
The repository also retains `./
|
|
52
|
+
The repository also retains `./project-context` as a direct development entry point.
|
|
53
53
|
|
|
54
54
|
### Distributions
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
The sole distribution is **`context-loader`**, containing the `context_loader` runtime
|
|
57
|
+
and the `project-context` console script. Version `1.0.0` breaks the CLI name and Markdown
|
|
58
|
+
heading contract. The JSON schema is unchanged. Terminal and any executor call this same
|
|
59
|
+
entry point with explicit repository and focus arguments; no provider adapter or private
|
|
60
|
+
session state is involved.
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
so installing either name yields exactly one implementation and one CLI. Existing pins keep
|
|
63
|
-
working, and releases `0.1.5`-`0.1.7` of the old name are unchanged. New integrations should
|
|
64
|
-
depend on `context-loader`.
|
|
62
|
+
Previously published packages and historical release records remain unchanged. The new
|
|
63
|
+
release publishes no compatibility distribution or legacy executable alias.
|
|
65
64
|
|
|
66
65
|
The wheel installs the runtime package only. The source distribution additionally carries
|
|
67
66
|
`tool_cli_contract.json`, so a package-only consumer can pin the declared public CLI contract
|
|
@@ -91,11 +90,11 @@ automatic redaction**. Inspect the output before sharing it with a person or ext
|
|
|
91
90
|
## Usage
|
|
92
91
|
|
|
93
92
|
```bash
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
93
|
+
project-context --version
|
|
94
|
+
project-context --repo /home/user/projects/example
|
|
95
|
+
project-context --repo /home/user/projects/example --format markdown
|
|
96
|
+
project-context --repo /home/user/projects/example --format json
|
|
97
|
+
project-context --repo /home/user/projects/example \
|
|
99
98
|
--focus "Authentication and session management" \
|
|
100
99
|
--path auth/session.py
|
|
101
100
|
```
|
|
@@ -135,7 +134,7 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
|
|
|
135
134
|
"schema_version": 1,
|
|
136
135
|
"tool": {
|
|
137
136
|
"name": "context-loader",
|
|
138
|
-
"version": "
|
|
137
|
+
"version": "1.1.0"
|
|
139
138
|
},
|
|
140
139
|
"repository": {
|
|
141
140
|
"requested_path": "/canonical/requested/path",
|
|
@@ -164,6 +163,7 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
|
|
|
164
163
|
],
|
|
165
164
|
"context": "the same assembled Markdown context",
|
|
166
165
|
"context_sha256": "sha256-hex",
|
|
166
|
+
"statuses": [],
|
|
167
167
|
"warnings": []
|
|
168
168
|
}
|
|
169
169
|
```
|
|
@@ -171,15 +171,17 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
|
|
|
171
171
|
`context_sha256` hashes the UTF-8 bytes of `context`; each `content_sha256` does the same for that
|
|
172
172
|
source's `content`. `sources` contains only file bodies that actually enter the final context, in
|
|
173
173
|
assembly order, after the existing newline normalization and truncation rules. `scope` distinguishes
|
|
174
|
-
`repository` from `global`; version 0.
|
|
174
|
+
`repository` from `global`; version 1.0.0's fixed root-file selection currently emits only
|
|
175
175
|
`repository` sources and does not add any global-file discovery.
|
|
176
176
|
|
|
177
|
+
`statuses` lists machine-readable collection and render conditions that previously appeared only inside `context` Markdown: skipped or absent sources, truncated sources, unreadable directory-tree entries, sections omitted under the global output budget, and truncated working-tree or declared-command listings. Each entry has stable `code`, `subject_kind`, and `subject` fields. `sources`, `context`, and `schema_version` remain unchanged; callers can ignore `statuses` safely.
|
|
178
|
+
|
|
177
179
|
The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
|
|
178
180
|
contain heading, heading level, and fixed selection reasons; it never contains the original focus or
|
|
179
181
|
target path. Existing source fields and schema version 1 remain unchanged.
|
|
180
182
|
|
|
181
183
|
The JSON schema version and package version are independent: `schema_version` is currently the
|
|
182
|
-
integer `1`, while `tool.version` is `
|
|
184
|
+
integer `1`, while `tool.version` is `1.1.0`. Callers must depend only on fields declared above.
|
|
183
185
|
The document contains no generated time or random identifier, so unchanged input produces identical
|
|
184
186
|
JSON bytes. On failure, stdout remains empty and stderr contains only a short diagnostic.
|
|
185
187
|
|
|
@@ -236,11 +238,15 @@ scan that ends before EOF still reports the characters it could not select.
|
|
|
236
238
|
- Directory tree: 12 KiB, 300 entries, and depth 2
|
|
237
239
|
- Working-tree changes: 100 paths and 4 KiB
|
|
238
240
|
- Recent commits: 8
|
|
241
|
+
- Git subprocess output: 16 MiB (bounded while reading)
|
|
242
|
+
- Candidate file validation: 16 MiB hard read bound
|
|
239
243
|
|
|
240
244
|
Truncation occurs only at complete UTF-8 and line boundaries and is marked explicitly.
|
|
241
|
-
These are output/capture limits. Eligible regular files are
|
|
242
|
-
and reject NUL bytes, including beyond the captured prefix
|
|
243
|
-
|
|
245
|
+
These are output/capture limits. Eligible regular files are streamed up to a hard safety limit of
|
|
246
|
+
16 MiB to validate UTF-8 and reject NUL bytes, including beyond the captured prefix. A file exceeding
|
|
247
|
+
this limit or failing validation fails closed and is skipped (`Skipped: unreadable.` /
|
|
248
|
+
`Skipped: unsupported text encoding.`); pathological files cannot require unbounded scanning.
|
|
249
|
+
Symlinked candidate files are skipped, and directory symlinks are not traversed.
|
|
244
250
|
|
|
245
251
|
## Git State Semantics
|
|
246
252
|
|
|
@@ -268,7 +274,7 @@ or candidate-file content.
|
|
|
268
274
|
|
|
269
275
|
## Not Included
|
|
270
276
|
|
|
271
|
-
Version 0.
|
|
277
|
+
Version 1.0.0 does not provide AI summaries, project-type detection, nested `AGENTS.md` handling,
|
|
272
278
|
Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
|
|
273
279
|
network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
|
|
274
280
|
|
|
@@ -10,9 +10,16 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
from . import __version__
|
|
12
12
|
from .collect import (
|
|
13
|
+
NOT_PRESENT,
|
|
14
|
+
SKIPPED_ENCODING,
|
|
15
|
+
SKIPPED_NOT_REGULAR,
|
|
16
|
+
SKIPPED_SYMLINK,
|
|
17
|
+
SKIPPED_UNREADABLE,
|
|
18
|
+
TRUNCATION_MARKER,
|
|
13
19
|
AgentsSectionAuditEntry,
|
|
14
20
|
AgentsSelectionAudit,
|
|
15
21
|
AgentsSelectionInputError,
|
|
22
|
+
CollectedFile,
|
|
16
23
|
ProjectContext,
|
|
17
24
|
collect_project_context,
|
|
18
25
|
)
|
|
@@ -22,6 +29,14 @@ from .render import render_markdown_with_details, rendered_source_contents
|
|
|
22
29
|
JSON_SCHEMA_VERSION = 1
|
|
23
30
|
TOOL_NAME = "context-loader"
|
|
24
31
|
|
|
32
|
+
_STATUS_CODE_BY_MESSAGE = {
|
|
33
|
+
NOT_PRESENT: "not_present",
|
|
34
|
+
SKIPPED_SYMLINK: "skipped_symlink",
|
|
35
|
+
SKIPPED_NOT_REGULAR: "skipped_not_regular",
|
|
36
|
+
SKIPPED_ENCODING: "skipped_encoding",
|
|
37
|
+
SKIPPED_UNREADABLE: "skipped_unreadable",
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
|
|
26
41
|
@dataclass(frozen=True, slots=True)
|
|
27
42
|
class ToolIdentity:
|
|
@@ -46,6 +61,15 @@ class ProjectContextSource:
|
|
|
46
61
|
selection: AgentsSelectionAudit | None = None
|
|
47
62
|
|
|
48
63
|
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class ContextStatus:
|
|
66
|
+
"""One machine-readable skipped, omitted, truncated, or unreadable condition."""
|
|
67
|
+
|
|
68
|
+
code: str
|
|
69
|
+
subject_kind: str
|
|
70
|
+
subject: str
|
|
71
|
+
|
|
72
|
+
|
|
49
73
|
@dataclass(frozen=True, slots=True)
|
|
50
74
|
class ProjectContextResult:
|
|
51
75
|
schema_version: int
|
|
@@ -55,6 +79,7 @@ class ProjectContextResult:
|
|
|
55
79
|
context: str
|
|
56
80
|
context_sha256: str
|
|
57
81
|
warnings: tuple[str, ...]
|
|
82
|
+
statuses: tuple[ContextStatus, ...]
|
|
58
83
|
|
|
59
84
|
|
|
60
85
|
def _text_sha256(content: str) -> str:
|
|
@@ -102,6 +127,79 @@ def _sources(
|
|
|
102
127
|
return tuple(sources)
|
|
103
128
|
|
|
104
129
|
|
|
130
|
+
def _status(code: str, subject_kind: str, subject: str) -> ContextStatus:
|
|
131
|
+
return ContextStatus(code=code, subject_kind=subject_kind, subject=subject)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _statuses_for_source(source: CollectedFile) -> list[ContextStatus]:
|
|
135
|
+
if source.status is not None:
|
|
136
|
+
code = _STATUS_CODE_BY_MESSAGE.get(source.status, "skipped_unreadable")
|
|
137
|
+
return [_status(code, "source", source.name)]
|
|
138
|
+
statuses: list[ContextStatus] = []
|
|
139
|
+
if source.truncated or (source.selection is not None and source.selection.truncated):
|
|
140
|
+
statuses.append(_status("truncated", "source", source.name))
|
|
141
|
+
if source.selection is not None:
|
|
142
|
+
if source.selection.parse_fallback:
|
|
143
|
+
statuses.append(_status("parse_fallback", "source", source.name))
|
|
144
|
+
if source.selection.source_scan_truncated:
|
|
145
|
+
statuses.append(_status("source_scan_truncated", "source", source.name))
|
|
146
|
+
if source.selection.index_truncated:
|
|
147
|
+
statuses.append(_status("index_truncated", "source", source.name))
|
|
148
|
+
return statuses
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _build_statuses(
|
|
152
|
+
project: ProjectContext,
|
|
153
|
+
*,
|
|
154
|
+
omitted_sections: tuple[str, ...],
|
|
155
|
+
changes_truncated: bool,
|
|
156
|
+
commands_truncated: bool,
|
|
157
|
+
rendered_sources: tuple[ProjectContextSource, ...],
|
|
158
|
+
) -> tuple[ContextStatus, ...]:
|
|
159
|
+
statuses: list[ContextStatus] = []
|
|
160
|
+
for source in (project.instructions, project.overview, *project.entry_files):
|
|
161
|
+
statuses.extend(_statuses_for_source(source))
|
|
162
|
+
|
|
163
|
+
truncated_sources = {
|
|
164
|
+
status.subject
|
|
165
|
+
for status in statuses
|
|
166
|
+
if status.subject_kind == "source" and status.code == "truncated"
|
|
167
|
+
}
|
|
168
|
+
for source in rendered_sources:
|
|
169
|
+
if TRUNCATION_MARKER not in source.content:
|
|
170
|
+
continue
|
|
171
|
+
name = source.path.name
|
|
172
|
+
if name in truncated_sources:
|
|
173
|
+
continue
|
|
174
|
+
statuses.append(_status("truncated", "source", name))
|
|
175
|
+
truncated_sources.add(name)
|
|
176
|
+
|
|
177
|
+
if commands_truncated:
|
|
178
|
+
statuses.append(_status("truncated", "commands", "Declared Commands"))
|
|
179
|
+
if changes_truncated:
|
|
180
|
+
statuses.append(_status("truncated", "changes", "Working Tree Changes"))
|
|
181
|
+
|
|
182
|
+
if project.directory_tree.truncated:
|
|
183
|
+
statuses.append(_status("truncated", "tree", "Directory Tree"))
|
|
184
|
+
for entry in project.directory_tree.entries:
|
|
185
|
+
if entry.kind == "unreadable_directory":
|
|
186
|
+
subject = entry.path if entry.path else "."
|
|
187
|
+
statuses.append(_status("unreadable", "tree_entry", subject))
|
|
188
|
+
|
|
189
|
+
for title in omitted_sections:
|
|
190
|
+
statuses.append(_status("section_omitted", "section", title))
|
|
191
|
+
|
|
192
|
+
deduped: list[ContextStatus] = []
|
|
193
|
+
seen: set[tuple[str, str, str]] = set()
|
|
194
|
+
for status in statuses:
|
|
195
|
+
key = (status.code, status.subject_kind, status.subject)
|
|
196
|
+
if key in seen:
|
|
197
|
+
continue
|
|
198
|
+
seen.add(key)
|
|
199
|
+
deduped.append(status)
|
|
200
|
+
return tuple(deduped)
|
|
201
|
+
|
|
202
|
+
|
|
105
203
|
def load_project_context(
|
|
106
204
|
repo: str | os.PathLike[str],
|
|
107
205
|
*,
|
|
@@ -120,6 +218,14 @@ def load_project_context(
|
|
|
120
218
|
raise ContextLoaderError(str(exc), exit_code=2) from None
|
|
121
219
|
rendered = render_markdown_with_details(state, project)
|
|
122
220
|
context = rendered.output.decode("utf-8")
|
|
221
|
+
sources = _sources(state.repository, project, rendered.included_sections)
|
|
222
|
+
statuses = _build_statuses(
|
|
223
|
+
project,
|
|
224
|
+
omitted_sections=rendered.omitted_sections,
|
|
225
|
+
changes_truncated=rendered.changes_truncated,
|
|
226
|
+
commands_truncated=rendered.commands_truncated,
|
|
227
|
+
rendered_sources=sources,
|
|
228
|
+
)
|
|
123
229
|
return ProjectContextResult(
|
|
124
230
|
schema_version=JSON_SCHEMA_VERSION,
|
|
125
231
|
tool=ToolIdentity(name=TOOL_NAME, version=__version__),
|
|
@@ -127,10 +233,11 @@ def load_project_context(
|
|
|
127
233
|
requested_path=location.requested_path,
|
|
128
234
|
canonical_root=location.canonical_root,
|
|
129
235
|
),
|
|
130
|
-
sources=
|
|
236
|
+
sources=sources,
|
|
131
237
|
context=context,
|
|
132
238
|
context_sha256=hashlib.sha256(rendered.output).hexdigest(),
|
|
133
239
|
warnings=(),
|
|
240
|
+
statuses=statuses,
|
|
134
241
|
)
|
|
135
242
|
|
|
136
243
|
|
|
@@ -157,6 +264,14 @@ def _selection_document(selection: AgentsSelectionAudit) -> dict[str, object]:
|
|
|
157
264
|
}
|
|
158
265
|
|
|
159
266
|
|
|
267
|
+
def _status_document(status: ContextStatus) -> dict[str, object]:
|
|
268
|
+
return {
|
|
269
|
+
"code": status.code,
|
|
270
|
+
"subject": status.subject,
|
|
271
|
+
"subject_kind": status.subject_kind,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
160
275
|
def _source_document(source: ProjectContextSource) -> dict[str, object]:
|
|
161
276
|
document: dict[str, object] = {
|
|
162
277
|
"ordinal": source.ordinal,
|
|
@@ -184,6 +299,7 @@ def render_json(result: ProjectContextResult) -> bytes:
|
|
|
184
299
|
"canonical_root": os.fspath(result.repository.canonical_root),
|
|
185
300
|
},
|
|
186
301
|
"sources": [_source_document(source) for source in result.sources],
|
|
302
|
+
"statuses": [_status_document(status) for status in result.statuses],
|
|
187
303
|
"context": result.context,
|
|
188
304
|
"context_sha256": result.context_sha256,
|
|
189
305
|
"warnings": list(result.warnings),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Command-line interface for deterministic
|
|
1
|
+
"""Command-line interface for deterministic project context."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -19,7 +19,7 @@ class SafeArgumentParser(argparse.ArgumentParser):
|
|
|
19
19
|
|
|
20
20
|
def _parser() -> argparse.ArgumentParser:
|
|
21
21
|
parser = SafeArgumentParser(
|
|
22
|
-
prog="
|
|
22
|
+
prog="project-context",
|
|
23
23
|
description="Render deterministic local Git context as Markdown or JSON.",
|
|
24
24
|
allow_abbrev=False,
|
|
25
25
|
)
|
|
@@ -26,6 +26,8 @@ DECLARED_COMMANDS_LIMIT_BYTES = 8 * 1024
|
|
|
26
26
|
DIRECTORY_TREE_LIMIT_BYTES = 12 * 1024
|
|
27
27
|
DIRECTORY_TREE_MAX_ITEMS = 300
|
|
28
28
|
DIRECTORY_TREE_MAX_DEPTH = 2
|
|
29
|
+
FILE_SCAN_LIMIT_BYTES = 16 * 1024 * 1024
|
|
30
|
+
MAX_FILE_SCAN_BYTES = FILE_SCAN_LIMIT_BYTES
|
|
29
31
|
TRUNCATION_MARKER = "… truncated by context-loader …"
|
|
30
32
|
|
|
31
33
|
NOT_PRESENT = "Not present."
|
|
@@ -705,11 +707,15 @@ def _read_validated_text(file_descriptor: int, limit: int) -> tuple[str, bool, s
|
|
|
705
707
|
source_characters += 1
|
|
706
708
|
append(character)
|
|
707
709
|
|
|
710
|
+
total_bytes_read = 0
|
|
708
711
|
try:
|
|
709
712
|
while True:
|
|
710
713
|
raw = os.read(file_descriptor, 64 * 1024)
|
|
711
714
|
if not raw:
|
|
712
715
|
break
|
|
716
|
+
total_bytes_read += len(raw)
|
|
717
|
+
if total_bytes_read > FILE_SCAN_LIMIT_BYTES:
|
|
718
|
+
return "", False, SKIPPED_UNREADABLE, 0
|
|
713
719
|
if b"\0" in raw:
|
|
714
720
|
return "", False, SKIPPED_ENCODING, 0
|
|
715
721
|
consume(decoder.decode(raw, final=False))
|
|
@@ -743,6 +749,8 @@ def _collect_root_file(root: Path, name: str, language: str, limit: int) -> Coll
|
|
|
743
749
|
return CollectedFile(name, language, SKIPPED_SYMLINK)
|
|
744
750
|
if not stat.S_ISREG(metadata.st_mode):
|
|
745
751
|
return CollectedFile(name, language, SKIPPED_NOT_REGULAR)
|
|
752
|
+
if metadata.st_size > FILE_SCAN_LIMIT_BYTES:
|
|
753
|
+
return CollectedFile(name, language, SKIPPED_UNREADABLE)
|
|
746
754
|
|
|
747
755
|
flags = os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | getattr(os, "O_NOFOLLOW", 0)
|
|
748
756
|
try:
|
|
@@ -752,8 +760,11 @@ def _collect_root_file(root: Path, name: str, language: str, limit: int) -> Coll
|
|
|
752
760
|
return CollectedFile(name, language, SKIPPED_SYMLINK)
|
|
753
761
|
return CollectedFile(name, language, SKIPPED_UNREADABLE)
|
|
754
762
|
try:
|
|
755
|
-
|
|
763
|
+
stat_result = os.fstat(file_descriptor)
|
|
764
|
+
if not stat.S_ISREG(stat_result.st_mode):
|
|
756
765
|
return CollectedFile(name, language, SKIPPED_NOT_REGULAR)
|
|
766
|
+
if stat_result.st_size > FILE_SCAN_LIMIT_BYTES:
|
|
767
|
+
return CollectedFile(name, language, SKIPPED_UNREADABLE)
|
|
757
768
|
content, truncated, error_status, source_characters = _read_validated_text(
|
|
758
769
|
file_descriptor, limit
|
|
759
770
|
)
|
|
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
|
+
import selectors
|
|
7
8
|
import subprocess
|
|
9
|
+
import time
|
|
8
10
|
from dataclasses import dataclass
|
|
9
11
|
from pathlib import Path
|
|
10
12
|
from types import MappingProxyType
|
|
@@ -108,25 +110,81 @@ class RepositoryLocation:
|
|
|
108
110
|
canonical_root: Path
|
|
109
111
|
|
|
110
112
|
|
|
113
|
+
def _read_bounded_process_stdout(
|
|
114
|
+
proc: subprocess.Popen[bytes],
|
|
115
|
+
max_bytes: int,
|
|
116
|
+
timeout_seconds: float,
|
|
117
|
+
) -> bytes:
|
|
118
|
+
deadline = time.monotonic() + timeout_seconds
|
|
119
|
+
buffer = bytearray()
|
|
120
|
+
assert proc.stdout is not None
|
|
121
|
+
os.set_blocking(proc.stdout.fileno(), False)
|
|
122
|
+
selector = selectors.DefaultSelector()
|
|
123
|
+
selector.register(proc.stdout, selectors.EVENT_READ)
|
|
124
|
+
try:
|
|
125
|
+
while True:
|
|
126
|
+
remaining = deadline - time.monotonic()
|
|
127
|
+
if remaining <= 0:
|
|
128
|
+
proc.kill()
|
|
129
|
+
proc.wait()
|
|
130
|
+
raise subprocess.TimeoutExpired(proc.args, timeout_seconds)
|
|
131
|
+
events = selector.select(timeout=max(0.0, remaining))
|
|
132
|
+
if not events:
|
|
133
|
+
proc.kill()
|
|
134
|
+
proc.wait()
|
|
135
|
+
raise subprocess.TimeoutExpired(proc.args, timeout_seconds)
|
|
136
|
+
try:
|
|
137
|
+
chunk = os.read(proc.stdout.fileno(), 64 * 1024)
|
|
138
|
+
except BlockingIOError:
|
|
139
|
+
continue
|
|
140
|
+
if not chunk:
|
|
141
|
+
break
|
|
142
|
+
buffer.extend(chunk)
|
|
143
|
+
if len(buffer) > max_bytes:
|
|
144
|
+
proc.kill()
|
|
145
|
+
proc.wait()
|
|
146
|
+
raise ContextLoaderError("repository Git output exceeded the safety limit")
|
|
147
|
+
except Exception:
|
|
148
|
+
try:
|
|
149
|
+
proc.kill()
|
|
150
|
+
proc.wait()
|
|
151
|
+
except OSError:
|
|
152
|
+
pass
|
|
153
|
+
raise
|
|
154
|
+
finally:
|
|
155
|
+
selector.close()
|
|
156
|
+
|
|
157
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
158
|
+
try:
|
|
159
|
+
proc.wait(timeout=remaining)
|
|
160
|
+
except subprocess.TimeoutExpired:
|
|
161
|
+
proc.kill()
|
|
162
|
+
proc.wait()
|
|
163
|
+
raise
|
|
164
|
+
return bytes(buffer)
|
|
165
|
+
|
|
166
|
+
|
|
111
167
|
def _run_git(repo: Path, arguments: tuple[str, ...], *, check: bool = True) -> GitResult:
|
|
112
168
|
argv = [GIT_EXECUTABLE, *_GIT_PREFIX, "-C", os.fspath(repo), *arguments]
|
|
113
169
|
try:
|
|
114
|
-
|
|
170
|
+
with subprocess.Popen( # noqa: S603 - executable and every command are fixed here.
|
|
115
171
|
argv,
|
|
116
172
|
cwd="/",
|
|
117
173
|
env=dict(_GIT_ENVIRONMENT),
|
|
118
174
|
stdin=subprocess.DEVNULL,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
175
|
+
stdout=subprocess.PIPE,
|
|
176
|
+
stderr=subprocess.DEVNULL,
|
|
177
|
+
) as proc:
|
|
178
|
+
stdout = _read_bounded_process_stdout(proc, MAX_GIT_OUTPUT_BYTES, GIT_TIMEOUT_SECONDS)
|
|
179
|
+
returncode = proc.returncode
|
|
180
|
+
except ContextLoaderError:
|
|
181
|
+
raise
|
|
123
182
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
124
183
|
raise ContextLoaderError("unable to read repository Git state") from exc
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if check and completed.returncode != 0:
|
|
184
|
+
|
|
185
|
+
if check and returncode != 0:
|
|
128
186
|
raise ContextLoaderError("unable to read repository Git state")
|
|
129
|
-
return GitResult(
|
|
187
|
+
return GitResult(stdout, returncode)
|
|
130
188
|
|
|
131
189
|
|
|
132
190
|
def _without_one_line_ending(raw: bytes) -> bytes:
|
|
@@ -34,6 +34,9 @@ GLOBAL_OMISSION = "Omitted: global output limit reached."
|
|
|
34
34
|
class MarkdownRender:
|
|
35
35
|
output: bytes
|
|
36
36
|
included_sections: tuple[str, ...]
|
|
37
|
+
omitted_sections: tuple[str, ...]
|
|
38
|
+
changes_truncated: bool
|
|
39
|
+
commands_truncated: bool
|
|
37
40
|
|
|
38
41
|
|
|
39
42
|
def _display(value: str) -> str:
|
|
@@ -173,10 +176,12 @@ def _command_line(command: DeclaredCommand) -> str:
|
|
|
173
176
|
return f"- `{invocation}` → `{_display(command.target)}`"
|
|
174
177
|
|
|
175
178
|
|
|
176
|
-
def _bounded_lines(
|
|
179
|
+
def _bounded_lines(
|
|
180
|
+
lines: list[str], limit: int, *, already_truncated: bool = False
|
|
181
|
+
) -> tuple[str, bool]:
|
|
177
182
|
complete = "\n".join(lines)
|
|
178
183
|
if not already_truncated and len(complete.encode()) <= limit:
|
|
179
|
-
return complete
|
|
184
|
+
return complete, False
|
|
180
185
|
marker_size = len(TRUNCATION_MARKER.encode())
|
|
181
186
|
budget = max(0, limit - marker_size - 1)
|
|
182
187
|
selected: list[str] = []
|
|
@@ -188,20 +193,15 @@ def _bounded_lines(lines: list[str], limit: int, *, already_truncated: bool = Fa
|
|
|
188
193
|
selected.append(line)
|
|
189
194
|
used += size
|
|
190
195
|
selected.append(TRUNCATION_MARKER)
|
|
191
|
-
return "\n".join(selected)
|
|
196
|
+
return "\n".join(selected), True
|
|
192
197
|
|
|
193
198
|
|
|
194
|
-
def _render_commands(commands: tuple[DeclaredCommand, ...]) -> str:
|
|
199
|
+
def _render_commands(commands: tuple[DeclaredCommand, ...]) -> tuple[str, bool]:
|
|
195
200
|
lines = [_command_line(command) for command in commands]
|
|
196
201
|
if not lines:
|
|
197
202
|
lines.append("No supported command declarations found.")
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
"## Declared Commands",
|
|
201
|
-
"",
|
|
202
|
-
_bounded_lines(lines, DECLARED_COMMANDS_LIMIT_BYTES),
|
|
203
|
-
)
|
|
204
|
-
)
|
|
203
|
+
body, truncated = _bounded_lines(lines, DECLARED_COMMANDS_LIMIT_BYTES)
|
|
204
|
+
return "\n".join(("## Declared Commands", "", body)), truncated
|
|
205
205
|
|
|
206
206
|
|
|
207
207
|
def _entry_body(source: CollectedFile, remaining: int) -> tuple[str | None, int]:
|
|
@@ -276,7 +276,7 @@ def _render_directory_tree(tree: DirectoryTree) -> str:
|
|
|
276
276
|
lines = [_tree_line(entry) for entry in tree.entries]
|
|
277
277
|
if not lines:
|
|
278
278
|
lines.append("No entries.")
|
|
279
|
-
body = _bounded_lines(
|
|
279
|
+
body, _truncated = _bounded_lines(
|
|
280
280
|
lines,
|
|
281
281
|
DIRECTORY_TREE_LIMIT_BYTES,
|
|
282
282
|
already_truncated=tree.truncated,
|
|
@@ -292,12 +292,14 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
|
|
|
292
292
|
"""Render all sections in fixed order without exceeding the global byte limit."""
|
|
293
293
|
header = "\n".join(
|
|
294
294
|
(
|
|
295
|
-
"#
|
|
295
|
+
"# Project Context",
|
|
296
296
|
"",
|
|
297
297
|
f"- Schema: `{SCHEMA}`",
|
|
298
298
|
f"- Repository: `{_display(os.fspath(state.repository))}`",
|
|
299
299
|
)
|
|
300
300
|
)
|
|
301
|
+
commands_section, commands_truncated = _render_commands(project.commands)
|
|
302
|
+
changes_truncated = _change_lines(state)[1]
|
|
301
303
|
sections: list[tuple[str, str | None]] = [
|
|
302
304
|
("Git State", _render_git_state(state)),
|
|
303
305
|
(
|
|
@@ -312,7 +314,7 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
|
|
|
312
314
|
"Project Overview",
|
|
313
315
|
_render_source_section("Project Overview", project.overview, README_LIMIT_BYTES),
|
|
314
316
|
),
|
|
315
|
-
("Declared Commands",
|
|
317
|
+
("Declared Commands", commands_section),
|
|
316
318
|
("Project Entry Files", _render_entry_files(project.entry_files)),
|
|
317
319
|
("Recent Commits", _render_recent_commits(state.commits)),
|
|
318
320
|
("Directory Tree", _render_directory_tree(project.directory_tree)),
|
|
@@ -320,6 +322,7 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
|
|
|
320
322
|
|
|
321
323
|
rendered_parts = [header.encode()]
|
|
322
324
|
included_sections: list[str] = []
|
|
325
|
+
omitted_sections: list[str] = []
|
|
323
326
|
omission_started = False
|
|
324
327
|
for index, (title, full_section) in enumerate(sections):
|
|
325
328
|
omission = _omitted_section(title).encode()
|
|
@@ -330,6 +333,7 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
|
|
|
330
333
|
if omission_started or full_section is None:
|
|
331
334
|
selected = omission
|
|
332
335
|
omission_started = True
|
|
336
|
+
omitted_sections.append(title)
|
|
333
337
|
else:
|
|
334
338
|
candidate = full_section.encode()
|
|
335
339
|
tentative = b"\n\n".join((*rendered_parts, candidate, *future_omissions)) + b"\n"
|
|
@@ -339,12 +343,19 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
|
|
|
339
343
|
else:
|
|
340
344
|
selected = omission
|
|
341
345
|
omission_started = True
|
|
346
|
+
omitted_sections.append(title)
|
|
342
347
|
rendered_parts.append(selected)
|
|
343
348
|
|
|
344
349
|
output = b"\n\n".join(rendered_parts) + b"\n"
|
|
345
350
|
if len(output) > GLOBAL_OUTPUT_LIMIT_BYTES:
|
|
346
351
|
raise RuntimeError("global context output limit could not be satisfied")
|
|
347
|
-
return MarkdownRender(
|
|
352
|
+
return MarkdownRender(
|
|
353
|
+
output=output,
|
|
354
|
+
included_sections=tuple(included_sections),
|
|
355
|
+
omitted_sections=tuple(omitted_sections),
|
|
356
|
+
changes_truncated=changes_truncated,
|
|
357
|
+
commands_truncated=commands_truncated,
|
|
358
|
+
)
|
|
348
359
|
|
|
349
360
|
|
|
350
361
|
def rendered_source_contents(
|
|
@@ -4,8 +4,8 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "context-loader"
|
|
7
|
-
version = "
|
|
8
|
-
description = "Render deterministic local Git context for
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Render deterministic local Git context for any executor"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12,<3.13"
|
|
11
11
|
dependencies = []
|
|
@@ -14,7 +14,7 @@ dependencies = []
|
|
|
14
14
|
file = "LICENSE"
|
|
15
15
|
|
|
16
16
|
[project.scripts]
|
|
17
|
-
|
|
17
|
+
project-context = "context_loader.cli:main"
|
|
18
18
|
|
|
19
19
|
[project.urls]
|
|
20
20
|
Repository = "https://github.com/xuanheng-tech/context-loader"
|
|
@@ -4,15 +4,15 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "context-loader"
|
|
7
|
-
version = "
|
|
8
|
-
description = "Render deterministic local Git context for
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Render deterministic local Git context for any executor"
|
|
9
9
|
license = { file = "LICENSE" }
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
requires-python = ">=3.12,<3.13"
|
|
12
12
|
dependencies = []
|
|
13
13
|
|
|
14
14
|
[project.scripts]
|
|
15
|
-
|
|
15
|
+
project-context = "context_loader.cli:main"
|
|
16
16
|
|
|
17
17
|
[project.urls]
|
|
18
18
|
Repository = "https://github.com/xuanheng-tech/context-loader"
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"--version",
|
|
10
10
|
"--help"
|
|
11
11
|
],
|
|
12
|
-
"name": "
|
|
12
|
+
"name": "project-context",
|
|
13
13
|
"operation_class": "read_only",
|
|
14
14
|
"preconditions": [
|
|
15
15
|
"repo_is_absolute_git_worktree",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
]
|
|
25
25
|
}
|
|
26
26
|
],
|
|
27
|
-
"contract_version":
|
|
27
|
+
"contract_version": 2,
|
|
28
28
|
"flags": {
|
|
29
29
|
"format": [
|
|
30
30
|
"markdown",
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
],
|
|
45
45
|
"schema_version": 1,
|
|
46
46
|
"tool_name": "context-loader",
|
|
47
|
-
"tool_version": "
|
|
47
|
+
"tool_version": "1.1.0"
|
|
48
48
|
}
|
|
File without changes
|