diff --git a/spec/schemas/config_schema.json b/spec/schemas/config_schema.json index abdc8dbac1..7aa62d7bee 100644 --- a/spec/schemas/config_schema.json +++ b/spec/schemas/config_schema.json @@ -44,6 +44,18 @@ "format": "uri", "description": "Path to original file, when this is a copy" }, + "compatible": { + "description": "Declares that this configuration is compatible with one or more other configurations — meaning there exists at least one concrete hardware implementation that satisfies both this config and each listed config simultaneously. Each pointer is either the name of a config in the repository cfgs/ directory or a path to a config YAML file (absolute, or relative to this file). Compatibility is checked transitively: if config B lists config C as compatible, then this config must also be compatible with C.", + "examples": [ + "profile/RVA23U64", + ["profile/RVA23U64", "profile/RVB23U64"], + "../other/my_config.yaml" + ], + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, "params": { "type": "object" }, @@ -111,6 +123,18 @@ "format": "uri", "description": "Path to original file, when this is a copy" }, + "compatible": { + "description": "Declares that this configuration is compatible with one or more other configurations — meaning there exists at least one concrete hardware implementation that satisfies both this config and each listed config simultaneously. Each pointer is either the name of a config in the repository cfgs/ directory or a path to a config YAML file (absolute, or relative to this file). Compatibility is checked transitively: if config B lists config C as compatible, then this config must also be compatible with C.", + "examples": [ + "profile/RVA23U64", + ["profile/RVA23U64", "profile/RVB23U64"], + "../other/my_config.yaml" + ], + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, "mandatory_extensions": { "description": "Extensions mandatory in this architecture", "type": "array", diff --git a/tools/ruby-gems/udb/lib/udb/cfg_arch.rb b/tools/ruby-gems/udb/lib/udb/cfg_arch.rb index d590999334..f2fce9ab6e 100644 --- a/tools/ruby-gems/udb/lib/udb/cfg_arch.rb +++ b/tools/ruby-gems/udb/lib/udb/cfg_arch.rb @@ -336,6 +336,8 @@ def full_config_valid? reasons += missing_params.map { |p| "Parameter is required but missing: '#{p.name}'" } end + validate_compatible(reasons) + if reasons.empty? raise "bad validity check" unless to_condition.satisfiable? ValidationResult.new(valid: true, reasons:) @@ -466,6 +468,8 @@ def partial_config_valid? end end + validate_compatible(reasons) + if reasons.empty? raise "Bad validation" unless to_condition.satisfiable? return ValidationResult.new(valid: true, reasons: []) @@ -1722,5 +1726,58 @@ def convert_monospace_to_links(adoc) end end + sig { params(pointer: String).returns(ConfiguredArchitecture) } + def resolve_compatible_pointer(pointer) + @config.info.resolver.cfg_arch_for_pointer( + pointer, + relative_dir: Pathname.new(@config.info.path).dirname + ) + end + private :resolve_compatible_pointer + + sig { params(other: ConfiguredArchitecture, reasons: T::Array[String], visited: T::Set[String]).void } + def check_compatible_with(other, reasons, visited) + return if visited.include?(other.name) + visited.add(other.name) + + combined = to_condition & other.to_condition + unless combined.satisfiable_by_cfg_arch?(self) + combined.to_logic_tree(expand: true).minimal_unsat_subsets.each do |min| + reasons << "Config '#{name}' is not compatible with '#{other.name}': not satisfiable: #{min.to_s(format: LogicNode::LogicSymbolFormat::C)}" + end + end + + # Resolve transitive pointers relative to `other`'s directory, not self's. + Array(other.config.compatible).each do |pointer| + begin + trans = other.send(:resolve_compatible_pointer, pointer) + check_compatible_with(trans, reasons, visited) + rescue => e + reasons << "Cannot resolve transitive compatible pointer '#{pointer}': #{e.message}" + end + end + end + private :check_compatible_with + + sig { params(reasons: T::Array[String]).void } + def validate_compatible(reasons) + return if @config.compatible.nil? + + # Use dup per top-level pointer so sibling pointers each get a fresh visited set — + # each branch independently validates against any shared transitive targets. Cycle + # detection within a single chain is still enforced because visited mutates in-place + # during the recursive descent. + visited = T.let(Set.new([name]), T::Set[String]) + Array(@config.compatible).each do |pointer| + begin + other = resolve_compatible_pointer(pointer) + check_compatible_with(other, reasons, visited.dup) + rescue => e + reasons << "Cannot resolve compatible pointer '#{pointer}': #{e.message}" + end + end + end + private :validate_compatible + end end diff --git a/tools/ruby-gems/udb/lib/udb/config.rb b/tools/ruby-gems/udb/lib/udb/config.rb index 169f2ca740..7fc7256d23 100644 --- a/tools/ruby-gems/udb/lib/udb/config.rb +++ b/tools/ruby-gems/udb/lib/udb/config.rb @@ -63,6 +63,9 @@ def arch_overlay_abs sig { returns(String) } def description = @data["description"] + sig { returns(T.nilable(T.any(String, T::Array[String]))) } + def compatible = @data["compatible"] + sig { abstract.returns(T.nilable(Integer)) } def mxlen; end @@ -359,5 +362,6 @@ def implemented_extensions end end end + end end diff --git a/tools/ruby-gems/udb/lib/udb/resolver.rb b/tools/ruby-gems/udb/lib/udb/resolver.rb index d60cf70f88..c25e4a8bba 100644 --- a/tools/ruby-gems/udb/lib/udb/resolver.rb +++ b/tools/ruby-gems/udb/lib/udb/resolver.rb @@ -15,6 +15,9 @@ module Udb # resolves the specification in the context of a config, and writes to a generation folder # + # Raised by Resolver#cfg_info when a config name or path cannot be found. + class ConfigNotFoundError < StandardError; end + # The primary interface for users will be #cfg_arch_for class Resolver extend T::Sig @@ -301,6 +304,33 @@ def cfg_info(config_path_or_name) end end + # Resolve a config pointer (name or file path) to a ConfiguredArchitecture. + # Resolution order: + # 1. If /.yaml exists on disk, treat as a repo config name + # (handles names that contain '/', e.g. "profile/RVA23U64"). + # 2. If the pointer ends in .yaml/.yml, is absolute, or starts with ./ or ../, + # treat as a file path resolved relative to +relative_dir+. + # 3. Otherwise raise ArgumentError — the pointer is neither a known config name + # nor a recognisable file path. + sig { params(pointer: String, relative_dir: Pathname).returns(Udb::ConfiguredArchitecture) } + def cfg_arch_for_pointer(pointer, relative_dir:) + if (@cfgs_path / "#{pointer}.yaml").file? + # Repo config name — may contain '/' for nested configs (e.g. "profile/RVA23U64"). + cfg_arch_for(pointer) + elsif pointer.end_with?(".yaml", ".yml") || + Pathname.new(pointer).absolute? || + pointer.start_with?("./", "../") + # Explicit file path — resolve relative to the caller's directory. + path = Pathname.new(pointer) + cfg_arch_for(path.absolute? ? path : (relative_dir / path).cleanpath) + else + raise ArgumentError, + "Cannot resolve config pointer '#{pointer}': not a known config name " \ + "under '#{@cfgs_path}' and not a recognisable file path " \ + "(.yaml/.yml extension, absolute, or starting with ./ or ../)" + end + end + # resolve the specification for a config, and return a ConfiguredArchitecture sig { params(config_path_or_name: T.any(Pathname, String)).returns(Udb::ConfiguredArchitecture) } def cfg_arch_for(config_path_or_name) diff --git a/tools/ruby-gems/udb/test/test_cfg_arch.rb b/tools/ruby-gems/udb/test/test_cfg_arch.rb index 81c6b10f33..8c98612837 100644 --- a/tools/ruby-gems/udb/test/test_cfg_arch.rb +++ b/tools/ruby-gems/udb/test/test_cfg_arch.rb @@ -555,4 +555,206 @@ def test_partial_config_parameter_defined_by_unknown_terms assert param_reasons.first.include?("{unknown}"), "Expected {unknown} annotations for possible-but-not-mandatory extensions" end end + + def test_compatible + base_64_yaml = <<~YAML + $schema: config_schema.json# + kind: architecture configuration + type: partially configured + name: compat_test_base_64 + description: Base 64-bit config for compatibility tests + params: + MXLEN: 64 + mandatory_extensions: + - name: "I" + version: ">= 0" + - name: "Sm" + version: ">= 0" + YAML + + compat_64_yaml = <<~YAML + $schema: config_schema.json# + kind: architecture configuration + type: partially configured + name: compat_test_compat_64 + description: Compatible 64-bit config + params: + MXLEN: 64 + mandatory_extensions: + - name: "I" + version: ">= 0" + - name: "Sm" + version: ">= 0" + - name: "M" + version: ">= 0" + YAML + + incompat_32_yaml = <<~YAML + $schema: config_schema.json# + kind: architecture configuration + type: partially configured + name: compat_test_incompat_32 + description: Incompatible 32-bit config (MXLEN conflict) + params: + MXLEN: 32 + mandatory_extensions: + - name: "I" + version: ">= 0" + - name: "Sm" + version: ">= 0" + YAML + + also_compat_64_yaml = <<~YAML + $schema: config_schema.json# + kind: architecture configuration + type: partially configured + name: compat_test_also_compat_64 + description: Another compatible 64-bit config + params: + MXLEN: 64 + mandatory_extensions: + - name: "I" + version: ">= 0" + - name: "Sm" + version: ">= 0" + YAML + + Tempfile.create(%w/compat_64 .yaml/) do |compat_64_file| + compat_64_file.write(compat_64_yaml) + compat_64_file.flush + + Tempfile.create(%w/incompat_32 .yaml/) do |incompat_32_file| + incompat_32_file.write(incompat_32_yaml) + incompat_32_file.flush + + Tempfile.create(%w/also_compat_64 .yaml/) do |also_compat_64_file| + also_compat_64_file.write(also_compat_64_yaml) + also_compat_64_file.flush + + # 1. Valid single compatible pointer + Tempfile.create(%w/cfg .yaml/) do |f| + f.write(base_64_yaml.sub("mandatory_extensions:", "compatible: #{compat_64_file.path}\nmandatory_extensions:")) + f.flush + result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid? + assert result.valid, "Expected valid for compatible with matching MXLEN, got: #{result.reasons}" + end + + # Name-based pointer: use a known repo config name (no '/' → name lookup branch) + Tempfile.create(%w/cfg .yaml/) do |f| + f.write(base_64_yaml.sub("mandatory_extensions:", "compatible: rv64\nmandatory_extensions:")) + f.flush + result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid? + # rv64 is a known repo config; compatibility check should not raise + assert [true, false].include?(result.valid), + "Expected cfg_arch_for_pointer to resolve name-based pointer 'rv64' without error" + end + + # Relative-path pointer: reference the compatible config via a relative path + Dir.mktmpdir do |dir| + dir_path = Pathname.new(dir) + rel_compat_path = dir_path / "compat_64.yaml" + rel_compat_path.write(compat_64_yaml) + + cfg_path = dir_path / "cfg.yaml" + cfg_path.write(base_64_yaml.sub("mandatory_extensions:", "compatible: ./compat_64.yaml\nmandatory_extensions:")) + + result = @resolver.cfg_arch_for(cfg_path).valid? + assert result.valid, + "Expected valid when compatible pointer is a relative path './compat_64.yaml', got: #{result.reasons}" + end + + # 2. Invalid single compatible pointer + Tempfile.create(%w/cfg .yaml/) do |f| + f.write(base_64_yaml.sub("mandatory_extensions:", "compatible: #{incompat_32_file.path}\nmandatory_extensions:")) + f.flush + result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid? + refute result.valid, "Expected invalid for compatible with conflicting MXLEN" + assert result.reasons.any? { |r| r.include?("not compatible with") }, + "Expected 'not compatible with' in reasons, got: #{result.reasons}" + end + + # 3. Valid multiple compatible pointers (non-overlapping) + Tempfile.create(%w/cfg .yaml/) do |f| + f.write(base_64_yaml.sub("mandatory_extensions:", "compatible:\n - #{compat_64_file.path}\n - #{also_compat_64_file.path}\nmandatory_extensions:")) + f.flush + result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid? + assert result.valid, "Expected valid for compatible with multiple matching configs, got: #{result.reasons}" + end + + # 4. Invalid multiple compatible pointers (one incompatible) + Tempfile.create(%w/cfg .yaml/) do |f| + f.write(base_64_yaml.sub("mandatory_extensions:", "compatible:\n - #{compat_64_file.path}\n - #{incompat_32_file.path}\nmandatory_extensions:")) + f.flush + result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid? + refute result.valid, "Expected invalid when one of multiple compatible configs conflicts" + assert result.reasons.any? { |r| r.include?("not compatible with") }, + "Expected 'not compatible with' in reasons, got: #{result.reasons}" + end + + # 5. Valid transitive compatible (base_64 -> transitive_b -> compat_64, all satisfiable) + transitive_compat_b_yaml = <<~YAML + $schema: config_schema.json# + kind: architecture configuration + type: partially configured + name: compat_test_transitive_compat_b + description: Transitive-compatible 64-bit config + compatible: #{compat_64_file.path} + params: + MXLEN: 64 + mandatory_extensions: + - name: "I" + version: ">= 0" + - name: "Sm" + version: ">= 0" + - name: "M" + version: ">= 0" + YAML + Tempfile.create(%w/transitive_compat_b .yaml/) do |trans_compat_file| + trans_compat_file.write(transitive_compat_b_yaml) + trans_compat_file.flush + + Tempfile.create(%w/cfg .yaml/) do |f| + f.write(base_64_yaml.sub("mandatory_extensions:", "compatible: #{trans_compat_file.path}\nmandatory_extensions:")) + f.flush + result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid? + assert result.valid, "Expected valid for transitive compatible chain where all are satisfiable, got: #{result.reasons}" + end + end + + # 6. Invalid transitive compatible (base_64 -> transitive_b -> incompat_32, unsatisfiable transitively) + transitive_incompat_b_yaml = <<~YAML + $schema: config_schema.json# + kind: architecture configuration + type: partially configured + name: compat_test_transitive_incompat_b + description: Config that is directly compatible with base_64 but transitively points to an incompatible config + compatible: #{incompat_32_file.path} + params: + MXLEN: 64 + mandatory_extensions: + - name: "I" + version: ">= 0" + - name: "Sm" + version: ">= 0" + - name: "M" + version: ">= 0" + YAML + Tempfile.create(%w/transitive_incompat_b .yaml/) do |trans_incompat_file| + trans_incompat_file.write(transitive_incompat_b_yaml) + trans_incompat_file.flush + + Tempfile.create(%w/cfg .yaml/) do |f| + f.write(base_64_yaml.sub("mandatory_extensions:", "compatible: #{trans_incompat_file.path}\nmandatory_extensions:")) + f.flush + result = @resolver.cfg_arch_for(Pathname.new(f.path)).valid? + refute result.valid, "Expected invalid for transitive chain that leads to an incompatible config" + assert result.reasons.any? { |r| r.include?("not compatible with") }, + "Expected 'not compatible with' in reasons, got: #{result.reasons}" + end + end + + end + end + end + end end