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 == []