diff --git a/README.md b/README.md index cd364f3..3c90f12 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Built-in primitive definitions carry a `kind` classification: The exported `BUILTIN_PORTABLE_PRIMITIVES` registry contains package-owned portable primitives plus the explicit host-owned bridge IDs that generated runtimes may call when the host supplies support modules. It does not carry product-shaped transitional compatibility primitives. +`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. + ## Public API - `load_command_package_ir(path, schema_path=None)` validates IR against the package-owned schema. diff --git a/docs/projection-primitives.md b/docs/projection-primitives.md new file mode 100644 index 0000000..9e7c318 --- /dev/null +++ b/docs/projection-primitives.md @@ -0,0 +1,20 @@ +# Projection primitives + +`payload.project` is a host-neutral primitive for exact payload projection. + +Command Generation owns only the mechanics: + +- read a source value from the operation value map; +- split declared selector strings into exact dot paths; +- resolve object fields and list indexes; +- return a selected-output wrapper with `values`, `missing`, and `available_selectors`. + +Host packages own the semantics: + +- payload construction; +- selector names and command names; +- view policy, ordering, labels, and text; +- whether a missing selector is acceptable; +- any user-facing interpretation of the projected values. + +The primitive intentionally does not evaluate expressions, execute embedded language snippets, infer selectors from prose, or encode host package vocabulary. diff --git a/src/command_generation/primitive_executor.py b/src/command_generation/primitive_executor.py index b623d75..3099118 100644 --- a/src/command_generation/primitive_executor.py +++ b/src/command_generation/primitive_executor.py @@ -101,6 +101,8 @@ def execute_primitive( return _toml_table_counts(values=values, arguments=arguments, context=context) if primitive == "payload.assemble": return _assemble_payload(values=values, arguments=arguments) + if primitive == "payload.project": + return _project_payload(values=values, arguments=arguments) if primitive == "output.emit": return _emit_output(values=values, arguments=arguments) if primitive == "python.function.call": @@ -509,6 +511,46 @@ def _emit_output( return "\n".join(lines).rstrip() + "\n" +def _project_payload(*, values: dict[str, Any], arguments: dict[str, Any]) -> dict[str, Any]: + source_name = str(arguments.get("source") or "result") + source_command = str(arguments.get("source_command") or values.get("operation_id") or "") + selected_output_kind = str(arguments.get("selected_output_kind") or "command-generation/selected-output/v1") + if source_name not in values: + raise PrimitiveExecutionError(f"payload.project source value is missing: {source_name!r}") + payload = values[source_name] + selectors = _projection_selectors(values=values, arguments=arguments) + if not selectors: + return _plain_output_result(payload) + selected: dict[str, Any] = { + "kind": selected_output_kind, + "source_command": source_command, + "values": {}, + } + missing: list[str] = [] + projected_values = cast(dict[str, Any], selected["values"]) + for selector in selectors: + found, value = _field_by_path(payload, selector) + if found: + projected_values[selector] = _plain_output_result(value) + else: + missing.append(selector) + if missing: + selected["missing"] = missing + selected["selector_rule"] = "Comma-separated dot paths select exact JSON fields; unknown fields are reported in missing." + selected["available_selectors"] = _available_selectors_for_payload(payload) + return selected + + +def _projection_selectors(*, values: dict[str, Any], arguments: dict[str, Any]) -> list[str]: + raw_selectors = arguments.get("selectors") + if raw_selectors is None: + select_value_name = str(arguments.get("select_value") or "select") + raw_selectors = values.get(select_value_name) + if isinstance(raw_selectors, Sequence) and not isinstance(raw_selectors, (str, bytes, bytearray)): + return [str(item).strip() for item in raw_selectors if str(item).strip()] + return [token.strip() for token in str(raw_selectors or "").split(",") if token.strip()] + + def _plain_output_result(result: Any) -> Any: if isinstance(result, Path): return str(result) @@ -702,6 +744,39 @@ def _resolve_dotted_value(payload: Mapping[str, Any], dotted_path: str) -> Any: return current +def _field_by_path(payload: Any, dotted_path: str) -> tuple[bool, Any]: + if not dotted_path: + return False, None + current: Any = payload + for part in dotted_path.split("."): + if isinstance(current, Mapping) and part in current: + current = current[part] + continue + if isinstance(current, Sequence) and not isinstance(current, (str, bytes, bytearray)): + try: + current = current[int(part)] + continue + except (ValueError, IndexError): + return False, None + return False, None + return True, current + + +def _available_selectors_for_payload(payload: Any, prefix: str = "") -> list[str]: + selectors: list[str] = [] + if isinstance(payload, Mapping): + for key in sorted(str(item) for item in payload): + path = f"{prefix}.{key}" if prefix else key + selectors.append(path) + selectors.extend(_available_selectors_for_payload(payload.get(key), path)) + elif isinstance(payload, Sequence) and not isinstance(payload, (str, bytes, bytearray)): + for index, item in enumerate(payload[:10]): + path = f"{prefix}.{index}" if prefix else str(index) + selectors.append(path) + selectors.extend(_available_selectors_for_payload(item, path)) + return selectors + + def _resolve_inside(root: Path, relative: str) -> Path: candidate = (root / relative).resolve() _ensure_inside(root, candidate) diff --git a/src/command_generation/primitive_registry.py b/src/command_generation/primitive_registry.py index a5ec206..76920fd 100644 --- a/src/command_generation/primitive_registry.py +++ b/src/command_generation/primitive_registry.py @@ -160,6 +160,12 @@ def to_jsonable(self) -> list[dict[str, Any]]: "description": "Assemble generic file, skill, template, or package-file-list payloads.", "target_support": {"python": "implemented", "typescript": "implemented"}, }, + { + "id": "payload.project", + "kind": "portable", + "description": "Project exact dot-path selectors from a payload into a generic selected-output wrapper.", + "target_support": {"python": "implemented", "typescript": "implemented"}, + }, { "id": "output.emit", "kind": "portable", diff --git a/src/command_generation/targets/typescript.py b/src/command_generation/targets/typescript.py index 9c8a605..a5f5dba 100644 --- a/src/command_generation/targets/typescript.py +++ b/src/command_generation/targets/typescript.py @@ -441,6 +441,75 @@ class RuntimeError extends Error {{}} return current; }} +function fieldByPath(root, dottedPath) {{ + if (!dottedPath) return [false, null]; + let current = root; + for (const part of String(dottedPath).split('.')) {{ + if (isObject(current) && Object.prototype.hasOwnProperty.call(current, part)) {{ + current = current[part]; + continue; + }} + if (Array.isArray(current)) {{ + const index = Number(part); + if (Number.isInteger(index) && index >= 0 && index < current.length) {{ + current = current[index]; + continue; + }} + }} + return [false, null]; + }} + return [true, current]; +}} + +function selectorTokens(value) {{ + if (Array.isArray(value)) return value.map(String).map((item) => item.trim()).filter(Boolean); + return String(value ?? '').split(',').map((item) => item.trim()).filter(Boolean); +}} + +function availableSelectorsForPayload(payload, prefix = '') {{ + const selectors = []; + if (isObject(payload)) {{ + for (const key of Object.keys(payload).map(String).sort()) {{ + const path = prefix ? `${{prefix}}.${{key}}` : key; + selectors.push(path); + selectors.push(...availableSelectorsForPayload(payload[key], path)); + }} + }} else if (Array.isArray(payload)) {{ + for (const [index, item] of payload.slice(0, 10).entries()) {{ + const path = prefix ? `${{prefix}}.${{index}}` : String(index); + selectors.push(path); + selectors.push(...availableSelectorsForPayload(item, path)); + }} + }} + return selectors; +}} + +function projectPayload(values, args) {{ + const sourceName = String(args.source ?? 'result'); + if (!Object.prototype.hasOwnProperty.call(values, sourceName)) throw new RuntimeError(`payload.project source value is missing: ${{sourceName}}`); + const payload = values[sourceName]; + const selectValueName = String(args.select_value ?? 'select'); + const selectors = selectorTokens(args.selectors ?? values[selectValueName]); + if (selectors.length === 0) return payload; + const selected = {{ + kind: String(args.selected_output_kind ?? 'command-generation/selected-output/v1'), + source_command: String(args.source_command ?? values.operation_id ?? ''), + values: {{}} + }}; + const missing = []; + for (const selector of selectors) {{ + const [found, value] = fieldByPath(payload, selector); + if (found) selected.values[selector] = value; + else missing.push(selector); + }} + if (missing.length) {{ + selected.missing = missing; + selected.selector_rule = 'Comma-separated dot paths select exact JSON fields; unknown fields are reported in missing.'; + selected.available_selectors = availableSelectorsForPayload(payload); + }} + return selected; +}} + function assemblePayload(values, args) {{ const fields = args.fields ?? {{}}; if (fields.template !== undefined) return resolveTemplate(fields.template, values); @@ -523,6 +592,7 @@ class RuntimeError extends Error {{}} if (primitive === 'filesystem.glob') return globFiles(valueRoot(args, values), String(args.pattern ?? '')).map((relative_path) => ({{ relative_path }})); if (primitive === 'json.parse') return JSON.parse(String(values[String(args.source ?? 'registry_text')])); if (primitive === 'payload.assemble') return assemblePayload(values, args); + if (primitive === 'payload.project') return projectPayload(values, args); if (primitive === 'output.emit') return emitOutput(values); return executeHostPrimitive(primitive, values, args, operationId); }} diff --git a/tests/primitive_conformance.py b/tests/primitive_conformance.py index 014b8d3..6d5dd36 100644 --- a/tests/primitive_conformance.py +++ b/tests/primitive_conformance.py @@ -122,6 +122,15 @@ def main() -> int: assert skill_payload["actions"][0]["path"] == "review" assert skill_payload["actions"][0]["source"] == "review" + selected_payload = execute_primitive( + "payload.project", + values={"result": skill_payload, "select": "actions.0.path,message,missing"}, + arguments={"source_command": "fixture.skills"}, + context=context, + ) + assert selected_payload["values"] == {"actions.0.path": "review", "message": "Skills"} + assert selected_payload["missing"] == ["missing"] + emitted_json = execute_primitive( "output.emit", values={"result": skill_payload, "format": "json"}, diff --git a/tests/test_primitive_executor.py b/tests/test_primitive_executor.py index c61407b..be9411c 100644 --- a/tests/test_primitive_executor.py +++ b/tests/test_primitive_executor.py @@ -251,6 +251,52 @@ def test_payload_assemble_supports_template_field_selectors(primitive_context: P assert payload == {"status": "present", "nested": {"note_count": 3, "required_count": 1}} +def test_payload_project_selects_exact_paths_and_reports_missing(primitive_context: PrimitiveContext) -> None: + result = execute_primitive( + "payload.project", + values={ + "operation_id": "fixture.show", + "select": "items.0.name,summary.count,missing.value", + "result": { + "summary": {"count": 2}, + "items": [{"name": "alpha"}, {"name": "beta"}], + }, + }, + context=primitive_context, + ) + + assert result["kind"] == "command-generation/selected-output/v1" + assert result["source_command"] == "fixture.show" + assert result["values"] == {"items.0.name": "alpha", "summary.count": 2} + assert result["missing"] == ["missing.value"] + assert "items.1.name" in result["available_selectors"] + + +def test_payload_project_can_use_declared_selector_list(primitive_context: PrimitiveContext) -> None: + result = execute_primitive( + "payload.project", + values={ + "payload": { + "status": "ready", + "details": {"owner": "fixture"}, + } + }, + arguments={ + "source": "payload", + "source_command": "fixture.status", + "selectors": ["status", "details.owner"], + "selected_output_kind": "fixture/selected-output/v1", + }, + context=primitive_context, + ) + + assert result == { + "kind": "fixture/selected-output/v1", + "source_command": "fixture.status", + "values": {"status": "ready", "details.owner": "fixture"}, + } + + def test_operation_fragments_compose_reusable_step_groups(primitive_context: PrimitiveContext) -> None: operation = { "id": "fixture.report", @@ -294,6 +340,39 @@ def test_operation_fragments_compose_reusable_step_groups(primitive_context: Pri assert json.loads(values["emitted"]) == {"status": "ok"} +def test_run_operation_steps_can_project_payload_fields(primitive_context: PrimitiveContext) -> None: + operation = { + "id": "fixture.project", + "ir_plan": { + "steps": [ + { + "id": "make_result", + "uses": "fixture.make-result", + "outputs": ["result"], + }, + { + "id": "project", + "uses": "payload.project", + "arguments": { + "selectors": ["summary.status"], + "source_command": "fixture.project", + }, + "outputs": ["selected"], + }, + ] + }, + } + + values = run_operation_steps( + operation, + initial_values={}, + context=primitive_context, + handlers={"fixture.make-result": lambda values, arguments, context: {"summary": {"status": "ready"}}}, + ) + + assert values["selected"]["values"] == {"summary.status": "ready"} + + def test_operation_fragments_reject_cycles(primitive_context: PrimitiveContext) -> None: operation = { "id": "fixture.report", diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 74e1bec..455dc3d 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -2028,6 +2028,7 @@ def test_primitive_registry_round_trips_host_metadata() -> None: def test_builtin_registry_declares_portable_primitives() -> None: assert "filesystem.read" in BUILTIN_PORTABLE_PRIMITIVES.ids() + assert "payload.project" in BUILTIN_PORTABLE_PRIMITIVES.ids() assert "output.emit" in BUILTIN_PORTABLE_PRIMITIVES.ids() @@ -2037,6 +2038,7 @@ def test_builtin_registry_classifies_primitive_ownership_boundaries() -> None: assert {item["kind"] for item in definitions.values()} <= {"portable", "host-owned"} assert definitions["filesystem.read"]["kind"] == "portable" assert definitions["json.parse"]["kind"] == "portable" + assert definitions["payload.project"]["kind"] == "portable" assert definitions["python.function.call"]["kind"] == "host-owned" assert definitions["typescript.domain.execute"]["kind"] == "host-owned"