Skip to content

Commit da85a11

Browse files
feat: Enhance annotation handling with multiline support and update f… (#29)
* feat: Enhance annotation handling with multiline support and update form builder for markdown * feat: Implement optimistic locking with content hashing and conflict handling in editor * feat: Refactor Editor module to use module_function for better encapsulation * feat: Update RBS annotations, enhance form builder input type handling, and improve validation error messaging * fix: Add steep ignore comment for block type mismatch in ComputedManager
1 parent edda61e commit da85a11

File tree

19 files changed

+295
-91
lines changed

19 files changed

+295
-91
lines changed

.rubocop.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,7 @@ Lint/RedundantSafeNavigation:
185185
Style/EmptyStringInsideInterpolation:
186186
Exclude:
187187
- "**/*.haml"
188+
189+
# Allow Steep RBS type annotations (#: Type)
190+
Layout/LeadingCommentSpace:
191+
AllowRBSInlineAnnotation: true

Steepfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ target :lib do
1515
check "lib/archsight/annotations/computed.rb"
1616

1717
library "yaml"
18+
library "uri"
1819
end

lib/archsight/annotations/annotation.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def value_for(instance)
8181

8282
# Validate a value and return array of error messages (empty if valid)
8383
def validate(value)
84-
errors = []
84+
errors = [] #: Array[String]
8585
return errors if value.nil?
8686

8787
validate_enum(value, errors)
@@ -104,6 +104,10 @@ def code?
104104
@format == :ruby
105105
end
106106

107+
def multiline?
108+
@format == :multiline
109+
end
110+
107111
def code_language
108112
@format if code?
109113
end

lib/archsight/annotations/computed.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ def compute_for(instance, definition)
212212
@computing.add(cache_key)
213213
begin
214214
evaluator = Archsight::Annotations::ComputedEvaluator.new(instance, @database, self)
215-
value = evaluator.instance_eval(&definition.block)
215+
value = evaluator.instance_eval(&definition.block) # steep:ignore BlockTypeMismatch
216216

217217
# Apply type coercion if specified
218218
value = coerce_value(value, definition.type) if definition.type

lib/archsight/editor.rb

Lines changed: 63 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@
33
require "yaml"
44
require_relative "resources"
55
require_relative "editor/file_writer"
6+
require_relative "editor/content_hasher"
67

78
module Archsight
89
# Editor handles building and validating resources for the web editor
9-
class Editor
10+
module Editor
11+
module_function
12+
1013
# Build a resource hash from form params
1114
# @param kind [String] Resource kind (e.g., "TechnologyArtifact")
1215
# @param name [String] Resource name
1316
# @param annotations [Hash] Annotation key-value pairs
1417
# @param relations [Array<Hash>] Array of {verb:, kind:, names:[]} hashes
1518
# where kind is the target class name (e.g., "TechnologyArtifact")
1619
# @return [Hash] Resource hash ready for YAML conversion
17-
def self.build_resource(kind:, name:, annotations: {}, relations: [])
20+
def build_resource(kind:, name:, annotations: {}, relations: [])
1821
resource = {
1922
"apiVersion" => "architecture/v1alpha1",
2023
"kind" => kind,
@@ -39,7 +42,7 @@ def self.build_resource(kind:, name:, annotations: {}, relations: [])
3942
# @param name [String] Resource name
4043
# @param annotations [Hash] Annotation key-value pairs
4144
# @return [Hash] { valid: Boolean, errors: { field => [messages] } }
42-
def self.validate(kind, name:, annotations: {})
45+
def validate(kind, name:, annotations: {})
4346
klass = Archsight::Resources[kind]
4447
errors = {}
4548

@@ -66,7 +69,7 @@ def self.validate(kind, name:, annotations: {})
6669
# Uses custom YAML dump that formats multiline strings with literal block scalars
6770
# @param resource_hash [Hash] Resource hash
6871
# @return [String] YAML string
69-
def self.to_yaml(resource_hash)
72+
def to_yaml(resource_hash)
7073
visitor = Psych::Visitors::YAMLTree.create
7174
visitor << resource_hash
7275

@@ -79,7 +82,7 @@ def self.to_yaml(resource_hash)
7982

8083
# Recursively apply literal block style for multiline strings in YAML AST
8184
# @param node [Psych::Nodes::Node] YAML AST node
82-
def self.apply_block_scalar_style(node)
85+
def apply_block_scalar_style(node)
8386
case node
8487
when Psych::Nodes::Scalar
8588
if node.value.is_a?(String)
@@ -97,7 +100,7 @@ def self.apply_block_scalar_style(node)
97100
# Excludes pattern annotations, computed annotations, and annotations with editor: false
98101
# @param kind [String] Resource kind
99102
# @return [Array<Archsight::Annotations::Annotation>]
100-
def self.editable_annotations(kind)
103+
def editable_annotations(kind)
101104
klass = Archsight::Resources[kind]
102105
return [] unless klass
103106

@@ -115,7 +118,7 @@ def self.editable_annotations(kind)
115118
# Get available relations for a resource kind
116119
# @param kind [String] Resource kind
117120
# @return [Array<Array>] Array of [verb, target_kind, target_class_name]
118-
def self.available_relations(kind)
121+
def available_relations(kind)
119122
klass = Archsight::Resources[kind]
120123
return [] unless klass
121124

@@ -125,15 +128,15 @@ def self.available_relations(kind)
125128
# Get unique verbs for a resource kind's relations
126129
# @param kind [String] Resource kind
127130
# @return [Array<String>]
128-
def self.relation_verbs(kind)
131+
def relation_verbs(kind)
129132
available_relations(kind).map { |v, _, _| v.to_s }.uniq.sort
130133
end
131134

132135
# Get valid target class names for a given verb (for UI display and instance lookup)
133136
# @param kind [String] Resource kind
134137
# @param verb [String] Relation verb
135138
# @return [Array<String>] Target class names (e.g., "TechnologyArtifact")
136-
def self.target_kinds_for_verb(kind, verb)
139+
def target_kinds_for_verb(kind, verb)
137140
# Relations structure is [verb, relation_name, target_class_name]
138141
available_relations(kind)
139142
.select { |v, _, _| v.to_s == verb.to_s }
@@ -147,7 +150,7 @@ def self.target_kinds_for_verb(kind, verb)
147150
# @param verb [String] Relation verb
148151
# @param target_class [String] Target class name
149152
# @return [String, nil] Relation name (e.g., "technologyComponents")
150-
def self.relation_name_for(kind, verb, target_class)
153+
def relation_name_for(kind, verb, target_class)
151154
relation = available_relations(kind).find do |v, _, tc|
152155
v.to_s == verb.to_s && tc.to_s == target_class.to_s
153156
end
@@ -161,7 +164,7 @@ def self.relation_name_for(kind, verb, target_class)
161164
# @param verb [String] Relation verb
162165
# @param relation_name [String] Relation name (e.g., "businessActors")
163166
# @return [String, nil] Target class name (e.g., "BusinessActor")
164-
def self.target_class_for_relation(kind, verb, relation_name)
167+
def target_class_for_relation(kind, verb, relation_name)
165168
relation = available_relations(kind).find do |v, rn, _|
166169
v.to_s == verb.to_s && rn.to_s == relation_name.to_s
167170
end
@@ -170,65 +173,65 @@ def self.target_class_for_relation(kind, verb, relation_name)
170173
relation[2].to_s
171174
end
172175

173-
class << self
174-
private
176+
# Filter out empty values from a hash and convert to plain Hash
177+
# (avoids !ruby/hash:Sinatra::IndifferentHash in YAML output)
178+
def filter_empty(hash)
179+
return {} if hash.nil?
175180

176-
# Filter out empty values from a hash and convert to plain Hash
177-
# (avoids !ruby/hash:Sinatra::IndifferentHash in YAML output)
178-
def filter_empty(hash)
179-
return {} if hash.nil?
181+
result = hash.reject { |_, v| v.nil? || v.to_s.strip.empty? }
182+
# Convert to plain Hash to avoid Ruby-specific YAML tags
183+
result.to_h
184+
end
180185

181-
result = hash.reject { |_, v| v.nil? || v.to_s.strip.empty? }
182-
# Convert to plain Hash to avoid Ruby-specific YAML tags
183-
result.to_h
184-
end
186+
# Build spec hash from relations array
187+
# @param source_kind [String] The source resource kind
188+
# @param relations [Array<Hash>] Array of {verb:, kind:, names:[]} hashes
189+
# where kind is the target class name (e.g., "TechnologyArtifact")
190+
# @return [Hash] Spec hash with proper relation_name keys
191+
def build_spec(source_kind, relations)
192+
return {} if relations.nil? || relations.empty?
185193

186-
# Build spec hash from relations array
187-
# @param source_kind [String] The source resource kind
188-
# @param relations [Array<Hash>] Array of {verb:, kind:, names:[]} hashes
189-
# where kind is the target class name (e.g., "TechnologyArtifact")
190-
# @return [Hash] Spec hash with proper relation_name keys
191-
def build_spec(source_kind, relations)
192-
return {} if relations.nil? || relations.empty?
193-
194-
spec = {}
195-
relations.each { |rel| add_relation_to_spec(spec, source_kind, rel) }
196-
deduplicate_spec_values(spec)
197-
end
194+
spec = {}
195+
relations.each { |rel| add_relation_to_spec(spec, source_kind, rel) }
196+
deduplicate_spec_values(spec)
197+
end
198198

199-
def add_relation_to_spec(spec, source_kind, rel)
200-
verb, target_class, names = extract_relation_parts(rel)
201-
return if invalid_relation?(verb, target_class, names)
199+
def add_relation_to_spec(spec, source_kind, rel)
200+
verb, target_class, names = extract_relation_parts(rel)
201+
return if invalid_relation?(verb, target_class, names)
202202

203-
relation_name = Archsight::Editor.relation_name_for(source_kind, verb, target_class)
204-
return unless relation_name
203+
rel_name = relation_name_for(source_kind, verb, target_class)
204+
return unless rel_name
205205

206-
spec[verb.to_s] ||= {}
207-
spec[verb.to_s][relation_name] ||= []
208-
spec[verb.to_s][relation_name].concat(names)
209-
end
206+
spec[verb.to_s] ||= {}
207+
spec[verb.to_s][rel_name] ||= []
208+
spec[verb.to_s][rel_name].concat(names)
209+
end
210210

211-
def extract_relation_parts(rel)
212-
verb = rel[:verb] || rel["verb"]
213-
target_class = rel[:kind] || rel["kind"]
214-
names = normalize_names(rel[:names] || rel["names"] || [])
215-
[verb, target_class, names]
216-
end
211+
def extract_relation_parts(rel)
212+
verb = rel[:verb] || rel["verb"]
213+
target_class = rel[:kind] || rel["kind"]
214+
names = normalize_names(rel[:names] || rel["names"] || [])
215+
[verb, target_class, names]
216+
end
217217

218-
def normalize_names(names)
219-
names = [names] unless names.is_a?(Array)
220-
names.map(&:to_s).reject(&:empty?)
221-
end
218+
def normalize_names(names)
219+
names = [names] unless names.is_a?(Array)
220+
names.map(&:to_s).reject(&:empty?)
221+
end
222222

223-
def invalid_relation?(verb, target_class, names)
224-
verb.nil? || verb.to_s.strip.empty? ||
225-
target_class.nil? || target_class.to_s.strip.empty? ||
226-
names.empty?
227-
end
223+
def invalid_relation?(verb, target_class, names)
224+
verb.nil? || verb.to_s.strip.empty? ||
225+
target_class.nil? || target_class.to_s.strip.empty? ||
226+
names.empty?
227+
end
228228

229-
def deduplicate_spec_values(spec)
230-
spec.transform_values { |kinds| kinds.transform_values(&:uniq) }
231-
end
229+
def deduplicate_spec_values(spec)
230+
spec.transform_values { |kinds| kinds.transform_values(&:uniq) }
232231
end
232+
233+
private_class_method :filter_empty, :build_spec, :add_relation_to_spec,
234+
:extract_relation_parts, :normalize_names, :invalid_relation?,
235+
:deduplicate_spec_values
233236
end
234237
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
require "digest"
4+
5+
module Archsight
6+
module Editor
7+
# ContentHasher generates SHA256 hashes for optimistic locking
8+
module ContentHasher
9+
module_function
10+
11+
# Generate a hash of YAML content for comparison
12+
# Normalizes line endings before hashing to ensure consistency across platforms
13+
# @param content [String] YAML content
14+
# @return [String] 16-character hex hash
15+
def hash(content)
16+
normalized = content.gsub("\r\n", "\n").gsub("\r", "\n")
17+
Digest::SHA256.hexdigest(normalized)[0, 16]
18+
end
19+
20+
# Validate that content hasn't changed since expected_hash was computed
21+
# @param path [String] File path
22+
# @param start_line [Integer] Line number where document starts
23+
# @param expected_hash [String, nil] Expected content hash
24+
# @return [Hash, nil] Error hash with :conflict and :error keys, or nil if valid
25+
def validate(path:, start_line:, expected_hash:)
26+
return nil unless expected_hash
27+
28+
current_content = FileWriter.read_document(path: path, start_line: start_line)
29+
current_hash = hash(current_content)
30+
31+
return nil if current_hash == expected_hash
32+
33+
{ conflict: true, error: "Conflict: The resource has been modified. Please reload the page and try again." }
34+
end
35+
end
36+
end
37+
end

lib/archsight/editor/file_writer.rb

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,39 @@
11
# frozen_string_literal: true
22

33
module Archsight
4-
class Editor
5-
# FileWriter handles writing YAML documents back to multi-document files
4+
module Editor
5+
# FileWriter handles reading and writing YAML documents in multi-document files
66
module FileWriter
77
class WriteError < StandardError; end
88

9+
module_function
10+
11+
# Read a YAML document from a file starting at a given line
12+
# @param path [String] File path
13+
# @param start_line [Integer] Line number where document starts (1-indexed)
14+
# @return [String] Document content
15+
# @raise [WriteError] if file not found or line out of bounds
16+
def read_document(path:, start_line:)
17+
raise WriteError, "File not found: #{path}" unless File.exist?(path)
18+
19+
lines = File.readlines(path)
20+
start_idx = start_line - 1 # Convert to 0-indexed
21+
22+
raise WriteError, "Line #{start_line} is beyond end of file" if start_idx >= lines.length
23+
24+
# Find the end of this document (next --- or EOF)
25+
end_idx = find_document_end(lines, start_idx)
26+
27+
# Extract and join the document lines
28+
lines[start_idx...end_idx].join
29+
end
30+
931
# Replace a YAML document in a file starting at a given line
1032
# @param path [String] File path
1133
# @param start_line [Integer] Line number where document starts (1-indexed)
1234
# @param new_yaml [String] New YAML content (without leading ---)
1335
# @raise [WriteError] if file cannot be written or document not found at expected line
14-
def self.replace_document(path:, start_line:, new_yaml:)
36+
def replace_document(path:, start_line:, new_yaml:)
1537
raise WriteError, "File not found: #{path}" unless File.exist?(path)
1638
raise WriteError, "File not writable: #{path}" unless File.writable?(path)
1739

@@ -38,7 +60,7 @@ def self.replace_document(path:, start_line:, new_yaml:)
3860
# @param lines [Array<String>] File lines
3961
# @param start_idx [Integer] Starting line index (0-indexed)
4062
# @return [Integer] End index (exclusive - the line after the document ends)
41-
def self.find_document_end(lines, start_idx)
63+
def find_document_end(lines, start_idx)
4264
# Start searching from the line after start_idx
4365
idx = start_idx + 1
4466

0 commit comments

Comments
 (0)