Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/archsight/mcp/analyze_resource_tool.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def build_resource_analysis(db, instance, kind, name, depth)
name: name,
spec: instance.spec,
annotations: instance.annotations
}
}.merge(Archsight::MCP.source_info(instance))

if depth.positive?
# Include dependency trees
Expand All @@ -101,6 +101,7 @@ def build_resource_analysis(db, instance, kind, name, depth)
else
# Just direct relations
result[:relations] = Archsight::MCP.extract_relations(instance)
result[:related] = Archsight::MCP.related_sources(instance)
result[:has_relations] = instance.has_relations?
end

Expand Down
37 changes: 32 additions & 5 deletions lib/archsight/mcp/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ module Archsight::MCP
class << self
attr_accessor :db

def complete_summary(resource, omit_kind: false)
def complete_summary(resource, omit_kind: false, with_source: false)
result = {
name: resource.name,
metadata: {
Expand All @@ -18,13 +18,40 @@ def complete_summary(resource, omit_kind: false)
spec: resource.spec
}
result[:kind] = resource.class.to_s.split("::").last unless omit_kind
result
with_source ? result.merge(source_info(resource)) : result
end

def brief_summary(resource, omit_kind: false)
def brief_summary(resource, omit_kind: false, with_source: false)
result = { name: resource.name }
result[:kind] = resource.class.to_s.split("::").last unless omit_kind
result
result[:kind] = kind_of(resource) unless omit_kind
with_source ? result.merge(source_info(resource)) : result
end

# Where the resource is defined: `source` ("path:line", relative to the resources root), `generated`
# (true = written by an importer, do not edit by hand) and, when several documents were merged into the
# resource, `sources` (every contributing file, hand-written ones included).
def source_info(resource)
refs = resource.source_refs
info = { source: format_ref(refs.first), generated: resource.generated? }
info[:sources] = refs.map { |ref| format_ref(ref) } if refs.length > 1
info
end

# `kind`, `name`, `source` and `generated` of each direct relation target, as [{kind:, name:, source:, generated:}]
def related_sources(instance)
targets = instance.class.relations.flat_map do |verb, kind_name, _|
instance.relations(verb, kind_name)
end
targets.uniq.map { |t| { kind: kind_of(t), name: t.name }.merge(source_info(t)) }
end

def format_ref(ref)
return nil unless ref

root = db.respond_to?(:path) ? db.path.to_s : ""
path = ref.path.to_s
path = path.delete_prefix(File.join(File.expand_path(root), "")) unless root.empty?
"#{path}:#{ref.line_no}"
end

def annotations_summary(resource, omit_kind: false)
Expand Down
7 changes: 4 additions & 3 deletions lib/archsight/mcp/query_tool.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ def call(query:, output: "complete", limit: 50, offset: 0)
results = parsed_query.filter(db)
total = results.length

# Omit kind from output when filtering by a specific kind (it's redundant)
# Complete output omits the kind when filtering by a specific kind (it's redundant); brief always names
# it, so a hit can feed analyze_resource(kind, name) without guessing
omit_kind = !parsed_query.kind_filter.nil?

# Count-only mode: return totals grouped by kind
Expand All @@ -86,9 +87,9 @@ def call(query:, output: "complete", limit: 50, offset: 0)
paginated = results.sort_by(&:name).drop(offset).take(limit)
resources = case output
when "brief"
paginated.map { |r| Archsight::MCP.brief_summary(r, omit_kind: omit_kind) }
paginated.map { |r| Archsight::MCP.brief_summary(r, with_source: true) }
else # "complete"
paginated.map { |r| Archsight::MCP.complete_summary(r, omit_kind: omit_kind) }
paginated.map { |r| Archsight::MCP.complete_summary(r, omit_kind: omit_kind, with_source: true) }
end

result = {
Expand Down
12 changes: 12 additions & 0 deletions lib/archsight/resources/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ def self.discovered_annotations
def initialize(raw, path_ref)
@raw = raw
@path_ref = path_ref
@merged_refs = [] # : Array[LineReference]
@references = []
# Auto-discover annotation keys
return unless annotations
Expand Down Expand Up @@ -372,6 +373,17 @@ def merge!(inst)
merged = Archsight::Helpers.deep_merge(@raw, inst.raw)
union_list_annotations!(merged, inst.raw)
@raw = merged
@merged_refs << inst.path_ref
end

# Every document this resource is made of (the first one, then the ones merged into it), as LineReferences
def source_refs
[path_ref, *@merged_refs].compact.uniq { |ref| [ref.path, ref.line_no] }
end

# True when an importer or generator wrote (part of) the resource: it carries a `generated/script` annotation
def generated?
!annotations["generated/script"].to_s.empty?
end

# Annotations hold strings, which replace each other on merge. The values of a list annotation (tags, aliases)
Expand Down
4 changes: 4 additions & 0 deletions sig/archsight/resources.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ module Archsight
def self.const_get: (String | Symbol) -> singleton(Base)

class Base
ANNOTATION_OPTIONS: Array[Symbol]

attr_accessor raw: Hash[String, untyped]
attr_accessor path_ref: LineReference
attr_accessor references: Array[Hash[Symbol, untyped]]
Expand Down Expand Up @@ -70,6 +72,8 @@ module Archsight
def relations_grouped: () -> Hash[String, Hash[String, Array[Base]]]
def verify!: () -> void
def merge!: (Base) -> Hash[String, untyped]
def source_refs: () -> Array[LineReference]
def generated?: () -> bool
def union_list_annotations!: (Hash[String, untyped], Hash[String, untyped]) -> void
def split_list: (String) -> Array[String]

Expand Down
29 changes: 29 additions & 0 deletions test/mcp/tools_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,35 @@ def test_query_tool_kind_filter
refute result["resources"].first.key?("kind"), "kind should be omitted when filtering by specific kind"
end

def test_query_tool_brief_has_kind_source_and_generated_with_kind_prefix
result = call_tool(Archsight::MCP::QueryTool, query: "TechnologyArtifact: repo-active", output: "brief")
hit = result["resources"].first

assert_equal "TechnologyArtifact", hit["kind"]
assert_equal "test:0", hit["source"]
refute hit["generated"]
refute hit.key?("sources")
end

def test_query_tool_marks_generated_and_merged_sources
inst = @db.add_instance("TechnologyArtifact", "gen", { "generated/script" => "import-x" })
other = Archsight::Resources["TechnologyArtifact"].new(inst.raw.dup, Archsight::LineReference.new("hand.yaml", 7))
inst.merge!(other)
result = call_tool(Archsight::MCP::QueryTool, query: "name == \"gen\"", output: "complete")
hit = result["resources"].first

assert hit["generated"]
assert_equal ["test:0", "hand.yaml:7"], hit["sources"]
end

def test_analyze_resource_shows_source_of_target_and_relations
result = call_tool(Archsight::MCP::AnalyzeResourceTool, kind: "ApplicationComponent", name: "MyService")

assert_equal "test:0", result["source"]
assert_includes result["related"].map { |r| r["name"] }, "repo-active"
assert(result["related"].all? { |r| r.key?("source") && r.key?("generated") })
end

def test_query_tool_brief_output
result = call_tool(Archsight::MCP::QueryTool, query: "repo-active", output: "brief")

Expand Down
Loading