open-code-review-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ocr_toolkit/__init__.py +1 -0
- ocr_toolkit/_version.py +24 -0
- ocr_toolkit/cli.py +56 -0
- ocr_toolkit/common/__init__.py +1 -0
- ocr_toolkit/common/language.py +60 -0
- ocr_toolkit/common/markdown.py +214 -0
- ocr_toolkit/common/redaction.py +324 -0
- ocr_toolkit/config_writer.py +106 -0
- ocr_toolkit/configure.py +194 -0
- ocr_toolkit/context/__init__.py +1 -0
- ocr_toolkit/context/__main__.py +8 -0
- ocr_toolkit/context/ansible.py +561 -0
- ocr_toolkit/context/categorize.py +162 -0
- ocr_toolkit/context/instructions.py +269 -0
- ocr_toolkit/context/manifests.py +459 -0
- ocr_toolkit/context/planner.py +227 -0
- ocr_toolkit/context/render.py +955 -0
- ocr_toolkit/context/repo.py +672 -0
- ocr_toolkit/context/settings.py +97 -0
- ocr_toolkit/mcp_config.py +257 -0
- ocr_toolkit/posting/__init__.py +1 -0
- ocr_toolkit/posting/__main__.py +8 -0
- ocr_toolkit/posting/comments.py +87 -0
- ocr_toolkit/posting/formatting.py +755 -0
- ocr_toolkit/posting/gitlab.py +853 -0
- ocr_toolkit/posting/markers.py +284 -0
- ocr_toolkit/posting/payloads.py +141 -0
- ocr_toolkit/posting/result.py +116 -0
- ocr_toolkit/posting/settings.py +181 -0
- ocr_toolkit/posting/snapshot.py +468 -0
- ocr_toolkit/posting/workflow.py +873 -0
- ocr_toolkit/preflight.py +395 -0
- ocr_toolkit/py.typed +1 -0
- open_code_review_toolkit-0.1.0.dist-info/METADATA +283 -0
- open_code_review_toolkit-0.1.0.dist-info/RECORD +38 -0
- open_code_review_toolkit-0.1.0.dist-info/WHEEL +4 -0
- open_code_review_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- open_code_review_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Dependency manifest parsers for OCR context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ocr_toolkit.common.redaction import redact_url_userinfo
|
|
12
|
+
from ocr_toolkit.context import repo as context_repo
|
|
13
|
+
from ocr_toolkit.context.settings import (
|
|
14
|
+
DEFAULT_MANIFEST_PARSE_MAX_BYTES,
|
|
15
|
+
JSON_OBJECT_PARSE_ERROR,
|
|
16
|
+
MAX_BACKGROUND_SECTION_ITEMS,
|
|
17
|
+
MAX_MANIFEST_PARSE_MAX_BYTES,
|
|
18
|
+
getenv_int,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ManifestPathDiscovery:
|
|
24
|
+
"""Bounded manifest path discovery result."""
|
|
25
|
+
|
|
26
|
+
paths: list[str]
|
|
27
|
+
omitted: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_manifest_text(path: Path) -> tuple[str | None, str | None]:
|
|
31
|
+
"""Read a manifest after enforcing an explicit size budget."""
|
|
32
|
+
|
|
33
|
+
safe_path = context_repo.resolve_repo_file(path)
|
|
34
|
+
if safe_path is None:
|
|
35
|
+
return None, "file is not a regular repository file"
|
|
36
|
+
|
|
37
|
+
max_bytes = getenv_int(
|
|
38
|
+
"OCR_MANIFEST_PARSE_MAX_BYTES",
|
|
39
|
+
DEFAULT_MANIFEST_PARSE_MAX_BYTES,
|
|
40
|
+
max_value=MAX_MANIFEST_PARSE_MAX_BYTES,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
if safe_path.stat().st_size > max_bytes:
|
|
45
|
+
return None, f"file exceeds OCR_MANIFEST_PARSE_MAX_BYTES ({max_bytes} bytes)"
|
|
46
|
+
return safe_path.read_text(encoding="utf-8", errors="replace"), None
|
|
47
|
+
except OSError as exc:
|
|
48
|
+
return None, str(exc)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def limited_manifest_items(items: list[str]) -> tuple[list[str], int]:
|
|
52
|
+
"""Return bounded manifest items plus the real omitted count."""
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
items[:MAX_BACKGROUND_SECTION_ITEMS],
|
|
56
|
+
max(0, len(items) - MAX_BACKGROUND_SECTION_ITEMS),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def discover_pyproject_paths(
|
|
61
|
+
changed: Sequence[str], limit: int = 20, max_depth: int = 40
|
|
62
|
+
) -> ManifestPathDiscovery:
|
|
63
|
+
"""Find pyproject.toml manifests relevant to this MR.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
- the repository root manifest, if any;
|
|
67
|
+
- every changed `pyproject.toml` (so a manifest-only edit still
|
|
68
|
+
gets its constraints into the background);
|
|
69
|
+
- the nearest existing `pyproject.toml` walking up the directory
|
|
70
|
+
tree from each changed Python file.
|
|
71
|
+
|
|
72
|
+
The walk is bounded by `max_depth` steps and by fixed-point
|
|
73
|
+
detection on `Path.parent`. Absolute paths and inputs that escape
|
|
74
|
+
ROOT are rejected so a future caller cannot deadlock CI or probe
|
|
75
|
+
manifests outside the repository.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
if limit <= 0:
|
|
79
|
+
return ManifestPathDiscovery([], 0)
|
|
80
|
+
|
|
81
|
+
found: dict[str, None] = {}
|
|
82
|
+
overflow_seen: set[str] = set()
|
|
83
|
+
root_resolved = context_repo.ROOT.resolve()
|
|
84
|
+
root_present = context_repo.path_exists("pyproject.toml")
|
|
85
|
+
effective_limit = max(1, limit) if root_present else limit
|
|
86
|
+
|
|
87
|
+
if root_present:
|
|
88
|
+
found["pyproject.toml"] = None
|
|
89
|
+
|
|
90
|
+
# Treat changed pyproject.toml paths as relevant directly.
|
|
91
|
+
for rel in changed:
|
|
92
|
+
if Path(rel).name == "pyproject.toml" and context_repo.path_exists(rel):
|
|
93
|
+
if len(found) < effective_limit:
|
|
94
|
+
found[rel] = None
|
|
95
|
+
elif rel not in found:
|
|
96
|
+
overflow_seen.add(rel)
|
|
97
|
+
|
|
98
|
+
for rel in changed:
|
|
99
|
+
if not rel.endswith((".py", ".pyi")):
|
|
100
|
+
continue
|
|
101
|
+
candidate_path = Path(rel)
|
|
102
|
+
if candidate_path.is_absolute():
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
cursor = candidate_path.parent
|
|
106
|
+
steps = 0
|
|
107
|
+
while steps < max_depth:
|
|
108
|
+
candidate = cursor / "pyproject.toml"
|
|
109
|
+
# Guard against probing outside ROOT (defence-in-depth in
|
|
110
|
+
# case a malformed relative path resolves above repo root).
|
|
111
|
+
try:
|
|
112
|
+
resolved = (context_repo.ROOT / candidate).resolve()
|
|
113
|
+
resolved.relative_to(root_resolved)
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
if context_repo.path_exists(str(candidate)):
|
|
118
|
+
if len(found) < effective_limit:
|
|
119
|
+
found[str(candidate)] = None
|
|
120
|
+
elif str(candidate) not in found:
|
|
121
|
+
overflow_seen.add(str(candidate))
|
|
122
|
+
# Nearest manifest is enough — outer manifests rarely
|
|
123
|
+
# change the answer for a nested package.
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
parent = cursor.parent
|
|
127
|
+
if parent == cursor: # fixed point: walked off the tree
|
|
128
|
+
break
|
|
129
|
+
cursor = parent
|
|
130
|
+
steps += 1
|
|
131
|
+
|
|
132
|
+
return ManifestPathDiscovery(list(found)[:effective_limit], len(overflow_seen))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def discover_package_json_paths(
|
|
136
|
+
changed: Sequence[str], limit: int = 20, max_depth: int = 40
|
|
137
|
+
) -> ManifestPathDiscovery:
|
|
138
|
+
"""Find package.json manifests relevant to changed JS/TS files."""
|
|
139
|
+
|
|
140
|
+
found: dict[str, None] = {}
|
|
141
|
+
overflow_seen: set[str] = set()
|
|
142
|
+
root_resolved = context_repo.ROOT.resolve()
|
|
143
|
+
|
|
144
|
+
if context_repo.path_exists("package.json"):
|
|
145
|
+
found["package.json"] = None
|
|
146
|
+
|
|
147
|
+
for rel in changed:
|
|
148
|
+
if Path(rel).name == "package.json" and context_repo.path_exists(rel):
|
|
149
|
+
if len(found) < limit:
|
|
150
|
+
found.setdefault(rel, None)
|
|
151
|
+
elif rel not in found:
|
|
152
|
+
overflow_seen.add(rel)
|
|
153
|
+
|
|
154
|
+
for rel in changed:
|
|
155
|
+
if not rel.endswith((".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs")):
|
|
156
|
+
continue
|
|
157
|
+
candidate_path = Path(rel)
|
|
158
|
+
if candidate_path.is_absolute():
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
cursor = candidate_path.parent
|
|
162
|
+
steps = 0
|
|
163
|
+
while steps < max_depth:
|
|
164
|
+
candidate = cursor / "package.json"
|
|
165
|
+
try:
|
|
166
|
+
resolved = (context_repo.ROOT / candidate).resolve()
|
|
167
|
+
resolved.relative_to(root_resolved)
|
|
168
|
+
except (OSError, ValueError):
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
if context_repo.path_exists(str(candidate)):
|
|
172
|
+
rel_candidate = str(candidate)
|
|
173
|
+
if len(found) < limit:
|
|
174
|
+
found.setdefault(rel_candidate, None)
|
|
175
|
+
elif rel_candidate not in found:
|
|
176
|
+
overflow_seen.add(rel_candidate)
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
parent = cursor.parent
|
|
180
|
+
if parent == cursor:
|
|
181
|
+
break
|
|
182
|
+
cursor = parent
|
|
183
|
+
steps += 1
|
|
184
|
+
|
|
185
|
+
return ManifestPathDiscovery(list(found)[:limit], len(overflow_seen))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def parse_pyproject(path: Path) -> dict[str, Any]:
|
|
189
|
+
"""Parse Python dependency metadata from pyproject.toml."""
|
|
190
|
+
|
|
191
|
+
text, parse_error = read_manifest_text(path)
|
|
192
|
+
if parse_error:
|
|
193
|
+
return {"present": True, "parse_error": parse_error}
|
|
194
|
+
if not text:
|
|
195
|
+
return {"requires_python": None, "dependencies": []}
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
try:
|
|
199
|
+
import tomllib
|
|
200
|
+
except ModuleNotFoundError:
|
|
201
|
+
import tomli as tomllib # type: ignore[import-not-found]
|
|
202
|
+
|
|
203
|
+
data = tomllib.loads(text)
|
|
204
|
+
except ModuleNotFoundError:
|
|
205
|
+
return {"present": True, "parse_error": "tomllib/tomli is unavailable"}
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
return {"present": True, "parse_error": str(exc)}
|
|
208
|
+
|
|
209
|
+
if not isinstance(data, dict):
|
|
210
|
+
return {"present": True, "parse_error": JSON_OBJECT_PARSE_ERROR}
|
|
211
|
+
|
|
212
|
+
project = data.get("project") if isinstance(data.get("project"), dict) else {}
|
|
213
|
+
tool = data.get("tool") if isinstance(data.get("tool"), dict) else {}
|
|
214
|
+
poetry = tool.get("poetry") if isinstance(tool.get("poetry"), dict) else {}
|
|
215
|
+
|
|
216
|
+
dependencies: list[str] = []
|
|
217
|
+
if isinstance(project.get("dependencies"), list):
|
|
218
|
+
dependencies.extend(redact_url_userinfo(str(dep)) for dep in project["dependencies"])
|
|
219
|
+
|
|
220
|
+
optional_dependencies = project.get("optional-dependencies")
|
|
221
|
+
if isinstance(optional_dependencies, dict):
|
|
222
|
+
for group_name, group_deps in optional_dependencies.items():
|
|
223
|
+
if isinstance(group_deps, list):
|
|
224
|
+
dependencies.extend(
|
|
225
|
+
f"optional.{group_name}: {redact_url_userinfo(str(dep))}" for dep in group_deps
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
dependency_groups = data.get("dependency-groups")
|
|
229
|
+
if isinstance(dependency_groups, dict):
|
|
230
|
+
for group_name, group_deps in dependency_groups.items():
|
|
231
|
+
if isinstance(group_deps, list):
|
|
232
|
+
dependencies.extend(
|
|
233
|
+
f"group.{group_name}: {redact_url_userinfo(str(dep))}" for dep in group_deps
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
poetry_deps = poetry.get("dependencies") if isinstance(poetry.get("dependencies"), dict) else {}
|
|
237
|
+
for name, version in poetry_deps.items():
|
|
238
|
+
if str(name).lower() != "python":
|
|
239
|
+
dependencies.append(f"{name}: {redact_url_userinfo(str(version))}")
|
|
240
|
+
|
|
241
|
+
poetry_groups = poetry.get("group") if isinstance(poetry.get("group"), dict) else {}
|
|
242
|
+
for group_name, group_value in poetry_groups.items():
|
|
243
|
+
if not isinstance(group_value, dict):
|
|
244
|
+
continue
|
|
245
|
+
group_deps = group_value.get("dependencies")
|
|
246
|
+
if not isinstance(group_deps, dict):
|
|
247
|
+
continue
|
|
248
|
+
for name, version in group_deps.items():
|
|
249
|
+
dependencies.append(f"poetry.{group_name}.{name}: {redact_url_userinfo(str(version))}")
|
|
250
|
+
|
|
251
|
+
dependencies, dependencies_omitted = limited_manifest_items(dependencies)
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
"requires_python": project.get("requires-python") or poetry_deps.get("python"),
|
|
255
|
+
"dependencies": dependencies,
|
|
256
|
+
"dependencies_omitted": dependencies_omitted,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def parse_requirements_txt(path: Path, limit: int = 80) -> dict[str, Any]:
|
|
261
|
+
"""Parse dependency and include directives from a requirements-style file."""
|
|
262
|
+
|
|
263
|
+
text, parse_error = read_manifest_text(path)
|
|
264
|
+
if parse_error:
|
|
265
|
+
return {"dependencies": [], "dependencies_omitted": 0, "parse_error": parse_error}
|
|
266
|
+
if not text:
|
|
267
|
+
return {"dependencies": [], "dependencies_omitted": 0}
|
|
268
|
+
|
|
269
|
+
dependencies: list[str] = []
|
|
270
|
+
for raw in text.splitlines():
|
|
271
|
+
line = raw.strip()
|
|
272
|
+
if not line or line.startswith("#"):
|
|
273
|
+
continue
|
|
274
|
+
if line.startswith(("-r ", "--requirement ", "-c ", "--constraint ")):
|
|
275
|
+
dependencies.append(redact_url_userinfo(line))
|
|
276
|
+
continue
|
|
277
|
+
if line.startswith(("-e ", "--editable ")):
|
|
278
|
+
dependencies.append(redact_url_userinfo(line))
|
|
279
|
+
continue
|
|
280
|
+
if line.startswith("--"):
|
|
281
|
+
continue
|
|
282
|
+
if line.startswith("-"):
|
|
283
|
+
continue
|
|
284
|
+
dependencies.append(redact_url_userinfo(line))
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
"dependencies": dependencies[:limit],
|
|
288
|
+
"dependencies_omitted": max(0, len(dependencies) - limit),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def parse_go_mod(path: Path) -> dict[str, Any]:
|
|
293
|
+
"""Parse Go version, toolchain and modules from go.mod."""
|
|
294
|
+
|
|
295
|
+
text, parse_error = read_manifest_text(path)
|
|
296
|
+
if parse_error:
|
|
297
|
+
return {"parse_error": parse_error, "modules": []}
|
|
298
|
+
if not text:
|
|
299
|
+
return {"modules": []}
|
|
300
|
+
|
|
301
|
+
go_version: str | None = None
|
|
302
|
+
toolchain: str | None = None
|
|
303
|
+
modules: list[str] = []
|
|
304
|
+
modules_seen = 0
|
|
305
|
+
in_require_block = False
|
|
306
|
+
|
|
307
|
+
for raw in text.splitlines():
|
|
308
|
+
line = raw.strip()
|
|
309
|
+
|
|
310
|
+
if line.startswith("go "):
|
|
311
|
+
go_version = line.split(None, 1)[1]
|
|
312
|
+
elif line.startswith("toolchain "):
|
|
313
|
+
toolchain = line.split(None, 1)[1]
|
|
314
|
+
elif line.startswith("require ("):
|
|
315
|
+
in_require_block = True
|
|
316
|
+
elif in_require_block and line == ")":
|
|
317
|
+
in_require_block = False
|
|
318
|
+
elif in_require_block:
|
|
319
|
+
if not line or line.startswith("//"):
|
|
320
|
+
continue
|
|
321
|
+
parts = line.split()
|
|
322
|
+
if len(parts) >= 2:
|
|
323
|
+
modules_seen += 1
|
|
324
|
+
if len(modules) < MAX_BACKGROUND_SECTION_ITEMS:
|
|
325
|
+
modules.append(f"{parts[0]} {parts[1]}")
|
|
326
|
+
elif line.startswith("require "):
|
|
327
|
+
parts = line.split()
|
|
328
|
+
if len(parts) >= 3:
|
|
329
|
+
modules_seen += 1
|
|
330
|
+
if len(modules) < MAX_BACKGROUND_SECTION_ITEMS:
|
|
331
|
+
modules.append(f"{parts[1]} {parts[2]}")
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
"go": go_version,
|
|
335
|
+
"toolchain": toolchain,
|
|
336
|
+
"modules": modules,
|
|
337
|
+
"modules_omitted": max(0, modules_seen - len(modules)),
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def parse_composer_json(path: Path) -> dict[str, Any]:
|
|
342
|
+
"""Parse PHP platform and package constraints from composer.json."""
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
text, parse_error = read_manifest_text(path)
|
|
346
|
+
if parse_error:
|
|
347
|
+
return {"present": True, "parse_error": parse_error}
|
|
348
|
+
data = json.loads(text or "")
|
|
349
|
+
except json.JSONDecodeError as exc:
|
|
350
|
+
return {"present": True, "parse_error": str(exc)}
|
|
351
|
+
except (RecursionError, ValueError, TypeError) as exc:
|
|
352
|
+
return {"present": True, "parse_error": str(exc)}
|
|
353
|
+
|
|
354
|
+
if not isinstance(data, dict):
|
|
355
|
+
return {"present": True, "parse_error": JSON_OBJECT_PARSE_ERROR}
|
|
356
|
+
|
|
357
|
+
require = data.get("require") if isinstance(data.get("require"), dict) else {}
|
|
358
|
+
require_dev = data.get("require-dev") if isinstance(data.get("require-dev"), dict) else {}
|
|
359
|
+
config = data.get("config") if isinstance(data.get("config"), dict) else {}
|
|
360
|
+
platform = config.get("platform") if isinstance(config.get("platform"), dict) else {}
|
|
361
|
+
|
|
362
|
+
platform_items, platform_omitted = limited_manifest_items(
|
|
363
|
+
[f"{name}: {version}" for name, version in platform.items()]
|
|
364
|
+
)
|
|
365
|
+
require_items, require_omitted = limited_manifest_items(
|
|
366
|
+
[f"{name}: {version}" for name, version in require.items()]
|
|
367
|
+
)
|
|
368
|
+
require_dev_items, require_dev_omitted = limited_manifest_items(
|
|
369
|
+
[f"{name}: {version}" for name, version in require_dev.items()]
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
"platform": platform_items,
|
|
374
|
+
"platform_omitted": platform_omitted,
|
|
375
|
+
"require": require_items,
|
|
376
|
+
"require_omitted": require_omitted,
|
|
377
|
+
"require_dev": require_dev_items,
|
|
378
|
+
"require_dev_omitted": require_dev_omitted,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def parse_composer_lock(path: Path) -> dict[str, Any]:
|
|
383
|
+
"""Parse locked PHP package versions from composer.lock."""
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
text, parse_error = read_manifest_text(path)
|
|
387
|
+
if parse_error:
|
|
388
|
+
return {"present": True, "parse_error": parse_error}
|
|
389
|
+
data = json.loads(text or "")
|
|
390
|
+
except json.JSONDecodeError as exc:
|
|
391
|
+
return {"present": True, "parse_error": str(exc)}
|
|
392
|
+
except (RecursionError, ValueError, TypeError) as exc:
|
|
393
|
+
return {"present": True, "parse_error": str(exc)}
|
|
394
|
+
|
|
395
|
+
if not isinstance(data, dict):
|
|
396
|
+
return {"present": True, "parse_error": JSON_OBJECT_PARSE_ERROR}
|
|
397
|
+
|
|
398
|
+
packages: list[str] = []
|
|
399
|
+
packages_seen = 0
|
|
400
|
+
for section in ("packages", "packages-dev"):
|
|
401
|
+
entries = data.get(section)
|
|
402
|
+
if not isinstance(entries, list):
|
|
403
|
+
continue
|
|
404
|
+
for package in entries:
|
|
405
|
+
if not isinstance(package, dict):
|
|
406
|
+
continue
|
|
407
|
+
name = package.get("name")
|
|
408
|
+
version = package.get("version")
|
|
409
|
+
if name and version:
|
|
410
|
+
packages_seen += 1
|
|
411
|
+
if len(packages) < MAX_BACKGROUND_SECTION_ITEMS:
|
|
412
|
+
packages.append(f"{name}: {version}")
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
"packages": packages,
|
|
416
|
+
"packages_omitted": max(0, packages_seen - len(packages)),
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def parse_package_json(path: Path) -> dict[str, Any]:
|
|
421
|
+
"""Parse JS/TS runtime and dependency constraints from package.json."""
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
text, parse_error = read_manifest_text(path)
|
|
425
|
+
if parse_error:
|
|
426
|
+
return {"present": True, "parse_error": parse_error}
|
|
427
|
+
data = json.loads(text or "")
|
|
428
|
+
except json.JSONDecodeError as exc:
|
|
429
|
+
return {"present": True, "parse_error": str(exc)}
|
|
430
|
+
except (RecursionError, ValueError, TypeError) as exc:
|
|
431
|
+
return {"present": True, "parse_error": str(exc)}
|
|
432
|
+
|
|
433
|
+
if not isinstance(data, dict):
|
|
434
|
+
return {"present": True, "parse_error": JSON_OBJECT_PARSE_ERROR}
|
|
435
|
+
|
|
436
|
+
engines = data.get("engines") if isinstance(data.get("engines"), dict) else {}
|
|
437
|
+
dependencies = data.get("dependencies") if isinstance(data.get("dependencies"), dict) else {}
|
|
438
|
+
dev_dependencies = (
|
|
439
|
+
data.get("devDependencies") if isinstance(data.get("devDependencies"), dict) else {}
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
engines_items, engines_omitted = limited_manifest_items(
|
|
443
|
+
[f"{name}: {version}" for name, version in engines.items()]
|
|
444
|
+
)
|
|
445
|
+
dependencies_items, dependencies_omitted = limited_manifest_items(
|
|
446
|
+
[f"{name}: {version}" for name, version in dependencies.items()]
|
|
447
|
+
)
|
|
448
|
+
dev_dependencies_items, dev_dependencies_omitted = limited_manifest_items(
|
|
449
|
+
[f"{name}: {version}" for name, version in dev_dependencies.items()]
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
"engines": engines_items,
|
|
454
|
+
"engines_omitted": engines_omitted,
|
|
455
|
+
"dependencies": dependencies_items,
|
|
456
|
+
"dependencies_omitted": dependencies_omitted,
|
|
457
|
+
"dev_dependencies": dev_dependencies_items,
|
|
458
|
+
"dev_dependencies_omitted": dev_dependencies_omitted,
|
|
459
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Budget and render generic review-context sections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from ocr_toolkit.common.markdown import markdown_fence_transition, open_markdown_fence
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ContextSection:
|
|
13
|
+
"""One independently budgeted top-level Markdown section."""
|
|
14
|
+
|
|
15
|
+
title: str
|
|
16
|
+
body: str
|
|
17
|
+
priority: int = 50
|
|
18
|
+
minimum_bytes: int = 160
|
|
19
|
+
|
|
20
|
+
def render(self) -> str:
|
|
21
|
+
"""Render the complete section without applying a budget."""
|
|
22
|
+
|
|
23
|
+
return f"## {self.title}\n{self.body.strip()}\n"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def split_markdown_sections(markdown: str) -> tuple[str, list[ContextSection]]:
|
|
27
|
+
"""Split top-level Markdown sections without treating fenced headings as structure."""
|
|
28
|
+
|
|
29
|
+
preamble: list[str] = []
|
|
30
|
+
sections: list[ContextSection] = []
|
|
31
|
+
title: str | None = None
|
|
32
|
+
body: list[str] = []
|
|
33
|
+
fence: str | None = None
|
|
34
|
+
for line in markdown.splitlines():
|
|
35
|
+
if fence is None and line.startswith("## "):
|
|
36
|
+
if title is None:
|
|
37
|
+
preamble = body
|
|
38
|
+
else:
|
|
39
|
+
sections.append(ContextSection(title=title, body="\n".join(body)))
|
|
40
|
+
title = line[3:].strip()
|
|
41
|
+
body = []
|
|
42
|
+
else:
|
|
43
|
+
body.append(line)
|
|
44
|
+
fence, _ = markdown_fence_transition(line, fence)
|
|
45
|
+
|
|
46
|
+
if title is None:
|
|
47
|
+
preamble = body
|
|
48
|
+
else:
|
|
49
|
+
sections.append(ContextSection(title=title, body="\n".join(body)))
|
|
50
|
+
return "\n".join(preamble).rstrip(), sections
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _truncate_section(section: ContextSection, max_bytes: int) -> str:
|
|
54
|
+
"""Render one section inside a byte budget while preserving Markdown fences."""
|
|
55
|
+
|
|
56
|
+
heading = f"## {section.title}\n"
|
|
57
|
+
marker = "\n- ... section truncated\n"
|
|
58
|
+
heading_bytes = len(heading.encode("utf-8"))
|
|
59
|
+
marker_bytes = len(marker.encode("utf-8"))
|
|
60
|
+
body = section.body.strip()
|
|
61
|
+
complete = heading + body + "\n"
|
|
62
|
+
if len(complete.encode("utf-8")) <= max_bytes:
|
|
63
|
+
return complete
|
|
64
|
+
if max_bytes < heading_bytes + marker_bytes:
|
|
65
|
+
return ""
|
|
66
|
+
|
|
67
|
+
body_bytes = body.encode("utf-8")
|
|
68
|
+
content_budget = max(0, max_bytes - heading_bytes - marker_bytes)
|
|
69
|
+
reserved_closing_bytes = 0
|
|
70
|
+
while True:
|
|
71
|
+
body_budget = max(0, content_budget - reserved_closing_bytes)
|
|
72
|
+
clipped = body_bytes[:body_budget].decode("utf-8", errors="ignore").rstrip()
|
|
73
|
+
fence = open_markdown_fence(clipped)
|
|
74
|
+
closing = f"\n{fence}" if fence else ""
|
|
75
|
+
required = len(closing.encode("utf-8"))
|
|
76
|
+
if required <= reserved_closing_bytes:
|
|
77
|
+
break
|
|
78
|
+
reserved_closing_bytes = required
|
|
79
|
+
|
|
80
|
+
return heading + clipped + closing + marker
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _render_context_bytes(preamble: str, sections: Sequence[ContextSection], max_bytes: int) -> str:
|
|
84
|
+
"""Render context densely inside one strict UTF-8 byte budget."""
|
|
85
|
+
|
|
86
|
+
if max_bytes <= 0:
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
clean_preamble = preamble.rstrip()
|
|
90
|
+
preamble_bytes = len(clean_preamble.encode("utf-8"))
|
|
91
|
+
if preamble_bytes >= max_bytes:
|
|
92
|
+
return (clean_preamble + "\n").encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore")
|
|
93
|
+
if not sections:
|
|
94
|
+
return clean_preamble + "\n"
|
|
95
|
+
|
|
96
|
+
separator_bytes = 2 * (len(sections) - 1 + bool(clean_preamble))
|
|
97
|
+
available = max_bytes - preamble_bytes - separator_bytes - 1
|
|
98
|
+
rendered_sizes = [len(section.render().encode("utf-8")) for section in sections]
|
|
99
|
+
minimums = [
|
|
100
|
+
min(
|
|
101
|
+
rendered_sizes[index],
|
|
102
|
+
max(
|
|
103
|
+
section.minimum_bytes,
|
|
104
|
+
len(f"## {section.title}\n".encode()),
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
for index, section in enumerate(sections)
|
|
108
|
+
]
|
|
109
|
+
minimum_total = sum(minimums)
|
|
110
|
+
if minimum_total > available:
|
|
111
|
+
minimums = [max(0, available * minimum // minimum_total) for minimum in minimums]
|
|
112
|
+
|
|
113
|
+
allocations = list(minimums)
|
|
114
|
+
remaining = max(0, available - sum(allocations))
|
|
115
|
+
pending = {
|
|
116
|
+
index
|
|
117
|
+
for index, rendered_size in enumerate(rendered_sizes)
|
|
118
|
+
if allocations[index] < rendered_size
|
|
119
|
+
}
|
|
120
|
+
while remaining > 0 and pending:
|
|
121
|
+
total_weight = sum(max(1, sections[index].priority) for index in pending)
|
|
122
|
+
progressed = False
|
|
123
|
+
for index in sorted(pending):
|
|
124
|
+
share = max(
|
|
125
|
+
1,
|
|
126
|
+
remaining * max(1, sections[index].priority) // total_weight,
|
|
127
|
+
)
|
|
128
|
+
addition = min(share, rendered_sizes[index] - allocations[index], remaining)
|
|
129
|
+
if addition:
|
|
130
|
+
allocations[index] += addition
|
|
131
|
+
remaining -= addition
|
|
132
|
+
progressed = True
|
|
133
|
+
if allocations[index] >= rendered_sizes[index]:
|
|
134
|
+
pending.discard(index)
|
|
135
|
+
if not remaining:
|
|
136
|
+
break
|
|
137
|
+
if not progressed:
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
rendered = [clean_preamble] if clean_preamble else []
|
|
141
|
+
rendered.extend(
|
|
142
|
+
_truncate_section(section, allocation).rstrip()
|
|
143
|
+
for section, allocation in zip(sections, allocations)
|
|
144
|
+
if allocation > 0
|
|
145
|
+
)
|
|
146
|
+
result = "\n\n".join(rendered).rstrip() + "\n"
|
|
147
|
+
if len(result.encode("utf-8")) > max_bytes:
|
|
148
|
+
raise ValueError("context planner exceeded its byte allocation")
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def render_context(
|
|
153
|
+
preamble: str,
|
|
154
|
+
sections: Sequence[ContextSection],
|
|
155
|
+
max_bytes: int,
|
|
156
|
+
*,
|
|
157
|
+
max_chars: int | None = None,
|
|
158
|
+
) -> str:
|
|
159
|
+
"""Render context inside independent OCR character and file byte limits."""
|
|
160
|
+
|
|
161
|
+
if max_chars is None:
|
|
162
|
+
return _render_context_bytes(preamble, sections, max_bytes)
|
|
163
|
+
if max_chars <= 0 or max_bytes <= 0:
|
|
164
|
+
return ""
|
|
165
|
+
|
|
166
|
+
selected = list(sections)
|
|
167
|
+
clean_preamble = preamble.rstrip()
|
|
168
|
+
minimum_char_cost = len(clean_preamble) + 1
|
|
169
|
+
minimum_char_cost += 2 * (len(selected) - 1 + bool(clean_preamble))
|
|
170
|
+
minimum_char_cost += sum(
|
|
171
|
+
len(f"## {section.title}\n- ... section truncated\n") for section in selected
|
|
172
|
+
)
|
|
173
|
+
if minimum_char_cost > max_chars:
|
|
174
|
+
ranked = sorted(
|
|
175
|
+
enumerate(selected),
|
|
176
|
+
key=lambda item: (-item[1].priority, item[0]),
|
|
177
|
+
)
|
|
178
|
+
kept_indexes: list[int] = []
|
|
179
|
+
cost = len(preamble.rstrip())
|
|
180
|
+
for index, section in ranked:
|
|
181
|
+
section_cost = len(f"\n\n## {section.title}\n- ... section truncated\n")
|
|
182
|
+
if cost + section_cost <= max_chars:
|
|
183
|
+
kept_indexes.append(index)
|
|
184
|
+
cost += section_cost
|
|
185
|
+
omitted = len(selected) - len(kept_indexes)
|
|
186
|
+
selected = [selected[index] for index in sorted(kept_indexes)]
|
|
187
|
+
if omitted:
|
|
188
|
+
coverage = ContextSection(
|
|
189
|
+
title="Context coverage",
|
|
190
|
+
body=f"- {omitted} lower-priority section(s) omitted by the character budget.",
|
|
191
|
+
priority=200,
|
|
192
|
+
minimum_bytes=100,
|
|
193
|
+
)
|
|
194
|
+
coverage_cost = len(f"\n\n{coverage.render()}")
|
|
195
|
+
while selected and cost + coverage_cost > max_chars:
|
|
196
|
+
lowest = min(
|
|
197
|
+
range(len(selected)),
|
|
198
|
+
key=lambda index: (selected[index].priority, -index),
|
|
199
|
+
)
|
|
200
|
+
removed = selected.pop(lowest)
|
|
201
|
+
cost -= len(f"\n\n## {removed.title}\n- ... section truncated\n")
|
|
202
|
+
omitted += 1
|
|
203
|
+
coverage = ContextSection(
|
|
204
|
+
title="Context coverage",
|
|
205
|
+
body=f"- {omitted} lower-priority section(s) omitted by the character budget.",
|
|
206
|
+
priority=200,
|
|
207
|
+
minimum_bytes=100,
|
|
208
|
+
)
|
|
209
|
+
coverage_cost = len(f"\n\n{coverage.render()}")
|
|
210
|
+
if cost + coverage_cost <= max_chars:
|
|
211
|
+
selected.append(coverage)
|
|
212
|
+
|
|
213
|
+
byte_budget = min(max_bytes, max_chars * 4)
|
|
214
|
+
result = _render_context_bytes(preamble, selected, byte_budget)
|
|
215
|
+
for _ in range(8):
|
|
216
|
+
if len(result) <= max_chars:
|
|
217
|
+
return result
|
|
218
|
+
next_budget = len(result[:max_chars].encode("utf-8"))
|
|
219
|
+
if next_budget >= byte_budget:
|
|
220
|
+
next_budget = byte_budget - 1
|
|
221
|
+
byte_budget = max(1, next_budget)
|
|
222
|
+
result = _render_context_bytes(preamble, selected, byte_budget)
|
|
223
|
+
|
|
224
|
+
result = _render_context_bytes(preamble, selected, min(max_bytes, max_chars))
|
|
225
|
+
if len(result) > max_chars:
|
|
226
|
+
raise ValueError("context planner could not satisfy the character budget")
|
|
227
|
+
return result
|