diff --git a/codewiki/cli/adapters/doc_generator.py b/codewiki/cli/adapters/doc_generator.py
index 07f425fc..94d3f591 100644
--- a/codewiki/cli/adapters/doc_generator.py
+++ b/codewiki/cli/adapters/doc_generator.py
@@ -6,6 +6,7 @@
"""
import asyncio
+import json
import logging
import os
import sys
@@ -442,8 +443,11 @@ async def _run_incremental_update(
)
working_dir = str(self.output_dir.absolute())
if record.outcome in ("incremental", "no_change"):
+ # create_documentation_metadata rewrites metadata.json from scratch;
+ # keep the history of earlier updates so a chain of updates accumulates.
+ prior_history = self._read_update_history(working_dir)
doc_generator.create_documentation_metadata(working_dir, components, len(leaf_nodes))
- self._merge_update_summary(working_dir)
+ self._merge_update_summary(working_dir, prior_history)
for file_path in os.listdir(working_dir):
if file_path.endswith((".md", ".json")):
self.job.files_generated.append(file_path)
@@ -480,7 +484,17 @@ def _move_docs_aside(self) -> None:
if self.verbose:
self.progress_tracker.update_stage(0.1, f"Previous docs preserved at {target}")
- def _merge_update_summary(self, working_dir: str) -> None:
+ @staticmethod
+ def _read_update_history(working_dir: str) -> list:
+ path = os.path.join(working_dir, "metadata.json")
+ try:
+ with open(path, encoding="utf-8") as f:
+ history = (json.load(f) or {}).get("update_history")
+ except (OSError, json.JSONDecodeError):
+ return []
+ return history if isinstance(history, list) else []
+
+ def _merge_update_summary(self, working_dir: str, prior_history: list | None = None) -> None:
record = getattr(self, "_last_update_record", None)
if record is None:
return
@@ -490,7 +504,7 @@ def _merge_update_summary(self, working_dir: str) -> None:
preserved = getattr(self, "_preserved_docs_dir", None)
if preserved:
summary["previous_docs"] = preserved
- merge_into_metadata(working_dir, summary)
+ merge_into_metadata(working_dir, summary, prior_history=prior_history)
try:
record.save(working_dir)
except OSError:
diff --git a/codewiki/cli/commands/generate.py b/codewiki/cli/commands/generate.py
index b0df3a84..1a0c9f90 100644
--- a/codewiki/cli/commands/generate.py
+++ b/codewiki/cli/commands/generate.py
@@ -386,6 +386,15 @@ def _find_affected(tree, parent_names=None):
default=None,
help="Cap on one component diff in a report (default 8000).",
)
+@click.option(
+ "--no-ownership-closure",
+ is_flag=True,
+ default=False,
+ help=(
+ "Do not give changed components that no module tracks an effective owner by the "
+ "placement rules; such changes then reach no page (default: give one)."
+ ),
+)
@click.option(
"--compare-to",
type=str,
@@ -427,6 +436,7 @@ def generate_command(
tau_tree: float | None = None,
k_hop: int | None = None,
max_diff_tokens: int | None = None,
+ no_ownership_closure: bool = False,
):
"""
Generate comprehensive documentation for a code repository.
@@ -761,6 +771,7 @@ def generate_command(
"tau_tree": tau_tree,
"k_hop": k_hop,
"max_diff_tokens": max_diff_tokens,
+ "use_ownership_closure": False if no_ownership_closure else None,
},
},
verbose=verbose,
diff --git a/codewiki/src/be/updater/change_report.py b/codewiki/src/be/updater/change_report.py
index d00ed630..c8b85412 100644
--- a/codewiki/src/be/updater/change_report.py
+++ b/codewiki/src/be/updater/change_report.py
@@ -26,6 +26,9 @@ class LeafReport:
leaf_path: tuple[str, ...]
mode: str = MODE_EDIT
own: list[str] = field(default_factory=list)
+ # Own entries this leaf does not list: changed components no leaf tracks,
+ # given to this leaf as effective owner (Step 3a). id -> placement rule.
+ adopted: dict[str, str] = field(default_factory=dict)
up: list[str] = field(default_factory=list)
context: list[str] = field(default_factory=list) # untracked changes next to this leaf
refch: list[str] = field(default_factory=list)
@@ -88,11 +91,24 @@ def build_reports(
repair: RepairResult,
opts: UpdateOptions,
reclustered: set[tuple[str, ...]] | None = None,
+ adopted: dict[str, Any] | None = None,
) -> dict[tuple[str, ...], LeafReport]:
- """Build ``Report(l)`` for every unit of the new tree plus deleted leaves."""
+ """Build ``Report(l)`` for every unit of the new tree plus deleted leaves.
+
+ ``adopted`` (Step 3a, ``ownership.close_ownership``) maps a changed
+ component that no leaf tracks to its effective owner; it enters that
+ leaf's Own exactly like a tracked component."""
reclustered = reclustered or set()
+ adopted = adopted or {}
old_owner = T.owner_map(old_tree)
new_owner = T.owner_map(new_tree)
+
+ def owner_of(cid: str) -> tuple[str, ...] | None:
+ o = T.resolve_owner(new_owner, cid) or T.resolve_owner(old_owner, cid)
+ if o is None and cid in adopted:
+ o = tuple(adopted[cid].leaf_path)
+ return o
+
old_units = set(T.unit_paths(old_tree))
new_units = T.unit_paths(new_tree)
reports: dict[tuple[str, ...], LeafReport] = {p: LeafReport(leaf_path=p) for p in new_units}
@@ -109,9 +125,13 @@ def build_reports(
o = T.resolve_owner(old_owner, rec.old_id)
if o is not None:
owners.add(o)
+ if not owners and cid in adopted:
+ owners.add(tuple(adopted[cid].leaf_path))
for p in owners:
if p in reports and cid not in reports[p].own:
reports[p].own.append(cid)
+ if cid in adopted:
+ reports[p].adopted[cid] = adopted[cid].rule
# Up: interface / deleted / renamed components used by this leaf's code.
contract_moved = set(diff.interface) | set(diff.deleted) | set(diff.renamed.values())
@@ -135,20 +155,18 @@ def build_reports(
p = T.resolve_owner(new_owner, user) or T.resolve_owner(old_owner, user)
if p is None or p not in reports:
continue
- own_leaf = (
- T.resolve_owner(new_owner, cid)
- or T.resolve_owner(old_owner, cid)
- or T.resolve_owner(old_owner, old_ids_of_renames.get(cid, ""))
+ own_leaf = owner_of(cid) or T.resolve_owner(
+ old_owner, old_ids_of_renames.get(cid, "")
)
if p == own_leaf:
continue
if cid not in reports[p].up:
reports[p].up.append(cid)
- # Untracked changed components (no class to attach to): context for the
- # leaves owning their neighbours.
+ # Untracked changed components that the closure could not place either:
+ # context for the leaves owning their neighbours.
for cid in diff.changed_ids:
- if T.resolve_owner(new_owner, cid) or T.resolve_owner(old_owner, cid):
+ if owner_of(cid) is not None:
continue
neighbours = _out_edges(cid, old_graph, new_graph)
for other, node in new_graph.items():
diff --git a/codewiki/src/be/updater/options.py b/codewiki/src/be/updater/options.py
index b6b8e446..a805c202 100644
--- a/codewiki/src/be/updater/options.py
+++ b/codewiki/src/be/updater/options.py
@@ -29,6 +29,11 @@ class UpdateOptions:
# Step 3: change report
k_hop: int = 1 # dependency hops followed for Up
+ # Step 3a: ownership closure. A changed component that no leaf tracks gets
+ # an effective owner by the placement rules of Step 2 (same file, same
+ # directory, neighbour majority, routing agent) and enters that leaf's Own.
+ # Activation only: the tree is not changed.
+ use_ownership_closure: bool = True
# Step 4: fallback
tau_full: float = 0.5 # active leaves / all leaves
@@ -57,6 +62,7 @@ def from_rung(cls, rung: str | int, **overrides: Any) -> "UpdateOptions":
opts.agent_may_patch_leaf = False
opts.agent_patches_related = False
opts.use_stale_scan = False
+ opts.use_ownership_closure = False
elif rung == "2":
opts.agent_may_patch_leaf = False
elif rung == "3b":
diff --git a/codewiki/src/be/updater/orchestrator.py b/codewiki/src/be/updater/orchestrator.py
index 3f7ebba2..f85c5563 100644
--- a/codewiki/src/be/updater/orchestrator.py
+++ b/codewiki/src/be/updater/orchestrator.py
@@ -25,6 +25,7 @@
from codewiki.src.be.updater.graph_store import load_graph
from codewiki.src.be.updater.leaf_agent import LeafAgentRunner
from codewiki.src.be.updater.options import UpdateOptions
+from codewiki.src.be.updater.ownership import close_ownership
from codewiki.src.be.updater.record import (
OUTCOME_DETECTOR_FAILURE,
OUTCOME_FULL_FALLBACK,
@@ -288,6 +289,14 @@ async def _run(
rec.repair = repair.to_dict()
self._deleted_nodes = list(repair.deleted_nodes)
+ # ---- Step 3a: effective owners for changed components no leaf tracks
+ adopted = {}
+ if not self.whole_repo:
+ adopted = close_ownership(
+ diff, old_tree, new_tree, old_graph, new_graph, self.opts, router, repair
+ )
+ rec.ownership = [a.to_dict() for a in adopted.values()]
+
# ---- Step 3: reports
reports = build_reports(
diff,
@@ -299,6 +308,7 @@ async def _run(
repair,
self.opts,
reclustered,
+ adopted,
)
active = active_set(reports)
rec.reports = {"/".join(p): r.to_dict() for p, r in reports.items() if p in active}
diff --git a/codewiki/src/be/updater/ownership.py b/codewiki/src/be/updater/ownership.py
new file mode 100644
index 00000000..b0151b58
--- /dev/null
+++ b/codewiki/src/be/updater/ownership.py
@@ -0,0 +1,149 @@
+"""Step 3a: ownership closure.
+
+The module tree tracks a subset of the code graph (on svelte 764 of 2239
+components). A changed component outside that subset has no owner, so it
+can never enter a leaf's Own and, when its neighbours' leaves are otherwise
+quiet, is dropped. Here the owner map is extended to a total function: an
+untracked changed component gets an *effective owner* by the very rules that
+place a new component in Step 2 (same file, same directory with one leaf,
+neighbour majority, routing agent). The component then enters Own of that
+leaf. Activation only: the tree on disk is not changed, and the closure is
+recomputed at every step.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Any
+
+from codewiki.src.be.dependency_analyzer.models.core import Node
+from codewiki.src.be.updater import tree as T
+from codewiki.src.be.updater.graph_diff import GraphDiff
+from codewiki.src.be.updater.options import UpdateOptions
+from codewiki.src.be.updater.tree_repair import (
+ RULE_AGENT,
+ OrphanRouter,
+ RepairResult,
+ RoutingDecision,
+ route_by_rules,
+)
+
+logger = logging.getLogger(__name__)
+
+PURPOSE_OWNERSHIP = "ownership"
+
+
+@dataclass
+class Adoption:
+ """Effective owner of one changed component that no leaf tracks."""
+
+ decision: RoutingDecision
+ deleted: bool = False # the component exists only in the old graph
+
+ @property
+ def component_id(self) -> str:
+ return self.decision.component_id
+
+ @property
+ def leaf_path(self) -> tuple[str, ...]:
+ assert self.decision.leaf_path is not None
+ return self.decision.leaf_path
+
+ @property
+ def rule(self) -> str:
+ return self.decision.rule
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "component_id": self.component_id,
+ "rule": self.rule,
+ "leaf_path": list(self.leaf_path),
+ "detail": self.decision.detail,
+ "deleted": self.deleted,
+ }
+
+
+def untracked_changed(
+ diff: GraphDiff,
+ old_owner: dict[str, tuple[str, ...]],
+ new_owner: dict[str, tuple[str, ...]],
+ repair: RepairResult | None,
+) -> list[str]:
+ """Changed or deleted ids with no (class-resolved) owner in either tree,
+ minus added components the repair step deliberately left untracked."""
+ left_out = set()
+ if repair is not None:
+ left_out = {d.component_id for d in repair.routing if d.leaf_path is None}
+ out = []
+ for cid in sorted(diff.changed_ids | set(diff.deleted)):
+ if cid in left_out:
+ continue
+ if T.resolve_owner(new_owner, cid) or T.resolve_owner(old_owner, cid):
+ continue
+ out.append(cid)
+ return out
+
+
+def close_ownership(
+ diff: GraphDiff,
+ old_tree: dict[str, Any],
+ new_tree: dict[str, Any],
+ old_graph: dict[str, Node],
+ new_graph: dict[str, Node],
+ opts: UpdateOptions,
+ route_orphans: OrphanRouter | None = None,
+ repair: RepairResult | None = None,
+) -> dict[str, Adoption]:
+ """Return ``{component_id: Adoption}`` for every changed untracked component
+ that rules 1-3, or the routing agent as rule 4, can give an owner."""
+ if not opts.use_ownership_closure:
+ return {}
+ old_owner = T.owner_map(old_tree)
+ new_owner = T.owner_map(new_tree)
+ candidates = untracked_changed(diff, old_owner, new_owner, repair)
+ if not candidates:
+ return {}
+
+ adopted: dict[str, Adoption] = {}
+ unplaced: list[str] = []
+ for cid in candidates:
+ deleted = cid not in new_graph and cid in old_graph
+ graph, owner = (old_graph, old_owner) if deleted else (new_graph, new_owner)
+ node = graph.get(cid)
+ if node is None:
+ continue
+ decision = route_by_rules(cid, node, owner, graph, opts)
+ if decision is None:
+ if deleted:
+ continue # no page can answer for a vanished untracked component
+ unplaced.append(cid)
+ continue
+ adopted[cid] = Adoption(decision, deleted)
+
+ if unplaced and route_orphans is not None and opts.use_routing_agent:
+ context = {
+ "tree": new_tree,
+ "owner": new_owner,
+ "graph": new_graph,
+ "purpose": PURPOSE_OWNERSHIP,
+ }
+ for d in route_orphans(unplaced, context):
+ if d.component_id not in unplaced or d.component_id in adopted:
+ continue
+ if d.leaf_path is None or d.new_leaf or T.node_at(new_tree, d.leaf_path) is None:
+ continue # left untracked, or a new leaf: the tree is not changed here
+ d.rule = RULE_AGENT
+ adopted[d.component_id] = Adoption(d)
+
+ by_rule: dict[str, int] = {}
+ for a in adopted.values():
+ by_rule[a.rule] = by_rule.get(a.rule, 0) + 1
+ logger.info(
+ "Ownership closure: %d untracked changed, %d adopted %s, %d left as context",
+ len(candidates),
+ len(adopted),
+ by_rule,
+ len(candidates) - len(adopted),
+ )
+ return adopted
diff --git a/codewiki/src/be/updater/prompts.py b/codewiki/src/be/updater/prompts.py
index 828cc672..86385c01 100644
--- a/codewiki/src/be/updater/prompts.py
+++ b/codewiki/src/be/updater/prompts.py
@@ -32,7 +32,9 @@
the report does not touch.
3. Per page role:
- the LEAF PAGE ({leaf_name}.md): update sections, tables and diagrams that describe changed
- components; add new components; remove deleted ones. If the change is so large that the
+ components; add new components; remove deleted ones. An OWN entry marked "not listed by
+ this module" is a nearby component the page never lists: fix only what its change makes
+ wrong in the page's description, do not add a section for it. If the change is so large that the
page is better rebuilt from scratch, do NOT rebuild it yourself: return verdict "rewrite"
for this page and leave it untouched (the normal module agent will regenerate it).
- an ANCESTOR page: change only where it summarizes this leaf or lists its children.
@@ -138,6 +140,32 @@
```
""".strip()
+OWNERSHIP_ROUTING_USER_PROMPT = """
+The repository changed. The components below CHANGED, but no wiki module lists them, and the
+rules (same file, same directory, majority of graph neighbours) found no module for them. The
+wiki is not restructured for this; the question is only which module's page answers for each
+change, so that page can be checked and, if needed, corrected.
+
+
+{module_tree}
+
+
+
+{orphans}
+
+
+For each component choose exactly one action:
+- "place": the leaf module whose page most likely describes the behaviour this component
+ implements ("leaf": exact module name from the tree).
+- "untracked": no page describes it (tests, throwaway scripts, trivial helpers).
+
+Return exactly one fenced JSON block:
+```json
+{{"decisions": [{{"component_id": "...", "action": "place|untracked", "leaf": "...",
+ "reason": ""}}]}}
+```
+""".strip()
+
STALE_FIX_SYSTEM_PROMPT = """
You fix stale references in ONE documentation page after a code change. Make the smallest edits
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:
parts: list[str] = []
if report.own:
parts.append("## OWN — components of this leaf that changed")
- parts += [render_record(diff, c) for c in report.own]
+ for c in report.own:
+ block = render_record(diff, c)
+ rule = report.adopted.get(c)
+ if rule:
+ block += (
+ f"\n(not listed by this module; assigned to it by rule {rule} as the nearest "
+ "documented module. The page may describe this behaviour without naming the "
+ "component: check whether the description is still true. no-op is fine.)"
+ )
+ parts.append(block)
if report.up:
parts.append(
"## UP — components outside this leaf that its code uses and whose contract moved"
@@ -310,6 +347,7 @@ def format_routing_prompt(
graph: dict[str, Node],
neighbours: dict[str, list[str]],
max_code_chars: int = 6_000,
+ allow_create: bool = True,
) -> str:
blocks = []
for cid in orphans:
@@ -321,7 +359,8 @@ def format_routing_prompt(
f"neighbour modules in the code graph: {nb if nb else 'none'}\n"
f"```{_fence_language(node.relative_path)}\n{code}\n```\n"
)
- return ROUTING_USER_PROMPT.format(module_tree=tree_outline, orphans="\n".join(blocks))
+ template = ROUTING_USER_PROMPT if allow_create else OWNERSHIP_ROUTING_USER_PROMPT
+ return template.format(module_tree=tree_outline, orphans="\n".join(blocks))
def format_stale_prompt(page: str, items: list[dict[str, Any]]) -> str:
diff --git a/codewiki/src/be/updater/record.py b/codewiki/src/be/updater/record.py
index 5c0344ad..0dc6efa1 100644
--- a/codewiki/src/be/updater/record.py
+++ b/codewiki/src/be/updater/record.py
@@ -46,6 +46,8 @@ class UpdateRecord:
repair: dict[str, Any] = field(default_factory=dict)
reclustered: list[list[str]] = field(default_factory=list)
reports: dict[str, Any] = field(default_factory=dict)
+ # Step 3a: effective owners given to changed components no leaf tracks
+ ownership: list[dict[str, Any]] = field(default_factory=list)
active: list[dict[str, Any]] = field(default_factory=list) # {leaf, mode, order}
write_sets: dict[str, list[str]] = field(default_factory=dict)
fallback: dict[str, Any] = field(default_factory=dict)
@@ -78,6 +80,10 @@ def summary(self) -> dict[str, Any]:
"revision": self.revision,
"diff_counts": self.diff.get("counts", {}),
"n_active": len(self.active),
+ "n_adopted": len(self.ownership),
+ "n_adopted_by_agent": sum(
+ 1 for a in self.ownership if str(a.get("rule", "")).startswith("4:")
+ ),
"fallback": self.fallback,
"n_calls": len(self.calls),
"usage_total": usage_total,
@@ -96,8 +102,14 @@ def save(self, docs_dir: str) -> str:
return path
-def merge_into_metadata(docs_dir: str, summary: dict[str, Any]) -> None:
- """Append ``summary`` under ``last_update`` (and an ``update_history`` list)."""
+def merge_into_metadata(
+ docs_dir: str, summary: dict[str, Any], prior_history: list[dict[str, Any]] | None = None
+) -> None:
+ """Append ``summary`` under ``last_update`` (and an ``update_history`` list).
+
+ ``prior_history`` restores the history when the caller rewrote metadata.json
+ in between (the update path regenerates it before merging).
+ """
path = os.path.join(docs_dir, "metadata.json")
meta: dict[str, Any] = {}
if os.path.exists(path):
@@ -110,6 +122,8 @@ def merge_into_metadata(docs_dir: str, summary: dict[str, Any]) -> None:
history = meta.get("update_history")
if not isinstance(history, list):
history = []
+ if not history and prior_history:
+ history = list(prior_history)
history.append(summary)
meta["update_history"] = history
with open(path, "w", encoding="utf-8") as f:
diff --git a/codewiki/src/be/updater/routing.py b/codewiki/src/be/updater/routing.py
index 4bea412a..c2619174 100644
--- a/codewiki/src/be/updater/routing.py
+++ b/codewiki/src/be/updater/routing.py
@@ -53,6 +53,10 @@ def __call__(self, orphans: list[str], context: dict[str, Any]) -> list[RoutingD
tree: dict[str, Any] = context["tree"]
owner: dict[str, tuple[str, ...]] = context["owner"]
graph: dict[str, Node] = context["graph"]
+ # "ownership" (Step 3a): the components changed but no leaf lists them;
+ # the agent names the page that answers for each one, or leaves it.
+ # No new leaves, since the tree is not changed for that purpose.
+ for_ownership = context.get("purpose") == "ownership"
rev = T.reverse_edges(graph)
neighbours = {}
for cid in orphans:
@@ -63,7 +67,11 @@ def __call__(self, orphans: list[str], context: dict[str, Any]) -> list[RoutingD
ROUTING_SYSTEM_PROMPT
+ "\n\n"
+ format_routing_prompt(
- tree_outline_with_summaries(tree, self.docs_dir), orphans, graph, neighbours
+ tree_outline_with_summaries(tree, self.docs_dir),
+ orphans,
+ graph,
+ neighbours,
+ allow_create=not for_ownership,
)
)
started = time.time()
@@ -77,7 +85,7 @@ def __call__(self, orphans: list[str], context: dict[str, Any]) -> list[RoutingD
self.record.add_call(
CallCost(
"routing",
- f"{len(orphans)} orphans",
+ f"{len(orphans)} {'changed untracked' if for_ownership else 'orphans'}",
time.time() - started,
getattr(self.backend, "last_usage", None),
err,
@@ -103,6 +111,12 @@ def __call__(self, orphans: list[str], context: dict[str, Any]) -> list[RoutingD
)
continue
decisions.append(RoutingDecision(cid, RULE_AGENT, path, detail=reason))
+ elif action == "create" and d.get("new_leaf") and for_ownership:
+ decisions.append(
+ RoutingDecision(
+ cid, RULE_AGENT, None, detail="create not allowed for ownership; left"
+ )
+ )
elif action == "create" and d.get("new_leaf"):
parent_name = d.get("parent")
parent: tuple[str, ...] = ()
diff --git a/codewiki/src/be/updater/tree_repair.py b/codewiki/src/be/updater/tree_repair.py
index f38c8e9e..da5ad357 100644
--- a/codewiki/src/be/updater/tree_repair.py
+++ b/codewiki/src/be/updater/tree_repair.py
@@ -84,7 +84,7 @@ def _dirname(rel: str) -> str:
return os.path.dirname(rel.replace("\\", "/"))
-def _route_by_rules(
+def route_by_rules(
cid: str,
node: Node,
owner: dict[str, tuple[str, ...]],
@@ -160,7 +160,7 @@ def repair_tree(
to_route = sorted(c for c in diff.added if c in tracked_new and c in new_graph)
orphans: list[str] = []
for cid in to_route:
- decision = _route_by_rules(cid, new_graph[cid], owner, new_graph, opts)
+ decision = route_by_rules(cid, new_graph[cid], owner, new_graph, opts)
if decision is None:
orphans.append(cid)
continue
diff --git a/tests/test_updater_change_report.py b/tests/test_updater_change_report.py
index e61f1ae6..c67dbe14 100644
--- a/tests/test_updater_change_report.py
+++ b/tests/test_updater_change_report.py
@@ -162,20 +162,120 @@ def test_untracked_method_attaches_to_its_class_leaf(tmp_path):
assert reports[("api",)].context == []
-def test_context_alone_does_not_activate(tmp_path):
- """An untracked free function next to a leaf changed: the leaf gets it as
- context but is not active for that reason alone."""
+def _with_untracked_helper(helper: str, deps_of_handle=(REFRESH,), helper_deps=()):
+ """Toy graphs where only an untracked free function ``helper`` changes (body only)."""
from updater_toy import node
- helper = "src/api/util.py::helper"
old_g = graph_r1()
- old_g[helper] = node(helper, "function", "def helper():\n return 1\n")
- old_g[HANDLE] = old_g[HANDLE].model_copy(update={"depends_on": {REFRESH, helper}})
+ old_g[helper] = node(helper, "function", "def helper():\n return 1\n", deps=helper_deps)
+ old_g[HANDLE] = old_g[HANDLE].model_copy(update={"depends_on": set(deps_of_handle)})
new_g = {k: v.model_copy(deep=True) for k, v in old_g.items()}
- new_g[helper] = node(helper, "function", "def helper():\n return 2\n")
- opts = UpdateOptions()
+ new_g[helper] = node(helper, "function", "def helper():\n return 2\n", deps=helper_deps)
+ return old_g, new_g
+
+
+def _reports_with_closure(old_g, new_g, opts, router=None):
+ from codewiki.src.be.updater.ownership import close_ownership
+
d = diff_graphs(old_g, new_g, opts)
r = repair_tree(tree_r1(), d, new_g, tracked_r2() - {OAUTH}, opts)
- reports = build_reports(d, tree_r1(), r.tree, old_g, new_g, None, r, opts)
+ adopted = close_ownership(d, tree_r1(), r.tree, old_g, new_g, opts, router, r)
+ reports = build_reports(d, tree_r1(), r.tree, old_g, new_g, None, r, opts, adopted=adopted)
+ return adopted, reports
+
+
+def test_context_alone_does_not_activate(tmp_path):
+ """Without the closure an untracked free function next to a leaf is context
+ only. With it (default) rule 2, same directory with one leaf, adopts it into
+ core/api's Own and the leaf is active."""
+ helper = "src/api/util.py::helper"
+ old_g, new_g = _with_untracked_helper(helper, deps_of_handle=(REFRESH, helper))
+
+ off = UpdateOptions(use_ownership_closure=False)
+ adopted, reports = _reports_with_closure(old_g, new_g, off)
+ assert adopted == {}
assert reports[("core", "api")].context == [helper]
- assert active_set(reports) == []
+ assert reports[("core", "api")].own == [] and active_set(reports) == []
+
+ on = UpdateOptions()
+ adopted, reports = _reports_with_closure(old_g, new_g, on)
+ assert set(adopted) == {helper} and adopted[helper].rule == "2:same_dir"
+ assert adopted[helper].leaf_path == ("core", "api") and adopted[helper].deleted is False
+ rep = reports[("core", "api")]
+ assert rep.own == [helper] and rep.adopted == {helper: "2:same_dir"}
+ assert rep.context == [] # adopted ids are owned now, not context
+ assert active_set(reports) == [("core", "api")]
+ assert rep.to_dict()["adopted"] == {helper: "2:same_dir"}
+
+
+def test_closure_rule_1_same_file():
+ helper = "src/api/routes.py::helper" # same file as HANDLE
+ old_g, new_g = _with_untracked_helper(helper)
+ adopted, reports = _reports_with_closure(old_g, new_g, UpdateOptions())
+ assert adopted[helper].rule == "1:same_file" and adopted[helper].leaf_path == ("core", "api")
+ assert reports[("core", "api")].own == [helper]
+
+
+def test_closure_rule_3_neighbour_majority():
+ helper = "src/misc/util.py::helper" # fresh dir, only neighbour is HANDLE
+ old_g, new_g = _with_untracked_helper(helper, deps_of_handle=(REFRESH, helper))
+ adopted, reports = _reports_with_closure(old_g, new_g, UpdateOptions())
+ assert adopted[helper].rule == "3:neighbor_majority"
+ assert adopted[helper].leaf_path == ("core", "api")
+ assert active_set(reports) == [("core", "api")]
+
+
+def test_closure_unplaced_stays_context():
+ """Fresh dir, no tracked neighbour, no routing agent: nothing adopts it and
+ no leaf is active; the change is recorded nowhere but the diff."""
+ helper = "src/misc/util.py::helper"
+ old_g, new_g = _with_untracked_helper(helper)
+ adopted, reports = _reports_with_closure(old_g, new_g, UpdateOptions())
+ assert adopted == {} and active_set(reports) == []
+ assert all(r.context == [] for r in reports.values())
+
+
+def test_closure_rule_4_via_router():
+ """The routing callable is asked for what rules 1-3 cannot place, with
+ purpose=ownership; a placed decision adopts, create/untracked do not."""
+ from codewiki.src.be.updater.tree_repair import RULE_AGENT, RoutingDecision
+
+ helper = "src/misc/util.py::helper"
+ other = "src/misc/other.py::other"
+ old_g, new_g = _with_untracked_helper(helper)
+ from updater_toy import node
+
+ old_g[other] = node(other, "function", "def other():\n return 1\n")
+ new_g[other] = node(other, "function", "def other():\n return 2\n")
+ seen = {}
+
+ def router(orphans, context):
+ seen["orphans"] = list(orphans)
+ seen["purpose"] = context.get("purpose")
+ return [
+ RoutingDecision(helper, RULE_AGENT, ("core", "auth"), detail="auth page covers it"),
+ RoutingDecision(other, RULE_AGENT, ("brand", "new"), new_leaf=True),
+ ]
+
+ adopted, reports = _reports_with_closure(old_g, new_g, UpdateOptions(), router)
+ assert set(seen["orphans"]) == {helper, other} and seen["purpose"] == "ownership"
+ assert set(adopted) == {helper} and adopted[helper].rule == RULE_AGENT
+ assert reports[("core", "auth")].own == [helper]
+ assert reports[("core", "auth")].adopted == {helper: RULE_AGENT}
+ assert active_set(reports) == [("core", "auth")]
+ # rung 1 never asks
+ adopted, reports = _reports_with_closure(old_g, new_g, UpdateOptions.from_rung(1), router)
+ assert adopted == {} and active_set(reports) == []
+
+
+def test_closure_deleted_untracked_uses_old_graph():
+ from updater_toy import node
+
+ helper = "src/api/routes.py::helper"
+ old_g = graph_r1()
+ old_g[helper] = node(helper, "function", "def helper():\n return 1\n")
+ new_g = graph_r1()
+ adopted, reports = _reports_with_closure(old_g, new_g, UpdateOptions())
+ assert adopted[helper].deleted is True and adopted[helper].rule == "1:same_file"
+ assert reports[("core", "api")].own == [helper]
+ assert adopted[helper].to_dict()["deleted"] is True
diff --git a/tests/test_updater_orchestrator.py b/tests/test_updater_orchestrator.py
index 292e4c05..a0754891 100644
--- a/tests/test_updater_orchestrator.py
+++ b/tests/test_updater_orchestrator.py
@@ -5,6 +5,7 @@
import asyncio
import json
import os
+import re
from pathlib import Path
from types import SimpleNamespace
@@ -22,16 +23,29 @@
class FakeBackend:
"""Writes pages the way the real agents do (through the editor tool)."""
- def __init__(self, own_verdict="patch"):
+ def __init__(self, own_verdict="patch", route_to=None):
self.own_verdict = own_verdict
+ self.route_to = route_to # leaf name the routing agent answers, None = untracked
self.update_calls = []
self.module_calls = []
self.complete_calls = []
+ self.routing_prompts = []
self.last_usage = None
def complete(self, prompt, *, model=None, system_prompt=None):
self.complete_calls.append(prompt[:80])
self.last_usage = {"prompt_tokens": 10, "completion_tokens": 5}
+ if "" in prompt or "" in prompt:
+ self.routing_prompts.append(prompt)
+ ids = re.findall(r'regenerated overview"
async def run_module_agent(
@@ -278,3 +292,78 @@ def test_whole_repo_baseline_falls_back_when_scope_needs_clustering(tmp_path):
assert backend.update_calls == []
assert json.load(open(docs / "module_tree.json")) == {}
assert (docs / "overview.md").exists()
+
+
+# ------------------------------------------------------------ ownership closure
+HELPER = "src/misc/util.py::helper" # fresh dir, no tracked neighbour: rules 1-3 fail
+
+
+def _graphs_with_untracked_helper():
+ from updater_toy import node
+
+ old_g = graph_r1()
+ old_g[HELPER] = node(HELPER, "function", "def helper():\n return 1\n")
+ new_g = {k: v.model_copy(deep=True) for k, v in old_g.items()}
+ new_g[HELPER] = node(HELPER, "function", "def helper():\n return 2\n")
+ return old_g, new_g
+
+
+def _run_untracked(tmp_path, backend, opts):
+ docs, config, prev = _setup(tmp_path)
+ old_g, new_g = _graphs_with_untracked_helper()
+ save_graph(old_g, prev)
+ gen = _generator(config, backend)
+ upd = IncrementalUpdater(config, backend, gen, opts)
+ tracked = sorted(set(old_g) - {HELPER})
+ rec = asyncio.run(upd.run(prev, new_g, tracked, {"old_commit": "old", "new_commit": "new"}))
+ return docs, rec
+
+
+def test_ownership_closure_rule_4_activates_leaf(tmp_path):
+ backend = FakeBackend(route_to="api")
+ docs, rec = _run_untracked(tmp_path, backend, UpdateOptions())
+ assert rec.outcome == "incremental"
+ assert rec.diff["counts"]["body"] == 1
+ # one routing call, in ownership mode (no "create" offered)
+ assert len(backend.routing_prompts) == 1
+ assert "" in backend.routing_prompts[0]
+ assert "create" not in backend.routing_prompts[0].split("For each component")[1]
+ assert rec.ownership == [
+ {
+ "component_id": HELPER,
+ "rule": "4:routing_agent",
+ "leaf_path": ["core", "api"],
+ "detail": "fake",
+ "deleted": False,
+ }
+ ]
+ routing_calls = [c for c in rec.calls if c["kind"] == "routing"]
+ assert len(routing_calls) == 1 and routing_calls[0]["target"] == "1 changed untracked"
+ # the leaf is active, its report shows the adopted id, the agent ran and patched
+ assert [a["leaf"] for a in rec.active] == ["core/api"]
+ assert rec.reports["core/api"]["own"] == [HELPER]
+ assert rec.reports["core/api"]["adopted"] == {HELPER: "4:routing_agent"}
+ assert backend.update_calls and backend.update_calls[0][0] == "api"
+ assert "" in (docs / "api.md").read_text()
+ # decision 2: the tree on disk is unchanged
+ assert json.load(open(docs / "module_tree.json")) == tree_r1()
+ assert rec.summary()["n_adopted"] == 1 and rec.summary()["n_adopted_by_agent"] == 1
+
+
+def test_ownership_closure_agent_leaves_untracked(tmp_path):
+ backend = FakeBackend(route_to=None)
+ docs, rec = _run_untracked(tmp_path, backend, UpdateOptions())
+ assert rec.outcome == "incremental"
+ assert len(backend.routing_prompts) == 1
+ assert rec.ownership == [] and rec.active == [] and backend.update_calls == []
+ assert (docs / "api.md").read_text().startswith("# api\n\nRoutes call")
+
+
+def test_ownership_closure_switched_off(tmp_path):
+ backend = FakeBackend(route_to="api")
+ docs, rec = _run_untracked(tmp_path, backend, UpdateOptions(use_ownership_closure=False))
+ assert backend.routing_prompts == [] and rec.ownership == [] and rec.active == []
+ (tmp_path / "r1").mkdir()
+ backend = FakeBackend(route_to="api")
+ docs, rec = _run_untracked(tmp_path / "r1", backend, UpdateOptions.from_rung(1))
+ assert backend.routing_prompts == [] and rec.ownership == [] and rec.active == []