Skip to content

Commit 1436182

Browse files
committed
updater: ownership closure for changed components no leaf tracks (Step 3a)
The module tree tracks a minority of the code graph (svelte: 764 of 2239 components). A changed component outside it has no owner, so it never enters a leaf's Own; it only reached neighbouring leaves as CONTEXT, which does not activate. Along svelte's 20-commit chain 24 of 38 changed components were untracked and 8 of 20 update steps wrote no page, including the pinned endpoint commit (transform-client.js::client_component). Extend the owner map to a total function with the placement rules of Step 2: rule 1 same file, rule 2 same directory with one leaf, rule 3 neighbour majority at tau_nb, rule 4 the routing agent in a "place or leave" mode (no new leaves). A changed untracked component enters Own of its effective owner and activates that leaf like a tracked change; the leaf agent sees it under OWN with a "not listed by this module; assigned by rule k" note and keeps the no-op / patch / rewrite judgment. Activation only: the tree on disk is not changed and the closure is recomputed every step. What no rule places stays context. - ownership.py: close_ownership(), Adoption; route_by_rules() made public - change_report.build_reports(adopted=...): Own/Up/context consult the closure; LeafReport.adopted records id -> rule - routing.RoutingAgent: purpose="ownership" uses OWNERSHIP_ROUTING_USER_PROMPT (place | untracked), maps create -> left - record: UpdateRecord.ownership, summary n_adopted / n_adopted_by_agent - options: use_ownership_closure (default on, off at rung 1); CLI --no-ownership-closure - tests: rules 1-4, deleted untracked, unplaced, switched off, end-to-end
1 parent 82e653f commit 1436182

11 files changed

Lines changed: 468 additions & 26 deletions

File tree

‎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

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

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
the report does not touch.
3333
3. Per page role:
3434
- the LEAF PAGE ({leaf_name}.md): update sections, tables and diagrams that describe changed
35-
components; add new components; remove deleted ones. If the change is so large that the
35+
components; add new components; remove deleted ones. An OWN entry marked "not listed by
36+
this module" is a nearby component the page never lists: fix only what its change makes
37+
wrong in the page's description, do not add a section for it. If the change is so large that the
3638
page is better rebuilt from scratch, do NOT rebuild it yourself: return verdict "rewrite"
3739
for this page and leave it untouched (the normal module agent will regenerate it).
3840
- an ANCESTOR page: change only where it summarizes this leaf or lists its children.
@@ -138,6 +140,32 @@
138140
```
139141
""".strip()
140142

143+
OWNERSHIP_ROUTING_USER_PROMPT = """
144+
The repository changed. The components below CHANGED, but no wiki module lists them, and the
145+
rules (same file, same directory, majority of graph neighbours) found no module for them. The
146+
wiki is not restructured for this; the question is only which module's page answers for each
147+
change, so that page can be checked and, if needed, corrected.
148+
149+
<MODULE_TREE>
150+
{module_tree}
151+
</MODULE_TREE>
152+
153+
<CHANGED_UNTRACKED>
154+
{orphans}
155+
</CHANGED_UNTRACKED>
156+
157+
For each component choose exactly one action:
158+
- "place": the leaf module whose page most likely describes the behaviour this component
159+
implements ("leaf": exact module name from the tree).
160+
- "untracked": no page describes it (tests, throwaway scripts, trivial helpers).
161+
162+
Return exactly one fenced JSON block:
163+
```json
164+
{{"decisions": [{{"component_id": "...", "action": "place|untracked", "leaf": "...",
165+
"reason": "<one line>"}}]}}
166+
```
167+
""".strip()
168+
141169
STALE_FIX_SYSTEM_PROMPT = """
142170
You fix stale references in ONE documentation page after a code change. Make the smallest edits
143171
that remove or correct the stale items; everything else on the page stays word for word.
@@ -185,7 +213,16 @@ def render_report(report: LeafReport, diff: GraphDiff) -> str:
185213
parts: list[str] = []
186214
if report.own:
187215
parts.append("## OWN — components of this leaf that changed")
188-
parts += [render_record(diff, c) for c in report.own]
216+
for c in report.own:
217+
block = render_record(diff, c)
218+
rule = report.adopted.get(c)
219+
if rule:
220+
block += (
221+
f"\n(not listed by this module; assigned to it by rule {rule} as the nearest "
222+
"documented module. The page may describe this behaviour without naming the "
223+
"component: check whether the description is still true. no-op is fine.)"
224+
)
225+
parts.append(block)
189226
if report.up:
190227
parts.append(
191228
"## UP — components outside this leaf that its code uses and whose contract moved"
@@ -310,6 +347,7 @@ def format_routing_prompt(
310347
graph: dict[str, Node],
311348
neighbours: dict[str, list[str]],
312349
max_code_chars: int = 6_000,
350+
allow_create: bool = True,
313351
) -> str:
314352
blocks = []
315353
for cid in orphans:
@@ -321,7 +359,8 @@ def format_routing_prompt(
321359
f"neighbour modules in the code graph: {nb if nb else 'none'}\n"
322360
f"```{_fence_language(node.relative_path)}\n{code}\n```\n</ORPHAN>"
323361
)
324-
return ROUTING_USER_PROMPT.format(module_tree=tree_outline, orphans="\n".join(blocks))
362+
template = ROUTING_USER_PROMPT if allow_create else OWNERSHIP_ROUTING_USER_PROMPT
363+
return template.format(module_tree=tree_outline, orphans="\n".join(blocks))
325364

326365

327366
def format_stale_prompt(page: str, items: list[dict[str, Any]]) -> str:

0 commit comments

Comments
 (0)