uml-viewer 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/CHANGELOG.md +16 -0
- data/LICENSE +21 -0
- data/README.md +58 -0
- data/exe/uml-viewer +4 -0
- data/lib/uml/viewer/cli.rb +133 -0
- data/lib/uml/viewer/commands.rb +51 -0
- data/lib/uml/viewer/complexity.rb +28 -0
- data/lib/uml/viewer/coverage.rb +67 -0
- data/lib/uml/viewer/graph.rb +113 -0
- data/lib/uml/viewer/inflector.rb +19 -0
- data/lib/uml/viewer/mailbox.rb +79 -0
- data/lib/uml/viewer/operations.rb +105 -0
- data/lib/uml/viewer/overlay.rb +165 -0
- data/lib/uml/viewer/policy.rb +59 -0
- data/lib/uml/viewer/policy_file.rb +124 -0
- data/lib/uml/viewer/policy_init.rb +51 -0
- data/lib/uml/viewer/proposals.rb +78 -0
- data/lib/uml/viewer/rescanner.rb +61 -0
- data/lib/uml/viewer/resolver.rb +50 -0
- data/lib/uml/viewer/scanner.rb +349 -0
- data/lib/uml/viewer/server.rb +226 -0
- data/lib/uml/viewer/skill/SKILL.md.erb +44 -0
- data/lib/uml/viewer/skill_installer.rb +29 -0
- data/lib/uml/viewer/smoke.rb +61 -0
- data/lib/uml/viewer/version.rb +5 -0
- data/lib/uml/viewer/views.rb +94 -0
- data/lib/uml/viewer/watcher.rb +66 -0
- data/lib/uml/viewer/web/LICENSE-EPL-2.0 +263 -0
- data/lib/uml/viewer/web/app.js +526 -0
- data/lib/uml/viewer/web/elk.bundled.js +6605 -0
- data/lib/uml/viewer/web/index.html +65 -0
- data/lib/uml/viewer/workspace.rb +75 -0
- data/lib/uml/viewer.rb +23 -0
- metadata +110 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "prism"
|
|
2
|
+
|
|
3
|
+
module Uml
|
|
4
|
+
module Viewer
|
|
5
|
+
class Operations
|
|
6
|
+
VISIBILITIES = %i[public private protected].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(program, file)
|
|
9
|
+
@program = program
|
|
10
|
+
@file = file
|
|
11
|
+
@ops = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def collect
|
|
15
|
+
scope(@program.statements.body, singleton: false)
|
|
16
|
+
@ops
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def scope(statements, singleton:)
|
|
22
|
+
state = { visibility: "public", named: {}, private_singletons: [], added: [] }
|
|
23
|
+
statements.each { |node| visit(node, state, singleton) }
|
|
24
|
+
state[:added].each { |op, receiver| settle(op, receiver, state) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def visit(node, state, singleton)
|
|
28
|
+
case node
|
|
29
|
+
when Prism::DefNode then state[:added] << add(node, state[:visibility], singleton)
|
|
30
|
+
when Prism::ClassNode, Prism::ModuleNode then scope(statements_of(node.body), singleton: false)
|
|
31
|
+
when Prism::SingletonClassNode then scope(statements_of(node.body), singleton: true)
|
|
32
|
+
when Prism::ConstantWriteNode, Prism::ConstantPathWriteNode then scope_block(node.value, singleton: false)
|
|
33
|
+
when Prism::CallNode then visit_call(node, state, singleton)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def visit_call(node, state, singleton)
|
|
38
|
+
arguments = node.arguments&.arguments
|
|
39
|
+
if node.receiver.nil? && VISIBILITIES.include?(node.name)
|
|
40
|
+
set_visibility(node.name.to_s, arguments, state, singleton)
|
|
41
|
+
elsif node.receiver.nil? && node.name == :private_class_method
|
|
42
|
+
arguments.to_a.each do |argument|
|
|
43
|
+
state[:added] << add(argument, "public", singleton) if argument.is_a?(Prism::DefNode)
|
|
44
|
+
name = argument.is_a?(Prism::DefNode) ? argument.name.to_s : literal(argument)
|
|
45
|
+
state[:private_singletons] << name if name
|
|
46
|
+
end
|
|
47
|
+
else
|
|
48
|
+
scope_block(node, singleton: node.name == :class_methods)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def set_visibility(visibility, arguments, state, singleton)
|
|
53
|
+
if arguments.nil?
|
|
54
|
+
state[:visibility] = visibility
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
arguments.each do |argument|
|
|
59
|
+
if argument.is_a?(Prism::DefNode)
|
|
60
|
+
state[:added] << add(argument, visibility, singleton)
|
|
61
|
+
elsif (name = literal(argument))
|
|
62
|
+
state[:named][name] = visibility
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def settle(op, receiver, state)
|
|
68
|
+
name = op["name"].delete_prefix("self.")
|
|
69
|
+
if receiver
|
|
70
|
+
op["visibility"] = "private" if state[:private_singletons].include?(name)
|
|
71
|
+
elsif state[:named].key?(name)
|
|
72
|
+
op["visibility"] = state[:named][name]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def add(node, visibility, singleton)
|
|
77
|
+
receiver = !node.receiver.nil?
|
|
78
|
+
op = {
|
|
79
|
+
"name" => receiver || singleton ? "self.#{node.name}" : node.name.to_s,
|
|
80
|
+
"visibility" => receiver ? "public" : visibility,
|
|
81
|
+
"cc" => Complexity.of(node),
|
|
82
|
+
"file" => @file,
|
|
83
|
+
"line_start" => node.location.start_line,
|
|
84
|
+
"line_end" => node.location.end_line
|
|
85
|
+
}
|
|
86
|
+
@ops << op
|
|
87
|
+
[op, receiver]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def scope_block(node, singleton:)
|
|
91
|
+
return unless node.is_a?(Prism::CallNode) && node.block.is_a?(Prism::BlockNode)
|
|
92
|
+
|
|
93
|
+
scope(statements_of(node.block.body), singleton: singleton)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def statements_of(body)
|
|
97
|
+
body.is_a?(Prism::StatementsNode) ? body.body : []
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def literal(node)
|
|
101
|
+
node.unescaped if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
module Uml
|
|
2
|
+
module Viewer
|
|
3
|
+
module Overlay
|
|
4
|
+
NO_COVERAGE = "no coverage: run your tests with SimpleCov".freeze
|
|
5
|
+
STALE = "coverage older than this file".freeze
|
|
6
|
+
NOT_LOADED = "not loaded by the tests".freeze
|
|
7
|
+
UNTESTED = "no test runs these methods".freeze
|
|
8
|
+
PARTIAL = "coverage looks partial: run the full suite".freeze
|
|
9
|
+
COUNTS = %w[killed survived uncovered].freeze
|
|
10
|
+
|
|
11
|
+
def self.apply(classes, coverage:, mtimes:, mutation:, cutoffs:, missing_note: NO_COVERAGE)
|
|
12
|
+
files = classes.flat_map { |entry| entry["files"] }.uniq
|
|
13
|
+
present = coverage ? files.count { |file| coverage["lines"].key?(file) } : 0
|
|
14
|
+
partial = !coverage.nil? && present < files.size / 2.0
|
|
15
|
+
states = files.to_h { |file| [file, file_state(file, coverage, mtimes, partial)] }
|
|
16
|
+
notes = { none: missing_note, stale: STALE, absent: NOT_LOADED, partial: PARTIAL }
|
|
17
|
+
graded = classes.map do |entry|
|
|
18
|
+
ops = entry["ops"].map { |op| with_metrics(op, states[op["file"]], coverage) }
|
|
19
|
+
crap = class_crap(ops, cutoffs)
|
|
20
|
+
counts = mutation_counts(entry["id"], ops, mutation)
|
|
21
|
+
tested = counts && mutation_grade(counts)
|
|
22
|
+
entry.merge(
|
|
23
|
+
"ops" => ops, "coverage_notes" => coverage_notes(entry["files"], states, notes, ops),
|
|
24
|
+
"crap" => crap, "mutation" => tested, "grade" => mean_grade(crap, tested)
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
{ "classes" => graded, "coverage" => summary(coverage, present, files.size, partial) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.coverage_notes(files, states, notes, ops)
|
|
31
|
+
listed = files.filter_map { |file| notes[states[file]] }.uniq
|
|
32
|
+
loaded = files.any? { |file| states[file] == :lines }
|
|
33
|
+
loaded && no_test_runs?(measured_ops(ops)) ? listed + [UNTESTED] : listed
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.measured_ops(ops)
|
|
37
|
+
ops.reject { |op| op["coverage"].nil? }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.no_test_runs?(measured)
|
|
41
|
+
measured.any? && measured.all? { |op| op["coverage"].zero? }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.class_crap(ops, cutoffs)
|
|
45
|
+
measured = measured_ops(ops)
|
|
46
|
+
return nil if measured.empty?
|
|
47
|
+
|
|
48
|
+
untested(stats(measured.map { |op| op["crap"] }, cutoffs), measured)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.untested(crap, measured)
|
|
52
|
+
no_test_runs?(measured) ? crap.merge("grade" => 1) : crap
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.file_state(file, coverage, mtimes, partial)
|
|
56
|
+
return :none if coverage.nil?
|
|
57
|
+
return :stale if mtimes[file].to_f > coverage["time"].to_f
|
|
58
|
+
return :lines if coverage["lines"].key?(file)
|
|
59
|
+
|
|
60
|
+
partial ? :partial : :absent
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.with_metrics(op, state, coverage)
|
|
64
|
+
value = case state
|
|
65
|
+
when :absent then 0.0
|
|
66
|
+
when :lines then body_coverage(op, coverage["lines"][op["file"]])
|
|
67
|
+
end
|
|
68
|
+
op.merge("coverage" => value, "crap" => value && crap(op["cc"], value))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.body_coverage(op, hits)
|
|
72
|
+
body = ((op["line_start"] + 1)..(op["line_end"] - 1)).filter_map { |line| hits[line - 1] }
|
|
73
|
+
body.empty? ? nil : body.count(&:positive?).fdiv(body.size)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.crap(cc, coverage)
|
|
77
|
+
(cc**2) * ((1 - coverage)**3) + cc.to_f
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.stats(values, cutoffs)
|
|
81
|
+
mu = values.sum.fdiv(values.size)
|
|
82
|
+
sigma = Math.sqrt(values.sum { |value| (value - mu)**2 }.fdiv(values.size))
|
|
83
|
+
{ "mu" => mu, "sigma" => sigma, "grade" => grade(mu + sigma, cutoffs) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.grade(value, cutoffs)
|
|
87
|
+
index = cutoffs.index { |cutoff| value <= cutoff }
|
|
88
|
+
index ? 10 - index : 1
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.mutation_counts(class_id, ops, mutation)
|
|
92
|
+
return nil if mutation.nil?
|
|
93
|
+
|
|
94
|
+
found = ops.filter_map { |op| mutation[mutation_key(class_id, op["name"])] }
|
|
95
|
+
return nil if found.empty?
|
|
96
|
+
|
|
97
|
+
COUNTS.to_h { |key| [key, found.sum { |entry| entry[key].to_i }] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.mutation_key(class_id, name)
|
|
101
|
+
name.start_with?("self.") ? "#{class_id}.#{name.delete_prefix('self.')}" : "#{class_id}##{name}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.mutation_grade(counts)
|
|
105
|
+
total = counts.values.sum
|
|
106
|
+
return nil if total.zero?
|
|
107
|
+
|
|
108
|
+
score = counts["killed"].fdiv(total)
|
|
109
|
+
{ "score" => score, "grade" => [1, (score * 10).ceil].max, "counts" => counts }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.mean_grade(crap, tested)
|
|
113
|
+
grades = [crap&.fetch("grade"), tested&.fetch("grade")].compact
|
|
114
|
+
grades.empty? ? nil : grades.sum.fdiv(grades.size)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.summary(coverage, present, scanned, partial)
|
|
118
|
+
return { "time" => nil, "files" => 0, "scanned" => scanned, "banner" => nil } if coverage.nil?
|
|
119
|
+
|
|
120
|
+
banner = partial ? "coverage looks partial: #{present} of #{scanned} files; run the full suite" : nil
|
|
121
|
+
{ "time" => coverage["time"], "files" => present, "scanned" => scanned, "banner" => banner }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.boxes(views, classes, cutoffs)
|
|
125
|
+
by_id = classes.to_h { |entry| [entry["id"], entry] }
|
|
126
|
+
memo = {}
|
|
127
|
+
views.transform_values do |view|
|
|
128
|
+
view.merge("nodes" => view["nodes"].map { |node| node.merge(node_grades(node, views, by_id, cutoffs, memo)) })
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def self.node_grades(node, views, by_id, cutoffs, memo)
|
|
133
|
+
return class_grades(by_id[node["id"]]) if node["type"] == "class"
|
|
134
|
+
return {} unless Views::BOXES.include?(node["type"])
|
|
135
|
+
|
|
136
|
+
entries = classes_under(node["id"], views, memo).filter_map { |id| by_id[id] }
|
|
137
|
+
pooled(entries, cutoffs)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.class_grades(entry)
|
|
141
|
+
return { "c_grade" => nil, "m_grade" => nil, "grade" => nil } if entry.nil?
|
|
142
|
+
|
|
143
|
+
{ "c_grade" => entry["crap"]&.fetch("grade"), "m_grade" => entry["mutation"]&.fetch("grade"), "grade" => entry["grade"] }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.pooled(entries, cutoffs)
|
|
147
|
+
measured = entries.flat_map { |entry| measured_ops(entry["ops"]) }
|
|
148
|
+
counts = entries.filter_map { |entry| entry.dig("mutation", "counts") }
|
|
149
|
+
crap = measured.empty? ? nil : untested(stats(measured.map { |op| op["crap"] }, cutoffs), measured)
|
|
150
|
+
tested = counts.empty? ? nil : mutation_grade(COUNTS.to_h { |key| [key, counts.sum { |count| count[key] }] })
|
|
151
|
+
{ "c_grade" => crap&.fetch("grade"), "m_grade" => tested&.fetch("grade"), "grade" => mean_grade(crap, tested) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.classes_under(view_id, views, memo)
|
|
155
|
+
memo[view_id] ||= views[view_id]["nodes"].flat_map do |node|
|
|
156
|
+
case node["type"]
|
|
157
|
+
when "class" then [node["id"]]
|
|
158
|
+
when *Views::BOXES then classes_under(node["id"], views, memo)
|
|
159
|
+
else []
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module Uml
|
|
2
|
+
module Viewer
|
|
3
|
+
module Policy
|
|
4
|
+
def self.apply(scan, policy)
|
|
5
|
+
omit = policy["omit"]
|
|
6
|
+
classes = scan["classes"].reject { |entry| omitted_by_name?(entry["id"], omit) }
|
|
7
|
+
namespaces = scan["namespaces"].select { |name| classes.any? { |entry| entry["id"].start_with?("#{name}::") } }
|
|
8
|
+
classes = classes.reject { |entry| omit.include?("folder:#{entry['kind_folder']}") && top_level?(entry, namespaces) }
|
|
9
|
+
levels = level_index(policy["levels"])
|
|
10
|
+
classes = classes.map { |entry| entry.merge("level" => levels[entry["kind_folder"]]) }
|
|
11
|
+
by_id = classes.to_h { |entry| [entry["id"], entry] }
|
|
12
|
+
edges = scan["edges"].select { |edge| by_id.key?(edge["from"]) && by_id.key?(edge["to"]) }
|
|
13
|
+
edges = adjust_edges(edges, policy["edge_kinds"], policy["omit_edges"])
|
|
14
|
+
.map { |edge| edge.merge("violating" => violating?(edge, by_id)) }
|
|
15
|
+
foreign_edges = scan["foreign_edges"].select { |edge| policy["foreign"].include?(edge["to"]) && by_id.key?(edge["from"]) }
|
|
16
|
+
proposals = Proposals.build(policy["proposals"], classes: classes, namespaces: namespaces, edges: edges, foreign_edges: foreign_edges)
|
|
17
|
+
{
|
|
18
|
+
"classes" => classes,
|
|
19
|
+
"edges" => edges,
|
|
20
|
+
"namespaces" => namespaces,
|
|
21
|
+
"views" => Views.build(classes, namespaces, edges, foreign_edges),
|
|
22
|
+
"foreign_edges" => foreign_edges,
|
|
23
|
+
"proposals" => proposals,
|
|
24
|
+
"warnings" => (policy["levels"].flatten.uniq - scan["folders"]).map { |name| "levels: unknown folder #{name}" } +
|
|
25
|
+
proposals.flat_map { |proposal| proposal["warnings"] }
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.omitted_by_name?(id, omit)
|
|
30
|
+
omit.any? { |name| id == name || id.start_with?("#{name}::") }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.top_level?(entry, namespaces)
|
|
34
|
+
!entry["id"].include?("::") && !namespaces.include?(entry["id"])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.adjust_edges(edges, edge_kinds, omit_edges)
|
|
38
|
+
edges.reject { |edge| omit_edges.include?(pair_key(edge)) }
|
|
39
|
+
.map { |edge| edge_kinds.key?(pair_key(edge)) ? edge.merge("kind" => edge_kinds[pair_key(edge)], "kinds" => [edge_kinds[pair_key(edge)]]) : edge }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.pair_key(edge)
|
|
43
|
+
"#{edge['from']} -> #{edge['to']}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.level_index(levels)
|
|
47
|
+
levels.each_with_index.flat_map { |row, index| row.map { |folder| [folder, index] } }.to_h
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.violating?(edge, by_id)
|
|
51
|
+
from = by_id[edge["from"]]["level"]
|
|
52
|
+
to = by_id[edge["to"]]["level"]
|
|
53
|
+
return false if from.nil? || to.nil? || from >= to
|
|
54
|
+
|
|
55
|
+
edge["kinds"].any? { |kind| kind != "association" }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Uml
|
|
4
|
+
module Viewer
|
|
5
|
+
module PolicyFile
|
|
6
|
+
class Invalid < StandardError; end
|
|
7
|
+
|
|
8
|
+
DEFAULTS = {
|
|
9
|
+
"title" => "",
|
|
10
|
+
"sources" => %w[app lib],
|
|
11
|
+
"skip" => [],
|
|
12
|
+
"levels" => [],
|
|
13
|
+
"foreign" => [],
|
|
14
|
+
"omit" => [],
|
|
15
|
+
"edge_kinds" => {},
|
|
16
|
+
"omit_edges" => [],
|
|
17
|
+
"crap_cutoffs" => [5, 10, 15, 20, 30, 40, 60, 80, 120],
|
|
18
|
+
"proposals" => []
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
NAME_LISTS = %w[sources skip foreign omit omit_edges].freeze
|
|
22
|
+
|
|
23
|
+
def self.load(path)
|
|
24
|
+
defaults = Marshal.load(Marshal.dump(DEFAULTS))
|
|
25
|
+
return defaults unless File.exist?(path)
|
|
26
|
+
|
|
27
|
+
data = YAML.safe_load(File.read(path)) || {}
|
|
28
|
+
raise Invalid, "policy.yml: the file must hold a mapping" unless data.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
validate(defaults.merge(data) { |_key, default, given| given.nil? ? default : given })
|
|
31
|
+
rescue Psych::SyntaxError => error
|
|
32
|
+
raise Invalid, "policy.yml line #{error.line}: #{error.problem}"
|
|
33
|
+
rescue Psych::Exception => error
|
|
34
|
+
raise Invalid, "policy.yml: #{error.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.validate(policy)
|
|
38
|
+
NAME_LISTS.each do |key|
|
|
39
|
+
next if policy[key].is_a?(Array) && policy[key].all?(String)
|
|
40
|
+
|
|
41
|
+
raise Invalid, "policy.yml: #{key} must be a list of names"
|
|
42
|
+
end
|
|
43
|
+
unless policy["levels"].is_a?(Array) && policy["levels"].all? { |row| row.is_a?(Array) && row.all?(String) }
|
|
44
|
+
raise Invalid, "policy.yml: levels must be a list of lists of folder names"
|
|
45
|
+
end
|
|
46
|
+
raise Invalid, "policy.yml: edge_kinds must be a mapping" unless policy["edge_kinds"].is_a?(Hash)
|
|
47
|
+
unless policy["edge_kinds"].all? { |pair, kind| pair.is_a?(String) && pair.match?(/\A\S+ -> \S+\z/) && Scanner::KINDS.include?(kind) }
|
|
48
|
+
raise Invalid, "policy.yml: edge_kinds maps \"From -> To\" to one of #{Scanner::KINDS.join(', ')}"
|
|
49
|
+
end
|
|
50
|
+
raise Invalid, "policy.yml: title must be text" unless policy["title"].is_a?(String)
|
|
51
|
+
policy["proposals"] = proposals(policy["proposals"])
|
|
52
|
+
cutoffs = policy["crap_cutoffs"]
|
|
53
|
+
unless cutoffs.is_a?(Array) && cutoffs.size == 9 && cutoffs.all?(Numeric) && cutoffs.each_cons(2).all? { |low, high| low < high }
|
|
54
|
+
raise Invalid, "policy.yml: crap_cutoffs must be 9 numbers in rising order"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
policy
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.proposals(list)
|
|
61
|
+
raise Invalid, "policy.yml: proposals must be a list" unless list.is_a?(Array)
|
|
62
|
+
|
|
63
|
+
seen = []
|
|
64
|
+
list.map do |proposal|
|
|
65
|
+
raise Invalid, "policy.yml: each proposal must be a mapping with an id" unless proposal.is_a?(Hash) && proposal.key?("id")
|
|
66
|
+
raise Invalid, "policy.yml: a proposal id must be text" unless text?(proposal["id"])
|
|
67
|
+
|
|
68
|
+
id = proposal["id"]
|
|
69
|
+
raise Invalid, "policy.yml: proposal ids must be unique: #{id}" if seen.include?(id)
|
|
70
|
+
|
|
71
|
+
seen << id
|
|
72
|
+
proposal(proposal)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.text?(value)
|
|
77
|
+
value.is_a?(String) && !value.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.proposal(proposal)
|
|
81
|
+
id = proposal["id"]
|
|
82
|
+
name = proposal["name"] || id
|
|
83
|
+
raise Invalid, "policy.yml: proposal #{id}: name must be text" unless name.is_a?(String)
|
|
84
|
+
|
|
85
|
+
omit = proposal["omit"] || []
|
|
86
|
+
raise Invalid, "policy.yml: proposal #{id}: omit must be a list of names" unless omit.is_a?(Array) && omit.all?(String)
|
|
87
|
+
|
|
88
|
+
proposal.merge("name" => name, "components" => components(id, proposal["components"] || [], []), "omit" => omit)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.components(proposal_id, list, seen)
|
|
92
|
+
raise Invalid, "policy.yml: proposal #{proposal_id}: components must be a list" unless list.is_a?(Array)
|
|
93
|
+
|
|
94
|
+
list.map do |component|
|
|
95
|
+
raise Invalid, "policy.yml: proposal #{proposal_id}: each component must be a mapping with an id" unless component.is_a?(Hash) && component.key?("id")
|
|
96
|
+
raise Invalid, "policy.yml: proposal #{proposal_id}: a component id must be text" unless text?(component["id"])
|
|
97
|
+
|
|
98
|
+
id = component["id"]
|
|
99
|
+
raise Invalid, "policy.yml: proposal #{proposal_id}: component ids must be unique: #{id}" if seen.include?(id)
|
|
100
|
+
|
|
101
|
+
seen << id
|
|
102
|
+
component(proposal_id, component, seen)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.component(proposal_id, component, seen)
|
|
107
|
+
prefix = "policy.yml: proposal #{proposal_id}: component #{component['id']}"
|
|
108
|
+
label = component["label"] || component["id"]
|
|
109
|
+
raise Invalid, "#{prefix}: label must be text" unless label.is_a?(String)
|
|
110
|
+
|
|
111
|
+
members = component["members"] || []
|
|
112
|
+
raise Invalid, "#{prefix}: members must be a list" unless members.is_a?(Array)
|
|
113
|
+
|
|
114
|
+
members = members.map do |member|
|
|
115
|
+
next member if member.is_a?(String)
|
|
116
|
+
next components(proposal_id, [member], seen).first if member.is_a?(Hash)
|
|
117
|
+
|
|
118
|
+
raise Invalid, "#{prefix}: a member is a name or a component, not #{member.inspect}"
|
|
119
|
+
end
|
|
120
|
+
component.merge("label" => label, "members" => members)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Uml
|
|
4
|
+
module Viewer
|
|
5
|
+
module PolicyInit
|
|
6
|
+
DEFAULT_LEVELS = [
|
|
7
|
+
%w[models],
|
|
8
|
+
%w[services queries policies lib uploaders validators inputs],
|
|
9
|
+
%w[jobs mailers channels presenters decorators serializers],
|
|
10
|
+
%w[controllers helpers components admin graphql]
|
|
11
|
+
].freeze
|
|
12
|
+
SKIPPED_KINDS = %w[assets views javascript frontend].freeze
|
|
13
|
+
OMIT_CANDIDATES = %w[ApplicationRecord ApplicationController ApplicationJob ApplicationMailer].freeze
|
|
14
|
+
|
|
15
|
+
def self.folder_kinds(root)
|
|
16
|
+
kinds = Dir.glob("app/*/", base: root).map { |folder| File.basename(folder) } - SKIPPED_KINDS
|
|
17
|
+
kinds << "lib" if File.directory?(File.join(root, "lib"))
|
|
18
|
+
kinds.uniq.sort
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.write(workspace, class_ids:)
|
|
22
|
+
return false if File.exist?(workspace.path("policy.yml"))
|
|
23
|
+
|
|
24
|
+
kinds = folder_kinds(workspace.root)
|
|
25
|
+
levels = DEFAULT_LEVELS.map { |row| row & kinds }.reject(&:empty?)
|
|
26
|
+
unlisted = kinds - DEFAULT_LEVELS.flatten
|
|
27
|
+
policy = {
|
|
28
|
+
"title" => File.basename(workspace.root),
|
|
29
|
+
"sources" => %w[app lib],
|
|
30
|
+
"skip" => [],
|
|
31
|
+
"levels" => levels,
|
|
32
|
+
"foreign" => [],
|
|
33
|
+
"omit" => OMIT_CANDIDATES & class_ids,
|
|
34
|
+
"edge_kinds" => {},
|
|
35
|
+
"omit_edges" => [],
|
|
36
|
+
"proposals" => []
|
|
37
|
+
}
|
|
38
|
+
workspace.write_atomic("policy.yml", with_unlisted_comment(YAML.dump(policy), unlisted))
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.with_unlisted_comment(text, unlisted)
|
|
43
|
+
return text if unlisted.empty?
|
|
44
|
+
|
|
45
|
+
lines = text.lines
|
|
46
|
+
position = lines.index { |line| line.start_with?("levels:") }
|
|
47
|
+
lines.insert(position + 1, "# not compared: #{unlisted.join(', ')}\n").join
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Uml
|
|
2
|
+
module Viewer
|
|
3
|
+
module Proposals
|
|
4
|
+
EXACT = 1_000
|
|
5
|
+
|
|
6
|
+
def self.build(proposals, classes:, namespaces:, edges:, foreign_edges:)
|
|
7
|
+
real_paths = Views.paths(classes, namespaces)
|
|
8
|
+
proposals.map { |proposal| view_of(proposal, classes, namespaces, edges, foreign_edges, real_paths) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.view_of(proposal, classes, namespaces, edges, foreign_edges, real_paths)
|
|
12
|
+
members = members_of(proposal)
|
|
13
|
+
placed = classes.to_h { |entry| [entry["id"], place(entry, proposal["id"], members, namespaces, real_paths[entry["id"]])] }
|
|
14
|
+
kept = classes.reject { |entry| omitted?(entry, placed[entry["id"]][:path], proposal["omit"], namespaces) }
|
|
15
|
+
.map { |entry| entry.merge("level" => placed[entry["id"]][:rank]) }
|
|
16
|
+
by_id = kept.to_h { |entry| [entry["id"], entry] }
|
|
17
|
+
kept_edges = edges.select { |edge| by_id.key?(edge["from"]) && by_id.key?(edge["to"]) }
|
|
18
|
+
.map { |edge| edge.merge("violating" => Policy.violating?(edge, by_id)) }
|
|
19
|
+
{
|
|
20
|
+
"id" => proposal["id"],
|
|
21
|
+
"name" => proposal["name"],
|
|
22
|
+
"views" => Views.build(kept, namespaces, kept_edges, foreign_edges.select { |edge| by_id.key?(edge["from"]) },
|
|
23
|
+
paths: kept.to_h { |entry| [entry["id"], placed[entry["id"]][:path]] }),
|
|
24
|
+
"warnings" => unmatched(members, classes, namespaces).map { |name| "proposal #{proposal['id']}: member #{name} matches no class in the diagram (check the name and the policy omit)" }
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.members_of(proposal)
|
|
29
|
+
list = []
|
|
30
|
+
walk = lambda do |components, chain, rank|
|
|
31
|
+
components.each_with_index do |component, index|
|
|
32
|
+
own_rank = rank || index
|
|
33
|
+
own_chain = chain + [["component", "component:#{proposal['id']}/#{component['id']}", component["label"]]]
|
|
34
|
+
component["members"].each do |member|
|
|
35
|
+
next walk.call([member], own_chain, own_rank) if member.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
list << { name: member, chain: own_chain, rank: own_rank, order: list.size }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
walk.call(proposal["components"], [], nil)
|
|
42
|
+
list
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.place(entry, proposal_id, members, namespaces, real_path)
|
|
46
|
+
best = members.filter_map { |member| (score = specificity(member[:name], entry, namespaces)) && [score, -member[:order], member] }
|
|
47
|
+
.max_by { |score, order, _| [score, order] }
|
|
48
|
+
return { path: [["component", "unassigned:#{proposal_id}", "Unassigned"]] + real_path, rank: nil } if best.nil?
|
|
49
|
+
|
|
50
|
+
member = best.last
|
|
51
|
+
below = real_path.select { |type, id| type == "namespace" && id.start_with?("#{member[:name]}::") }
|
|
52
|
+
{ path: member[:chain] + below + [real_path.last], rank: member[:rank] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.specificity(name, entry, namespaces)
|
|
56
|
+
id = entry["id"]
|
|
57
|
+
return EXACT if name == id
|
|
58
|
+
return name.split("::").size if id.start_with?("#{name}::")
|
|
59
|
+
return 0 if name == "folder:#{entry['kind_folder']}" && Policy.top_level?(entry, namespaces)
|
|
60
|
+
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.omitted?(entry, path, omit, namespaces)
|
|
65
|
+
omit.any? do |name|
|
|
66
|
+
Policy.omitted_by_name?(entry["id"], [name]) ||
|
|
67
|
+
(name == "folder:#{entry['kind_folder']}" && Policy.top_level?(entry, namespaces)) ||
|
|
68
|
+
path.any? { |_, id| id == name }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.unmatched(members, classes, namespaces)
|
|
73
|
+
members.map { |member| member[:name] }.uniq
|
|
74
|
+
.reject { |name| classes.any? { |entry| specificity(name, entry, namespaces) } }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Uml
|
|
2
|
+
module Viewer
|
|
3
|
+
class Rescanner
|
|
4
|
+
attr_reader :error, :scans
|
|
5
|
+
|
|
6
|
+
def initialize(scan:)
|
|
7
|
+
@scan = scan
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
@wake = ConditionVariable.new
|
|
10
|
+
@dirty = false
|
|
11
|
+
@running = false
|
|
12
|
+
@scans = 0
|
|
13
|
+
@error = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def start
|
|
17
|
+
@running = true
|
|
18
|
+
@thread = Thread.new { work }
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def trigger
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
@dirty = true
|
|
25
|
+
@wake.signal
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stop
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
@running = false
|
|
32
|
+
@wake.signal
|
|
33
|
+
end
|
|
34
|
+
@thread&.join(2)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def work
|
|
40
|
+
loop do
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
@wake.wait(@mutex) while @running && !@dirty
|
|
43
|
+
return unless @running
|
|
44
|
+
|
|
45
|
+
@dirty = false
|
|
46
|
+
end
|
|
47
|
+
run_once
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def run_once
|
|
52
|
+
@scan.call
|
|
53
|
+
@error = nil
|
|
54
|
+
rescue StandardError, SystemStackError => failure
|
|
55
|
+
@error = failure.message
|
|
56
|
+
ensure
|
|
57
|
+
@scans += 1
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|