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.
@@ -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