Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ htmlcov/
.hypothesis/
tests/*
!tests/test_gitignore_filtering.py
!tests/test_module_tree_validation.py

# Jupyter
*.ipynb
Expand Down
3 changes: 2 additions & 1 deletion IDE_DRIVEN_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ The server exposes **8 fine-grained tools** (zero LLM config) plus **2 legacy to
| `read_code_components` | Write component source code to workspace `.src` files | Each component → `sources/{sanitized_id}.src`, returns file paths | No |
| `write_doc_file` | Create .md documents with auto Mermaid validation | Writes file directly to output dir | No |
| `edit_doc_file` | Edit documents: `str_replace` / `insert` / `undo` | Modifies file in place, keeps edit history (capped at 20/file) | No |
| `save_module_tree` | Persist IDE agent's module clustering | Writes `module_tree.json` + `first_module_tree.json` + `processing_order.json` | No |
| `save_module_tree` | Persist IDE agent's module clustering | Writes `module_tree.json` + `first_module_tree.json` + `processing_order.json` + `module_tree_validation.json`; returns `warning` for orphaned IDs and `note` for unassigned leaf candidates (IDs capped at 20) | No |
| `get_processing_order` | Compute leaf-first processing order | Writes `processing_order.json` to workspace, returns path | No |
| `get_prompt` | Retrieve prompt templates for each pipeline stage | Returns inline (small payload) | No |
| `close_session` | Write `metadata.json`, clean up workspace files, free memory | Cleans workspace dir + prunes empty parent dirs | No |
Expand Down Expand Up @@ -311,6 +311,7 @@ Each `analyze_repo` call creates a session workspace at `{repo_path}/.codewiki/s
├── changes.json # Incremental change info (optional)
├── summary.json # Compact analysis summary
├── processing_order.json # Leaf-first generation order (after save_module_tree)
├── module_tree_validation.json # Full unmatched/leftover ID lists (after save_module_tree)
└── sources/
└── {sanitized_id}.src # Individual component source files
```
Expand Down
7 changes: 6 additions & 1 deletion codewiki/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,12 @@ def _fine_grained_tools() -> list[Tool]:
"Save the IDE agent's module clustering result. "
"Accepts a JSON module tree and persists it to disk. "
"Computes the leaf-first processing order and writes it to a workspace file. "
"Returns the file path for the processing order."
"Returns the file path for the processing order. "
"Validates each component id against the analysis index and reports "
"orphaned (unmatched) ids as a 'warning' and unassigned leaf-node "
"candidates as an informational 'note' in the response; ID lists in "
"the response are capped at 20 and full lists go to "
"module_tree_validation.json."
),
inputSchema={
"type": "object",
Expand Down
107 changes: 107 additions & 0 deletions codewiki/mcp/tools/module_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@

logger = logging.getLogger(__name__)

# Cap on ID lists embedded in the MCP response. Full lists live in the
# workspace module_tree_validation.json file so stdio stays small.
_MAX_IDS_IN_RESPONSE = 20


def _cap(ids: List[str]) -> Tuple[List[str], bool]:
"""Return (ids capped to _MAX_IDS_IN_RESPONSE, was_truncated)."""
if len(ids) <= _MAX_IDS_IN_RESPONSE:
return ids, False
return ids[:_MAX_IDS_IN_RESPONSE], True


def _get_processing_order(module_tree: Dict[str, Any], parent_path: List[str] | None = None) -> List[Dict[str, Any]]:
"""Compute leaf-first processing order from a module tree.
Expand Down Expand Up @@ -56,6 +67,46 @@ def _collect(tree: Dict[str, Any], path: List[str]) -> None:
return order


def _collect_component_ids(module_tree: Dict[str, Any]) -> set[str]:
"""Return the set of all component ids referenced across the module tree.

Walks every module (and nested ``children``) and collects the entries of
each module's ``components`` list.
"""
ids: set[str] = set()

def _walk(tree: Dict[str, Any]) -> None:
for module_info in tree.values():
ids.update(module_info.get("components", []) or [])
children = module_info.get("children", {})
if isinstance(children, dict):
_walk(children)

_walk(module_tree)
return ids


def _validate_module_tree(
module_tree: Dict[str, Any],
known_ids: set[str],
candidate_ids: set[str],
) -> Tuple[List[str], List[str]]:
"""Check the tree's component ids against the analysis index.

Returns ``(unmatched_ids, leftover_ids)``:
* ``unmatched_ids``: ids referenced by the tree that do not exist in the
index (typos / drift) -- they will be silently omitted from docs.
* ``leftover_ids``: clustering candidate ids (leaf nodes) that are not
assigned to any module -- a coverage gap for the current clustering.
Non-candidate components (excluded / non-essential) are intentionally
not reported as leftover.
"""
assigned = _collect_component_ids(module_tree)
unmatched = sorted(assigned - known_ids)
leftover = sorted(candidate_ids - assigned)
return unmatched, leftover


def handle_save_module_tree(
arguments: Dict[str, Any],
store: SessionStore,
Expand All @@ -68,6 +119,8 @@ def handle_save_module_tree(

module_tree = arguments["module_tree"]
output_dir = session.output_dir
known_ids = set(session.components.keys())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

known_ids = set(session.components.keys()) is the full component index, but clustering by design operates on a subset and intentionally excludes components. The cluster prompt (prompt_template.py:132-137) explicitly says "It's normal that some components are not essential to the repository" and "DO NOT include components that are not essential." The original pipeline also clusters only leaf_nodes (format_potential_core_components(leaf_nodes, ...) in cluster_modules.py).

So on any real repo, every non-leaf and every deliberately-excluded component lands in leftover_component_ids, and the "will receive no documentation" warning fires on every normal save. Since this response is consumed by an LLM agent, a persistent warning is likely to push agents to cram all components into modules just to silence it — degrading clustering quality, the opposite of this PR's intent.

Suggestion:

  • Keep the unmatched_ids check against the full index as-is — that's the real bug-catcher and is correct.
  • Compute leftovers against set(session.leaf_nodes) (the actual clustering candidate set) instead, and present it as informational (e.g. a note field) rather than folding it into warning.

Note the tests don't catch this because _make_session sets leaf_nodes identical to components — worth adding a case where components ⊃ leaf_nodes once the baseline is fixed.

candidate_ids = set(session.leaf_nodes)

# Save both immutable snapshot and mutable working copy
first_path = os.path.join(output_dir, FIRST_MODULE_TREE_FILENAME)
Expand All @@ -83,6 +136,55 @@ def handle_save_module_tree(
# Cache in session
session.module_tree = module_tree

# Validate the tree so orphaned / stale ids surface instead of being
# silently dropped from docs. Unmatched ids are checked against the full
# index; leftovers only against the clustering candidate set (leaf nodes),
# since the cluster prompt deliberately excludes non-essential components.
unmatched_ids, leftover_ids = _validate_module_tree(
module_tree, known_ids, candidate_ids
)
full_validation = {
"unmatched_ids": unmatched_ids,
"unmatched_count": len(unmatched_ids),
"leftover_component_ids": leftover_ids,
"leftover_component_count": len(leftover_ids),
}
if session.workspace is not None:
session.workspace.write_json("module_tree_validation.json", full_validation)

unmatched_capped, unmatched_truncated = _cap(unmatched_ids)
leftover_capped, leftover_truncated = _cap(leftover_ids)
validation = {
"unmatched_ids": unmatched_capped,
"unmatched_count": len(unmatched_ids),
"unmatched_truncated": unmatched_truncated,
"leftover_component_ids": leftover_capped,
"leftover_component_count": len(leftover_ids),
"leftover_truncated": leftover_truncated,
}

warning = ""
if unmatched_ids:
warning = (
f"{len(unmatched_ids)} component id(s) in the module tree do not "
f"exist in the analysis index and will be omitted from docs: "
f"{unmatched_capped}"
)
if unmatched_truncated:
warning += " ... (see module_tree_validation.json for the full list)"
logger.warning("save_module_tree for session %s: %s", session_id, warning)

note = ""
if leftover_ids:
note = (
f"{len(leftover_ids)} clustering candidate component(s) are not "
f"assigned to any module and will receive no documentation: "
f"{leftover_capped}"
)
if leftover_truncated:
note += " ... (see module_tree_validation.json for the full list)"
logger.info("save_module_tree for session %s: %s", session_id, note)

# Compute processing order and write to workspace file
order = _get_processing_order(module_tree)
order_file = None
Expand All @@ -96,13 +198,18 @@ def handle_save_module_tree(
"tree_path": working_path,
"first_tree_path": first_path,
"processing_order_file": order_file,
"validation": validation,
"hint": (
"Read the processing_order.json file for the leaf-first generation order. "
"Process leaf modules first (is_leaf=true), then parent modules. "
"For each leaf module: get_prompt('system_leaf') + read_code_components + write_doc_file. "
"For each parent module: get_prompt('overview_module') + write_doc_file."
),
}
if warning:
result["warning"] = warning
if note:
result["note"] = note
return json.dumps(result, indent=2, ensure_ascii=False)


Expand Down
1 change: 1 addition & 0 deletions codewiki/mcp/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
changes.json
summary.json
processing_order.json
module_tree_validation.json
sources/
{sanitized_component_id}.src
"""
Expand Down
Loading