deepdoc 1.9.2__tar.gz → 2.0.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.
- {deepdoc-1.9.2 → deepdoc-2.0.0}/PKG-INFO +1 -1
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/__init__.py +1 -1
- deepdoc-2.0.0/deepdoc/changelog_writer.py +106 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/cli.py +13 -13
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/generator/generation.py +1 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/generator/post_processors.py +36 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/persistence_v2.py +27 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/pipeline_v2.py +59 -1
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/planner/bucket_injection.py +103 -11
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/planner/nav_shaping.py +51 -1
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/planner/specializations.py +10 -10
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/site/builder/engine.py +12 -32
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/site/builder/scaffold_files.py +58 -1
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/smart_update_v2.py +104 -21
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc.egg-info/PKG-INFO +1 -1
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc.egg-info/SOURCES.txt +2 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/pyproject.toml +1 -1
- deepdoc-2.0.0/tests/test_changelog.py +74 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_classify.py +8 -12
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_cli_serve.py +2 -2
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_fumadocs_builder.py +5 -1
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_planner_granularity.py +74 -2
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_smart_update.py +104 -21
- {deepdoc-1.9.2 → deepdoc-2.0.0}/LICENSE +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/README.md +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/__main__.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/_legacy_types.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/benchmark_v2.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/call_graph.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/__init__.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/answer_mixin.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/chunker.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/deep_research.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/docs_summary.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/embeddings.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/indexer.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/linking.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/live_fallback_mixin.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/persistence.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/providers.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/retrieval_mixin.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/routes.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/scaffold.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/service.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/settings.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/source_archive.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/symbol_index.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/chatbot/types.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/config.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/generator/__init__.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/generator/evidence.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/generator/validation.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/llm/__init__.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/llm/client.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/llm/json_utils.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/llm/litellm_compat.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/manifest.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/openapi.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/__init__.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/api_detector.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/base.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/go_parser.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/js_ts_parser.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/php_parser.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/python_parser.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/registry.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/__init__.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/base.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/common.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/detector.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/django.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/express.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/falcon.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/fastify.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/go.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/js_shared.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/laravel.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/nestjs.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/python_shared.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/registry.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/routes/repo_resolver.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/parser/vue_parser.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/planner/__init__.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/planner/bucket_refinement.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/planner/common.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/planner/endpoint_refs.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/planner/engine.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/planner/flow_candidates.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/planner/heuristics.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/planner/topology.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/planner/utils.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/prompts/__init__.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/prompts/bucket_types.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/prompts/page_types.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/prompts/selectors.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/prompts/system.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/prompts/update.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/prompts_v2.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/py.typed +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/scanner/__init__.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/scanner/artifacts.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/scanner/clustering.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/scanner/common.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/scanner/database.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/scanner/endpoints.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/scanner/integrations.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/scanner/runtime.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/scanner/utils.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/site/__init__.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/site/builder/__init__.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/site/builder/chatbot_components.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/site/builder/common.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/site/builder/mdx_utils.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/site/builder/templates.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/source_metadata.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/updater_v2.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc/v2_models.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc.egg-info/dependency_links.txt +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc.egg-info/entry_points.txt +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc.egg-info/requires.txt +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/deepdoc.egg-info/top_level.txt +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/setup.cfg +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_benchmark_scorecard.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_call_graph.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_chatbot_config.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_chatbot_embeddings.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_chatbot_eval.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_chatbot_index.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_chatbot_persistence.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_chatbot_providers.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_chatbot_query.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_chatbot_relationship.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_chatbot_scaffold.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_chatbot_source_archive.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_cli_generate.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_cli_update.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_flow_candidates.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_framework_fixtures.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_framework_support.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_generation_evidence.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_internal_docs_metadata.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_litellm_compat.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_llm_json_utils.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_parallel_pipeline.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_parser_ranges.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_planner_consolidation.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_route_registry.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_runtime_scan.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_stale.py +0 -0
- {deepdoc-1.9.2 → deepdoc-2.0.0}/tests/test_state.py +0 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .persistence_v2 import append_changelog_entry, load_changelog, load_plan, save_plan
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def record_and_write(
|
|
9
|
+
repo_root: Path,
|
|
10
|
+
output_dir: Path,
|
|
11
|
+
*,
|
|
12
|
+
commit: str,
|
|
13
|
+
commit_message: str,
|
|
14
|
+
commit_date: str,
|
|
15
|
+
strategy: str,
|
|
16
|
+
pages_updated: list[str],
|
|
17
|
+
files_changed: list[str],
|
|
18
|
+
is_initial: bool = False,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Append one changelog entry and regenerate whats-changed.mdx."""
|
|
21
|
+
entry = {
|
|
22
|
+
"commit": commit[:8],
|
|
23
|
+
"date": commit_date,
|
|
24
|
+
"commit_message": commit_message,
|
|
25
|
+
"strategy": strategy,
|
|
26
|
+
"pages_updated": pages_updated,
|
|
27
|
+
"files_changed": files_changed[:20],
|
|
28
|
+
"is_initial": is_initial,
|
|
29
|
+
}
|
|
30
|
+
append_changelog_entry(repo_root, entry)
|
|
31
|
+
write_whats_changed_page(repo_root, output_dir)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_whats_changed_page(repo_root: Path, output_dir: Path) -> None:
|
|
35
|
+
"""Write docs/whats-changed.mdx from .deepdoc/changelog.json."""
|
|
36
|
+
entries = load_changelog(repo_root)
|
|
37
|
+
mdx = _build_mdx(entries)
|
|
38
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
(output_dir / "whats-changed.mdx").write_text(mdx, encoding="utf-8")
|
|
40
|
+
_ensure_in_nav(repo_root)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _build_mdx(entries: list[dict]) -> str:
|
|
44
|
+
lines = [
|
|
45
|
+
"---",
|
|
46
|
+
'title: "What\'s Changed"',
|
|
47
|
+
'description: "Documentation changes per commit"',
|
|
48
|
+
"---",
|
|
49
|
+
"",
|
|
50
|
+
"# What's Changed",
|
|
51
|
+
"",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
if not entries:
|
|
55
|
+
lines.append("No changes recorded yet — this is the initial documentation.")
|
|
56
|
+
return "\n".join(lines)
|
|
57
|
+
|
|
58
|
+
lines.append("<Accordions>")
|
|
59
|
+
for entry in entries:
|
|
60
|
+
date = entry.get("date", "")
|
|
61
|
+
msg = entry.get("commit_message", "update")[:80]
|
|
62
|
+
sha = entry.get("commit", "")
|
|
63
|
+
pages = entry.get("pages_updated", [])
|
|
64
|
+
files = entry.get("files_changed", [])
|
|
65
|
+
is_initial = entry.get("is_initial", False)
|
|
66
|
+
|
|
67
|
+
title = f"{date} — {msg} ({sha})"
|
|
68
|
+
lines.append(f'<Accordion title="{title}">')
|
|
69
|
+
lines.append("")
|
|
70
|
+
|
|
71
|
+
if is_initial:
|
|
72
|
+
lines.append(f"**{len(pages)} pages generated** — initial documentation run.")
|
|
73
|
+
else:
|
|
74
|
+
if pages:
|
|
75
|
+
page_links = ", ".join(f"[{_slug_to_title(s)}](/{s})" for s in pages)
|
|
76
|
+
lines.append(f"**{len(pages)} page(s) updated:** {page_links}")
|
|
77
|
+
else:
|
|
78
|
+
lines.append("No pages regenerated.")
|
|
79
|
+
|
|
80
|
+
if files and not is_initial:
|
|
81
|
+
file_list = ", ".join(f"`{f}`" for f in files[:10])
|
|
82
|
+
if len(files) > 10:
|
|
83
|
+
file_list += f" +{len(files) - 10} more"
|
|
84
|
+
lines.append("")
|
|
85
|
+
lines.append(f"**Files changed:** {file_list}")
|
|
86
|
+
|
|
87
|
+
lines.append("")
|
|
88
|
+
lines.append("</Accordion>")
|
|
89
|
+
|
|
90
|
+
lines.append("</Accordions>")
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _slug_to_title(slug: str) -> str:
|
|
95
|
+
return slug.replace("-", " ").title()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ensure_in_nav(repo_root: Path) -> None:
|
|
99
|
+
"""Add whats-changed to Start Here section of the saved plan if not already there."""
|
|
100
|
+
plan = load_plan(repo_root)
|
|
101
|
+
if plan is None or not hasattr(plan, "nav_structure"):
|
|
102
|
+
return
|
|
103
|
+
section = plan.nav_structure.setdefault("Start Here", [])
|
|
104
|
+
if "whats-changed" not in section:
|
|
105
|
+
section.append("whats-changed")
|
|
106
|
+
save_plan(plan, repo_root)
|
|
@@ -1200,13 +1200,17 @@ def _warn_if_deprecated_generated_version(cfg: dict, repo_root: Path) -> None:
|
|
|
1200
1200
|
if not warning_cfg.get("enabled", True):
|
|
1201
1201
|
return
|
|
1202
1202
|
|
|
1203
|
-
minimum_version = str(warning_cfg.get("minimum_version") or "1.0.0")
|
|
1204
1203
|
generated_version = _detect_generated_deepdoc_version(
|
|
1205
1204
|
repo_root, repo_root / str(cfg.get("output_dir", "docs") or "docs")
|
|
1206
1205
|
)
|
|
1207
1206
|
if generated_version is None:
|
|
1208
1207
|
return
|
|
1209
|
-
|
|
1208
|
+
|
|
1209
|
+
# Warn when the docs were generated by a different major version of the CLI.
|
|
1210
|
+
# Minor/patch bumps are backwards-compatible — no need to nag.
|
|
1211
|
+
gen_major = _version_tuple(generated_version)[0]
|
|
1212
|
+
cli_major = _version_tuple(__version__)[0]
|
|
1213
|
+
if gen_major >= cli_major:
|
|
1210
1214
|
return
|
|
1211
1215
|
|
|
1212
1216
|
resolved_root = repo_root.resolve()
|
|
@@ -1214,19 +1218,15 @@ def _warn_if_deprecated_generated_version(cfg: dict, repo_root: Path) -> None:
|
|
|
1214
1218
|
return
|
|
1215
1219
|
_DEPRECATED_VERSION_WARNING_REPOS.add(resolved_root)
|
|
1216
1220
|
|
|
1217
|
-
upgrade_command = str(
|
|
1218
|
-
warning_cfg.get("upgrade_command")
|
|
1219
|
-
or "python3 -m pip install --upgrade deepdoc"
|
|
1220
|
-
)
|
|
1221
1221
|
console.print(
|
|
1222
1222
|
Panel.fit(
|
|
1223
|
-
"[bold yellow]
|
|
1224
|
-
f"
|
|
1225
|
-
f"
|
|
1226
|
-
"
|
|
1227
|
-
|
|
1228
|
-
"To
|
|
1229
|
-
"`compatibility.deprecated_version_warning
|
|
1223
|
+
"[bold yellow]Docs need regeneration[/bold yellow]\n\n"
|
|
1224
|
+
f"Your docs were generated with DeepDoc [bold]{generated_version}[/bold] "
|
|
1225
|
+
f"but the current CLI is [bold]{__version__}[/bold] (different major version).\n"
|
|
1226
|
+
"Run the following to bring them up to date:\n\n"
|
|
1227
|
+
"[bold]deepdoc generate[/bold]\n\n"
|
|
1228
|
+
"To disable this warning: set "
|
|
1229
|
+
"`compatibility.deprecated_version_warning.enabled: false` in `.deepdoc.yaml`.",
|
|
1230
1230
|
border_style="yellow",
|
|
1231
1231
|
)
|
|
1232
1232
|
)
|
|
@@ -1072,6 +1072,7 @@ Re-run `deepdoc generate` to retry.
|
|
|
1072
1072
|
"deepdoc_generated_version": DEEPDOC_VERSION,
|
|
1073
1073
|
"deepdoc_status": status_value,
|
|
1074
1074
|
"deepdoc_evidence_files": evidence_files[:50],
|
|
1075
|
+
"deepdoc_prereqs": bucket.depends_on,
|
|
1075
1076
|
}
|
|
1076
1077
|
return _merge_frontmatter_fields(content, fields)
|
|
1077
1078
|
|
|
@@ -957,6 +957,10 @@ def repair_mdx_component_blocks(content: str) -> str:
|
|
|
957
957
|
|
|
958
958
|
content = pattern.sub(_rewrite, content)
|
|
959
959
|
|
|
960
|
+
# Fix Accordion nesting first (inserts missing </Accordion> before </Accordions>)
|
|
961
|
+
# so the subsequent count-based repair sees balanced tags and doesn't double-add.
|
|
962
|
+
content = _repair_accordion_nesting(content)
|
|
963
|
+
|
|
960
964
|
for component in multiline_components:
|
|
961
965
|
open_count = len(re.findall(rf"<{component}(?:\s[^>]*)?>", content))
|
|
962
966
|
close_count = len(re.findall(rf"</{component}>", content))
|
|
@@ -968,6 +972,38 @@ def repair_mdx_component_blocks(content: str) -> str:
|
|
|
968
972
|
return content
|
|
969
973
|
|
|
970
974
|
|
|
975
|
+
def _repair_accordion_nesting(content: str) -> str:
|
|
976
|
+
"""Fix Accordion nesting issues emitted by the LLM.
|
|
977
|
+
|
|
978
|
+
Two cases handled:
|
|
979
|
+
1. Missing </Accordion> before </Accordions> — inserts them in the right place.
|
|
980
|
+
2. Swapped order (</Accordions> then </Accordion>) — strips the orphaned
|
|
981
|
+
</Accordion> that appears after </Accordions> with no matching open tag.
|
|
982
|
+
"""
|
|
983
|
+
import re as _re
|
|
984
|
+
|
|
985
|
+
def _fix_block(m: re.Match) -> str:
|
|
986
|
+
block = m.group(0)
|
|
987
|
+
opens = len(_re.findall(r"<Accordion(?:\s[^>]*)?>", block))
|
|
988
|
+
closes = len(_re.findall(r"</Accordion>", block))
|
|
989
|
+
deficit = opens - closes
|
|
990
|
+
if deficit <= 0:
|
|
991
|
+
return block
|
|
992
|
+
return block[:-len("</Accordions>")] + ("</Accordion>\n" * deficit) + "</Accordions>"
|
|
993
|
+
|
|
994
|
+
content = re.sub(
|
|
995
|
+
r"<Accordions(?:\s[^>]*)?>[\s\S]*?</Accordions>",
|
|
996
|
+
_fix_block,
|
|
997
|
+
content,
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
# Strip orphaned </Accordion> tags that appear after </Accordions>
|
|
1001
|
+
# (the LLM sometimes emits </Accordions>\n</Accordion> in the wrong order)
|
|
1002
|
+
content = re.sub(r"(</Accordions>)(\s*</Accordion>)+", r"\1", content)
|
|
1003
|
+
|
|
1004
|
+
return content
|
|
1005
|
+
|
|
1006
|
+
|
|
971
1007
|
_PROVENANCE_KEYS = frozenset({
|
|
972
1008
|
"deepdoc_generated_at",
|
|
973
1009
|
"deepdoc_generated_commit",
|
|
@@ -37,6 +37,8 @@ LEDGER_FILE = "ledger.json"
|
|
|
37
37
|
FILE_MAP_FILE = "file_map.json"
|
|
38
38
|
STATE_FILE = "state.json"
|
|
39
39
|
SYNC_RECEIPT_FILE = "sync_receipt.json"
|
|
40
|
+
CHANGELOG_FILE = "changelog.json"
|
|
41
|
+
CHANGELOG_MAX_ENTRIES = 50
|
|
40
42
|
ENGINE_FINGERPRINT = "routes_repo_resolution_v2_trimmed_scope"
|
|
41
43
|
|
|
42
44
|
# Legacy top-level files (kept for backwards-compat)
|
|
@@ -108,6 +110,31 @@ def load_sync_state(repo_root: Path) -> dict[str, Any] | None:
|
|
|
108
110
|
return None
|
|
109
111
|
|
|
110
112
|
|
|
113
|
+
def append_changelog_entry(repo_root: Path, entry: dict[str, Any]) -> None:
|
|
114
|
+
"""Append one entry to .deepdoc/changelog.json, capped at CHANGELOG_MAX_ENTRIES."""
|
|
115
|
+
path = _state_dir(repo_root) / CHANGELOG_FILE
|
|
116
|
+
entries: list[dict[str, Any]] = []
|
|
117
|
+
if path.exists():
|
|
118
|
+
try:
|
|
119
|
+
entries = json.loads(path.read_text(encoding="utf-8"))
|
|
120
|
+
except Exception:
|
|
121
|
+
entries = []
|
|
122
|
+
entries.insert(0, entry)
|
|
123
|
+
entries = entries[:CHANGELOG_MAX_ENTRIES]
|
|
124
|
+
path.write_text(json.dumps(entries, indent=2), encoding="utf-8")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_changelog(repo_root: Path) -> list[dict[str, Any]]:
|
|
128
|
+
"""Read .deepdoc/changelog.json. Returns empty list if not present or corrupt."""
|
|
129
|
+
path = _state_dir(repo_root) / CHANGELOG_FILE
|
|
130
|
+
if not path.exists():
|
|
131
|
+
return []
|
|
132
|
+
try:
|
|
133
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
134
|
+
except Exception:
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
|
|
111
138
|
def save_sync_receipt(repo_root: Path, receipt: dict[str, Any]) -> None:
|
|
112
139
|
"""Write a top-level receipt for the latest generate/update sync run."""
|
|
113
140
|
path = _state_dir(repo_root) / SYNC_RECEIPT_FILE
|
|
@@ -51,8 +51,10 @@ from .openapi import (
|
|
|
51
51
|
parse_openapi_spec,
|
|
52
52
|
spec_to_context_string,
|
|
53
53
|
)
|
|
54
|
+
from .changelog_writer import record_and_write as _record_changelog
|
|
54
55
|
from .persistence_v2 import (
|
|
55
56
|
cleanup_stale_generated_files,
|
|
57
|
+
load_changelog,
|
|
56
58
|
load_generation_ledger,
|
|
57
59
|
prune_generation_ledger,
|
|
58
60
|
save_all,
|
|
@@ -112,6 +114,36 @@ def _endpoint_ref_slug(method: str, path: str) -> str:
|
|
|
112
114
|
return f"{method.lower()}-{path_slug}"
|
|
113
115
|
|
|
114
116
|
|
|
117
|
+
def _spec_base_path(spec: dict) -> str:
|
|
118
|
+
"""Return the base path from the first server URL, e.g. '/api/v2'."""
|
|
119
|
+
from urllib.parse import urlparse
|
|
120
|
+
if "openapi" in spec:
|
|
121
|
+
servers = spec.get("servers", [])
|
|
122
|
+
if servers:
|
|
123
|
+
url = str(servers[0].get("url", "") or "").strip()
|
|
124
|
+
parsed = urlparse(url)
|
|
125
|
+
base = parsed.path if (parsed.scheme or parsed.netloc) else url
|
|
126
|
+
return base.rstrip("/")
|
|
127
|
+
elif "swagger" in spec:
|
|
128
|
+
return spec.get("basePath", "").rstrip("/")
|
|
129
|
+
return ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _write_spec(dest: Path, spec: dict) -> None:
|
|
133
|
+
"""Write spec dict to dest as YAML or JSON depending on suffix."""
|
|
134
|
+
if dest.suffix == ".json":
|
|
135
|
+
dest.write_text(json.dumps(spec, indent=2) + "\n", encoding="utf-8")
|
|
136
|
+
else:
|
|
137
|
+
try:
|
|
138
|
+
import yaml
|
|
139
|
+
dest.write_text(
|
|
140
|
+
yaml.dump(spec, allow_unicode=True, sort_keys=False, default_flow_style=False),
|
|
141
|
+
encoding="utf-8",
|
|
142
|
+
)
|
|
143
|
+
except ImportError:
|
|
144
|
+
dest.write_text(json.dumps(spec, indent=2) + "\n", encoding="utf-8")
|
|
145
|
+
|
|
146
|
+
|
|
115
147
|
def stage_openapi_assets(
|
|
116
148
|
repo_root: Path, openapi_paths: list[str] | None = None
|
|
117
149
|
) -> bool:
|
|
@@ -136,7 +168,6 @@ def stage_openapi_assets(
|
|
|
136
168
|
|
|
137
169
|
spec_name = Path(spec_rel_path).name
|
|
138
170
|
staged_spec = site_openapi_dir / spec_name
|
|
139
|
-
shutil.copy2(spec_src, staged_spec)
|
|
140
171
|
|
|
141
172
|
spec = parse_openapi_spec(spec_src)
|
|
142
173
|
if not spec:
|
|
@@ -145,6 +176,20 @@ def stage_openapi_assets(
|
|
|
145
176
|
)
|
|
146
177
|
return False
|
|
147
178
|
|
|
179
|
+
# Bake the server base path into each path key so Fumadocs can do a
|
|
180
|
+
# direct dict lookup (it does not prepend the server URL itself).
|
|
181
|
+
base_path = _spec_base_path(spec)
|
|
182
|
+
if base_path and not any(
|
|
183
|
+
k.startswith(base_path) for k in spec.get("paths", {})
|
|
184
|
+
):
|
|
185
|
+
spec = {
|
|
186
|
+
**spec,
|
|
187
|
+
"paths": {base_path + k: v for k, v in spec["paths"].items()},
|
|
188
|
+
"servers": [{"url": "/"}],
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
_write_spec(staged_spec, spec)
|
|
192
|
+
|
|
148
193
|
endpoints = extract_endpoints_from_spec(spec)
|
|
149
194
|
manifest: list[dict[str, str]] = []
|
|
150
195
|
for ep in endpoints:
|
|
@@ -381,6 +426,19 @@ class PipelineV2:
|
|
|
381
426
|
"replanned": True,
|
|
382
427
|
},
|
|
383
428
|
)
|
|
429
|
+
changelog_exists = bool(load_changelog(self.repo_root))
|
|
430
|
+
_commit_obj = _repo.head.commit
|
|
431
|
+
_record_changelog(
|
|
432
|
+
self.repo_root,
|
|
433
|
+
self.output_dir,
|
|
434
|
+
commit=head_sha,
|
|
435
|
+
commit_message=_commit_obj.message.strip().splitlines()[0],
|
|
436
|
+
commit_date=_commit_obj.committed_datetime.strftime("%Y-%m-%d"),
|
|
437
|
+
strategy="full_generate",
|
|
438
|
+
pages_updated=[b.slug for b in plan.buckets],
|
|
439
|
+
files_changed=[],
|
|
440
|
+
is_initial=not changelog_exists,
|
|
441
|
+
)
|
|
384
442
|
except Exception:
|
|
385
443
|
pass # Not a git repo or detached HEAD — skip silently
|
|
386
444
|
|
|
@@ -405,6 +405,64 @@ _GENERIC_PLACEHOLDER_SECTIONS = {
|
|
|
405
405
|
"pages",
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
_BACKEND_INTEGRATION_TOKENS = {
|
|
409
|
+
"cdn",
|
|
410
|
+
"clickpost",
|
|
411
|
+
"express",
|
|
412
|
+
"gateway",
|
|
413
|
+
"integration",
|
|
414
|
+
"provider",
|
|
415
|
+
"third",
|
|
416
|
+
"vinculum",
|
|
417
|
+
"warehouse",
|
|
418
|
+
"wms",
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
_BACKEND_OPERATION_TOKENS = {
|
|
422
|
+
"config",
|
|
423
|
+
"cron",
|
|
424
|
+
"debug",
|
|
425
|
+
"health",
|
|
426
|
+
"log",
|
|
427
|
+
"logger",
|
|
428
|
+
"logging",
|
|
429
|
+
"metric",
|
|
430
|
+
"monitor",
|
|
431
|
+
"observability",
|
|
432
|
+
"scheduler",
|
|
433
|
+
"utility",
|
|
434
|
+
"utilities",
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
_BACKEND_RUNTIME_TOKENS = {
|
|
438
|
+
"agenda",
|
|
439
|
+
"async",
|
|
440
|
+
"background",
|
|
441
|
+
"celery",
|
|
442
|
+
"command",
|
|
443
|
+
"consumer",
|
|
444
|
+
"cron",
|
|
445
|
+
"django",
|
|
446
|
+
"job",
|
|
447
|
+
"queue",
|
|
448
|
+
"scheduler",
|
|
449
|
+
"signal",
|
|
450
|
+
"task",
|
|
451
|
+
"worker",
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
_PATH_SECTION_PREFIXES = (
|
|
455
|
+
"new-src-",
|
|
456
|
+
"src-",
|
|
457
|
+
"app-",
|
|
458
|
+
"lib-",
|
|
459
|
+
"packages-",
|
|
460
|
+
"services-",
|
|
461
|
+
"controllers-",
|
|
462
|
+
"middlewares-",
|
|
463
|
+
"utils-",
|
|
464
|
+
)
|
|
465
|
+
|
|
408
466
|
|
|
409
467
|
def _canonical_section_for_bucket(bucket: DocBucket, primary_type: str) -> str:
|
|
410
468
|
# Supporting-tier buckets always get re-sectioned (Testing, CI/CD, etc.) regardless
|
|
@@ -412,7 +470,12 @@ def _canonical_section_for_bucket(bucket: DocBucket, primary_type: str) -> str:
|
|
|
412
470
|
# domain-specific section name rather than overriding with a generic canonical one.
|
|
413
471
|
if bucket.publication_tier != "supporting":
|
|
414
472
|
existing = (bucket.section or "").strip()
|
|
415
|
-
if
|
|
473
|
+
if (
|
|
474
|
+
existing
|
|
475
|
+
and existing.lower() not in _GENERIC_PLACEHOLDER_SECTIONS
|
|
476
|
+
and not _looks_like_path_slug_section(existing)
|
|
477
|
+
and not _is_backend_placeholder_section(existing, primary_type)
|
|
478
|
+
):
|
|
416
479
|
return existing
|
|
417
480
|
|
|
418
481
|
title_tokens = _bucket_semantic_tokens(bucket)
|
|
@@ -570,23 +633,25 @@ def _canonical_section_for_bucket(bucket: DocBucket, primary_type: str) -> str:
|
|
|
570
633
|
"is_endpoint_family"
|
|
571
634
|
) or bucket.generation_hints.get("is_endpoint_ref"):
|
|
572
635
|
return "API Reference"
|
|
573
|
-
if any(
|
|
574
|
-
token in title_tokens
|
|
575
|
-
for token in {"middleware", "auth", "route", "controller", "handler"}
|
|
576
|
-
):
|
|
577
|
-
return "Runtime & Frameworks"
|
|
578
636
|
if any(
|
|
579
637
|
token in title_tokens
|
|
580
638
|
for token in {"model", "schema", "migration", "database"}
|
|
581
639
|
):
|
|
582
640
|
return "Data Layer"
|
|
583
|
-
if any(
|
|
584
|
-
token in title_tokens for token in
|
|
641
|
+
if bucket.bucket_type in {"runtime", "runtime-group"} or any(
|
|
642
|
+
token in title_tokens for token in _BACKEND_RUNTIME_TOKENS
|
|
585
643
|
):
|
|
586
|
-
return "Integrations"
|
|
587
|
-
if any(token in title_tokens for token in {"queue", "task", "worker", "sync"}):
|
|
588
644
|
return "Background Jobs"
|
|
589
|
-
|
|
645
|
+
if any(token in title_tokens for token in _BACKEND_INTEGRATION_TOKENS):
|
|
646
|
+
return "Integrations"
|
|
647
|
+
if any(token in title_tokens for token in _BACKEND_OPERATION_TOKENS):
|
|
648
|
+
return "Operations"
|
|
649
|
+
if any(
|
|
650
|
+
token in title_tokens
|
|
651
|
+
for token in {"middleware", "auth", "route", "controller", "handler"}
|
|
652
|
+
):
|
|
653
|
+
return "Supporting Infrastructure"
|
|
654
|
+
return "Core Workflows"
|
|
590
655
|
if bucket.generation_hints.get("is_introduction_page"):
|
|
591
656
|
return "Overview"
|
|
592
657
|
if bucket.generation_hints.get("is_endpoint_family") or bucket.generation_hints.get(
|
|
@@ -605,3 +670,30 @@ def _canonical_section_for_bucket(bucket: DocBucket, primary_type: str) -> str:
|
|
|
605
670
|
):
|
|
606
671
|
return "Operations"
|
|
607
672
|
return "Architecture"
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _looks_like_path_slug_section(section: str) -> bool:
|
|
676
|
+
value = (section or "").strip()
|
|
677
|
+
if not value:
|
|
678
|
+
return False
|
|
679
|
+
if " > " in value or "/" in value or "\\" in value:
|
|
680
|
+
return False
|
|
681
|
+
lower = value.lower()
|
|
682
|
+
if lower != value or "-" not in lower:
|
|
683
|
+
return False
|
|
684
|
+
if lower.endswith(("-ts", "-js", "-tsx", "-jsx", "-py", "-php", "-go")):
|
|
685
|
+
return True
|
|
686
|
+
return lower.startswith(_PATH_SECTION_PREFIXES) and lower.count("-") >= 2
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _is_backend_placeholder_section(section: str, primary_type: str) -> bool:
|
|
690
|
+
if primary_type not in {"backend_service", "backend_api", "falcon_backend"}:
|
|
691
|
+
return False
|
|
692
|
+
return section.strip().lower() in {
|
|
693
|
+
"architecture",
|
|
694
|
+
"core",
|
|
695
|
+
"features",
|
|
696
|
+
"runtime & frameworks",
|
|
697
|
+
"services",
|
|
698
|
+
"subsystems",
|
|
699
|
+
}
|
|
@@ -2,6 +2,18 @@ from .common import *
|
|
|
2
2
|
from .bucket_refinement import _bucket_semantic_tokens
|
|
3
3
|
from .bucket_injection import _canonical_section_for_bucket
|
|
4
4
|
|
|
5
|
+
_PATH_SECTION_PREFIXES = (
|
|
6
|
+
"new-src-",
|
|
7
|
+
"src-",
|
|
8
|
+
"app-",
|
|
9
|
+
"lib-",
|
|
10
|
+
"packages-",
|
|
11
|
+
"services-",
|
|
12
|
+
"controllers-",
|
|
13
|
+
"middlewares-",
|
|
14
|
+
"utils-",
|
|
15
|
+
)
|
|
16
|
+
|
|
5
17
|
|
|
6
18
|
def _shape_plan_nav(
|
|
7
19
|
plan: DocPlan,
|
|
@@ -24,7 +36,7 @@ def _shape_plan_nav(
|
|
|
24
36
|
continue
|
|
25
37
|
|
|
26
38
|
hints = bucket.generation_hints or {}
|
|
27
|
-
if not hints.get("preserve_section"):
|
|
39
|
+
if not hints.get("preserve_section") or _is_path_slug_section(bucket.section):
|
|
28
40
|
bucket.section = _canonical_section_for_bucket(bucket, primary)
|
|
29
41
|
|
|
30
42
|
bucket.section = _normalize_nav_section(bucket.section, primary)
|
|
@@ -150,10 +162,27 @@ def _normalize_nav_section(section: str, primary: str) -> str:
|
|
|
150
162
|
}.get(top, top)
|
|
151
163
|
|
|
152
164
|
if sep:
|
|
165
|
+
if rest.strip() == top:
|
|
166
|
+
return top
|
|
153
167
|
return f"{top} > {rest}"
|
|
154
168
|
return top
|
|
155
169
|
|
|
156
170
|
|
|
171
|
+
def _is_path_slug_section(section: str) -> bool:
|
|
172
|
+
"""Return True when the section looks like a file path cluster id."""
|
|
173
|
+
value = (section or "").strip()
|
|
174
|
+
if not value:
|
|
175
|
+
return False
|
|
176
|
+
if " > " in value or "/" in value or "\\" in value:
|
|
177
|
+
return False
|
|
178
|
+
lower = value.lower()
|
|
179
|
+
if lower != value or "-" not in lower:
|
|
180
|
+
return False
|
|
181
|
+
if lower.endswith(("-ts", "-js", "-tsx", "-jsx", "-py", "-php", "-go")):
|
|
182
|
+
return True
|
|
183
|
+
return lower.startswith(_PATH_SECTION_PREFIXES) and lower.count("-") >= 2
|
|
184
|
+
|
|
185
|
+
|
|
157
186
|
def _build_endpoint_reference_nav(buckets: list[DocBucket]) -> dict[str, list[str]]:
|
|
158
187
|
families = [
|
|
159
188
|
bucket
|
|
@@ -283,6 +312,27 @@ def _section_sort_key(
|
|
|
283
312
|
rank = _RT_ORDER.index(top) if top in _RT_ORDER else 10
|
|
284
313
|
return (rank, section_order.get(section, 999), section)
|
|
285
314
|
|
|
315
|
+
if primary in {"backend_service", "backend_api", "falcon_backend"}:
|
|
316
|
+
_BACKEND_ORDER = [
|
|
317
|
+
"Start Here",
|
|
318
|
+
"Overview",
|
|
319
|
+
"Core Workflows",
|
|
320
|
+
"API Reference",
|
|
321
|
+
"Data Model",
|
|
322
|
+
"Background Jobs",
|
|
323
|
+
"Integrations",
|
|
324
|
+
"Operations",
|
|
325
|
+
"Runtime & Frameworks",
|
|
326
|
+
"Supporting Infrastructure",
|
|
327
|
+
"Design & Notes",
|
|
328
|
+
"Testing",
|
|
329
|
+
"CI/CD and Release",
|
|
330
|
+
"Supporting Material",
|
|
331
|
+
]
|
|
332
|
+
rank = _BACKEND_ORDER.index(top) if top in _BACKEND_ORDER else 8
|
|
333
|
+
child_rank = 0 if section == top else 1
|
|
334
|
+
return (rank, child_rank, section_order.get(section, 999), section)
|
|
335
|
+
|
|
286
336
|
_FIRST = {"Start Here": 0, "Overview": 1, "Getting Started": 2}
|
|
287
337
|
_LAST = {
|
|
288
338
|
"Supporting Infrastructure": 55,
|