Skip to content

Commit 3e3d848

Browse files
authored
Merge pull request #122 from FSoft-AI4Code/feat/ownership-closure
updater: ownership closure for untracked changed components, keep update history
2 parents 878bf38 + 1436182 commit 3e3d848

12 files changed

Lines changed: 495 additions & 31 deletions

File tree

‎codewiki/cli/adapters/doc_generator.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
import asyncio
9+
import json
910
import logging
1011
import os
1112
import sys
@@ -442,8 +443,11 @@ async def _run_incremental_update(
442443
)
443444
working_dir = str(self.output_dir.absolute())
444445
if record.outcome in ("incremental", "no_change"):
446+
# create_documentation_metadata rewrites metadata.json from scratch;
447+
# keep the history of earlier updates so a chain of updates accumulates.
448+
prior_history = self._read_update_history(working_dir)
445449
doc_generator.create_documentation_metadata(working_dir, components, len(leaf_nodes))
446-
self._merge_update_summary(working_dir)
450+
self._merge_update_summary(working_dir, prior_history)
447451
for file_path in os.listdir(working_dir):
448452
if file_path.endswith((".md", ".json")):
449453
self.job.files_generated.append(file_path)
@@ -480,7 +484,17 @@ def _move_docs_aside(self) -> None:
480484
if self.verbose:
481485
self.progress_tracker.update_stage(0.1, f"Previous docs preserved at {target}")
482486

483-
def _merge_update_summary(self, working_dir: str) -> None:
487+
@staticmethod
488+
def _read_update_history(working_dir: str) -> list:
489+
path = os.path.join(working_dir, "metadata.json")
490+
try:
491+
with open(path, encoding="utf-8") as f:
492+
history = (json.load(f) or {}).get("update_history")
493+
except (OSError, json.JSONDecodeError):
494+
return []
495+
return history if isinstance(history, list) else []
496+
497+
def _merge_update_summary(self, working_dir: str, prior_history: list | None = None) -> None:
484498
record = getattr(self, "_last_update_record", None)
485499
if record is None:
486500
return
@@ -490,7 +504,7 @@ def _merge_update_summary(self, working_dir: str) -> None:
490504
preserved = getattr(self, "_preserved_docs_dir", None)
491505
if preserved:
492506
summary["previous_docs"] = preserved
493-
merge_into_metadata(working_dir, summary)
507+
merge_into_metadata(working_dir, summary, prior_history=prior_history)
494508
try:
495509
record.save(working_dir)
496510
except OSError:

‎codewiki/cli/commands/generate.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,15 @@ def _find_affected(tree, parent_names=None):
386386
default=None,
387387
help="Cap on one component diff in a report (default 8000).",
388388
)
389+
@click.option(
390+
"--no-ownership-closure",
391+
is_flag=True,
392+
default=False,
393+
help=(
394+
"Do not give changed components that no module tracks an effective owner by the "
395+
"placement rules; such changes then reach no page (default: give one)."
396+
),
397+
)
389398
@click.option(
390399
"--compare-to",
391400
type=str,
@@ -427,6 +436,7 @@ def generate_command(
427436
tau_tree: float | None = None,
428437
k_hop: int | None = None,
429438
max_diff_tokens: int | None = None,
439+
no_ownership_closure: bool = False,
430440
):
431441
"""
432442
Generate comprehensive documentation for a code repository.
@@ -761,6 +771,7 @@ def generate_command(
761771
"tau_tree": tau_tree,
762772
"k_hop": k_hop,
763773
"max_diff_tokens": max_diff_tokens,
774+
"use_ownership_closure": False if no_ownership_closure else None,
764775
},
765776
},
766777
verbose=verbose,

‎codewiki/src/be/updater/change_report.py‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ class LeafReport:
2626
leaf_path: tuple[str, ...]
2727
mode: str = MODE_EDIT
2828
own: list[str] = field(default_factory=list)
29+
# Own entries this leaf does not list: changed components no leaf tracks,
30+
# given to this leaf as effective owner (Step 3a). id -> placement rule.
31+
adopted: dict[str, str] = field(default_factory=dict)
2932
up: list[str] = field(default_factory=list)
3033
context: list[str] = field(default_factory=list) # untracked changes next to this leaf
3134
refch: list[str] = field(default_factory=list)
@@ -88,11 +91,24 @@ def build_reports(
8891
repair: RepairResult,
8992
opts: UpdateOptions,
9093
reclustered: set[tuple[str, ...]] | None = None,
94+
adopted: dict[str, Any] | None = None,
9195
) -> dict[tuple[str, ...], LeafReport]:
92-
"""Build ``Report(l)`` for every unit of the new tree plus deleted leaves."""
96+
"""Build ``Report(l)`` for every unit of the new tree plus deleted leaves.
97+
98+
``adopted`` (Step 3a, ``ownership.close_ownership``) maps a changed
99+
component that no leaf tracks to its effective owner; it enters that
100+
leaf's Own exactly like a tracked component."""
93101
reclustered = reclustered or set()
102+
adopted = adopted or {}
94103
old_owner = T.owner_map(old_tree)
95104
new_owner = T.owner_map(new_tree)
105+
106+
def owner_of(cid: str) -> tuple[str, ...] | None:
107+
o = T.resolve_owner(new_owner, cid) or T.resolve_owner(old_owner, cid)
108+
if o is None and cid in adopted:
109+
o = tuple(adopted[cid].leaf_path)
110+
return o
111+
96112
old_units = set(T.unit_paths(old_tree))
97113
new_units = T.unit_paths(new_tree)
98114
reports: dict[tuple[str, ...], LeafReport] = {p: LeafReport(leaf_path=p) for p in new_units}
@@ -109,9 +125,13 @@ def build_reports(
109125
o = T.resolve_owner(old_owner, rec.old_id)
110126
if o is not None:
111127
owners.add(o)
128+
if not owners and cid in adopted:
129+
owners.add(tuple(adopted[cid].leaf_path))
112130
for p in owners:
113131
if p in reports and cid not in reports[p].own:
114132
reports[p].own.append(cid)
133+
if cid in adopted:
134+
reports[p].adopted[cid] = adopted[cid].rule
115135

116136
# Up: interface / deleted / renamed components used by this leaf's code.
117137
contract_moved = set(diff.interface) | set(diff.deleted) | set(diff.renamed.values())
@@ -135,20 +155,18 @@ def build_reports(
135155
p = T.resolve_owner(new_owner, user) or T.resolve_owner(old_owner, user)
136156
if p is None or p not in reports:
137157
continue
138-
own_leaf = (
139-
T.resolve_owner(new_owner, cid)
140-
or T.resolve_owner(old_owner, cid)
141-
or T.resolve_owner(old_owner, old_ids_of_renames.get(cid, ""))
158+
own_leaf = owner_of(cid) or T.resolve_owner(
159+
old_owner, old_ids_of_renames.get(cid, "")
142160
)
143161
if p == own_leaf:
144162
continue
145163
if cid not in reports[p].up:
146164
reports[p].up.append(cid)
147165

148-
# Untracked changed components (no class to attach to): context for the
149-
# leaves owning their neighbours.
166+
# Untracked changed components that the closure could not place either:
167+
# context for the leaves owning their neighbours.
150168
for cid in diff.changed_ids:
151-
if T.resolve_owner(new_owner, cid) or T.resolve_owner(old_owner, cid):
169+
if owner_of(cid) is not None:
152170
continue
153171
neighbours = _out_edges(cid, old_graph, new_graph)
154172
for other, node in new_graph.items():

‎codewiki/src/be/updater/options.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ class UpdateOptions:
2929

3030
# Step 3: change report
3131
k_hop: int = 1 # dependency hops followed for Up
32+
# Step 3a: ownership closure. A changed component that no leaf tracks gets
33+
# an effective owner by the placement rules of Step 2 (same file, same
34+
# directory, neighbour majority, routing agent) and enters that leaf's Own.
35+
# Activation only: the tree is not changed.
36+
use_ownership_closure: bool = True
3237

3338
# Step 4: fallback
3439
tau_full: float = 0.5 # active leaves / all leaves
@@ -57,6 +62,7 @@ def from_rung(cls, rung: str | int, **overrides: Any) -> "UpdateOptions":
5762
opts.agent_may_patch_leaf = False
5863
opts.agent_patches_related = False
5964
opts.use_stale_scan = False
65+
opts.use_ownership_closure = False
6066
elif rung == "2":
6167
opts.agent_may_patch_leaf = False
6268
elif rung == "3b":

‎codewiki/src/be/updater/orchestrator.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from codewiki.src.be.updater.graph_store import load_graph
2626
from codewiki.src.be.updater.leaf_agent import LeafAgentRunner
2727
from codewiki.src.be.updater.options import UpdateOptions
28+
from codewiki.src.be.updater.ownership import close_ownership
2829
from codewiki.src.be.updater.record import (
2930
OUTCOME_DETECTOR_FAILURE,
3031
OUTCOME_FULL_FALLBACK,
@@ -288,6 +289,14 @@ async def _run(
288289
rec.repair = repair.to_dict()
289290
self._deleted_nodes = list(repair.deleted_nodes)
290291

292+
# ---- Step 3a: effective owners for changed components no leaf tracks
293+
adopted = {}
294+
if not self.whole_repo:
295+
adopted = close_ownership(
296+
diff, old_tree, new_tree, old_graph, new_graph, self.opts, router, repair
297+
)
298+
rec.ownership = [a.to_dict() for a in adopted.values()]
299+
291300
# ---- Step 3: reports
292301
reports = build_reports(
293302
diff,
@@ -299,6 +308,7 @@ async def _run(
299308
repair,
300309
self.opts,
301310
reclustered,
311+
adopted,
302312
)
303313
active = active_set(reports)
304314
rec.reports = {"/".join(p): r.to_dict() for p, r in reports.items() if p in active}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Step 3a: ownership closure.
2+
3+
The module tree tracks a subset of the code graph (on svelte 764 of 2239
4+
components). A changed component outside that subset has no owner, so it
5+
can never enter a leaf's Own and, when its neighbours' leaves are otherwise
6+
quiet, is dropped. Here the owner map is extended to a total function: an
7+
untracked changed component gets an *effective owner* by the very rules that
8+
place a new component in Step 2 (same file, same directory with one leaf,
9+
neighbour majority, routing agent). The component then enters Own of that
10+
leaf. Activation only: the tree on disk is not changed, and the closure is
11+
recomputed at every step.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import logging
17+
from dataclasses import dataclass
18+
from typing import Any
19+
20+
from codewiki.src.be.dependency_analyzer.models.core import Node
21+
from codewiki.src.be.updater import tree as T
22+
from codewiki.src.be.updater.graph_diff import GraphDiff
23+
from codewiki.src.be.updater.options import UpdateOptions
24+
from codewiki.src.be.updater.tree_repair import (
25+
RULE_AGENT,
26+
OrphanRouter,
27+
RepairResult,
28+
RoutingDecision,
29+
route_by_rules,
30+
)
31+
32+
logger = logging.getLogger(__name__)
33+
34+
PURPOSE_OWNERSHIP = "ownership"
35+
36+
37+
@dataclass
38+
class Adoption:
39+
"""Effective owner of one changed component that no leaf tracks."""
40+
41+
decision: RoutingDecision
42+
deleted: bool = False # the component exists only in the old graph
43+
44+
@property
45+
def component_id(self) -> str:
46+
return self.decision.component_id
47+
48+
@property
49+
def leaf_path(self) -> tuple[str, ...]:
50+
assert self.decision.leaf_path is not None
51+
return self.decision.leaf_path
52+
53+
@property
54+
def rule(self) -> str:
55+
return self.decision.rule
56+
57+
def to_dict(self) -> dict[str, Any]:
58+
return {
59+
"component_id": self.component_id,
60+
"rule": self.rule,
61+
"leaf_path": list(self.leaf_path),
62+
"detail": self.decision.detail,
63+
"deleted": self.deleted,
64+
}
65+
66+
67+
def untracked_changed(
68+
diff: GraphDiff,
69+
old_owner: dict[str, tuple[str, ...]],
70+
new_owner: dict[str, tuple[str, ...]],
71+
repair: RepairResult | None,
72+
) -> list[str]:
73+
"""Changed or deleted ids with no (class-resolved) owner in either tree,
74+
minus added components the repair step deliberately left untracked."""
75+
left_out = set()
76+
if repair is not None:
77+
left_out = {d.component_id for d in repair.routing if d.leaf_path is None}
78+
out = []
79+
for cid in sorted(diff.changed_ids | set(diff.deleted)):
80+
if cid in left_out:
81+
continue
82+
if T.resolve_owner(new_owner, cid) or T.resolve_owner(old_owner, cid):
83+
continue
84+
out.append(cid)
85+
return out
86+
87+
88+
def close_ownership(
89+
diff: GraphDiff,
90+
old_tree: dict[str, Any],
91+
new_tree: dict[str, Any],
92+
old_graph: dict[str, Node],
93+
new_graph: dict[str, Node],
94+
opts: UpdateOptions,
95+
route_orphans: OrphanRouter | None = None,
96+
repair: RepairResult | None = None,
97+
) -> dict[str, Adoption]:
98+
"""Return ``{component_id: Adoption}`` for every changed untracked component
99+
that rules 1-3, or the routing agent as rule 4, can give an owner."""
100+
if not opts.use_ownership_closure:
101+
return {}
102+
old_owner = T.owner_map(old_tree)
103+
new_owner = T.owner_map(new_tree)
104+
candidates = untracked_changed(diff, old_owner, new_owner, repair)
105+
if not candidates:
106+
return {}
107+
108+
adopted: dict[str, Adoption] = {}
109+
unplaced: list[str] = []
110+
for cid in candidates:
111+
deleted = cid not in new_graph and cid in old_graph
112+
graph, owner = (old_graph, old_owner) if deleted else (new_graph, new_owner)
113+
node = graph.get(cid)
114+
if node is None:
115+
continue
116+
decision = route_by_rules(cid, node, owner, graph, opts)
117+
if decision is None:
118+
if deleted:
119+
continue # no page can answer for a vanished untracked component
120+
unplaced.append(cid)
121+
continue
122+
adopted[cid] = Adoption(decision, deleted)
123+
124+
if unplaced and route_orphans is not None and opts.use_routing_agent:
125+
context = {
126+
"tree": new_tree,
127+
"owner": new_owner,
128+
"graph": new_graph,
129+
"purpose": PURPOSE_OWNERSHIP,
130+
}
131+
for d in route_orphans(unplaced, context):
132+
if d.component_id not in unplaced or d.component_id in adopted:
133+
continue
134+
if d.leaf_path is None or d.new_leaf or T.node_at(new_tree, d.leaf_path) is None:
135+
continue # left untracked, or a new leaf: the tree is not changed here
136+
d.rule = RULE_AGENT
137+
adopted[d.component_id] = Adoption(d)
138+
139+
by_rule: dict[str, int] = {}
140+
for a in adopted.values():
141+
by_rule[a.rule] = by_rule.get(a.rule, 0) + 1
142+
logger.info(
143+
"Ownership closure: %d untracked changed, %d adopted %s, %d left as context",
144+
len(candidates),
145+
len(adopted),
146+
by_rule,
147+
len(candidates) - len(adopted),
148+
)
149+
return adopted

0 commit comments

Comments
 (0)