Skip to content

Commit b5e9de0

Browse files
authored
Merge pull request #111 from FSoft-AI4Code/feat/component-level-updater
Feat/component level updater
2 parents a089266 + f4486a2 commit b5e9de0

34 files changed

Lines changed: 4458 additions & 54 deletions

‎.gitignore‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,10 @@ Thumbs.db
8080
*.log
8181
*.bak
8282
.codewiki/
83+
!tests/updater_toy.py
84+
!tests/test_updater_graph_diff.py
85+
!tests/test_updater_tree_repair.py
86+
!tests/test_updater_reference_index.py
87+
!tests/test_updater_change_report.py
88+
!tests/test_updater_write_guard.py
89+
!tests/test_updater_orchestrator.py

‎codewiki/cli/adapters/doc_generator.py‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,17 @@ async def _run_backend_generation(self, backend_config: BackendConfig):
186186
# Create documentation generator
187187
doc_generator = DocumentationGenerator(backend_config, commit_id=self.commit_id)
188188

189+
# Incremental update (component-level): keep the previous graph before
190+
# the builder overwrites it, so the updater can diff old against new.
191+
update_opts = self._update_options()
192+
prev_graph_path = None
193+
if update_opts is not None:
194+
from codewiki.src.be.updater.graph_store import snapshot_old_graph
195+
196+
prev_graph_path = snapshot_old_graph(
197+
backend_config.dependency_graph_dir, str(self.repo_path)
198+
)
199+
189200
if self.verbose:
190201
self.progress_tracker.update_stage(0.5, "Parsing source files...")
191202

@@ -211,6 +222,27 @@ async def _run_backend_generation(self, backend_config: BackendConfig):
211222

212223
self.progress_tracker.complete_stage()
213224

225+
if update_opts is not None:
226+
outcome = await self._run_incremental_update(
227+
backend_config, doc_generator, update_opts, prev_graph_path, components, leaf_nodes
228+
)
229+
if outcome in ("incremental", "no_change"):
230+
# The builder wrote the new graph under this checkout's name. Drop graphs
231+
# left by earlier checkouts so the next update has exactly one to pick.
232+
from codewiki.src.be.updater.graph_store import (
233+
graph_file_path,
234+
prune_superseded_graphs,
235+
)
236+
237+
prune_superseded_graphs(
238+
backend_config.dependency_graph_dir,
239+
keep=graph_file_path(backend_config.dependency_graph_dir, str(self.repo_path)),
240+
)
241+
return
242+
# full_fallback / detector_failure: preserve the old docs, rebuild from scratch
243+
self._move_docs_aside()
244+
components, leaf_nodes = doc_generator.graph_builder.build_dependency_graph()
245+
214246
# Stage 2: Module Clustering
215247
self.progress_tracker.start_stage(2, "Module Clustering")
216248
if self.verbose:
@@ -334,6 +366,7 @@ async def _run_backend_generation(self, backend_config: BackendConfig):
334366

335367
# Create metadata
336368
doc_generator.create_documentation_metadata(working_dir, components, len(leaf_nodes))
369+
self._merge_update_summary(working_dir)
337370

338371
# Collect generated files
339372
for file_path in os.listdir(working_dir):
@@ -356,6 +389,111 @@ async def _run_backend_generation(self, backend_config: BackendConfig):
356389

357390
self.progress_tracker.complete_stage()
358391

392+
# ------------------------------------------------------------------
393+
# Incremental update helpers
394+
# ------------------------------------------------------------------
395+
396+
def _update_options(self):
397+
"""Return ``UpdateOptions`` when this run is a component-level update, else None."""
398+
if not self.config.get("update"):
399+
return None
400+
raw = dict(self.config.get("update_options") or {})
401+
rung = str(raw.pop("rung", "3") or "3")
402+
if rung == "0":
403+
return None # legacy path handled in generate.py
404+
if not (self.output_dir / "module_tree.json").exists():
405+
return None # nothing to update against: normal generation
406+
from codewiki.src.be.updater.options import UpdateOptions
407+
408+
return UpdateOptions.from_rung(rung, **raw)
409+
410+
async def _run_incremental_update(
411+
self, backend_config, doc_generator, update_opts, prev_graph_path, components, leaf_nodes
412+
) -> str:
413+
import json
414+
415+
from codewiki.src.be.updater.orchestrator import IncrementalUpdater
416+
417+
self.progress_tracker.start_stage(2, "Incremental Update")
418+
prev_commit = None
419+
try:
420+
with open(self.output_dir / "metadata.json", encoding="utf-8") as f:
421+
prev_commit = (json.load(f).get("generation_info") or {}).get("commit_id")
422+
except (OSError, json.JSONDecodeError, AttributeError):
423+
pass
424+
revision = {
425+
"old_commit": prev_commit,
426+
"new_commit": self.commit_id,
427+
"repo_path": str(self.repo_path),
428+
}
429+
updater = IncrementalUpdater(
430+
backend_config, doc_generator.backend, doc_generator, update_opts
431+
)
432+
record = await updater.run(prev_graph_path, components, leaf_nodes, revision)
433+
self._last_update_record = record
434+
summary = record.summary()
435+
if self.verbose:
436+
self.progress_tracker.update_stage(
437+
0.9,
438+
f"Update outcome: {record.outcome} (diff {summary['diff_counts']}, "
439+
f"{summary['n_active']} active leaves, {summary['n_calls']} LLM calls)",
440+
)
441+
working_dir = str(self.output_dir.absolute())
442+
if record.outcome in ("incremental", "no_change"):
443+
doc_generator.create_documentation_metadata(working_dir, components, len(leaf_nodes))
444+
self._merge_update_summary(working_dir)
445+
for file_path in os.listdir(working_dir):
446+
if file_path.endswith((".md", ".json")):
447+
self.job.files_generated.append(file_path)
448+
tree_path = os.path.join(working_dir, "module_tree.json")
449+
if os.path.exists(tree_path):
450+
with open(tree_path, encoding="utf-8") as f:
451+
self.job.module_count = len(json.load(f))
452+
missing_docs = doc_generator.validate_generated_docs(working_dir)
453+
if missing_docs:
454+
raise IncompleteGenerationError(
455+
"Incremental update finished but these module docs are missing: "
456+
+ ", ".join(f"{name}.md" for name in missing_docs),
457+
missing_modules=missing_docs,
458+
)
459+
self.progress_tracker.complete_stage()
460+
return record.outcome
461+
462+
def _move_docs_aside(self) -> None:
463+
"""Keep the previous docs (and the failed attempt's record) next to the output dir."""
464+
import shutil
465+
466+
suffix = (self.commit_id or "unknown")[:8]
467+
target = self.output_dir.parent / f"{self.output_dir.name}.prev-{suffix}"
468+
n = 2
469+
while target.exists():
470+
target = self.output_dir.parent / f"{self.output_dir.name}.prev-{suffix}-{n}"
471+
n += 1
472+
shutil.move(str(self.output_dir), str(target))
473+
self.output_dir.mkdir(parents=True, exist_ok=True)
474+
self._preserved_docs_dir = str(target)
475+
record = getattr(self, "_last_update_record", None)
476+
if record is not None:
477+
record.detector_notes.append(f"previous docs moved to {target}")
478+
if self.verbose:
479+
self.progress_tracker.update_stage(0.1, f"Previous docs preserved at {target}")
480+
481+
def _merge_update_summary(self, working_dir: str) -> None:
482+
record = getattr(self, "_last_update_record", None)
483+
if record is None:
484+
return
485+
from codewiki.src.be.updater.record import merge_into_metadata
486+
487+
summary = record.summary()
488+
preserved = getattr(self, "_preserved_docs_dir", None)
489+
if preserved:
490+
summary["previous_docs"] = preserved
491+
merge_into_metadata(working_dir, summary)
492+
try:
493+
record.save(working_dir)
494+
except OSError:
495+
pass
496+
359497
def _run_html_generation(self):
360498
"""Run HTML generation stage."""
361499
self.progress_tracker.start_stage(4, "HTML Generation")

‎codewiki/cli/commands/generate.py‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,49 @@ def _find_affected(tree, parent_names=None):
330330
is_flag=True,
331331
help="Incremental update: only regenerate modules affected by changes since last generation",
332332
)
333+
@click.option(
334+
"--update-rung",
335+
type=click.Choice(["0", "1", "2", "3", "3b"]),
336+
default="3",
337+
show_default=True,
338+
help=(
339+
"Incremental updater variant used with --update. 0 = legacy file-level "
340+
"invalidation; 1-3 = component-level updater ablation rungs; 3b = rung 3 with 2 hops."
341+
),
342+
)
343+
@click.option(
344+
"--tau-ren", type=float, default=None, help="Rename similarity threshold (default 0.95)."
345+
)
346+
@click.option(
347+
"--tau-nb", type=float, default=None, help="Neighbour-majority routing share (default 0.5)."
348+
)
349+
@click.option(
350+
"--tau-grow",
351+
type=float,
352+
default=None,
353+
help="Leaf growth share that re-clusters (default 0.33).",
354+
)
355+
@click.option(
356+
"--tau-full",
357+
type=float,
358+
default=None,
359+
help="Active-leaf share that forces a full build (default 0.5).",
360+
)
361+
@click.option(
362+
"--tau-tree",
363+
type=float,
364+
default=None,
365+
help="Structural-change share that forces a full build (default 0.3).",
366+
)
367+
@click.option(
368+
"--k-hop", type=int, default=None, help="Dependency hops followed for Up (default 1)."
369+
)
370+
@click.option(
371+
"--max-diff-tokens",
372+
type=int,
373+
default=None,
374+
help="Cap on one component diff in a report (default 8000).",
375+
)
333376
@click.option(
334377
"--compare-to",
335378
type=str,
@@ -361,6 +404,14 @@ def generate_command(
361404
artifact_exclude: str | None = None,
362405
update: bool = False,
363406
compare_to: str | None = None,
407+
update_rung: str = "3",
408+
tau_ren: float | None = None,
409+
tau_nb: float | None = None,
410+
tau_grow: float | None = None,
411+
tau_full: float | None = None,
412+
tau_tree: float | None = None,
413+
k_hop: int | None = None,
414+
max_diff_tokens: int | None = None,
364415
):
365416
"""
366417
Generate comprehensive documentation for a code repository.
@@ -485,12 +536,23 @@ def generate_command(
485536
"No changes detected since last generation. Documentation is up to date."
486537
)
487538
sys.exit(EXIT_SUCCESS)
488-
if changed_files is not None:
539+
if changed_files is not None and update_rung == "0":
489540
logger.info(
490541
f" Detected {len(changed_files)} changed files — regenerating affected modules."
491542
)
492543
# Remove cached module docs for affected files so they get regenerated
493544
_invalidate_affected_modules(output_dir, changed_files, logger, verbose)
545+
elif update_rung != "0":
546+
if changed_files is not None:
547+
logger.info(
548+
f" Detected {len(changed_files)} changed files — running the "
549+
f"component-level updater (rung {update_rung})."
550+
)
551+
else:
552+
logger.info(
553+
f" Git diff unavailable — the component-level updater (rung {update_rung}) "
554+
f"will compare the saved dependency graph instead."
555+
)
494556

495557
# Check for existing documentation
496558
if (
@@ -665,6 +727,18 @@ def generate_command(
665727
"artifacts_enabled": artifacts,
666728
"artifact_token_budget": artifact_token_budget,
667729
"with_prose": with_prose,
730+
# Incremental updater (runtime-only)
731+
"update": update,
732+
"update_options": {
733+
"rung": update_rung,
734+
"tau_ren": tau_ren,
735+
"tau_nb": tau_nb,
736+
"tau_grow": tau_grow,
737+
"tau_full": tau_full,
738+
"tau_tree": tau_tree,
739+
"k_hop": k_hop,
740+
"max_diff_tokens": max_diff_tokens,
741+
},
668742
},
669743
verbose=verbose,
670744
generate_html=github_pages,

‎codewiki/src/be/agent_tools/deps.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from codewiki.src.be.dependency_analyzer.models.core import Node
33
from codewiki.src.config import Config
44

5+
56
@dataclass
67
class CodeWikiDeps:
78
absolute_docs_path: str
@@ -14,4 +15,8 @@ class CodeWikiDeps:
1415
max_depth: int
1516
current_depth: int
1617
config: Config # LLM configuration
17-
custom_instructions: str = None
18+
custom_instructions: str = None
19+
# Incremental updates: when set, ``str_replace_editor`` refuses any write
20+
# (create / str_replace / insert / undo_edit) to a docs file whose
21+
# absolute, resolved path is not in this set. ``None`` = unrestricted.
22+
allowed_write_paths: set[str] | None = None

0 commit comments

Comments
 (0)