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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 43940d01f25e1e7ac319450763c3c3498654a77ac60de6a5ca1bf5dcc8ded444
|
|
4
|
+
data.tar.gz: 00cd3efb4a8ede7ffc031b56b8d4f3f16caf4fed27746e4723682d786343b411
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 03a181720c4fd021b9f7bc13dc5b3a414c9ad2b146d2197f51c0e6a5e6e198d3a46a8accfdffbc3b660cc5d6d9fbec86aabef1914fad90e73642b79d4c6dcaa3
|
|
7
|
+
data.tar.gz: 69bee8c7b56e6f0c6ec3a1b4f532ea67a43649d2f76f2483270af913afd366b0786e45b9823f087738fa2e4ad723f32d8648e214fc96fd7a4d730fc175b0961c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-10-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `uml-viewer <project>` scans a Ruby or Rails project with Prism and opens a live UML diagram in the browser. You drill down from folder and namespace boxes to classes and to source lines.
|
|
11
|
+
- Levels come from `policy.yml`. A dependency from an inner level to an outer level is a red arrow. Associations are never red.
|
|
12
|
+
- Box colors show CRAP from `coverage/.resultset.json` (SimpleCov) and M grades from an optional `mutation.json`. Grey means no data.
|
|
13
|
+
- Proposals regroup the code into ranked components without a change to the code.
|
|
14
|
+
- Declutter cycles through five modes on the `d` key.
|
|
15
|
+
- A right-click sends Explain or Omit to a Claude Code session through a mailbox. `uml-viewer install-skill` installs the skill, and `uml-viewer watch` wakes the session through the Monitor tool.
|
|
16
|
+
- State lives in `~/.uml-viewer/projects/`. Nothing is written into the viewed project.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Hopman
|
|
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/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# uml-viewer
|
|
2
|
+
|
|
3
|
+
A live UML diagram of a Ruby or Rails project in the browser. Red arrows break the Dependency Rule. A Claude Code session handles your clicks.
|
|
4
|
+
|
|
5
|
+
> Based on the ideas of [unclebob/uml-viewer](https://github.com/unclebob/uml-viewer) by Robert C. Martin (Uncle Bob), a Clojure tool. This gem reuses its ideas and its observed behavior for Ruby and Rails. No code is copied.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install uml-viewer
|
|
11
|
+
uml-viewer install-skill
|
|
12
|
+
uml-viewer path/to/project
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`install-skill` installs the Claude Code skill that handles the clicks from the diagram. Nothing is written into the viewed project. State lives in `~/.uml-viewer/projects/`.
|
|
16
|
+
|
|
17
|
+
## Navigate
|
|
18
|
+
|
|
19
|
+
- Double-click a box to open it. Press `Esc` to go back.
|
|
20
|
+
- Double-click a class to open its card. Click an operation to see its source.
|
|
21
|
+
- Hover an arrow to see the class pairs behind it.
|
|
22
|
+
- Right-click a box or a class to send **Explain** or **Omit** to Claude.
|
|
23
|
+
|
|
24
|
+
## Test quality
|
|
25
|
+
|
|
26
|
+
Box colors show test quality: green is a good grade, red is a poor grade, and grey dots mean no data. The class card says why.
|
|
27
|
+
|
|
28
|
+
- **Coverage and CRAP:** run your tests with [SimpleCov](https://github.com/simplecov-ruby/simplecov). The viewer reads `coverage/.resultset.json` in the project.
|
|
29
|
+
- **Mutation grades:** put a mutation snapshot as `mutation.json` in the state folder. `uml-viewer path` prints that folder.
|
|
30
|
+
|
|
31
|
+
## Proposals
|
|
32
|
+
|
|
33
|
+
Proposals regroup the code without a change to the code.
|
|
34
|
+
|
|
35
|
+
1. Press **New proposal**, or ask Claude for one. Claude writes it into `policy.yml`.
|
|
36
|
+
2. A proposal lists components in rank order. The first component is level 0.
|
|
37
|
+
3. A member is a class id, a namespace, or a folder box such as `folder:models`.
|
|
38
|
+
4. Choose the proposal in the diagram list. The diagram shows the code in its components, with red arrows by the ranks of the proposal.
|
|
39
|
+
|
|
40
|
+
Classes in no component wait in **Unassigned**.
|
|
41
|
+
|
|
42
|
+
## Declutter
|
|
43
|
+
|
|
44
|
+
Press `d` or the declutter button to cycle through five modes: one arrow per pair, triangles, hide members, hide classes, none.
|
|
45
|
+
|
|
46
|
+
## Check the skill
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
bundle exec rake eval:skill
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This runs the skill with `claude -p` on a copy of the fixture project and checks what it changed. It needs a logged-in `claude` and costs a few cents per run. `EVAL_MODEL`, `EVAL_BUDGET` and `EVAL_TIMEOUT` tune it.
|
|
53
|
+
|
|
54
|
+
## Credits and licence
|
|
55
|
+
|
|
56
|
+
- The ideas come from [unclebob/uml-viewer](https://github.com/unclebob/uml-viewer) by Robert C. Martin. No code is copied.
|
|
57
|
+
- [elkjs](https://github.com/kieler/elkjs) (EPL-2.0) does the layout. It is vendored in `lib/uml/viewer/web/`.
|
|
58
|
+
- uml-viewer is released under the [MIT licence](LICENSE).
|
data/exe/uml-viewer
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "optparse"
|
|
3
|
+
|
|
4
|
+
module Uml
|
|
5
|
+
module Viewer
|
|
6
|
+
class CLI
|
|
7
|
+
COMMANDS = %w[scan inbox done watch path init install-skill].freeze
|
|
8
|
+
USAGE = "usage: uml-viewer [path] | scan|inbox|watch|path|init [path] | done <id> [path] [--failed reason] | install-skill"
|
|
9
|
+
|
|
10
|
+
def initialize(argv, out: $stdout, err: $stderr)
|
|
11
|
+
@argv = argv.dup
|
|
12
|
+
@out = out
|
|
13
|
+
@err = err
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
command = COMMANDS.include?(@argv.first) ? @argv.shift : "open"
|
|
18
|
+
send("run_#{command.tr('-', '_')}")
|
|
19
|
+
rescue Interrupt
|
|
20
|
+
130
|
|
21
|
+
rescue Workspace::MissingProject, PolicyFile::Invalid => error
|
|
22
|
+
@err.puts error.message
|
|
23
|
+
1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def workspace
|
|
29
|
+
@workspace ||= Workspace.new(@argv.first || ".")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def run_path
|
|
33
|
+
@out.puts workspace.dir
|
|
34
|
+
0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run_init
|
|
38
|
+
policy = workspace.path("policy.yml")
|
|
39
|
+
if File.exist?(policy)
|
|
40
|
+
@out.puts "policy.yml exists: #{policy}"
|
|
41
|
+
else
|
|
42
|
+
Graph.prepare(workspace)
|
|
43
|
+
@out.puts "wrote #{policy}"
|
|
44
|
+
end
|
|
45
|
+
0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run_scan
|
|
49
|
+
Graph.prepare(workspace)
|
|
50
|
+
graph = Graph.build(workspace)
|
|
51
|
+
summary = graph["summary"]
|
|
52
|
+
violating = graph["edges"].count { |edge| edge["violating"] }
|
|
53
|
+
@out.puts "#{graph['classes'].size} classes, #{graph['edges'].size} edges, #{violating} violating, " \
|
|
54
|
+
"skipped #{summary['skipped'].size} files, no constant"
|
|
55
|
+
@out.puts "#{summary['unresolved']} unresolved associations, #{summary['polymorphic']} polymorphic associations (no target)"
|
|
56
|
+
@out.puts "unlisted foreign: #{summary['foreign'].map { |name, count| "#{name} (#{count})" }.join(', ')}"
|
|
57
|
+
@out.puts Graph.coverage_line(summary["coverage"])
|
|
58
|
+
summary["warnings"] += [summary["coverage"]["banner"]].compact
|
|
59
|
+
summary["warnings"].each { |warning| @out.puts "warning: #{warning}" }
|
|
60
|
+
0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def run_inbox
|
|
64
|
+
command = Mailbox.new(workspace).peek
|
|
65
|
+
@out.puts JSON.generate(command) if command
|
|
66
|
+
0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def run_done
|
|
70
|
+
id, failed = parse_done_arguments
|
|
71
|
+
return usage unless id
|
|
72
|
+
|
|
73
|
+
if Mailbox.new(workspace).done(id, failed: failed)
|
|
74
|
+
0
|
|
75
|
+
else
|
|
76
|
+
@err.puts "no command with id #{id}"
|
|
77
|
+
1
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parse_done_arguments
|
|
82
|
+
failed = nil
|
|
83
|
+
OptionParser.new { |opts| opts.on("--failed REASON") { |reason| failed = reason } }.parse!(@argv)
|
|
84
|
+
[Integer(@argv.shift, 10), failed]
|
|
85
|
+
rescue OptionParser::ParseError, ArgumentError, TypeError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def usage
|
|
90
|
+
@err.puts USAGE
|
|
91
|
+
2
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run_watch
|
|
95
|
+
Watcher.new(workspace).run
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def run_install_skill
|
|
99
|
+
installer = SkillInstaller.new(executable: File.expand_path($PROGRAM_NAME))
|
|
100
|
+
@out.puts "wrote #{installer.install}"
|
|
101
|
+
installer.allowed_tools.each { |rule| @out.puts " #{rule}" }
|
|
102
|
+
0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def run_open
|
|
106
|
+
if workspace.server_alive?
|
|
107
|
+
info = workspace.server_info
|
|
108
|
+
url = "http://127.0.0.1:#{info['port']}/?token=#{info['token']}"
|
|
109
|
+
@out.puts "already running at #{url}"
|
|
110
|
+
open_browser(url)
|
|
111
|
+
return 0
|
|
112
|
+
end
|
|
113
|
+
Graph.prepare(workspace)
|
|
114
|
+
Graph.build(workspace)
|
|
115
|
+
server = Server.new(workspace).start
|
|
116
|
+
@out.puts "uml-viewer: #{server.url}"
|
|
117
|
+
open_browser(server.url)
|
|
118
|
+
trap("INT") { Thread.new { server.shutdown } }
|
|
119
|
+
trap("TERM") { Thread.new { server.shutdown } }
|
|
120
|
+
begin
|
|
121
|
+
server.run
|
|
122
|
+
ensure
|
|
123
|
+
server.shutdown
|
|
124
|
+
end
|
|
125
|
+
0
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def open_browser(url)
|
|
129
|
+
system("open", url) unless ENV["UML_VIEWER_NO_BROWSER"] == "1"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Uml
|
|
2
|
+
module Viewer
|
|
3
|
+
module Commands
|
|
4
|
+
TARGET_KEYS = {
|
|
5
|
+
"explain" => %w[id kind proposal_id],
|
|
6
|
+
"omit" => %w[id kind proposal_id],
|
|
7
|
+
"context" => %w[proposal_id],
|
|
8
|
+
"propose" => nil
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
def self.validate(body, ids:, proposal_ids:)
|
|
12
|
+
return refuse("body must be a JSON object") unless body.is_a?(Hash)
|
|
13
|
+
|
|
14
|
+
extra = (body.keys - %w[op target]).first
|
|
15
|
+
return refuse("unknown key: #{extra}") if extra
|
|
16
|
+
|
|
17
|
+
op = body["op"]
|
|
18
|
+
return refuse("unknown op: #{op}") unless TARGET_KEYS.key?(op)
|
|
19
|
+
|
|
20
|
+
keys = TARGET_KEYS[op]
|
|
21
|
+
return refuse("#{op} takes no target") if keys.nil? && body.key?("target")
|
|
22
|
+
return [op, nil, nil] if keys.nil?
|
|
23
|
+
|
|
24
|
+
target = body["target"]
|
|
25
|
+
return refuse("#{op} needs a target") unless target.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
extra = (target.keys - keys).first
|
|
28
|
+
return refuse("unknown key in target: #{extra}") if extra
|
|
29
|
+
return refuse("target needs #{(keys - target.keys).join(', ')}") unless (keys - target.keys).empty?
|
|
30
|
+
|
|
31
|
+
reason = check_target(target, ids, proposal_ids)
|
|
32
|
+
reason ? refuse(reason) : [op, target, nil]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.check_target(target, ids, proposal_ids)
|
|
36
|
+
proposal = target["proposal_id"]
|
|
37
|
+
return "unknown proposal: #{proposal}" unless proposal.nil? || proposal_ids.include?(proposal)
|
|
38
|
+
return nil unless target.key?("id")
|
|
39
|
+
return "target kind must be class or box" unless %w[class box].include?(target["kind"])
|
|
40
|
+
known = target["kind"] == "box" && proposal ? ids.fetch("proposal_boxes", {}).fetch(proposal, Set.new) : ids.fetch(target["kind"])
|
|
41
|
+
return "unknown target: #{target['id']}" unless known.include?(target["id"])
|
|
42
|
+
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.refuse(reason)
|
|
47
|
+
[nil, nil, reason]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "prism"
|
|
2
|
+
|
|
3
|
+
module Uml
|
|
4
|
+
module Viewer
|
|
5
|
+
module Complexity
|
|
6
|
+
BRANCHES = [
|
|
7
|
+
Prism::IfNode, Prism::UnlessNode, Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::WhenNode,
|
|
8
|
+
Prism::InNode, Prism::RescueNode, Prism::RescueModifierNode, Prism::AndNode, Prism::OrNode
|
|
9
|
+
].freeze
|
|
10
|
+
SHORT_CIRCUIT_WRITES = Prism.constants.grep(/(And|Or)WriteNode\z/).map { |name| Prism.const_get(name) }.freeze
|
|
11
|
+
|
|
12
|
+
def self.of(definition)
|
|
13
|
+
1 + count(definition.body)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.count(node)
|
|
17
|
+
return 0 if node.nil?
|
|
18
|
+
|
|
19
|
+
(branch?(node) ? 1 : 0) + node.compact_child_nodes.sum { |child| count(child) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.branch?(node)
|
|
23
|
+
BRANCHES.include?(node.class) || SHORT_CIRCUIT_WRITES.include?(node.class) ||
|
|
24
|
+
(node.respond_to?(:safe_navigation?) && node.safe_navigation?)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "set"
|
|
3
|
+
|
|
4
|
+
module Uml
|
|
5
|
+
module Viewer
|
|
6
|
+
module Coverage
|
|
7
|
+
class Unreadable < StandardError; end
|
|
8
|
+
|
|
9
|
+
def self.load(root, scanned_files)
|
|
10
|
+
path = File.join(root, "coverage", ".resultset.json")
|
|
11
|
+
return nil unless File.exist?(path)
|
|
12
|
+
|
|
13
|
+
resultset = JSON.parse(File.read(path))
|
|
14
|
+
raise Unreadable, "coverage file unreadable: #{path}" unless suites?(resultset)
|
|
15
|
+
|
|
16
|
+
parse(resultset, scanned_files)
|
|
17
|
+
rescue JSON::ParserError, SystemCallError
|
|
18
|
+
raise Unreadable, "coverage file unreadable: #{path}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.suites?(resultset)
|
|
22
|
+
resultset.is_a?(Hash) && !resultset.empty? && resultset.values.all? do |suite|
|
|
23
|
+
suite.is_a?(Hash) && suite["timestamp"].is_a?(Numeric) && suite["coverage"].is_a?(Hash) &&
|
|
24
|
+
suite["coverage"].values.all? { |entry| hits?(entry.is_a?(Hash) ? entry["lines"] : entry) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.hits?(hits)
|
|
29
|
+
hits.is_a?(Array) && hits.all? { |hit| hit.nil? || hit.is_a?(Integer) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.parse(resultset, scanned_files)
|
|
33
|
+
wanted = scanned_files.to_set
|
|
34
|
+
lines = {}
|
|
35
|
+
resultset.each_value do |suite|
|
|
36
|
+
root = root_of(suite["coverage"].keys, scanned_files)
|
|
37
|
+
suite["coverage"].each do |absolute, value|
|
|
38
|
+
file = root && absolute.start_with?("#{root}/") ? absolute.delete_prefix("#{root}/") : nil
|
|
39
|
+
next unless wanted.include?(file)
|
|
40
|
+
|
|
41
|
+
lines[file] = add(lines[file], value.is_a?(Hash) ? value["lines"] : value)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
{ "time" => resultset.values.filter_map { |suite| suite["timestamp"] }.max, "lines" => lines }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.root_of(paths, scanned_files)
|
|
48
|
+
by_name = scanned_files.group_by { |file| File.basename(file) }
|
|
49
|
+
candidates = paths.filter_map do |absolute|
|
|
50
|
+
file = by_name.fetch(File.basename(absolute), []).select { |name| absolute.end_with?("/#{name}") }.max_by(&:size)
|
|
51
|
+
file && absolute.delete_suffix("/#{file}")
|
|
52
|
+
end
|
|
53
|
+
candidates.tally.max_by { |_, count| count }&.first
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.add(sum, hits)
|
|
57
|
+
return hits.dup if sum.nil?
|
|
58
|
+
|
|
59
|
+
Array.new([sum.size, hits.size].max) do |index|
|
|
60
|
+
left = sum[index]
|
|
61
|
+
right = hits[index]
|
|
62
|
+
left.nil? && right.nil? ? nil : left.to_i + right.to_i
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "set"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Uml
|
|
6
|
+
module Viewer
|
|
7
|
+
module Graph
|
|
8
|
+
def self.prepare(workspace)
|
|
9
|
+
return if File.exist?(workspace.path("policy.yml"))
|
|
10
|
+
|
|
11
|
+
scan = Scanner.new(workspace.root).scan
|
|
12
|
+
PolicyInit.write(workspace, class_ids: scan["classes"].map { |entry| entry["id"] })
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.build(workspace)
|
|
16
|
+
policy = PolicyFile.load(workspace.path("policy.yml"))
|
|
17
|
+
scan = Scanner.new(workspace.root, sources: policy["sources"], skip: policy["skip"]).scan
|
|
18
|
+
applied = Policy.apply(scan, policy)
|
|
19
|
+
files = applied["classes"].flat_map { |entry| entry["files"] }.uniq
|
|
20
|
+
coverage, coverage_error = load_coverage(workspace, files)
|
|
21
|
+
mutation, mutation_error = load_mutation(workspace)
|
|
22
|
+
overlay = Overlay.apply(applied["classes"], coverage: coverage, mutation: mutation, cutoffs: policy["crap_cutoffs"],
|
|
23
|
+
mtimes: files.to_h { |file| [file, mtime(File.join(workspace.root, file))] },
|
|
24
|
+
missing_note: coverage_error || Overlay::NO_COVERAGE)
|
|
25
|
+
summary = summary(scan, applied, policy)
|
|
26
|
+
summary["coverage"] = overlay["coverage"].merge("banner" => coverage_error || overlay["coverage"]["banner"])
|
|
27
|
+
summary["warnings"] += [mutation_error].compact
|
|
28
|
+
graph = {
|
|
29
|
+
"version" => 3,
|
|
30
|
+
"title" => policy["title"].empty? ? File.basename(workspace.root) : policy["title"],
|
|
31
|
+
"proposals" => applied["proposals"].map do |proposal|
|
|
32
|
+
proposal.slice("id", "name").merge("views" => Overlay.boxes(proposal["views"], overlay["classes"], policy["crap_cutoffs"]))
|
|
33
|
+
end,
|
|
34
|
+
"summary" => summary,
|
|
35
|
+
"namespaces" => applied["namespaces"],
|
|
36
|
+
"classes" => overlay["classes"],
|
|
37
|
+
"edges" => applied["edges"],
|
|
38
|
+
"views" => Overlay.boxes(applied["views"], overlay["classes"], policy["crap_cutoffs"])
|
|
39
|
+
}
|
|
40
|
+
workspace.write_atomic("graph.json", JSON.generate(graph))
|
|
41
|
+
graph
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.load_coverage(workspace, files)
|
|
45
|
+
[Coverage.load(workspace.root, files), nil]
|
|
46
|
+
rescue Coverage::Unreadable => error
|
|
47
|
+
[nil, error.message]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.load_mutation(workspace)
|
|
51
|
+
path = workspace.path("mutation.json")
|
|
52
|
+
return [nil, nil] unless File.exist?(path)
|
|
53
|
+
|
|
54
|
+
data = JSON.parse(File.read(path))
|
|
55
|
+
mutation_snapshot?(data) ? [data, nil] : [nil, "mutation snapshot unreadable: #{path}"]
|
|
56
|
+
rescue JSON::ParserError, SystemCallError
|
|
57
|
+
[nil, "mutation snapshot unreadable: #{path}"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.mutation_snapshot?(data)
|
|
61
|
+
data.is_a?(Hash) && data.values.all? do |entry|
|
|
62
|
+
entry.is_a?(Hash) && Overlay::COUNTS.all? { |key| !entry.key?(key) || (entry[key].is_a?(Integer) && entry[key] >= 0) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.coverage_line(coverage)
|
|
67
|
+
return "coverage: none" if coverage["time"].nil?
|
|
68
|
+
|
|
69
|
+
"coverage: #{coverage['files']} of #{coverage['scanned']} files from #{Time.at(coverage['time']).utc.iso8601}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.summary(scan, applied, policy)
|
|
73
|
+
{
|
|
74
|
+
"classes" => applied["classes"].size,
|
|
75
|
+
"skipped" => scan["skipped"],
|
|
76
|
+
"foreign" => scan["foreign"].reject { |name, _| policy["foreign"].include?(name) }.first(20),
|
|
77
|
+
"unresolved" => scan["unresolved"],
|
|
78
|
+
"polymorphic" => scan["polymorphic"],
|
|
79
|
+
"warnings" => applied["warnings"] + scan["unreadable"].map { |file| "unreadable file: #{file}" }
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.ids(graph)
|
|
84
|
+
{
|
|
85
|
+
"class" => Set.new(graph["classes"].map { |entry| entry["id"] }),
|
|
86
|
+
"box" => box_ids(graph["views"]),
|
|
87
|
+
"proposal_boxes" => graph.fetch("proposals", []).to_h { |proposal| [proposal["id"], box_ids(proposal["views"])] }
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.box_ids(views)
|
|
92
|
+
Set.new(views.values.flat_map { |view| view["nodes"] }.select { |node| Views::BOXES.include?(node["type"]) }.map { |node| node["id"] })
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.inputs(workspace)
|
|
96
|
+
sources = begin
|
|
97
|
+
PolicyFile.load(workspace.path("policy.yml"))["sources"]
|
|
98
|
+
rescue PolicyFile::Invalid
|
|
99
|
+
PolicyFile::DEFAULTS["sources"]
|
|
100
|
+
end
|
|
101
|
+
files = sources.flat_map { |source| Dir.glob("#{source}/**/*.rb", base: workspace.root) }
|
|
102
|
+
extra = [File.join(workspace.root, "coverage", ".resultset.json"), workspace.path("mutation.json")]
|
|
103
|
+
([workspace.path("policy.yml")] + extra + files.map { |file| File.join(workspace.root, file) }).to_h { |path| [path, mtime(path)] }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.mtime(path)
|
|
107
|
+
File.mtime(path).to_f
|
|
108
|
+
rescue Errno::ENOENT
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Uml
|
|
2
|
+
module Viewer
|
|
3
|
+
module Inflector
|
|
4
|
+
SINGULAR = [
|
|
5
|
+
[/people\z/, "person"], [/children\z/, "child"], [/ies\z/, "y"], [/sses\z/, "ss"], [/xes\z/, "x"],
|
|
6
|
+
[/ches\z/, "ch"], [/shes\z/, "sh"], [/ses\z/, "s"], [/s\z/, ""]
|
|
7
|
+
].freeze
|
|
8
|
+
|
|
9
|
+
def self.singularize(word)
|
|
10
|
+
pattern, replacement = SINGULAR.find { |rule, _| word.match?(rule) }
|
|
11
|
+
pattern ? word.sub(pattern, replacement) : word
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.camelize(word)
|
|
15
|
+
word.split("_").map(&:capitalize).join
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Uml
|
|
5
|
+
module Viewer
|
|
6
|
+
class Mailbox
|
|
7
|
+
class QueueFull < StandardError; end
|
|
8
|
+
|
|
9
|
+
CAP = 100
|
|
10
|
+
|
|
11
|
+
def initialize(workspace)
|
|
12
|
+
@workspace = workspace
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def append(op:, target: nil)
|
|
16
|
+
locked do |state|
|
|
17
|
+
raise QueueFull, "queue full: start Claude or clear the queue" if state["queue"].size >= self.class::CAP
|
|
18
|
+
|
|
19
|
+
id = state["next_id"]
|
|
20
|
+
command = { "id" => id, "op" => op }
|
|
21
|
+
command["target"] = target unless target.nil?
|
|
22
|
+
command["at"] = Time.now.utc.iso8601
|
|
23
|
+
state["queue"] << command
|
|
24
|
+
state["next_id"] = id + 1
|
|
25
|
+
id
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def peek
|
|
30
|
+
queue.first
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def queue
|
|
34
|
+
read_state["queue"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def size
|
|
38
|
+
queue.size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def done(id, failed: nil)
|
|
42
|
+
locked do |state|
|
|
43
|
+
command = state["queue"].find { |entry| entry["id"] == id }
|
|
44
|
+
if command
|
|
45
|
+
state["queue"].delete(command)
|
|
46
|
+
log_failure(command, failed) if failed
|
|
47
|
+
end
|
|
48
|
+
command
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def last_failed
|
|
53
|
+
@workspace.read_json("failed.json", []).last
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def read_state
|
|
59
|
+
@workspace.read_json("to-agent.json", { "next_id" => 1, "queue" => [] })
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def locked
|
|
63
|
+
File.open(@workspace.path("to-agent.lock"), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
64
|
+
lock.flock(File::LOCK_EX)
|
|
65
|
+
state = read_state
|
|
66
|
+
result = yield(state)
|
|
67
|
+
@workspace.write_atomic("to-agent.json", JSON.generate(state))
|
|
68
|
+
result
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def log_failure(command, reason)
|
|
73
|
+
failures = @workspace.read_json("failed.json", [])
|
|
74
|
+
failures << { "id" => command["id"], "op" => command["op"], "reason" => reason, "at" => Time.now.utc.iso8601 }
|
|
75
|
+
@workspace.write_atomic("failed.json", JSON.generate(failures))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|