diff --git a/packages/govoplan-meta/pyproject.toml b/packages/govoplan-meta/pyproject.toml index 1b55a82..19aa54b 100644 --- a/packages/govoplan-meta/pyproject.toml +++ b/packages/govoplan-meta/pyproject.toml @@ -4,46 +4,46 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan" -version = "0.1.38" +version = "0.1.39" description = "Developer convenience package for a versioned GovOPlaN composition" readme = "README.md" requires-python = ">=3.12" license = { text = "AGPL-3.0-or-later" } dependencies = [ - "govoplan-core[server]==0.1.38", + "govoplan-core[server]==0.1.39", "govoplan-tenancy==0.1.20", - "govoplan-organizations==0.1.19", + "govoplan-organizations==0.1.20", "govoplan-identity==0.1.20", - "govoplan-idm==0.1.23", - "govoplan-access==0.1.22", - "govoplan-admin==0.1.21", + "govoplan-idm==0.1.24", + "govoplan-access==0.1.23", + "govoplan-admin==0.1.22", "govoplan-policy==0.1.22", "govoplan-audit==0.1.20", "govoplan-dashboard==0.1.20", - "govoplan-files==0.1.22", - "govoplan-mail==0.1.23", - "govoplan-campaign==0.1.26", - "govoplan-calendar==0.1.21", - "govoplan-docs==0.1.21", + "govoplan-files==0.1.23", + "govoplan-mail==0.1.24", + "govoplan-campaign==0.1.27", + "govoplan-calendar==0.1.22", + "govoplan-docs==0.1.22", "govoplan-ops==0.1.20", ] [project.optional-dependencies] full = [ - "govoplan-addresses==0.1.20", - "govoplan-approvals==0.1.19", + "govoplan-addresses==0.1.21", + "govoplan-approvals==0.1.20", "govoplan-assets==0.1.20", "govoplan-booking==0.1.20", "govoplan-cases==0.1.22", "govoplan-certificates==0.1.20", - "govoplan-committee==0.1.19", - "govoplan-connectors==0.1.24", + "govoplan-committee==0.1.20", + "govoplan-connectors==0.1.25", "govoplan-consultation==0.1.20", "govoplan-contracts==0.1.20", - "govoplan-dataflow==0.1.22", - "govoplan-datasources==0.1.23", + "govoplan-dataflow==0.1.23", + "govoplan-datasources==0.1.24", "govoplan-decisions==0.1.19", - "govoplan-dist-lists==0.1.19", + "govoplan-dist-lists==0.1.20", "govoplan-dms==0.1.20", "govoplan-encryption==0.1.19", "govoplan-erp==0.1.20", @@ -54,7 +54,7 @@ full = [ "govoplan-forms-runtime==0.1.19", "govoplan-grants==0.1.20", "govoplan-helpdesk==0.1.21", - "govoplan-identity-trust==0.1.19", + "govoplan-identity-trust==0.1.20", "govoplan-inspections==0.1.20", "govoplan-learning==0.1.20", "govoplan-mandates==0.1.19", @@ -63,28 +63,28 @@ full = [ "govoplan-payments==0.1.21", "govoplan-permits==0.1.20", "govoplan-poll==0.1.20", - "govoplan-portal==0.1.20", - "govoplan-postbox==0.1.20", + "govoplan-portal==0.1.21", + "govoplan-postbox==0.1.21", "govoplan-procurement==0.1.20", "govoplan-projects==0.1.19", "govoplan-quick-access==0.1.20", - "govoplan-records==0.1.21", - "govoplan-reporting==0.1.19", + "govoplan-records==0.1.22", + "govoplan-reporting==0.1.20", "govoplan-resources==0.1.20", "govoplan-rest==0.1.19", - "govoplan-risk-compliance==0.1.19", + "govoplan-risk-compliance==0.1.20", "govoplan-scheduling==0.1.21", "govoplan-search==0.1.19", "govoplan-services==0.1.19", "govoplan-soap==0.1.19", "govoplan-tasks==0.1.21", - "govoplan-templates==0.1.20", - "govoplan-tickets==0.1.21", + "govoplan-templates==0.1.21", + "govoplan-tickets==0.1.22", "govoplan-transparency==0.1.20", - "govoplan-views==0.1.20", + "govoplan-views==0.1.21", "govoplan-voting==0.1.20", - "govoplan-wiki==0.1.21", - "govoplan-workflow==0.1.21", + "govoplan-wiki==0.1.22", + "govoplan-workflow==0.1.22", "govoplan-workflow-engine==0.1.21", "govoplan-xrechnung==0.1.21", ] diff --git a/requirements-release.txt b/requirements-release.txt index 3cf130d..41c54ce 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -2,17 +2,17 @@ # Only add a module after its referenced tag has been published. ../govoplan-core[server] govoplan-tenancy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git@v0.1.20 -govoplan-organizations @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git@v0.1.19 +govoplan-organizations @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git@v0.1.20 govoplan-identity @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity.git@v0.1.20 -govoplan-idm @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git@v0.1.23 -govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.22 -govoplan-admin @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git@v0.1.21 +govoplan-idm @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git@v0.1.24 +govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.23 +govoplan-admin @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git@v0.1.22 govoplan-policy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git@v0.1.22 govoplan-audit @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git@v0.1.20 govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.20 -govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.22 -govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.23 -govoplan-campaign @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git@v0.1.26 -govoplan-calendar @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git@v0.1.21 -govoplan-docs @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git@v0.1.21 +govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.23 +govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.24 +govoplan-campaign @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git@v0.1.27 +govoplan-calendar @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git@v0.1.22 +govoplan-docs @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git@v0.1.22 govoplan-ops @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git@v0.1.20 diff --git a/tests/test_platform_interface_inventory.py b/tests/test_platform_interface_inventory.py index 12ab793..8a124a8 100644 --- a/tests/test_platform_interface_inventory.py +++ b/tests/test_platform_interface_inventory.py @@ -169,6 +169,69 @@ class PlatformInterfaceInventoryTests(unittest.TestCase): ), ) + def test_high_risk_help_baseline_is_validated(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "help-baseline.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "maximum_missing_exact_help": 3, + } + ), + encoding="utf-8", + ) + + self.assertEqual( + 3, + inventory._load_high_risk_help_baseline(path)[ + "maximum_missing_exact_help" + ], + ) + path.write_text( + json.dumps( + { + "schema_version": 1, + "maximum_missing_exact_help": -1, + } + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "non-negative integer"): + inventory._load_high_risk_help_baseline(path) + + def test_declaration_strict_mode_rejects_high_risk_help_regression( + self, + ) -> None: + result = { + "translation_health": {"missing_catalog_entries": []}, + "api": { + "unclassified_endpoints": [], + "stale_endpoint_declarations": [], + }, + "declaration_health": {}, + "help_health": { + "invalid_risk_annotations": [], + "unresolved_exact_high_risk_help": [], + "high_risk_help_without_german": [], + "missing_exact_high_risk_help": [{"id": "example.delete"}], + "baseline_maximum_missing": 0, + "baseline_regression": True, + }, + } + + self.assertEqual( + [ + "1 high-risk controls lack exact F1 help; baseline permits at most 0" + ], + inventory._strict_failures( + result, + check_translations=False, + check_endpoints=False, + check_declarations=True, + ), + ) + def test_fastapi_route_scanner_includes_router_prefix(self) -> None: tree = ast.parse( """ diff --git a/tools/inventory/extract-webui-structure.mjs b/tools/inventory/extract-webui-structure.mjs index b121931..9a8f159 100644 --- a/tools/inventory/extract-webui-structure.mjs +++ b/tools/inventory/extract-webui-structure.mjs @@ -63,6 +63,28 @@ const helpAttributes = new Set([ "helperText", "helpText" ]); +const exactHelpAttributes = new Set([ + "data-help-context-id", + "helpContextId" +]); +const helpRiskAttributes = new Set([ + "data-help-risk", + "helpRisk" +]); +const reviewedHelpRiskAttributes = new Set([ + "data-help-risk-reviewed", + "helpRiskReviewed" +]); +const supportedHelpRisks = new Set([ + "authority", + "credential", + "disclosure", + "encryption", + "external-effect", + "irreversible", + "policy", + "retention" +]); const actionComponentPattern = /(?:Action|Button|Link)$/; const contributionTypes = new Map([ ["AdminSectionsUiCapability", "admin_section"], @@ -200,20 +222,43 @@ function inspectSource(repository, sourceRoot, sourcePath) { const parentAttributes = parentFormField ? jsxAttributes(parentFormField) : new Map(); + const scopedAncestorAttributes = nearestScopedHelpAttributes(node); const label = attributes.get("label") ?? attributes.get("aria-label") ?? parentAttributes.get("label") ?? null; const help = firstAttribute(attributes, helpAttributes) ?? - firstAttribute(parentAttributes, helpAttributes); + firstAttribute(parentAttributes, helpAttributes) ?? + firstAttribute(scopedAncestorAttributes, helpAttributes); const hasHelp = hasAnyAttribute(attributes, helpAttributes) || - hasAnyAttribute(parentAttributes, helpAttributes); + hasAnyAttribute(parentAttributes, helpAttributes) || + hasAnyAttribute(scopedAncestorAttributes, helpAttributes); + const hasExactHelp = hasAnyAttribute(attributes, exactHelpAttributes) || + hasAnyAttribute(parentAttributes, exactHelpAttributes) || + hasAnyAttribute(scopedAncestorAttributes, exactHelpAttributes); + const helpContextId = firstAttribute(attributes, exactHelpAttributes) ?? + firstAttribute(parentAttributes, exactHelpAttributes) ?? + firstAttribute(scopedAncestorAttributes, exactHelpAttributes); const explicitId = firstAttribute( attributes, new Set(["interfaceId", "data-interface-id", "id", "name", "field"]) ); const context = nearestNamedContext(node); + const risk = helpRiskFor({ + component, + context, + file: relativeFile, + label, + explicitId, + name: attributes.get("name") ?? attributes.get("id") ?? attributes.get("field") ?? null, + explicitRisk: firstAttribute(attributes, helpRiskAttributes) ?? + firstAttribute(parentAttributes, helpRiskAttributes) ?? + firstAttribute(scopedAncestorAttributes, helpRiskAttributes) + }); + const riskReviewed = firstAttribute(attributes, reviewedHelpRiskAttributes) ?? + firstAttribute(parentAttributes, reviewedHelpRiskAttributes) ?? + firstAttribute(scopedAncestorAttributes, reviewedHelpRiskAttributes); const stableId = sourceIdentity( "field", node, @@ -237,7 +282,14 @@ function inspectSource(repository, sourceRoot, sourcePath) { help: help ?? null, helpId: hasHelp ? `${stableId}.help` : null, helpDynamic: hasHelp && help === null, - helpCandidate: !hasHelp + helpCandidate: !hasHelp, + helpExact: hasExactHelp, + helpContextId, + helpContextDynamic: hasExactHelp && helpContextId === null, + helpRisk: risk.value, + helpRiskSource: risk.source, + helpRiskReviewed: riskReviewed, + highRiskHelpMissing: risk.value !== null && !hasExactHelp && riskReviewed !== "standard" }); } @@ -261,6 +313,19 @@ function inspectSource(repository, sourceRoot, sourcePath) { new Set(["interfaceId", "data-interface-id", "id", "name"]) ); const context = nearestNamedContext(node); + const hasHelp = hasAnyAttribute(attributes, helpAttributes); + const hasExactHelp = hasAnyAttribute(attributes, exactHelpAttributes); + const helpContextId = firstAttribute(attributes, exactHelpAttributes); + const risk = helpRiskFor({ + component, + context, + file: relativeFile, + label, + explicitId, + name: attributes.get("name") ?? attributes.get("id") ?? null, + explicitRisk: firstAttribute(attributes, helpRiskAttributes) + }); + const riskReviewed = firstAttribute(attributes, reviewedHelpRiskAttributes); result.actions.push({ ...locate(node), id: sourceIdentity( @@ -273,7 +338,15 @@ function inspectSource(repository, sourceRoot, sourcePath) { idSource: explicitId === null ? "source_anchor" : "explicit", context, component, - label + label, + helpExact: hasExactHelp, + helpContextId, + helpContextDynamic: hasExactHelp && helpContextId === null, + helpDynamic: hasHelp && firstAttribute(attributes, helpAttributes) === null, + helpRisk: risk.value, + helpRiskSource: risk.source, + helpRiskReviewed: riskReviewed, + highRiskHelpMissing: risk.value !== null && !hasExactHelp && riskReviewed !== "standard" }); } @@ -354,6 +427,26 @@ function inspectSource(repository, sourceRoot, sourcePath) { return null; } + function nearestScopedHelpAttributes(node) { + let current = node.parent; + while (current) { + if (ts.isJsxElement(current)) { + const attributes = jsxAttributes(current.openingElement); + if (attributes.get("data-help-scope") === "field") return attributes; + } + if ( + ts.isFunctionDeclaration(current) || + ts.isMethodDeclaration(current) || + ts.isArrowFunction(current) || + ts.isFunctionExpression(current) + ) { + return new Map(); + } + current = current.parent; + } + return new Map(); + } + function jsxAttributes(node) { const mapped = new Map(); for (const attribute of node.attributes.properties) { @@ -579,6 +672,34 @@ function hasAnyAttribute(attributes, names) { return false; } +function helpRiskFor({ component, context, file, label, explicitId, name, explicitRisk }) { + if (typeof explicitRisk === "string") { + return supportedHelpRisks.has(explicitRisk) + ? { value: explicitRisk, source: "explicit" } + : { value: null, source: "invalid_explicit" }; + } + const value = [component, context, file, label, explicitId, name] + .filter((item) => typeof item === "string") + .join(" ") + .toLowerCase() + .replace(/^i18n:/g, "") + .replace(/[._-]+/g, " "); + const patterns = [ + ["irreversible", /\b(delete|destroy|erase|purge|dispose|disposition|revoke|withdraw|shred)\b/], + ["credential", /\b(credential|password|secret|token|api key|private key)\b/], + ["retention", /\b(retention|legal hold|archive lifecycle)\b/], + ["encryption", /\b(encrypt|encryption|decrypt|decryption|signing key|signature key)\b/], + ["disclosure", /\b(disclose|disclosure|publish|share externally|public export)\b/], + ["external-effect", /\b(send|deliver|transfer|refund|payment execution|webhook execution)\b/], + ["authority", /\b(grant permission|role assignment|approve|reject|formal decision|mandate)\b/], + ["policy", /\b(policy apply|policy override|enforcement mode)\b/] + ]; + for (const [risk, pattern] of patterns) { + if (pattern.test(value)) return { value: risk, source: "inferred" }; + } + return { value: null, source: null }; +} + function slug(value) { const normalized = value .toLowerCase() diff --git a/tools/inventory/high-risk-help-baseline.json b/tools/inventory/high-risk-help-baseline.json new file mode 100644 index 0000000..3d97901 --- /dev/null +++ b/tools/inventory/high-risk-help-baseline.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, + "maximum_missing_exact_help": 0, + "rationale": "The source-derived high-risk queue for Core issue #284 is fully resolved. Strict declarations reject any new high-risk control without an exact, manifest-declared, German-complete F1 context." +} diff --git a/tools/inventory/platform-interface-inventory.py b/tools/inventory/platform-interface-inventory.py index 7c655ee..aad600f 100644 --- a/tools/inventory/platform-interface-inventory.py +++ b/tools/inventory/platform-interface-inventory.py @@ -31,6 +31,9 @@ ENDPOINT_SURFACE_CATEGORIES = { DEFAULT_ENDPOINT_DECLARATIONS = ( META_ROOT / "tools" / "inventory" / "endpoint-surface-declarations.json" ) +DEFAULT_HIGH_RISK_HELP_BASELINE = ( + META_ROOT / "tools" / "inventory" / "high-risk-help-baseline.json" +) REQUIRED_LOCALES = ("de", "en") REFERENCE_LOCALE = "de" @@ -75,6 +78,12 @@ def main() -> int: default=DEFAULT_ENDPOINT_DECLARATIONS, help="Versioned endpoint-surface declaration registry.", ) + parser.add_argument( + "--high-risk-help-baseline", + type=Path, + default=DEFAULT_HIGH_RISK_HELP_BASELINE, + help="Versioned upper bound for high-risk controls without exact F1 help.", + ) args = parser.parse_args() catalog = json.loads((META_ROOT / "repositories.json").read_text(encoding="utf-8")) @@ -85,11 +94,15 @@ def main() -> int: endpoint_declarations = _load_endpoint_declarations( args.endpoint_declarations.resolve() ) + high_risk_help_baseline = _load_high_risk_help_baseline( + args.high_risk_help_baseline.resolve() + ) inventory = _assemble_inventory( webui=webui, backend_endpoints=backend_endpoints, manifests=manifests, endpoint_declarations=endpoint_declarations, + high_risk_help_baseline=high_risk_help_baseline, runtime_snapshot=( _load_runtime_snapshot(args.runtime_snapshot.resolve()) if args.runtime_snapshot is not None @@ -164,6 +177,28 @@ def _strict_failures( f"{len(declaration_health['stale_runtime_routes'])} runtime route " "declarations have no WebUI implementation" ) + help_health = inventory.get("help_health", {}) + if check_declarations and help_health.get("invalid_risk_annotations"): + failures.append( + f"{len(help_health['invalid_risk_annotations'])} controls use an " + "unsupported contextual-help risk class" + ) + if check_declarations and help_health.get("baseline_regression"): + failures.append( + f"{len(help_health['missing_exact_high_risk_help'])} high-risk " + "controls lack exact F1 help; baseline permits at most " + f"{help_health['baseline_maximum_missing']}" + ) + if check_declarations and help_health.get("unresolved_exact_high_risk_help"): + failures.append( + f"{len(help_health['unresolved_exact_high_risk_help'])} high-risk " + "controls reference no manifest DocumentationTopic help context" + ) + if check_declarations and help_health.get("high_risk_help_without_german"): + failures.append( + f"{len(help_health['high_risk_help_without_german'])} high-risk " + "controls resolve to documentation without complete German content" + ) runtime_comparison = inventory.get("runtime_comparison") if ( check_declarations @@ -355,6 +390,29 @@ def _extract_manifests( } for permission in manifest.permissions ], + "documentation": [ + { + "id": topic.id, + "help_contexts": sorted( + { + str(context) + for context in topic.metadata.get( + "help_contexts", () + ) + if isinstance(context, str) and context.strip() + } + ), + "german_complete": ( + isinstance(topic.translations.get("de"), dict) + and all( + isinstance(topic.translations["de"].get(field), str) + and topic.translations["de"][field].strip() + for field in ("title", "summary", "body") + ) + ), + } + for topic in manifest.documentation + ], "architecture": ( manifest.architecture.to_dict() if manifest.architecture is not None @@ -400,6 +458,7 @@ def _assemble_inventory( backend_endpoints: list[dict[str, Any]], manifests: list[dict[str, Any]], endpoint_declarations: dict[tuple[str, str, str], dict[str, Any]], + high_risk_help_baseline: dict[str, Any] | None = None, runtime_snapshot: dict[str, Any] | None = None, ) -> dict[str, Any]: frontend_refs = webui["frontendApiReferences"] @@ -471,8 +530,47 @@ def _assemble_inventory( if any(key not in catalog_keys.get(locale, set()) for locale in expected_locales) ] fields = webui["fields"] + actions = webui.get("actions", []) help_candidates = [field for field in fields if field["helpCandidate"]] dynamic_help = [field for field in fields if field.get("helpDynamic")] + controls = [*fields, *actions] + high_risk_controls = [item for item in controls if item.get("helpRisk")] + missing_exact_high_risk_help = [ + item for item in controls if item.get("highRiskHelpMissing") + ] + invalid_risk_annotations = [ + item + for item in controls + if item.get("helpRiskSource") == "invalid_explicit" + ] + documentation_contexts = { + context: { + "module_id": manifest["id"], + "topic_id": topic["id"], + "german_complete": topic["german_complete"], + } + for manifest in manifests + for topic in manifest.get("documentation", []) + for context in topic.get("help_contexts", []) + } + unresolved_exact_high_risk_help = [ + item + for item in high_risk_controls + if item.get("helpExact") + and not item.get("helpContextDynamic") + and item.get("helpContextId") not in documentation_contexts + ] + high_risk_help_without_german = [ + item + for item in high_risk_controls + if item.get("helpContextId") in documentation_contexts + and not documentation_contexts[item["helpContextId"]]["german_complete"] + ] + baseline_maximum_missing = ( + high_risk_help_baseline["maximum_missing_exact_help"] + if high_risk_help_baseline is not None + else None + ) governance_adoption = Counter( dimension["adoption"] for manifest in manifests @@ -499,10 +597,34 @@ def _assemble_inventory( "modules": manifests, "interface_declarations": source_declarations, "declaration_health": declaration_health, + "help_health": { + "supported_risk_classes": sorted( + { + str(item["helpRisk"]) + for item in high_risk_controls + if item.get("helpRisk") + } + ), + "high_risk_controls": high_risk_controls, + "missing_exact_high_risk_help": missing_exact_high_risk_help, + "invalid_risk_annotations": invalid_risk_annotations, + "unresolved_exact_high_risk_help": unresolved_exact_high_risk_help, + "high_risk_help_without_german": high_risk_help_without_german, + "dynamic_owner_context_controls": [ + item + for item in high_risk_controls + if item.get("helpContextDynamic") + ], + "baseline_maximum_missing": baseline_maximum_missing, + "baseline_regression": ( + baseline_maximum_missing is not None + and len(missing_exact_high_risk_help) > baseline_maximum_missing + ), + }, "runtime_comparison": runtime_comparison, "ui": { "fields": fields, - "actions": webui.get("actions", []), + "actions": actions, "labels": webui["labels"], "visible_text": webui["visibleText"], "routes": webui["routes"], @@ -554,7 +676,19 @@ def _assemble_inventory( "ui_fields_with_resolvable_f1_context": len(fields), "help_review_candidates": len(help_candidates), "dynamic_help_references": len(dynamic_help), - "ui_actions": len(webui.get("actions", [])), + "ui_actions": len(actions), + "high_risk_controls": len(high_risk_controls), + "high_risk_controls_with_exact_help": ( + len(high_risk_controls) - len(missing_exact_high_risk_help) + ), + "high_risk_controls_missing_exact_help": len( + missing_exact_high_risk_help + ), + "invalid_help_risk_annotations": len(invalid_risk_annotations), + "unresolved_exact_high_risk_help": len( + unresolved_exact_high_risk_help + ), + "high_risk_help_without_german": len(high_risk_help_without_german), "interface_declarations": len(source_declarations), "duplicate_interface_ids": len(declaration_health["duplicate_ids"]), "undeclared_source_surfaces": len( @@ -885,6 +1019,10 @@ def _render_markdown(inventory: dict[str, Any]) -> str: for item in inventory["api"]["unreferenced_by_static_webui_scan"] ) classification_counts = inventory["api"]["classification_counts"] + high_risk_by_repository = Counter( + item["repository"] + for item in inventory["help_health"]["missing_exact_high_risk_help"] + ) lines = [ "# GovOPlaN Platform Interface Inventory", "", @@ -900,6 +1038,12 @@ def _render_markdown(inventory: dict[str, Any]) -> str: f"- Fields with a resolvable F1 context: {summary['ui_fields_with_resolvable_f1_context']}", f"- Fields with dynamic help references: {summary['dynamic_help_references']}", f"- Help review candidates: {summary['help_review_candidates']}", + f"- High-risk controls: {summary['high_risk_controls']}", + f"- High-risk controls with exact F1 help: {summary['high_risk_controls_with_exact_help']}", + f"- High-risk controls missing exact F1 help: {summary['high_risk_controls_missing_exact_help']}", + f"- Invalid help-risk annotations: {summary['invalid_help_risk_annotations']}", + f"- High-risk exact contexts missing a manifest topic: {summary['unresolved_exact_high_risk_help']}", + f"- High-risk contexts without complete German topic content: {summary['high_risk_help_without_german']}", f"- Stable interface declarations: {summary['interface_declarations']}", f"- Duplicate interface IDs: {summary['duplicate_interface_ids']}", f"- WebUI surfaces missing runtime declarations: {summary['undeclared_source_surfaces']}", @@ -930,6 +1074,23 @@ def _render_markdown(inventory: dict[str, Any]) -> str: f"| `{repository}` | {count} |" for repository, count in sorted(help_by_repository.items()) ) + lines.extend( + [ + "", + "## High-risk Contextual-help Debt", + "", + "Inferred or explicitly classified high-risk controls require an exact", + "F1 context. `data-help-risk-reviewed=\"standard\"` records a reviewed", + "false positive. The versioned baseline makes this queue non-regressing.", + "", + "| Repository | Missing exact contexts |", + "| --- | ---: |", + ] + ) + lines.extend( + f"| `{repository}` | {count} |" + for repository, count in sorted(high_risk_by_repository.items()) + ) lines.extend( [ "", @@ -1005,6 +1166,29 @@ def endpoint_key(endpoint: dict[str, Any]) -> tuple[str, str, str]: ) +def _load_high_risk_help_baseline(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise ValueError( + f"High-risk contextual-help baseline does not exist: {path}" + ) from exc + except json.JSONDecodeError as exc: + raise ValueError( + f"High-risk contextual-help baseline is invalid JSON: {exc}" + ) from exc + if not isinstance(payload, dict) or payload.get("schema_version") != 1: + raise ValueError( + "High-risk contextual-help baseline must use schema_version 1." + ) + maximum = payload.get("maximum_missing_exact_help") + if not isinstance(maximum, int) or isinstance(maximum, bool) or maximum < 0: + raise ValueError( + "High-risk contextual-help baseline maximum must be a non-negative integer." + ) + return payload + + def _load_endpoint_declarations( path: Path, ) -> dict[tuple[str, str, str], dict[str, Any]]: