pystdoc 0.7.2__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {pystdoc-0.7.2/src/pystdoc.egg-info → pystdoc-0.8.0}/PKG-INFO +10 -6
  2. {pystdoc-0.7.2 → pystdoc-0.8.0}/README.md +9 -5
  3. {pystdoc-0.7.2 → pystdoc-0.8.0}/pyproject.toml +43 -1
  4. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/__init__.py +1 -1
  5. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/db.py +42 -12
  6. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/design_engine.py +209 -39
  7. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/engine.py +26 -39
  8. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/llm_client.py +3 -4
  9. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/mcp_server.py +0 -3
  10. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/perf.py +2 -2
  11. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/query.py +1 -1
  12. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/report_engine.py +6 -2
  13. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/scanner.py +1 -1
  14. pystdoc-0.8.0/src/pystdoc/similarity.py +45 -0
  15. {pystdoc-0.7.2 → pystdoc-0.8.0/src/pystdoc.egg-info}/PKG-INFO +10 -6
  16. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc.egg-info/SOURCES.txt +2 -0
  17. pystdoc-0.8.0/tests/test_reduce_cache.py +78 -0
  18. {pystdoc-0.7.2 → pystdoc-0.8.0}/LICENSE +0 -0
  19. {pystdoc-0.7.2 → pystdoc-0.8.0}/setup.cfg +0 -0
  20. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/__main__.py +0 -0
  21. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/adapters/__init__.py +0 -0
  22. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/adapters/base.py +0 -0
  23. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/adapters/clang_adapter.py +0 -0
  24. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/adapters/generic_adapter.py +0 -0
  25. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/adapters/java_adapter.py +0 -0
  26. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/adapters/kotlin_adapter.py +0 -0
  27. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/adapters/python_adapter.py +0 -0
  28. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/cache.py +0 -0
  29. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/call_graph.py +0 -0
  30. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/cli.py +0 -0
  31. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/compilation_db.py +0 -0
  32. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/config.py +0 -0
  33. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/doc_writer.py +0 -0
  34. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/hasher.py +0 -0
  35. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/interrupt.py +0 -0
  36. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/parser_clang.py +0 -0
  37. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/parser_generic.py +0 -0
  38. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/parser_java.py +0 -0
  39. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/parser_kotlin.py +0 -0
  40. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/parser_python.py +0 -0
  41. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/progress.py +0 -0
  42. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/symbols.py +0 -0
  43. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/ui_detector.py +0 -0
  44. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc/watcher.py +0 -0
  45. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc.egg-info/dependency_links.txt +0 -0
  46. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc.egg-info/entry_points.txt +0 -0
  47. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc.egg-info/requires.txt +0 -0
  48. {pystdoc-0.7.2 → pystdoc-0.8.0}/src/pystdoc.egg-info/top_level.txt +0 -0
  49. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_adapters.py +0 -0
  50. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_cli.py +0 -0
  51. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_config.py +0 -0
  52. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_coverage_boost.py +0 -0
  53. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_design_engine_full.py +0 -0
  54. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_designgen.py +0 -0
  55. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_docgen.py +0 -0
  56. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_engine.py +0 -0
  57. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_engine_full.py +0 -0
  58. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_final_100.py +0 -0
  59. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_interrupt.py +0 -0
  60. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_live_server_100.py +0 -0
  61. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_llm_client.py +0 -0
  62. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_mcp_server.py +0 -0
  63. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_parser_generic.py +0 -0
  64. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_parser_java.py +0 -0
  65. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_parser_kotlin.py +0 -0
  66. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_pep8.py +0 -0
  67. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_perf.py +0 -0
  68. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_progress.py +0 -0
  69. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_query.py +0 -0
  70. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_reportgen.py +0 -0
  71. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_ui_detector.py +0 -0
  72. {pystdoc-0.7.2 → pystdoc-0.8.0}/tests/test_watcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pystdoc
3
- Version: 0.7.2
3
+ Version: 0.8.0
4
4
  Summary: Python Structural & Topological Documentation Engine (pystdoc): High-precision, LLM-powered hierarchical codebase and architecture documentation generator.
5
5
  Author: tab4moji
6
6
  License: MIT
@@ -78,21 +78,25 @@ your_project/
78
78
  - Evaluates call graphs in $O(V+E)$ linear time.
79
79
  - Automatically breaks cyclic mutual recursions and organizes code symbols into dependency-safe execution levels.
80
80
  - Level-by-level parallel LLM execution guarantees context-rich bottom-up summaries without race conditions.
81
- 2. **3-in-1 Unified Documentation Pipeline**:
81
+ 2. **High-Speed Incremental Sync & Semantic Change Propagation (v0.8.0)**:
82
+ - **Selective Bottom-Up Propagation**: Detects AST and logic-level code differences. If regenerated specifications remain semantically unchanged (evaluated by deterministic Jaccard / token-overlap metrics), change propagation terminates immediately without triggering cascading LLM re-analysis across upper modules.
83
+ - **Content-Defined Map-Reduce Caching**: Intermediate summaries in architecture synthesis (`designgen`) are chunked using stable hash boundaries and cached per group in SQLite, ensuring that only groups containing edited functions are re-summarized.
84
+ - **Architectural Entry-Point Filtering**: For system execution modeling (`execution_model.md`), key architectural functions (entry points, CLI routers, and core dispatchers) are automatically filtered and passed directly into the LLM context, bypassing heavy multi-step Map-Reduce loops and speeding up generation by up to 90%+.
85
+ 3. **3-in-1 Unified Documentation Pipeline**:
82
86
  - `docgen`: Bottom-up & top-down symbol-level documentation with SHA-256 and SQLite caching (`.pystdoc/documents/`).
83
87
  - `designgen`: Map-Reduce architectural synthesis (`.pystdoc/design/`).
84
88
  - `reportgen` / `pystdoc`: Executive summary README (`.pystdoc/README.md`) answering *"What does this project actually do?"*
85
- 3. **Multi-Language Codebase Deep Understanding (C/C++, Python, Java, Kotlin, Shell)**:
89
+ 4. **Multi-Language Codebase Deep Understanding (C/C++, Python, Java, Kotlin, Shell)**:
86
90
  - **C/C++**: `compile_commands.json` integration, include path resolution, and macro expansion via `libclang`.
87
91
  - **Java / Kotlin**: AST parsing via `javalang` & `kopyt` with class hierarchy, companion objects, and data classes.
88
92
  - **Python & Shell**: AST visitor and generic regex parser with import/call extraction.
89
93
  - **Fully Qualified Domain Names (FQDN)**: Disambiguates identical symbol names across large monorepos.
90
- 4. **Feature Location & UI Component Pinpointing (`locate` / `pystdoc_locate_feature`)**:
94
+ 5. **Feature Location & UI Component Pinpointing (`locate` / `pystdoc_locate_feature`)**:
91
95
  - Pinpoint exact files, functions, UI elements, and line ranges (`Lines X-Y`) from high-level natural language queries (e.g., *"where is the debug notification button"*, *"add auth header"*) without running brute-force `Glob`/`Grep` across the entire project.
92
96
  - Built-in multi-lingual token scoring with automatic boosting for UI elements (`@Composable`, `Button`, `Dialog`, `Activity`, `Modifier`, etc.).
93
- 5. **Impact & Inbound/Outbound Dependency Analysis (`impact` / `pystdoc_trace_impact`)**:
97
+ 6. **Impact & Inbound/Outbound Dependency Analysis (`impact` / `pystdoc_trace_impact`)**:
94
98
  - Instant 360-degree dependency tracing: inspect where a symbol is called from (*Inbound Callers / References*) and what it uses (*Outbound Dependencies*) before making breaking code changes or deletions.
95
- 6. **Standard LLM Options & Multi-Language Support**:
99
+ 7. **Standard LLM Options & Multi-Language Support**:
96
100
  - Works with **Ollama, LiteRT-LM, vLLM, and OpenAI API**.
97
101
  - Supports `--host`, `--model`, `--token` / `--api-key`, `--context-size`, and `--language` (defaults to terminal language, e.g. `Japanese`, `English`, `日本語`).
98
102
 
@@ -49,21 +49,25 @@ your_project/
49
49
  - Evaluates call graphs in $O(V+E)$ linear time.
50
50
  - Automatically breaks cyclic mutual recursions and organizes code symbols into dependency-safe execution levels.
51
51
  - Level-by-level parallel LLM execution guarantees context-rich bottom-up summaries without race conditions.
52
- 2. **3-in-1 Unified Documentation Pipeline**:
52
+ 2. **High-Speed Incremental Sync & Semantic Change Propagation (v0.8.0)**:
53
+ - **Selective Bottom-Up Propagation**: Detects AST and logic-level code differences. If regenerated specifications remain semantically unchanged (evaluated by deterministic Jaccard / token-overlap metrics), change propagation terminates immediately without triggering cascading LLM re-analysis across upper modules.
54
+ - **Content-Defined Map-Reduce Caching**: Intermediate summaries in architecture synthesis (`designgen`) are chunked using stable hash boundaries and cached per group in SQLite, ensuring that only groups containing edited functions are re-summarized.
55
+ - **Architectural Entry-Point Filtering**: For system execution modeling (`execution_model.md`), key architectural functions (entry points, CLI routers, and core dispatchers) are automatically filtered and passed directly into the LLM context, bypassing heavy multi-step Map-Reduce loops and speeding up generation by up to 90%+.
56
+ 3. **3-in-1 Unified Documentation Pipeline**:
53
57
  - `docgen`: Bottom-up & top-down symbol-level documentation with SHA-256 and SQLite caching (`.pystdoc/documents/`).
54
58
  - `designgen`: Map-Reduce architectural synthesis (`.pystdoc/design/`).
55
59
  - `reportgen` / `pystdoc`: Executive summary README (`.pystdoc/README.md`) answering *"What does this project actually do?"*
56
- 3. **Multi-Language Codebase Deep Understanding (C/C++, Python, Java, Kotlin, Shell)**:
60
+ 4. **Multi-Language Codebase Deep Understanding (C/C++, Python, Java, Kotlin, Shell)**:
57
61
  - **C/C++**: `compile_commands.json` integration, include path resolution, and macro expansion via `libclang`.
58
62
  - **Java / Kotlin**: AST parsing via `javalang` & `kopyt` with class hierarchy, companion objects, and data classes.
59
63
  - **Python & Shell**: AST visitor and generic regex parser with import/call extraction.
60
64
  - **Fully Qualified Domain Names (FQDN)**: Disambiguates identical symbol names across large monorepos.
61
- 4. **Feature Location & UI Component Pinpointing (`locate` / `pystdoc_locate_feature`)**:
65
+ 5. **Feature Location & UI Component Pinpointing (`locate` / `pystdoc_locate_feature`)**:
62
66
  - Pinpoint exact files, functions, UI elements, and line ranges (`Lines X-Y`) from high-level natural language queries (e.g., *"where is the debug notification button"*, *"add auth header"*) without running brute-force `Glob`/`Grep` across the entire project.
63
67
  - Built-in multi-lingual token scoring with automatic boosting for UI elements (`@Composable`, `Button`, `Dialog`, `Activity`, `Modifier`, etc.).
64
- 5. **Impact & Inbound/Outbound Dependency Analysis (`impact` / `pystdoc_trace_impact`)**:
68
+ 6. **Impact & Inbound/Outbound Dependency Analysis (`impact` / `pystdoc_trace_impact`)**:
65
69
  - Instant 360-degree dependency tracing: inspect where a symbol is called from (*Inbound Callers / References*) and what it uses (*Outbound Dependencies*) before making breaking code changes or deletions.
66
- 6. **Standard LLM Options & Multi-Language Support**:
70
+ 7. **Standard LLM Options & Multi-Language Support**:
67
71
  - Works with **Ollama, LiteRT-LM, vLLM, and OpenAI API**.
68
72
  - Supports `--host`, `--model`, `--token` / `--api-key`, `--context-size`, and `--language` (defaults to terminal language, e.g. `Japanese`, `English`, `日本語`).
69
73
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pystdoc"
7
- version = "0.7.2"
7
+ version = "0.8.0"
8
8
  description = "Python Structural & Topological Documentation Engine (pystdoc): High-precision, LLM-powered hierarchical codebase and architecture documentation generator."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -51,3 +51,45 @@ filterwarnings = [
51
51
  "ignore::ResourceWarning",
52
52
  "ignore::pytest.PytestUnraisableExceptionWarning",
53
53
  ]
54
+
55
+ [tool.pylint.main]
56
+ # Third-party libs without static member info (javalang) / libclang.
57
+ extension-pkg-allow-list = []
58
+
59
+ [tool.pylint.messages_control]
60
+ # Intentional design choices of this code base:
61
+ # - broad-exception-caught: parsers must never crash a whole sync run
62
+ # - import-outside-toplevel: optional/heavy deps are imported lazily
63
+ # - complexity/size metrics, duplicate-code, cyclic-import (adapter registry)
64
+ # - invalid-name: ast.NodeVisitor visit_* methods and module singletons
65
+ disable = [
66
+ "broad-exception-caught",
67
+ "import-outside-toplevel",
68
+ "too-many-locals",
69
+ "too-many-branches",
70
+ "too-many-statements",
71
+ "too-many-arguments",
72
+ "too-many-positional-arguments",
73
+ "too-many-return-statements",
74
+ "too-many-instance-attributes",
75
+ "too-many-nested-blocks",
76
+ "too-many-lines",
77
+ "too-few-public-methods",
78
+ "duplicate-code",
79
+ "cyclic-import",
80
+ "no-else-return",
81
+ "no-else-break",
82
+ "invalid-name",
83
+ "disallowed-name",
84
+ "global-statement",
85
+ "protected-access",
86
+ "unnecessary-ellipsis",
87
+ "missing-function-docstring",
88
+ "missing-class-docstring",
89
+ "missing-module-docstring",
90
+ "consider-using-with",
91
+ "attribute-defined-outside-init",
92
+ "cell-var-from-loop",
93
+ "unused-argument",
94
+ "no-member",
95
+ ]
@@ -1,3 +1,3 @@
1
1
  """pystdoc: Python Structural & Topological Documentation Engine (pystdoc)."""
2
2
 
3
- __version__ = "0.7.2"
3
+ __version__ = "0.8.0"
@@ -6,6 +6,7 @@ import threading
6
6
  from pathlib import Path
7
7
  from typing import Any, Dict, List, Optional, Tuple
8
8
 
9
+ from pystdoc.similarity import is_semantically_same
9
10
  from pystdoc.symbols import Symbol
10
11
 
11
12
 
@@ -71,9 +72,16 @@ class DocgenDB:
71
72
  target_key TEXT PRIMARY KEY,
72
73
  input_hash TEXT NOT NULL,
73
74
  content TEXT NOT NULL,
75
+ input_text TEXT,
74
76
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
75
77
  );
76
78
  """)
79
+ try:
80
+ self.conn.execute(
81
+ "ALTER TABLE design_cache ADD COLUMN input_text TEXT;"
82
+ )
83
+ except Exception:
84
+ pass
77
85
 
78
86
  self.conn.execute("""
79
87
  CREATE TABLE IF NOT EXISTS symbols_metadata (
@@ -231,41 +239,63 @@ class DocgenDB:
231
239
  return None
232
240
 
233
241
  def load_design_cache(
234
- self, target_key: str, current_input_hash: str
242
+ self,
243
+ target_key: str,
244
+ current_input_hash: str,
245
+ input_text: Optional[str] = None,
246
+ similarity_threshold: float = 0.85,
235
247
  ) -> Optional[str]:
236
- """Load cached design document if input hash matches."""
248
+ """Load cached design document if the input is unchanged.
249
+
250
+ Exact hash match is a hit. If ``input_text`` is given, a stored
251
+ input that is semantically similar (>= threshold) is also a hit,
252
+ so that wording-only changes do not cascade upward.
253
+ """
237
254
  with self.lock:
238
255
  cur = self.conn.cursor()
239
256
  cur.execute(
240
257
  """
241
- SELECT content FROM design_cache
242
- WHERE target_key = ? AND input_hash = ?
258
+ SELECT content, input_hash, input_text FROM design_cache
259
+ WHERE target_key = ?
243
260
  """,
244
- (target_key, current_input_hash),
261
+ (target_key,),
245
262
  )
246
263
  row = cur.fetchone()
247
- if row:
248
- return row["content"]
264
+ if not row:
249
265
  return None
266
+ if row["input_hash"] == current_input_hash:
267
+ return row["content"]
268
+ if input_text is not None and row["input_text"]:
269
+ if is_semantically_same(
270
+ row["input_text"], input_text, similarity_threshold
271
+ ):
272
+ return row["content"]
273
+ return None
250
274
 
251
275
  def save_design_cache(
252
- self, target_key: str, input_hash: str, content: str
276
+ self,
277
+ target_key: str,
278
+ input_hash: str,
279
+ content: str,
280
+ input_text: Optional[str] = None,
253
281
  ) -> None:
254
- """Save design doc content and input hash with immediate commit."""
282
+ """Save design doc content, input hash and text (immediate commit)."""
255
283
  with self.lock:
256
284
  with self.conn:
257
285
  self.conn.execute(
258
286
  """
259
287
  INSERT INTO design_cache (
260
- target_key, input_hash, content, updated_at
288
+ target_key, input_hash, content, input_text,
289
+ updated_at
261
290
  )
262
- VALUES (?, ?, ?, CURRENT_TIMESTAMP)
291
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
263
292
  ON CONFLICT(target_key) DO UPDATE SET
264
293
  input_hash=excluded.input_hash,
265
294
  content=excluded.content,
295
+ input_text=excluded.input_text,
266
296
  updated_at=CURRENT_TIMESTAMP;
267
297
  """,
268
- (target_key, input_hash, content),
298
+ (target_key, input_hash, content, input_text),
269
299
  )
270
300
 
271
301
  def save_symbol_metadata(
@@ -169,6 +169,43 @@ def chunk_list_by_size(
169
169
  return chunks
170
170
 
171
171
 
172
+ def chunk_list_stable(
173
+ items: List[str], max_items: int = 15, max_chars: int = 3500
174
+ ) -> List[List[str]]:
175
+ """Split items into chunks whose boundaries are content-defined.
176
+
177
+ A boundary is placed after an item whose hash hits a target value, so
178
+ inserting/removing/editing one item only changes the chunk(s) around
179
+ it; the rest of the chunks stay byte-identical (cache friendly).
180
+ Size limits are still enforced as hard boundaries.
181
+ """
182
+ divisor = max(2, max_items // 2)
183
+ chunks: List[List[str]] = []
184
+ current_chunk: List[str] = []
185
+ current_length = 0
186
+
187
+ for item in items:
188
+ item_len = len(item)
189
+ if current_chunk and (
190
+ len(current_chunk) >= max_items
191
+ or current_length + item_len > max_chars
192
+ ):
193
+ chunks.append(current_chunk)
194
+ current_chunk = []
195
+ current_length = 0
196
+ current_chunk.append(item)
197
+ current_length += item_len
198
+ digest = hashlib.sha1(item.encode("utf-8")).hexdigest()
199
+ if int(digest[:8], 16) % divisor == 0:
200
+ chunks.append(current_chunk)
201
+ current_chunk = []
202
+ current_length = 0
203
+
204
+ if current_chunk:
205
+ chunks.append(current_chunk)
206
+ return chunks
207
+
208
+
172
209
  def hierarchical_reduce_summaries(
173
210
  items: List[str],
174
211
  category_title: str,
@@ -178,8 +215,15 @@ def hierarchical_reduce_summaries(
178
215
  max_items_per_chunk: int = 15,
179
216
  max_chars_per_chunk: int = 3500,
180
217
  depth: int = 0,
218
+ db: Optional[DocgenDB] = None,
219
+ force: bool = False,
181
220
  ) -> str:
182
- """Recursively reduce descriptions into a concise summary."""
221
+ """Recursively reduce descriptions into a concise summary.
222
+
223
+ When ``db`` is given, each group summary is cached by the hash of its
224
+ input text, and groups are formed by content-defined chunking. Thus
225
+ only the groups that contain changed items are sent to the LLM again.
226
+ """
183
227
  if not items:
184
228
  return "(None)"
185
229
 
@@ -189,15 +233,25 @@ def hierarchical_reduce_summaries(
189
233
  ) or depth > 5:
190
234
  return "\n".join(items)
191
235
 
192
- chunks = chunk_list_by_size(
193
- items, max_items=max_items_per_chunk, max_chars=max_chars_per_chunk
194
- )
236
+ if db is not None:
237
+ chunks = chunk_list_stable(
238
+ sorted(items),
239
+ max_items=max_items_per_chunk,
240
+ max_chars=max_chars_per_chunk,
241
+ )
242
+ else:
243
+ chunks = chunk_list_by_size(
244
+ items,
245
+ max_items=max_items_per_chunk,
246
+ max_chars=max_chars_per_chunk,
247
+ )
195
248
  if len(chunks) <= 1 and depth > 0:
196
249
  return "\n".join(items)
197
250
 
198
251
  intermediate_summaries: List[str] = []
199
252
 
200
253
  for idx, chunk in enumerate(chunks, 1):
254
+ InterruptionState.check_interrupted()
201
255
  chunk_text = "\n".join(chunk)
202
256
  if not llm_client:
203
257
  summary_item = (
@@ -207,6 +261,26 @@ def hierarchical_reduce_summaries(
207
261
  intermediate_summaries.append(summary_item)
208
262
  continue
209
263
 
264
+ group_cache_key = ""
265
+ group_hash = ""
266
+ if db is not None:
267
+ group_hash = hashlib.sha256(
268
+ f"{language}\n{category_title}\n{chunk_text}".encode("utf-8")
269
+ ).hexdigest()
270
+ group_cache_key = (
271
+ f"reduce::{language}::{category_title}::{group_hash}"
272
+ )
273
+ if not force:
274
+ cached_summary = db.load_design_cache(
275
+ group_cache_key, group_hash
276
+ )
277
+ if cached_summary:
278
+ # Header without index keeps upper levels stable.
279
+ intermediate_summaries.append(
280
+ f"#### Group Summary\n{cached_summary.strip()}"
281
+ )
282
+ continue
283
+
210
284
  grp_info = f"(Group {idx}/{len(chunks)})"
211
285
  prompt = f"""Please analyze {category_title} {grp_info}
212
286
  and create a concise, factual summary (200-400 words) in {language}.
@@ -231,9 +305,17 @@ Output as Markdown bullet points in {language}:"""
231
305
 
232
306
  try:
233
307
  summary = llm_client.chat_completion(messages)
234
- intermediate_summaries.append(
235
- f"#### Group {idx} Summary\n{summary.strip()}"
236
- )
308
+ if db is not None:
309
+ db.save_design_cache(
310
+ group_cache_key, group_hash, summary.strip()
311
+ )
312
+ intermediate_summaries.append(
313
+ f"#### Group Summary\n{summary.strip()}"
314
+ )
315
+ else:
316
+ intermediate_summaries.append(
317
+ f"#### Group {idx} Summary\n{summary.strip()}"
318
+ )
237
319
  except Exception as e:
238
320
  if not allow_fallback:
239
321
  raise LLMError(
@@ -257,6 +339,8 @@ Output as Markdown bullet points in {language}:"""
257
339
  max_items_per_chunk=max_items_per_chunk,
258
340
  max_chars_per_chunk=max_chars_per_chunk,
259
341
  depth=depth + 1,
342
+ db=db,
343
+ force=force,
260
344
  )
261
345
 
262
346
  return "\n\n".join(intermediate_summaries)
@@ -292,7 +376,9 @@ def generate_data_models_doc(
292
376
  out_file = target_dir / ".pystdoc" / "design" / "data_models.md"
293
377
 
294
378
  if db and not force and out_file.exists():
295
- cached_content = db.load_design_cache(cache_key, input_hash)
379
+ cached_content = db.load_design_cache(
380
+ cache_key, input_hash, combined_input
381
+ )
296
382
  if cached_content:
297
383
  if tracker:
298
384
  tracker.advance(1, extra="[Cached]: data_models.md")
@@ -305,20 +391,37 @@ def generate_data_models_doc(
305
391
  if tracker:
306
392
  tracker.render_current(extra="data_models.md")
307
393
 
308
- reduced_types_summary = hierarchical_reduce_summaries(
309
- types_raw,
310
- "Type Definitions",
311
- llm_client,
312
- language=norm_lang,
313
- allow_fallback=allow_fallback,
314
- )
315
- reduced_vars_summary = hierarchical_reduce_summaries(
316
- vars_raw,
317
- "Variables and Fields",
318
- llm_client,
319
- language=norm_lang,
320
- allow_fallback=allow_fallback,
321
- )
394
+ total_types_len = sum(len(it) for it in types_raw)
395
+ if total_types_len <= 15000 or len(types_raw) <= 50:
396
+ reduced_types_summary = "\n".join(types_raw) if types_raw else "(None)"
397
+ else:
398
+ reduced_types_summary = hierarchical_reduce_summaries(
399
+ types_raw,
400
+ "Type Definitions",
401
+ llm_client,
402
+ language=norm_lang,
403
+ allow_fallback=allow_fallback,
404
+ max_items_per_chunk=30,
405
+ max_chars_per_chunk=8000,
406
+ db=db,
407
+ force=force,
408
+ )
409
+
410
+ total_vars_len = sum(len(it) for it in vars_raw)
411
+ if total_vars_len <= 15000 or len(vars_raw) <= 50:
412
+ reduced_vars_summary = "\n".join(vars_raw) if vars_raw else "(None)"
413
+ else:
414
+ reduced_vars_summary = hierarchical_reduce_summaries(
415
+ vars_raw,
416
+ "Variables and Fields",
417
+ llm_client,
418
+ language=norm_lang,
419
+ allow_fallback=allow_fallback,
420
+ max_items_per_chunk=30,
421
+ max_chars_per_chunk=8000,
422
+ db=db,
423
+ force=force,
424
+ )
322
425
 
323
426
  prompt = f"""Please analyze aggregated types and variables/fields,
324
427
  and create a factual "Data Models & Data Structure Design" doc in {norm_lang}.
@@ -405,10 +508,48 @@ Structure the Markdown as follows:
405
508
 
406
509
  write_flushed_text(out_file, content.strip() + "\n")
407
510
  if db:
408
- db.save_design_cache(cache_key, input_hash, content)
511
+ db.save_design_cache(
512
+ cache_key, input_hash, content, combined_input
513
+ )
409
514
  return content
410
515
 
411
516
 
517
+ def select_architectural_functions(
518
+ fn_docs: List[Dict[str, Any]], max_count: int = 60
519
+ ) -> List[Dict[str, Any]]:
520
+ """Select key architectural functions (entry points, pipelines).
521
+
522
+ Instead of passing hundreds of utility/test/leaf functions, we prioritize:
523
+ 1. Functions with 'main', 'cli', 'run', 'start', 'handler', 'app', 'serve'.
524
+ 2. Functions with UI interaction roles.
525
+ 3. Functions that call many others (pipeline orchestrators).
526
+ 4. Functions with no callers inside the project (root entry points).
527
+ """
528
+ if len(fn_docs) <= max_count:
529
+ return fn_docs
530
+
531
+ def score(d: Dict[str, Any]) -> int:
532
+ fname = d["file_name"].lower()
533
+ s = 0
534
+ if any(
535
+ k in fname
536
+ for k in (
537
+ "main", "cli", "run", "start", "handler", "app", "serve",
538
+ "pipeline", "engine", "orchestrat"
539
+ )
540
+ ):
541
+ s += 20
542
+ if d.get("ui_role"):
543
+ s += 15
544
+ s += len(d.get("callees") or []) * 2
545
+ if not d.get("referencing_funcs"):
546
+ s += 5
547
+ return s
548
+
549
+ scored = sorted(fn_docs, key=score, reverse=True)
550
+ return scored[:max_count]
551
+
552
+
412
553
  def generate_execution_model_doc(
413
554
  fn_docs: List[Dict[str, Any]],
414
555
  llm_client: Optional[LLMClient],
@@ -420,11 +561,15 @@ def generate_execution_model_doc(
420
561
  ui_info: Optional[Dict[str, Any]] = None,
421
562
  tracker: Optional[PhaseProgressTracker] = None,
422
563
  ) -> str:
423
- """Step 2: Generate execution_model.md with SQLite cache & Map-Reduce."""
564
+ """Step 2: Generate execution_model.md with filtered functions."""
424
565
  norm_lang = normalize_language(language)
566
+
567
+ # 1. Filter to core entry points and high-level callers
568
+ arch_funcs = select_architectural_functions(fn_docs, max_count=60)
569
+
425
570
  funcs_raw = []
426
- for d in fn_docs:
427
- callees_str = ", ".join(d["callees"][:3]) if d["callees"] else "None"
571
+ for d in arch_funcs:
572
+ callees_str = ", ".join(d["callees"][:4]) if d["callees"] else "None"
428
573
  ui_extra = f" / UI Role: {d['ui_role']}" if d.get("ui_role") else ""
429
574
  line_desc = (
430
575
  f"- Function `{d['file_name']}` (Signature: `{d['signature']}`): "
@@ -438,7 +583,9 @@ def generate_execution_model_doc(
438
583
  out_file = target_dir / ".pystdoc" / "design" / "execution_model.md"
439
584
 
440
585
  if db and not force and out_file.exists():
441
- cached_content = db.load_design_cache(cache_key, input_hash)
586
+ cached_content = db.load_design_cache(
587
+ cache_key, input_hash, combined_input
588
+ )
442
589
  if cached_content:
443
590
  if tracker:
444
591
  tracker.advance(
@@ -453,13 +600,22 @@ def generate_execution_model_doc(
453
600
  if tracker:
454
601
  tracker.render_current(extra="execution_model.md")
455
602
 
456
- reduced_funcs_summary = hierarchical_reduce_summaries(
457
- funcs_raw,
458
- "Function Call Structures",
459
- llm_client,
460
- language=norm_lang,
461
- allow_fallback=allow_fallback,
462
- )
603
+ # If items fit comfortably in a single LLM context, directly use them
604
+ total_raw_len = sum(len(it) for it in funcs_raw)
605
+ if total_raw_len <= 18000 or len(funcs_raw) <= 60:
606
+ reduced_funcs_summary = "\n".join(funcs_raw)
607
+ else:
608
+ reduced_funcs_summary = hierarchical_reduce_summaries(
609
+ funcs_raw,
610
+ "Function Call Structures",
611
+ llm_client,
612
+ language=norm_lang,
613
+ allow_fallback=allow_fallback,
614
+ max_items_per_chunk=30,
615
+ max_chars_per_chunk=8000,
616
+ db=db,
617
+ force=force,
618
+ )
463
619
 
464
620
  ui_context_text = ""
465
621
  if ui_info and ui_info.get("has_ui"):
@@ -554,7 +710,9 @@ Structure the Markdown as follows:
554
710
 
555
711
  write_flushed_text(out_file, content.strip() + "\n")
556
712
  if db:
557
- db.save_design_cache(cache_key, input_hash, content)
713
+ db.save_design_cache(
714
+ cache_key, input_hash, content, combined_input
715
+ )
558
716
  return content
559
717
 
560
718
 
@@ -592,7 +750,9 @@ def generate_module_docs(
592
750
  )
593
751
 
594
752
  if db and not force and out_file.exists():
595
- cached_content = db.load_design_cache(cache_key, input_hash)
753
+ cached_content = db.load_design_cache(
754
+ cache_key, input_hash, combined_input
755
+ )
596
756
  if cached_content:
597
757
  if tracker:
598
758
  tracker.advance(
@@ -615,6 +775,8 @@ def generate_module_docs(
615
775
  llm_client,
616
776
  language=norm_lang,
617
777
  allow_fallback=allow_fallback,
778
+ db=db,
779
+ force=force,
618
780
  )
619
781
 
620
782
  prompt = f"""Please analyze elements in module `{mod_name}`
@@ -700,7 +862,9 @@ Structure the Markdown as follows:
700
862
 
701
863
  write_flushed_text(out_file, content.strip() + "\n")
702
864
  if db:
703
- db.save_design_cache(cache_key, input_hash, content)
865
+ db.save_design_cache(
866
+ cache_key, input_hash, content, combined_input
867
+ )
704
868
  module_summaries[mod_name] = content
705
869
 
706
870
  return module_summaries
@@ -740,7 +904,9 @@ def generate_overview_doc(
740
904
  out_file = target_dir / ".pystdoc" / "design" / "overview.md"
741
905
 
742
906
  if db and not force and out_file.exists():
743
- cached_content = db.load_design_cache(cache_key, input_hash)
907
+ cached_content = db.load_design_cache(
908
+ cache_key, input_hash, combined_input
909
+ )
744
910
  if cached_content:
745
911
  if tracker:
746
912
  tracker.advance(1, extra="[Cached]: overview.md")
@@ -758,6 +924,8 @@ def generate_overview_doc(
758
924
  llm_client,
759
925
  language=norm_lang,
760
926
  allow_fallback=allow_fallback,
927
+ db=db,
928
+ force=force,
761
929
  )
762
930
 
763
931
  ui_overview_text = ""
@@ -852,7 +1020,9 @@ Requirements:
852
1020
 
853
1021
  write_flushed_text(out_file, content.strip() + "\n")
854
1022
  if db:
855
- db.save_design_cache(cache_key, input_hash, content)
1023
+ db.save_design_cache(
1024
+ cache_key, input_hash, content, combined_input
1025
+ )
856
1026
  return content
857
1027
 
858
1028
 
@@ -46,6 +46,7 @@ from pystdoc.progress import (
46
46
  )
47
47
  from pystdoc.interrupt import InterruptionState
48
48
  from pystdoc.perf import PerfProfileManager
49
+ from pystdoc.similarity import similarity
49
50
 
50
51
 
51
52
  class FileLock:
@@ -58,7 +59,7 @@ class FileLock:
58
59
 
59
60
  def __enter__(self):
60
61
  self.lock_file.parent.mkdir(parents=True, exist_ok=True)
61
- self.fd = open(self.lock_file, "w")
62
+ self.fd = open(self.lock_file, "w", encoding="utf-8")
62
63
  try:
63
64
  fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
64
65
  except BlockingIOError:
@@ -130,18 +131,6 @@ def check_external_interface_changed(
130
131
  if old_cache is None:
131
132
  return True
132
133
 
133
- def get_tokens(text: Optional[str]) -> Set[str]:
134
- clean = re.sub(
135
- r"[.,。、::\-_\(\)()「」『』`\"'\s\r\n\t]+", " ", text or ""
136
- )
137
- words = set(clean.lower().split())
138
- # Add 2-grams for CJK strings
139
- compact = "".join(clean.lower().split())
140
- if len(compact) >= 2:
141
- for i in range(len(compact) - 1):
142
- words.add(compact[i:i+2])
143
- return words
144
-
145
134
  def normalize_spec_text(text: Optional[str]) -> str:
146
135
  if not text:
147
136
  return ""
@@ -160,28 +149,22 @@ def check_external_interface_changed(
160
149
  if old_in != new_in or old_out != new_out:
161
150
  if bool(old_in) != bool(new_in) or bool(old_out) != bool(new_out):
162
151
  return True
163
- t_in1 = get_tokens(old_cache.get("inputs_note"))
164
- t_in2 = get_tokens(new_sym.inputs_note)
165
- if t_in1 and t_in2:
166
- jaccard_in = len(t_in1 & t_in2) / max(len(t_in1 | t_in2), 1)
167
- if jaccard_in < 0.5:
168
- return True
169
- t_out1 = get_tokens(old_cache.get("outputs_note"))
170
- t_out2 = get_tokens(new_sym.outputs_note)
171
- if t_out1 and t_out2:
172
- jaccard_out = len(t_out1 & t_out2) / max(len(t_out1 | t_out2), 1)
173
- if jaccard_out < 0.5:
174
- return True
152
+ if old_in and new_in and similarity(
153
+ old_cache.get("inputs_note"), new_sym.inputs_note
154
+ ) < 0.5:
155
+ return True
156
+ if old_out and new_out and similarity(
157
+ old_cache.get("outputs_note"), new_sym.outputs_note
158
+ ) < 0.5:
159
+ return True
175
160
 
176
161
  # Check purpose
177
162
  if old_p == new_p:
178
163
  return False
179
- t1 = get_tokens(old_cache.get("purpose"))
180
- t2 = get_tokens(new_sym.purpose)
181
- if t1 and t2:
182
- jaccard = len(t1 & t2) / max(len(t1 | t2), 1)
183
- if jaccard >= 0.5:
184
- return False
164
+ if old_p and new_p and similarity(
165
+ old_cache.get("purpose"), new_sym.purpose
166
+ ) >= 0.5:
167
+ return False
185
168
 
186
169
  return True
187
170
 
@@ -924,7 +907,7 @@ def run_docgen(
924
907
  )
925
908
 
926
909
  # Process Level by Level
927
- for lvl_idx, lvl_nodes in enumerate(level_groups):
910
+ for lvl_nodes in level_groups:
928
911
  InterruptionState.check_interrupted()
929
912
  executor = concurrent.futures.ThreadPoolExecutor(
930
913
  max_workers=concurrency,
@@ -1093,18 +1076,18 @@ def run_docgen(
1093
1076
  parent_container_info is not None
1094
1077
  and any(
1095
1078
  t.symbol.name == parent_container_info["name"]
1096
- and t.unique_id in changed_upstream_ids
1079
+ and t.unique_id in interface_changed_symbol_ids
1097
1080
  for t in type_nodes
1098
1081
  if t.rel_path == v_rel
1099
1082
  )
1100
1083
  )
1101
1084
  is_parent_func_updated = (
1102
1085
  any(
1103
- caller_id in changed_upstream_ids
1086
+ caller_id in interface_changed_symbol_ids
1104
1087
  for caller_id in v_node.direct_caller_ids
1105
1088
  )
1106
1089
  or any(
1107
- f_node.unique_id in changed_upstream_ids
1090
+ f_node.unique_id in interface_changed_symbol_ids
1108
1091
  for f_node in fn_nodes
1109
1092
  if f_node.rel_path == v_rel
1110
1093
  and (
@@ -1182,10 +1165,14 @@ def run_docgen(
1182
1165
  or top_down_res.get("significance")
1183
1166
  or top_down_res.get("top_down_summary")
1184
1167
  )
1185
- if raw_v_role:
1186
- v_sym.top_down_context = sanitize_architectural_context(
1187
- raw_v_role, fallback_text=v_sym.purpose or ""
1188
- )
1168
+ v_sym.top_down_context = sanitize_architectural_context(
1169
+ raw_v_role,
1170
+ fallback_text=(
1171
+ v_sym.top_down_context
1172
+ or v_sym.purpose
1173
+ or v_sym.name
1174
+ ),
1175
+ )
1189
1176
  if top_down_res.get("purpose"):
1190
1177
  v_sym.purpose = top_down_res["purpose"]
1191
1178
  if top_down_res.get("overview"):
@@ -15,8 +15,6 @@ from pystdoc.interrupt import InterruptionState, interruptible_sleep
15
15
  class LLMError(RuntimeError):
16
16
  """Exception raised when LLM generation fails in strict mode."""
17
17
 
18
- pass
19
-
20
18
 
21
19
  def normalize_host_url(host: Optional[str]) -> str:
22
20
  """Normalize user-provided host string into a standard base URL."""
@@ -244,7 +242,8 @@ class LLMClient:
244
242
  """Detect WSL default gateway host IP via ip route or resolv.conf."""
245
243
  try:
246
244
  res = subprocess.run(
247
- ["ip", "route"], capture_output=True, text=True, timeout=1
245
+ ["ip", "route"], capture_output=True, text=True, timeout=1,
246
+ check=False
248
247
  )
249
248
  for line in res.stdout.splitlines():
250
249
  if line.startswith("default via"):
@@ -253,7 +252,7 @@ class LLMClient:
253
252
  pass
254
253
 
255
254
  try:
256
- resolv = Path("/etc/resolv.conf").read_text()
255
+ resolv = Path("/etc/resolv.conf").read_text(encoding="utf-8")
257
256
  for line in resolv.splitlines():
258
257
  if line.strip().startswith("nameserver"):
259
258
  return line.split()[1].strip()
@@ -401,7 +401,6 @@ def create_mcp_server(
401
401
 
402
402
  # Capture output or format response
403
403
  from io import StringIO
404
- import sys
405
404
  old_stdout = sys.stdout
406
405
  old_stderr = sys.stderr
407
406
  capture_out = StringIO()
@@ -439,7 +438,6 @@ def create_mcp_server(
439
438
  return _missing_docgen_msg(target_dir)
440
439
 
441
440
  from io import StringIO
442
- import sys
443
441
  old_stdout = sys.stdout
444
442
  old_stderr = sys.stderr
445
443
  capture = StringIO()
@@ -547,7 +545,6 @@ def create_mcp_server(
547
545
  if not docgen_dir.exists():
548
546
  return _missing_docgen_msg(target_dir)
549
547
  from io import StringIO
550
- import sys
551
548
  old_stdout = sys.stdout
552
549
  capture = StringIO()
553
550
  try:
@@ -266,7 +266,7 @@ class PerfProfileManager:
266
266
 
267
267
  if not coeffs:
268
268
  all_cats = self.data.get(key, {}).get("categories", {})
269
- for c_k, c_v in all_cats.items():
269
+ for c_v in all_cats.values():
270
270
  if c_v.get("coeffs"):
271
271
  coeffs = c_v["coeffs"]
272
272
  break
@@ -300,7 +300,7 @@ class PerfProfileManager:
300
300
  chars_coeffs = cat_data.get("chars_coeffs")
301
301
  if not chars_coeffs:
302
302
  all_cats = self.data.get(key, {}).get("categories", {})
303
- for c_k, c_v in all_cats.items():
303
+ for c_v in all_cats.values():
304
304
  if c_v.get("chars_coeffs"):
305
305
  chars_coeffs = c_v["chars_coeffs"]
306
306
  break
@@ -456,7 +456,7 @@ def locate_features(target_dir: Path, query: str, limit: int = 10) -> str:
456
456
  (
457
457
  uid, sname, skind, rpath,
458
458
  lstart, lend, fqdn, sig,
459
- ref_funcs_raw, callees_raw,
459
+ _ref_funcs_raw, _callees_raw,
460
460
  purp, ovw
461
461
  ) = row
462
462
  sname_l = (sname or "").lower()
@@ -142,7 +142,9 @@ def generate_readme_doc(
142
142
  cache_key = f"report::readme::{norm_lang}"
143
143
 
144
144
  if db and not force and out_file.exists():
145
- cached_content = db.load_design_cache(cache_key, input_hash)
145
+ cached_content = db.load_design_cache(
146
+ cache_key, input_hash, combined_input
147
+ )
146
148
  if cached_content:
147
149
  tracker.advance(1, extra="[Cached]: .pystdoc/README.md")
148
150
  tracker.finish()
@@ -347,5 +349,7 @@ topological dependency mapping.
347
349
 
348
350
  write_flushed_text(out_file, content.strip() + "\n")
349
351
  if db:
350
- db.save_design_cache(cache_key, input_hash, content)
352
+ db.save_design_cache(
353
+ cache_key, input_hash, content, combined_input
354
+ )
351
355
  return content
@@ -58,7 +58,7 @@ def scan_files(target_dir: Path) -> List[Path]:
58
58
  rel_path = file_path.relative_to(target_dir)
59
59
  matched_files.append(rel_path)
60
60
 
61
- matched_files.sort(key=lambda p: str(p))
61
+ matched_files.sort(key=str)
62
62
  return matched_files
63
63
 
64
64
 
@@ -0,0 +1,45 @@
1
+ """Lightweight, deterministic semantic-similarity helpers (no LLM calls).
2
+
3
+ Used to decide whether a regenerated description is "essentially the same"
4
+ as the previous one, so that change propagation can stop early.
5
+ """
6
+
7
+ import re
8
+ from typing import Optional, Set
9
+
10
+ _PUNCT_RE = re.compile(r"[.,。、::;;!?!?\-_()()「」『』`\"'\[\]{}*#>|/\\\s]+")
11
+
12
+
13
+ def get_tokens(text: Optional[str]) -> Set[str]:
14
+ """Tokenize into lowercase words plus character 2-grams (for CJK)."""
15
+ clean = _PUNCT_RE.sub(" ", (text or "").lower()).strip()
16
+ words = set(clean.split())
17
+ compact = clean.replace(" ", "")
18
+ for i in range(len(compact) - 1):
19
+ words.add(compact[i:i + 2])
20
+ return words
21
+
22
+
23
+ def similarity(a: Optional[str], b: Optional[str]) -> float:
24
+ """Return the max of Jaccard and overlap coefficient (0.0 - 1.0).
25
+
26
+ The overlap coefficient tolerates elaboration (one text containing the
27
+ other) while Jaccard penalizes unrelated text.
28
+ """
29
+ t1, t2 = get_tokens(a), get_tokens(b)
30
+ if not t1 and not t2:
31
+ return 1.0
32
+ if not t1 or not t2:
33
+ return 0.0
34
+ inter = len(t1 & t2)
35
+ jaccard = inter / len(t1 | t2)
36
+ overlap = inter / min(len(t1), len(t2))
37
+ # overlap alone is too lenient for very different sizes; damp it.
38
+ size_ratio = min(len(t1), len(t2)) / max(len(t1), len(t2))
39
+ return max(jaccard, overlap * (0.5 + 0.5 * size_ratio))
40
+
41
+
42
+ def is_semantically_same(
43
+ a: Optional[str], b: Optional[str], threshold: float = 0.85
44
+ ) -> bool:
45
+ return similarity(a, b) >= threshold
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pystdoc
3
- Version: 0.7.2
3
+ Version: 0.8.0
4
4
  Summary: Python Structural & Topological Documentation Engine (pystdoc): High-precision, LLM-powered hierarchical codebase and architecture documentation generator.
5
5
  Author: tab4moji
6
6
  License: MIT
@@ -78,21 +78,25 @@ your_project/
78
78
  - Evaluates call graphs in $O(V+E)$ linear time.
79
79
  - Automatically breaks cyclic mutual recursions and organizes code symbols into dependency-safe execution levels.
80
80
  - Level-by-level parallel LLM execution guarantees context-rich bottom-up summaries without race conditions.
81
- 2. **3-in-1 Unified Documentation Pipeline**:
81
+ 2. **High-Speed Incremental Sync & Semantic Change Propagation (v0.8.0)**:
82
+ - **Selective Bottom-Up Propagation**: Detects AST and logic-level code differences. If regenerated specifications remain semantically unchanged (evaluated by deterministic Jaccard / token-overlap metrics), change propagation terminates immediately without triggering cascading LLM re-analysis across upper modules.
83
+ - **Content-Defined Map-Reduce Caching**: Intermediate summaries in architecture synthesis (`designgen`) are chunked using stable hash boundaries and cached per group in SQLite, ensuring that only groups containing edited functions are re-summarized.
84
+ - **Architectural Entry-Point Filtering**: For system execution modeling (`execution_model.md`), key architectural functions (entry points, CLI routers, and core dispatchers) are automatically filtered and passed directly into the LLM context, bypassing heavy multi-step Map-Reduce loops and speeding up generation by up to 90%+.
85
+ 3. **3-in-1 Unified Documentation Pipeline**:
82
86
  - `docgen`: Bottom-up & top-down symbol-level documentation with SHA-256 and SQLite caching (`.pystdoc/documents/`).
83
87
  - `designgen`: Map-Reduce architectural synthesis (`.pystdoc/design/`).
84
88
  - `reportgen` / `pystdoc`: Executive summary README (`.pystdoc/README.md`) answering *"What does this project actually do?"*
85
- 3. **Multi-Language Codebase Deep Understanding (C/C++, Python, Java, Kotlin, Shell)**:
89
+ 4. **Multi-Language Codebase Deep Understanding (C/C++, Python, Java, Kotlin, Shell)**:
86
90
  - **C/C++**: `compile_commands.json` integration, include path resolution, and macro expansion via `libclang`.
87
91
  - **Java / Kotlin**: AST parsing via `javalang` & `kopyt` with class hierarchy, companion objects, and data classes.
88
92
  - **Python & Shell**: AST visitor and generic regex parser with import/call extraction.
89
93
  - **Fully Qualified Domain Names (FQDN)**: Disambiguates identical symbol names across large monorepos.
90
- 4. **Feature Location & UI Component Pinpointing (`locate` / `pystdoc_locate_feature`)**:
94
+ 5. **Feature Location & UI Component Pinpointing (`locate` / `pystdoc_locate_feature`)**:
91
95
  - Pinpoint exact files, functions, UI elements, and line ranges (`Lines X-Y`) from high-level natural language queries (e.g., *"where is the debug notification button"*, *"add auth header"*) without running brute-force `Glob`/`Grep` across the entire project.
92
96
  - Built-in multi-lingual token scoring with automatic boosting for UI elements (`@Composable`, `Button`, `Dialog`, `Activity`, `Modifier`, etc.).
93
- 5. **Impact & Inbound/Outbound Dependency Analysis (`impact` / `pystdoc_trace_impact`)**:
97
+ 6. **Impact & Inbound/Outbound Dependency Analysis (`impact` / `pystdoc_trace_impact`)**:
94
98
  - Instant 360-degree dependency tracing: inspect where a symbol is called from (*Inbound Callers / References*) and what it uses (*Outbound Dependencies*) before making breaking code changes or deletions.
95
- 6. **Standard LLM Options & Multi-Language Support**:
99
+ 7. **Standard LLM Options & Multi-Language Support**:
96
100
  - Works with **Ollama, LiteRT-LM, vLLM, and OpenAI API**.
97
101
  - Supports `--host`, `--model`, `--token` / `--api-key`, `--context-size`, and `--language` (defaults to terminal language, e.g. `Japanese`, `English`, `日本語`).
98
102
 
@@ -26,6 +26,7 @@ src/pystdoc/progress.py
26
26
  src/pystdoc/query.py
27
27
  src/pystdoc/report_engine.py
28
28
  src/pystdoc/scanner.py
29
+ src/pystdoc/similarity.py
29
30
  src/pystdoc/symbols.py
30
31
  src/pystdoc/ui_detector.py
31
32
  src/pystdoc/watcher.py
@@ -63,6 +64,7 @@ tests/test_pep8.py
63
64
  tests/test_perf.py
64
65
  tests/test_progress.py
65
66
  tests/test_query.py
67
+ tests/test_reduce_cache.py
66
68
  tests/test_reportgen.py
67
69
  tests/test_ui_detector.py
68
70
  tests/test_watcher.py
@@ -0,0 +1,78 @@
1
+ """Tests for group-level caching in hierarchical_reduce_summaries."""
2
+
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest.mock import MagicMock
7
+
8
+ from pystdoc.db import DocgenDB
9
+ from pystdoc.design_engine import (
10
+ chunk_list_stable,
11
+ hierarchical_reduce_summaries,
12
+ )
13
+
14
+
15
+ def _items(n, changed=None):
16
+ out = []
17
+ for i in range(n):
18
+ text = f"- Function `mod{i:04d}.fn.f{i}.md`: purpose of item {i} " * 3
19
+ if changed is not None and i == changed:
20
+ text += " CHANGED"
21
+ out.append(text)
22
+ return out
23
+
24
+
25
+ class TestReduceCache(unittest.TestCase):
26
+ def setUp(self):
27
+ self.tmp = Path(tempfile.mkdtemp())
28
+ self.db = DocgenDB(self.tmp / "index.db")
29
+
30
+ def tearDown(self):
31
+ self.db.close()
32
+
33
+ def test_stable_chunking_localizes_change(self):
34
+ a = chunk_list_stable(_items(300))
35
+ b = chunk_list_stable(_items(300, changed=150))
36
+ changed = sum(1 for c in b if c not in a)
37
+ self.assertGreater(len(a), 5)
38
+ self.assertLessEqual(changed, 2)
39
+ self.assertEqual(sum(len(c) for c in a), 300)
40
+
41
+ def test_group_cache_only_recomputes_changed_groups(self):
42
+ llm = MagicMock()
43
+ llm.chat_completion.side_effect = lambda m: "summary"
44
+ hierarchical_reduce_summaries(
45
+ _items(300), "Funcs", llm, language="Japanese", db=self.db
46
+ )
47
+ first_calls = llm.chat_completion.call_count
48
+ self.assertGreater(first_calls, 5)
49
+
50
+ llm.chat_completion.reset_mock()
51
+ hierarchical_reduce_summaries(
52
+ _items(300), "Funcs", llm, language="Japanese", db=self.db
53
+ )
54
+ self.assertEqual(llm.chat_completion.call_count, 0)
55
+
56
+ llm.chat_completion.reset_mock()
57
+ hierarchical_reduce_summaries(
58
+ _items(300, changed=150), "Funcs", llm,
59
+ language="Japanese", db=self.db,
60
+ )
61
+ self.assertLessEqual(llm.chat_completion.call_count, 4)
62
+ self.assertGreater(llm.chat_completion.call_count, 0)
63
+
64
+ def test_force_bypasses_group_cache(self):
65
+ llm = MagicMock()
66
+ llm.chat_completion.side_effect = lambda m: "summary"
67
+ hierarchical_reduce_summaries(
68
+ _items(100), "Funcs", llm, db=self.db
69
+ )
70
+ llm.chat_completion.reset_mock()
71
+ hierarchical_reduce_summaries(
72
+ _items(100), "Funcs", llm, db=self.db, force=True
73
+ )
74
+ self.assertGreater(llm.chat_completion.call_count, 0)
75
+
76
+
77
+ if __name__ == "__main__":
78
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes