Skip to content

Commit 2d6d83a

Browse files
anhnh2002claude
andcommitted
fix: incremental-update correctness and MCP tool robustness
- validate_single_diagram: return "" (not an error string) when validation is skipped via MERMAID_VALIDATE=0 or unavailable, so valid diagrams are not reported as syntax errors; latch on the 15s timeout so a broken Node.js setup doesn't block every subsequent diagram - _detect_via_git: exclude output_dir/.codewiki paths from committed and staged/unstaged diffs too (previously only untracked), via a shared _normalize helper; fall back to mtime detection when the stored baseline commit is unreachable (shallow clone/rebase/gc) instead of silently reporting "up to date" - close_session: record the commit analyzed by analyze_repo as the incremental baseline (new SessionState.analyzed_commit) instead of HEAD at close time, and skip the metadata.json baseline update entirely when the session wrote no docs (new SessionState.docs_written) - _legacy_generate_docs: pass commit_id to DocumentationGenerator so metadata.json from the legacy MCP path supports --update - edit_doc_file: save undo history only after validation, immediately before a successful write, so failed edits no longer pollute the undo stack - _safe_filename: cap the sanitized component ID at 180 chars (hash suffix keeps names unique) to stay under NAME_MAX for deep component paths - read_code_components: tolerate Node.source_code being None Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8ffb946 commit 2d6d83a

7 files changed

Lines changed: 104 additions & 60 deletions

File tree

‎codewiki/mcp/server.py‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,16 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
400400
sid = arguments["session_id"]
401401
session = _store.get(sid)
402402
if session:
403-
_write_generation_metadata(session)
403+
# Only stamp the incremental-update baseline when this
404+
# session actually produced docs — otherwise an aborted
405+
# session would make the next analyze_repo report
406+
# "Documentation is up to date" over missing/stale docs.
407+
if session.docs_written > 0:
408+
_write_generation_metadata(session)
409+
else:
410+
logger.info(
411+
"Session %s wrote no docs; skipping metadata.json baseline update", sid
412+
)
404413
# Clean up workspace files on disk
405414
if session.workspace is not None:
406415
session.workspace.cleanup()
@@ -482,8 +491,9 @@ async def _legacy_generate_docs(arguments: dict[str, Any]) -> list[TextContent]:
482491
agent_instructions=agent_instructions or None,
483492
)
484493

494+
from codewiki.cli.utils.repo_validator import get_git_commit_hash
485495
from codewiki.src.be.documentation_generator import DocumentationGenerator
486-
doc_gen = DocumentationGenerator(backend_config)
496+
doc_gen = DocumentationGenerator(backend_config, commit_id=get_git_commit_hash(repo_path) or None)
487497
await doc_gen.run()
488498

489499
generated_files = []
@@ -554,13 +564,13 @@ def _write_generation_metadata(session: SessionState) -> None:
554564
output_dir = Path(session.output_dir)
555565
repo_path = Path(session.repo_path)
556566

557-
commit_id: str | None = None
558-
try:
559-
import git
560-
repo = git.Repo(repo_path, search_parent_directories=True)
561-
commit_id = repo.head.commit.hexsha
562-
except Exception:
563-
pass
567+
# Baseline on the commit analyze_repo saw, NOT the current HEAD:
568+
# commits made mid-session were never analyzed, and recording HEAD
569+
# here would silently exclude them from the next incremental run.
570+
commit_id: str | None = session.analyzed_commit
571+
if not commit_id:
572+
from codewiki.cli.utils.repo_validator import get_git_commit_hash
573+
commit_id = get_git_commit_hash(repo_path) or None
564574

565575
from datetime import datetime
566576

‎codewiki/mcp/session.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ class SessionState:
3939
module_tree: Dict[str, Any] = field(default_factory=dict)
4040
registry: Dict[str, Any] = field(default_factory=dict)
4141
workspace: Optional[SessionWorkspace] = field(default=None)
42+
# HEAD commit at analyze_repo time — the incremental-update baseline.
43+
# close_session must record this commit (not the HEAD at close time),
44+
# otherwise commits made mid-session are baselined away undocumented.
45+
analyzed_commit: Optional[str] = None
46+
# Number of successful write_doc_file/edit_doc_file operations; used to
47+
# skip the metadata.json baseline update when a session wrote no docs.
48+
docs_written: int = 0
4249
created_at: float = field(default_factory=time.time)
4350
last_accessed: float = field(default_factory=time.time)
4451

‎codewiki/mcp/tools/analysis.py‎

Lines changed: 52 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -125,29 +125,8 @@ def _detect_via_git(
125125
if subpath == ".":
126126
subpath = ""
127127

128-
changed: list[str] = []
129-
method = "git"
130-
131-
# 1) Committed changes since last generation
132-
if prev_commit != current_commit:
133-
try:
134-
diff_index = repo.commit(prev_commit).diff(current_commit)
135-
seen: set[str] = set()
136-
for diff in diff_index:
137-
for p in (diff.a_path, diff.b_path):
138-
if p and p not in seen:
139-
if subpath:
140-
if p.startswith(subpath + "/"):
141-
p = p[len(subpath) + 1:]
142-
else:
143-
continue # outside target subdirectory
144-
changed.append(p)
145-
seen.add(p)
146-
except Exception:
147-
pass
148-
149-
# 2) Uncommitted changes: staged (index vs HEAD) + unstaged (working tree vs index) + untracked
150-
# Compute output_dir relative prefix so we can skip generated docs
128+
# Output-dir prefix (relative to repo_path) so generated docs, metadata
129+
# and session workspace files never count as source changes.
151130
output_dir_rel = ""
152131
if output_dir is not None:
153132
try:
@@ -157,34 +136,57 @@ def _detect_via_git(
157136
except (ValueError, TypeError):
158137
pass
159138

139+
def _normalize(p: str) -> Optional[str]:
140+
"""Strip the monorepo subpath and drop generated/non-source paths."""
141+
if subpath:
142+
if not p.startswith(subpath + "/"):
143+
return None # outside target subdirectory
144+
p = p[len(subpath) + 1:]
145+
if p.startswith(".codewiki/"):
146+
return None
147+
if output_dir_rel and (p == output_dir_rel or p.startswith(output_dir_rel + "/")):
148+
return None
149+
return p
150+
151+
changed: list[str] = []
152+
seen: set[str] = set()
153+
154+
def _add(raw: Optional[str]) -> None:
155+
if raw:
156+
p = _normalize(raw)
157+
if p and p not in seen:
158+
changed.append(p)
159+
seen.add(p)
160+
161+
# 1) Committed changes since last generation
162+
if prev_commit != current_commit:
163+
try:
164+
diff_index = repo.commit(prev_commit).diff(current_commit)
165+
except Exception:
166+
# Baseline commit unreachable (shallow clone, rebase, gc).
167+
# Committed changes can't be enumerated, and returning an empty
168+
# list here would falsely report "up to date" on a clean tree —
169+
# fall back to mtime detection instead.
170+
logger.warning(
171+
"Stored commit %s is unreachable in %s; falling back to mtime detection",
172+
prev_commit, repo_path,
173+
)
174+
return None
175+
for diff in diff_index:
176+
for p in (diff.a_path, diff.b_path):
177+
_add(p)
178+
179+
# 2) Uncommitted changes: staged (index vs HEAD) + unstaged (working tree vs index) + untracked
160180
try:
161181
for d in list(repo.index.diff("HEAD")) + list(repo.index.diff(None)):
162-
p = d.a_path
163-
if p and p not in changed:
164-
if subpath:
165-
if p.startswith(subpath + "/"):
166-
p = p[len(subpath) + 1:]
167-
else:
168-
continue
169-
changed.append(p)
182+
_add(d.a_path)
183+
_add(d.b_path)
170184
for item in repo.untracked_files:
171-
p = item
172-
if subpath:
173-
if p.startswith(subpath + "/"):
174-
p = p[len(subpath) + 1:]
175-
else:
176-
continue
177-
# Skip generated docs and session workspace files
178-
if p.startswith(".codewiki/"):
179-
continue
180-
if output_dir_rel and (p == output_dir_rel or p.startswith(output_dir_rel + "/")):
181-
continue
182-
if p not in changed:
183-
changed.append(p)
185+
_add(item)
184186
except Exception:
185187
pass
186188

187-
return {"changed_files": changed, "method": method}
189+
return {"changed_files": changed, "method": "git"}
188190

189191

190192
def _detect_via_mtime(
@@ -327,6 +329,11 @@ def handle_analyze_repo(
327329
leaf_nodes=leaf_nodes,
328330
)
329331

332+
# Record the analyzed commit now — close_session uses it as the
333+
# incremental-update baseline in metadata.json.
334+
from codewiki.cli.utils.repo_validator import get_git_commit_hash
335+
session.analyzed_commit = get_git_commit_hash(repo_path) or None
336+
330337
# Create the workspace with the real session_id
331338
workspace = SessionWorkspace(repo_path, session.session_id)
332339
session.workspace = workspace

‎codewiki/mcp/tools/code_reader.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def handle_read_code_components(
4747
not_found.append(cid)
4848
continue
4949
lang = getattr(node, "language", "")
50-
source = getattr(node, "source_code", "").strip()
50+
source = (getattr(node, "source_code", None) or "").strip()
5151
file_path = workspace.write_component_source(cid, source, lang)
5252
written_files[file_path.name] = cid
5353

‎codewiki/mcp/tools/doc_writer.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ async def handle_write_doc_file(
9595
})
9696

9797
await asyncio.to_thread(doc_path.write_text, content, "utf-8")
98+
session.docs_written += 1
9899

99100
# Mermaid validation
100101
mermaid_result = await _validate_mermaid(str(doc_path), filename)
@@ -150,9 +151,7 @@ async def handle_edit_doc_file(
150151
if not await asyncio.to_thread(doc_path.exists):
151152
return json.dumps({"error": f"File not found: {filename}. Use write_doc_file to create it."})
152153

153-
# Save current content to history before editing
154154
current_content = await asyncio.to_thread(doc_path.read_text, "utf-8")
155-
_save_history(session, doc_path, current_content)
156155

157156
if command == "str_replace":
158157
old_str = arguments.get("old_str")
@@ -167,6 +166,9 @@ async def handle_edit_doc_file(
167166
return json.dumps({"error": f"old_str appears {occurrences} times in {filename}. Make it unique."})
168167

169168
new_content = current_content.replace(old_str, new_str, 1)
169+
# Save history only for edits that actually happen, so undo never
170+
# pops a no-op entry left behind by a failed/rejected command.
171+
_save_history(session, doc_path, current_content)
170172
await asyncio.to_thread(doc_path.write_text, new_content, "utf-8")
171173

172174
# Snippet around the edit
@@ -187,6 +189,7 @@ async def handle_edit_doc_file(
187189
new_str_lines = new_str.split("\n")
188190
lines = lines[:insert_line] + new_str_lines + lines[insert_line:]
189191
new_content = "\n".join(lines)
192+
_save_history(session, doc_path, current_content)
190193
await asyncio.to_thread(doc_path.write_text, new_content, "utf-8")
191194

192195
start = max(0, insert_line - 4)
@@ -196,6 +199,8 @@ async def handle_edit_doc_file(
196199
else:
197200
return json.dumps({"error": f"Unknown command: {command}. Use str_replace, insert, or undo."})
198201

202+
session.docs_written += 1
203+
199204
# Mermaid validation
200205
mermaid_result = await _validate_mermaid(str(doc_path), filename)
201206

‎codewiki/mcp/workspace.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,13 @@ def _safe_filename(component_id: str) -> str:
4242
A short hash suffix is appended to prevent collisions when different
4343
component IDs sanitize to the same string (e.g. ``src/a-b.py`` vs
4444
``src/a_b.py``).
45+
46+
The sanitized part is truncated so the filename stays under the
47+
common 255-byte NAME_MAX (separator chars expand to two chars each,
48+
so deep paths overflow it easily); the hash suffix keeps truncated
49+
names unique.
4550
"""
46-
sanitized = re.sub(r"[^\w\-.]", "__", component_id)
51+
sanitized = re.sub(r"[^\w\-.]", "__", component_id)[:180]
4752
hash_suffix = hashlib.sha1(component_id.encode()).hexdigest()[:8]
4853
return f"{sanitized}_{hash_suffix}.src"
4954

‎codewiki/src/be/utils.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,17 +237,27 @@ async def validate_single_diagram(diagram_content: str, diagram_num: int, line_s
237237
Returns:
238238
Error message if invalid, empty string if valid
239239
"""
240+
global _MERMAID_PY_BROKEN
240241
core_error = await _try_pythonmonkey_parse(diagram_content)
241242
if core_error is None:
242243
if _MERMAID_PY_BROKEN:
243-
return f" Diagram {diagram_num}: validation skipped (mermaid-py unavailable; set MERMAID_VALIDATE=0 to suppress)"
244+
# Validation disabled/unavailable is not a syntax error — a
245+
# non-empty return here would make callers report valid diagrams
246+
# as broken and send agents into fix loops.
247+
logger.debug("Diagram %d: validation skipped (mermaid-py disabled or unavailable)", diagram_num)
248+
return ""
244249
try:
245250
core_error = await asyncio.wait_for(
246251
asyncio.to_thread(_parse_via_mermaid_py, diagram_content),
247252
timeout=15.0,
248253
)
249254
except asyncio.TimeoutError:
250-
return f" Diagram {diagram_num}: validation timed out (15s) — diagram may be invalid"
255+
# Inconclusive, not a parse failure. Latch so the remaining
256+
# diagrams (and later calls) don't each block 15s on the same
257+
# broken Node.js setup.
258+
_MERMAID_PY_BROKEN = True
259+
logger.warning("Diagram %d: mermaid validation timed out (15s); skipping further validation", diagram_num)
260+
return ""
251261
except Exception as e:
252262
return f" Diagram {diagram_num}: Exception during validation - {str(e)}"
253263

0 commit comments

Comments
 (0)