contextcraft-ai 0.2.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.
@@ -0,0 +1,44 @@
1
+ """ContextCraft: AST-Aware, Dependency-Graph-Ranked, Token-Budgeted Codebase Compressor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextcraft.core import ContextCraft
6
+ from contextcraft.models import (
7
+ ASTParseError,
8
+ ByteRange,
9
+ CompilationConfig,
10
+ CompilationError,
11
+ CompilationResult,
12
+ CompilationStats,
13
+ ContextCraftError,
14
+ FidelityLevel,
15
+ FileAnalysis,
16
+ InvalidBudgetError,
17
+ OutputFormat,
18
+ RepositoryNotFoundError,
19
+ SecretKind,
20
+ SecretMatch,
21
+ SymbolNode,
22
+ )
23
+
24
+ __version__ = "0.2.0"
25
+
26
+ __all__ = [
27
+ "ASTParseError",
28
+ "ByteRange",
29
+ "CompilationConfig",
30
+ "CompilationError",
31
+ "CompilationResult",
32
+ "CompilationStats",
33
+ "ContextCraft",
34
+ "ContextCraftError",
35
+ "FidelityLevel",
36
+ "FileAnalysis",
37
+ "InvalidBudgetError",
38
+ "OutputFormat",
39
+ "RepositoryNotFoundError",
40
+ "SecretKind",
41
+ "SecretMatch",
42
+ "SymbolNode",
43
+ "__version__",
44
+ ]
@@ -0,0 +1,8 @@
1
+ """Allow executing ContextCraft via `python -m contextcraft`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextcraft.cli import app
6
+
7
+ if __name__ == "__main__":
8
+ app()
@@ -0,0 +1,147 @@
1
+ """Shared Tree-sitter traversal and language-specific extraction dispatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from contextcraft.languages import get_extractor
8
+ from contextcraft.models import ASTParseResult, SymbolNode
9
+
10
+ if TYPE_CHECKING:
11
+ from tree_sitter import Node
12
+
13
+ try:
14
+ from tree_sitter_language_pack import get_parser
15
+
16
+ HAS_TREE_SITTER = True
17
+ except ImportError: # pragma: no cover
18
+ HAS_TREE_SITTER = False
19
+
20
+
21
+ class UniversalASTParser:
22
+ """Extracts architectural symbols, byte spans, and dependency references."""
23
+
24
+ @classmethod
25
+ def parse(cls, content: str, language_id: str) -> tuple[list[SymbolNode], list[str]]:
26
+ """Parse source code into `(symbols, imports)`."""
27
+ detailed = cls.parse_detailed(content, language_id)
28
+ return detailed.symbols, detailed.imports
29
+
30
+ @classmethod
31
+ def parse_detailed(cls, content: str, language_id: str) -> ASTParseResult:
32
+ """Parse symbols, imports, definitions, and identifier references."""
33
+ if not content.strip():
34
+ return ASTParseResult([], [], [], [])
35
+
36
+ extractor = get_extractor(language_id)
37
+ if not HAS_TREE_SITTER:
38
+ return extractor.parse_fallback(content)
39
+
40
+ try:
41
+ parser = get_parser(language_id)
42
+ except Exception:
43
+ # Grammar loading can fail for unsupported, missing, or uncached grammars.
44
+ return extractor.parse_fallback(content)
45
+
46
+ source = content.encode("utf-8")
47
+ tree = parser.parse(source)
48
+ if not extractor.should_extract(tree.root_node):
49
+ return extractor.parse_fallback(content)
50
+ return cls._extract_from_tree(tree.root_node, source, language_id)
51
+
52
+ @classmethod
53
+ def _extract_from_tree(
54
+ cls,
55
+ root: Node,
56
+ raw_bytes: bytes,
57
+ language_id: str,
58
+ ) -> ASTParseResult:
59
+ extractor = get_extractor(language_id)
60
+ symbols: list[SymbolNode] = []
61
+ imports: dict[str, None] = {}
62
+ definitions: dict[str, None] = {}
63
+ references: dict[str, None] = {}
64
+
65
+ def traverse(
66
+ node: Node,
67
+ parent_name: str | None = None,
68
+ parent_type: str | None = None,
69
+ ) -> None:
70
+ scope = extractor.enclosing_scope(node, raw_bytes)
71
+ if scope is not None:
72
+ scope_name, parent_type = scope
73
+ parent_name = f"{parent_name}.{scope_name}" if parent_name else scope_name
74
+
75
+ declaration, decorators = extractor.unwrap_declaration(node, raw_bytes)
76
+ if node.type in extractor.IMPORT_NODE_TYPES:
77
+ text = cls._node_text(node, raw_bytes).strip()
78
+ if text:
79
+ imports[text] = None
80
+
81
+ kind = extractor.symbol_kind(declaration, parent_type)
82
+ if kind is not None:
83
+ record_symbol(declaration, node, kind, parent_name, parent_type, decorators)
84
+ return
85
+
86
+ if extractor.is_reference(node):
87
+ name = cls._node_text(node, raw_bytes).strip()
88
+ if len(name) >= 3:
89
+ references[name] = None
90
+
91
+ for child in node.children:
92
+ traverse(child, parent_name, parent_type)
93
+
94
+ def record_symbol(
95
+ node: Node,
96
+ outer_node: Node,
97
+ kind: str,
98
+ parent_name: str | None,
99
+ parent_type: str | None,
100
+ decorators: list[str],
101
+ ) -> None:
102
+ if extractor.name_node(node) is None:
103
+ for child in node.children:
104
+ traverse(child, parent_name, parent_type)
105
+ return
106
+
107
+ name = extractor.symbol_name(node, raw_bytes)
108
+ if not name:
109
+ return
110
+
111
+ qualified_name = f"{parent_name}.{name}" if parent_name else name
112
+ definitions[name] = None
113
+ if parent_name:
114
+ definitions[qualified_name] = None
115
+
116
+ docstring, doc_range = extractor.extract_docstring(node, outer_node, raw_bytes)
117
+ body_range = extractor.elidable_body_range(
118
+ node, raw_bytes, doc_range.end_byte if doc_range is not None else None
119
+ )
120
+ symbols.append(
121
+ SymbolNode(
122
+ name=name,
123
+ qualified_name=qualified_name,
124
+ kind=kind,
125
+ signature=extractor.extract_signature(node, outer_node, raw_bytes),
126
+ docstring=docstring,
127
+ full_range=extractor.node_range(outer_node),
128
+ body_range=body_range,
129
+ is_exported=extractor.is_exported(node, name, raw_bytes),
130
+ parent_symbol=parent_name,
131
+ decorators=decorators,
132
+ )
133
+ )
134
+ for child in node.children:
135
+ traverse(child, qualified_name, node.type)
136
+
137
+ traverse(root)
138
+ return ASTParseResult(
139
+ symbols=symbols,
140
+ imports=list(imports),
141
+ defined_identifiers=list(definitions),
142
+ referenced_identifiers=list(references),
143
+ )
144
+
145
+ @staticmethod
146
+ def _node_text(node: Node, raw_bytes: bytes) -> str:
147
+ return raw_bytes[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
contextcraft/cli.py ADDED
@@ -0,0 +1,336 @@
1
+ """Rich CLI interface for ContextCraft.
2
+
3
+ Provides a fast, zero-config command-line interface:
4
+ contextcraft . --budget 32k --focus "authentication flow" -o context.md
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ import typer
16
+ from rich import box
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+
21
+ from contextcraft import __version__
22
+ from contextcraft.core import ContextCraft
23
+ from contextcraft.models import (
24
+ CompilationResult,
25
+ ContextCraftError,
26
+ FidelityLevel,
27
+ OutputFormat,
28
+ )
29
+
30
+ app = typer.Typer(
31
+ name="contextcraft",
32
+ help=(
33
+ "AST-Aware, Dependency-Graph-Ranked, Token-Budgeted "
34
+ "Semantic Codebase Compressor for LLMs."
35
+ ),
36
+ add_completion=False,
37
+ no_args_is_help=False,
38
+ )
39
+
40
+ err_console = Console(stderr=True)
41
+ out_console = Console(stderr=False)
42
+
43
+
44
+ def _version_callback(value: bool) -> None:
45
+ if value:
46
+ out_console.print(f"contextcraft {__version__}")
47
+ raise typer.Exit(code=0)
48
+
49
+
50
+ @app.command()
51
+ def main(
52
+ path: Annotated[
53
+ Path,
54
+ typer.Argument(
55
+ help="Path to the target Git repository or source directory.",
56
+ show_default=True,
57
+ ),
58
+ ] = Path("."),
59
+ budget: Annotated[
60
+ str,
61
+ typer.Option(
62
+ "--budget",
63
+ "-b",
64
+ help="Strict token budget (supports shorthand like '16k', '32k', '128k', or '8000').",
65
+ show_default=True,
66
+ ),
67
+ ] = "32k",
68
+ focus: Annotated[
69
+ str | None,
70
+ typer.Option(
71
+ "--focus",
72
+ "-f",
73
+ help="Natural-language query or symbol keywords to bias Personalized PageRank.",
74
+ ),
75
+ ] = None,
76
+ focus_file: Annotated[
77
+ list[str] | None,
78
+ typer.Option(
79
+ "--focus-file",
80
+ help="Specific relative file path or glob to boost in PageRank (repeatable).",
81
+ ),
82
+ ] = None,
83
+ output_format: Annotated[
84
+ OutputFormat,
85
+ typer.Option(
86
+ "--format",
87
+ "-m",
88
+ case_sensitive=False,
89
+ help="Target output format: markdown, xml (Claude-optimized), or json.",
90
+ show_default=True,
91
+ ),
92
+ ] = OutputFormat.MARKDOWN,
93
+ output: Annotated[
94
+ Path | None,
95
+ typer.Option(
96
+ "--output",
97
+ "-o",
98
+ help="Write compiled context to this file path instead of stdout.",
99
+ ),
100
+ ] = None,
101
+ include: Annotated[
102
+ list[str] | None,
103
+ typer.Option(
104
+ "--include",
105
+ "-i",
106
+ help="Glob pattern of files to include (repeatable).",
107
+ ),
108
+ ] = None,
109
+ exclude: Annotated[
110
+ list[str] | None,
111
+ typer.Option(
112
+ "--exclude",
113
+ "-e",
114
+ help="Additional glob pattern of files to exclude (repeatable).",
115
+ ),
116
+ ] = None,
117
+ copy: Annotated[
118
+ bool,
119
+ typer.Option(
120
+ "--copy",
121
+ "-c",
122
+ help="Copy the compiled output directly to the system clipboard.",
123
+ ),
124
+ ] = False,
125
+ no_redact: Annotated[
126
+ bool,
127
+ typer.Option(
128
+ "--no-redact",
129
+ help="Disable automatic secret and API key redaction.",
130
+ ),
131
+ ] = False,
132
+ no_gitignore: Annotated[
133
+ bool,
134
+ typer.Option(
135
+ "--no-gitignore",
136
+ help="Do not respect .gitignore and .contextignore files.",
137
+ ),
138
+ ] = False,
139
+ quiet: Annotated[
140
+ bool,
141
+ typer.Option(
142
+ "--quiet",
143
+ "-q",
144
+ help="Suppress the Rich telemetry summary table on stderr.",
145
+ ),
146
+ ] = False,
147
+ version: Annotated[
148
+ bool,
149
+ typer.Option(
150
+ "--version",
151
+ "-V",
152
+ callback=_version_callback,
153
+ is_eager=True,
154
+ help="Show ContextCraft version and exit.",
155
+ ),
156
+ ] = False,
157
+ ) -> None:
158
+ """Compile a repository into an optimal, token-budgeted LLM context artifact."""
159
+ _ = version
160
+ try:
161
+ exclude_list = list(exclude) if exclude else []
162
+ if output is not None:
163
+ # Ensure we never accidentally ingest the output file being written
164
+ exclude_list.append(output.name)
165
+
166
+ compiler = ContextCraft(
167
+ budget_tokens=budget,
168
+ focus_query=focus,
169
+ focus_files=list(focus_file) if focus_file else [],
170
+ output_format=output_format,
171
+ include_patterns=list(include) if include else [],
172
+ exclude_patterns=exclude_list,
173
+ respect_gitignore=not no_gitignore,
174
+ redact_secrets=not no_redact,
175
+ )
176
+ result = compiler.compile(path)
177
+ except ContextCraftError as exc:
178
+ err_console.print(f"[bold red]ContextCraft Error:[/bold red] {exc}")
179
+ raise typer.Exit(code=1) from exc
180
+ except Exception as exc: # pragma: no cover
181
+ err_console.print(f"[bold red]Unexpected Failure:[/bold red] {exc}")
182
+ raise typer.Exit(code=1) from exc
183
+
184
+ # 1. Emit artifact to file or stdout
185
+ if output is not None:
186
+ output.parent.mkdir(parents=True, exist_ok=True)
187
+ output.write_text(result.content, encoding="utf-8")
188
+ else:
189
+ sys.stdout.write(result.content)
190
+ if not result.content.endswith("\n"):
191
+ sys.stdout.write("\n")
192
+ sys.stdout.flush()
193
+
194
+ # 2. Optionally copy to clipboard
195
+ copied_ok = False
196
+ if copy:
197
+ copied_ok = _copy_to_clipboard(result.content)
198
+
199
+ # 3. Render telemetry dashboard to stderr unless --quiet is set
200
+ if not quiet:
201
+ _render_telemetry_summary(
202
+ result=result,
203
+ output_path=output,
204
+ copied_to_clipboard=copied_ok,
205
+ copy_requested=copy,
206
+ )
207
+
208
+
209
+ def _render_telemetry_summary(
210
+ *,
211
+ result: CompilationResult,
212
+ output_path: Path | None,
213
+ copied_to_clipboard: bool,
214
+ copy_requested: bool,
215
+ ) -> None:
216
+ """Render a compact Rich telemetry table to `stderr`."""
217
+ stats = result.stats
218
+
219
+ metrics_table = Table(
220
+ box=box.ROUNDED,
221
+ show_header=True,
222
+ header_style="bold cyan",
223
+ expand=False,
224
+ )
225
+ metrics_table.add_column("Metric", style="bold white")
226
+ metrics_table.add_column("Value", justify="right", style="green")
227
+
228
+ metrics_table.add_row("Repository Root", result.root_path)
229
+ metrics_table.add_row(
230
+ "Files Compiled",
231
+ f"{stats.files_compiled} (skipped {stats.ignored_files_skipped} ignored, {stats.binary_files_skipped} binary)",
232
+ )
233
+ metrics_table.add_row("Raw Repository Tokens", f"{stats.raw_tokens:,}")
234
+ metrics_table.add_row(
235
+ "Compressed Tokens",
236
+ f"{stats.compressed_tokens:,} / {stats.budget_tokens:,} ({stats.budget_utilization_pct:.1f}%)",
237
+ )
238
+ metrics_table.add_row(
239
+ "Compression Ratio",
240
+ f"{stats.compression_ratio:.2f}x ({stats.token_savings_pct:.1f}% saved)",
241
+ )
242
+ metrics_table.add_row(
243
+ "Fidelity Allocation",
244
+ (
245
+ f"FULL: {stats.fidelity_counts.get('FULL', 0)} | "
246
+ f"SKEL: {stats.fidelity_counts.get('SKEL', 0)} | "
247
+ f"SIGS: {stats.fidelity_counts.get('SIGS', 0)} | "
248
+ f"TREE: {stats.fidelity_counts.get('TREE', 0)}"
249
+ ),
250
+ )
251
+ metrics_table.add_row(
252
+ "Signature Coverage",
253
+ f"{stats.preserved_signatures_pct:.1f}% ({stats.total_symbols_extracted} AST symbols)",
254
+ )
255
+ if stats.secrets_redacted > 0:
256
+ metrics_table.add_row(
257
+ "Secrets Scrubbed",
258
+ f"[bold yellow]{stats.secrets_redacted} redacted[/bold yellow]",
259
+ )
260
+ metrics_table.add_row("Execution Time", f"{stats.execution_time_ms:.1f} ms")
261
+
262
+ if output_path is not None:
263
+ metrics_table.add_row("Saved Output", str(output_path))
264
+ if copy_requested:
265
+ status_str = "Copied to clipboard" if copied_to_clipboard else "Clipboard unavailable"
266
+ metrics_table.add_row("Clipboard", status_str)
267
+
268
+ err_console.print()
269
+ err_console.print(
270
+ Panel.fit(
271
+ metrics_table,
272
+ title="[bold magenta]ContextCraft Compilation Summary[/bold magenta]",
273
+ border_style="bright_blue",
274
+ )
275
+ )
276
+
277
+ # Display top-ranked files table if files were compiled
278
+ if result.files:
279
+ top_table = Table(
280
+ title="Top Architectural Files by Personalized PageRank",
281
+ box=box.SIMPLE_HEAD,
282
+ header_style="bold blue",
283
+ )
284
+ top_table.add_column("File Path", style="white")
285
+ top_table.add_column("Fidelity", justify="center")
286
+ top_table.add_column("PageRank", justify="right", style="cyan")
287
+ top_table.add_column("Tokens (Selected / Raw)", justify="right", style="green")
288
+
289
+ badge_styles = {
290
+ FidelityLevel.FULL_SOURCE: "[bold green]FULL[/bold green]",
291
+ FidelityLevel.SKELETON_WITH_DOCSTRINGS: "[bold cyan]SKEL[/bold cyan]",
292
+ FidelityLevel.SIGNATURES_ONLY: "[bold yellow]SIGS[/bold yellow]",
293
+ FidelityLevel.TREE_ONLY: "[dim]TREE[/dim]",
294
+ }
295
+
296
+ ranked = sorted(result.files, key=lambda f: (-f.pagerank_score, f.rel_path))
297
+ for file_obj in ranked[:5]:
298
+ top_table.add_row(
299
+ file_obj.rel_path,
300
+ badge_styles[file_obj.selected_fidelity],
301
+ f"{file_obj.pagerank_score * 100.0:.1f}%",
302
+ f"{file_obj.selected_tokens:,} / {file_obj.raw_tokens:,}",
303
+ )
304
+ err_console.print(top_table)
305
+
306
+
307
+ def _copy_to_clipboard(text: str) -> bool:
308
+ """Copy `text` to the system clipboard using native OS utilities."""
309
+ commands: list[list[str]] = [
310
+ ["pbcopy"],
311
+ ["wl-copy"],
312
+ ["xclip", "-selection", "clipboard"],
313
+ ["xsel", "--clipboard", "--input"],
314
+ ["clip"],
315
+ ]
316
+ for cmd in commands:
317
+ executable = shutil.which(cmd[0])
318
+ if executable is None:
319
+ continue
320
+ try:
321
+ subprocess.run(
322
+ [executable, *cmd[1:]],
323
+ input=text.encode("utf-8"),
324
+ check=True,
325
+ stdout=subprocess.DEVNULL,
326
+ stderr=subprocess.DEVNULL,
327
+ timeout=3,
328
+ )
329
+ return True
330
+ except Exception:
331
+ continue
332
+ return False
333
+
334
+
335
+ if __name__ == "__main__": # pragma: no cover
336
+ app()