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..a966f40 --- /dev/null +++ b/docs/output-emit-text-views.md @@ -0,0 +1,69 @@ +# `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`: boolean marking the fallback view when `true`. +- `lines`: list of text line declarations. + +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. + +`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 + +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; +- finite safe integers render without a trailing decimal. + +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. 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, 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 f9ac9ef..11073f6 100644 --- a/src/command_generation/primitive_executor.py +++ b/src/command_generation/primitive_executor.py @@ -2,9 +2,11 @@ import importlib import json +import math 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 @@ -15,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] @@ -570,6 +575,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 +612,314 @@ 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") + 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 view.get("default") is True: + 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(): + 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 not _declared_text_scalar_equal(actual, expected): + return False + return True + + +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") + 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") + _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"] + 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") + _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") + 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"]) + 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: + 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( + _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: + 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 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): + 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 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) + 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: + if isinstance(value, bool): + return "true" if value else "false" + if value is None: + return "" + 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 1abaaa9..b48678e 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 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/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/src/command_generation/targets/typescript.py b/src/command_generation/targets/typescript.py index 3357d86..9a90621 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,242 @@ 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'); + 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 === true) 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)) {{ + if (!declaredTextIsScalar(expected)) throw new RuntimeError('output.emit text view match values must be JSON scalars'); + const [found, actual] = fieldByPath(result, path); + if (!found || !declaredTextScalarEqual(actual, expected)) return false; + }} + return true; +}} + +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)) {{ + 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'); + 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) {{ + 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 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)]; + 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 (!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; + 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) {{ + 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); +}} + +function declaredTextFormat(value) {{ + 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', '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 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)); @@ -706,7 +946,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..3052fe4 100644 --- a/tests/test_primitive_executor.py +++ b/tests/test_primitive_executor.py @@ -578,6 +578,68 @@ 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, + "score": 1, + "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.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:"}, + {"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" + "Score: 1\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: diff --git a/tests/test_public_api.py b/tests/test_public_api.py index da1a05b..a197a41 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1736,6 +1736,405 @@ 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_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": []}, + {"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" + 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"}, + "active": {"$value": "active"}, + "score": {"$value": "score"}, + "flags": {"$value": "flags"}, + } + } + }, + "outputs": ["result"], + }, + { + "id": "emit", + "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"}, + "lines": [ + "Profile: {profile}", + "Active: {active}", + "Score: {score}", + "Flags: {flags|join:/}", + "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", "city": "Malm\u00f6", "10": "a", "2": "b", "nested": {"10": "inner-a", "2": "inner-b"}}, + "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: + 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)};\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, 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" + "Warnings:\n" + "- check config\n" + "Metadata:\n" + "{\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" + "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)};\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, 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" + + 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 + + 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", + ) + 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.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.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", + ) + 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": [ + { + "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: runtime_module = types.ModuleType("fake_module_front_door_runtime") calls: list[list[str]] = []