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.
- {agent_wiki_cli-0.5.2/src/agent_wiki_cli.egg-info → agent_wiki_cli-0.6.0}/PKG-INFO +1 -1
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/pyproject.toml +1 -1
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0/src/agent_wiki_cli.egg-info}/PKG-INFO +1 -1
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/cli.py +2 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/release_cmd.py +1 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/sync_cmd.py +415 -12
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/imports.py +0 -5
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_config.py +34 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_release.py +17 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_sync.py +194 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/LICENSE +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/README.md +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/setup.cfg +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/agent_wiki_cli.egg-info/SOURCES.txt +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/agent_wiki_cli.egg-info/dependency_links.txt +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/agent_wiki_cli.egg-info/entry_points.txt +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/agent_wiki_cli.egg-info/requires.txt +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/agent_wiki_cli.egg-info/top_level.txt +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/__init__.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/api.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/__init__.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/bootstrap_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/bump_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/ci_check_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/context_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/extract_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/generate_prompt_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/hook_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/init_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/install_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/lint_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/mcp_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/metrics_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/migrate_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/obsidian_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/plugins_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/prepare_extractors_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/review_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/status_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/team_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/trigger_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/uninstall_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/commands/upgrade_cmd.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/config.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/__init__.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/common.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/go_extractor.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/go_scripts/go.mod +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/go_scripts/main.go +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/python_extractor.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/rust_extractor.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/rust_scripts/Cargo.lock +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/rust_scripts/Cargo.toml +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/rust_scripts/src/main.rs +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/ts_extractor.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/ts_scripts/extract.js +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/extractors/ts_scripts/package.json +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/__init__.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/circuit_breaker.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/contracts.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/extractor_helpers.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/inventory_cache.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/io.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/lockfile.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/mcp_server.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/metrics.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/obsidian.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/packages.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/paths.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/plugins.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/schema.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/secure_file.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/source_snapshot.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/team.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/src/llm_wiki_cli/services/versioning.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_api.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_bootstrap.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_bump.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_ci_check.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_circuit_breaker.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_context.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_docker_bootstrap.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_docker_extract.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_docker_lint.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_e2e.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_extract.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_extractor_helpers.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_generate_prompt.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_go_extract.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_hook.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_imports.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_init.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_inventory_cache.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_io.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_lint.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_lockfile.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_mcp.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_migrate.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_multilanguage_wiki.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_obsidian.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_package_metadata.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_phase4_quality.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_plugins.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_rust_extract.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_schema.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_source_snapshot.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_status.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_team.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_trigger.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_ts_extract.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_uninstall.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_upgrade.py +0 -0
- {agent_wiki_cli-0.5.2 → agent_wiki_cli-0.6.0}/tests/test_versioning.py +0 -0
|
@@ -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(
|
|
@@ -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 =
|
|
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":
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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
|
-
|
|
499
|
-
|
|
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.
|
|
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."""
|