ai-dev-cli-tools 0.5.0a1__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.
- ai_dev_cli_tools-0.5.0a1.dist-info/METADATA +240 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/RECORD +60 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/WHEEL +4 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/entry_points.txt +2 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/licenses/LICENSE +21 -0
- ai_dev_tools/__init__.py +3 -0
- ai_dev_tools/cache/__init__.py +11 -0
- ai_dev_tools/cache/graph.py +136 -0
- ai_dev_tools/cache/repository.py +169 -0
- ai_dev_tools/cache/validation.py +154 -0
- ai_dev_tools/cli.py +387 -0
- ai_dev_tools/completion.py +72 -0
- ai_dev_tools/config.py +223 -0
- ai_dev_tools/context/__init__.py +5 -0
- ai_dev_tools/context/builder.py +506 -0
- ai_dev_tools/context/incremental.py +107 -0
- ai_dev_tools/context/models.py +59 -0
- ai_dev_tools/context/profiles.py +49 -0
- ai_dev_tools/context/selection.py +270 -0
- ai_dev_tools/context/symbols.py +178 -0
- ai_dev_tools/detectors/__init__.py +1 -0
- ai_dev_tools/detectors/environment.py +125 -0
- ai_dev_tools/detectors/project.py +189 -0
- ai_dev_tools/detectors/repository_map.py +129 -0
- ai_dev_tools/detectors/runtime.py +190 -0
- ai_dev_tools/detectors/workspaces.py +228 -0
- ai_dev_tools/git/__init__.py +1 -0
- ai_dev_tools/git/inspect.py +219 -0
- ai_dev_tools/models/__init__.py +1 -0
- ai_dev_tools/models/report.py +95 -0
- ai_dev_tools/models/workspace.py +48 -0
- ai_dev_tools/parsers/__init__.py +1 -0
- ai_dev_tools/parsers/logs.py +372 -0
- ai_dev_tools/parsers/registry.py +60 -0
- ai_dev_tools/reporters/__init__.py +1 -0
- ai_dev_tools/reporters/progressive.py +161 -0
- ai_dev_tools/reporters/writer.py +74 -0
- ai_dev_tools/runners/__init__.py +1 -0
- ai_dev_tools/runners/baseline.py +190 -0
- ai_dev_tools/runners/bootstrap.py +191 -0
- ai_dev_tools/runners/bootstrap_models.py +64 -0
- ai_dev_tools/runners/bootstrap_strategies.py +444 -0
- ai_dev_tools/runners/cache.py +23 -0
- ai_dev_tools/runners/check.py +509 -0
- ai_dev_tools/runners/check_checkpoint.py +50 -0
- ai_dev_tools/runners/check_models.py +51 -0
- ai_dev_tools/runners/check_scheduler.py +94 -0
- ai_dev_tools/runners/check_selection.py +267 -0
- ai_dev_tools/runners/diagnostics.py +96 -0
- ai_dev_tools/runners/feedback.py +193 -0
- ai_dev_tools/runners/finish.py +105 -0
- ai_dev_tools/runners/focused.py +37 -0
- ai_dev_tools/runners/index.py +44 -0
- ai_dev_tools/runtime/__init__.py +3 -0
- ai_dev_tools/runtime/runner.py +380 -0
- ai_dev_tools/runtime/supervisor.py +145 -0
- ai_dev_tools/security/__init__.py +1 -0
- ai_dev_tools/security/secrets.py +58 -0
- ai_dev_tools/utils/__init__.py +1 -0
- ai_dev_tools/utils/subprocess.py +74 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, replace
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ai_dev_tools.config import load_settings
|
|
8
|
+
from ai_dev_tools.context.incremental import (
|
|
9
|
+
IncrementalSelection,
|
|
10
|
+
save_incremental_manifest,
|
|
11
|
+
select_incremental,
|
|
12
|
+
)
|
|
13
|
+
from ai_dev_tools.context.models import (
|
|
14
|
+
DEFAULT_MAX_CHARS,
|
|
15
|
+
DEFAULT_MAX_DIFF_CHARS,
|
|
16
|
+
DEFAULT_MAX_FILE_CHARS,
|
|
17
|
+
DEFAULT_MAX_FILES,
|
|
18
|
+
)
|
|
19
|
+
from ai_dev_tools.context.models import (
|
|
20
|
+
ContextOptions as ContextOptions,
|
|
21
|
+
)
|
|
22
|
+
from ai_dev_tools.context.profiles import get_context_profile
|
|
23
|
+
from ai_dev_tools.context.selection import (
|
|
24
|
+
ALWAYS_IGNORE,
|
|
25
|
+
_dependency_files,
|
|
26
|
+
_object_list,
|
|
27
|
+
_read_selected_files,
|
|
28
|
+
_rel,
|
|
29
|
+
_select_candidates,
|
|
30
|
+
_selection_reason_code,
|
|
31
|
+
_truncate_text,
|
|
32
|
+
)
|
|
33
|
+
from ai_dev_tools.detectors.project import scan_project
|
|
34
|
+
from ai_dev_tools.detectors.repository_map import map_repository
|
|
35
|
+
from ai_dev_tools.git.inspect import inspect_git
|
|
36
|
+
from ai_dev_tools.models.report import Artifact, Issue, Report
|
|
37
|
+
from ai_dev_tools.runners.check import (
|
|
38
|
+
ChangedSelection,
|
|
39
|
+
CheckTask,
|
|
40
|
+
build_validation_plan,
|
|
41
|
+
select_changed_checks,
|
|
42
|
+
)
|
|
43
|
+
from ai_dev_tools.security.secrets import mask_text, scan_paths_for_secrets
|
|
44
|
+
from ai_dev_tools.utils.subprocess import run_command
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_context(project_root: Path, options: ContextOptions) -> Report:
|
|
48
|
+
options = _apply_context_profile(options)
|
|
49
|
+
settings = load_settings(project_root)
|
|
50
|
+
root = settings.project_root
|
|
51
|
+
report = Report(command="context build", project_root=root)
|
|
52
|
+
|
|
53
|
+
scan = scan_project(root)
|
|
54
|
+
repo_map = map_repository(root, max_files=max(options.max_files * 4, 100), max_depth=8)
|
|
55
|
+
git_report = None if options.no_git else inspect_git(root, detailed=True)
|
|
56
|
+
git_available = (
|
|
57
|
+
git_report is not None and git_report.summary.get("state") != "NOT_A_GIT_REPOSITORY"
|
|
58
|
+
)
|
|
59
|
+
plan = build_validation_plan(settings)
|
|
60
|
+
changed_analysis = None
|
|
61
|
+
if git_available:
|
|
62
|
+
changed_analysis = select_changed_checks(settings, plan)
|
|
63
|
+
|
|
64
|
+
changed_files = _changed_files(git_report, staged_only=options.staged_only)
|
|
65
|
+
candidates, rejected = _select_candidates(
|
|
66
|
+
root=root,
|
|
67
|
+
options=options,
|
|
68
|
+
changed_files=changed_files,
|
|
69
|
+
scan_summary=scan.summary,
|
|
70
|
+
map_summary=repo_map.summary,
|
|
71
|
+
related_tests=_related_tests(changed_analysis),
|
|
72
|
+
)
|
|
73
|
+
dependency_files = _dependency_files(root, candidates)
|
|
74
|
+
for path, reason in dependency_files.items():
|
|
75
|
+
if path not in candidates:
|
|
76
|
+
candidates[path] = reason
|
|
77
|
+
|
|
78
|
+
ordered_paths = sorted(candidates, key=lambda item: _candidate_sort_key(item, candidates[item]))
|
|
79
|
+
if options.changed_only or options.staged_only:
|
|
80
|
+
ordered_paths = [path for path in ordered_paths if _rel(root, path) in set(changed_files)]
|
|
81
|
+
incremental_state: IncrementalSelection | None = None
|
|
82
|
+
if options.incremental:
|
|
83
|
+
incremental_state = select_incremental(root, ordered_paths)
|
|
84
|
+
ordered_paths = incremental_state.selected
|
|
85
|
+
ordered_paths = ordered_paths[: max(options.max_files, 0)]
|
|
86
|
+
|
|
87
|
+
secret_findings = scan_paths_for_secrets(root, ordered_paths)
|
|
88
|
+
if options.explain:
|
|
89
|
+
summary = _base_summary(
|
|
90
|
+
options,
|
|
91
|
+
scan.summary,
|
|
92
|
+
repo_map.summary,
|
|
93
|
+
git_report.summary if git_report else None,
|
|
94
|
+
plan,
|
|
95
|
+
changed_analysis,
|
|
96
|
+
)
|
|
97
|
+
summary.update(
|
|
98
|
+
{
|
|
99
|
+
"explain_only": True,
|
|
100
|
+
"selected_files": [
|
|
101
|
+
{
|
|
102
|
+
"path": _rel(root, path),
|
|
103
|
+
"reason": candidates[path],
|
|
104
|
+
"reason_code": _selection_reason_code(candidates[path]),
|
|
105
|
+
}
|
|
106
|
+
for path in ordered_paths
|
|
107
|
+
],
|
|
108
|
+
"rejected_files": [item.to_dict() for item in rejected],
|
|
109
|
+
"secret_findings": [finding.masked_dict() for finding in secret_findings],
|
|
110
|
+
"budget": _budget_summary(options, 0, False),
|
|
111
|
+
"incremental": _incremental_summary(incremental_state),
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
report.summary = summary
|
|
115
|
+
report.finish()
|
|
116
|
+
return report
|
|
117
|
+
|
|
118
|
+
selected, snippet_rejections = _read_selected_files(root, ordered_paths, candidates, options)
|
|
119
|
+
rejected.extend(snippet_rejections)
|
|
120
|
+
diffs = [] if not git_available else _limited_diffs(root, options)
|
|
121
|
+
latest_errors = _latest_error_reports(root)
|
|
122
|
+
summary = _base_summary(
|
|
123
|
+
options,
|
|
124
|
+
scan.summary,
|
|
125
|
+
repo_map.summary,
|
|
126
|
+
git_report.summary if git_report else None,
|
|
127
|
+
plan,
|
|
128
|
+
changed_analysis,
|
|
129
|
+
)
|
|
130
|
+
summary.update(
|
|
131
|
+
{
|
|
132
|
+
"incremental": _incremental_summary(incremental_state, len(selected)),
|
|
133
|
+
"selected_files": [item.to_dict() for item in selected],
|
|
134
|
+
"rejected_files": [item.to_dict() for item in rejected],
|
|
135
|
+
"diffs": diffs,
|
|
136
|
+
"latest_errors": latest_errors,
|
|
137
|
+
"secret_findings": [finding.masked_dict() for finding in secret_findings],
|
|
138
|
+
"recent_commits": (git_report.summary.get("recent_commits", []) if git_report else []),
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
markdown = _render_markdown(report, summary)
|
|
143
|
+
markdown, markdown_truncated = _truncate_text(markdown, options.max_chars)
|
|
144
|
+
json_payload = _context_payload(report, summary, markdown_truncated, len(markdown))
|
|
145
|
+
json_payload, json_truncated = _cap_json_payload(json_payload, options.max_chars)
|
|
146
|
+
summary["budget"] = _budget_summary(
|
|
147
|
+
options,
|
|
148
|
+
max(len(markdown), len(json.dumps(json_payload, ensure_ascii=False))),
|
|
149
|
+
markdown_truncated or json_truncated,
|
|
150
|
+
)
|
|
151
|
+
if markdown_truncated or json_truncated:
|
|
152
|
+
summary["truncated"] = True
|
|
153
|
+
report.status = "partial"
|
|
154
|
+
report.issues.append(
|
|
155
|
+
Issue(
|
|
156
|
+
severity="warning",
|
|
157
|
+
message="Context pack was truncated to respect configured character budget.",
|
|
158
|
+
code="CONTEXT_BUDGET_TRUNCATED",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
summary["truncated"] = any(item.truncated for item in selected)
|
|
163
|
+
report.status = "partial" if summary["truncated"] else "success"
|
|
164
|
+
manifest_artifact: Artifact | None = None
|
|
165
|
+
if incremental_state is not None:
|
|
166
|
+
manifest_path, context_id = save_incremental_manifest(
|
|
167
|
+
root,
|
|
168
|
+
incremental_state,
|
|
169
|
+
[item.path for item in selected],
|
|
170
|
+
)
|
|
171
|
+
incremental = summary.get("incremental")
|
|
172
|
+
if isinstance(incremental, dict):
|
|
173
|
+
incremental["context_id"] = context_id
|
|
174
|
+
incremental["manifest"] = str(manifest_path)
|
|
175
|
+
manifest_artifact = Artifact(
|
|
176
|
+
str(manifest_path), "context-manifest", "Incremental context state"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
report.summary = summary
|
|
180
|
+
report.finish()
|
|
181
|
+
|
|
182
|
+
output_dir = options.output or (root / ".ai" / "context")
|
|
183
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
md_path = output_dir / "context-latest.md"
|
|
185
|
+
json_path = output_dir / "context-latest.json"
|
|
186
|
+
if options.format in {"markdown", "both"}:
|
|
187
|
+
report.artifacts.append(Artifact(str(md_path), "markdown", "Bounded AI context package"))
|
|
188
|
+
if options.format in {"json", "both"}:
|
|
189
|
+
report.artifacts.append(Artifact(str(json_path), "json", "Bounded AI context package"))
|
|
190
|
+
if manifest_artifact is not None:
|
|
191
|
+
report.artifacts.append(manifest_artifact)
|
|
192
|
+
if options.format in {"markdown", "both"}:
|
|
193
|
+
md_path.write_text(mask_text(_render_markdown(report, report.summary)), encoding="utf-8")
|
|
194
|
+
if options.format in {"json", "both"}:
|
|
195
|
+
json_text = mask_text(json.dumps(report.to_dict(), indent=2, sort_keys=True))
|
|
196
|
+
json_path.write_text(json_text, encoding="utf-8")
|
|
197
|
+
return report
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _apply_context_profile(options: ContextOptions) -> ContextOptions:
|
|
201
|
+
profile = get_context_profile(options.profile)
|
|
202
|
+
if profile is None:
|
|
203
|
+
return options
|
|
204
|
+
return replace(
|
|
205
|
+
options,
|
|
206
|
+
max_chars=(
|
|
207
|
+
profile.max_chars if options.max_chars == DEFAULT_MAX_CHARS else options.max_chars
|
|
208
|
+
),
|
|
209
|
+
max_files=(
|
|
210
|
+
profile.max_files if options.max_files == DEFAULT_MAX_FILES else options.max_files
|
|
211
|
+
),
|
|
212
|
+
max_file_chars=(
|
|
213
|
+
profile.max_file_chars
|
|
214
|
+
if options.max_file_chars == DEFAULT_MAX_FILE_CHARS
|
|
215
|
+
else options.max_file_chars
|
|
216
|
+
),
|
|
217
|
+
max_diff_chars=(
|
|
218
|
+
profile.max_diff_chars
|
|
219
|
+
if options.max_diff_chars == DEFAULT_MAX_DIFF_CHARS
|
|
220
|
+
else options.max_diff_chars
|
|
221
|
+
),
|
|
222
|
+
changed_only=options.changed_only or profile.changed_only,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _base_summary(
|
|
227
|
+
options: ContextOptions,
|
|
228
|
+
scan_summary: dict[str, object],
|
|
229
|
+
map_summary: dict[str, object],
|
|
230
|
+
git_summary: dict[str, object] | None,
|
|
231
|
+
plan: list[CheckTask],
|
|
232
|
+
changed_analysis: ChangedSelection | None,
|
|
233
|
+
) -> dict[str, object]:
|
|
234
|
+
validation_plan = [task.to_dict() for task in plan]
|
|
235
|
+
changed_dict = changed_analysis.to_dict() if changed_analysis is not None else None
|
|
236
|
+
return {
|
|
237
|
+
"task": options.task,
|
|
238
|
+
"technologies": {
|
|
239
|
+
"languages": scan_summary.get("languages", []),
|
|
240
|
+
"frameworks": scan_summary.get("frameworks", []),
|
|
241
|
+
"package_managers": scan_summary.get("package_managers", []),
|
|
242
|
+
"entrypoints": scan_summary.get("entrypoints", []),
|
|
243
|
+
"runtime_requirements": scan_summary.get("runtime_requirements", []),
|
|
244
|
+
"workspaces": scan_summary.get("workspaces", []),
|
|
245
|
+
},
|
|
246
|
+
"git_state": git_summary,
|
|
247
|
+
"changed_files": _extract_changed_files(git_summary),
|
|
248
|
+
"related_tests": changed_dict.get("selected_tests", []) if changed_dict else [],
|
|
249
|
+
"validation_plan": validation_plan,
|
|
250
|
+
"changed_analysis": changed_dict,
|
|
251
|
+
"repository_map": {
|
|
252
|
+
"important_files": map_summary.get("important_files", []),
|
|
253
|
+
"tests": map_summary.get("tests", []),
|
|
254
|
+
"ci_workflows": map_summary.get("ci_workflows", []),
|
|
255
|
+
"documentation": map_summary.get("documentation", []),
|
|
256
|
+
"generated_or_lock_files": map_summary.get("generated_or_lock_files", []),
|
|
257
|
+
},
|
|
258
|
+
"options": _options_dict(options),
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _limited_diffs(root: Path, options: ContextOptions) -> list[dict[str, object]]:
|
|
263
|
+
commands: list[tuple[str, list[str]]] = []
|
|
264
|
+
if not options.staged_only:
|
|
265
|
+
commands.append(("unstaged", ["git", "diff", "--", "."]))
|
|
266
|
+
commands.append(("staged", ["git", "diff", "--cached", "--", "."]))
|
|
267
|
+
remaining = max(options.max_diff_chars, 0)
|
|
268
|
+
diffs: list[dict[str, object]] = []
|
|
269
|
+
for name, command in commands:
|
|
270
|
+
if remaining <= 0:
|
|
271
|
+
diffs.append({"name": name, "content": "", "truncated": True, "chars": 0})
|
|
272
|
+
continue
|
|
273
|
+
result = run_command(command, root, timeout_seconds=60)
|
|
274
|
+
if result.exit_code != 0:
|
|
275
|
+
diffs.append(
|
|
276
|
+
{"name": name, "error": result.stderr.strip(), "truncated": False, "chars": 0}
|
|
277
|
+
)
|
|
278
|
+
continue
|
|
279
|
+
text, truncated = _truncate_text(mask_text(result.stdout), remaining)
|
|
280
|
+
remaining -= len(text)
|
|
281
|
+
diffs.append({"name": name, "content": text, "truncated": truncated, "chars": len(text)})
|
|
282
|
+
return diffs
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _latest_error_reports(root: Path) -> list[dict[str, object]]:
|
|
286
|
+
reports_dir = root / ".ai" / "reports"
|
|
287
|
+
if not reports_dir.exists():
|
|
288
|
+
return []
|
|
289
|
+
found: list[dict[str, object]] = []
|
|
290
|
+
latest_paths = sorted(
|
|
291
|
+
reports_dir.glob("check-*-latest.json"),
|
|
292
|
+
key=lambda item: item.stat().st_mtime,
|
|
293
|
+
reverse=True,
|
|
294
|
+
)[:3]
|
|
295
|
+
for path in latest_paths:
|
|
296
|
+
try:
|
|
297
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
298
|
+
except json.JSONDecodeError:
|
|
299
|
+
continue
|
|
300
|
+
if data.get("status") == "failed" or data.get("summary", {}).get("first_failure"):
|
|
301
|
+
found.append(
|
|
302
|
+
{
|
|
303
|
+
"path": _rel(root, path),
|
|
304
|
+
"status": data.get("status"),
|
|
305
|
+
"first_failure": data.get("summary", {}).get("first_failure"),
|
|
306
|
+
}
|
|
307
|
+
)
|
|
308
|
+
return found
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _render_markdown(report: Report, summary: dict[str, object]) -> str:
|
|
312
|
+
lines = [
|
|
313
|
+
"# AI Development Context",
|
|
314
|
+
"",
|
|
315
|
+
f"STATUS: {report.status.upper()}",
|
|
316
|
+
f"COMMAND: {report.command}",
|
|
317
|
+
f"PROJECT_ROOT: {report.project_root}",
|
|
318
|
+
"",
|
|
319
|
+
"## Task",
|
|
320
|
+
str(summary.get("task") or "No task provided."),
|
|
321
|
+
"",
|
|
322
|
+
"## Technologies",
|
|
323
|
+
_json_block(summary.get("technologies", {})),
|
|
324
|
+
"",
|
|
325
|
+
"## Git State",
|
|
326
|
+
_json_block(summary.get("git_state", {"state": "SKIPPED"})),
|
|
327
|
+
"",
|
|
328
|
+
"## Changed Files",
|
|
329
|
+
_bullet_list(_object_list(summary.get("changed_files"))),
|
|
330
|
+
"",
|
|
331
|
+
"## Related Tests",
|
|
332
|
+
_bullet_list(_object_list(summary.get("related_tests"))),
|
|
333
|
+
"",
|
|
334
|
+
"## Validation Plan",
|
|
335
|
+
_json_block(summary.get("validation_plan", [])),
|
|
336
|
+
"",
|
|
337
|
+
"## Latest Errors",
|
|
338
|
+
_json_block(summary.get("latest_errors", [])),
|
|
339
|
+
"",
|
|
340
|
+
"## Context Budget",
|
|
341
|
+
_json_block(summary.get("budget", {})),
|
|
342
|
+
"",
|
|
343
|
+
"## Selected Files",
|
|
344
|
+
]
|
|
345
|
+
for item in _dict_list(summary.get("selected_files", [])):
|
|
346
|
+
lines.extend(
|
|
347
|
+
[
|
|
348
|
+
"",
|
|
349
|
+
f"### {item.get('path')}",
|
|
350
|
+
f"Reason: {item.get('reason')}",
|
|
351
|
+
f"Selection: {item.get('selection_strategy', 'file-prefix')}",
|
|
352
|
+
f"Omitted content: {item.get('omitted_content', False)}",
|
|
353
|
+
f"Truncated: {item.get('truncated')}",
|
|
354
|
+
"```text",
|
|
355
|
+
str(item.get("content", "")),
|
|
356
|
+
"```",
|
|
357
|
+
]
|
|
358
|
+
)
|
|
359
|
+
lines.extend(["", "## Diffs"])
|
|
360
|
+
for item in _dict_list(summary.get("diffs", [])):
|
|
361
|
+
lines.extend(
|
|
362
|
+
[
|
|
363
|
+
"",
|
|
364
|
+
f"### {item.get('name')}",
|
|
365
|
+
f"Selection: {item.get('selection_strategy', 'file-prefix')}",
|
|
366
|
+
f"Omitted content: {item.get('omitted_content', False)}",
|
|
367
|
+
f"Truncated: {item.get('truncated')}",
|
|
368
|
+
"```diff",
|
|
369
|
+
str(item.get("content", item.get("error", ""))),
|
|
370
|
+
"```",
|
|
371
|
+
]
|
|
372
|
+
)
|
|
373
|
+
lines.extend(["", "## Rejected Files", _json_block(summary.get("rejected_files", []))])
|
|
374
|
+
return "\n".join(lines).strip() + "\n"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _context_payload(
|
|
378
|
+
report: Report, summary: dict[str, object], truncated: bool, markdown_chars: int
|
|
379
|
+
) -> dict[str, object]:
|
|
380
|
+
return {
|
|
381
|
+
"schema_version": report.schema_version,
|
|
382
|
+
"tool_version": report.tool_version,
|
|
383
|
+
"command": report.command,
|
|
384
|
+
"status": report.status,
|
|
385
|
+
"project_root": str(report.project_root),
|
|
386
|
+
"summary": summary,
|
|
387
|
+
"budget": {"markdown_chars": markdown_chars, "truncated": truncated},
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _cap_json_payload(payload: dict[str, object], max_chars: int) -> tuple[dict[str, object], bool]:
|
|
392
|
+
text = json.dumps(payload, ensure_ascii=False)
|
|
393
|
+
if len(text) <= max_chars:
|
|
394
|
+
return payload, False
|
|
395
|
+
capped = dict(payload)
|
|
396
|
+
summary = capped.get("summary")
|
|
397
|
+
if isinstance(summary, dict):
|
|
398
|
+
capped_summary = dict(summary)
|
|
399
|
+
capped_summary["selected_files"] = []
|
|
400
|
+
capped_summary["diffs"] = []
|
|
401
|
+
capped_summary["json_payload_note"] = (
|
|
402
|
+
"Large snippets and diffs omitted from JSON budget. See markdown artifact when enabled."
|
|
403
|
+
)
|
|
404
|
+
capped["summary"] = capped_summary
|
|
405
|
+
return capped, True
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _changed_files(git_report: Report | None, staged_only: bool) -> list[str]:
|
|
409
|
+
if git_report is None:
|
|
410
|
+
return []
|
|
411
|
+
key = "staged_files" if staged_only else "changed_files"
|
|
412
|
+
return _visible_project_files(_object_list(git_report.summary.get(key)))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _extract_changed_files(git_summary: dict[str, object] | None) -> list[str]:
|
|
416
|
+
if not git_summary:
|
|
417
|
+
return []
|
|
418
|
+
return _visible_project_files(_object_list(git_summary.get("changed_files")))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _incremental_summary(
|
|
422
|
+
state: IncrementalSelection | None, emitted: int | None = None
|
|
423
|
+
) -> dict[str, object]:
|
|
424
|
+
if state is None:
|
|
425
|
+
return {"enabled": False}
|
|
426
|
+
pending = len(state.selected)
|
|
427
|
+
return {
|
|
428
|
+
"enabled": True,
|
|
429
|
+
"changed_candidates": pending,
|
|
430
|
+
"emitted": emitted,
|
|
431
|
+
"deferred": max(pending - emitted, 0) if emitted is not None else None,
|
|
432
|
+
"reused": len(state.reused),
|
|
433
|
+
"reused_files": state.reused[:100],
|
|
434
|
+
"index": state.index_summary,
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _options_dict(options: ContextOptions) -> dict[str, object]:
|
|
439
|
+
data = asdict(options)
|
|
440
|
+
output = data.get("output")
|
|
441
|
+
if output is not None:
|
|
442
|
+
data["output"] = str(output)
|
|
443
|
+
return data
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _visible_project_files(files: list[str]) -> list[str]:
|
|
447
|
+
return [item for item in files if not _is_context_generated_path(item)]
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _is_context_generated_path(rel: str) -> bool:
|
|
451
|
+
normalized = rel.replace("\\", "/")
|
|
452
|
+
parts = set(Path(normalized).parts)
|
|
453
|
+
if normalized.startswith(".ai/"):
|
|
454
|
+
return True
|
|
455
|
+
return any(
|
|
456
|
+
pattern in parts or normalized.startswith(f"{pattern}/") for pattern in ALWAYS_IGNORE
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _related_tests(changed_analysis: ChangedSelection | None) -> list[str]:
|
|
461
|
+
if changed_analysis is None:
|
|
462
|
+
return []
|
|
463
|
+
data = changed_analysis.to_dict()
|
|
464
|
+
return _object_list(data.get("selected_tests"))
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _candidate_sort_key(path: Path, reason: str) -> tuple[int, str]:
|
|
468
|
+
priority = 50
|
|
469
|
+
if "changed" in reason:
|
|
470
|
+
priority = 0
|
|
471
|
+
elif "related" in reason:
|
|
472
|
+
priority = 5
|
|
473
|
+
elif "included" in reason:
|
|
474
|
+
priority = 10
|
|
475
|
+
elif "entrypoint" in reason:
|
|
476
|
+
priority = 20
|
|
477
|
+
elif "important" in reason:
|
|
478
|
+
priority = 30
|
|
479
|
+
elif "CI" in reason or "documentation" in reason:
|
|
480
|
+
priority = 40
|
|
481
|
+
return (priority, path.as_posix())
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _budget_summary(options: ContextOptions, used_chars: int, truncated: bool) -> dict[str, object]:
|
|
485
|
+
return {
|
|
486
|
+
"max_chars": options.max_chars,
|
|
487
|
+
"max_files": options.max_files,
|
|
488
|
+
"max_file_chars": options.max_file_chars,
|
|
489
|
+
"max_diff_chars": options.max_diff_chars,
|
|
490
|
+
"used_chars": used_chars,
|
|
491
|
+
"truncated": truncated,
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _dict_list(value: object) -> list[dict[str, object]]:
|
|
496
|
+
if not isinstance(value, list):
|
|
497
|
+
return []
|
|
498
|
+
return [item for item in value if isinstance(item, dict)]
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _json_block(value: object) -> str:
|
|
502
|
+
return "```json\n" + json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n```"
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _bullet_list(items: list[str]) -> str:
|
|
506
|
+
return "\n".join(f"- {item}" for item in items) if items else "- none"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ai_dev_tools.cache.repository import update_repository_index
|
|
11
|
+
|
|
12
|
+
MANIFEST_SCHEMA_VERSION = "1"
|
|
13
|
+
MANIFEST_RELATIVE_PATH = Path(".ai/cache/context-manifest.json")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class IncrementalSelection:
|
|
18
|
+
selected: list[Path]
|
|
19
|
+
reused: list[str]
|
|
20
|
+
current_hashes: dict[str, str]
|
|
21
|
+
previous_hashes: dict[str, str]
|
|
22
|
+
index_summary: dict[str, object]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def select_incremental(root: Path, candidates: list[Path]) -> IncrementalSelection:
|
|
26
|
+
index = update_repository_index(root)
|
|
27
|
+
current_hashes = _index_hashes(index.get("entries"))
|
|
28
|
+
previous_hashes = _manifest_hashes(root)
|
|
29
|
+
selected: list[Path] = []
|
|
30
|
+
reused: list[str] = []
|
|
31
|
+
for path in candidates:
|
|
32
|
+
relative = path.resolve().relative_to(root.resolve()).as_posix()
|
|
33
|
+
digest = current_hashes.get(relative)
|
|
34
|
+
if digest is None or previous_hashes.get(relative) != digest:
|
|
35
|
+
selected.append(path)
|
|
36
|
+
else:
|
|
37
|
+
reused.append(relative)
|
|
38
|
+
summary = index.get("summary")
|
|
39
|
+
return IncrementalSelection(
|
|
40
|
+
selected=selected,
|
|
41
|
+
reused=reused,
|
|
42
|
+
current_hashes=current_hashes,
|
|
43
|
+
previous_hashes=previous_hashes,
|
|
44
|
+
index_summary=summary if isinstance(summary, dict) else {},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_incremental_manifest(
|
|
49
|
+
root: Path,
|
|
50
|
+
state: IncrementalSelection,
|
|
51
|
+
emitted_paths: list[str],
|
|
52
|
+
) -> tuple[Path, str]:
|
|
53
|
+
hashes = {
|
|
54
|
+
path: digest
|
|
55
|
+
for path, digest in state.previous_hashes.items()
|
|
56
|
+
if path in state.current_hashes
|
|
57
|
+
}
|
|
58
|
+
for emitted_path in emitted_paths:
|
|
59
|
+
digest = state.current_hashes.get(emitted_path)
|
|
60
|
+
if digest is not None:
|
|
61
|
+
hashes[emitted_path] = digest
|
|
62
|
+
canonical = json.dumps(hashes, sort_keys=True, separators=(",", ":"))
|
|
63
|
+
context_id = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
|
|
64
|
+
payload: dict[str, object] = {
|
|
65
|
+
"schema_version": MANIFEST_SCHEMA_VERSION,
|
|
66
|
+
"context_id": context_id,
|
|
67
|
+
"generated_at": datetime.now(UTC).isoformat(),
|
|
68
|
+
"files": hashes,
|
|
69
|
+
}
|
|
70
|
+
manifest_path = root.resolve() / MANIFEST_RELATIVE_PATH
|
|
71
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
temporary = manifest_path.with_suffix(".tmp")
|
|
73
|
+
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
74
|
+
os.replace(temporary, manifest_path)
|
|
75
|
+
return manifest_path, context_id
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _manifest_hashes(root: Path) -> dict[str, str]:
|
|
79
|
+
path = root.resolve() / MANIFEST_RELATIVE_PATH
|
|
80
|
+
try:
|
|
81
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
82
|
+
except (OSError, json.JSONDecodeError):
|
|
83
|
+
return {}
|
|
84
|
+
if not isinstance(value, dict) or value.get("schema_version") != MANIFEST_SCHEMA_VERSION:
|
|
85
|
+
return {}
|
|
86
|
+
files = value.get("files")
|
|
87
|
+
if not isinstance(files, dict):
|
|
88
|
+
return {}
|
|
89
|
+
return {
|
|
90
|
+
str(key): item
|
|
91
|
+
for key, item in files.items()
|
|
92
|
+
if isinstance(key, str) and isinstance(item, str)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _index_hashes(value: object) -> dict[str, str]:
|
|
97
|
+
if not isinstance(value, list):
|
|
98
|
+
return {}
|
|
99
|
+
result: dict[str, str] = {}
|
|
100
|
+
for item in value:
|
|
101
|
+
if not isinstance(item, dict):
|
|
102
|
+
continue
|
|
103
|
+
path = item.get("path")
|
|
104
|
+
digest = item.get("sha256")
|
|
105
|
+
if isinstance(path, str) and isinstance(digest, str):
|
|
106
|
+
result[path] = digest
|
|
107
|
+
return result
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from ai_dev_tools.context.symbols import SymbolSnippet
|
|
8
|
+
|
|
9
|
+
ContextFormat = Literal["markdown", "json", "both"]
|
|
10
|
+
|
|
11
|
+
DEFAULT_MAX_CHARS = 50_000
|
|
12
|
+
DEFAULT_MAX_FILES = 30
|
|
13
|
+
DEFAULT_MAX_FILE_CHARS = 8_000
|
|
14
|
+
DEFAULT_MAX_DIFF_CHARS = 15_000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class ContextOptions:
|
|
19
|
+
task: str = ""
|
|
20
|
+
max_chars: int = DEFAULT_MAX_CHARS
|
|
21
|
+
max_files: int = DEFAULT_MAX_FILES
|
|
22
|
+
max_file_chars: int = DEFAULT_MAX_FILE_CHARS
|
|
23
|
+
max_diff_chars: int = DEFAULT_MAX_DIFF_CHARS
|
|
24
|
+
include: tuple[str, ...] = ()
|
|
25
|
+
exclude: tuple[str, ...] = ()
|
|
26
|
+
changed_only: bool = False
|
|
27
|
+
staged_only: bool = False
|
|
28
|
+
no_git: bool = False
|
|
29
|
+
output: Path | None = None
|
|
30
|
+
format: ContextFormat = "both"
|
|
31
|
+
explain: bool = False
|
|
32
|
+
incremental: bool = False
|
|
33
|
+
profile: str = "default"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class SelectedFile:
|
|
38
|
+
path: str
|
|
39
|
+
reason: str
|
|
40
|
+
reason_code: str
|
|
41
|
+
chars: int
|
|
42
|
+
truncated: bool
|
|
43
|
+
content: str
|
|
44
|
+
selection_strategy: str = "file-prefix"
|
|
45
|
+
omitted_content: bool = False
|
|
46
|
+
snippets: list[SymbolSnippet] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, object]:
|
|
49
|
+
return asdict(self)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class RejectedFile:
|
|
54
|
+
path: str
|
|
55
|
+
reason: str
|
|
56
|
+
reason_code: str
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict[str, object]:
|
|
59
|
+
return asdict(self)
|