diff --git a/src/extensions/score_metamodel/agent_context/BUILD b/src/extensions/score_metamodel/agent_context/BUILD new file mode 100644 index 000000000..166b5bf78 --- /dev/null +++ b/src/extensions/score_metamodel/agent_context/BUILD @@ -0,0 +1,49 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_library") +load("@docs_as_code_hub_env//:requirements.bzl", "all_requirements") +load("//:score_pytest.bzl", "score_pytest") + +py_library( + name = "agent_context_generator", + srcs = ["generate_agent_context.py"], + imports = ["."], + deps = all_requirements, +) + +# Public so other repositories can consume the generated projection. +py_binary( + name = "generate_agent_context_bin", + srcs = ["generate_agent_context.py"], + data = ["//src/extensions/score_metamodel:metamodel_yaml"], + main = "generate_agent_context.py", + visibility = ["//visibility:public"], + deps = all_requirements, +) + +score_pytest( + name = "unit_tests", + size = "small", + srcs = glob(["tests/*.py"]), + data = [ + "tests/model/nested_model.yaml", + "tests/model/precedence_model.yaml", + "tests/model/simple_expected.json", + "tests/model/simple_model.yaml", + "//src/extensions/score_metamodel:metamodel_yaml", + ], + pytest_config = "//:pyproject.toml", + visibility = ["//visibility:public"], + deps = [":agent_context_generator"], +) diff --git a/src/extensions/score_metamodel/agent_context/generate_agent_context.py b/src/extensions/score_metamodel/agent_context/generate_agent_context.py new file mode 100644 index 000000000..31730132f --- /dev/null +++ b/src/extensions/score_metamodel/agent_context/generate_agent_context.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Generate a vendor-neutral JSON projection of the S-CORE metamodel. + +The projection contains the metamodel vocabulary and raw graph-check data +needed by downstream agent-context renderers. It deliberately does not +evaluate or otherwise interpret graph-check expressions. + +Usage: + generate_agent_context.py --output FILE [METAMODEL_YAML] +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any, cast + +import ruamel.yaml + + +def load_metamodel_yaml(path: Path) -> Mapping[str, Any]: + """Load a metamodel YAML file, defaulting malformed roots to empty data.""" + yaml = ruamel.yaml.YAML() + yaml.preserve_quotes = True + with path.open(encoding="utf-8") as fh: + loaded = yaml.load(fh) + return cast(Mapping[str, Any], loaded if isinstance(loaded, Mapping) else {}) + + +def _as_mapping(value: Any) -> Mapping[str, Any]: + """Return *value* as a mapping or an empty mapping for missing sections.""" + if isinstance(value, Mapping): + return cast(Mapping[str, Any], value) + return {} + + +def _as_string_mapping(value: Any) -> dict[str, str]: + """Convert a YAML mapping into a string mapping.""" + mapping = cast(Mapping[object, object], _as_mapping(value)) + return {str(key): str(item) for key, item in mapping.items()} + + +def _as_string_list(value: Any) -> list[str]: + """Convert a YAML sequence into a list of strings.""" + if isinstance(value, Sequence) and not isinstance(value, str | bytes): + sequence = cast(Sequence[object], value) + return [str(item) for item in sequence] + return [] + + +def _split_targets(targets: Any) -> tuple[list[str], bool]: + """Split comma-separated targets and preserve the ``ANY`` wildcard.""" + target_text = str(targets) + if target_text == "ANY": + return [], True + return ( + [target.strip() for target in target_text.split(",") if target.strip()], + False, + ) + + +def _to_plain(value: Any) -> Any: + """Copy YAML containers into ordinary JSON-compatible containers.""" + if isinstance(value, Mapping): + mapping = cast(Mapping[object, Any], value) + return {str(key): _to_plain(item) for key, item in mapping.items()} + if isinstance(value, Sequence) and not isinstance(value, str | bytes): + sequence = cast(Sequence[Any], value) + return [_to_plain(item) for item in sequence] + return value + + +def _base_options(data: Mapping[str, Any]) -> dict[str, list[dict[str, str]]]: + base = _as_mapping(data.get("needs_types_base_options")) + result: dict[str, list[dict[str, str]]] = {} + for output_name, source_name in ( + ("mandatory", "mandatory_options"), + ("optional", "optional_options"), + ): + result[output_name] = [ + {"name": name, "pattern": pattern} + for name, pattern in sorted( + _as_string_mapping(base.get(source_name)).items() + ) + ] + return result + + +def _type_options( + raw_type: Mapping[str, Any], + base: Mapping[str, list[dict[str, str]]], +) -> list[dict[str, Any]]: + options: dict[str, dict[str, Any]] = {} + for required, section in ( + (True, "mandatory"), + (False, "optional"), + ): + for item in base.get(section, []): + options[item["name"]] = { + "name": item["name"], + "pattern": item["pattern"], + "required": required, + "inherited": True, + } + + # Type-level options override inherited options, including their required + # status. Mandatory options are applied last if a malformed type repeats a + # name in both type-level sections. + for required, source_name in ( + (False, "optional_options"), + (True, "mandatory_options"), + ): + for name, pattern in sorted( + _as_string_mapping(raw_type.get(source_name)).items() + ): + options[name] = { + "name": name, + "pattern": pattern, + "required": required, + "inherited": False, + } + return [options[name] for name in sorted(options)] + + +def _type_links(raw_type: Mapping[str, Any]) -> list[dict[str, Any]]: + links: list[dict[str, Any]] = [] + for required, source_name in ( + (True, "mandatory_links"), + (False, "optional_links"), + ): + for name, raw_targets in sorted( + _as_string_mapping(raw_type.get(source_name)).items() + ): + targets, any_target = _split_targets(raw_targets) + links.append( + { + "name": name, + "targets": targets, + "any_target": any_target, + "required": required, + } + ) + return sorted(links, key=lambda item: item["name"]) + + +def _need_types( + data: Mapping[str, Any], + base: Mapping[str, list[dict[str, str]]], +) -> list[dict[str, Any]]: + raw_types = _as_mapping(data.get("needs_types")) + result: list[dict[str, Any]] = [] + for name, raw_value in sorted(raw_types.items(), key=lambda item: str(item[0])): + raw_type = _as_mapping(raw_value) + result.append( + { + "name": str(name), + "title": raw_type.get("title"), + "prefix": raw_type.get("prefix"), + "tags": _as_string_list(raw_type.get("tags")), + "parts": raw_type.get("parts"), + "options": _type_options(raw_type, base), + "links": _type_links(raw_type), + } + ) + return result + + +def _prohibited_words(data: Mapping[str, Any]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + checks = _as_mapping(data.get("prohibited_words_checks")) + for check, raw_value in checks.items(): + raw_check = _as_mapping(raw_value) + applies_to_tags = _as_string_list(raw_check.get("types")) + for option, words in raw_check.items(): + if option == "types": + continue + result.append( + { + "check": str(check), + "option": str(option), + "applies_to_tags": applies_to_tags, + "words": _as_string_list(words), + } + ) + return sorted(result, key=lambda item: (item["check"], item["option"])) + + +def _link_types(data: Mapping[str, Any]) -> list[dict[str, str | bool | None]]: + declared = _as_mapping(data.get("needs_extra_links")) + result: dict[str, dict[str, str | bool | None]] = {} + for name, raw_value in declared.items(): + raw_link = _as_mapping(raw_value) + link_name = str(name) + result[link_name] = { + "name": link_name, + "outgoing": (str(raw_link["outgoing"]) if "outgoing" in raw_link else None), + "incoming": (str(raw_link["incoming"]) if "incoming" in raw_link else None), + "declared": True, + } + + # ``links`` is a built-in Sphinx-Needs wildcard used by the metamodel but + # is not declared in ``needs_extra_links``. Retain it in the projection, + # marked as undeclared, so consumers can distinguish it from a declaration. + for raw_type in _as_mapping(data.get("needs_types")).values(): + for section in ("mandatory_links", "optional_links"): + for name in _as_mapping(raw_type).get(section, {}): + link_name = str(name) + result.setdefault( + link_name, + { + "name": link_name, + "outgoing": None, + "incoming": None, + "declared": False, + }, + ) + return sorted(result.values(), key=lambda item: item["name"] or "") + + +def _graph_rules(data: Mapping[str, Any]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for name, raw_value in _as_mapping(data.get("graph_checks")).items(): + raw_rule = _as_mapping(raw_value) + raw_needs = _as_mapping(raw_rule.get("needs")) + result.append( + { + "name": str(name), + "applies_to": _split_targets(raw_needs.get("include", ""))[0], + "condition_raw": _to_plain(raw_needs.get("condition")), + "check_raw": _to_plain(raw_rule.get("check")), + "explanation": raw_rule.get("explanation"), + } + ) + return sorted(result, key=lambda item: item["name"]) + + +def build_projection(data: Mapping[str, Any], digest: str) -> dict[str, Any]: + """Build the ordered JSON projection from parsed YAML and its digest.""" + base = _base_options(data) + return { + "schema_version": 1, + "metamodel_digest": f"sha256:{digest}", + "base_options": base, + "prohibited_words": _prohibited_words(data), + "link_types": _link_types(data), + "need_types": _need_types(data, base), + "graph_rules": _graph_rules(data), + } + + +def render_projection(projection: Mapping[str, Any]) -> str: + """Serialize a projection deterministically with a trailing newline.""" + return json.dumps(projection, indent=2, ensure_ascii=False) + "\n" + + +def resolve_metamodel_path( + argument: Path | None, + *, + workspace: str | None = None, +) -> Path: + """Resolve the metamodel input path for both direct and Bazel invocations. + + ``bazel run`` executes inside the runfiles tree, so a relative argument is + resolved against the workspace directory Bazel exports. Without an + argument the metamodel packaged next to this generator is used, which + covers runfiles as well as source-tree execution. + """ + default_relative = Path("src/extensions/score_metamodel/metamodel.yaml") + if argument is None: + packaged = Path(__file__).resolve().parents[1] / "metamodel.yaml" + if packaged.is_file() or workspace is None: + return packaged + return Path(workspace) / default_relative + if argument.is_absolute() or argument.is_file() or workspace is None: + return argument + return Path(workspace) / argument + + +def _argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Generate JSON agent context from metamodel.yaml" + ) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("metamodel", type=Path, nargs="?", default=None) + return parser + + +def main() -> int: + args = _argument_parser().parse_args() + meta_path = resolve_metamodel_path( + args.metamodel, + workspace=os.environ.get("BUILD_WORKSPACE_DIRECTORY"), + ) + if not meta_path.is_file(): + print(f"Error: metamodel.yaml not found at {meta_path}", file=sys.stderr) + return 1 + + raw_bytes = meta_path.read_bytes() + try: + data = load_metamodel_yaml(meta_path) + except Exception as exc: + print(f"Error parsing YAML: {exc}", file=sys.stderr) + return 1 + + projection = build_projection( + data, + hashlib.sha256(raw_bytes).hexdigest(), + ) + args.output.write_text(render_projection(projection), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/extensions/score_metamodel/agent_context/tests/model/nested_model.yaml b/src/extensions/score_metamodel/agent_context/tests/model/nested_model.yaml new file mode 100644 index 000000000..9b11a0bb0 --- /dev/null +++ b/src/extensions/score_metamodel/agent_context/tests/model/nested_model.yaml @@ -0,0 +1,64 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +needs_types_base_options: + mandatory_options: + version: "^[0-9]+$" + optional_options: + shared: "^shared$" + +prohibited_words_checks: + title_check: + title: + - shall + - must + +needs_types: + sample: + title: Sample + prefix: sample__ + tags: + - requirement + parts: 2 + mandatory_options: + own: "^own$" + optional_options: + shared: "^override$" + mandatory_links: + required_link: first, second + optional_links: + any_link: ANY + +needs_extra_links: + any_link: + incoming: incoming any + outgoing: outgoing any + required_link: + incoming: incoming required + outgoing: outgoing required + +graph_checks: + nested_rule: + needs: + include: sample + condition: + and: + - status == valid + - or: + - safety == QM + - safety == ASIL_B + check: + required_link: + or: + - status == valid + - status == draft + explanation: Nested conditions remain raw data. diff --git a/src/extensions/score_metamodel/agent_context/tests/model/precedence_model.yaml b/src/extensions/score_metamodel/agent_context/tests/model/precedence_model.yaml new file mode 100644 index 000000000..0e49d04ad --- /dev/null +++ b/src/extensions/score_metamodel/agent_context/tests/model/precedence_model.yaml @@ -0,0 +1,22 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +needs_types_base_options: + mandatory_options: + version: "^[0-9]+$" + optional_options: + shared: "^base$" + +needs_types: + sample: + optional_options: + shared: "^type$" diff --git a/src/extensions/score_metamodel/agent_context/tests/model/simple_expected.json b/src/extensions/score_metamodel/agent_context/tests/model/simple_expected.json new file mode 100644 index 000000000..74d361381 --- /dev/null +++ b/src/extensions/score_metamodel/agent_context/tests/model/simple_expected.json @@ -0,0 +1,121 @@ +{ + "schema_version": 1, + "metamodel_digest": "sha256:6d6cca74c544bef3b157c6147f922eb3691ae83a1c63ca8c086a7bbed79dd3d0", + "base_options": { + "mandatory": [], + "optional": [ + { + "name": "global_opt", + "pattern": "global_value" + } + ] + }, + "prohibited_words": [ + { + "check": "content_check", + "option": "content", + "applies_to_tags": [ + "req_type" + ], + "words": [ + "weak_word1" + ] + }, + { + "check": "title_check", + "option": "title", + "applies_to_tags": [], + "words": [ + "stop_word1" + ] + } + ], + "link_types": [ + { + "name": "link1", + "outgoing": "outgoing_link1", + "incoming": "incoming_link1", + "declared": true + }, + { + "name": "link2", + "outgoing": "outgoing_link2", + "incoming": "incoming_link2", + "declared": true + }, + { + "name": "link_option1", + "outgoing": "outgoing1", + "incoming": "incoming1", + "declared": true + } + ], + "need_types": [ + { + "name": "type1", + "title": "Type 1", + "prefix": "T1", + "tags": [ + "req_type" + ], + "parts": null, + "options": [ + { + "name": "global_opt", + "pattern": "global_value", + "required": false, + "inherited": true + }, + { + "name": "opt1", + "pattern": "value1", + "required": true, + "inherited": false + }, + { + "name": "opt2", + "pattern": "value2", + "required": false, + "inherited": false + }, + { + "name": "opt3", + "pattern": "value3", + "required": false, + "inherited": false + } + ], + "links": [ + { + "name": "link1", + "targets": [ + "value1" + ], + "any_target": false, + "required": true + }, + { + "name": "link2", + "targets": [ + "value2" + ], + "any_target": false, + "required": false + } + ] + } + ], + "graph_rules": [ + { + "name": "needs_graph_check", + "applies_to": [ + "type1" + ], + "condition_raw": "opt1 == test", + "check_raw": { + "link1": "opt1 == test" + }, + "explanation": null + } + ] +} diff --git a/src/extensions/score_metamodel/agent_context/tests/model/simple_model.yaml b/src/extensions/score_metamodel/agent_context/tests/model/simple_model.yaml new file mode 100644 index 000000000..7e256da79 --- /dev/null +++ b/src/extensions/score_metamodel/agent_context/tests/model/simple_model.yaml @@ -0,0 +1,64 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +needs_types_base_options: + optional_options: + global_opt: "global_value" + +prohibited_words_checks: + title_check: + title: + - stop_word1 + content_check: + types: + - req_type + content: + - weak_word1 + +needs_types: + type1: + title: "Type 1" + prefix: "T1" + color: "blue" + style: "bold" + mandatory_options: + opt1: "value1" + optional_options: + opt2: "value2" + opt3: "value3" + mandatory_links: + link1: "value1" + optional_links: + link2: "value2" + tags: + - req_type + +needs_extra_links: + link_option1: + incoming: "incoming1" + outgoing: "outgoing1" + # Declared because type1 uses them; undeclared links are rejected while + # parsing, see _validate_link_declarations. + link1: + incoming: "incoming_link1" + outgoing: "outgoing_link1" + link2: + incoming: "incoming_link2" + outgoing: "outgoing_link2" + +graph_checks: + needs_graph_check: + needs: + include: type1 + condition: opt1 == test + check: + link1: opt1 == test diff --git a/src/extensions/score_metamodel/agent_context/tests/test_generate_agent_context.py b/src/extensions/score_metamodel/agent_context/tests/test_generate_agent_context.py new file mode 100644 index 000000000..d00b130bd --- /dev/null +++ b/src/extensions/score_metamodel/agent_context/tests/test_generate_agent_context.py @@ -0,0 +1,214 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +from typing import Any + +from generate_agent_context import ( + build_projection, + load_metamodel_yaml, + render_projection, + resolve_metamodel_path, +) + + +def _runfiles_data_path(relative: Path) -> Path: + for variable in ("TEST_SRCDIR", "RUNFILES_DIR"): + runfiles_dir = os.environ.get(variable) + if runfiles_dir: + candidate = Path(runfiles_dir) / "_main" / relative + if candidate.exists(): + return candidate + for ancestor in Path(__file__).absolute().parents: + if (ancestor / "MODULE.bazel").is_file(): + return ancestor / relative + return relative + + +MODEL_DIR = _runfiles_data_path( + Path("src/extensions/score_metamodel/agent_context/tests/model") +) +SIMPLE_MODEL_PATH = MODEL_DIR / "simple_model.yaml" +METAMODEL_PATH = _runfiles_data_path( + Path("src/extensions/score_metamodel/metamodel.yaml") +) + + +def _projection(path: Path) -> dict[str, Any]: + raw_bytes = path.read_bytes() + return build_projection( + load_metamodel_yaml(path), hashlib.sha256(raw_bytes).hexdigest() + ) + + +def test_relative_argument_resolves_against_workspace(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + argument = Path("__agent_context_workspace_fixture__/metamodel.yaml") + + assert not argument.is_file() + assert resolve_metamodel_path(argument, workspace=str(workspace)) == ( + workspace / argument + ) + + +def test_absolute_argument_is_returned_unchanged(tmp_path: Path) -> None: + argument = tmp_path / "metamodel.yaml" + + assert resolve_metamodel_path(argument, workspace=str(tmp_path / "workspace")) == ( + argument + ) + + +def test_existing_relative_argument_is_returned_unchanged() -> None: + argument = Path("src/extensions/score_metamodel/metamodel.yaml") + + assert argument.is_file() + assert resolve_metamodel_path(argument, workspace="/not-used") == argument + + +def test_relative_argument_without_workspace_is_returned_unchanged() -> None: + argument = Path("missing/metamodel.yaml") + + assert resolve_metamodel_path(argument, workspace=None) == argument + + +def test_default_path_is_packaged_metamodel() -> None: + path = resolve_metamodel_path(None) + + assert path.is_file() + assert path.name == "metamodel.yaml" + assert path.parent.name == "score_metamodel" + assert path.parent.parent.name == "extensions" + assert path.parent.parent.parent.name == "src" + + +def test_small_fixture_matches_complete_golden_json() -> None: + fixture = SIMPLE_MODEL_PATH + expected = (MODEL_DIR / "simple_expected.json").read_text(encoding="utf-8") + + assert render_projection(_projection(fixture)) == expected + + +def test_real_metamodel_has_complete_structural_projection() -> None: + source = load_metamodel_yaml(METAMODEL_PATH) + projection = _projection(METAMODEL_PATH) + source_types = set(source["needs_types"]) + generated_types = projection["need_types"] + + assert len(generated_types) == len(source_types) + assert [item["name"] for item in generated_types] == sorted(source_types) + + for need_type in generated_types: + options = {option["name"]: option for option in need_type["options"]} + assert options["version"] == { + "name": "version", + "pattern": "^[0-9]+$", + "required": True, + "inherited": True, + } + for option_name in ("source_code_link", "testlink"): + assert options[option_name]["required"] is False + assert options[option_name]["inherited"] is True + + by_name = {item["name"]: item for item in generated_types} + comp_links = {link["name"]: link for link in by_name["comp_req"]["links"]} + assert comp_links["satisfied_by"] == { + "name": "satisfied_by", + "targets": ["comp"], + "any_target": False, + "required": True, + } + dec_links = {link["name"]: link for link in by_name["dec_rec"]["links"]} + assert dec_links["affects"]["any_target"] is True + testcase_links = {link["name"]: link for link in by_name["testcase"]["links"]} + assert testcase_links["fully_verifies"]["required"] is False + assert testcase_links["partially_verifies"]["required"] is False + + undeclared_link_names = { + link["name"] for link in projection["link_types"] if not link["declared"] + } + assert undeclared_link_names == {"links"} + for link in projection["link_types"]: + if link["declared"]: + assert link["outgoing"] is not None + assert link["incoming"] is not None + + known_types = {item["name"] for item in generated_types} + graph_rules = projection["graph_rules"] + assert len(graph_rules) == 5 + assert {rule["name"] for rule in graph_rules} == set(source["graph_checks"]) + assert all(rule["applies_to"] for rule in graph_rules) + for rule in graph_rules: + assert set(rule["applies_to"]) <= known_types + + +def test_projection_is_deterministic() -> None: + fixture = MODEL_DIR / "nested_model.yaml" + first = render_projection(_projection(fixture)).encode() + second = render_projection(_projection(fixture)).encode() + + assert first == second + + +def test_digest_changes_with_input_bytes_and_repeats_for_identical_input( + tmp_path: Path, +) -> None: + source = SIMPLE_MODEL_PATH + original = source.read_bytes() + first_path = tmp_path / "first.yaml" + second_path = tmp_path / "second.yaml" + first_path.write_bytes(original) + second_path.write_bytes(original) + + first = _projection(first_path)["metamodel_digest"] + second = _projection(second_path)["metamodel_digest"] + changed_path = tmp_path / "changed.yaml" + changed_path.write_bytes(original + b"\n") + changed = _projection(changed_path)["metamodel_digest"] + + assert first == second + assert changed != first + + +def test_nested_graph_rules_round_trip_without_interpretation() -> None: + projection = _projection(MODEL_DIR / "nested_model.yaml") + rule = projection["graph_rules"][0] + + assert rule["condition_raw"] == { + "and": [ + "status == valid", + {"or": ["safety == QM", "safety == ASIL_B"]}, + ] + } + assert rule["check_raw"] == { + "required_link": { + "or": ["status == valid", "status == draft"], + } + } + + +def test_type_options_override_inherited_base_options() -> None: + projection = _projection(MODEL_DIR / "precedence_model.yaml") + options = { + option["name"]: option for option in projection["need_types"][0]["options"] + } + + assert options["shared"] == { + "name": "shared", + "pattern": "^type$", + "required": False, + "inherited": False, + }