context-loader 1.0.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: context-loader
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Render deterministic local Git context for any executor
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -240,7 +240,7 @@ project-context --repo /path/to/repo --format json
240
240
 
241
241
  ## Install
242
242
 
243
- Source version: `1.0.0`. Install its matching published release or an exact source commit.
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,7 +254,7 @@ Install via `pip`:
254
254
  pip install context-loader
255
255
  ```
256
256
 
257
- For development or source-based installs tracking current repository (`1.0.0`):
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
@@ -345,7 +345,7 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
345
345
  "schema_version": 1,
346
346
  "tool": {
347
347
  "name": "context-loader",
348
- "version": "1.0.0"
348
+ "version": "1.1.0"
349
349
  },
350
350
  "repository": {
351
351
  "requested_path": "/canonical/requested/path",
@@ -374,6 +374,7 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
374
374
  ],
375
375
  "context": "the same assembled Markdown context",
376
376
  "context_sha256": "sha256-hex",
377
+ "statuses": [],
377
378
  "warnings": []
378
379
  }
379
380
  ```
@@ -384,12 +385,14 @@ assembly order, after the existing newline normalization and truncation rules. `
384
385
  `repository` from `global`; version 1.0.0's fixed root-file selection currently emits only
385
386
  `repository` sources and does not add any global-file discovery.
386
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
+
387
390
  The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
388
391
  contain heading, heading level, and fixed selection reasons; it never contains the original focus or
389
392
  target path. Existing source fields and schema version 1 remain unchanged.
390
393
 
391
394
  The JSON schema version and package version are independent: `schema_version` is currently the
392
- integer `1`, while `tool.version` is `1.0.0`. Callers must depend only on fields declared above.
395
+ integer `1`, while `tool.version` is `1.1.0`. Callers must depend only on fields declared above.
393
396
  The document contains no generated time or random identifier, so unchanged input produces identical
394
397
  JSON bytes. On failure, stdout remains empty and stderr contains only a short diagnostic.
395
398
 
@@ -29,7 +29,7 @@ project-context --repo /path/to/repo --format json
29
29
 
30
30
  ## Install
31
31
 
32
- Source version: `1.0.0`. Install its matching published release or an exact source commit.
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,7 +43,7 @@ Install via `pip`:
43
43
  pip install context-loader
44
44
  ```
45
45
 
46
- For development or source-based installs tracking current repository (`1.0.0`):
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
@@ -134,7 +134,7 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
134
134
  "schema_version": 1,
135
135
  "tool": {
136
136
  "name": "context-loader",
137
- "version": "1.0.0"
137
+ "version": "1.1.0"
138
138
  },
139
139
  "repository": {
140
140
  "requested_path": "/canonical/requested/path",
@@ -163,6 +163,7 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
163
163
  ],
164
164
  "context": "the same assembled Markdown context",
165
165
  "context_sha256": "sha256-hex",
166
+ "statuses": [],
166
167
  "warnings": []
167
168
  }
168
169
  ```
@@ -173,12 +174,14 @@ assembly order, after the existing newline normalization and truncation rules. `
173
174
  `repository` from `global`; version 1.0.0's fixed root-file selection currently emits only
174
175
  `repository` sources and does not add any global-file discovery.
175
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
+
176
179
  The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
177
180
  contain heading, heading level, and fixed selection reasons; it never contains the original focus or
178
181
  target path. Existing source fields and schema version 1 remain unchanged.
179
182
 
180
183
  The JSON schema version and package version are independent: `schema_version` is currently the
181
- integer `1`, while `tool.version` is `1.0.0`. Callers must depend only on fields declared above.
184
+ integer `1`, while `tool.version` is `1.1.0`. Callers must depend only on fields declared above.
182
185
  The document contains no generated time or random identifier, so unchanged input produces identical
183
186
  JSON bytes. On failure, stdout remains empty and stderr contains only a short diagnostic.
184
187
 
@@ -1,3 +1,3 @@
1
1
  """Project Context Loader."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.1.0"
@@ -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=_sources(state.repository, project, rendered.included_sections),
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),
@@ -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(lines: list[str], limit: int, *, already_truncated: bool = False) -> str:
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
- return "\n".join(
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,
@@ -298,6 +298,8 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
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", _render_commands(project.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(output=output, included_sections=tuple(included_sections))
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,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "context-loader"
7
- version = "1.0.0"
7
+ version = "1.1.0"
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.0.0"
7
+ version = "1.1.0"
8
8
  description = "Render deterministic local Git context for any executor"
9
9
  license = { file = "LICENSE" }
10
10
  readme = "README.md"
@@ -44,5 +44,5 @@
44
44
  ],
45
45
  "schema_version": 1,
46
46
  "tool_name": "context-loader",
47
- "tool_version": "1.0.0"
47
+ "tool_version": "1.1.0"
48
48
  }
File without changes