omoteura-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/exe/omoteura +15 -0
- data/lib/omoteura/checker.rb +81 -0
- data/lib/omoteura/cli.rb +251 -0
- data/lib/omoteura/config.rb +96 -0
- data/lib/omoteura/core/version.rb +5 -0
- data/lib/omoteura/core.rb +25 -0
- data/lib/omoteura/coverage_report.rb +122 -0
- data/lib/omoteura/diagram.rb +139 -0
- data/lib/omoteura/draft/scene_builder.rb +176 -0
- data/lib/omoteura/draft.rb +244 -0
- data/lib/omoteura/inspector.rb +89 -0
- data/lib/omoteura/link_candidates.rb +53 -0
- data/lib/omoteura/link_resolver.rb +127 -0
- data/lib/omoteura/lz_string.rb +230 -0
- data/lib/omoteura/magic_comment.rb +39 -0
- data/lib/omoteura/minimum_ruby.rb +14 -0
- data/lib/omoteura/obsidian.rb +88 -0
- data/lib/omoteura/outline.rb +52 -0
- data/lib/omoteura/project.rb +32 -0
- data/lib/omoteura/resolver.rb +75 -0
- data/lib/omoteura/source_index.rb +136 -0
- data/lib/omoteura/test_results.rb +74 -0
- data/lib/omoteura/writer.rb +89 -0
- metadata +65 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Omoteura
|
|
6
|
+
# Decides which diagram is attached to a code file. In order of priority:
|
|
7
|
+
# the magic comment, the attach setting, then the path convention
|
|
8
|
+
# (<diagrams_dir>/<code path>.excalidraw.md or .excalidraw).
|
|
9
|
+
class Resolver
|
|
10
|
+
class InvalidPath < Error; end
|
|
11
|
+
|
|
12
|
+
# Paths are relative to the project root. An explicitly attached diagram
|
|
13
|
+
# that does not exist is still returned, marked missing, so callers can
|
|
14
|
+
# report it instead of silently falling back to the convention.
|
|
15
|
+
Attachment = Data.define(:code_path, :diagram_path, :source, :missing) do
|
|
16
|
+
def missing?
|
|
17
|
+
missing
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
CONVENTION_EXTENSIONS = %w[.excalidraw.md .excalidraw].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :config
|
|
24
|
+
|
|
25
|
+
def initialize(config)
|
|
26
|
+
@config = config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns an Attachment, or nil when the file has no diagram or lies
|
|
30
|
+
# outside the project root. The code file must exist.
|
|
31
|
+
def resolve(code_path)
|
|
32
|
+
code = config.expand(code_path) or return nil
|
|
33
|
+
relative = relative_to_root(code)
|
|
34
|
+
|
|
35
|
+
if (written = MagicComment.find(File.read(code, encoding: Encoding::UTF_8), path: relative))
|
|
36
|
+
explicit(relative, written, :magic_comment)
|
|
37
|
+
elsif (written = config.attach[relative])
|
|
38
|
+
explicit(relative, written, :config)
|
|
39
|
+
else
|
|
40
|
+
by_convention(relative)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Obsidian URIs and wikilinks leave out the .md extension, so a path
|
|
47
|
+
# copied from Obsidian is accepted when only "<path>.md" exists.
|
|
48
|
+
def explicit(code_path, written, source)
|
|
49
|
+
path = config.expand(written) or
|
|
50
|
+
raise InvalidPath, "#{code_path} attaches #{written}, which is outside the project"
|
|
51
|
+
|
|
52
|
+
unless File.file?(path)
|
|
53
|
+
with_md = config.expand("#{written}.md")
|
|
54
|
+
path = with_md if with_md && File.file?(with_md)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Attachment.new(code_path: code_path, diagram_path: relative_to_root(path), source: source, missing: !File.file?(path))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def by_convention(code_path)
|
|
61
|
+
base = File.join(config.diagrams_dir, code_path.delete_suffix(File.extname(code_path)))
|
|
62
|
+
CONVENTION_EXTENSIONS.each do |extension|
|
|
63
|
+
path = config.expand(base + extension)
|
|
64
|
+
next unless path && File.file?(path)
|
|
65
|
+
|
|
66
|
+
return Attachment.new(code_path: code_path, diagram_path: relative_to_root(path), source: :convention, missing: false)
|
|
67
|
+
end
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def relative_to_root(path)
|
|
72
|
+
Pathname(path).relative_path_from(Pathname(config.root)).to_s
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Omoteura
|
|
6
|
+
# The places in a Ruby file that an omoteura: link can point at, found by
|
|
7
|
+
# parsing the file with Prism without loading or running it.
|
|
8
|
+
class SourceIndex
|
|
9
|
+
# kind is :spec (an example group or example), :method or :statement.
|
|
10
|
+
# For :spec, name is the full description as RSpec builds it, and group
|
|
11
|
+
# tells example groups from examples; for :statement, name is the first
|
|
12
|
+
# line of the statement.
|
|
13
|
+
# Every entry is built with all five members: a default would need an
|
|
14
|
+
# initialize override that allocates on each of thousands of entries.
|
|
15
|
+
Entry = Data.define(:kind, :name, :start_line, :end_line, :group)
|
|
16
|
+
|
|
17
|
+
attr_reader :entries, :line_count, :spec_entries, :method_entries, :statement_entries
|
|
18
|
+
|
|
19
|
+
def self.parse_file(path)
|
|
20
|
+
new(File.read(path, encoding: Encoding::UTF_8))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(source)
|
|
24
|
+
visitor = Visitor.new(source)
|
|
25
|
+
Prism.parse(source).value.accept(visitor)
|
|
26
|
+
@entries = visitor.entries.freeze
|
|
27
|
+
# Grouped once here; link resolution reads these for every link.
|
|
28
|
+
by_kind = @entries.group_by(&:kind)
|
|
29
|
+
@spec_entries = by_kind.fetch(:spec, []).freeze
|
|
30
|
+
@method_entries = by_kind.fetch(:method, []).freeze
|
|
31
|
+
@statement_entries = by_kind.fetch(:statement, []).freeze
|
|
32
|
+
@line_count = source.count("\n") + ((source.empty? || source.end_with?("\n")) ? 0 : 1)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class Visitor < Prism::Visitor
|
|
36
|
+
EXAMPLE_GROUPS = %i[describe context feature example_group xdescribe xcontext xfeature fdescribe fcontext ffeature].freeze
|
|
37
|
+
EXAMPLES = %i[it specify example scenario xit xspecify xexample xscenario fit fspecify fexample fscenario].freeze
|
|
38
|
+
|
|
39
|
+
Group = Data.define(:description, :module)
|
|
40
|
+
|
|
41
|
+
attr_reader :entries
|
|
42
|
+
|
|
43
|
+
def initialize(source)
|
|
44
|
+
super()
|
|
45
|
+
@source = source
|
|
46
|
+
@entries = []
|
|
47
|
+
# Enclosing example groups. A nil description means a parent could
|
|
48
|
+
# not be read statically, so nothing below it has a known one.
|
|
49
|
+
@groups = []
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def visit_statements_node(node)
|
|
53
|
+
node.body.each { |statement| add(:statement, first_line(statement), statement) }
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def visit_def_node(node)
|
|
58
|
+
name = node.receiver.is_a?(Prism::SelfNode) ? "self.#{node.name}" : node.name.name
|
|
59
|
+
add(:method, name, node)
|
|
60
|
+
super
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def visit_call_node(node)
|
|
64
|
+
return super unless spec_call?(node)
|
|
65
|
+
|
|
66
|
+
part, is_module = description_of(node)
|
|
67
|
+
description = full_description(part)
|
|
68
|
+
add(:spec, description, node, group: EXAMPLE_GROUPS.include?(node.name)) if description
|
|
69
|
+
|
|
70
|
+
if EXAMPLE_GROUPS.include?(node.name)
|
|
71
|
+
@groups.push(Group.new(description: description, module: is_module))
|
|
72
|
+
super
|
|
73
|
+
@groups.pop
|
|
74
|
+
else
|
|
75
|
+
super
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Cuts the statement's first line out of the source by byte offsets.
|
|
82
|
+
# Slicing the whole statement would copy most of the file for every
|
|
83
|
+
# class or describe block, and again for each level of nesting.
|
|
84
|
+
def first_line(node)
|
|
85
|
+
start = node.location.start_offset
|
|
86
|
+
stop = @source.byteindex("\n", start) || @source.bytesize
|
|
87
|
+
line = @source.byteslice(start, stop - start)
|
|
88
|
+
line.rstrip!
|
|
89
|
+
line
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def add(kind, name, node, group: false)
|
|
93
|
+
location = node.location
|
|
94
|
+
@entries << Entry.new(kind: kind, name: name, start_line: location.start_line, end_line: location.end_line, group: group)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def spec_call?(node)
|
|
98
|
+
return false unless EXAMPLE_GROUPS.include?(node.name) || EXAMPLES.include?(node.name)
|
|
99
|
+
|
|
100
|
+
receiver = node.receiver
|
|
101
|
+
receiver.nil? || (receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :RSpec)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# RSpec takes the first argument as the description, plus a second
|
|
105
|
+
# one when it is a string; later symbols and hashes are metadata.
|
|
106
|
+
def description_of(node)
|
|
107
|
+
first, second = node.arguments&.arguments
|
|
108
|
+
part, is_module = literal(first)
|
|
109
|
+
part = "#{part} #{second.unescaped}" if part && second.is_a?(Prism::StringNode)
|
|
110
|
+
[part, is_module]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def literal(node)
|
|
114
|
+
case node
|
|
115
|
+
when Prism::StringNode, Prism::SymbolNode then [node.unescaped, false]
|
|
116
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode then [node.slice, true]
|
|
117
|
+
else [nil, false]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Mirrors RSpec's description building: parts are joined with a space,
|
|
122
|
+
# except that "#method", ".method" and "::Const" attach directly to a
|
|
123
|
+
# parent described by a module, as in "User#display_name".
|
|
124
|
+
def full_description(part)
|
|
125
|
+
return nil if part.nil?
|
|
126
|
+
|
|
127
|
+
parent = @groups.last
|
|
128
|
+
return part if parent.nil?
|
|
129
|
+
return nil if parent.description.nil?
|
|
130
|
+
|
|
131
|
+
separator = (parent.module && part.match?(/\A(?:#|::|\.)/)) ? "" : " "
|
|
132
|
+
"#{parent.description}#{separator}#{part}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Omoteura
|
|
6
|
+
# Test results written by Omoteura::RSpec::Formatter.
|
|
7
|
+
class TestResults
|
|
8
|
+
class Invalid < Error; end
|
|
9
|
+
|
|
10
|
+
DEFAULT_PATH = "tmp/omoteura/results.json"
|
|
11
|
+
SUPPORTED_VERSION = 1
|
|
12
|
+
|
|
13
|
+
Example = Data.define(:file_path, :line_number, :full_description, :status, :message)
|
|
14
|
+
|
|
15
|
+
# Combined status of the examples behind one shape: any failure fails
|
|
16
|
+
# the shape, then any pending example makes it pending.
|
|
17
|
+
Summary = Data.define(:status, :examples) do
|
|
18
|
+
def self.of(examples)
|
|
19
|
+
statuses = examples.map(&:status)
|
|
20
|
+
status =
|
|
21
|
+
if statuses.include?("failed") then "failed"
|
|
22
|
+
elsif statuses.include?("pending") then "pending"
|
|
23
|
+
else "passed"
|
|
24
|
+
end
|
|
25
|
+
new(status: status, examples: examples)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :examples
|
|
30
|
+
|
|
31
|
+
# Returns empty results when the file does not exist yet, so diagrams
|
|
32
|
+
# still work before the first test run.
|
|
33
|
+
def self.load(path)
|
|
34
|
+
return new([]) unless File.file?(path)
|
|
35
|
+
|
|
36
|
+
data = JSON.parse(File.read(path, encoding: Encoding::UTF_8))
|
|
37
|
+
unless data.is_a?(Hash) && data["version"] == SUPPORTED_VERSION && data["examples"].is_a?(Array)
|
|
38
|
+
raise Invalid, "#{path} is not an Omoteura results file of version #{SUPPORTED_VERSION}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
examples = data["examples"].map do |e|
|
|
42
|
+
Example.new(file_path: e["file_path"], line_number: e["line_number"], full_description: e["full_description"],
|
|
43
|
+
status: e["status"], message: e["message"])
|
|
44
|
+
end
|
|
45
|
+
executed = data["executed_lines"]
|
|
46
|
+
new(examples, executed_lines: executed.is_a?(Hash) ? executed : nil)
|
|
47
|
+
rescue JSON::ParserError => e
|
|
48
|
+
raise Invalid, "#{path} is not valid JSON: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# executed_lines maps files to the line numbers that ran during the
|
|
52
|
+
# tests, when the run measured coverage; nil when it did not.
|
|
53
|
+
def initialize(examples, executed_lines: nil)
|
|
54
|
+
@examples = examples
|
|
55
|
+
@executed_lines = executed_lines&.transform_values { |lines| lines.to_h { |line| [line, true] } }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Whether code between the lines ran, or nil when nothing says: the run
|
|
59
|
+
# did not measure coverage, or did not load the file.
|
|
60
|
+
def executed?(path, lines)
|
|
61
|
+
executed = @executed_lines&.[](path) or return nil
|
|
62
|
+
lines.any? { |line| executed[line] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Summarizes the examples defined within the lines of a link target,
|
|
66
|
+
# or returns nil when no recorded example is there.
|
|
67
|
+
def summary_for(target)
|
|
68
|
+
matching = examples.select do |e|
|
|
69
|
+
e.file_path == target.path && (target.start_line..target.end_line).cover?(e.line_number)
|
|
70
|
+
end
|
|
71
|
+
matching.empty? ? nil : Summary.of(matching)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Omoteura
|
|
8
|
+
# Saves a drawing an editor changed, in the format of the file it goes
|
|
9
|
+
# to: a .excalidraw file as JSON, an Obsidian note through
|
|
10
|
+
# Obsidian.rewrite so the rest of the note stays as it was.
|
|
11
|
+
#
|
|
12
|
+
# The file is replaced in one step, so a failed save leaves it whole.
|
|
13
|
+
# Given the digest of the file as the editor read it, a save that would
|
|
14
|
+
# overwrite someone else's change is refused instead.
|
|
15
|
+
class Writer
|
|
16
|
+
class Conflict < Error; end
|
|
17
|
+
|
|
18
|
+
class Invalid < Error; end
|
|
19
|
+
|
|
20
|
+
Result = Data.define(:diagram_path, :digest)
|
|
21
|
+
|
|
22
|
+
NEW_FILE_EXTENSIONS = [".excalidraw", ".excalidraw.md"].freeze
|
|
23
|
+
|
|
24
|
+
def self.digest(content)
|
|
25
|
+
Digest::SHA256.hexdigest(content)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(config)
|
|
29
|
+
@config = config
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# data is the drawing as Excalidraw saves it: a Hash with "elements",
|
|
33
|
+
# and usually "type", "version", "source", "appState" and "files".
|
|
34
|
+
def save(diagram_path, data, if_unchanged: nil)
|
|
35
|
+
file = @config.expand(diagram_path) or raise Invalid, "#{diagram_path} is outside #{@config.root}"
|
|
36
|
+
raise Invalid, "the drawing has no elements list" unless data.is_a?(Hash) && data["elements"].is_a?(Array)
|
|
37
|
+
|
|
38
|
+
file = File.realpath(file) if File.symlink?(file)
|
|
39
|
+
existing = File.exist?(file) ? File.binread(file) : nil
|
|
40
|
+
if if_unchanged && (existing.nil? || self.class.digest(existing) != if_unchanged)
|
|
41
|
+
raise Conflict, "#{@config.relative(file)} changed since it was read"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
content = render(file, data, existing)
|
|
45
|
+
replace(file, content)
|
|
46
|
+
Result.new(diagram_path: @config.relative(file), digest: self.class.digest(content))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def render(file, data, existing)
|
|
52
|
+
if existing
|
|
53
|
+
raise Invalid, "#{@config.relative(file)} is not an Excalidraw drawing" unless Diagram.diagram_file?(file)
|
|
54
|
+
elsif NEW_FILE_EXTENSIONS.none? { |ext| file.end_with?(ext) }
|
|
55
|
+
raise Invalid, "a new drawing must end in #{NEW_FILE_EXTENSIONS.join(" or ")}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
return JSON.pretty_generate(data) + "\n" if file.end_with?(".excalidraw")
|
|
59
|
+
|
|
60
|
+
existing ? Obsidian.rewrite(existing.force_encoding(Encoding::UTF_8), keep_raw_text(file, data)) : Obsidian.render(data)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The plugin keeps the markdown behind a text element, such as a
|
|
64
|
+
# [[wiki link]], in rawText, and lists it in the note. Excalidraw does
|
|
65
|
+
# not know it, so it is carried over from the saved drawing while the
|
|
66
|
+
# text is unchanged, and dropped once the text was edited.
|
|
67
|
+
def keep_raw_text(file, data)
|
|
68
|
+
before = Diagram.load(file).scene["elements"].to_h { |e| [e["id"], e] }
|
|
69
|
+
elements = data["elements"].map do |element|
|
|
70
|
+
next element unless element.is_a?(Hash) && element["type"] == "text"
|
|
71
|
+
|
|
72
|
+
old = before[element["id"]]
|
|
73
|
+
raw = old["rawText"] if old && old["originalText"] == element["originalText"]
|
|
74
|
+
raw ? element.merge("rawText" => raw) : element.except("rawText")
|
|
75
|
+
end
|
|
76
|
+
data.merge("elements" => elements)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def replace(file, content)
|
|
80
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
81
|
+
temp = File.join(File.dirname(file), ".#{File.basename(file)}.#{Process.pid}.omoteura")
|
|
82
|
+
File.binwrite(temp, content)
|
|
83
|
+
File.chmod(File.stat(file).mode, temp) if File.exist?(file)
|
|
84
|
+
File.rename(temp, file)
|
|
85
|
+
ensure
|
|
86
|
+
File.delete(temp) if temp && File.exist?(temp)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: omoteura-core
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- KAIBYAKU K.K
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
executables:
|
|
13
|
+
- omoteura
|
|
14
|
+
extensions: []
|
|
15
|
+
extra_rdoc_files: []
|
|
16
|
+
files:
|
|
17
|
+
- LICENSE
|
|
18
|
+
- exe/omoteura
|
|
19
|
+
- lib/omoteura/checker.rb
|
|
20
|
+
- lib/omoteura/cli.rb
|
|
21
|
+
- lib/omoteura/config.rb
|
|
22
|
+
- lib/omoteura/core.rb
|
|
23
|
+
- lib/omoteura/core/version.rb
|
|
24
|
+
- lib/omoteura/coverage_report.rb
|
|
25
|
+
- lib/omoteura/diagram.rb
|
|
26
|
+
- lib/omoteura/draft.rb
|
|
27
|
+
- lib/omoteura/draft/scene_builder.rb
|
|
28
|
+
- lib/omoteura/inspector.rb
|
|
29
|
+
- lib/omoteura/link_candidates.rb
|
|
30
|
+
- lib/omoteura/link_resolver.rb
|
|
31
|
+
- lib/omoteura/lz_string.rb
|
|
32
|
+
- lib/omoteura/magic_comment.rb
|
|
33
|
+
- lib/omoteura/minimum_ruby.rb
|
|
34
|
+
- lib/omoteura/obsidian.rb
|
|
35
|
+
- lib/omoteura/outline.rb
|
|
36
|
+
- lib/omoteura/project.rb
|
|
37
|
+
- lib/omoteura/resolver.rb
|
|
38
|
+
- lib/omoteura/source_index.rb
|
|
39
|
+
- lib/omoteura/test_results.rb
|
|
40
|
+
- lib/omoteura/writer.rb
|
|
41
|
+
homepage: https://github.com/kaibyaku-jp/omoteura
|
|
42
|
+
licenses:
|
|
43
|
+
- MIT
|
|
44
|
+
metadata:
|
|
45
|
+
source_code_uri: https://github.com/kaibyaku-jp/omoteura
|
|
46
|
+
changelog_uri: https://github.com/kaibyaku-jp/omoteura/blob/main/CHANGELOG.md
|
|
47
|
+
rubygems_mfa_required: 'true'
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '3.4'
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 3.6.9
|
|
63
|
+
specification_version: 4
|
|
64
|
+
summary: 'Diagram file I/O, omoteura: link resolution and CLI for Omoteura'
|
|
65
|
+
test_files: []
|