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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0c9ec9195c34a9aa30eb4cb708dbf72cbaca5ee7b5f6f04a5e78df33c52ae428
|
|
4
|
+
data.tar.gz: 27841d7cd4cffc527a4270467d33d12e3629b0c85a260a5621d09ec630629811
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9565e3f5fdf46d9ceb06ff93b9de14c7bba26f40e385c401776725ecaadfdb67c1b7d7e5338096c684a36fb9f2165c06ace31b545bfef33139d6e24134d242bd
|
|
7
|
+
data.tar.gz: ea41624e44792a929c4429f6a8b4f640b5b6027bd7f28d203c8a2035419ca8c257321697e6eb3750d267e480360227b5681ee4ece9a6dc09197b2f71f14e642d
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KAIBYAKU K.K
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/exe/omoteura
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Checked before loading anything else, so an editor that finds the system
|
|
5
|
+
# Ruby gets a message it can act on. Not warn, which prints nothing when
|
|
6
|
+
# warnings are off.
|
|
7
|
+
require_relative "../lib/omoteura/minimum_ruby"
|
|
8
|
+
if (message = Omoteura.ruby_too_old_message)
|
|
9
|
+
$stderr.puts message # standard:disable Style/StderrPuts
|
|
10
|
+
exit 2
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
require "omoteura/cli"
|
|
14
|
+
|
|
15
|
+
exit Omoteura::CLI.new.run(ARGV)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omoteura
|
|
4
|
+
# Checks a whole project: every attached diagram must exist and parse,
|
|
5
|
+
# every omoteura: link in it must resolve, every magic comment must be
|
|
6
|
+
# valid, and every diagram under diagrams_dir must be attached to code.
|
|
7
|
+
class Checker
|
|
8
|
+
# file is relative to the project root. shape names the diagram element
|
|
9
|
+
# a link problem comes from, as its label or id.
|
|
10
|
+
Problem = Data.define(:file, :message, :shape)
|
|
11
|
+
|
|
12
|
+
Report = Data.define(:problems, :diagram_count, :link_count) do
|
|
13
|
+
def ok?
|
|
14
|
+
problems.empty?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :config
|
|
19
|
+
|
|
20
|
+
def initialize(config)
|
|
21
|
+
@config = config
|
|
22
|
+
@project = Project.new(config)
|
|
23
|
+
@resolver = Resolver.new(config)
|
|
24
|
+
@link_resolver = LinkResolver.new(config)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
problems = []
|
|
29
|
+
attachments = @project.code_paths.filter_map do |code_path|
|
|
30
|
+
@resolver.resolve(code_path)
|
|
31
|
+
rescue MagicComment::Duplicate, Resolver::InvalidPath => e
|
|
32
|
+
problems << Problem.new(file: code_path, message: e.message.delete_prefix("#{code_path} "), shape: nil)
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
link_count = 0
|
|
37
|
+
attachments.each do |attachment|
|
|
38
|
+
if attachment.missing?
|
|
39
|
+
problems << Problem.new(file: attachment.code_path, message: "attaches #{attachment.diagram_path}, which does not exist", shape: nil)
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
diagram = load_diagram(attachment.diagram_path, problems) or next
|
|
44
|
+
diagram.linked_elements.each do |element|
|
|
45
|
+
link_count += 1
|
|
46
|
+
result = @link_resolver.resolve(element.link, context_path: attachment.code_path)
|
|
47
|
+
next unless result.broken?
|
|
48
|
+
|
|
49
|
+
problems << Problem.new(file: attachment.diagram_path, message: result.error, shape: element.label || element.id)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
problems.concat(unattached_diagrams(attachments))
|
|
54
|
+
diagram_paths = attachments.reject(&:missing?).map(&:diagram_path).uniq
|
|
55
|
+
Report.new(problems: problems, diagram_count: diagram_paths.size, link_count: link_count)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def load_diagram(diagram_path, problems)
|
|
61
|
+
Diagram.load(File.join(config.root, diagram_path))
|
|
62
|
+
rescue Diagram::NotADiagram, Diagram::ParseError => e
|
|
63
|
+
problems << Problem.new(file: diagram_path, message: e.message.delete_prefix("#{File.join(config.root, diagram_path)} "), shape: nil)
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def unattached_diagrams(attachments)
|
|
68
|
+
attached = attachments.map(&:diagram_path).to_set
|
|
69
|
+
diagram_dir = File.join(config.root, config.diagrams_dir)
|
|
70
|
+
return [] unless Dir.exist?(diagram_dir)
|
|
71
|
+
|
|
72
|
+
Dir.glob("**/*.{excalidraw,md}", base: diagram_dir).sort.filter_map do |relative|
|
|
73
|
+
path = File.join(config.diagrams_dir, relative)
|
|
74
|
+
next if attached.include?(path) || !Diagram.diagram_file?(File.join(config.root, path))
|
|
75
|
+
|
|
76
|
+
message = path.match?(/\.draft\.excalidraw(?:\.md)?\z/) ? "is a draft; copy what you need into the diagram, then delete it" : "is not attached to any code file"
|
|
77
|
+
Problem.new(file: path, message: message, shape: nil)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/omoteura/cli.rb
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "omoteura/core"
|
|
6
|
+
|
|
7
|
+
module Omoteura
|
|
8
|
+
# The omoteura command. #run returns the exit status: 0 when everything
|
|
9
|
+
# is fine, 1 when check finds problems or save refuses to write, 2 for
|
|
10
|
+
# usage or settings errors.
|
|
11
|
+
class CLI
|
|
12
|
+
USAGE = <<~TEXT
|
|
13
|
+
Usage: omoteura <command> [options]
|
|
14
|
+
|
|
15
|
+
Commands:
|
|
16
|
+
check Report broken omoteura: links and diagrams not attached to code
|
|
17
|
+
inspect Print what an editor needs about one code file, as JSON
|
|
18
|
+
diagram Print what an editor needs to show one diagram, as JSON
|
|
19
|
+
coverage Print which declarations and methods of one file its specs cover, as JSON
|
|
20
|
+
links Print the places in one code file a shape can link to, as JSON
|
|
21
|
+
draft Draft a diagram from code, as a starting point to draw on
|
|
22
|
+
save Save a drawing an editor changed, read as JSON from stdin
|
|
23
|
+
version Print the version
|
|
24
|
+
|
|
25
|
+
Run "omoteura <command> --help" for a command's options.
|
|
26
|
+
TEXT
|
|
27
|
+
|
|
28
|
+
def initialize(out: $stdout, err: $stderr, input: $stdin)
|
|
29
|
+
@out = out
|
|
30
|
+
@err = err
|
|
31
|
+
@input = input
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run(argv)
|
|
35
|
+
command, *args = argv
|
|
36
|
+
case command
|
|
37
|
+
when "check" then check(args)
|
|
38
|
+
when "inspect" then editor_report(args, "inspect FILE") { |config, results, file| Inspector.new(config, results: results).report(file) }
|
|
39
|
+
when "diagram" then editor_report(args, "diagram FILE") { |config, results, file| Inspector.new(config, results: results).diagram_report(file) }
|
|
40
|
+
when "coverage" then editor_report(args, "coverage FILE") { |config, results, file| CoverageReport.new(config, results: results).report(file) }
|
|
41
|
+
when "links"
|
|
42
|
+
editor_report(args, "links FILE") do |config, _results, file|
|
|
43
|
+
code_path = config.relative(file)
|
|
44
|
+
{version: Inspector::FORMAT_VERSION, code_path: code_path, candidates: LinkCandidates.new(config).for(code_path).map(&:to_h)}
|
|
45
|
+
end
|
|
46
|
+
when "draft" then draft(args)
|
|
47
|
+
when "save" then save(args)
|
|
48
|
+
when "version", "--version", "-v"
|
|
49
|
+
@out.puts VERSION
|
|
50
|
+
0
|
|
51
|
+
when "help", "--help", "-h"
|
|
52
|
+
@out.puts USAGE
|
|
53
|
+
0
|
|
54
|
+
when nil
|
|
55
|
+
@err.puts USAGE
|
|
56
|
+
2
|
|
57
|
+
else
|
|
58
|
+
@err.puts "omoteura: unknown command '#{command}'", "", USAGE
|
|
59
|
+
2
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def check(args)
|
|
66
|
+
options = {root: Dir.pwd, format: "text"}
|
|
67
|
+
parser = OptionParser.new do |o|
|
|
68
|
+
o.banner = "Usage: omoteura check [options]"
|
|
69
|
+
o.on("--root DIR", "Project root (default: the current directory)") { |dir| options[:root] = dir }
|
|
70
|
+
o.on("--format FORMAT", %w[text github], "text, or github for GitHub Actions annotations (default: text)") { |f| options[:format] = f }
|
|
71
|
+
o.on("-h", "--help", "Show this help") do
|
|
72
|
+
@out.puts o
|
|
73
|
+
return 0
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
parser.parse!(args)
|
|
77
|
+
|
|
78
|
+
report = Checker.new(Config.load(options[:root])).run
|
|
79
|
+
report.problems.each { |problem| @out.puts format_problem(problem, options[:format]) }
|
|
80
|
+
@out.puts summary(report)
|
|
81
|
+
report.ok? ? 0 : 1
|
|
82
|
+
rescue OptionParser::ParseError, Config::Invalid => e
|
|
83
|
+
@err.puts "omoteura: #{e.message}"
|
|
84
|
+
2
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The commands editors run: one file in, one line of JSON out.
|
|
88
|
+
def editor_report(args, usage)
|
|
89
|
+
options = {root: Dir.pwd, results: TestResults::DEFAULT_PATH}
|
|
90
|
+
parser = OptionParser.new do |o|
|
|
91
|
+
o.banner = "Usage: omoteura #{usage} [options]"
|
|
92
|
+
o.on("--root DIR", "Project root (default: the current directory)") { |dir| options[:root] = dir }
|
|
93
|
+
o.on("--results PATH", "Test results, relative to the root (default: #{TestResults::DEFAULT_PATH})") { |path| options[:results] = path }
|
|
94
|
+
o.on("-h", "--help", "Show this help") do
|
|
95
|
+
@out.puts o
|
|
96
|
+
return 0
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
parser.parse!(args)
|
|
100
|
+
raise OptionParser::InvalidArgument, "expected exactly one FILE" unless args.size == 1
|
|
101
|
+
|
|
102
|
+
config = Config.load(options[:root])
|
|
103
|
+
file = File.expand_path(args.first)
|
|
104
|
+
raise OptionParser::InvalidArgument, "#{args.first} is not a file" unless File.file?(file)
|
|
105
|
+
raise OptionParser::InvalidArgument, "#{args.first} is outside #{config.root}" unless config.expand(file)
|
|
106
|
+
|
|
107
|
+
results = TestResults.load(File.expand_path(options[:results], config.root))
|
|
108
|
+
@out.puts JSON.generate(yield(config, results, file))
|
|
109
|
+
0
|
|
110
|
+
rescue OptionParser::ParseError, Config::Invalid, TestResults::Invalid => e
|
|
111
|
+
@err.puts "omoteura: #{e.message}"
|
|
112
|
+
2
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def draft(args)
|
|
116
|
+
options = {root: Dir.pwd, only: Draft::CATEGORIES, out: nil, format: "json", dry_run: false, merge: false, json: false}
|
|
117
|
+
parser = OptionParser.new do |o|
|
|
118
|
+
o.banner = "Usage: omoteura draft FILE_OR_GLOB... [options]"
|
|
119
|
+
o.on("--root DIR", "Project root (default: the current directory)") { |dir| options[:root] = dir }
|
|
120
|
+
o.on("--out PATH", "Write here instead, relative to the root: a file or a directory") { |path| options[:out] = path }
|
|
121
|
+
o.on("--format FORMAT", %w[json md], "json for .excalidraw (default), or md for an Obsidian .excalidraw.md") { |f| options[:format] = f }
|
|
122
|
+
o.on("--only LIST", Array, "For code other than specs, draw only: #{Draft::CATEGORIES.join(", ")}") { |list| options[:only] = list }
|
|
123
|
+
o.on("--merge", "Add what the file's diagram has no shape for yet, to the right of it") { options[:merge] = true }
|
|
124
|
+
o.on("--dry-run", "Show what would be written without writing") { options[:dry_run] = true }
|
|
125
|
+
o.on("--json", "Print the results as one line of JSON, for editors") { options[:json] = true }
|
|
126
|
+
o.on("-h", "--help", "Show this help") do
|
|
127
|
+
@out.puts o
|
|
128
|
+
return 0
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
parser.parse!(args)
|
|
132
|
+
raise OptionParser::InvalidArgument, "expected at least one FILE" if args.empty?
|
|
133
|
+
unknown = options[:only] - Draft::CATEGORIES
|
|
134
|
+
raise OptionParser::InvalidArgument, "--only does not know #{unknown.join(", ")}" unless unknown.empty?
|
|
135
|
+
|
|
136
|
+
raise OptionParser::InvalidArgument, "--merge adds to the diagram in place, so it takes no --out" if options[:merge] && options[:out]
|
|
137
|
+
|
|
138
|
+
config = Config.load(options[:root])
|
|
139
|
+
paths = draft_paths(args, config)
|
|
140
|
+
format = (options[:format] == "md") ? :obsidian : :excalidraw
|
|
141
|
+
out_file = options[:out]&.match?(/\.excalidraw(?:\.md)?\z/)
|
|
142
|
+
if out_file && !options[:out].end_with?(Draft::EXTENSIONS.fetch(format))
|
|
143
|
+
raise OptionParser::InvalidArgument, "--out #{options[:out]} does not end in #{Draft::EXTENSIONS.fetch(format)}, as --format #{options[:format]} writes"
|
|
144
|
+
end
|
|
145
|
+
if out_file && paths.size > 1
|
|
146
|
+
raise OptionParser::InvalidArgument, "--out names one file, but #{paths.size} files match"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
drafter = Draft.new(config, only: options[:only], out: options[:out], format: format)
|
|
150
|
+
results = paths.map do |path|
|
|
151
|
+
result = options[:merge] ? drafter.merge(path, dry_run: options[:dry_run]) : drafter.draft(path, dry_run: options[:dry_run])
|
|
152
|
+
@out.puts draft_line(result, options[:dry_run]) unless options[:json]
|
|
153
|
+
result
|
|
154
|
+
end
|
|
155
|
+
@out.puts JSON.generate(version: Inspector::FORMAT_VERSION, results: results.map { |r| r.to_h.merge(status: r.status.to_s) }) if options[:json]
|
|
156
|
+
0
|
|
157
|
+
rescue OptionParser::ParseError, Config::Invalid, Writer::Conflict, Diagram::ParseError, Diagram::NotADiagram, Resolver::InvalidPath, MagicComment::Duplicate => e
|
|
158
|
+
@err.puts "omoteura: #{e.message}"
|
|
159
|
+
2
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Prints one line of JSON: where the drawing went and the digest of
|
|
163
|
+
# what was written, or why nothing was.
|
|
164
|
+
def save(args)
|
|
165
|
+
options = {root: Dir.pwd, if_unchanged: nil}
|
|
166
|
+
parser = OptionParser.new do |o|
|
|
167
|
+
o.banner = "Usage: omoteura save FILE [options] < drawing.json"
|
|
168
|
+
o.on("--root DIR", "Project root (default: the current directory)") { |dir| options[:root] = dir }
|
|
169
|
+
o.on("--if-unchanged DIGEST", "Refuse to save unless the file's SHA-256 is still DIGEST") { |digest| options[:if_unchanged] = digest }
|
|
170
|
+
o.on("-h", "--help", "Show this help") do
|
|
171
|
+
@out.puts o
|
|
172
|
+
return 0
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
parser.parse!(args)
|
|
176
|
+
raise OptionParser::InvalidArgument, "expected exactly one FILE" unless args.size == 1
|
|
177
|
+
|
|
178
|
+
config = Config.load(options[:root])
|
|
179
|
+
begin
|
|
180
|
+
result = Writer.new(config).save(File.expand_path(args.first), JSON.parse(@input.read), if_unchanged: options[:if_unchanged])
|
|
181
|
+
@out.puts JSON.generate(version: Inspector::FORMAT_VERSION, diagram_path: result.diagram_path, digest: result.digest, error: nil, conflict: false)
|
|
182
|
+
0
|
|
183
|
+
rescue JSON::ParserError, Writer::Invalid, Writer::Conflict, Diagram::ParseError => e
|
|
184
|
+
error = e.is_a?(JSON::ParserError) ? "the drawing is not valid JSON" : e.message
|
|
185
|
+
@out.puts JSON.generate(version: Inspector::FORMAT_VERSION, diagram_path: nil, digest: nil, error: error, conflict: e.is_a?(Writer::Conflict))
|
|
186
|
+
1
|
|
187
|
+
end
|
|
188
|
+
rescue OptionParser::ParseError, Config::Invalid => e
|
|
189
|
+
@err.puts "omoteura: #{e.message}"
|
|
190
|
+
2
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Arguments are files or globs, relative to the root.
|
|
194
|
+
def draft_paths(args, config)
|
|
195
|
+
paths = args.flat_map do |arg|
|
|
196
|
+
matches = arg.match?(/[*?\[{]/) ? Dir.glob(arg, base: config.root).sort : [arg]
|
|
197
|
+
raise OptionParser::InvalidArgument, "#{arg} matches no files" if matches.empty?
|
|
198
|
+
|
|
199
|
+
matches.map do |path|
|
|
200
|
+
file = config.expand(path) or raise OptionParser::InvalidArgument, "#{path} is outside #{config.root}"
|
|
201
|
+
raise OptionParser::InvalidArgument, "#{path} is not a file" unless File.file?(file)
|
|
202
|
+
|
|
203
|
+
config.relative(file)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
paths.uniq
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def draft_line(result, dry_run)
|
|
210
|
+
shapes = "#{result.shape_count} #{(result.shape_count == 1) ? "shape" : "shapes"}"
|
|
211
|
+
verb = dry_run ? "would write" : "wrote"
|
|
212
|
+
case result.status
|
|
213
|
+
when :written then "#{verb} #{result.diagram_path} (#{shapes})"
|
|
214
|
+
when :draft
|
|
215
|
+
"#{verb} #{result.diagram_path} (#{shapes}) beside the diagram #{result.code_path} already has; copy what you need, then delete it"
|
|
216
|
+
when :exists then "skipped #{result.code_path}: #{result.diagram_path} already exists; delete it to draft again"
|
|
217
|
+
when :empty then "skipped #{result.code_path}: nothing to draw"
|
|
218
|
+
when :merged then "#{dry_run ? "would add" : "added"} #{shapes} to #{result.diagram_path}"
|
|
219
|
+
when :up_to_date then "skipped #{result.code_path}: #{result.diagram_path} already has a shape for everything"
|
|
220
|
+
when :no_diagram then "skipped #{result.code_path}: no diagram to merge into; draft without --merge"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def format_problem(problem, format)
|
|
225
|
+
message = problem.shape ? "#{problem.message} (shape \"#{problem.shape}\")" : problem.message
|
|
226
|
+
if format == "github"
|
|
227
|
+
"::error file=#{escape_property(problem.file)},title=omoteura check::#{escape_data(message)}"
|
|
228
|
+
else
|
|
229
|
+
"#{problem.file}: #{message}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def summary(report)
|
|
234
|
+
checked = "Checked #{count(report.diagram_count, "diagram")} and #{count(report.link_count, "link")}"
|
|
235
|
+
report.ok? ? "#{checked}: no problems." : "#{checked}: #{count(report.problems.size, "problem")}."
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def count(number, noun)
|
|
239
|
+
"#{number} #{noun}#{"s" unless number == 1}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Escaping rules for GitHub Actions workflow commands.
|
|
243
|
+
def escape_data(text)
|
|
244
|
+
text.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def escape_property(text)
|
|
248
|
+
escape_data(text).gsub(":", "%3A").gsub(",", "%2C")
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "psych"
|
|
5
|
+
|
|
6
|
+
module Omoteura
|
|
7
|
+
# Project settings from .omoteura.yml, overridden by .omoteura.local.yml.
|
|
8
|
+
class Config
|
|
9
|
+
class Invalid < Error; end
|
|
10
|
+
|
|
11
|
+
FILE_NAMES = %w[.omoteura.yml .omoteura.local.yml].freeze
|
|
12
|
+
DEFAULT_DIAGRAMS_DIR = "ura"
|
|
13
|
+
|
|
14
|
+
# coverage_panel is false to turn the editor's coverage panel off.
|
|
15
|
+
attr_reader :root, :diagrams_dir, :attach, :coverage_panel
|
|
16
|
+
|
|
17
|
+
def self.load(root)
|
|
18
|
+
root = File.expand_path(root)
|
|
19
|
+
settings = FILE_NAMES.each_with_object({}) do |name, merged|
|
|
20
|
+
path = File.join(root, name)
|
|
21
|
+
merge_settings(merged, read_settings(path)) if File.file?(path)
|
|
22
|
+
end
|
|
23
|
+
new(root: root, **settings)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.read_settings(path)
|
|
27
|
+
settings = Psych.safe_load_file(path) || {}
|
|
28
|
+
raise Invalid, "#{path} must be a mapping" unless settings.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
settings
|
|
31
|
+
rescue Psych::Exception => e
|
|
32
|
+
raise Invalid, "#{path} is not valid YAML: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The local file adds to and overrides individual attach entries
|
|
36
|
+
# rather than replacing the whole attach mapping.
|
|
37
|
+
def self.merge_settings(merged, settings)
|
|
38
|
+
merged[:diagrams_dir] = settings["diagrams_dir"] if settings.key?("diagrams_dir")
|
|
39
|
+
merged[:coverage_panel] = settings["coverage_panel"] if settings.key?("coverage_panel")
|
|
40
|
+
return unless settings.key?("attach")
|
|
41
|
+
|
|
42
|
+
attach = settings["attach"]
|
|
43
|
+
# A non-mapping value is passed through so #initialize reports it.
|
|
44
|
+
merged[:attach] = attach.is_a?(Hash) ? (merged[:attach] || {}).merge(attach) : attach
|
|
45
|
+
end
|
|
46
|
+
private_class_method :read_settings, :merge_settings
|
|
47
|
+
|
|
48
|
+
def initialize(root:, diagrams_dir: DEFAULT_DIAGRAMS_DIR, attach: {}, coverage_panel: true)
|
|
49
|
+
@root = File.expand_path(root)
|
|
50
|
+
@root_prefix = (@root + File::SEPARATOR).freeze
|
|
51
|
+
@diagrams_dir = validate_relative_path(diagrams_dir, "diagrams_dir")
|
|
52
|
+
@attach = validate_attach(attach).freeze
|
|
53
|
+
raise Invalid, "coverage_panel must be true or false, got #{coverage_panel.inspect}" unless [true, false].include?(coverage_panel)
|
|
54
|
+
|
|
55
|
+
@coverage_panel = coverage_panel
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Expands a path written relative to the project root. Returns nil when
|
|
59
|
+
# it points outside the root, so a repository cannot make Omoteura read
|
|
60
|
+
# arbitrary files on the machine.
|
|
61
|
+
def expand(relative_path)
|
|
62
|
+
path = File.expand_path(relative_path, root)
|
|
63
|
+
inside_root?(path) ? path : nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The path relative to the root, for paths that #expand accepted.
|
|
67
|
+
def relative(path)
|
|
68
|
+
Pathname(path).relative_path_from(Pathname(root)).to_s
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def inside_root?(path)
|
|
74
|
+
return false unless path.start_with?(@root_prefix)
|
|
75
|
+
return true unless File.exist?(path)
|
|
76
|
+
|
|
77
|
+
File.realpath(path).start_with?(@real_root_prefix ||= (File.realpath(root) + File::SEPARATOR).freeze)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_relative_path(value, key)
|
|
81
|
+
unless value.is_a?(String) && !value.empty? && !Pathname(value).absolute?
|
|
82
|
+
raise Invalid, "#{key} must be a relative path, got #{value.inspect}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
value
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_attach(value)
|
|
89
|
+
raise Invalid, "attach must be a mapping of code paths to diagram paths" unless value.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
value.to_h do |code_path, diagram_path|
|
|
92
|
+
[validate_relative_path(code_path, "attach key"), validate_relative_path(diagram_path, "attach[#{code_path}]")]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "omoteura/core/version"
|
|
4
|
+
|
|
5
|
+
module Omoteura
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require "omoteura/checker"
|
|
10
|
+
require "omoteura/config"
|
|
11
|
+
require "omoteura/coverage_report"
|
|
12
|
+
require "omoteura/diagram"
|
|
13
|
+
require "omoteura/draft"
|
|
14
|
+
require "omoteura/inspector"
|
|
15
|
+
require "omoteura/link_candidates"
|
|
16
|
+
require "omoteura/link_resolver"
|
|
17
|
+
require "omoteura/magic_comment"
|
|
18
|
+
require "omoteura/minimum_ruby"
|
|
19
|
+
require "omoteura/obsidian"
|
|
20
|
+
require "omoteura/outline"
|
|
21
|
+
require "omoteura/project"
|
|
22
|
+
require "omoteura/resolver"
|
|
23
|
+
require "omoteura/source_index"
|
|
24
|
+
require "omoteura/test_results"
|
|
25
|
+
require "omoteura/writer"
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omoteura
|
|
4
|
+
# Which of a code file's declarations and public methods its specs cover,
|
|
5
|
+
# for the editor's coverage panel. Separate from diagrams: it works on any
|
|
6
|
+
# file and needs none.
|
|
7
|
+
#
|
|
8
|
+
# An item counts as covered by an example that names it, in the example's
|
|
9
|
+
# description or its code: "validates :email" by an example mentioning
|
|
10
|
+
# email, "def display_name" by one inside describe "#display_name". The
|
|
11
|
+
# descriptions RSpec generated in the last run, such as those of
|
|
12
|
+
# shoulda-matchers one-liners, count as well. A method also counts as
|
|
13
|
+
# covered when its body ran in the last run, if that run measured
|
|
14
|
+
# coverage.
|
|
15
|
+
class CoverageReport
|
|
16
|
+
FORMAT_VERSION = 1
|
|
17
|
+
|
|
18
|
+
def initialize(config, results: TestResults.new([]))
|
|
19
|
+
@config = config
|
|
20
|
+
@results = results
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# code_path must be an existing file inside the project root.
|
|
24
|
+
def report(code_path)
|
|
25
|
+
file = @config.expand(code_path) or raise ArgumentError, "#{code_path} is outside the project"
|
|
26
|
+
relative = @config.relative(file)
|
|
27
|
+
data = {version: FORMAT_VERSION, code_path: relative, enabled: @config.coverage_panel, spec_paths: [], items: []}
|
|
28
|
+
return data unless @config.coverage_panel
|
|
29
|
+
|
|
30
|
+
specs = spec_paths(relative)
|
|
31
|
+
examples = specs.flat_map { |spec| examples_in(spec) }
|
|
32
|
+
items = Outline.new(SourceIndex.parse_file(file)).items
|
|
33
|
+
data.merge(spec_paths: specs, items: items.map { |item| item_data(item, examples, relative) })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
ONE_LINER = /\A(?:it|specify|example|its)\b(?!\s*["'(])/
|
|
39
|
+
# label is what the panel shows; description is what names are looked for in.
|
|
40
|
+
Example = Data.define(:path, :line, :label, :description, :source, :status)
|
|
41
|
+
private_constant :ONE_LINER, :Example
|
|
42
|
+
|
|
43
|
+
# The specs for a file by RSpec's conventions: app/models/user.rb is
|
|
44
|
+
# tested in spec/models/user_spec.rb, lib/a/b.rb in spec/a/b_spec.rb or
|
|
45
|
+
# spec/lib/a/b_spec.rb.
|
|
46
|
+
def spec_paths(code_path)
|
|
47
|
+
base = code_path.delete_suffix(File.extname(code_path))
|
|
48
|
+
candidates =
|
|
49
|
+
if base.start_with?("app/") then ["spec/#{base.delete_prefix("app/")}_spec.rb"]
|
|
50
|
+
elsif base.start_with?("lib/") then ["spec/#{base.delete_prefix("lib/")}_spec.rb", "spec/#{base}_spec.rb"]
|
|
51
|
+
else ["spec/#{base}_spec.rb"]
|
|
52
|
+
end
|
|
53
|
+
candidates.select { |path| (full = @config.expand(path)) && File.file?(full) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def examples_in(spec_path)
|
|
57
|
+
source = File.read(File.join(@config.root, spec_path), encoding: Encoding::UTF_8)
|
|
58
|
+
lines = source.lines
|
|
59
|
+
recorded = @results.examples.select { |e| e.file_path.delete_prefix("./") == spec_path }.to_h { |e| [e.line_number, e] }
|
|
60
|
+
index = SourceIndex.new(source)
|
|
61
|
+
described = index.spec_entries.reject(&:group)
|
|
62
|
+
# Examples without a description, such as it { is_expected.to ... },
|
|
63
|
+
# are statements only; their description comes from the last run.
|
|
64
|
+
lines_taken = described.to_h { |entry| [entry.start_line, true] }
|
|
65
|
+
undescribed = index.statement_entries.select { |e| e.name.match?(ONE_LINER) && !lines_taken[e.start_line] }
|
|
66
|
+
(described + undescribed).sort_by(&:start_line).map do |entry|
|
|
67
|
+
result = recorded[entry.start_line]
|
|
68
|
+
static = (entry.kind == :spec) ? entry.name : nil
|
|
69
|
+
description = [static, result&.full_description].compact.uniq.join("\n")
|
|
70
|
+
Example.new(path: spec_path, line: entry.start_line, label: static || result&.full_description || entry.name, description: description,
|
|
71
|
+
source: lines[(entry.start_line - 1)...entry.end_line].join, status: result&.status)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def item_data(item, examples, code_path)
|
|
76
|
+
entry = item.entry
|
|
77
|
+
patterns = names_of(item).map { |name| patterns_for(name) }
|
|
78
|
+
covering = examples.select { |example| patterns.any? { |words, code| example.description.match?(words) || example.source.match?(code) } }
|
|
79
|
+
executed = executed?(item, code_path)
|
|
80
|
+
{
|
|
81
|
+
kind: item.kind,
|
|
82
|
+
name: entry.name,
|
|
83
|
+
start_line: entry.start_line,
|
|
84
|
+
end_line: entry.end_line,
|
|
85
|
+
covered: !covering.empty? || executed == true,
|
|
86
|
+
executed: executed,
|
|
87
|
+
examples: covering.map { |e| {path: e.path, line: e.line, description: e.label, status: e.status} }
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Whether a method's body ran: its def line runs when the class loads,
|
|
92
|
+
# so only the lines after it tell. nil for declarations, which run when
|
|
93
|
+
# the class loads, and for methods on one line.
|
|
94
|
+
def executed?(item, code_path)
|
|
95
|
+
entry = item.entry
|
|
96
|
+
return nil unless item.kind == "methods" && entry.end_line - entry.start_line >= 2
|
|
97
|
+
|
|
98
|
+
@results.executed?(code_path, (entry.start_line + 1)...entry.end_line)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# What an example would call the item: the symbols a declaration names,
|
|
102
|
+
# such as email and name in "validates :email, :name, presence: true",
|
|
103
|
+
# or a method's name.
|
|
104
|
+
def names_of(item)
|
|
105
|
+
return [item.entry.name.delete_prefix("self.")] if item.kind == "methods"
|
|
106
|
+
|
|
107
|
+
call = item.entry.name.sub(/\A\S+\s*/, "")
|
|
108
|
+
symbols = call.scan(/(?<![\w:]):(\w+[?!]?)/).flatten
|
|
109
|
+
# enum role: { ... }, the older syntax, names its attribute as a key.
|
|
110
|
+
symbols = call.scan(/\A(\w+):/).flatten if symbols.empty?
|
|
111
|
+
symbols.reject { |name| name.length < 2 }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The name as a word in an example's description, and in its code.
|
|
115
|
+
# Underscores may be spaces in a description: "display name" for
|
|
116
|
+
# display_name. Built once per name, not per example.
|
|
117
|
+
def patterns_for(name)
|
|
118
|
+
code = Regexp.escape(name)
|
|
119
|
+
[/(?<!\w)#{code.gsub("_", "[_ ]")}(?![\w?!])/i, /(?<!\w)#{code}(?![\w?!])/]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|