agent-wiki-cli 0.5.2__tar.gz → 0.6.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 (113) hide show
  1. {agent_wiki_cli-0.5.2/src/agent_wiki_cli.egg-info → agent_wiki_cli-0.6.0}/PKG-INFO +1 -1
  2. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/pyproject.toml +1 -1
  3. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0/src/agent_wiki_cli.egg-info}/PKG-INFO +1 -1
  4. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/cli.py +2 -0
  5. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/release_cmd.py +1 -0
  6. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/sync_cmd.py +415 -12
  7. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/imports.py +0 -5
  8. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_config.py +34 -0
  9. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_release.py +17 -0
  10. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_sync.py +194 -0
  11. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/LICENSE +0 -0
  12. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/README.md +0 -0
  13. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/setup.cfg +0 -0
  14. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/agent_wiki_cli.egg-info/SOURCES.txt +0 -0
  15. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/agent_wiki_cli.egg-info/dependency_links.txt +0 -0
  16. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/agent_wiki_cli.egg-info/entry_points.txt +0 -0
  17. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/agent_wiki_cli.egg-info/requires.txt +0 -0
  18. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/agent_wiki_cli.egg-info/top_level.txt +0 -0
  19. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/__init__.py +0 -0
  20. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/api.py +0 -0
  21. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/__init__.py +0 -0
  22. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/bootstrap_cmd.py +0 -0
  23. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/bump_cmd.py +0 -0
  24. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/ci_check_cmd.py +0 -0
  25. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/context_cmd.py +0 -0
  26. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/extract_cmd.py +0 -0
  27. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/generate_prompt_cmd.py +0 -0
  28. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/hook_cmd.py +0 -0
  29. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/init_cmd.py +0 -0
  30. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/install_cmd.py +0 -0
  31. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/lint_cmd.py +0 -0
  32. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/mcp_cmd.py +0 -0
  33. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/metrics_cmd.py +0 -0
  34. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/migrate_cmd.py +0 -0
  35. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/obsidian_cmd.py +0 -0
  36. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/plugins_cmd.py +0 -0
  37. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/prepare_extractors_cmd.py +0 -0
  38. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/review_cmd.py +0 -0
  39. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/status_cmd.py +0 -0
  40. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/team_cmd.py +0 -0
  41. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/trigger_cmd.py +0 -0
  42. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/uninstall_cmd.py +0 -0
  43. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/upgrade_cmd.py +0 -0
  44. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/config.py +0 -0
  45. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/__init__.py +0 -0
  46. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/common.py +0 -0
  47. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/go_extractor.py +0 -0
  48. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/go_scripts/go.mod +0 -0
  49. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/go_scripts/main.go +0 -0
  50. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/python_extractor.py +0 -0
  51. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/rust_extractor.py +0 -0
  52. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/rust_scripts/Cargo.lock +0 -0
  53. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/rust_scripts/Cargo.toml +0 -0
  54. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/rust_scripts/src/main.rs +0 -0
  55. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/ts_extractor.py +0 -0
  56. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/ts_scripts/extract.js +0 -0
  57. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/ts_scripts/package.json +0 -0
  58. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/__init__.py +0 -0
  59. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/circuit_breaker.py +0 -0
  60. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/contracts.py +0 -0
  61. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/extractor_helpers.py +0 -0
  62. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/inventory_cache.py +0 -0
  63. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/io.py +0 -0
  64. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/lockfile.py +0 -0
  65. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/mcp_server.py +0 -0
  66. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/metrics.py +0 -0
  67. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/obsidian.py +0 -0
  68. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/packages.py +0 -0
  69. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/paths.py +0 -0
  70. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/plugins.py +0 -0
  71. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/schema.py +0 -0
  72. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/secure_file.py +0 -0
  73. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/source_snapshot.py +0 -0
  74. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/team.py +0 -0
  75. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/versioning.py +0 -0
  76. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_api.py +0 -0
  77. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_bootstrap.py +0 -0
  78. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_bump.py +0 -0
  79. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_ci_check.py +0 -0
  80. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_circuit_breaker.py +0 -0
  81. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_context.py +0 -0
  82. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_docker_bootstrap.py +0 -0
  83. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_docker_extract.py +0 -0
  84. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_docker_lint.py +0 -0
  85. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_e2e.py +0 -0
  86. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_extract.py +0 -0
  87. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_extractor_helpers.py +0 -0
  88. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_generate_prompt.py +0 -0
  89. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_go_extract.py +0 -0
  90. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_hook.py +0 -0
  91. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_imports.py +0 -0
  92. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_init.py +0 -0
  93. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_inventory_cache.py +0 -0
  94. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_io.py +0 -0
  95. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_lint.py +0 -0
  96. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_lockfile.py +0 -0
  97. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_mcp.py +0 -0
  98. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_migrate.py +0 -0
  99. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_multilanguage_wiki.py +0 -0
  100. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_obsidian.py +0 -0
  101. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_package_metadata.py +0 -0
  102. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_phase4_quality.py +0 -0
  103. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_plugins.py +0 -0
  104. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_rust_extract.py +0 -0
  105. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_schema.py +0 -0
  106. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_source_snapshot.py +0 -0
  107. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_status.py +0 -0
  108. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_team.py +0 -0
  109. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_trigger.py +0 -0
  110. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_ts_extract.py +0 -0
  111. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_uninstall.py +0 -0
  112. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_upgrade.py +0 -0
  113. {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-wiki-cli
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: CLI tool to maintain hybrid LLM Wikis for multi-language projects.
5
5
  Author-email: Denis Sivagin <denissvgn@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agent-wiki-cli"
7
- version = "0.5.2"
7
+ version = "0.6.0"
8
8
  description = "CLI tool to maintain hybrid LLM Wikis for multi-language projects."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-wiki-cli
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: CLI tool to maintain hybrid LLM Wikis for multi-language projects.
5
5
  Author-email: Denis Sivagin <denissvgn@gmail.com>
6
6
  License: MIT
@@ -372,6 +372,8 @@ def main():
372
372
  help="Parallel built-in extractor jobs: positive integer or 'auto' (default: 1)")
373
373
  sync_parser.add_argument("--force", action="store_true",
374
374
  help="Allow sync to apply unusually broad source diffs")
375
+ sync_parser.add_argument("--no-preserve-semantic", action="store_true",
376
+ help="Disable preservation of existing semantic wiki descriptions")
375
377
 
376
378
  # migrate command
377
379
  migrate_parser = subparsers.add_parser(
@@ -149,6 +149,7 @@ def run(args):
149
149
  ["git", "add", str(changelog_path)],
150
150
  check=True,
151
151
  capture_output=True,
152
+ shell=False,
152
153
  text=True,
153
154
  )
154
155
  except FileNotFoundError:
@@ -40,11 +40,13 @@ from ..services.io import read_md, write_md
40
40
  # ── Constants ─────────────────────────────────────────────────────────────────
41
41
 
42
42
  MANIFEST_FILENAME = ".llm-wiki-manifest.json"
43
- MANIFEST_VERSION = 2
43
+ MANIFEST_VERSION = 3
44
44
  MAX_SYNC_AFFECTED_FILES = 50
45
45
  MAX_SYNC_AFFECTED_RATIO = 0.30
46
46
  MIN_SOURCES_FOR_RATIO_GUARD = 10
47
47
  _HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
48
+ _AUTO_GENERATED_RE = re.compile(r"^_Auto-generated from `.+`(?: in `.+`)?\._$")
49
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
48
50
  _DEPRECATION_HEADER = (
49
51
  "> ⚠️ **Stale:** Source no longer found in codebase. "
50
52
  "Run `llm-wiki lint` to audit.\n\n"
@@ -118,6 +120,329 @@ def _write_md_if_changed(path: Path, text: str) -> str:
118
120
  write_md(path, normalized)
119
121
  return "created"
120
122
 
123
+
124
+ def _without_line_metadata(value):
125
+ """Return inventory data with line-only metadata removed."""
126
+ if isinstance(value, dict):
127
+ return {
128
+ key: _without_line_metadata(item)
129
+ for key, item in sorted(value.items())
130
+ if key != "line"
131
+ }
132
+ if isinstance(value, list):
133
+ return [_without_line_metadata(item) for item in value]
134
+ return value
135
+
136
+
137
+ def _semantic_hash_for_file(file_data: dict) -> str:
138
+ """Fingerprint extracted source semantics while ignoring line shifts."""
139
+ payload = _without_line_metadata(file_data)
140
+ encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
141
+ digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
142
+ return f"sha256:{digest}"
143
+
144
+
145
+ def _first_doc_line(info: dict) -> str:
146
+ docstring = info.get("docstring", "")
147
+ return docstring.split("\n")[0] if docstring else "—"
148
+
149
+
150
+ def _generated_semantics_for_file(filepath: str, file_data: dict) -> dict:
151
+ """Return the generated description fields that sync may later preserve over."""
152
+ module_docstring = file_data.get("module_docstring", "")
153
+ module_description = module_docstring or f"_Auto-generated from `{filepath}`._"
154
+ return {
155
+ "module": {
156
+ "description": module_description,
157
+ "classes": {
158
+ cls["name"]: _first_doc_line(cls)
159
+ for cls in file_data.get("classes", [])
160
+ },
161
+ "functions": {
162
+ fn["name"]: _first_doc_line(fn)
163
+ for fn in file_data.get("functions", [])
164
+ },
165
+ },
166
+ "entities": {
167
+ cls["name"]: {
168
+ "description": cls.get("docstring", "")
169
+ or f"_Auto-generated from `{cls['name']}` in `{filepath}`._",
170
+ "attributes": {
171
+ attr["name"]: "—"
172
+ for attr in cls.get("attributes", [])
173
+ },
174
+ "methods": {
175
+ method["name"]: _first_doc_line(method)
176
+ for method in cls.get("methods", [])
177
+ },
178
+ }
179
+ for cls in file_data.get("classes", [])
180
+ },
181
+ }
182
+
183
+
184
+ def _section_bounds(lines: list[str], heading: str) -> tuple[int, int, int] | None:
185
+ """Return ``(heading_index, body_start, body_end)`` for a level-2 heading."""
186
+ target = heading.casefold()
187
+ for i, line in enumerate(lines):
188
+ match = _HEADING_RE.match(line.strip())
189
+ if not match:
190
+ continue
191
+ level = len(match.group(1))
192
+ title = match.group(2).strip().casefold()
193
+ if level != 2 or title != target:
194
+ continue
195
+ end = len(lines)
196
+ for j in range(i + 1, len(lines)):
197
+ next_match = _HEADING_RE.match(lines[j].strip())
198
+ if next_match and len(next_match.group(1)) <= level:
199
+ end = j
200
+ break
201
+ return i, i + 1, end
202
+ return None
203
+
204
+
205
+ def _trim_blank_lines(lines: list[str]) -> list[str]:
206
+ start = 0
207
+ end = len(lines)
208
+ while start < end and lines[start].strip() == "":
209
+ start += 1
210
+ while end > start and lines[end - 1].strip() == "":
211
+ end -= 1
212
+ return lines[start:end]
213
+
214
+
215
+ def _section_body(markdown: str, heading: str) -> str | None:
216
+ lines = _normalize_md(markdown).splitlines()
217
+ bounds = _section_bounds(lines, heading)
218
+ if not bounds:
219
+ return None
220
+ _, start, end = bounds
221
+ body_lines = _trim_blank_lines(lines[start:end])
222
+ return "\n".join(body_lines).strip()
223
+
224
+
225
+ def _replace_section_body(markdown: str, heading: str, body: str) -> str:
226
+ lines = _normalize_md(markdown).splitlines()
227
+ bounds = _section_bounds(lines, heading)
228
+ if not bounds:
229
+ return markdown
230
+ heading_idx, _, end = bounds
231
+ replacement = [""] + body.splitlines() + [""]
232
+ return "\n".join(lines[: heading_idx + 1] + replacement + lines[end:])
233
+
234
+
235
+ def _is_placeholder_description(value: str | None) -> bool:
236
+ if value is None:
237
+ return True
238
+ stripped = value.strip()
239
+ if not stripped or stripped in {"—", "-"}:
240
+ return True
241
+ if _AUTO_GENERATED_RE.match(stripped):
242
+ return True
243
+ return False
244
+
245
+
246
+ def _should_preserve_semantic_value(
247
+ existing: str | None,
248
+ generated: str | None,
249
+ old_generated: str | None,
250
+ ) -> bool:
251
+ if _is_placeholder_description(existing):
252
+ return False
253
+ existing_stripped = (existing or "").strip()
254
+ generated_stripped = (generated or "").strip()
255
+ if old_generated is None:
256
+ return existing_stripped != generated_stripped
257
+ old_stripped = old_generated.strip()
258
+ if existing_stripped == old_stripped:
259
+ return False
260
+ return existing_stripped != generated_stripped
261
+
262
+
263
+ def _split_table_row(line: str) -> list[str]:
264
+ stripped = line.strip()
265
+ if not stripped.startswith("|") or not stripped.endswith("|"):
266
+ return []
267
+ return [cell.strip() for cell in stripped.strip("|").split("|")]
268
+
269
+
270
+ def _format_table_row(cells: list[str]) -> str:
271
+ return "| " + " | ".join(cells) + " |"
272
+
273
+
274
+ def _is_table_separator(cells: list[str]) -> bool:
275
+ if not cells:
276
+ return False
277
+ return all(re.fullmatch(r":?-{3,}:?", cell.replace(" ", "")) for cell in cells)
278
+
279
+
280
+ def _semantic_table_key(cell: str) -> str:
281
+ key = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", cell)
282
+ key = key.replace("`", "").replace("*", "")
283
+ return key.strip()
284
+
285
+
286
+ def _table_description_cells(markdown: str, heading: str) -> dict[str, str]:
287
+ lines = _normalize_md(markdown).splitlines()
288
+ bounds = _section_bounds(lines, heading)
289
+ if not bounds:
290
+ return {}
291
+ _, start, end = bounds
292
+
293
+ for i in range(start, end):
294
+ headers = _split_table_row(lines[i])
295
+ if not headers or "Description" not in headers:
296
+ continue
297
+ desc_idx = headers.index("Description")
298
+ row_start = i + 1
299
+ if row_start < end and _is_table_separator(_split_table_row(lines[row_start])):
300
+ row_start += 1
301
+
302
+ descriptions: dict[str, str] = {}
303
+ for row_idx in range(row_start, end):
304
+ row = _split_table_row(lines[row_idx])
305
+ if not row:
306
+ break
307
+ if len(row) <= desc_idx:
308
+ continue
309
+ key = _semantic_table_key(row[0])
310
+ description = row[desc_idx].strip()
311
+ if key and not _is_placeholder_description(description):
312
+ descriptions[key] = description
313
+ return descriptions
314
+ return {}
315
+
316
+
317
+ def _preserve_table_description_cells(
318
+ markdown: str,
319
+ heading: str,
320
+ descriptions: dict[str, str],
321
+ old_descriptions: dict[str, str] | None = None,
322
+ ) -> tuple[str, int]:
323
+ if not descriptions:
324
+ return markdown, 0
325
+
326
+ lines = _normalize_md(markdown).splitlines()
327
+ bounds = _section_bounds(lines, heading)
328
+ if not bounds:
329
+ return markdown, 0
330
+ _, start, end = bounds
331
+
332
+ preserved = 0
333
+ for i in range(start, end):
334
+ headers = _split_table_row(lines[i])
335
+ if not headers or "Description" not in headers:
336
+ continue
337
+ desc_idx = headers.index("Description")
338
+ row_start = i + 1
339
+ if row_start < end and _is_table_separator(_split_table_row(lines[row_start])):
340
+ row_start += 1
341
+
342
+ for row_idx in range(row_start, end):
343
+ row = _split_table_row(lines[row_idx])
344
+ if not row:
345
+ break
346
+ if len(row) <= desc_idx:
347
+ continue
348
+ key = _semantic_table_key(row[0])
349
+ existing_description = descriptions.get(key)
350
+ old_description = (old_descriptions or {}).get(key)
351
+ if not _should_preserve_semantic_value(
352
+ existing_description,
353
+ row[desc_idx],
354
+ old_description,
355
+ ):
356
+ continue
357
+ row[desc_idx] = existing_description
358
+ lines[row_idx] = _format_table_row(row)
359
+ preserved += 1
360
+ break
361
+
362
+ if preserved == 0:
363
+ return markdown, 0
364
+ updated = "\n".join(lines)
365
+ if markdown.endswith("\n"):
366
+ updated += "\n"
367
+ return updated, preserved
368
+
369
+
370
+ @dataclass
371
+ class SemanticMergeResult:
372
+ text: str
373
+ preserved: int = 0
374
+
375
+
376
+ def _merge_semantic_markdown(
377
+ existing: str,
378
+ generated: str,
379
+ table_headings: tuple[str, ...],
380
+ *,
381
+ old_description: str | None = None,
382
+ old_table_descriptions: dict[str, dict[str, str]] | None = None,
383
+ ) -> SemanticMergeResult:
384
+ """Preserve human-written semantic fields in regenerated wiki markdown."""
385
+ merged = _normalize_md(generated)
386
+ preserved = 0
387
+
388
+ existing_description = _section_body(existing, "Description")
389
+ generated_description = _section_body(generated, "Description")
390
+ if _should_preserve_semantic_value(
391
+ existing_description,
392
+ generated_description,
393
+ old_description,
394
+ ):
395
+ merged = _replace_section_body(merged, "Description", existing_description)
396
+ preserved += 1
397
+
398
+ for heading in table_headings:
399
+ descriptions = _table_description_cells(existing, heading)
400
+ merged, table_preserved = _preserve_table_description_cells(
401
+ merged,
402
+ heading,
403
+ descriptions,
404
+ (old_table_descriptions or {}).get(heading),
405
+ )
406
+ preserved += table_preserved
407
+
408
+ return SemanticMergeResult(merged, preserved)
409
+
410
+
411
+ def _merge_entity_semantics(
412
+ existing: str,
413
+ generated: str,
414
+ old_semantics: dict | None = None,
415
+ ) -> SemanticMergeResult:
416
+ old_semantics = old_semantics or {}
417
+ return _merge_semantic_markdown(
418
+ existing,
419
+ generated,
420
+ ("Attributes", "Methods"),
421
+ old_description=old_semantics.get("description"),
422
+ old_table_descriptions={
423
+ "Attributes": old_semantics.get("attributes", {}),
424
+ "Methods": old_semantics.get("methods", {}),
425
+ },
426
+ )
427
+
428
+
429
+ def _merge_module_semantics(
430
+ existing: str,
431
+ generated: str,
432
+ old_semantics: dict | None = None,
433
+ ) -> SemanticMergeResult:
434
+ old_semantics = old_semantics or {}
435
+ return _merge_semantic_markdown(
436
+ existing,
437
+ generated,
438
+ ("Classes", "Functions"),
439
+ old_description=old_semantics.get("description"),
440
+ old_table_descriptions={
441
+ "Classes": old_semantics.get("classes", {}),
442
+ "Functions": old_semantics.get("functions", {}),
443
+ },
444
+ )
445
+
121
446
  # ── Manifest ──────────────────────────────────────────────────────────────────
122
447
 
123
448
 
@@ -128,12 +453,28 @@ class SyncManifest:
128
453
  Schema v2::
129
454
 
130
455
  {
131
- "version": 1,
456
+ "version": 2,
132
457
  "sources": {
133
458
  "src/models.py": {
134
459
  "hash": "sha256:<hex>",
460
+ "semantic_hash": "sha256:<hex>",
461
+ "generated_semantics": {
462
+ "module": {
463
+ "description": "...",
464
+ "classes": {"User": "..."},
465
+ "functions": {}
466
+ },
467
+ "entities": {
468
+ "User": {
469
+ "description": "...",
470
+ "attributes": {"name": "—"},
471
+ "methods": {}
472
+ }
473
+ }
474
+ },
135
475
  "language": "python",
136
476
  "entities": ["User", "Role"],
477
+ "entity_pages": {"User": "User", "Role": "Role"},
137
478
  "module_page": "models"
138
479
  }
139
480
  }
@@ -184,6 +525,8 @@ class SyncManifest:
184
525
  for filepath, file_data in inventory.items():
185
526
  sources[filepath] = {
186
527
  "hash": _hash_file(Path(src_dir) / filepath),
528
+ "semantic_hash": _semantic_hash_for_file(file_data),
529
+ "generated_semantics": _generated_semantics_for_file(filepath, file_data),
187
530
  "language": file_data.get("language") or infer_language_from_path(filepath),
188
531
  "entities": [c["name"] for c in file_data.get("classes", [])],
189
532
  "entity_pages": {
@@ -220,6 +563,7 @@ class SyncDiff:
220
563
 
221
564
  new_files: list[str] = field(default_factory=list)
222
565
  changed_files: list[str] = field(default_factory=list)
566
+ metadata_only_files: list[str] = field(default_factory=list)
223
567
  unchanged_files: list[str] = field(default_factory=list)
224
568
  removed_files: list[str] = field(default_factory=list)
225
569
  # {class_name: (old_filepath, new_filepath)}
@@ -234,6 +578,7 @@ class SyncDiff:
234
578
  return bool(
235
579
  self.new_files
236
580
  or self.changed_files
581
+ or self.metadata_only_files
237
582
  or self.removed_files
238
583
  or self.moved_entities
239
584
  or self.renamed_entity_pages
@@ -242,7 +587,12 @@ class SyncDiff:
242
587
 
243
588
 
244
589
  def _affected_source_files(diff: SyncDiff) -> set[str]:
245
- affected = set(diff.new_files) | set(diff.changed_files) | set(diff.removed_files)
590
+ affected = (
591
+ set(diff.new_files)
592
+ | set(diff.changed_files)
593
+ | set(diff.metadata_only_files)
594
+ | set(diff.removed_files)
595
+ )
246
596
  for old_path, new_path in diff.moved_entities.values():
247
597
  affected.add(old_path)
248
598
  affected.add(new_path)
@@ -322,7 +672,11 @@ def _compute_diff(
322
672
  # Re-hash to detect content changes
323
673
  current_hash = _hash_file(Path(src_dir) / filepath)
324
674
  if current_hash != manifest.sources[filepath].get("hash", ""):
325
- diff.changed_files.append(filepath)
675
+ current_semantic_hash = _semantic_hash_for_file(file_data)
676
+ if current_semantic_hash == manifest.sources[filepath].get("semantic_hash"):
677
+ diff.metadata_only_files.append(filepath)
678
+ else:
679
+ diff.changed_files.append(filepath)
326
680
  else:
327
681
  diff.unchanged_files.append(filepath)
328
682
 
@@ -367,8 +721,10 @@ def _compute_diff(
367
721
  class SyncResult:
368
722
  created: int = 0
369
723
  updated: int = 0
724
+ metadata_only: int = 0
370
725
  skipped: int = 0
371
726
  deprecated: int = 0
727
+ preserved_semantic: int = 0
372
728
 
373
729
 
374
730
  def _collision_maps(
@@ -393,6 +749,7 @@ def _apply_diff(
393
749
  *,
394
750
  entity_page_cache: dict[tuple[str, str], str] | None = None,
395
751
  module_page_map: dict[str, str] | None = None,
752
+ preserve_semantic: bool = True,
396
753
  ) -> SyncResult:
397
754
  """Regenerate pages for new/changed files, deprecate pages for removed files."""
398
755
  result = SyncResult()
@@ -404,7 +761,7 @@ def _apply_diff(
404
761
 
405
762
  target_entities = {
406
763
  (cls["name"], filepath)
407
- for filepath in diff.new_files + diff.changed_files
764
+ for filepath in diff.new_files + diff.changed_files + diff.metadata_only_files
408
765
  if filepath in inventory
409
766
  for cls in inventory[filepath].get("classes", [])
410
767
  }
@@ -430,9 +787,11 @@ def _apply_diff(
430
787
  refresh_files = list(dict.fromkeys(
431
788
  diff.new_files
432
789
  + diff.changed_files
790
+ + diff.metadata_only_files
433
791
  + [filepath for _, filepath in diff.renamed_entity_pages]
434
792
  + list(diff.renamed_module_pages)
435
793
  ))
794
+ metadata_only_files = set(diff.metadata_only_files)
436
795
  current_entity_pages = set(entity_page_cache.values())
437
796
  current_module_pages = set(module_page_map.values())
438
797
 
@@ -441,6 +800,7 @@ def _apply_diff(
441
800
  for filepath in refresh_files:
442
801
  file_data = inventory[filepath]
443
802
  mod_page_name = module_page_map.get(filepath, _page_name_for_module(filepath))
803
+ old_generated_semantics = manifest.sources.get(filepath, {}).get("generated_semantics", {})
444
804
 
445
805
  file_entity_page_map = {
446
806
  cls["name"]: entity_page_cache[(cls["name"], filepath)]
@@ -463,14 +823,32 @@ def _apply_diff(
463
823
  elif old_page_name not in current_entity_pages:
464
824
  old_entity_path.unlink()
465
825
  print(f" REMOVE stale entity page: {old_page_name}")
466
- content = _generate_entity_md(cls, filepath, relationships, mod_page_name)
826
+ generated = _generate_entity_md(cls, filepath, relationships, mod_page_name)
827
+ merge_result = SemanticMergeResult(generated)
828
+ if preserve_semantic and entity_path.exists():
829
+ old_entity_semantics = (
830
+ old_generated_semantics.get("entities", {}).get(cls["name"])
831
+ if isinstance(old_generated_semantics, dict)
832
+ else None
833
+ )
834
+ merge_result = _merge_entity_semantics(
835
+ read_md(entity_path),
836
+ generated,
837
+ old_entity_semantics,
838
+ )
839
+ result.preserved_semantic += merge_result.preserved
840
+ content = merge_result.text
467
841
  write_state = _write_md_if_changed(entity_path, content)
468
842
  if write_state == "created":
469
843
  result.created += 1
470
844
  print(f" CREATE entity: {entity_page_name}")
471
845
  elif write_state == "updated":
472
- result.updated += 1
473
- print(f" UPDATE entity: {entity_page_name}")
846
+ if filepath in metadata_only_files:
847
+ result.metadata_only += 1
848
+ print(f" METADATA entity: {entity_page_name}")
849
+ else:
850
+ result.updated += 1
851
+ print(f" UPDATE entity: {entity_page_name}")
474
852
  else:
475
853
  result.skipped += 1
476
854
  print(f" SKIP entity (unchanged): {entity_page_name}")
@@ -489,14 +867,32 @@ def _apply_diff(
489
867
  elif old_page_name not in current_module_pages:
490
868
  old_module_path.unlink()
491
869
  print(f" REMOVE stale module page: {old_page_name}")
492
- content = _generate_module_md(filepath, file_data, file_entity_page_map)
870
+ generated = _generate_module_md(filepath, file_data, file_entity_page_map)
871
+ merge_result = SemanticMergeResult(generated)
872
+ if preserve_semantic and module_path.exists():
873
+ old_module_semantics = (
874
+ old_generated_semantics.get("module")
875
+ if isinstance(old_generated_semantics, dict)
876
+ else None
877
+ )
878
+ merge_result = _merge_module_semantics(
879
+ read_md(module_path),
880
+ generated,
881
+ old_module_semantics,
882
+ )
883
+ result.preserved_semantic += merge_result.preserved
884
+ content = merge_result.text
493
885
  write_state = _write_md_if_changed(module_path, content)
494
886
  if write_state == "created":
495
887
  result.created += 1
496
888
  print(f" CREATE module: {mod_page_name}")
497
889
  elif write_state == "updated":
498
- result.updated += 1
499
- print(f" UPDATE module: {mod_page_name}")
890
+ if filepath in metadata_only_files:
891
+ result.metadata_only += 1
892
+ print(f" METADATA module: {mod_page_name}")
893
+ else:
894
+ result.updated += 1
895
+ print(f" UPDATE module: {mod_page_name}")
500
896
  else:
501
897
  result.skipped += 1
502
898
  print(f" SKIP module (unchanged): {mod_page_name}")
@@ -592,6 +988,7 @@ def run(args) -> None:
592
988
  cache_stats_enabled = bool(getattr(args, "cache_stats", False))
593
989
  parallel_jobs = getattr(args, "jobs", 1)
594
990
  force = bool(getattr(args, "force", False))
991
+ preserve_semantic = not bool(getattr(args, "no_preserve_semantic", False))
595
992
  validate_path(src_dir, "--src-dir")
596
993
  validate_path(str(wiki_dir), "--wiki-dir")
597
994
 
@@ -708,6 +1105,7 @@ def run(args) -> None:
708
1105
  manifest,
709
1106
  entity_page_cache=entity_page_cache,
710
1107
  module_page_map=module_page_map,
1108
+ preserve_semantic=preserve_semantic,
711
1109
  )
712
1110
 
713
1111
  # 5. Rebuild index.md
@@ -735,8 +1133,11 @@ def run(args) -> None:
735
1133
 
736
1134
  print(
737
1135
  f"\nSync complete: {result.created} created, {result.updated} updated, "
738
- f"{result.skipped} skipped, {result.deprecated} deprecated."
1136
+ f"{result.metadata_only} metadata-only, {result.skipped} skipped, "
1137
+ f"{result.deprecated} deprecated."
739
1138
  )
1139
+ if result.preserved_semantic:
1140
+ print(f"Preserved semantic fields: {result.preserved_semantic}")
740
1141
  if diff.moved_entities:
741
1142
  names = ", ".join(diff.moved_entities.keys())
742
1143
  print(f"Moved entities detected (pages updated in-place): {names}")
@@ -815,8 +1216,10 @@ def _append_log(wiki_dir: Path, src_dir: str, diff: SyncDiff, result: SyncResult
815
1216
  f"- Source: `{src_dir}`\n"
816
1217
  f"- Pages created: {result.created}\n"
817
1218
  f"- Pages updated: {result.updated}\n"
1219
+ f"- Pages metadata-only: {result.metadata_only}\n"
818
1220
  f"- Pages skipped (unchanged): {result.skipped}\n"
819
1221
  f"- Pages deprecated: {result.deprecated}\n"
1222
+ f"- Semantic fields preserved: {result.preserved_semantic}\n"
820
1223
  f"- Moved entities: {moved_str}\n"
821
1224
  )
822
1225
  if log_path.exists():
@@ -110,8 +110,3 @@ def _candidate_stems(module: str, importer_filepath: str) -> set[str]:
110
110
  def build_module_path_resolver(inventory: dict) -> ModulePathResolver:
111
111
  """Build an indexed import resolver for repeated lookups."""
112
112
  return ModulePathResolver.build(inventory)
113
-
114
-
115
- def module_path_candidates(module: str, importer_filepath: str, inventory: dict) -> set[str]:
116
- """Resolve an import module string to inventory file paths when possible."""
117
- return build_module_path_resolver(inventory).candidates(module, importer_filepath)
@@ -87,6 +87,40 @@ class TestValidatePath:
87
87
  with pytest.raises(PathValidationError):
88
88
  validate_source_paths(source, ["../outside.py"])
89
89
 
90
+ def test_source_paths_accept_relative_and_absolute_inside_root(self, tmp_path):
91
+ source = tmp_path / "source"
92
+ package = source / "package"
93
+ package.mkdir(parents=True)
94
+ module = package / "module.py"
95
+ module.write_text("VALUE = 1\n", encoding="utf-8")
96
+
97
+ assert validate_source_paths(
98
+ source,
99
+ ["package/module.py", str(module)],
100
+ ) is None
101
+
102
+ def test_source_paths_accept_nonexistent_relative_inside_root(self, tmp_path):
103
+ source = tmp_path / "source"
104
+ source.mkdir()
105
+
106
+ assert validate_source_paths(source, ["generated/future.py"]) is None
107
+
108
+ def test_source_paths_ignore_empty_inputs(self, tmp_path):
109
+ source = tmp_path / "source"
110
+ source.mkdir()
111
+
112
+ assert validate_source_paths(source, None) is None
113
+ assert validate_source_paths(source, [""]) is None
114
+
115
+ def test_source_paths_reject_absolute_outside_root(self, tmp_path):
116
+ source = tmp_path / "source"
117
+ outside = tmp_path / "outside.py"
118
+ source.mkdir()
119
+ outside.write_text("VALUE = 1\n", encoding="utf-8")
120
+
121
+ with pytest.raises(PathValidationError):
122
+ validate_source_paths(source, [str(outside)])
123
+
90
124
 
91
125
  class TestReadWriteConfig:
92
126
  """Round-trip JSON config and backward compatibility."""