From 3d6efa5175a34219a10797b4aff6f672d4049030 Mon Sep 17 00:00:00 2001 From: Rickard von Haugwitz Date: Mon, 13 Jul 2026 12:03:13 +0200 Subject: [PATCH 1/8] Add generated config output override --- .../schemas/command_package_ir.schema.json | 7 +- src/command_generation/targets/python.py | 114 ++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/command_generation/schemas/command_package_ir.schema.json b/src/command_generation/schemas/command_package_ir.schema.json index ad43930..2cbd7cf 100644 --- a/src/command_generation/schemas/command_package_ir.schema.json +++ b/src/command_generation/schemas/command_package_ir.schema.json @@ -829,7 +829,8 @@ "target_root_resolve", "sectioned_payload_select", "json_resource_load", - "json_output_with_source_fallback" + "json_output_with_source_fallback", + "config_output" ] }, "generated_root": { @@ -856,6 +857,10 @@ "type": "string", "minLength": 1 }, + "tiny_config_kind": { + "type": "string", + "minLength": 1 + }, "compact_profile_ref": { "type": "string" }, diff --git a/src/command_generation/targets/python.py b/src/command_generation/targets/python.py index 8d69e36..cfbdc7b 100644 --- a/src/command_generation/targets/python.py +++ b/src/command_generation/targets/python.py @@ -821,6 +821,94 @@ def _python_local_runtime_helper_block() -> str: ) +def _python_config_output_helper_block() -> str: + return ( + "def _emit_config_tiny_text(payload: dict[str, Any]) -> str:\n" + " workspace = payload['workspace']\n" + " local_runtime = payload['local_runtime']\n" + " next_detail = payload['next_detail']\n" + " lines = [\n" + " f\"Target: {payload['target']}\",\n" + " f\"Config path: {payload['config_path']}\",\n" + " f\"AW enabled: {workspace['enabled']} ({workspace['enabled_source']})\",\n" + " f\"Enabled modules: {', '.join(workspace['enabled_modules']) or '(none)'}\",\n" + " f\"Improvement latitude: {workspace['improvement_latitude']}\",\n" + " f\"Optimization bias: {workspace['optimization_bias']}\",\n" + " f\"Delegation mode: {local_runtime['delegation_mode']['value']}\",\n" + " f\"Safe to auto-run commands: {local_runtime['safe_to_auto_run_commands']['value']}\",\n" + " f\"Select fields: {next_detail['select']}\",\n" + " f\"Verbose diagnostics: {next_detail['verbose']}\",\n" + " ]\n" + " return '\\n'.join(lines) + '\\n'\n" + "\n\n" + "def _emit_config_compact_text(payload: dict[str, Any]) -> str:\n" + " workspace = payload['workspace']\n" + " local_runtime = payload['local_runtime']\n" + " lines = [\n" + " f\"Target: {payload['target']}\",\n" + " f\"Config path: {payload['config_path']}\",\n" + " f\"Exists: {payload['exists']}\",\n" + " f\"AW enabled: {workspace['enabled']} ({workspace['enabled_source']})\",\n" + " f\"Enabled modules: {', '.join(workspace['enabled_modules']) or '(none)'}\",\n" + " f\"Improvement latitude: {workspace['improvement_latitude']}\",\n" + " f\"Optimization bias: {workspace['optimization_bias']}\",\n" + " f\"Workflow obligations: {len(workspace['workflow_obligations'])} configured\",\n" + " f\"Delegation mode: {local_runtime['delegation_mode']['value']}\",\n" + " f\"Safe to auto-run commands: {local_runtime['safe_to_auto_run_commands']['value']}\",\n" + " f\"Full profile: {payload['full_profile_command']}\",\n" + " ]\n" + " return '\\n'.join(lines) + '\\n'\n" + "\n\n" + "def _emit_config_full_text(payload: dict[str, Any]) -> str:\n" + " workspace = payload['workspace']\n" + " edit_reference = payload['edit_reference']\n" + " mixed_agent = payload['mixed_agent']\n" + " lines = [\n" + " f\"Target: {payload['target']}\",\n" + " f\"Config path: {payload['config_path']}\",\n" + " f\"Exists: {payload['exists']}\",\n" + " f\"AW enabled: {workspace['enabled']} ({workspace['enabled_source']})\",\n" + " f\"Reference: {edit_reference['reference_doc']}\",\n" + " f\"Schema: {edit_reference['source_schema']}\",\n" + " f\"Check command: {edit_reference['check_command']}\",\n" + " ]\n" + " warnings = payload['warnings']\n" + " if warnings:\n" + " lines.append('Warnings:')\n" + " lines.extend(f'- {warning}' for warning in warnings)\n" + " lines.extend([\n" + " f\"Enabled modules: {', '.join(workspace['enabled_modules']) or '(none)'}\",\n" + " f\"Agent instructions file: {workspace['agent_instructions_file']} ({workspace['agent_instructions_file_source']})\",\n" + " f\"Workflow artifact profile: {workspace['workflow_artifact_profile']} ({workspace['workflow_artifact_profile_source']})\",\n" + " f\"Agent configuration substrate: {workspace['agent_configuration_substrate']['canonical_doc']} ({workspace['agent_configuration_substrate']['owner_surface']})\",\n" + " f\"System-intent sources: {', '.join(workspace['system_intent']['sources']) or 'none'} ({workspace['system_intent']['sources_source']})\",\n" + " f\"Workflow obligations: {len(workspace['workflow_obligations'])} configured\",\n" + " f\"Improvement latitude: {workspace['improvement_latitude']} ({workspace['improvement_latitude_source']})\",\n" + " f\"Optimization bias: {workspace['optimization_bias']} ({workspace['optimization_bias_source']})\",\n" + " f\"Advanced features: {', '.join(workspace['advanced_features']) or 'none'} ({workspace['advanced_features_source']})\",\n" + " f\"CLI invoke: {workspace['cli_invoke']} ({workspace['cli_invoke_source']})\",\n" + " f\"Wrapper rule: {payload['update']['wrapper_rule']}\",\n" + " 'Update modules:',\n" + " ])\n" + " for module in payload['update']['modules']:\n" + " lines.append(f\"- {module['module']}: {module['source_type']} {module['source_ref']}\")\n" + " lines.append(f\" label: {module['source_label']}\")\n" + " lines.append(f\" metadata: {module['metadata_path']} ({module['sync_status']})\")\n" + " lines.extend([\n" + " 'Mixed-agent:',\n" + " f\"- rule: {mixed_agent['rule']}\",\n" + " f\"- repo policy: {mixed_agent['repo_policy']['path']} ({mixed_agent['repo_policy']['source']})\",\n" + " f\"- local override: {mixed_agent['local_override']['path']} ({mixed_agent['local_override']['status']})\",\n" + " f\"- local integration area: {mixed_agent['local_integration_area']['root']} ({mixed_agent['local_integration_area']['status']})\",\n" + " f\"- effective posture: internal delegation={mixed_agent['effective_posture']['supports_internal_delegation']['value']}, strong planner={mixed_agent['effective_posture']['strong_planner_available']['value']}, cheap bounded executor={mixed_agent['effective_posture']['cheap_bounded_executor_available']['value']}\",\n" + " f\"- delegation targets: {len(mixed_agent['delegation_targets']['profiles'])} configured\",\n" + " f\"- delegation outcome evidence: {mixed_agent['delegation_targets']['outcome_artifact']['path']} ({mixed_agent['delegation_targets']['outcome_artifact']['status']})\",\n" + " ])\n" + " return '\\n'.join(lines) + '\\n'\n" + "\n\n" + ) + + def _python_local_runtime_generated_function( function: str, override: dict[str, Any], @@ -904,6 +992,30 @@ def _python_local_runtime_generated_function( f" from {source_import_module} import {function} as source_function\n\n" " return source_function(values, arguments, context)\n" ) + if implementation == "config_output": + selected_output_kind = str(override.get("selected_output_kind") or "command-generation/selected-output/v1") + tiny_config_kind = str(override.get("tiny_config_kind") or "agentic-workspace/config-tiny/v1") + return ( + f"def {function}(values: dict[str, Any], _arguments: dict[str, Any], _context: Any) -> Any:\n" + " result = values['result']\n" + " output_format = str(values.get('format') or 'text')\n" + " if not isinstance(result, dict):\n" + " raise ValueError('workspace config output requires an object result payload')\n" + " if output_format == 'json':\n" + " print(json.dumps(_serialise_value(result), indent=2))\n" + " return None\n" + f" if result.get('kind') == {selected_output_kind!r}:\n" + " print(_emit_selected_output_text(result), end='')\n" + " return None\n" + f" if result.get('kind') == {tiny_config_kind!r}:\n" + " print(_emit_config_tiny_text(result), end='')\n" + " return None\n" + " if result.get('profile') == 'compact':\n" + " print(_emit_config_compact_text(result), end='')\n" + " return None\n" + " print(_emit_config_full_text(result), end='')\n" + " return None\n" + ) raise ValueError(f"unsupported generated local runtime implementation: {implementation!r}") @@ -933,6 +1045,8 @@ def _python_local_runtime_binding_module( helper_parts: list[str] = [] if overrides: helper_parts.append(_python_local_runtime_helper_block()) + if any(str(override.get("implementation")) == "config_output" for override in overrides.values()): + helper_parts.append(_python_config_output_helper_block()) helper_block = "\n\n".join(helper_parts) + "\n\n" if helper_parts else "" helper_imports = "" if overrides: From e96114c897c8e4630a019093296b4d96ee4aab99 Mon Sep 17 00:00:00 2001 From: Rickard von Haugwitz Date: Mon, 13 Jul 2026 12:45:30 +0200 Subject: [PATCH 2/8] Add declared text view output rendering --- src/command_generation/primitive_executor.py | 156 ++++++++++++++++++ .../schemas/command_package_ir.schema.json | 7 +- src/command_generation/targets/python.py | 114 ------------- src/command_generation/targets/typescript.py | 115 ++++++++++++- tests/test_primitive_executor.py | 54 ++++++ 5 files changed, 324 insertions(+), 122 deletions(-) diff --git a/src/command_generation/primitive_executor.py b/src/command_generation/primitive_executor.py index f9ac9ef..a70d7c6 100644 --- a/src/command_generation/primitive_executor.py +++ b/src/command_generation/primitive_executor.py @@ -570,6 +570,10 @@ def _emit_output( output_format = str(values.get("format") or "text") if output_format == "json": return json.dumps(result, indent=2, sort_keys=True) + "\n" + if isinstance(result, dict): + declared_view = _emit_declared_text_view(result, arguments.get("text_views", [])) + if declared_view is not None: + return declared_view if str(arguments.get("text_style", "")) == "current-memory" and isinstance( result, dict ): @@ -603,6 +607,158 @@ def _emit_output( return "\n".join(lines).rstrip() + "\n" +def _emit_declared_text_view(result: dict[str, Any], views: Any) -> str | None: + if views is None: + return None + if not isinstance(views, Sequence) or isinstance(views, (str, bytes, bytearray)): + raise PrimitiveExecutionError("output.emit text_views must be a list") + default_view: Mapping[str, Any] | None = None + for view in views: + if not isinstance(view, Mapping): + raise PrimitiveExecutionError("output.emit text_views entries must be objects") + if bool(view.get("default")): + default_view = view + if _declared_text_view_matches(result, view): + return _render_declared_text_view(result, view) + if default_view is not None: + return _render_declared_text_view(result, default_view) + return None + + +def _declared_text_view_matches(result: dict[str, Any], view: Mapping[str, Any]) -> bool: + match = view.get("match", {}) + if not isinstance(match, Mapping) or not match: + return False + for path, expected in match.items(): + found, actual = _field_by_path(result, str(path)) + if not found or actual != expected: + return False + return True + + +def _render_declared_text_view(result: dict[str, Any], view: Mapping[str, Any]) -> str: + rendered = _render_declared_text_lines(view.get("lines", []), current=result, root=result) + return "\n".join(rendered).rstrip() + "\n" + + +def _render_declared_text_lines(lines: Any, *, current: Any, root: dict[str, Any]) -> list[str]: + if not isinstance(lines, Sequence) or isinstance(lines, (str, bytes, bytearray)): + raise PrimitiveExecutionError("output.emit text view lines must be a list") + rendered: list[str] = [] + for line in lines: + rendered.extend(_render_declared_text_line(line, current=current, root=root)) + return rendered + + +def _render_declared_text_line(line: Any, *, current: Any, root: dict[str, Any]) -> list[str]: + if isinstance(line, str): + return [_render_declared_text_template(line, current=current, root=root)] + if not isinstance(line, Mapping): + raise PrimitiveExecutionError("output.emit text view lines must be strings or objects") + if "when" in line: + found, value = _declared_text_value(line["when"], current=current, root=root) + if not found or not _declared_text_truthy(value): + return [] + return _render_declared_text_lines(line.get("lines", []), current=current, root=root) + if "for_each" in line: + spec = line["for_each"] + if not isinstance(spec, Mapping): + raise PrimitiveExecutionError("output.emit for_each line must be an object") + found, value = _declared_text_value(spec.get("path", ""), current=current, root=root) + if not found or value in (None, ""): + return [] + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + raise PrimitiveExecutionError("output.emit for_each path must resolve to a list") + nested_lines = spec.get("lines") + if nested_lines is None: + nested_lines = [str(spec.get("template", "{}"))] + return [ + nested + for item in value + for nested in _render_declared_text_lines(nested_lines, current=item, root=root) + ] + if "json" in line: + found, value = _declared_text_value(line["json"], current=current, root=root) + if not found: + value = None + return json.dumps(_plain_output_result(value), indent=2).splitlines() + if "template" in line: + return [_render_declared_text_template(str(line["template"]), current=current, root=root)] + if "literal" in line: + return [str(line["literal"])] + raise PrimitiveExecutionError("output.emit text view line object must declare when, for_each, json, template, or literal") + + +def _render_declared_text_template(template: str, *, current: Any, root: dict[str, Any]) -> str: + rendered = template + for token in _declared_text_template_tokens(template): + found, value = _declared_text_placeholder_value(token, current=current, root=root) + rendered = rendered.replace("{" + token + "}", _declared_text_format(value if found else "")) + return rendered + + +def _declared_text_template_tokens(template: str) -> list[str]: + tokens: list[str] = [] + index = 0 + while index < len(template): + start = template.find("{", index) + if start == -1: + break + end = template.find("}", start + 1) + if end == -1: + break + tokens.append(template[start + 1 : end]) + index = end + 1 + return tokens + + +def _declared_text_placeholder_value(token: str, *, current: Any, root: dict[str, Any]) -> tuple[bool, Any]: + parts = token.split("|") + found, value = _declared_text_value(parts[0], current=current, root=root) + for raw_filter in parts[1:]: + name, _, argument = raw_filter.partition(":") + if name == "len": + value = len(value) if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)) else 0 + found = True + elif name == "join": + separator = argument + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + value = separator.join(str(item) for item in value) + found = True + elif name == "empty": + if not _declared_text_truthy(value): + value = argument + found = True + else: + raise PrimitiveExecutionError(f"unsupported output.emit text view filter: {name!r}") + return found, value + + +def _declared_text_value(path: Any, *, current: Any, root: dict[str, Any]) -> tuple[bool, Any]: + path_text = str(path or "") + if path_text in {"", "."}: + return True, current + if path_text.startswith("root."): + return _field_by_path(root, path_text.removeprefix("root.")) + if isinstance(current, Mapping): + found, value = _field_by_path(current, path_text) + if found: + return True, value + return _field_by_path(root, path_text) + + +def _declared_text_truthy(value: Any) -> bool: + return bool(value) + + +def _declared_text_format(value: Any) -> str: + if isinstance(value, bool): + return "True" if value else "False" + if value is None: + return "" + return str(value) + + def _view_payload(*, values: dict[str, Any], arguments: dict[str, Any]) -> dict[str, Any]: source_name = str(arguments.get("source") or "result") if source_name not in values: diff --git a/src/command_generation/schemas/command_package_ir.schema.json b/src/command_generation/schemas/command_package_ir.schema.json index 2cbd7cf..ad43930 100644 --- a/src/command_generation/schemas/command_package_ir.schema.json +++ b/src/command_generation/schemas/command_package_ir.schema.json @@ -829,8 +829,7 @@ "target_root_resolve", "sectioned_payload_select", "json_resource_load", - "json_output_with_source_fallback", - "config_output" + "json_output_with_source_fallback" ] }, "generated_root": { @@ -857,10 +856,6 @@ "type": "string", "minLength": 1 }, - "tiny_config_kind": { - "type": "string", - "minLength": 1 - }, "compact_profile_ref": { "type": "string" }, diff --git a/src/command_generation/targets/python.py b/src/command_generation/targets/python.py index cfbdc7b..8d69e36 100644 --- a/src/command_generation/targets/python.py +++ b/src/command_generation/targets/python.py @@ -821,94 +821,6 @@ def _python_local_runtime_helper_block() -> str: ) -def _python_config_output_helper_block() -> str: - return ( - "def _emit_config_tiny_text(payload: dict[str, Any]) -> str:\n" - " workspace = payload['workspace']\n" - " local_runtime = payload['local_runtime']\n" - " next_detail = payload['next_detail']\n" - " lines = [\n" - " f\"Target: {payload['target']}\",\n" - " f\"Config path: {payload['config_path']}\",\n" - " f\"AW enabled: {workspace['enabled']} ({workspace['enabled_source']})\",\n" - " f\"Enabled modules: {', '.join(workspace['enabled_modules']) or '(none)'}\",\n" - " f\"Improvement latitude: {workspace['improvement_latitude']}\",\n" - " f\"Optimization bias: {workspace['optimization_bias']}\",\n" - " f\"Delegation mode: {local_runtime['delegation_mode']['value']}\",\n" - " f\"Safe to auto-run commands: {local_runtime['safe_to_auto_run_commands']['value']}\",\n" - " f\"Select fields: {next_detail['select']}\",\n" - " f\"Verbose diagnostics: {next_detail['verbose']}\",\n" - " ]\n" - " return '\\n'.join(lines) + '\\n'\n" - "\n\n" - "def _emit_config_compact_text(payload: dict[str, Any]) -> str:\n" - " workspace = payload['workspace']\n" - " local_runtime = payload['local_runtime']\n" - " lines = [\n" - " f\"Target: {payload['target']}\",\n" - " f\"Config path: {payload['config_path']}\",\n" - " f\"Exists: {payload['exists']}\",\n" - " f\"AW enabled: {workspace['enabled']} ({workspace['enabled_source']})\",\n" - " f\"Enabled modules: {', '.join(workspace['enabled_modules']) or '(none)'}\",\n" - " f\"Improvement latitude: {workspace['improvement_latitude']}\",\n" - " f\"Optimization bias: {workspace['optimization_bias']}\",\n" - " f\"Workflow obligations: {len(workspace['workflow_obligations'])} configured\",\n" - " f\"Delegation mode: {local_runtime['delegation_mode']['value']}\",\n" - " f\"Safe to auto-run commands: {local_runtime['safe_to_auto_run_commands']['value']}\",\n" - " f\"Full profile: {payload['full_profile_command']}\",\n" - " ]\n" - " return '\\n'.join(lines) + '\\n'\n" - "\n\n" - "def _emit_config_full_text(payload: dict[str, Any]) -> str:\n" - " workspace = payload['workspace']\n" - " edit_reference = payload['edit_reference']\n" - " mixed_agent = payload['mixed_agent']\n" - " lines = [\n" - " f\"Target: {payload['target']}\",\n" - " f\"Config path: {payload['config_path']}\",\n" - " f\"Exists: {payload['exists']}\",\n" - " f\"AW enabled: {workspace['enabled']} ({workspace['enabled_source']})\",\n" - " f\"Reference: {edit_reference['reference_doc']}\",\n" - " f\"Schema: {edit_reference['source_schema']}\",\n" - " f\"Check command: {edit_reference['check_command']}\",\n" - " ]\n" - " warnings = payload['warnings']\n" - " if warnings:\n" - " lines.append('Warnings:')\n" - " lines.extend(f'- {warning}' for warning in warnings)\n" - " lines.extend([\n" - " f\"Enabled modules: {', '.join(workspace['enabled_modules']) or '(none)'}\",\n" - " f\"Agent instructions file: {workspace['agent_instructions_file']} ({workspace['agent_instructions_file_source']})\",\n" - " f\"Workflow artifact profile: {workspace['workflow_artifact_profile']} ({workspace['workflow_artifact_profile_source']})\",\n" - " f\"Agent configuration substrate: {workspace['agent_configuration_substrate']['canonical_doc']} ({workspace['agent_configuration_substrate']['owner_surface']})\",\n" - " f\"System-intent sources: {', '.join(workspace['system_intent']['sources']) or 'none'} ({workspace['system_intent']['sources_source']})\",\n" - " f\"Workflow obligations: {len(workspace['workflow_obligations'])} configured\",\n" - " f\"Improvement latitude: {workspace['improvement_latitude']} ({workspace['improvement_latitude_source']})\",\n" - " f\"Optimization bias: {workspace['optimization_bias']} ({workspace['optimization_bias_source']})\",\n" - " f\"Advanced features: {', '.join(workspace['advanced_features']) or 'none'} ({workspace['advanced_features_source']})\",\n" - " f\"CLI invoke: {workspace['cli_invoke']} ({workspace['cli_invoke_source']})\",\n" - " f\"Wrapper rule: {payload['update']['wrapper_rule']}\",\n" - " 'Update modules:',\n" - " ])\n" - " for module in payload['update']['modules']:\n" - " lines.append(f\"- {module['module']}: {module['source_type']} {module['source_ref']}\")\n" - " lines.append(f\" label: {module['source_label']}\")\n" - " lines.append(f\" metadata: {module['metadata_path']} ({module['sync_status']})\")\n" - " lines.extend([\n" - " 'Mixed-agent:',\n" - " f\"- rule: {mixed_agent['rule']}\",\n" - " f\"- repo policy: {mixed_agent['repo_policy']['path']} ({mixed_agent['repo_policy']['source']})\",\n" - " f\"- local override: {mixed_agent['local_override']['path']} ({mixed_agent['local_override']['status']})\",\n" - " f\"- local integration area: {mixed_agent['local_integration_area']['root']} ({mixed_agent['local_integration_area']['status']})\",\n" - " f\"- effective posture: internal delegation={mixed_agent['effective_posture']['supports_internal_delegation']['value']}, strong planner={mixed_agent['effective_posture']['strong_planner_available']['value']}, cheap bounded executor={mixed_agent['effective_posture']['cheap_bounded_executor_available']['value']}\",\n" - " f\"- delegation targets: {len(mixed_agent['delegation_targets']['profiles'])} configured\",\n" - " f\"- delegation outcome evidence: {mixed_agent['delegation_targets']['outcome_artifact']['path']} ({mixed_agent['delegation_targets']['outcome_artifact']['status']})\",\n" - " ])\n" - " return '\\n'.join(lines) + '\\n'\n" - "\n\n" - ) - - def _python_local_runtime_generated_function( function: str, override: dict[str, Any], @@ -992,30 +904,6 @@ def _python_local_runtime_generated_function( f" from {source_import_module} import {function} as source_function\n\n" " return source_function(values, arguments, context)\n" ) - if implementation == "config_output": - selected_output_kind = str(override.get("selected_output_kind") or "command-generation/selected-output/v1") - tiny_config_kind = str(override.get("tiny_config_kind") or "agentic-workspace/config-tiny/v1") - return ( - f"def {function}(values: dict[str, Any], _arguments: dict[str, Any], _context: Any) -> Any:\n" - " result = values['result']\n" - " output_format = str(values.get('format') or 'text')\n" - " if not isinstance(result, dict):\n" - " raise ValueError('workspace config output requires an object result payload')\n" - " if output_format == 'json':\n" - " print(json.dumps(_serialise_value(result), indent=2))\n" - " return None\n" - f" if result.get('kind') == {selected_output_kind!r}:\n" - " print(_emit_selected_output_text(result), end='')\n" - " return None\n" - f" if result.get('kind') == {tiny_config_kind!r}:\n" - " print(_emit_config_tiny_text(result), end='')\n" - " return None\n" - " if result.get('profile') == 'compact':\n" - " print(_emit_config_compact_text(result), end='')\n" - " return None\n" - " print(_emit_config_full_text(result), end='')\n" - " return None\n" - ) raise ValueError(f"unsupported generated local runtime implementation: {implementation!r}") @@ -1045,8 +933,6 @@ def _python_local_runtime_binding_module( helper_parts: list[str] = [] if overrides: helper_parts.append(_python_local_runtime_helper_block()) - if any(str(override.get("implementation")) == "config_output" for override in overrides.values()): - helper_parts.append(_python_config_output_helper_block()) helper_block = "\n\n".join(helper_parts) + "\n\n" if helper_parts else "" helper_imports = "" if overrides: diff --git a/src/command_generation/targets/typescript.py b/src/command_generation/targets/typescript.py index 3357d86..e81df82 100644 --- a/src/command_generation/targets/typescript.py +++ b/src/command_generation/targets/typescript.py @@ -589,9 +589,13 @@ class RuntimeError extends Error {{}} }}); }} -function emitOutput(values) {{ +function emitOutput(values, args = {{}}) {{ const result = values.result; if (String(values.format ?? 'text') === 'json') return `${{JSON.stringify(result, null, 2)}}\n`; + if (isObject(result)) {{ + const declaredView = emitDeclaredTextView(result, args.text_views ?? []); + if (declaredView !== null) return declaredView; + }} if (!isObject(result)) return `${{result}}\n`; if (Array.isArray(result.files) && result.files.every((item) => typeof item === 'string')) return `${{result.files.join('\\n')}}\n`; const lines = [String(result.message ?? result.kind ?? '')]; @@ -599,6 +603,113 @@ class RuntimeError extends Error {{}} return `${{lines.join('\\n').trimEnd()}}\n`; }} +function emitDeclaredTextView(result, views) {{ + if (views === null || views === undefined) return null; + if (!Array.isArray(views)) throw new RuntimeError('output.emit text_views must be a list'); + let defaultView = null; + for (const view of views) {{ + if (!isObject(view)) throw new RuntimeError('output.emit text_views entries must be objects'); + if (view.default) defaultView = view; + if (declaredTextViewMatches(result, view)) return renderDeclaredTextView(result, view); + }} + return defaultView ? renderDeclaredTextView(result, defaultView) : null; +}} + +function declaredTextViewMatches(result, view) {{ + const match = view.match ?? {{}}; + if (!isObject(match) || Object.keys(match).length === 0) return false; + for (const [path, expected] of Object.entries(match)) {{ + const [found, actual] = fieldByPath(result, path); + if (!found || actual !== expected) return false; + }} + return true; +}} + +function renderDeclaredTextView(result, view) {{ + return `${{renderDeclaredTextLines(view.lines ?? [], result, result).join('\\n').trimEnd()}}\n`; +}} + +function renderDeclaredTextLines(lines, current, root) {{ + if (!Array.isArray(lines)) throw new RuntimeError('output.emit text view lines must be a list'); + return lines.flatMap((line) => renderDeclaredTextLine(line, current, root)); +}} + +function renderDeclaredTextLine(line, current, root) {{ + if (typeof line === 'string') return [renderDeclaredTextTemplate(line, current, root)]; + if (!isObject(line)) throw new RuntimeError('output.emit text view lines must be strings or objects'); + if (Object.prototype.hasOwnProperty.call(line, 'when')) {{ + const [found, value] = declaredTextValue(line.when, current, root); + return found && declaredTextTruthy(value) ? renderDeclaredTextLines(line.lines ?? [], current, root) : []; + }} + if (Object.prototype.hasOwnProperty.call(line, 'for_each')) {{ + const spec = line.for_each; + if (!isObject(spec)) throw new RuntimeError('output.emit for_each line must be an object'); + const [found, value] = declaredTextValue(spec.path ?? '', current, root); + if (!found || value === null || value === undefined || value === '') return []; + if (!Array.isArray(value)) throw new RuntimeError('output.emit for_each path must resolve to a list'); + const nestedLines = spec.lines ?? [String(spec.template ?? '{{}}')]; + return value.flatMap((item) => renderDeclaredTextLines(nestedLines, item, root)); + }} + if (Object.prototype.hasOwnProperty.call(line, 'json')) {{ + const [found, value] = declaredTextValue(line.json, current, root); + return JSON.stringify(found ? value : null, null, 2).split('\\n'); + }} + if (Object.prototype.hasOwnProperty.call(line, 'template')) return [renderDeclaredTextTemplate(String(line.template), current, root)]; + if (Object.prototype.hasOwnProperty.call(line, 'literal')) return [String(line.literal)]; + throw new RuntimeError('output.emit text view line object must declare when, for_each, json, template, or literal'); +}} + +function renderDeclaredTextTemplate(template, current, root) {{ + return String(template).replace(/\\{{([^}}]+)\\}}/g, (_match, token) => {{ + const [found, value] = declaredTextPlaceholderValue(String(token), current, root); + return declaredTextFormat(found ? value : ''); + }}); +}} + +function declaredTextPlaceholderValue(token, current, root) {{ + const parts = String(token).split('|'); + let [found, value] = declaredTextValue(parts[0], current, root); + for (const rawFilter of parts.slice(1)) {{ + const separatorIndex = rawFilter.indexOf(':'); + const name = separatorIndex === -1 ? rawFilter : rawFilter.slice(0, separatorIndex); + const argument = separatorIndex === -1 ? '' : rawFilter.slice(separatorIndex + 1); + if (name === 'len') {{ + value = Array.isArray(value) ? value.length : 0; + found = true; + }} else if (name === 'join') {{ + if (Array.isArray(value)) value = value.map(String).join(argument); + found = true; + }} else if (name === 'empty') {{ + if (!declaredTextTruthy(value)) value = argument; + found = true; + }} else {{ + throw new RuntimeError(`unsupported output.emit text view filter: ${{name}}`); + }} + }} + return [found, value]; +}} + +function declaredTextValue(path, current, root) {{ + const pathText = String(path ?? ''); + if (pathText === '' || pathText === '.') return [true, current]; + if (pathText.startsWith('root.')) return fieldByPath(root, pathText.slice('root.'.length)); + if (isObject(current)) {{ + const [found, value] = fieldByPath(current, pathText); + if (found) return [true, value]; + }} + return fieldByPath(root, pathText); +}} + +function declaredTextTruthy(value) {{ + return Boolean(value); +}} + +function declaredTextFormat(value) {{ + if (typeof value === 'boolean') return value ? 'True' : 'False'; + if (value === null || value === undefined) return ''; + return String(value); +}} + function limitedViewValue(value, limit) {{ if (!Number.isInteger(limit) || typeof value === 'string') return value; if (Array.isArray(value)) return value.slice(0, Math.max(limit, 0)); @@ -706,7 +817,7 @@ class RuntimeError extends Error {{}} if (primitive === 'payload.assemble') return assemblePayload(values, args); if (primitive === 'payload.view') return viewPayload(values, args); if (primitive === 'payload.project') return projectPayload(values, args); - if (primitive === 'output.emit') return emitOutput(values); + if (primitive === 'output.emit') return emitOutput(values, args); if (primitive === 'transaction.plan') return transactionPlan(values, args); return executeHostPrimitive(primitive, values, args, operationId); }} diff --git a/tests/test_primitive_executor.py b/tests/test_primitive_executor.py index e9eff20..9c9937a 100644 --- a/tests/test_primitive_executor.py +++ b/tests/test_primitive_executor.py @@ -578,6 +578,60 @@ def test_output_emit_supports_json_and_text(primitive_context: PrimitiveContext) assert emitted_text == "Skills\n- review/SKILL.md\n" +def test_output_emit_supports_declared_text_views(primitive_context: PrimitiveContext) -> None: + payload = { + "kind": "fixture/report/v1", + "profile": "compact", + "enabled": True, + "items": ["alpha", "beta"], + "records": [{"name": "one", "status": "ready"}], + "values": {"status": "ok"}, + "warnings": ["check config"], + } + + emitted_text = execute_primitive( + "output.emit", + values={"result": payload, "format": "text"}, + arguments={ + "text_views": [ + { + "id": "fixture.compact", + "match": {"kind": "fixture/report/v1", "profile": "compact"}, + "lines": [ + "Enabled: {enabled}", + "Items: {items|join:, |empty:(none)}", + "Record count: {records|len}", + {"literal": "Values:"}, + {"json": "values"}, + {"when": "warnings", "lines": ["Warnings:", {"for_each": {"path": "warnings", "template": "- {}"}}]}, + { + "for_each": { + "path": "records", + "lines": ["Record: {name} ({status})"], + } + }, + ], + }, + {"id": "fixture.default", "default": True, "lines": ["Default"]}, + ] + }, + context=primitive_context, + ) + + assert emitted_text == ( + "Enabled: True\n" + "Items: alpha, beta\n" + "Record count: 1\n" + "Values:\n" + "{\n" + ' "status": "ok"\n' + "}\n" + "Warnings:\n" + "- check config\n" + "Record: one (ready)\n" + ) + + def test_output_emit_serializes_module_result_objects(primitive_context: PrimitiveContext) -> None: @dataclass class Action: From 7bd918989b9698b8f3f7a477be65dd820fb0405e Mon Sep 17 00:00:00 2001 From: Rickard von Haugwitz Date: Mon, 13 Jul 2026 12:52:22 +0200 Subject: [PATCH 3/8] Route declared text views through fallback facade --- src/command_generation/targets/python.py | 4 +++ tests/test_public_api.py | 35 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/command_generation/targets/python.py b/src/command_generation/targets/python.py index 8d69e36..2c3ddb8 100644 --- a/src/command_generation/targets/python.py +++ b/src/command_generation/targets/python.py @@ -885,6 +885,10 @@ def _python_local_runtime_generated_function( " if str(values.get('format') or 'text') == 'json' and isinstance(result, dict):\n" " print(json.dumps(_serialise_value(values['result']), indent=2))\n" " return None\n" + " if isinstance(result, dict) and arguments.get('text_views'):\n" + " from .primitive_executor import _emit_output\n\n" + " print(_emit_output(values=values, arguments=arguments), end='')\n" + " return None\n" " if isinstance(result, dict) and (isinstance(result.get('route_report_summary'), dict) or result.get('kind') == 'memory-module-report/v1' or (result.get('kind') == 'planning-module-report/v1' and result.get('profile') == 'tiny')):\n" " from .primitive_executor import _emit_output\n\n" " print(_emit_output(values=values, arguments=arguments), end='')\n" diff --git a/tests/test_public_api.py b/tests/test_public_api.py index da1a05b..542f594 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1736,6 +1736,41 @@ def second_value() -> str: sys.modules.pop(source_module.__name__, None) +def test_generated_json_output_fallback_delegates_declared_text_views() -> None: + rendered = _python_local_runtime_binding_module( + { + "program": "demo-cli", + "python_runtime_binding": { + "operation_executor": { + "handlers": [ + { + "primitive": "output.emit", + "handler": "runtime_handler", + "import_module": "fake_source_runtime_for_text_views", + "function": "emit_output", + } + ] + } + }, + }, + { + "source_import_module": "fake_source_runtime_for_text_views", + "module_file": "primitives.demo_runtime", + "generated_function_overrides": [ + { + "function": "emit_output", + "implementation": "json_output_with_source_fallback", + } + ], + }, + source_path="demo_ir.json", + regenerate_command="generate-demo", + ) + + assert "arguments.get('text_views')" in rendered + assert "print(_emit_output(values=values, arguments=arguments), end='')" in rendered + + def test_generated_module_front_door_handler_delegates_with_data_driven_argv_and_help() -> None: runtime_module = types.ModuleType("fake_module_front_door_runtime") calls: list[list[str]] = [] From 4a98ea0f8f16d9c125880cf5d0998a4de626c2a6 Mon Sep 17 00:00:00 2001 From: Rickard von Haugwitz Date: Mon, 13 Jul 2026 13:40:40 +0200 Subject: [PATCH 4/8] Fix text view rendering parity --- src/command_generation/targets/typescript.py | 6 +- tests/test_public_api.py | 181 +++++++++++++++++++ 2 files changed, 186 insertions(+), 1 deletion(-) diff --git a/src/command_generation/targets/typescript.py b/src/command_generation/targets/typescript.py index e81df82..97d2202 100644 --- a/src/command_generation/targets/typescript.py +++ b/src/command_generation/targets/typescript.py @@ -660,7 +660,7 @@ class RuntimeError extends Error {{}} }} function renderDeclaredTextTemplate(template, current, root) {{ - return String(template).replace(/\\{{([^}}]+)\\}}/g, (_match, token) => {{ + return String(template).replace(/\\{{([^}}]*)\\}}/g, (_match, token) => {{ const [found, value] = declaredTextPlaceholderValue(String(token), current, root); return declaredTextFormat(found ? value : ''); }}); @@ -701,6 +701,10 @@ class RuntimeError extends Error {{}} }} function declaredTextTruthy(value) {{ + if (value === null || value === undefined) return false; + if (Array.isArray(value)) return value.length > 0; + if (isObject(value)) return Object.keys(value).length > 0; + if (typeof value === 'string') return value.length > 0; return Boolean(value); }} diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 542f594..f1f9ca8 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1771,6 +1771,187 @@ def test_generated_json_output_fallback_delegates_declared_text_views() -> None: assert "print(_emit_output(values=values, arguments=arguments), end='')" in rendered +def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_path: Path) -> None: + if shutil.which("node") is None: + pytest.skip("node is required for TypeScript generated-runtime conformance") + manifest = _fixture_manifest_with_typescript(tmp_path) + package = cast(dict[str, object], cast(list[object], manifest["packages"])[0]) + operation_executor = cast(dict[str, object], cast(dict[str, object], package["python_runtime_binding"])["operation_executor"]) + cast(list[object], operation_executor["initial_values"]).extend( + [ + {"name": "profile", "arg": "profile", "default": "compact"}, + {"name": "items", "arg": "items", "default": []}, + {"name": "records", "arg": "records", "default": []}, + {"name": "warnings", "arg": "warnings", "default": []}, + {"name": "metadata", "arg": "metadata", "default": {}}, + {"name": "empty_object", "arg": "empty_object", "default": {}}, + {"name": "missing", "arg": "missing", "default": []}, + ] + ) + operation_path = tmp_path / "contracts" / "operations" / "todo.list.report.json" + operation = json.loads(operation_path.read_text(encoding="utf-8")) + steps = [ + { + "id": "assemble", + "uses": "payload.assemble", + "arguments": { + "fields": { + "template": { + "kind": "todo-list/v1", + "profile": {"$value": "profile"}, + "items": {"$value": "items"}, + "records": {"$value": "records"}, + "warnings": {"$value": "warnings"}, + "metadata": {"$value": "metadata"}, + "empty_object": {"$value": "empty_object"}, + "missing": {"$value": "missing"}, + } + } + }, + "outputs": ["result"], + }, + { + "id": "emit", + "uses": "output.emit", + "arguments": { + "text_views": [ + { + "id": "fixture.compact", + "match": {"kind": "todo-list/v1", "profile": "compact"}, + "lines": [ + "Profile: {profile}", + "Items: {items|join:, |empty:(none)}", + "Missing: {missing|join:, |empty:(none)}", + "Warnings count: {warnings|len}", + {"when": "warnings", "lines": ["Warnings:", {"for_each": {"path": "warnings", "template": "- {}"}}]}, + {"when": "metadata", "lines": ["Metadata:", {"json": "metadata"}]}, + {"when": "empty_object", "lines": ["Empty object should not render"]}, + { + "for_each": { + "path": "records", + "lines": ["Record: {name} ({status})", "Root profile: {root.profile}"], + } + }, + ], + }, + {"id": "fixture.default", "default": True, "lines": ["Default profile: {profile}", "Items: {items|join:, |empty:(none)}"]}, + ] + }, + "outputs": ["result"], + }, + ] + cast(dict[str, object], operation["ir_plan"])["steps"] = steps + operation_path.write_text(json.dumps(operation, indent=2), encoding="utf-8") + + assert ( + generate_command_packages( + manifest, + repo_root=tmp_path, + source_path="command_package_ir.json", + regenerate_command="python generate.py", + check=False, + ) + == [] + ) + sys.path.insert(0, str(tmp_path)) + try: + py_cli = importlib.import_module("todo_cli_pkg.cli") + py_executor = importlib.import_module("todo_cli_pkg.primitives.operation_executor") + py_contract = py_cli.generated_operation_contract("todo.list.report") + values = { + "format": "text", + "output_format": "text", + "profile": "compact", + "items": ["alpha", "beta"], + "records": [{"name": "one", "status": "ready"}], + "warnings": ["check config"], + "metadata": {"source": "fixture"}, + "empty_object": {}, + "missing": [], + } + py_text = py_executor.run_operation_callable(py_contract, values) + finally: + sys.path.remove(str(tmp_path)) + for name in list(sys.modules): + if name == "todo_cli_pkg" or name.startswith("todo_cli_pkg."): + sys.modules.pop(name, None) + + runner = tmp_path / "invoke-text-view.mjs" + runner.write_text( + "import { invokeGeneratedOperation } from './todo_ts_pkg/src/runtime.mjs';\n" + f"const values = {json.dumps(values, sort_keys=True)};\n" + "const result = invokeGeneratedOperation({ operationId: 'todo.list.report', operationPath: 'operations/todo.list.report.json', values });\n" + "process.stdout.write(JSON.stringify(result));\n", + encoding="utf-8", + ) + ts_result = subprocess.run(["node", str(runner)], cwd=tmp_path, text=True, capture_output=True, check=False) + assert ts_result.returncode == 0, ts_result.stderr + ts_text = json.loads(ts_result.stdout) + + assert py_text == ts_text + assert py_text == ( + "Profile: compact\n" + "Items: alpha, beta\n" + "Missing: (none)\n" + "Warnings count: 1\n" + "Warnings:\n" + "- check config\n" + "Metadata:\n" + "{\n" + ' "source": "fixture"\n' + "}\n" + "Record: one (ready)\n" + "Root profile: compact\n" + ) + + default_values = {**values, "profile": "expanded", "items": []} + sys.path.insert(0, str(tmp_path)) + try: + py_cli = importlib.import_module("todo_cli_pkg.cli") + py_executor = importlib.import_module("todo_cli_pkg.primitives.operation_executor") + py_default = py_executor.run_operation_callable(py_cli.generated_operation_contract("todo.list.report"), default_values) + finally: + sys.path.remove(str(tmp_path)) + for name in list(sys.modules): + if name == "todo_cli_pkg" or name.startswith("todo_cli_pkg."): + sys.modules.pop(name, None) + runner.write_text( + "import { invokeGeneratedOperation } from './todo_ts_pkg/src/runtime.mjs';\n" + f"const values = {json.dumps(default_values, sort_keys=True)};\n" + "const result = invokeGeneratedOperation({ operationId: 'todo.list.report', operationPath: 'operations/todo.list.report.json', values });\n" + "process.stdout.write(JSON.stringify(result));\n", + encoding="utf-8", + ) + ts_default = subprocess.run(["node", str(runner)], cwd=tmp_path, text=True, capture_output=True, check=False) + assert ts_default.returncode == 0, ts_default.stderr + assert py_default == json.loads(ts_default.stdout) == "Default profile: expanded\nItems: (none)\n" + + for generated_operation in ( + tmp_path / "todo_cli_pkg" / "operations" / "todo.list.report.json", + tmp_path / "todo_ts_pkg" / "resources" / "operations" / "todo.list.report.json", + ): + malformed = json.loads(generated_operation.read_text(encoding="utf-8")) + cast(dict[str, object], cast(list[object], cast(dict[str, object], malformed["ir_plan"])["steps"])[1])["arguments"] = { + "text_views": "not-a-list" + } + generated_operation.write_text(json.dumps(malformed, indent=2), encoding="utf-8") + + sys.path.insert(0, str(tmp_path)) + try: + py_cli = importlib.import_module("todo_cli_pkg.cli") + py_executor = importlib.import_module("todo_cli_pkg.primitives.operation_executor") + with pytest.raises(Exception, match="output.emit text_views must be a list"): + py_executor.run_operation_callable(py_cli.generated_operation_contract("todo.list.report"), values) + finally: + sys.path.remove(str(tmp_path)) + for name in list(sys.modules): + if name == "todo_cli_pkg" or name.startswith("todo_cli_pkg."): + sys.modules.pop(name, None) + malformed_ts = subprocess.run(["node", str(runner)], cwd=tmp_path, text=True, capture_output=True, check=False) + assert malformed_ts.returncode != 0 + assert "output.emit text_views must be a list" in malformed_ts.stderr + + def test_generated_module_front_door_handler_delegates_with_data_driven_argv_and_help() -> None: runtime_module = types.ModuleType("fake_module_front_door_runtime") calls: list[list[str]] = [] From a40e67636255cf54e22e167a09bb2c54e1db8399 Mon Sep 17 00:00:00 2001 From: Rickard von Haugwitz Date: Mon, 13 Jul 2026 14:07:47 +0200 Subject: [PATCH 5/8] Tighten text view portability contract --- README.md | 2 + docs/output-emit-text-views.md | 65 ++++++++++++ src/command_generation/primitive_executor.py | 31 +++++- src/command_generation/primitive_registry.py | 6 +- src/command_generation/targets/typescript.py | 21 +++- tests/test_primitive_executor.py | 2 +- tests/test_public_api.py | 100 +++++++++++++------ 7 files changed, 190 insertions(+), 37 deletions(-) create mode 100644 docs/output-emit-text-views.md diff --git a/README.md b/README.md index 3c90f12..05c56d3 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ The exported `BUILTIN_PORTABLE_PRIMITIVES` registry contains package-owned porta `payload.project` is the generic projection primitive for exact dot-path selected-output wrappers. See [Projection primitives](docs/projection-primitives.md) for its ownership boundary. +`output.emit` is the generic JSON/text emission primitive. Declared `text_views` provide portable compact text rendering with scalar matching, scalar placeholders, list-of-scalar `join`, JSON-domain truthiness, iteration, and explicit JSON blocks for structured values. See [`output.emit` text views](docs/output-emit-text-views.md) for the rendering language and failure behavior. + ## Public API - `load_command_package_ir(path, schema_path=None)` validates IR against the package-owned schema. diff --git a/docs/output-emit-text-views.md b/docs/output-emit-text-views.md new file mode 100644 index 0000000..39dcd43 --- /dev/null +++ b/docs/output-emit-text-views.md @@ -0,0 +1,65 @@ +# `output.emit` text views + +`output.emit` accepts optional `text_views` arguments for host-neutral text rendering from JSON-shaped result payloads. Command Generation owns the rendering language and cross-target behavior. Host packages own view ids, labels, ordering, matching policy, and payload semantics. + +## View Selection + +`text_views` must be a list of objects. Each object may declare: + +- `id`: optional view identifier for host documentation and review. +- `match`: object mapping dot paths to expected JSON scalar values. +- `default`: truthy value marking the fallback view. +- `lines`: list of text line declarations. + +The first matching view renders. If no view matches, the last declared default view renders. If neither exists, `output.emit` falls back to its built-in compact text behavior. + +`match` values are intentionally scalar-only: string, number, boolean, or null. Arrays and objects in `match` are rejected in every target. Actual result values are compared by scalar equality at exact dot paths. + +## Truthiness + +Conditional lines use JSON-domain truthiness: + +- `null`, missing values, empty strings, empty arrays, and empty objects are false. +- Non-empty strings, arrays, and objects are true. +- Booleans use their boolean value. +- Numbers use normal boolean truthiness, so `0` is false and other numbers are true. + +## Line Forms + +String lines are templates. Object lines use exactly one of these forms: + +- `{"literal": "text"}` emits the literal text. +- `{"template": "text {path}"}` emits a template line. +- `{"when": "path", "lines": [...]}` emits nested lines only when the selected value is truthy. +- `{"for_each": {"path": "items", "lines": [...]}}` iterates over a list and renders nested lines with each item as the current value. +- `{"for_each": {"path": "items", "template": "- {}"}}` is shorthand for one template line per item. +- `{"json": "path"}` emits the selected value as an indented JSON block. + +Missing `for_each` values, `null`, and empty strings render no lines. Present non-list `for_each` values are rejected. + +## Placeholders And Filters + +Templates replace `{path}` placeholders with scalar values. Empty `{}` and `{.}` refer to the current item. `root.` paths resolve against the root result payload. + +Direct placeholders accept JSON scalars only. Arrays and objects must use the explicit `{"json": "path"}` line form. Missing values and null render as an empty string. + +Supported filters: + +- `len`: returns the length of a list; non-lists return `0`. +- `join:SEP`: joins a list of JSON scalar values with `SEP`. +- `empty:TEXT`: replaces falsey values with `TEXT`. + +`join` treats missing and null values as empty so a later `empty` filter can provide fallback text. It rejects present non-list values and lists containing arrays or objects. It uses the same scalar formatter as direct placeholders. + +Scalar formatting is portable: + +- strings render as their raw string value; +- booleans render as `true` or `false`; +- null renders as an empty string; +- integer-valued numbers render without a trailing decimal. + +JSON blocks render with two-space indentation, preserved object key order, and unescaped Unicode characters in Python and TypeScript generated runtimes. + +## Failure Behavior + +Malformed `text_views` fail at runtime with an `output.emit` error instead of silently falling back. Rejected cases include non-list `text_views`, non-object view entries, non-list `lines`, unsupported filters, structured `match` values, structured direct placeholders, invalid `for_each` values, and invalid `join` values. diff --git a/src/command_generation/primitive_executor.py b/src/command_generation/primitive_executor.py index a70d7c6..7afec1a 100644 --- a/src/command_generation/primitive_executor.py +++ b/src/command_generation/primitive_executor.py @@ -5,6 +5,7 @@ import tomllib from collections.abc import Callable, Mapping, Sequence from dataclasses import asdict, dataclass, field, is_dataclass +from numbers import Real from pathlib import Path, PurePosixPath from typing import Any, cast @@ -630,6 +631,8 @@ def _declared_text_view_matches(result: dict[str, Any], view: Mapping[str, Any]) if not isinstance(match, Mapping) or not match: return False for path, expected in match.items(): + if not _is_declared_text_scalar(expected): + raise PrimitiveExecutionError("output.emit text view match values must be JSON scalars") found, actual = _field_by_path(result, str(path)) if not found or actual != expected: return False @@ -681,7 +684,7 @@ def _render_declared_text_line(line: Any, *, current: Any, root: dict[str, Any]) found, value = _declared_text_value(line["json"], current=current, root=root) if not found: value = None - return json.dumps(_plain_output_result(value), indent=2).splitlines() + return json.dumps(_plain_output_result(value), indent=2, ensure_ascii=False).splitlines() if "template" in line: return [_render_declared_text_template(str(line["template"]), current=current, root=root)] if "literal" in line: @@ -722,8 +725,14 @@ def _declared_text_placeholder_value(token: str, *, current: Any, root: dict[str found = True elif name == "join": separator = argument - if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - value = separator.join(str(item) for item in value) + if not found or value is None: + value = "" + elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + if not all(_is_declared_text_scalar(item) for item in value): + raise PrimitiveExecutionError("output.emit join filter requires a list of JSON scalars") + value = separator.join(_declared_text_format_scalar(item) for item in value) + else: + raise PrimitiveExecutionError("output.emit join filter requires a list") found = True elif name == "empty": if not _declared_text_truthy(value): @@ -752,10 +761,24 @@ def _declared_text_truthy(value: Any) -> bool: def _declared_text_format(value: Any) -> str: + if not _is_declared_text_scalar(value): + raise PrimitiveExecutionError("output.emit text view placeholders require JSON scalars; use json lines for arrays or objects") + return _declared_text_format_scalar(value) + + +def _is_declared_text_scalar(value: Any) -> bool: + return value is None or isinstance(value, str | bool | int | float) + + +def _declared_text_format_scalar(value: Any) -> str: if isinstance(value, bool): - return "True" if value else "False" + return "true" if value else "false" if value is None: return "" + if isinstance(value, Real) and not isinstance(value, bool): + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return json.dumps(value, ensure_ascii=False, allow_nan=False) return str(value) diff --git a/src/command_generation/primitive_registry.py b/src/command_generation/primitive_registry.py index 1abaaa9..5a63c95 100644 --- a/src/command_generation/primitive_registry.py +++ b/src/command_generation/primitive_registry.py @@ -175,7 +175,11 @@ def to_jsonable(self) -> list[dict[str, Any]]: { "id": "output.emit", "kind": "portable", - "description": "Emit JSON or compact text from a result payload.", + "description": ( + "Emit JSON or compact text from a result payload, including declared text_views " + "with scalar matching, scalar placeholders, list-of-scalar join, JSON-domain " + "truthiness, and explicit JSON blocks for arrays or objects." + ), "target_support": {"python": "implemented", "typescript": "implemented"}, }, { diff --git a/src/command_generation/targets/typescript.py b/src/command_generation/targets/typescript.py index 97d2202..1fae674 100644 --- a/src/command_generation/targets/typescript.py +++ b/src/command_generation/targets/typescript.py @@ -619,6 +619,7 @@ class RuntimeError extends Error {{}} const match = view.match ?? {{}}; if (!isObject(match) || Object.keys(match).length === 0) return false; for (const [path, expected] of Object.entries(match)) {{ + if (!declaredTextIsScalar(expected)) throw new RuntimeError('output.emit text view match values must be JSON scalars'); const [found, actual] = fieldByPath(result, path); if (!found || actual !== expected) return false; }} @@ -677,7 +678,14 @@ class RuntimeError extends Error {{}} value = Array.isArray(value) ? value.length : 0; found = true; }} else if (name === 'join') {{ - if (Array.isArray(value)) value = value.map(String).join(argument); + if (!found || value === null || value === undefined) {{ + value = ''; + }} else if (Array.isArray(value)) {{ + if (!value.every(declaredTextIsScalar)) throw new RuntimeError('output.emit join filter requires a list of JSON scalars'); + value = value.map(declaredTextFormatScalar).join(argument); + }} else {{ + throw new RuntimeError('output.emit join filter requires a list'); + }} found = true; }} else if (name === 'empty') {{ if (!declaredTextTruthy(value)) value = argument; @@ -709,7 +717,16 @@ class RuntimeError extends Error {{}} }} function declaredTextFormat(value) {{ - if (typeof value === 'boolean') return value ? 'True' : 'False'; + if (!declaredTextIsScalar(value)) throw new RuntimeError('output.emit text view placeholders require JSON scalars; use json lines for arrays or objects'); + return declaredTextFormatScalar(value); +}} + +function declaredTextIsScalar(value) {{ + return value === null || value === undefined || ['string', 'number', 'boolean'].includes(typeof value); +}} + +function declaredTextFormatScalar(value) {{ + if (typeof value === 'boolean') return value ? 'true' : 'false'; if (value === null || value === undefined) return ''; return String(value); }} diff --git a/tests/test_primitive_executor.py b/tests/test_primitive_executor.py index 9c9937a..4a2f9cb 100644 --- a/tests/test_primitive_executor.py +++ b/tests/test_primitive_executor.py @@ -619,7 +619,7 @@ def test_output_emit_supports_declared_text_views(primitive_context: PrimitiveCo ) assert emitted_text == ( - "Enabled: True\n" + "Enabled: true\n" "Items: alpha, beta\n" "Record count: 1\n" "Values:\n" diff --git a/tests/test_public_api.py b/tests/test_public_api.py index f1f9ca8..165a743 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1786,6 +1786,9 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p {"name": "metadata", "arg": "metadata", "default": {}}, {"name": "empty_object", "arg": "empty_object", "default": {}}, {"name": "missing", "arg": "missing", "default": []}, + {"name": "active", "arg": "active", "default": False}, + {"name": "score", "arg": "score", "default": 0}, + {"name": "flags", "arg": "flags", "default": []}, ] ) operation_path = tmp_path / "contracts" / "operations" / "todo.list.report.json" @@ -1805,6 +1808,9 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p "metadata": {"$value": "metadata"}, "empty_object": {"$value": "empty_object"}, "missing": {"$value": "missing"}, + "active": {"$value": "active"}, + "score": {"$value": "score"}, + "flags": {"$value": "flags"}, } } }, @@ -1820,6 +1826,9 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p "match": {"kind": "todo-list/v1", "profile": "compact"}, "lines": [ "Profile: {profile}", + "Active: {active}", + "Score: {score}", + "Flags: {flags|join:/}", "Items: {items|join:, |empty:(none)}", "Missing: {missing|join:, |empty:(none)}", "Warnings count: {warnings|len}", @@ -1865,9 +1874,12 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p "items": ["alpha", "beta"], "records": [{"name": "one", "status": "ready"}], "warnings": ["check config"], - "metadata": {"source": "fixture"}, + "metadata": {"source": "fixture", "city": "Malm\u00f6"}, "empty_object": {}, "missing": [], + "active": True, + "score": 1.0, + "flags": [True, False, "ok", 2.0], } py_text = py_executor.run_operation_callable(py_contract, values) finally: @@ -1879,18 +1891,21 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p runner = tmp_path / "invoke-text-view.mjs" runner.write_text( "import { invokeGeneratedOperation } from './todo_ts_pkg/src/runtime.mjs';\n" - f"const values = {json.dumps(values, sort_keys=True)};\n" + f"const values = {json.dumps(values)};\n" "const result = invokeGeneratedOperation({ operationId: 'todo.list.report', operationPath: 'operations/todo.list.report.json', values });\n" "process.stdout.write(JSON.stringify(result));\n", encoding="utf-8", ) - ts_result = subprocess.run(["node", str(runner)], cwd=tmp_path, text=True, capture_output=True, check=False) + ts_result = subprocess.run(["node", str(runner)], cwd=tmp_path, text=True, encoding="utf-8", capture_output=True, check=False) assert ts_result.returncode == 0, ts_result.stderr ts_text = json.loads(ts_result.stdout) assert py_text == ts_text assert py_text == ( "Profile: compact\n" + "Active: true\n" + "Score: 1\n" + "Flags: true/false/ok/2\n" "Items: alpha, beta\n" "Missing: (none)\n" "Warnings count: 1\n" @@ -1898,7 +1913,8 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p "- check config\n" "Metadata:\n" "{\n" - ' "source": "fixture"\n' + ' "source": "fixture",\n' + ' "city": "Malm\u00f6"\n' "}\n" "Record: one (ready)\n" "Root profile: compact\n" @@ -1917,39 +1933,65 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p sys.modules.pop(name, None) runner.write_text( "import { invokeGeneratedOperation } from './todo_ts_pkg/src/runtime.mjs';\n" - f"const values = {json.dumps(default_values, sort_keys=True)};\n" + f"const values = {json.dumps(default_values)};\n" "const result = invokeGeneratedOperation({ operationId: 'todo.list.report', operationPath: 'operations/todo.list.report.json', values });\n" "process.stdout.write(JSON.stringify(result));\n", encoding="utf-8", ) - ts_default = subprocess.run(["node", str(runner)], cwd=tmp_path, text=True, capture_output=True, check=False) + ts_default = subprocess.run(["node", str(runner)], cwd=tmp_path, text=True, encoding="utf-8", capture_output=True, check=False) assert ts_default.returncode == 0, ts_default.stderr assert py_default == json.loads(ts_default.stdout) == "Default profile: expanded\nItems: (none)\n" - for generated_operation in ( - tmp_path / "todo_cli_pkg" / "operations" / "todo.list.report.json", - tmp_path / "todo_ts_pkg" / "resources" / "operations" / "todo.list.report.json", - ): - malformed = json.loads(generated_operation.read_text(encoding="utf-8")) - cast(dict[str, object], cast(list[object], cast(dict[str, object], malformed["ir_plan"])["steps"])[1])["arguments"] = { - "text_views": "not-a-list" - } - generated_operation.write_text(json.dumps(malformed, indent=2), encoding="utf-8") + def write_generated_emit_arguments(arguments: dict[str, object]) -> None: + for generated_operation in ( + tmp_path / "todo_cli_pkg" / "operations" / "todo.list.report.json", + tmp_path / "todo_ts_pkg" / "resources" / "operations" / "todo.list.report.json", + ): + malformed = json.loads(generated_operation.read_text(encoding="utf-8")) + cast(dict[str, object], cast(list[object], cast(dict[str, object], malformed["ir_plan"])["steps"])[1])["arguments"] = arguments + generated_operation.write_text(json.dumps(malformed, indent=2), encoding="utf-8") + + def assert_generated_text_view_error(arguments: dict[str, object], expected_message: str) -> None: + write_generated_emit_arguments(arguments) + sys.path.insert(0, str(tmp_path)) + try: + py_cli = importlib.import_module("todo_cli_pkg.cli") + py_executor = importlib.import_module("todo_cli_pkg.primitives.operation_executor") + with pytest.raises(Exception, match=expected_message): + py_executor.run_operation_callable(py_cli.generated_operation_contract("todo.list.report"), values) + finally: + sys.path.remove(str(tmp_path)) + for name in list(sys.modules): + if name == "todo_cli_pkg" or name.startswith("todo_cli_pkg."): + sys.modules.pop(name, None) + runner.write_text( + "import { invokeGeneratedOperation } from './todo_ts_pkg/src/runtime.mjs';\n" + f"const values = {json.dumps(values)};\n" + "const result = invokeGeneratedOperation({ operationId: 'todo.list.report', operationPath: 'operations/todo.list.report.json', values });\n" + "process.stdout.write(JSON.stringify(result));\n", + encoding="utf-8", + ) + malformed_ts = subprocess.run(["node", str(runner)], cwd=tmp_path, text=True, encoding="utf-8", capture_output=True, check=False) + assert malformed_ts.returncode != 0 + assert expected_message in malformed_ts.stderr - sys.path.insert(0, str(tmp_path)) - try: - py_cli = importlib.import_module("todo_cli_pkg.cli") - py_executor = importlib.import_module("todo_cli_pkg.primitives.operation_executor") - with pytest.raises(Exception, match="output.emit text_views must be a list"): - py_executor.run_operation_callable(py_cli.generated_operation_contract("todo.list.report"), values) - finally: - sys.path.remove(str(tmp_path)) - for name in list(sys.modules): - if name == "todo_cli_pkg" or name.startswith("todo_cli_pkg."): - sys.modules.pop(name, None) - malformed_ts = subprocess.run(["node", str(runner)], cwd=tmp_path, text=True, capture_output=True, check=False) - assert malformed_ts.returncode != 0 - assert "output.emit text_views must be a list" in malformed_ts.stderr + assert_generated_text_view_error({"text_views": "not-a-list"}, "output.emit text_views must be a list") + assert_generated_text_view_error( + {"text_views": [{"id": "bad.match", "match": {"items": ["alpha"]}, "lines": ["Bad"]}]}, + "output.emit text view match values must be JSON scalars", + ) + assert_generated_text_view_error( + { + "text_views": [ + {"id": "bad.placeholder", "match": {"kind": "todo-list/v1"}, "lines": ["Metadata: {metadata}"]} + ] + }, + "output.emit text view placeholders require JSON scalars", + ) + assert_generated_text_view_error( + {"text_views": [{"id": "bad.join", "match": {"kind": "todo-list/v1"}, "lines": ["Records: {records|join:, }"]}]}, + "output.emit join filter requires a list of JSON scalars", + ) def test_generated_module_front_door_handler_delegates_with_data_driven_argv_and_help() -> None: From de7b685c4f4dfbddc52458b25cf01110190d3fef Mon Sep 17 00:00:00 2001 From: Rickard von Haugwitz Date: Mon, 13 Jul 2026 14:42:16 +0200 Subject: [PATCH 6/8] Tighten text view portability validation --- docs/output-emit-text-views.md | 10 +- src/command_generation/primitive_executor.py | 138 +++++++++++++++++-- src/command_generation/primitive_registry.py | 4 +- src/command_generation/targets/typescript.py | 87 +++++++++++- tests/test_primitive_executor.py | 8 ++ tests/test_public_api.py | 90 +++++++++++- 6 files changed, 316 insertions(+), 21 deletions(-) diff --git a/docs/output-emit-text-views.md b/docs/output-emit-text-views.md index 39dcd43..6206d75 100644 --- a/docs/output-emit-text-views.md +++ b/docs/output-emit-text-views.md @@ -13,7 +13,7 @@ The first matching view renders. If no view matches, the last declared default view renders. If neither exists, `output.emit` falls back to its built-in compact text behavior. -`match` values are intentionally scalar-only: string, number, boolean, or null. Arrays and objects in `match` are rejected in every target. Actual result values are compared by scalar equality at exact dot paths. +`match` values are intentionally scalar-only: string, finite safe integer, boolean, or null. Arrays, objects, and non-integer or unsafe numeric values in `match` are rejected in every target. Actual result values are compared by explicit JSON scalar equality at exact dot paths, so booleans do not equal numbers. ## Truthiness @@ -56,10 +56,12 @@ Scalar formatting is portable: - strings render as their raw string value; - booleans render as `true` or `false`; - null renders as an empty string; -- integer-valued numbers render without a trailing decimal. +- finite safe integers render without a trailing decimal. -JSON blocks render with two-space indentation, preserved object key order, and unescaped Unicode characters in Python and TypeScript generated runtimes. +Portable number rendering is intentionally limited to finite safe integers. Non-integer numbers, infinities, NaN, and integers outside JavaScript's safe integer range are rejected in placeholders, `join`, `match`, and JSON text-view blocks. + +JSON blocks render with two-space indentation, recursively sorted object keys, and unescaped Unicode characters in Python and TypeScript generated runtimes. ## Failure Behavior -Malformed `text_views` fail at runtime with an `output.emit` error instead of silently falling back. Rejected cases include non-list `text_views`, non-object view entries, non-list `lines`, unsupported filters, structured `match` values, structured direct placeholders, invalid `for_each` values, and invalid `join` values. +Malformed `text_views` fail at runtime with an `output.emit` error instead of silently falling back. Rejected cases include non-list `text_views`, non-object view entries, non-list `lines`, unsupported filters, structured or unsafe-number `match` values, structured or unsafe-number direct placeholders, invalid `for_each` values, hidden invalid nested line declarations, and invalid `join` values. diff --git a/src/command_generation/primitive_executor.py b/src/command_generation/primitive_executor.py index 7afec1a..3b9729e 100644 --- a/src/command_generation/primitive_executor.py +++ b/src/command_generation/primitive_executor.py @@ -2,6 +2,7 @@ import importlib import json +import math import tomllib from collections.abc import Callable, Mapping, Sequence from dataclasses import asdict, dataclass, field, is_dataclass @@ -16,6 +17,9 @@ class PrimitiveExecutionError(RuntimeError): pass +_DECLARED_TEXT_MAX_SAFE_INTEGER = 9_007_199_254_740_991 + + PrimitiveHandler = Callable[[dict[str, Any], dict[str, Any], "PrimitiveContext"], Any] @@ -613,10 +617,14 @@ def _emit_declared_text_view(result: dict[str, Any], views: Any) -> str | None: return None if not isinstance(views, Sequence) or isinstance(views, (str, bytes, bytearray)): raise PrimitiveExecutionError("output.emit text_views must be a list") - default_view: Mapping[str, Any] | None = None + declared_views: list[Mapping[str, Any]] = [] for view in views: if not isinstance(view, Mapping): raise PrimitiveExecutionError("output.emit text_views entries must be objects") + _validate_declared_text_view(view) + declared_views.append(view) + default_view: Mapping[str, Any] | None = None + for view in declared_views: if bool(view.get("default")): default_view = view if _declared_text_view_matches(result, view): @@ -634,11 +642,74 @@ def _declared_text_view_matches(result: dict[str, Any], view: Mapping[str, Any]) if not _is_declared_text_scalar(expected): raise PrimitiveExecutionError("output.emit text view match values must be JSON scalars") found, actual = _field_by_path(result, str(path)) - if not found or actual != expected: + if not found or not _declared_text_scalar_equal(actual, expected): return False return True +def _validate_declared_text_view(view: Mapping[str, Any]) -> None: + match = view.get("match", {}) + if "match" in view and not isinstance(match, Mapping): + raise PrimitiveExecutionError("output.emit text view match must be an object") + if isinstance(match, Mapping): + for expected in match.values(): + if not _is_declared_text_scalar(expected): + raise PrimitiveExecutionError("output.emit text view match values must be JSON scalars") + if "lines" in view: + _validate_declared_text_lines(view["lines"]) + + +def _validate_declared_text_lines(lines: Any) -> None: + if not isinstance(lines, Sequence) or isinstance(lines, (str, bytes, bytearray)): + raise PrimitiveExecutionError("output.emit text view lines must be a list") + for line in lines: + _validate_declared_text_line(line) + + +def _validate_declared_text_line(line: Any) -> None: + if isinstance(line, str): + return + if not isinstance(line, Mapping): + raise PrimitiveExecutionError("output.emit text view lines must be strings or objects") + discriminators = {"when", "for_each", "json", "template", "literal"} + present = [key for key in discriminators if key in line] + if len(present) != 1: + raise PrimitiveExecutionError( + "output.emit text view line object must declare exactly one of when, for_each, json, template, or literal" + ) + key = present[0] + if key == "literal": + if set(line) != {"literal"}: + raise PrimitiveExecutionError("output.emit literal line must only declare literal") + return + if key == "template": + if set(line) != {"template"}: + raise PrimitiveExecutionError("output.emit template line must only declare template") + return + if key == "json": + if set(line) != {"json"}: + raise PrimitiveExecutionError("output.emit json line must only declare json") + return + if key == "when": + if set(line) != {"when", "lines"}: + raise PrimitiveExecutionError("output.emit when line must declare when and lines") + _validate_declared_text_lines(line["lines"]) + return + spec = line["for_each"] + if not isinstance(spec, Mapping): + raise PrimitiveExecutionError("output.emit for_each line must be an object") + if "path" not in spec: + raise PrimitiveExecutionError("output.emit for_each line must declare path") + nested_forms = [name for name in ("lines", "template") if name in spec] + if len(nested_forms) != 1: + raise PrimitiveExecutionError("output.emit for_each line must declare exactly one of lines or template") + expected_keys = {"path", nested_forms[0]} + if set(spec) != expected_keys: + raise PrimitiveExecutionError("output.emit for_each line has unsupported fields") + if "lines" in spec: + _validate_declared_text_lines(spec["lines"]) + + def _render_declared_text_view(result: dict[str, Any], view: Mapping[str, Any]) -> str: rendered = _render_declared_text_lines(view.get("lines", []), current=result, root=result) return "\n".join(rendered).rstrip() + "\n" @@ -684,7 +755,11 @@ def _render_declared_text_line(line: Any, *, current: Any, root: dict[str, Any]) found, value = _declared_text_value(line["json"], current=current, root=root) if not found: value = None - return json.dumps(_plain_output_result(value), indent=2, ensure_ascii=False).splitlines() + return json.dumps( + _declared_text_canonical_json_value(_plain_output_result(value)), + indent=2, + ensure_ascii=False, + ).splitlines() if "template" in line: return [_render_declared_text_template(str(line["template"]), current=current, root=root)] if "literal" in line: @@ -767,7 +842,37 @@ def _declared_text_format(value: Any) -> str: def _is_declared_text_scalar(value: Any) -> bool: - return value is None or isinstance(value, str | bool | int | float) + return ( + value is None + or isinstance(value, str | bool) + or _is_declared_text_safe_integer(value) + ) + + +def _is_declared_text_safe_integer(value: Any) -> bool: + if isinstance(value, bool) or not isinstance(value, Real): + return False + try: + numeric = float(value) + except OverflowError: + return False + return ( + math.isfinite(numeric) + and numeric.is_integer() + and abs(int(numeric)) <= _DECLARED_TEXT_MAX_SAFE_INTEGER + ) + + +def _declared_text_scalar_equal(actual: Any, expected: Any) -> bool: + if expected is None: + return actual is None + if isinstance(expected, bool): + return isinstance(actual, bool) and actual is expected + if isinstance(expected, str): + return isinstance(actual, str) and actual == expected + if _is_declared_text_safe_integer(expected): + return _is_declared_text_safe_integer(actual) and int(actual) == int(expected) + return False def _declared_text_format_scalar(value: Any) -> str: @@ -775,13 +880,30 @@ def _declared_text_format_scalar(value: Any) -> str: return "true" if value else "false" if value is None: return "" - if isinstance(value, Real) and not isinstance(value, bool): - if isinstance(value, float) and value.is_integer(): - return str(int(value)) - return json.dumps(value, ensure_ascii=False, allow_nan=False) + if _is_declared_text_safe_integer(value): + return str(int(value)) return str(value) +def _declared_text_canonical_json_value(value: Any) -> Any: + if isinstance(value, Mapping): + return { + str(key): _declared_text_canonical_json_value(value[key]) + for key in sorted(value, key=lambda item: str(item)) + } + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_declared_text_canonical_json_value(item) for item in value] + if isinstance(value, bool) or value is None or isinstance(value, str): + return value + if _is_declared_text_safe_integer(value): + return int(value) + if isinstance(value, Real): + raise PrimitiveExecutionError( + "output.emit text view JSON numbers must be finite safe integers" + ) + return value + + def _view_payload(*, values: dict[str, Any], arguments: dict[str, Any]) -> dict[str, Any]: source_name = str(arguments.get("source") or "result") if source_name not in values: diff --git a/src/command_generation/primitive_registry.py b/src/command_generation/primitive_registry.py index 5a63c95..b48678e 100644 --- a/src/command_generation/primitive_registry.py +++ b/src/command_generation/primitive_registry.py @@ -177,8 +177,8 @@ def to_jsonable(self) -> list[dict[str, Any]]: "kind": "portable", "description": ( "Emit JSON or compact text from a result payload, including declared text_views " - "with scalar matching, scalar placeholders, list-of-scalar join, JSON-domain " - "truthiness, and explicit JSON blocks for arrays or objects." + "with explicit JSON scalar matching, finite-safe-integer scalar formatting, " + "list-of-scalar join, JSON-domain truthiness, and canonical JSON blocks." ), "target_support": {"python": "implemented", "typescript": "implemented"}, }, diff --git a/src/command_generation/targets/typescript.py b/src/command_generation/targets/typescript.py index 1fae674..35034ee 100644 --- a/src/command_generation/targets/typescript.py +++ b/src/command_generation/targets/typescript.py @@ -606,9 +606,12 @@ class RuntimeError extends Error {{}} function emitDeclaredTextView(result, views) {{ if (views === null || views === undefined) return null; if (!Array.isArray(views)) throw new RuntimeError('output.emit text_views must be a list'); - let defaultView = null; for (const view of views) {{ if (!isObject(view)) throw new RuntimeError('output.emit text_views entries must be objects'); + validateDeclaredTextView(view); + }} + let defaultView = null; + for (const view of views) {{ if (view.default) defaultView = view; if (declaredTextViewMatches(result, view)) return renderDeclaredTextView(result, view); }} @@ -621,11 +624,61 @@ class RuntimeError extends Error {{}} for (const [path, expected] of Object.entries(match)) {{ if (!declaredTextIsScalar(expected)) throw new RuntimeError('output.emit text view match values must be JSON scalars'); const [found, actual] = fieldByPath(result, path); - if (!found || actual !== expected) return false; + if (!found || !declaredTextScalarEqual(actual, expected)) return false; }} return true; }} +function validateDeclaredTextView(view) {{ + const match = view.match ?? {{}}; + if (Object.prototype.hasOwnProperty.call(view, 'match') && !isObject(match)) throw new RuntimeError('output.emit text view match must be an object'); + for (const expected of Object.values(match)) {{ + if (!declaredTextIsScalar(expected)) throw new RuntimeError('output.emit text view match values must be JSON scalars'); + }} + if (Object.prototype.hasOwnProperty.call(view, 'lines')) validateDeclaredTextLines(view.lines); +}} + +function validateDeclaredTextLines(lines) {{ + if (!Array.isArray(lines)) throw new RuntimeError('output.emit text view lines must be a list'); + for (const line of lines) validateDeclaredTextLine(line); +}} + +function validateDeclaredTextLine(line) {{ + if (typeof line === 'string') return; + if (!isObject(line)) throw new RuntimeError('output.emit text view lines must be strings or objects'); + const discriminators = ['when', 'for_each', 'json', 'template', 'literal']; + const present = discriminators.filter((key) => Object.prototype.hasOwnProperty.call(line, key)); + if (present.length !== 1) throw new RuntimeError('output.emit text view line object must declare exactly one of when, for_each, json, template, or literal'); + const key = present[0]; + const keys = Object.keys(line).sort(); + if (key === 'literal') {{ + if (keys.length !== 1 || keys[0] !== 'literal') throw new RuntimeError('output.emit literal line must only declare literal'); + return; + }} + if (key === 'template') {{ + if (keys.length !== 1 || keys[0] !== 'template') throw new RuntimeError('output.emit template line must only declare template'); + return; + }} + if (key === 'json') {{ + if (keys.length !== 1 || keys[0] !== 'json') throw new RuntimeError('output.emit json line must only declare json'); + return; + }} + if (key === 'when') {{ + if (keys.length !== 2 || keys[0] !== 'lines' || keys[1] !== 'when') throw new RuntimeError('output.emit when line must declare when and lines'); + validateDeclaredTextLines(line.lines); + return; + }} + const spec = line.for_each; + if (!isObject(spec)) throw new RuntimeError('output.emit for_each line must be an object'); + if (!Object.prototype.hasOwnProperty.call(spec, 'path')) throw new RuntimeError('output.emit for_each line must declare path'); + const nestedForms = ['lines', 'template'].filter((name) => Object.prototype.hasOwnProperty.call(spec, name)); + if (nestedForms.length !== 1) throw new RuntimeError('output.emit for_each line must declare exactly one of lines or template'); + const specKeys = Object.keys(spec).sort(); + const expectedKeys = ['path', nestedForms[0]].sort(); + if (specKeys.length !== 2 || specKeys[0] !== expectedKeys[0] || specKeys[1] !== expectedKeys[1]) throw new RuntimeError('output.emit for_each line has unsupported fields'); + if (Object.prototype.hasOwnProperty.call(spec, 'lines')) validateDeclaredTextLines(spec.lines); +}} + function renderDeclaredTextView(result, view) {{ return `${{renderDeclaredTextLines(view.lines ?? [], result, result).join('\\n').trimEnd()}}\n`; }} @@ -653,7 +706,7 @@ class RuntimeError extends Error {{}} }} if (Object.prototype.hasOwnProperty.call(line, 'json')) {{ const [found, value] = declaredTextValue(line.json, current, root); - return JSON.stringify(found ? value : null, null, 2).split('\\n'); + return JSON.stringify(declaredTextCanonicalJsonValue(found ? value : null), null, 2).split('\\n'); }} if (Object.prototype.hasOwnProperty.call(line, 'template')) return [renderDeclaredTextTemplate(String(line.template), current, root)]; if (Object.prototype.hasOwnProperty.call(line, 'literal')) return [String(line.literal)]; @@ -722,15 +775,41 @@ class RuntimeError extends Error {{}} }} function declaredTextIsScalar(value) {{ - return value === null || value === undefined || ['string', 'number', 'boolean'].includes(typeof value); + return value === null || value === undefined || ['string', 'boolean'].includes(typeof value) || declaredTextIsSafeInteger(value); +}} + +function declaredTextIsSafeInteger(value) {{ + return typeof value === 'number' && Number.isSafeInteger(value); +}} + +function declaredTextScalarEqual(actual, expected) {{ + if (expected === null || expected === undefined) return actual === null || actual === undefined; + if (typeof expected === 'boolean') return typeof actual === 'boolean' && actual === expected; + if (typeof expected === 'string') return typeof actual === 'string' && actual === expected; + if (declaredTextIsSafeInteger(expected)) return declaredTextIsSafeInteger(actual) && actual === expected; + return false; }} function declaredTextFormatScalar(value) {{ if (typeof value === 'boolean') return value ? 'true' : 'false'; if (value === null || value === undefined) return ''; + if (declaredTextIsSafeInteger(value)) return String(value); return String(value); }} +function declaredTextCanonicalJsonValue(value) {{ + if (Array.isArray(value)) return value.map(declaredTextCanonicalJsonValue); + if (isObject(value)) {{ + const out = {{}}; + for (const key of Object.keys(value).sort()) out[key] = declaredTextCanonicalJsonValue(value[key]); + return out; + }} + if (value === null || value === undefined || ['string', 'boolean'].includes(typeof value)) return value; + if (declaredTextIsSafeInteger(value)) return value; + if (typeof value === 'number') throw new RuntimeError('output.emit text view JSON numbers must be finite safe integers'); + return value; +}} + function limitedViewValue(value, limit) {{ if (!Number.isInteger(limit) || typeof value === 'string') return value; if (Array.isArray(value)) return value.slice(0, Math.max(limit, 0)); diff --git a/tests/test_primitive_executor.py b/tests/test_primitive_executor.py index 4a2f9cb..3052fe4 100644 --- a/tests/test_primitive_executor.py +++ b/tests/test_primitive_executor.py @@ -583,6 +583,7 @@ def test_output_emit_supports_declared_text_views(primitive_context: PrimitiveCo "kind": "fixture/report/v1", "profile": "compact", "enabled": True, + "score": 1, "items": ["alpha", "beta"], "records": [{"name": "one", "status": "ready"}], "values": {"status": "ok"}, @@ -594,11 +595,17 @@ def test_output_emit_supports_declared_text_views(primitive_context: PrimitiveCo values={"result": payload, "format": "text"}, arguments={ "text_views": [ + { + "id": "fixture.bool-number-mismatch", + "match": {"enabled": 1}, + "lines": ["Wrong view"], + }, { "id": "fixture.compact", "match": {"kind": "fixture/report/v1", "profile": "compact"}, "lines": [ "Enabled: {enabled}", + "Score: {score}", "Items: {items|join:, |empty:(none)}", "Record count: {records|len}", {"literal": "Values:"}, @@ -620,6 +627,7 @@ def test_output_emit_supports_declared_text_views(primitive_context: PrimitiveCo assert emitted_text == ( "Enabled: true\n" + "Score: 1\n" "Items: alpha, beta\n" "Record count: 1\n" "Values:\n" diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 165a743..47a063e 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1821,6 +1821,11 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p "uses": "output.emit", "arguments": { "text_views": [ + { + "id": "fixture.bool-number-mismatch", + "match": {"active": 1}, + "lines": ["Wrong view"], + }, { "id": "fixture.compact", "match": {"kind": "todo-list/v1", "profile": "compact"}, @@ -1874,7 +1879,7 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p "items": ["alpha", "beta"], "records": [{"name": "one", "status": "ready"}], "warnings": ["check config"], - "metadata": {"source": "fixture", "city": "Malm\u00f6"}, + "metadata": {"source": "fixture", "city": "Malm\u00f6", "2": "b", "1": "a"}, "empty_object": {}, "missing": [], "active": True, @@ -1913,8 +1918,10 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p "- check config\n" "Metadata:\n" "{\n" - ' "source": "fixture",\n' - ' "city": "Malm\u00f6"\n' + ' "1": "a",\n' + ' "2": "b",\n' + ' "city": "Malm\u00f6",\n' + ' "source": "fixture"\n' "}\n" "Record: one (ready)\n" "Root profile: compact\n" @@ -1992,6 +1999,83 @@ def assert_generated_text_view_error(arguments: dict[str, object], expected_mess {"text_views": [{"id": "bad.join", "match": {"kind": "todo-list/v1"}, "lines": ["Records: {records|join:, }"]}]}, "output.emit join filter requires a list of JSON scalars", ) + assert_generated_text_view_error( + {"text_views": [{"id": "bad.number", "match": {"score": 1e-7}, "lines": ["Bad"]}]}, + "output.emit text view match values must be JSON scalars", + ) + assert_generated_text_view_error( + { + "text_views": [ + { + "id": "bad.hidden-line", + "match": {"kind": "todo-list/v1"}, + "lines": [ + { + "when": "empty_object", + "lines": [{"template": "Hidden", "literal": "Invalid"}], + } + ], + } + ] + }, + "output.emit text view line object must declare exactly one of when, for_each, json, template, or literal", + ) + assert_generated_text_view_error( + { + "text_views": [ + { + "id": "bad.for-each", + "match": {"kind": "todo-list/v1"}, + "lines": [ + { + "for_each": { + "path": "warnings", + "template": "- {}", + "lines": ["- {}"], + } + } + ], + } + ] + }, + "output.emit for_each line must declare exactly one of lines or template", + ) + bad_json_number_values = {**values, "metadata": {"small": 1e-7}} + write_generated_emit_arguments( + { + "text_views": [ + { + "id": "bad.json-number", + "match": {"kind": "todo-list/v1"}, + "lines": [{"json": "metadata"}], + } + ] + } + ) + sys.path.insert(0, str(tmp_path)) + try: + py_cli = importlib.import_module("todo_cli_pkg.cli") + py_executor = importlib.import_module("todo_cli_pkg.primitives.operation_executor") + with pytest.raises(Exception, match="output.emit text view JSON numbers must be finite safe integers"): + py_executor.run_operation_callable( + py_cli.generated_operation_contract("todo.list.report"), + bad_json_number_values, + ) + finally: + sys.path.remove(str(tmp_path)) + for name in list(sys.modules): + if name == "todo_cli_pkg" or name.startswith("todo_cli_pkg."): + sys.modules.pop(name, None) + runner.write_text( + "import { invokeGeneratedOperation } from './todo_ts_pkg/src/runtime.mjs';\n" + f"const values = {json.dumps(bad_json_number_values)};\n" + "const result = invokeGeneratedOperation({ operationId: 'todo.list.report', operationPath: 'operations/todo.list.report.json', values });\n" + "process.stdout.write(JSON.stringify(result));\n", + encoding="utf-8", + ) + bad_json_number_ts = subprocess.run(["node", str(runner)], cwd=tmp_path, text=True, encoding="utf-8", capture_output=True, check=False) + assert bad_json_number_ts.returncode != 0 + assert "output.emit text view JSON numbers must be finite safe integers" in bad_json_number_ts.stderr def test_generated_module_front_door_handler_delegates_with_data_driven_argv_and_help() -> None: From b83feddd755c145c8889343419f28bea80585895 Mon Sep 17 00:00:00 2001 From: Rickard von Haugwitz Date: Mon, 13 Jul 2026 15:15:58 +0200 Subject: [PATCH 7/8] Tighten text view validation and JSON ordering --- docs/output-emit-text-views.md | 6 ++- src/command_generation/primitive_executor.py | 14 +++++ src/command_generation/targets/typescript.py | 30 ++++++++++- tests/test_public_api.py | 57 +++++++++++++++++++- 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/docs/output-emit-text-views.md b/docs/output-emit-text-views.md index 6206d75..83b543b 100644 --- a/docs/output-emit-text-views.md +++ b/docs/output-emit-text-views.md @@ -11,6 +11,8 @@ - `default`: truthy value marking the fallback view. - `lines`: list of text line declarations. +Other view-level fields are rejected. Path-bearing and template-bearing line fields are strings: `literal`, `template`, `when`, `json`, `for_each.path`, and `for_each.template` do not accept arrays, objects, numbers, booleans, or null. + The first matching view renders. If no view matches, the last declared default view renders. If neither exists, `output.emit` falls back to its built-in compact text behavior. `match` values are intentionally scalar-only: string, finite safe integer, boolean, or null. Arrays, objects, and non-integer or unsafe numeric values in `match` are rejected in every target. Actual result values are compared by explicit JSON scalar equality at exact dot paths, so booleans do not equal numbers. @@ -60,8 +62,8 @@ Scalar formatting is portable: Portable number rendering is intentionally limited to finite safe integers. Non-integer numbers, infinities, NaN, and integers outside JavaScript's safe integer range are rejected in placeholders, `join`, `match`, and JSON text-view blocks. -JSON blocks render with two-space indentation, recursively sorted object keys, and unescaped Unicode characters in Python and TypeScript generated runtimes. +JSON blocks render with two-space indentation, recursively sorted object keys, and unescaped Unicode characters in Python and TypeScript generated runtimes. Sorting is lexicographic over string keys and is target-independent even for integer-index-like keys such as `"10"` and `"2"`. ## Failure Behavior -Malformed `text_views` fail at runtime with an `output.emit` error instead of silently falling back. Rejected cases include non-list `text_views`, non-object view entries, non-list `lines`, unsupported filters, structured or unsafe-number `match` values, structured or unsafe-number direct placeholders, invalid `for_each` values, hidden invalid nested line declarations, and invalid `join` values. +Malformed `text_views` fail at runtime with an `output.emit` error instead of silently falling back. Rejected cases include non-list `text_views`, non-object view entries, unsupported view fields, non-list `lines`, non-string path/template/literal line fields, unsupported filters, structured or unsafe-number `match` values, structured or unsafe-number direct placeholders, invalid `for_each` values, hidden invalid nested line declarations, and invalid `join` values. diff --git a/src/command_generation/primitive_executor.py b/src/command_generation/primitive_executor.py index 3b9729e..ef32cda 100644 --- a/src/command_generation/primitive_executor.py +++ b/src/command_generation/primitive_executor.py @@ -648,6 +648,8 @@ def _declared_text_view_matches(result: dict[str, Any], view: Mapping[str, Any]) def _validate_declared_text_view(view: Mapping[str, Any]) -> None: + if not set(view).issubset({"id", "match", "default", "lines"}): + raise PrimitiveExecutionError("output.emit text view has unsupported fields") match = view.get("match", {}) if "match" in view and not isinstance(match, Mapping): raise PrimitiveExecutionError("output.emit text view match must be an object") @@ -681,18 +683,22 @@ def _validate_declared_text_line(line: Any) -> None: if key == "literal": if set(line) != {"literal"}: raise PrimitiveExecutionError("output.emit literal line must only declare literal") + _validate_declared_text_string(line["literal"], "output.emit literal line value must be a string") return if key == "template": if set(line) != {"template"}: raise PrimitiveExecutionError("output.emit template line must only declare template") + _validate_declared_text_string(line["template"], "output.emit template line value must be a string") return if key == "json": if set(line) != {"json"}: raise PrimitiveExecutionError("output.emit json line must only declare json") + _validate_declared_text_string(line["json"], "output.emit json line path must be a string") return if key == "when": if set(line) != {"when", "lines"}: raise PrimitiveExecutionError("output.emit when line must declare when and lines") + _validate_declared_text_string(line["when"], "output.emit when line path must be a string") _validate_declared_text_lines(line["lines"]) return spec = line["for_each"] @@ -700,6 +706,7 @@ def _validate_declared_text_line(line: Any) -> None: raise PrimitiveExecutionError("output.emit for_each line must be an object") if "path" not in spec: raise PrimitiveExecutionError("output.emit for_each line must declare path") + _validate_declared_text_string(spec["path"], "output.emit for_each path must be a string") nested_forms = [name for name in ("lines", "template") if name in spec] if len(nested_forms) != 1: raise PrimitiveExecutionError("output.emit for_each line must declare exactly one of lines or template") @@ -708,6 +715,13 @@ def _validate_declared_text_line(line: Any) -> None: raise PrimitiveExecutionError("output.emit for_each line has unsupported fields") if "lines" in spec: _validate_declared_text_lines(spec["lines"]) + else: + _validate_declared_text_string(spec["template"], "output.emit for_each template must be a string") + + +def _validate_declared_text_string(value: Any, message: str) -> None: + if not isinstance(value, str): + raise PrimitiveExecutionError(message) def _render_declared_text_view(result: dict[str, Any], view: Mapping[str, Any]) -> str: diff --git a/src/command_generation/targets/typescript.py b/src/command_generation/targets/typescript.py index 35034ee..9a14fb4 100644 --- a/src/command_generation/targets/typescript.py +++ b/src/command_generation/targets/typescript.py @@ -630,6 +630,8 @@ class RuntimeError extends Error {{}} }} function validateDeclaredTextView(view) {{ + const allowedViewKeys = new Set(['id', 'match', 'default', 'lines']); + if (Object.keys(view).some((key) => !allowedViewKeys.has(key))) throw new RuntimeError('output.emit text view has unsupported fields'); const match = view.match ?? {{}}; if (Object.prototype.hasOwnProperty.call(view, 'match') && !isObject(match)) throw new RuntimeError('output.emit text view match must be an object'); for (const expected of Object.values(match)) {{ @@ -653,30 +655,40 @@ class RuntimeError extends Error {{}} const keys = Object.keys(line).sort(); if (key === 'literal') {{ if (keys.length !== 1 || keys[0] !== 'literal') throw new RuntimeError('output.emit literal line must only declare literal'); + requireDeclaredTextString(line.literal, 'output.emit literal line value must be a string'); return; }} if (key === 'template') {{ if (keys.length !== 1 || keys[0] !== 'template') throw new RuntimeError('output.emit template line must only declare template'); + requireDeclaredTextString(line.template, 'output.emit template line value must be a string'); return; }} if (key === 'json') {{ if (keys.length !== 1 || keys[0] !== 'json') throw new RuntimeError('output.emit json line must only declare json'); + requireDeclaredTextString(line.json, 'output.emit json line path must be a string'); return; }} if (key === 'when') {{ if (keys.length !== 2 || keys[0] !== 'lines' || keys[1] !== 'when') throw new RuntimeError('output.emit when line must declare when and lines'); + requireDeclaredTextString(line.when, 'output.emit when line path must be a string'); validateDeclaredTextLines(line.lines); return; }} const spec = line.for_each; if (!isObject(spec)) throw new RuntimeError('output.emit for_each line must be an object'); if (!Object.prototype.hasOwnProperty.call(spec, 'path')) throw new RuntimeError('output.emit for_each line must declare path'); + requireDeclaredTextString(spec.path, 'output.emit for_each path must be a string'); const nestedForms = ['lines', 'template'].filter((name) => Object.prototype.hasOwnProperty.call(spec, name)); if (nestedForms.length !== 1) throw new RuntimeError('output.emit for_each line must declare exactly one of lines or template'); const specKeys = Object.keys(spec).sort(); const expectedKeys = ['path', nestedForms[0]].sort(); if (specKeys.length !== 2 || specKeys[0] !== expectedKeys[0] || specKeys[1] !== expectedKeys[1]) throw new RuntimeError('output.emit for_each line has unsupported fields'); if (Object.prototype.hasOwnProperty.call(spec, 'lines')) validateDeclaredTextLines(spec.lines); + else requireDeclaredTextString(spec.template, 'output.emit for_each template must be a string'); +}} + +function requireDeclaredTextString(value, message) {{ + if (typeof value !== 'string') throw new RuntimeError(message); }} function renderDeclaredTextView(result, view) {{ @@ -706,7 +718,7 @@ class RuntimeError extends Error {{}} }} if (Object.prototype.hasOwnProperty.call(line, 'json')) {{ const [found, value] = declaredTextValue(line.json, current, root); - return JSON.stringify(declaredTextCanonicalJsonValue(found ? value : null), null, 2).split('\\n'); + return declaredTextCanonicalJsonString(declaredTextCanonicalJsonValue(found ? value : null)).split('\\n'); }} if (Object.prototype.hasOwnProperty.call(line, 'template')) return [renderDeclaredTextTemplate(String(line.template), current, root)]; if (Object.prototype.hasOwnProperty.call(line, 'literal')) return [String(line.literal)]; @@ -810,6 +822,22 @@ class RuntimeError extends Error {{}} return value; }} +function declaredTextCanonicalJsonString(value, level = 0) {{ + const indent = ' '.repeat(level); + const childIndent = ' '.repeat(level + 1); + if (Array.isArray(value)) {{ + if (value.length === 0) return '[]'; + return '[\\n' + value.map((item) => `${{childIndent}}${{declaredTextCanonicalJsonString(item, level + 1)}}`).join(',\\n') + '\\n' + indent + ']'; + }} + if (isObject(value)) {{ + const keys = Object.keys(value).sort(); + if (keys.length === 0) return '{{}}'; + return '{{\\n' + keys.map((key) => `${{childIndent}}${{JSON.stringify(key)}}: ${{declaredTextCanonicalJsonString(value[key], level + 1)}}`).join(',\\n') + '\\n' + indent + '}}'; + }} + if (value === undefined) return 'null'; + return JSON.stringify(value); +}} + function limitedViewValue(value, limit) {{ if (!Number.isInteger(limit) || typeof value === 'string') return value; if (Array.isArray(value)) return value.slice(0, Math.max(limit, 0)); diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 47a063e..2fd66ec 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1879,7 +1879,7 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p "items": ["alpha", "beta"], "records": [{"name": "one", "status": "ready"}], "warnings": ["check config"], - "metadata": {"source": "fixture", "city": "Malm\u00f6", "2": "b", "1": "a"}, + "metadata": {"source": "fixture", "city": "Malm\u00f6", "10": "a", "2": "b", "nested": {"10": "inner-a", "2": "inner-b"}}, "empty_object": {}, "missing": [], "active": True, @@ -1918,9 +1918,13 @@ def test_generated_output_emit_text_views_execute_in_python_and_typescript(tmp_p "- check config\n" "Metadata:\n" "{\n" - ' "1": "a",\n' + ' "10": "a",\n' ' "2": "b",\n' ' "city": "Malm\u00f6",\n' + ' "nested": {\n' + ' "10": "inner-a",\n' + ' "2": "inner-b"\n' + " },\n" ' "source": "fixture"\n' "}\n" "Record: one (ready)\n" @@ -2003,6 +2007,55 @@ def assert_generated_text_view_error(arguments: dict[str, object], expected_mess {"text_views": [{"id": "bad.number", "match": {"score": 1e-7}, "lines": ["Bad"]}]}, "output.emit text view match values must be JSON scalars", ) + assert_generated_text_view_error( + { + "text_views": [ + { + "id": "bad.view-field", + "match": {"kind": "todo-list/v1"}, + "title": "unsupported", + "lines": ["Bad"], + } + ] + }, + "output.emit text view has unsupported fields", + ) + assert_generated_text_view_error( + {"text_views": [{"id": "bad.literal-type", "match": {"kind": "todo-list/v1"}, "lines": [{"literal": {"a": 1}}]}]}, + "output.emit literal line value must be a string", + ) + assert_generated_text_view_error( + {"text_views": [{"id": "bad.template-type", "match": {"kind": "todo-list/v1"}, "lines": [{"template": 42}]}]}, + "output.emit template line value must be a string", + ) + assert_generated_text_view_error( + {"text_views": [{"id": "bad.json-path-type", "match": {"kind": "todo-list/v1"}, "lines": [{"json": ["metadata"]}]}]}, + "output.emit json line path must be a string", + ) + assert_generated_text_view_error( + { + "text_views": [ + {"id": "bad.when-path-type", "match": {"kind": "todo-list/v1"}, "lines": [{"when": ["warnings"], "lines": ["Bad"]}]} + ] + }, + "output.emit when line path must be a string", + ) + assert_generated_text_view_error( + { + "text_views": [ + {"id": "bad.for-each-path-type", "match": {"kind": "todo-list/v1"}, "lines": [{"for_each": {"path": ["warnings"], "template": "- {}"}}]} + ] + }, + "output.emit for_each path must be a string", + ) + assert_generated_text_view_error( + { + "text_views": [ + {"id": "bad.for-each-template-type", "match": {"kind": "todo-list/v1"}, "lines": [{"for_each": {"path": "warnings", "template": {"line": "- {}"}}}]} + ] + }, + "output.emit for_each template must be a string", + ) assert_generated_text_view_error( { "text_views": [ From 624da888ce83a4f61f0c09b231e8766f1eeb313e Mon Sep 17 00:00:00 2001 From: Rickard von Haugwitz Date: Mon, 13 Jul 2026 16:25:30 +0200 Subject: [PATCH 8/8] Require boolean defaults for text views --- docs/output-emit-text-views.md | 6 +++--- src/command_generation/primitive_executor.py | 4 +++- src/command_generation/targets/typescript.py | 3 ++- tests/test_public_api.py | 4 ++++ 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/output-emit-text-views.md b/docs/output-emit-text-views.md index 83b543b..a966f40 100644 --- a/docs/output-emit-text-views.md +++ b/docs/output-emit-text-views.md @@ -8,10 +8,10 @@ - `id`: optional view identifier for host documentation and review. - `match`: object mapping dot paths to expected JSON scalar values. -- `default`: truthy value marking the fallback view. +- `default`: boolean marking the fallback view when `true`. - `lines`: list of text line declarations. -Other view-level fields are rejected. Path-bearing and template-bearing line fields are strings: `literal`, `template`, `when`, `json`, `for_each.path`, and `for_each.template` do not accept arrays, objects, numbers, booleans, or null. +Other view-level fields are rejected. `default` must be a boolean when present. Path-bearing and template-bearing line fields are strings: `literal`, `template`, `when`, `json`, `for_each.path`, and `for_each.template` do not accept arrays, objects, numbers, booleans, or null. The first matching view renders. If no view matches, the last declared default view renders. If neither exists, `output.emit` falls back to its built-in compact text behavior. @@ -66,4 +66,4 @@ JSON blocks render with two-space indentation, recursively sorted object keys, a ## Failure Behavior -Malformed `text_views` fail at runtime with an `output.emit` error instead of silently falling back. Rejected cases include non-list `text_views`, non-object view entries, unsupported view fields, non-list `lines`, non-string path/template/literal line fields, unsupported filters, structured or unsafe-number `match` values, structured or unsafe-number direct placeholders, invalid `for_each` values, hidden invalid nested line declarations, and invalid `join` values. +Malformed `text_views` fail at runtime with an `output.emit` error instead of silently falling back. Rejected cases include non-list `text_views`, non-object view entries, unsupported view fields, non-boolean `default` values, non-list `lines`, non-string path/template/literal line fields, unsupported filters, structured or unsafe-number `match` values, structured or unsafe-number direct placeholders, invalid `for_each` values, hidden invalid nested line declarations, and invalid `join` values. diff --git a/src/command_generation/primitive_executor.py b/src/command_generation/primitive_executor.py index ef32cda..11073f6 100644 --- a/src/command_generation/primitive_executor.py +++ b/src/command_generation/primitive_executor.py @@ -625,7 +625,7 @@ def _emit_declared_text_view(result: dict[str, Any], views: Any) -> str | None: declared_views.append(view) default_view: Mapping[str, Any] | None = None for view in declared_views: - if bool(view.get("default")): + if view.get("default") is True: default_view = view if _declared_text_view_matches(result, view): return _render_declared_text_view(result, view) @@ -650,6 +650,8 @@ def _declared_text_view_matches(result: dict[str, Any], view: Mapping[str, Any]) def _validate_declared_text_view(view: Mapping[str, Any]) -> None: if not set(view).issubset({"id", "match", "default", "lines"}): raise PrimitiveExecutionError("output.emit text view has unsupported fields") + if "default" in view and not isinstance(view["default"], bool): + raise PrimitiveExecutionError("output.emit text view default must be a boolean") match = view.get("match", {}) if "match" in view and not isinstance(match, Mapping): raise PrimitiveExecutionError("output.emit text view match must be an object") diff --git a/src/command_generation/targets/typescript.py b/src/command_generation/targets/typescript.py index 9a14fb4..9a90621 100644 --- a/src/command_generation/targets/typescript.py +++ b/src/command_generation/targets/typescript.py @@ -612,7 +612,7 @@ class RuntimeError extends Error {{}} }} let defaultView = null; for (const view of views) {{ - if (view.default) defaultView = view; + if (view.default === true) defaultView = view; if (declaredTextViewMatches(result, view)) return renderDeclaredTextView(result, view); }} return defaultView ? renderDeclaredTextView(result, defaultView) : null; @@ -632,6 +632,7 @@ class RuntimeError extends Error {{}} function validateDeclaredTextView(view) {{ const allowedViewKeys = new Set(['id', 'match', 'default', 'lines']); if (Object.keys(view).some((key) => !allowedViewKeys.has(key))) throw new RuntimeError('output.emit text view has unsupported fields'); + if (Object.prototype.hasOwnProperty.call(view, 'default') && typeof view.default !== 'boolean') throw new RuntimeError('output.emit text view default must be a boolean'); const match = view.match ?? {{}}; if (Object.prototype.hasOwnProperty.call(view, 'match') && !isObject(match)) throw new RuntimeError('output.emit text view match must be an object'); for (const expected of Object.values(match)) {{ diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 2fd66ec..a197a41 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -2020,6 +2020,10 @@ def assert_generated_text_view_error(arguments: dict[str, object], expected_mess }, "output.emit text view has unsupported fields", ) + assert_generated_text_view_error( + {"text_views": [{"id": "bad.default-type", "default": [], "lines": ["Bad"]}]}, + "output.emit text view default must be a boolean", + ) assert_generated_text_view_error( {"text_views": [{"id": "bad.literal-type", "match": {"kind": "todo-list/v1"}, "lines": [{"literal": {"a": 1}}]}]}, "output.emit literal line value must be a string",