feat(docs): localize structured metadata and gate coverage
This commit is contained in:
@@ -82,7 +82,8 @@ module, or every module checkout in a workspace, with:
|
|||||||
govoplan-docs-export-public \
|
govoplan-docs-export-public \
|
||||||
--workspace-root /mnt/DATA/git \
|
--workspace-root /mnt/DATA/git \
|
||||||
--output public/docs/v1/catalog.json \
|
--output public/docs/v1/catalog.json \
|
||||||
--coverage-output docs/DOCUMENTATION_COVERAGE.md
|
--coverage-output docs/DOCUMENTATION_COVERAGE.md \
|
||||||
|
--coverage-baseline docs/DOCUMENTATION_COVERAGE_BASELINE.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `--check` in publication CI to reject a stale checked-in catalog. Dynamic
|
Use `--check` in publication CI to reject a stale checked-in catalog. Dynamic
|
||||||
@@ -90,6 +91,15 @@ Use `--check` in publication CI to reject a stale checked-in catalog. Dynamic
|
|||||||
on permissions, policy, configuration, and live provider state; the export
|
on permissions, policy, configuration, and live provider state; the export
|
||||||
records which modules have such additional documentation.
|
records which modules have such additional documentation.
|
||||||
|
|
||||||
|
Static topics localize title, summary, and body through `translations`.
|
||||||
|
Rendered metadata such as steps, fields, limitations, consequences, and
|
||||||
|
verification uses Core's opt-in `structured_translation_version="1"` plus
|
||||||
|
`structured_translations` contract. The registry validates exact shape before
|
||||||
|
Docs overlays the requested locale. The public catalog reports structured
|
||||||
|
adoption separately. A reviewed coverage-baseline file sets monotonic minima
|
||||||
|
and maxima so publication CI also rejects localization or coverage regressions
|
||||||
|
after generated output is refreshed.
|
||||||
|
|
||||||
Pressing `F1` resolves the focused field or action first, then its containing
|
Pressing `F1` resolves the focused field or action first, then its containing
|
||||||
dialog or section, current page, and owning module. The shell sends the focused
|
dialog or section, current page, and owning module. The shell sends the focused
|
||||||
context together with `fallback_context` and `module`; Docs selects the first
|
context together with `fallback_context` and `module`; Docs selects the first
|
||||||
|
|||||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-docs"
|
name = "govoplan-docs"
|
||||||
version = "0.1.20"
|
version = "0.1.21"
|
||||||
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
|
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.18",
|
"govoplan-core>=0.1.37",
|
||||||
"govoplan-access>=0.1.18",
|
"govoplan-access>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
localized_documentation_metadata,
|
||||||
user_workflow_scope_condition_issues,
|
user_workflow_scope_condition_issues,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
@@ -1432,6 +1433,10 @@ def _documentation_topic_payload(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
module_id = topic.source_module_id or source_module_id
|
module_id = topic.source_module_id or source_module_id
|
||||||
translation_locale, translation = _translation_for_locale(topic, locale)
|
translation_locale, translation = _translation_for_locale(topic, locale)
|
||||||
|
structured_translation_locale = _structured_translation_locale(topic, locale)
|
||||||
|
localized_metadata = localized_documentation_metadata(
|
||||||
|
topic, structured_translation_locale
|
||||||
|
)
|
||||||
kind = _documentation_topic_kind(topic)
|
kind = _documentation_topic_kind(topic)
|
||||||
payload = {
|
payload = {
|
||||||
"id": topic.id,
|
"id": topic.id,
|
||||||
@@ -1459,6 +1464,8 @@ def _documentation_topic_payload(
|
|||||||
"i18n_key": topic.i18n_key or topic.id,
|
"i18n_key": topic.i18n_key or topic.id,
|
||||||
"locale": locale,
|
"locale": locale,
|
||||||
"translation_locale": translation_locale,
|
"translation_locale": translation_locale,
|
||||||
|
"structured_translation_locale": structured_translation_locale,
|
||||||
|
"structured_translation_version": topic.structured_translation_version,
|
||||||
"version": {
|
"version": {
|
||||||
"resolved": resolved_version,
|
"resolved": resolved_version,
|
||||||
"minimum": topic.version_min,
|
"minimum": topic.version_min,
|
||||||
@@ -1483,7 +1490,7 @@ def _documentation_topic_payload(
|
|||||||
_documentation_configuration_payload(configuration[key])
|
_documentation_configuration_payload(configuration[key])
|
||||||
for key in sorted(configuration)
|
for key in sorted(configuration)
|
||||||
],
|
],
|
||||||
"metadata": dict(topic.metadata),
|
"metadata": localized_metadata,
|
||||||
}
|
}
|
||||||
if documentation_type == "admin":
|
if documentation_type == "admin":
|
||||||
return payload
|
return payload
|
||||||
@@ -1511,6 +1518,12 @@ def _documentation_topic_payload(
|
|||||||
"i18n_key": "",
|
"i18n_key": "",
|
||||||
"locale": locale,
|
"locale": locale,
|
||||||
"translation_locale": payload["translation_locale"],
|
"translation_locale": payload["translation_locale"],
|
||||||
|
"structured_translation_locale": payload[
|
||||||
|
"structured_translation_locale"
|
||||||
|
],
|
||||||
|
"structured_translation_version": payload[
|
||||||
|
"structured_translation_version"
|
||||||
|
],
|
||||||
"conditions": [],
|
"conditions": [],
|
||||||
"links": [
|
"links": [
|
||||||
_documentation_link_payload(link)
|
_documentation_link_payload(link)
|
||||||
@@ -1521,7 +1534,7 @@ def _documentation_topic_payload(
|
|||||||
"unlocks": list(topic.unlocks),
|
"unlocks": list(topic.unlocks),
|
||||||
"configuration_keys": [],
|
"configuration_keys": [],
|
||||||
"configuration_states": [],
|
"configuration_states": [],
|
||||||
"metadata": _user_topic_metadata(kind, topic.metadata),
|
"metadata": _user_topic_metadata(kind, localized_metadata),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1662,6 +1675,13 @@ def _translation_for_locale(topic: DocumentationTopic, locale: str) -> tuple[str
|
|||||||
return "source", {}
|
return "source", {}
|
||||||
|
|
||||||
|
|
||||||
|
def _structured_translation_locale(topic: DocumentationTopic, locale: str) -> str:
|
||||||
|
for candidate in _locale_candidates(locale):
|
||||||
|
if candidate in topic.structured_translations:
|
||||||
|
return candidate
|
||||||
|
return "source"
|
||||||
|
|
||||||
|
|
||||||
def _locale_candidates(locale: str) -> tuple[str, ...]:
|
def _locale_candidates(locale: str) -> tuple[str, ...]:
|
||||||
normalized = _normalize_locale(locale)
|
normalized = _normalize_locale(locale)
|
||||||
base = normalized.split("-", 1)[0]
|
base = normalized.split("-", 1)[0]
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ def _dsar_provider(_context: ModuleContext) -> DocsDsarProvider:
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="docs",
|
id="docs",
|
||||||
name="Docs",
|
name="Docs",
|
||||||
version="0.1.20",
|
version="0.1.21",
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -458,7 +458,8 @@ manifest = ModuleManifest(
|
|||||||
"The public exporter reads DocumentationTopic contributions from all installed packages or sibling module checkouts, "
|
"The public exporter reads DocumentationTopic contributions from all installed packages or sibling module checkouts, "
|
||||||
"projects German and English content, and records documentation coverage. Runtime providers remain in the authenticated "
|
"projects German and English content, and records documentation coverage. Runtime providers remain in the authenticated "
|
||||||
"Docs surface because their output can depend on the current actor, policy, configuration, and live service state. "
|
"Docs surface because their output can depend on the current actor, policy, configuration, and live service state. "
|
||||||
"Publication CI should run the export check and reject a stale source digest."
|
"Rendered steps, fields, limitations, consequences, and verification use Core's versioned same-shape structured-translation contract. "
|
||||||
|
"Publication CI runs both the source-digest check and a reviewed monotonic coverage baseline, so regenerating the catalog cannot conceal a regression."
|
||||||
),
|
),
|
||||||
layer="always",
|
layer="always",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -472,7 +473,8 @@ manifest = ModuleManifest(
|
|||||||
"Der öffentliche Export liest die DocumentationTopic-Beiträge aus allen installierten Paketen oder benachbarten Modulquellen, "
|
"Der öffentliche Export liest die DocumentationTopic-Beiträge aus allen installierten Paketen oder benachbarten Modulquellen, "
|
||||||
"projiziert deutsche und englische Inhalte und weist Dokumentationslücken aus. Laufzeit-Provider verbleiben in der authentifizierten "
|
"projiziert deutsche und englische Inhalte und weist Dokumentationslücken aus. Laufzeit-Provider verbleiben in der authentifizierten "
|
||||||
"Dokumentationsoberfläche, da ihre Ausgabe von Rolle, Richtlinie, Konfiguration und Dienstzustand abhängen kann. "
|
"Dokumentationsoberfläche, da ihre Ausgabe von Rolle, Richtlinie, Konfiguration und Dienstzustand abhängen kann. "
|
||||||
"Die Veröffentlichungs-CI soll den Quelldigest prüfen und veraltete Exporte ablehnen."
|
"Gerenderte Schritte, Felder, Einschränkungen, Folgen und Prüfhinweise verwenden den versionierten, formgleichen Vertrag für strukturierte Übersetzungen in Core. "
|
||||||
|
"Die Veröffentlichungs-CI prüft sowohl den Quelldigest als auch einen freigegebenen monotonen Abdeckungsstand, damit das Neuerzeugen des Katalogs keine Verschlechterung verdecken kann."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -573,6 +575,23 @@ manifest = ModuleManifest(
|
|||||||
"access.workflow.grant-user-access",
|
"access.workflow.grant-user-access",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"purpose": (
|
||||||
|
"Beschriftungen bleiben schnell erfassbar, während kurze Erläuterungen bei Bedarf verfügbar sind."
|
||||||
|
),
|
||||||
|
"when_used": (
|
||||||
|
"Formular- und Umschalterbeschriftungen, Zeilen mit wirksamen Werten und kompakte Verwaltungsbegriffe."
|
||||||
|
),
|
||||||
|
"user_explanation": (
|
||||||
|
"Öffnen Sie die Markierung, wenn eine Beschriftung unklar ist. Sie erläutert die lokale Auswahl in ein oder zwei Sätzen."
|
||||||
|
),
|
||||||
|
"admin_explanation": (
|
||||||
|
"Feldhilfe bleibt am Feld. Längere Verfahrens-, API- oder Richtlinienerläuterungen gehören in verknüpfte Ablauf- oder Referenzthemen."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="docs.pattern.contextual-help",
|
id="docs.pattern.contextual-help",
|
||||||
@@ -725,6 +744,17 @@ manifest = ModuleManifest(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"admin_explanation": (
|
||||||
|
"Auswirkungen von Funktionen auf Rollen gehören Access. Änderungen an IDM-Zuordnungen können unabhängig von Änderungen am Organisationsmodell gesteuert werden."
|
||||||
|
),
|
||||||
|
"user_explanation": (
|
||||||
|
"Eine Person kann eine Funktion innehaben, weil IDM ihre Identität mit der Organisationsfunktion verknüpft. Access entscheidet, welche Anwendungsberechtigungen diese Funktion gewährt."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation_sources=(
|
documentation_sources=(
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ from typing import Any
|
|||||||
|
|
||||||
from govoplan_core.core.discovery import discover_module_manifests
|
from govoplan_core.core.discovery import discover_module_manifests
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION,
|
||||||
DocumentationCondition,
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
|
localizable_documentation_metadata_keys,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -102,12 +104,12 @@ def documentation_coverage_markdown(catalog: Mapping[str, Any]) -> str:
|
|||||||
"2. Give every user-facing module at least one scope-conditioned workflow topic and one field/consequence reference.",
|
"2. Give every user-facing module at least one scope-conditioned workflow topic and one field/consequence reference.",
|
||||||
"3. Give every configurable module an administrator topic covering permissions, policy provenance, retention, and operational consequences.",
|
"3. Give every configurable module an administrator topic covering permissions, policy provenance, retention, and operational consequences.",
|
||||||
"4. Keep live provider-state and instance-specific limitations in `documentation_providers`; do not publish them as generic facts.",
|
"4. Keep live provider-state and instance-specific limitations in `documentation_providers`; do not publish them as generic facts.",
|
||||||
"5. Add a versioned localization contract for structured metadata such as steps, fields, limitations, and verification; these values currently retain their manifest source language.",
|
"5. Adopt the versioned structured-localization contract for steps, fields, limitations, consequences, and verification; the coverage column tracks this migration independently from title/body completeness.",
|
||||||
"",
|
"",
|
||||||
"## Module gaps",
|
"## Module gaps",
|
||||||
"",
|
"",
|
||||||
"| Module | Topics | Missing German | Missing coverage |",
|
"| Module | Topics | Missing German | Structured German | Missing coverage |",
|
||||||
"| --- | ---: | ---: | --- |",
|
"| --- | ---: | ---: | ---: | --- |",
|
||||||
]
|
]
|
||||||
gaps_by_id = {str(item.get("module_id")): item for item in gaps}
|
gaps_by_id = {str(item.get("module_id")): item for item in gaps}
|
||||||
for module in modules:
|
for module in modules:
|
||||||
@@ -117,7 +119,9 @@ def documentation_coverage_markdown(catalog: Mapping[str, Any]) -> str:
|
|||||||
missing = ", ".join(str(item) for item in gap.get("missing", ())) or "-"
|
missing = ", ".join(str(item) for item in gap.get("missing", ())) or "-"
|
||||||
lines.append(
|
lines.append(
|
||||||
f"| `{module_id}` | {coverage.get('topic_count', 0)} | "
|
f"| `{module_id}` | {coverage.get('topic_count', 0)} | "
|
||||||
f"{coverage.get('missing_german_topic_count', 0)} | {missing} |"
|
f"{coverage.get('missing_german_topic_count', 0)} | "
|
||||||
|
f"{coverage.get('german_structured_complete_topic_count', 0)}/"
|
||||||
|
f"{coverage.get('structured_localizable_topic_count', 0)} | {missing} |"
|
||||||
)
|
)
|
||||||
lines.extend(("", "Generated file. Edit module manifests, then regenerate this report.", ""))
|
lines.extend(("", "Generated file. Edit module manifests, then regenerate this report.", ""))
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
@@ -238,6 +242,7 @@ def _topic_payload(module_id: str, topic: DocumentationTopic) -> dict[str, Any]:
|
|||||||
if key != "kind"
|
if key != "kind"
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
**_structured_content_payload(topic),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -258,6 +263,36 @@ def _localized_topic(topic: DocumentationTopic, locale: str) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _localized_content(topic: DocumentationTopic, locale: str) -> dict[str, Any]:
|
||||||
|
localizable_keys = localizable_documentation_metadata_keys(topic)
|
||||||
|
translation = topic.structured_translations.get(locale, {})
|
||||||
|
translated_fields = sorted(set(localizable_keys).intersection(translation))
|
||||||
|
complete = locale == "en" or not localizable_keys or (
|
||||||
|
topic.structured_translation_version
|
||||||
|
== DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION
|
||||||
|
and len(translated_fields) == len(localizable_keys)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"content": _public_value(dict(translation)),
|
||||||
|
"source_locale": locale if translated_fields else "en",
|
||||||
|
"translated_fields": translated_fields,
|
||||||
|
"complete": complete,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _structured_content_payload(topic: DocumentationTopic) -> dict[str, Any]:
|
||||||
|
if not localizable_documentation_metadata_keys(topic):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"structured_translation_version": topic.structured_translation_version,
|
||||||
|
"content_localizations": {
|
||||||
|
locale: _localized_content(topic, locale)
|
||||||
|
for locale in SUPPORTED_LOCALES
|
||||||
|
if locale != "en"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _module_coverage(
|
def _module_coverage(
|
||||||
manifest: ModuleManifest,
|
manifest: ModuleManifest,
|
||||||
topics: Sequence[Mapping[str, Any]],
|
topics: Sequence[Mapping[str, Any]],
|
||||||
@@ -270,6 +305,21 @@ def _module_coverage(
|
|||||||
for topic in topics
|
for topic in topics
|
||||||
if not _mapping(_mapping(topic.get("localizations")).get("de")).get("complete")
|
if not _mapping(_mapping(topic.get("localizations")).get("de")).get("complete")
|
||||||
]
|
]
|
||||||
|
structured_localizable = [
|
||||||
|
topic
|
||||||
|
for topic in manifest.documentation
|
||||||
|
if localizable_documentation_metadata_keys(topic)
|
||||||
|
]
|
||||||
|
german_structured_complete = [
|
||||||
|
topic
|
||||||
|
for topic in structured_localizable
|
||||||
|
if _localized_content(topic, "de")["complete"]
|
||||||
|
]
|
||||||
|
missing_german_structured = [
|
||||||
|
topic.id
|
||||||
|
for topic in structured_localizable
|
||||||
|
if not _localized_content(topic, "de")["complete"]
|
||||||
|
]
|
||||||
missing: list[str] = []
|
missing: list[str] = []
|
||||||
if not has_user:
|
if not has_user:
|
||||||
missing.append("user documentation")
|
missing.append("user documentation")
|
||||||
@@ -291,6 +341,15 @@ def _module_coverage(
|
|||||||
),
|
),
|
||||||
"missing_german_topic_count": len(missing_german),
|
"missing_german_topic_count": len(missing_german),
|
||||||
"missing_german_topic_ids": missing_german,
|
"missing_german_topic_ids": missing_german,
|
||||||
|
"structured_localizable_topic_count": len(structured_localizable),
|
||||||
|
"structured_translation_contract_topic_count": sum(
|
||||||
|
topic.structured_translation_version
|
||||||
|
== DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION
|
||||||
|
for topic in structured_localizable
|
||||||
|
),
|
||||||
|
"german_structured_complete_topic_count": len(german_structured_complete),
|
||||||
|
"missing_german_structured_topic_count": len(missing_german_structured),
|
||||||
|
"missing_german_structured_topic_ids": missing_german_structured,
|
||||||
"kinds": sorted(kinds),
|
"kinds": sorted(kinds),
|
||||||
"missing": missing,
|
"missing": missing,
|
||||||
}
|
}
|
||||||
@@ -314,6 +373,30 @@ def _coverage_summary(modules: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
|||||||
"module_count": len(modules),
|
"module_count": len(modules),
|
||||||
"topic_count": topic_count,
|
"topic_count": topic_count,
|
||||||
"german_complete_topic_count": topic_count - missing_german,
|
"german_complete_topic_count": topic_count - missing_german,
|
||||||
|
"structured_localizable_topic_count": sum(
|
||||||
|
int(
|
||||||
|
_mapping(module.get("coverage")).get(
|
||||||
|
"structured_localizable_topic_count", 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for module in modules
|
||||||
|
),
|
||||||
|
"structured_translation_contract_topic_count": sum(
|
||||||
|
int(
|
||||||
|
_mapping(module.get("coverage")).get(
|
||||||
|
"structured_translation_contract_topic_count", 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for module in modules
|
||||||
|
),
|
||||||
|
"german_structured_complete_topic_count": sum(
|
||||||
|
int(
|
||||||
|
_mapping(module.get("coverage")).get(
|
||||||
|
"german_structured_complete_topic_count", 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for module in modules
|
||||||
|
),
|
||||||
"runtime_provider_module_count": sum(
|
"runtime_provider_module_count": sum(
|
||||||
int(module.get("runtime_documentation_provider_count", 0)) > 0
|
int(module.get("runtime_documentation_provider_count", 0)) > 0
|
||||||
for module in modules
|
for module in modules
|
||||||
@@ -323,6 +406,79 @@ def _coverage_summary(modules: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def coverage_baseline(catalog: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Build the monotonic public-documentation coverage baseline."""
|
||||||
|
|
||||||
|
summary = _mapping(catalog.get("summary"))
|
||||||
|
topic_count = int(summary.get("topic_count", 0))
|
||||||
|
german_complete = int(summary.get("german_complete_topic_count", 0))
|
||||||
|
structured_localizable = int(
|
||||||
|
summary.get("structured_localizable_topic_count", 0)
|
||||||
|
)
|
||||||
|
structured_complete = int(
|
||||||
|
summary.get("german_structured_complete_topic_count", 0)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"schema_version": "1",
|
||||||
|
"minimum": {
|
||||||
|
"german_complete_topic_count": german_complete,
|
||||||
|
"structured_translation_contract_topic_count": int(
|
||||||
|
summary.get("structured_translation_contract_topic_count", 0)
|
||||||
|
),
|
||||||
|
"german_structured_complete_topic_count": structured_complete,
|
||||||
|
},
|
||||||
|
"maximum": {
|
||||||
|
"missing_german_topic_count": topic_count - german_complete,
|
||||||
|
"gap_module_count": int(summary.get("gap_module_count", 0)),
|
||||||
|
"missing_german_structured_topic_count": (
|
||||||
|
structured_localizable - structured_complete
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def coverage_regression_issues(
|
||||||
|
catalog: Mapping[str, Any], baseline: Mapping[str, Any]
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""Return monotonic coverage failures against a reviewed baseline."""
|
||||||
|
|
||||||
|
if str(baseline.get("schema_version")) != "1":
|
||||||
|
return ("unsupported coverage baseline schema_version",)
|
||||||
|
summary = _mapping(catalog.get("summary"))
|
||||||
|
minimum = _mapping(baseline.get("minimum"))
|
||||||
|
maximum = _mapping(baseline.get("maximum"))
|
||||||
|
actual = {
|
||||||
|
"german_complete_topic_count": int(
|
||||||
|
summary.get("german_complete_topic_count", 0)
|
||||||
|
),
|
||||||
|
"structured_translation_contract_topic_count": int(
|
||||||
|
summary.get("structured_translation_contract_topic_count", 0)
|
||||||
|
),
|
||||||
|
"german_structured_complete_topic_count": int(
|
||||||
|
summary.get("german_structured_complete_topic_count", 0)
|
||||||
|
),
|
||||||
|
"missing_german_topic_count": int(summary.get("topic_count", 0))
|
||||||
|
- int(summary.get("german_complete_topic_count", 0)),
|
||||||
|
"gap_module_count": int(summary.get("gap_module_count", 0)),
|
||||||
|
"missing_german_structured_topic_count": int(
|
||||||
|
summary.get("structured_localizable_topic_count", 0)
|
||||||
|
)
|
||||||
|
- int(summary.get("german_structured_complete_topic_count", 0)),
|
||||||
|
}
|
||||||
|
issues: list[str] = []
|
||||||
|
for key, expected in minimum.items():
|
||||||
|
if key in actual and actual[key] < int(expected):
|
||||||
|
issues.append(
|
||||||
|
f"coverage {key} regressed: {actual[key]} is below {int(expected)}"
|
||||||
|
)
|
||||||
|
for key, expected in maximum.items():
|
||||||
|
if key in actual and actual[key] > int(expected):
|
||||||
|
issues.append(
|
||||||
|
f"coverage {key} regressed: {actual[key]} exceeds {int(expected)}"
|
||||||
|
)
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
def _topic_kind(topic: DocumentationTopic) -> str:
|
def _topic_kind(topic: DocumentationTopic) -> str:
|
||||||
raw = topic.metadata.get("kind")
|
raw = topic.metadata.get("kind")
|
||||||
if isinstance(raw, str):
|
if isinstance(raw, str):
|
||||||
@@ -394,6 +550,7 @@ def _parser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument("--output", type=Path, required=True)
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
parser.add_argument("--coverage-output", type=Path)
|
parser.add_argument("--coverage-output", type=Path)
|
||||||
parser.add_argument("--workspace-root", type=Path)
|
parser.add_argument("--workspace-root", type=Path)
|
||||||
|
parser.add_argument("--coverage-baseline", type=Path)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--check",
|
"--check",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -408,10 +565,22 @@ def main(argv: Iterable[str] | None = None) -> int:
|
|||||||
collect_manifest_sources(workspace_root=args.workspace_root)
|
collect_manifest_sources(workspace_root=args.workspace_root)
|
||||||
)
|
)
|
||||||
if args.check:
|
if args.check:
|
||||||
if catalog_matches_sources(args.output, catalog):
|
failed = False
|
||||||
return 0
|
if not catalog_matches_sources(args.output, catalog):
|
||||||
print(f"Public documentation catalog is stale: {args.output}", file=sys.stderr)
|
print(f"Public documentation catalog is stale: {args.output}", file=sys.stderr)
|
||||||
return 1
|
failed = True
|
||||||
|
if args.coverage_baseline is not None:
|
||||||
|
try:
|
||||||
|
baseline = json.loads(
|
||||||
|
args.coverage_baseline.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
print(f"Invalid coverage baseline: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
for issue in coverage_regression_issues(catalog, _mapping(baseline)):
|
||||||
|
print(f"Public documentation {issue}", file=sys.stderr)
|
||||||
|
failed = True
|
||||||
|
return int(failed)
|
||||||
write_public_catalog(
|
write_public_catalog(
|
||||||
args.output,
|
args.output,
|
||||||
catalog,
|
catalog,
|
||||||
|
|||||||
@@ -52,6 +52,50 @@ class FakePrincipal:
|
|||||||
|
|
||||||
|
|
||||||
class DocsContextTests(unittest.TestCase):
|
class DocsContextTests(unittest.TestCase):
|
||||||
|
def test_structured_topic_metadata_uses_requested_locale(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="1.0.0",
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="example.reference.localized",
|
||||||
|
title="Reference",
|
||||||
|
summary="Reference summary.",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"limitations": ["English limitation."],
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"limitations": ["Deutsche Einschränkung."]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
layers = _classify_documentation(
|
||||||
|
registry,
|
||||||
|
FakePrincipal({"docs:documentation:read"}),
|
||||||
|
settings=None,
|
||||||
|
session=None,
|
||||||
|
documentation_type="admin",
|
||||||
|
locale="de-DE",
|
||||||
|
)
|
||||||
|
|
||||||
|
topic = layers["configured"][0]
|
||||||
|
self.assertEqual("de", topic["structured_translation_locale"])
|
||||||
|
self.assertEqual("1", topic["structured_translation_version"])
|
||||||
|
self.assertEqual(
|
||||||
|
["Deutsche Einschränkung."], topic["metadata"]["limitations"]
|
||||||
|
)
|
||||||
|
|
||||||
def test_user_provider_state_omits_binding_details(self) -> None:
|
def test_user_provider_state_omits_binding_details(self) -> None:
|
||||||
state = {
|
state = {
|
||||||
"configured": True,
|
"configured": True,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from govoplan_docs.public_export import (
|
|||||||
ManifestSource,
|
ManifestSource,
|
||||||
build_public_catalog,
|
build_public_catalog,
|
||||||
catalog_matches_sources,
|
catalog_matches_sources,
|
||||||
|
coverage_baseline,
|
||||||
|
coverage_regression_issues,
|
||||||
documentation_coverage_markdown,
|
documentation_coverage_markdown,
|
||||||
write_public_catalog,
|
write_public_catalog,
|
||||||
)
|
)
|
||||||
@@ -25,6 +27,13 @@ class PublicDocumentationExportTests(unittest.TestCase):
|
|||||||
self.assertEqual(9, module["coverage"]["topic_count"])
|
self.assertEqual(9, module["coverage"]["topic_count"])
|
||||||
self.assertEqual(0, module["coverage"]["missing_german_topic_count"])
|
self.assertEqual(0, module["coverage"]["missing_german_topic_count"])
|
||||||
self.assertEqual([], module["coverage"]["missing_german_topic_ids"])
|
self.assertEqual([], module["coverage"]["missing_german_topic_ids"])
|
||||||
|
self.assertEqual(2, module["coverage"]["structured_localizable_topic_count"])
|
||||||
|
self.assertEqual(
|
||||||
|
2, module["coverage"]["german_structured_complete_topic_count"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[], module["coverage"]["missing_german_structured_topic_ids"]
|
||||||
|
)
|
||||||
self.assertEqual([], module["coverage"]["missing"])
|
self.assertEqual([], module["coverage"]["missing"])
|
||||||
|
|
||||||
def test_catalog_projects_localized_manifest_topics_and_gaps(self) -> None:
|
def test_catalog_projects_localized_manifest_topics_and_gaps(self) -> None:
|
||||||
@@ -39,7 +48,11 @@ class PublicDocumentationExportTests(unittest.TestCase):
|
|||||||
summary="Perform the example workflow.",
|
summary="Perform the example workflow.",
|
||||||
body="Open and finish it.",
|
body="Open and finish it.",
|
||||||
documentation_types=("user", "admin"),
|
documentation_types=("user", "admin"),
|
||||||
metadata={"kind": "workflow"},
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"steps": ["Review", "Execute"],
|
||||||
|
"verification": "Confirm completion.",
|
||||||
|
},
|
||||||
translations={
|
translations={
|
||||||
"de": {
|
"de": {
|
||||||
"title": "Beispiel verwenden",
|
"title": "Beispiel verwenden",
|
||||||
@@ -47,6 +60,13 @@ class PublicDocumentationExportTests(unittest.TestCase):
|
|||||||
"body": "Öffnen und abschließen.",
|
"body": "Öffnen und abschließen.",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"steps": ["Prüfen", "Ausführen"],
|
||||||
|
"verification": "Den Abschluss bestätigen.",
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -59,7 +79,12 @@ class PublicDocumentationExportTests(unittest.TestCase):
|
|||||||
self.assertEqual("Beispiel verwenden", topic["localizations"]["de"]["title"])
|
self.assertEqual("Beispiel verwenden", topic["localizations"]["de"]["title"])
|
||||||
self.assertTrue(topic["localizations"]["de"]["complete"])
|
self.assertTrue(topic["localizations"]["de"]["complete"])
|
||||||
self.assertEqual("workflow", topic["kind"])
|
self.assertEqual("workflow", topic["kind"])
|
||||||
self.assertEqual({}, topic["content"])
|
self.assertEqual(["Review", "Execute"], topic["content"]["steps"])
|
||||||
|
self.assertEqual(
|
||||||
|
["Prüfen", "Ausführen"],
|
||||||
|
topic["content_localizations"]["de"]["content"]["steps"],
|
||||||
|
)
|
||||||
|
self.assertTrue(topic["content_localizations"]["de"]["complete"])
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"field/consequence reference",
|
"field/consequence reference",
|
||||||
catalog["modules"][0]["coverage"]["missing"],
|
catalog["modules"][0]["coverage"]["missing"],
|
||||||
@@ -159,6 +184,57 @@ class PublicDocumentationExportTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual("operator-workflow", topic["kind"])
|
self.assertEqual("operator-workflow", topic["kind"])
|
||||||
|
|
||||||
|
def test_coverage_baseline_rejects_text_and_structured_regressions(self) -> None:
|
||||||
|
healthy = build_public_catalog(
|
||||||
|
(
|
||||||
|
ManifestSource(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="1.0.0",
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="example.reference",
|
||||||
|
title="Reference",
|
||||||
|
summary="Reference summary.",
|
||||||
|
body="Reference body.",
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Referenz",
|
||||||
|
"summary": "Referenzzusammenfassung.",
|
||||||
|
"body": "Referenzinhalt.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"limitations": ["Source limitation."],
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"limitations": [
|
||||||
|
"Einschränkung der Quelle."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
baseline = coverage_baseline(healthy)
|
||||||
|
self.assertEqual((), coverage_regression_issues(healthy, baseline))
|
||||||
|
|
||||||
|
regressed = json.loads(json.dumps(healthy))
|
||||||
|
regressed["summary"]["german_complete_topic_count"] = 0
|
||||||
|
regressed["summary"]["german_structured_complete_topic_count"] = 0
|
||||||
|
issues = coverage_regression_issues(regressed, baseline)
|
||||||
|
self.assertTrue(any("german_complete_topic_count" in issue for issue in issues))
|
||||||
|
self.assertTrue(
|
||||||
|
any("german_structured_complete_topic_count" in issue for issue in issues)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/docs-webui",
|
"name": "@govoplan/docs-webui",
|
||||||
"version": "0.1.20",
|
"version": "0.1.21",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ export type DocsDocumentationTopic = {
|
|||||||
i18n_key: string;
|
i18n_key: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
translation_locale: string;
|
translation_locale: string;
|
||||||
|
structured_translation_locale: string;
|
||||||
|
structured_translation_version?: string | null;
|
||||||
version: {
|
version: {
|
||||||
resolved: string;
|
resolved: string;
|
||||||
minimum?: string | null;
|
minimum?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user