codeledger 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.
- codeledger/__init__.py +4 -0
- codeledger/__main__.py +6 -0
- codeledger/classifier/__init__.py +21 -0
- codeledger/classifier/deferred.py +143 -0
- codeledger/classifier/rules.py +116 -0
- codeledger/classifier/session.py +60 -0
- codeledger/classifier/slm.py +19 -0
- codeledger/cli.py +510 -0
- codeledger/compressor/__init__.py +15 -0
- codeledger/compressor/scope_engine.py +73 -0
- codeledger/compressor/token_compressor.py +138 -0
- codeledger/config/__init__.py +19 -0
- codeledger/config/loader.py +141 -0
- codeledger/config/presets/cli_tool.yaml +62 -0
- codeledger/config/presets/data_pipeline.yaml +67 -0
- codeledger/config/presets/fullstack.yaml +80 -0
- codeledger/config/presets/minimal.yaml +48 -0
- codeledger/config/presets/ml_research.yaml +73 -0
- codeledger/config/presets/python_api.yaml +86 -0
- codeledger/config/presets/react_frontend.yaml +70 -0
- codeledger/config/schema.py +212 -0
- codeledger/generator/__init__.py +15 -0
- codeledger/generator/api_client.py +104 -0
- codeledger/generator/local_client.py +72 -0
- codeledger/generator/model_router.py +61 -0
- codeledger/generator/prompt_builder.py +125 -0
- codeledger/merge/__init__.py +15 -0
- codeledger/merge/deduplicator.py +100 -0
- codeledger/merge/extractor.py +131 -0
- codeledger/merge/merge_engine.py +127 -0
- codeledger/models/__init__.py +3 -0
- codeledger/models/inference.py +0 -0
- codeledger/parser/__init__.py +39 -0
- codeledger/parser/base.py +136 -0
- codeledger/parser/fallback.py +122 -0
- codeledger/parser/java_parser.py +10 -0
- codeledger/parser/js_parser.py +10 -0
- codeledger/parser/python_parser.py +239 -0
- codeledger/postprocess/__init__.py +23 -0
- codeledger/postprocess/file_manager.py +183 -0
- codeledger/postprocess/formatter.py +114 -0
- codeledger/postprocess/validator.py +130 -0
- codeledger/scanner/__init__.py +30 -0
- codeledger/scanner/change_dag.py +228 -0
- codeledger/scanner/dependency.py +124 -0
- codeledger/scanner/file_scanner.py +207 -0
- codeledger/scanner/snapshot.py +278 -0
- codeledger/templates/doc_template.md.j2 +23 -0
- codeledger/templates/merge_template.md.j2 +17 -0
- codeledger/templates/prompt_templates/merge.txt +35 -0
- codeledger/templates/prompt_templates/micro.txt +20 -0
- codeledger/templates/prompt_templates/refactor.txt +30 -0
- codeledger/templates/prompt_templates/standard.txt +19 -0
- codeledger-0.1.0.dist-info/METADATA +296 -0
- codeledger-0.1.0.dist-info/RECORD +58 -0
- codeledger-0.1.0.dist-info/WHEEL +4 -0
- codeledger-0.1.0.dist-info/entry_points.txt +2 -0
- codeledger-0.1.0.dist-info/licenses/LICENSE +21 -0
codeledger/cli.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""CLI — Typer-based command-line interface for CodeLedger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
import codeledger
|
|
14
|
+
from codeledger.config.loader import (
|
|
15
|
+
init_project,
|
|
16
|
+
list_presets,
|
|
17
|
+
load_config,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
name="codeledger",
|
|
22
|
+
help="Auto-generated code comprehension for AI-assisted development.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_root(project_dir: Optional[Path]) -> Path:
|
|
29
|
+
"""Resolve the project root directory."""
|
|
30
|
+
if project_dir is None:
|
|
31
|
+
return Path.cwd()
|
|
32
|
+
return project_dir.resolve()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── init ──
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command()
|
|
39
|
+
def init(
|
|
40
|
+
project_dir: Optional[Path] = typer.Argument(
|
|
41
|
+
None, help="Project root directory (defaults to current directory)."
|
|
42
|
+
),
|
|
43
|
+
preset: Optional[str] = typer.Option(
|
|
44
|
+
None, "--preset", "-p", help="Configuration preset to use."
|
|
45
|
+
),
|
|
46
|
+
name: Optional[str] = typer.Option(
|
|
47
|
+
None, "--name", "-n", help="Project name."
|
|
48
|
+
),
|
|
49
|
+
language: Optional[str] = typer.Option(
|
|
50
|
+
None, "--lang", "-l", help="Primary language (python, javascript, etc.)."
|
|
51
|
+
),
|
|
52
|
+
list_all_presets: bool = typer.Option(
|
|
53
|
+
False, "--list-presets", help="List available presets and exit."
|
|
54
|
+
),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Initialize CodeLedger for a project."""
|
|
57
|
+
if list_all_presets:
|
|
58
|
+
presets = list_presets()
|
|
59
|
+
console.print("[bold]Available presets:[/bold]")
|
|
60
|
+
for p in presets:
|
|
61
|
+
console.print(f" - {p}")
|
|
62
|
+
raise typer.Exit()
|
|
63
|
+
|
|
64
|
+
root = _resolve_root(project_dir)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
config = init_project(
|
|
68
|
+
project_root=root,
|
|
69
|
+
preset=preset,
|
|
70
|
+
project_name=name,
|
|
71
|
+
language=language,
|
|
72
|
+
)
|
|
73
|
+
console.print(
|
|
74
|
+
Panel(
|
|
75
|
+
f"[green]Initialized CodeLedger[/green] for [bold]{config.project.name}[/bold]\n"
|
|
76
|
+
f"Config: {root / '.codeledger' / 'config.yaml'}\n"
|
|
77
|
+
f"Preset: {preset or 'default'}",
|
|
78
|
+
title="codeledger init",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
except FileExistsError as e:
|
|
82
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
83
|
+
raise typer.Exit(code=1)
|
|
84
|
+
except ValueError as e:
|
|
85
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
86
|
+
raise typer.Exit(code=1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── generate ──
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command()
|
|
93
|
+
def generate(
|
|
94
|
+
project_dir: Optional[Path] = typer.Argument(
|
|
95
|
+
None, help="Project root directory."
|
|
96
|
+
),
|
|
97
|
+
force: bool = typer.Option(
|
|
98
|
+
False, "--force", "-f", help="Force generation even if no changes detected."
|
|
99
|
+
),
|
|
100
|
+
dry_run: bool = typer.Option(
|
|
101
|
+
False, "--dry-run", help="Show what would be generated without calling the model."
|
|
102
|
+
),
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Generate documentation based on current project state."""
|
|
105
|
+
root = _resolve_root(project_dir)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
config = load_config(root)
|
|
109
|
+
except FileNotFoundError as e:
|
|
110
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
111
|
+
raise typer.Exit(code=1)
|
|
112
|
+
|
|
113
|
+
with console.status("[bold blue]Scanning project...[/bold blue]"):
|
|
114
|
+
# 1. Scan files
|
|
115
|
+
from codeledger.scanner import (
|
|
116
|
+
ProjectDAG,
|
|
117
|
+
compare_snapshots,
|
|
118
|
+
create_snapshot,
|
|
119
|
+
load_latest_snapshot,
|
|
120
|
+
save_snapshot,
|
|
121
|
+
scan_project,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
manifest = scan_project(
|
|
125
|
+
root,
|
|
126
|
+
include_patterns=config.focus.include_patterns,
|
|
127
|
+
exclude_patterns=config.focus.exclude_patterns,
|
|
128
|
+
)
|
|
129
|
+
console.print(
|
|
130
|
+
f" Scanned [bold]{manifest.total_files}[/bold] files "
|
|
131
|
+
f"({manifest.total_lines} lines)"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# 2. Snapshot comparison
|
|
135
|
+
old_snapshot = load_latest_snapshot(root)
|
|
136
|
+
new_snapshot = create_snapshot(manifest)
|
|
137
|
+
|
|
138
|
+
if old_snapshot:
|
|
139
|
+
diff = compare_snapshots(old_snapshot, new_snapshot)
|
|
140
|
+
if diff.is_empty and not force:
|
|
141
|
+
console.print("[yellow]No changes detected.[/yellow] Use --force to generate anyway.")
|
|
142
|
+
raise typer.Exit()
|
|
143
|
+
console.print(
|
|
144
|
+
f" Changes: [green]+{diff.files_created}[/green] created, "
|
|
145
|
+
f"[yellow]~{diff.files_modified}[/yellow] modified, "
|
|
146
|
+
f"[red]-{diff.files_deleted}[/red] deleted"
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
diff = None
|
|
150
|
+
console.print(" [dim]First scan — no previous snapshot[/dim]")
|
|
151
|
+
|
|
152
|
+
# 3. Build Change DAG
|
|
153
|
+
dag = ProjectDAG()
|
|
154
|
+
dag.build(manifest, root)
|
|
155
|
+
if diff:
|
|
156
|
+
subgraph = dag.extract_subgraph(diff)
|
|
157
|
+
else:
|
|
158
|
+
subgraph = None
|
|
159
|
+
|
|
160
|
+
# 4. Classify session
|
|
161
|
+
from codeledger.classifier import SessionType, classify_session
|
|
162
|
+
|
|
163
|
+
if subgraph and not subgraph.is_empty:
|
|
164
|
+
metrics = subgraph.metrics()
|
|
165
|
+
classification = classify_session(metrics)
|
|
166
|
+
else:
|
|
167
|
+
# First run or forced — treat as STANDARD
|
|
168
|
+
from codeledger.classifier.session import SessionClassification
|
|
169
|
+
|
|
170
|
+
classification = SessionClassification(
|
|
171
|
+
session_type=SessionType.STANDARD,
|
|
172
|
+
confidence=1.0,
|
|
173
|
+
input_token_budget=config.model.max_input_tokens,
|
|
174
|
+
output_token_budget=config.model.max_output_tokens,
|
|
175
|
+
reason="Initial scan or forced generation",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
console.print(
|
|
179
|
+
f" Session: [bold]{classification.session_type.value}[/bold] "
|
|
180
|
+
f"(confidence: {classification.confidence:.0%})"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# 5. Handle deferred sessions
|
|
184
|
+
if classification.should_defer and not force:
|
|
185
|
+
from codeledger.classifier.deferred import load_pending, save_pending
|
|
186
|
+
|
|
187
|
+
pending = load_pending(root)
|
|
188
|
+
if subgraph and not subgraph.is_empty:
|
|
189
|
+
pending.add_session(
|
|
190
|
+
changed_paths=diff.changed_paths() if diff else [],
|
|
191
|
+
metrics=subgraph.metrics(),
|
|
192
|
+
summary=f"Deferred: {classification.session_type.value}",
|
|
193
|
+
)
|
|
194
|
+
save_pending(root, pending)
|
|
195
|
+
console.print("[dim]Trivial changes deferred. Use --force to generate anyway.[/dim]")
|
|
196
|
+
# Save snapshot even though we deferred
|
|
197
|
+
save_snapshot(root, new_snapshot)
|
|
198
|
+
raise typer.Exit()
|
|
199
|
+
|
|
200
|
+
if dry_run:
|
|
201
|
+
console.print(Panel(
|
|
202
|
+
f"Session type: {classification.session_type.value}\n"
|
|
203
|
+
f"Input budget: {classification.input_token_budget} tokens\n"
|
|
204
|
+
f"Output budget: {classification.output_token_budget} tokens\n"
|
|
205
|
+
f"Files to analyze: {manifest.total_files}\n"
|
|
206
|
+
f"Model: {config.model.model_name}",
|
|
207
|
+
title="Dry Run",
|
|
208
|
+
))
|
|
209
|
+
raise typer.Exit()
|
|
210
|
+
|
|
211
|
+
# 6. Parse and compress
|
|
212
|
+
with console.status("[bold blue]Analyzing code...[/bold blue]"):
|
|
213
|
+
from codeledger.parser import parse_file
|
|
214
|
+
from codeledger.compressor import compress_project, trim_to_budget
|
|
215
|
+
|
|
216
|
+
parsed_files = []
|
|
217
|
+
for fi in manifest.code_files():
|
|
218
|
+
try:
|
|
219
|
+
parsed = parse_file(fi.absolute_path, fi.language or "")
|
|
220
|
+
parsed_files.append(parsed)
|
|
221
|
+
except Exception:
|
|
222
|
+
pass # Skip unparseable files
|
|
223
|
+
|
|
224
|
+
compressed = compress_project(parsed_files)
|
|
225
|
+
trimmed_files, trimmed_sections = trim_to_budget(
|
|
226
|
+
compressed,
|
|
227
|
+
config.template_sections,
|
|
228
|
+
classification.input_token_budget,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# 7. Build prompt and generate
|
|
232
|
+
from codeledger.generator import build_generation_prompt, generate as gen_call
|
|
233
|
+
|
|
234
|
+
system_prompt, user_prompt = build_generation_prompt(
|
|
235
|
+
compressed_payload=trimmed_files,
|
|
236
|
+
sections=trimmed_sections,
|
|
237
|
+
session_type=classification.session_type,
|
|
238
|
+
project_name=config.project.name,
|
|
239
|
+
focus_highlights=config.focus.highlight or None,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
with console.status(f"[bold blue]Generating with {config.model.model_name}...[/bold blue]"):
|
|
243
|
+
response = gen_call(
|
|
244
|
+
system_prompt=system_prompt,
|
|
245
|
+
user_prompt=user_prompt,
|
|
246
|
+
config=config.model,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
console.print(
|
|
250
|
+
f" Generated {response.output_tokens} tokens in {response.latency_ms / 1000:.1f}s"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# 8. Validate
|
|
254
|
+
from codeledger.postprocess import ValidationResult, validate_output
|
|
255
|
+
|
|
256
|
+
known_paths = {fi.path for fi in manifest.files}
|
|
257
|
+
section_names = [s.name for s in config.template_sections]
|
|
258
|
+
validation = validate_output(response.content, section_names, known_paths)
|
|
259
|
+
|
|
260
|
+
if validation.has_warnings:
|
|
261
|
+
for w in validation.warnings:
|
|
262
|
+
icon = "[red]✗[/red]" if w.severity == "error" else "[yellow]![/yellow]"
|
|
263
|
+
console.print(f" {icon} {w.message}")
|
|
264
|
+
|
|
265
|
+
# 9. Format and save
|
|
266
|
+
from codeledger.postprocess import format_doc, load_manifest, save_doc
|
|
267
|
+
|
|
268
|
+
doc_manifest = load_manifest(root)
|
|
269
|
+
doc_id = doc_manifest.next_doc_id()
|
|
270
|
+
|
|
271
|
+
formatted = format_doc(
|
|
272
|
+
content=response.content,
|
|
273
|
+
doc_id=doc_id,
|
|
274
|
+
project_name=config.project.name,
|
|
275
|
+
model_name=config.model.model_name,
|
|
276
|
+
session_type=classification.session_type.value,
|
|
277
|
+
files_analyzed=len(parsed_files),
|
|
278
|
+
input_tokens=response.input_tokens,
|
|
279
|
+
output_tokens=response.output_tokens,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
doc_path = save_doc(
|
|
283
|
+
project_root=root,
|
|
284
|
+
doc_id=doc_id,
|
|
285
|
+
content=formatted,
|
|
286
|
+
session_type=classification.session_type.value,
|
|
287
|
+
model=config.model.model_name,
|
|
288
|
+
files_analyzed=len(parsed_files),
|
|
289
|
+
manifest=doc_manifest,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# 10. Save new snapshot
|
|
293
|
+
new_snapshot.doc_id = doc_id
|
|
294
|
+
save_snapshot(root, new_snapshot)
|
|
295
|
+
|
|
296
|
+
console.print(Panel(
|
|
297
|
+
f"[green]Documentation generated:[/green] {doc_path.relative_to(root)}\n"
|
|
298
|
+
f"Doc ID: [bold]{doc_id}[/bold]",
|
|
299
|
+
title="codeledger generate",
|
|
300
|
+
))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ── merge ──
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@app.command()
|
|
307
|
+
def merge(
|
|
308
|
+
project_dir: Optional[Path] = typer.Argument(
|
|
309
|
+
None, help="Project root directory."
|
|
310
|
+
),
|
|
311
|
+
local_only: bool = typer.Option(
|
|
312
|
+
False, "--local", help="Merge locally without calling an LLM."
|
|
313
|
+
),
|
|
314
|
+
output: Optional[Path] = typer.Option(
|
|
315
|
+
None, "--output", "-o", help="Output file path for merged doc."
|
|
316
|
+
),
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Merge all generated docs into a single conceptualized document."""
|
|
319
|
+
root = _resolve_root(project_dir)
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
config = load_config(root)
|
|
323
|
+
except FileNotFoundError as e:
|
|
324
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
325
|
+
raise typer.Exit(code=1)
|
|
326
|
+
|
|
327
|
+
from codeledger.merge import merge_local as ml, merge_with_llm
|
|
328
|
+
|
|
329
|
+
with console.status("[bold blue]Merging documentation...[/bold blue]"):
|
|
330
|
+
if local_only:
|
|
331
|
+
content = ml(root)
|
|
332
|
+
else:
|
|
333
|
+
content = merge_with_llm(root, config)
|
|
334
|
+
|
|
335
|
+
# Write output
|
|
336
|
+
if output:
|
|
337
|
+
out_path = output.resolve()
|
|
338
|
+
else:
|
|
339
|
+
out_path = root / ".codeledger" / "DOCUMENTATION.md"
|
|
340
|
+
|
|
341
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
out_path.write_text(content, encoding="utf-8")
|
|
343
|
+
|
|
344
|
+
console.print(Panel(
|
|
345
|
+
f"[green]Merged documentation written to:[/green] {out_path}",
|
|
346
|
+
title="codeledger merge",
|
|
347
|
+
))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ── status ──
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@app.command()
|
|
354
|
+
def status(
|
|
355
|
+
project_dir: Optional[Path] = typer.Argument(
|
|
356
|
+
None, help="Project root directory."
|
|
357
|
+
),
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Show the current CodeLedger status for a project."""
|
|
360
|
+
root = _resolve_root(project_dir)
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
config = load_config(root)
|
|
364
|
+
except FileNotFoundError as e:
|
|
365
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
366
|
+
raise typer.Exit(code=1)
|
|
367
|
+
|
|
368
|
+
from codeledger.postprocess.file_manager import load_manifest
|
|
369
|
+
|
|
370
|
+
manifest = load_manifest(root)
|
|
371
|
+
|
|
372
|
+
table = Table(title=f"CodeLedger Status — {config.project.name}")
|
|
373
|
+
table.add_column("Property", style="bold")
|
|
374
|
+
table.add_column("Value")
|
|
375
|
+
|
|
376
|
+
table.add_row("Project", config.project.name)
|
|
377
|
+
table.add_row("Language", config.project.language)
|
|
378
|
+
table.add_row("Model", config.model.model_name)
|
|
379
|
+
table.add_row("Docs generated", str(manifest.total_docs))
|
|
380
|
+
table.add_row("Last doc ID", manifest.last_doc_id or "—")
|
|
381
|
+
table.add_row("Merge state", manifest.merge_state)
|
|
382
|
+
table.add_row("Cadence (N)", str(config.cadence.n_value))
|
|
383
|
+
table.add_row("Trigger", config.cadence.trigger.value)
|
|
384
|
+
|
|
385
|
+
console.print(table)
|
|
386
|
+
|
|
387
|
+
if manifest.docs:
|
|
388
|
+
doc_table = Table(title="Generated Documents")
|
|
389
|
+
doc_table.add_column("ID", style="cyan")
|
|
390
|
+
doc_table.add_column("Session")
|
|
391
|
+
doc_table.add_column("Model")
|
|
392
|
+
doc_table.add_column("Files")
|
|
393
|
+
doc_table.add_column("Timestamp")
|
|
394
|
+
|
|
395
|
+
for doc in manifest.docs:
|
|
396
|
+
doc_table.add_row(
|
|
397
|
+
doc.doc_id,
|
|
398
|
+
doc.session_type,
|
|
399
|
+
doc.model,
|
|
400
|
+
str(doc.files_analyzed),
|
|
401
|
+
doc.timestamp[:19],
|
|
402
|
+
)
|
|
403
|
+
console.print(doc_table)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ── diff ──
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@app.command()
|
|
410
|
+
def diff(
|
|
411
|
+
project_dir: Optional[Path] = typer.Argument(
|
|
412
|
+
None, help="Project root directory."
|
|
413
|
+
),
|
|
414
|
+
) -> None:
|
|
415
|
+
"""Show changes since the last snapshot."""
|
|
416
|
+
root = _resolve_root(project_dir)
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
config = load_config(root)
|
|
420
|
+
except FileNotFoundError as e:
|
|
421
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
422
|
+
raise typer.Exit(code=1)
|
|
423
|
+
|
|
424
|
+
from codeledger.scanner import (
|
|
425
|
+
compare_snapshots,
|
|
426
|
+
create_snapshot,
|
|
427
|
+
load_latest_snapshot,
|
|
428
|
+
scan_project,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
manifest = scan_project(root, config.focus)
|
|
432
|
+
old_snapshot = load_latest_snapshot(root)
|
|
433
|
+
|
|
434
|
+
if not old_snapshot:
|
|
435
|
+
console.print("[yellow]No previous snapshot found.[/yellow] Run 'codeledger generate' first.")
|
|
436
|
+
raise typer.Exit()
|
|
437
|
+
|
|
438
|
+
new_snapshot = create_snapshot(manifest)
|
|
439
|
+
snap_diff = compare_snapshots(old_snapshot, new_snapshot)
|
|
440
|
+
|
|
441
|
+
if snap_diff.is_empty:
|
|
442
|
+
console.print("[green]No changes detected since last snapshot.[/green]")
|
|
443
|
+
raise typer.Exit()
|
|
444
|
+
|
|
445
|
+
table = Table(title="Changes Since Last Snapshot")
|
|
446
|
+
table.add_column("Status", width=10)
|
|
447
|
+
table.add_column("File")
|
|
448
|
+
table.add_column("Lines Δ", justify="right")
|
|
449
|
+
|
|
450
|
+
for change in snap_diff.changes:
|
|
451
|
+
if change.change_type == "created":
|
|
452
|
+
status_str = "[green]+ created[/green]"
|
|
453
|
+
elif change.change_type == "modified":
|
|
454
|
+
status_str = "[yellow]~ modified[/yellow]"
|
|
455
|
+
else:
|
|
456
|
+
status_str = "[red]- deleted[/red]"
|
|
457
|
+
|
|
458
|
+
delta = change.lines_delta
|
|
459
|
+
delta_str = f"+{delta}" if delta > 0 else str(delta)
|
|
460
|
+
|
|
461
|
+
table.add_row(status_str, change.path, delta_str)
|
|
462
|
+
|
|
463
|
+
console.print(table)
|
|
464
|
+
console.print(
|
|
465
|
+
f"\nTotal: [green]+{snap_diff.files_created}[/green] "
|
|
466
|
+
f"[yellow]~{snap_diff.files_modified}[/yellow] "
|
|
467
|
+
f"[red]-{snap_diff.files_deleted}[/red]"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ── version ──
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@app.command()
|
|
475
|
+
def version() -> None:
|
|
476
|
+
"""Show the CodeLedger version."""
|
|
477
|
+
console.print(f"codeledger {codeledger.__version__}")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ── explain ──
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@app.command()
|
|
484
|
+
def explain(
|
|
485
|
+
doc_id: str = typer.Argument(..., help="Document ID to explain (e.g., pd_001)."),
|
|
486
|
+
project_dir: Optional[Path] = typer.Option(
|
|
487
|
+
None, "--project", "-p", help="Project root directory."
|
|
488
|
+
),
|
|
489
|
+
) -> None:
|
|
490
|
+
"""Display a generated document by its ID."""
|
|
491
|
+
root = _resolve_root(project_dir)
|
|
492
|
+
|
|
493
|
+
from codeledger.postprocess.file_manager import load_manifest
|
|
494
|
+
|
|
495
|
+
manifest = load_manifest(root)
|
|
496
|
+
record = manifest.get_doc(doc_id)
|
|
497
|
+
|
|
498
|
+
if not record:
|
|
499
|
+
console.print(f"[red]Error:[/red] Document '{doc_id}' not found.")
|
|
500
|
+
raise typer.Exit(code=1)
|
|
501
|
+
|
|
502
|
+
doc_path = root / record.path
|
|
503
|
+
if not doc_path.exists():
|
|
504
|
+
console.print(f"[red]Error:[/red] Document file missing: {record.path}")
|
|
505
|
+
raise typer.Exit(code=1)
|
|
506
|
+
|
|
507
|
+
content = doc_path.read_text(encoding="utf-8")
|
|
508
|
+
from rich.markdown import Markdown
|
|
509
|
+
|
|
510
|
+
console.print(Markdown(content))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Compressor package — token compression and scope management."""
|
|
2
|
+
|
|
3
|
+
from codeledger.compressor.token_compressor import (
|
|
4
|
+
compress_file,
|
|
5
|
+
compress_project,
|
|
6
|
+
estimate_tokens,
|
|
7
|
+
)
|
|
8
|
+
from codeledger.compressor.scope_engine import trim_to_budget
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"compress_file",
|
|
12
|
+
"compress_project",
|
|
13
|
+
"estimate_tokens",
|
|
14
|
+
"trim_to_budget",
|
|
15
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Scope engine — trims payload and template sections to fit within token budget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from codeledger.compressor.token_compressor import estimate_tokens
|
|
6
|
+
from codeledger.config.schema import TemplateSectionConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def trim_to_budget(
|
|
10
|
+
compressed_files: list[dict],
|
|
11
|
+
sections: list[TemplateSectionConfig],
|
|
12
|
+
input_token_budget: int,
|
|
13
|
+
) -> tuple[list[dict], list[TemplateSectionConfig]]:
|
|
14
|
+
"""Trim payload and sections to fit within the input token budget.
|
|
15
|
+
|
|
16
|
+
Strategy (applied in order):
|
|
17
|
+
1. Drop affected-only files (keep changed files)
|
|
18
|
+
2. Drop priority 3 sections
|
|
19
|
+
3. Drop priority 2 sections
|
|
20
|
+
4. Truncate individual file descriptions (remove docstrings, globals)
|
|
21
|
+
5. Drop files with fewest changes
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Tuple of (trimmed_files, trimmed_sections).
|
|
25
|
+
"""
|
|
26
|
+
current_tokens = estimate_tokens(compressed_files)
|
|
27
|
+
|
|
28
|
+
# Estimate section overhead (template text per section)
|
|
29
|
+
section_overhead_per = 50 # ~50 tokens per section in prompt template
|
|
30
|
+
section_tokens = len(sections) * section_overhead_per
|
|
31
|
+
total = current_tokens + section_tokens
|
|
32
|
+
|
|
33
|
+
if total <= input_token_budget:
|
|
34
|
+
return compressed_files, sections
|
|
35
|
+
|
|
36
|
+
# Step 1: Drop priority 3 sections
|
|
37
|
+
trimmed_sections = [s for s in sections if s.priority <= 2]
|
|
38
|
+
section_tokens = len(trimmed_sections) * section_overhead_per
|
|
39
|
+
total = current_tokens + section_tokens
|
|
40
|
+
|
|
41
|
+
if total <= input_token_budget:
|
|
42
|
+
return compressed_files, trimmed_sections
|
|
43
|
+
|
|
44
|
+
# Step 2: Drop priority 2 sections
|
|
45
|
+
trimmed_sections = [s for s in trimmed_sections if s.priority <= 1]
|
|
46
|
+
section_tokens = len(trimmed_sections) * section_overhead_per
|
|
47
|
+
total = current_tokens + section_tokens
|
|
48
|
+
|
|
49
|
+
if total <= input_token_budget:
|
|
50
|
+
return compressed_files, trimmed_sections
|
|
51
|
+
|
|
52
|
+
# Step 3: Strip verbose fields from files
|
|
53
|
+
for f in compressed_files:
|
|
54
|
+
f.pop("module_doc", None)
|
|
55
|
+
f.pop("globals", None)
|
|
56
|
+
for cls in f.get("classes", []):
|
|
57
|
+
cls.pop("doc", None)
|
|
58
|
+
for func in f.get("functions", []):
|
|
59
|
+
func.pop("doc", None)
|
|
60
|
+
|
|
61
|
+
current_tokens = estimate_tokens(compressed_files)
|
|
62
|
+
total = current_tokens + section_tokens
|
|
63
|
+
|
|
64
|
+
if total <= input_token_budget:
|
|
65
|
+
return compressed_files, trimmed_sections
|
|
66
|
+
|
|
67
|
+
# Step 4: Drop files from the end (least important) until within budget
|
|
68
|
+
while compressed_files and total > input_token_budget:
|
|
69
|
+
compressed_files.pop()
|
|
70
|
+
current_tokens = estimate_tokens(compressed_files)
|
|
71
|
+
total = current_tokens + section_tokens
|
|
72
|
+
|
|
73
|
+
return compressed_files, trimmed_sections
|