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.
Files changed (60) hide show
  1. ai_dev_cli_tools-0.5.0a1.dist-info/METADATA +240 -0
  2. ai_dev_cli_tools-0.5.0a1.dist-info/RECORD +60 -0
  3. ai_dev_cli_tools-0.5.0a1.dist-info/WHEEL +4 -0
  4. ai_dev_cli_tools-0.5.0a1.dist-info/entry_points.txt +2 -0
  5. ai_dev_cli_tools-0.5.0a1.dist-info/licenses/LICENSE +21 -0
  6. ai_dev_tools/__init__.py +3 -0
  7. ai_dev_tools/cache/__init__.py +11 -0
  8. ai_dev_tools/cache/graph.py +136 -0
  9. ai_dev_tools/cache/repository.py +169 -0
  10. ai_dev_tools/cache/validation.py +154 -0
  11. ai_dev_tools/cli.py +387 -0
  12. ai_dev_tools/completion.py +72 -0
  13. ai_dev_tools/config.py +223 -0
  14. ai_dev_tools/context/__init__.py +5 -0
  15. ai_dev_tools/context/builder.py +506 -0
  16. ai_dev_tools/context/incremental.py +107 -0
  17. ai_dev_tools/context/models.py +59 -0
  18. ai_dev_tools/context/profiles.py +49 -0
  19. ai_dev_tools/context/selection.py +270 -0
  20. ai_dev_tools/context/symbols.py +178 -0
  21. ai_dev_tools/detectors/__init__.py +1 -0
  22. ai_dev_tools/detectors/environment.py +125 -0
  23. ai_dev_tools/detectors/project.py +189 -0
  24. ai_dev_tools/detectors/repository_map.py +129 -0
  25. ai_dev_tools/detectors/runtime.py +190 -0
  26. ai_dev_tools/detectors/workspaces.py +228 -0
  27. ai_dev_tools/git/__init__.py +1 -0
  28. ai_dev_tools/git/inspect.py +219 -0
  29. ai_dev_tools/models/__init__.py +1 -0
  30. ai_dev_tools/models/report.py +95 -0
  31. ai_dev_tools/models/workspace.py +48 -0
  32. ai_dev_tools/parsers/__init__.py +1 -0
  33. ai_dev_tools/parsers/logs.py +372 -0
  34. ai_dev_tools/parsers/registry.py +60 -0
  35. ai_dev_tools/reporters/__init__.py +1 -0
  36. ai_dev_tools/reporters/progressive.py +161 -0
  37. ai_dev_tools/reporters/writer.py +74 -0
  38. ai_dev_tools/runners/__init__.py +1 -0
  39. ai_dev_tools/runners/baseline.py +190 -0
  40. ai_dev_tools/runners/bootstrap.py +191 -0
  41. ai_dev_tools/runners/bootstrap_models.py +64 -0
  42. ai_dev_tools/runners/bootstrap_strategies.py +444 -0
  43. ai_dev_tools/runners/cache.py +23 -0
  44. ai_dev_tools/runners/check.py +509 -0
  45. ai_dev_tools/runners/check_checkpoint.py +50 -0
  46. ai_dev_tools/runners/check_models.py +51 -0
  47. ai_dev_tools/runners/check_scheduler.py +94 -0
  48. ai_dev_tools/runners/check_selection.py +267 -0
  49. ai_dev_tools/runners/diagnostics.py +96 -0
  50. ai_dev_tools/runners/feedback.py +193 -0
  51. ai_dev_tools/runners/finish.py +105 -0
  52. ai_dev_tools/runners/focused.py +37 -0
  53. ai_dev_tools/runners/index.py +44 -0
  54. ai_dev_tools/runtime/__init__.py +3 -0
  55. ai_dev_tools/runtime/runner.py +380 -0
  56. ai_dev_tools/runtime/supervisor.py +145 -0
  57. ai_dev_tools/security/__init__.py +1 -0
  58. ai_dev_tools/security/secrets.py +58 -0
  59. ai_dev_tools/utils/__init__.py +1 -0
  60. 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)