devex 0.3.5
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/.obsidian/app.json +6 -0
- data/.obsidian/appearance.json +4 -0
- data/.obsidian/community-plugins.json +5 -0
- data/.obsidian/core-plugins.json +33 -0
- data/.obsidian/plugins/obsidian-minimal-settings/data.json +34 -0
- data/.obsidian/plugins/obsidian-minimal-settings/main.js +8 -0
- data/.obsidian/plugins/obsidian-minimal-settings/manifest.json +11 -0
- data/.obsidian/plugins/obsidian-style-settings/data.json +15 -0
- data/.obsidian/plugins/obsidian-style-settings/main.js +165 -0
- data/.obsidian/plugins/obsidian-style-settings/manifest.json +10 -0
- data/.obsidian/plugins/obsidian-style-settings/styles.css +243 -0
- data/.obsidian/plugins/table-editor-obsidian/data.json +6 -0
- data/.obsidian/plugins/table-editor-obsidian/main.js +236 -0
- data/.obsidian/plugins/table-editor-obsidian/manifest.json +17 -0
- data/.obsidian/plugins/table-editor-obsidian/styles.css +78 -0
- data/.obsidian/themes/AnuPpuccin/manifest.json +7 -0
- data/.obsidian/themes/AnuPpuccin/theme.css +9080 -0
- data/.obsidian/themes/Minimal/manifest.json +8 -0
- data/.obsidian/themes/Minimal/theme.css +2251 -0
- data/.rubocop.yml +231 -0
- data/CHANGELOG.md +97 -0
- data/LICENSE +21 -0
- data/README.md +314 -0
- data/Rakefile +13 -0
- data/devex-logo.jpg +0 -0
- data/docs/developing-tools.md +1000 -0
- data/docs/ref/agent-mode.md +46 -0
- data/docs/ref/cli-interface.md +60 -0
- data/docs/ref/configuration.md +46 -0
- data/docs/ref/design-philosophy.md +17 -0
- data/docs/ref/error-handling.md +38 -0
- data/docs/ref/io-handling.md +88 -0
- data/docs/ref/signals.md +141 -0
- data/docs/ref/temporal-software-theory.md +790 -0
- data/exe/dx +52 -0
- data/lib/devex/builtins/.index.rb +10 -0
- data/lib/devex/builtins/debug.rb +43 -0
- data/lib/devex/builtins/format.rb +44 -0
- data/lib/devex/builtins/gem.rb +77 -0
- data/lib/devex/builtins/lint.rb +61 -0
- data/lib/devex/builtins/test.rb +76 -0
- data/lib/devex/builtins/version.rb +156 -0
- data/lib/devex/cli.rb +340 -0
- data/lib/devex/context.rb +433 -0
- data/lib/devex/core/configuration.rb +136 -0
- data/lib/devex/core.rb +79 -0
- data/lib/devex/dirs.rb +210 -0
- data/lib/devex/dsl.rb +100 -0
- data/lib/devex/exec/controller.rb +245 -0
- data/lib/devex/exec/result.rb +229 -0
- data/lib/devex/exec.rb +662 -0
- data/lib/devex/loader.rb +136 -0
- data/lib/devex/output.rb +257 -0
- data/lib/devex/project_paths.rb +309 -0
- data/lib/devex/support/ansi.rb +437 -0
- data/lib/devex/support/core_ext.rb +560 -0
- data/lib/devex/support/global.rb +68 -0
- data/lib/devex/support/path.rb +357 -0
- data/lib/devex/support.rb +71 -0
- data/lib/devex/template_helpers.rb +136 -0
- data/lib/devex/templates/debug.erb +24 -0
- data/lib/devex/tool.rb +374 -0
- data/lib/devex/version.rb +5 -0
- data/lib/devex/working_dir.rb +99 -0
- data/lib/devex.rb +158 -0
- data/ruby-project-template/.gitignore +0 -0
- data/ruby-project-template/Gemfile +0 -0
- data/ruby-project-template/README.md +0 -0
- data/ruby-project-template/docs/README.md +0 -0
- data/sig/devex.rbs +4 -0
- metadata +122 -0
data/lib/devex/loader.rb
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Devex
|
|
4
|
+
# Loads tool definitions from task files
|
|
5
|
+
class Loader
|
|
6
|
+
INDEX_FILE = ".index.rb"
|
|
7
|
+
|
|
8
|
+
# Load all tools from a directory into a root tool
|
|
9
|
+
#
|
|
10
|
+
# Directory structure maps to tool hierarchy:
|
|
11
|
+
# tools/test.rb -> dx test
|
|
12
|
+
# tools/version.rb -> dx version (with nested tools defined inside)
|
|
13
|
+
# tools/docs/ -> dx docs (if .index.rb exists) or namespace
|
|
14
|
+
# tools/docs/generate.rb -> dx docs generate
|
|
15
|
+
#
|
|
16
|
+
def self.load_directory(dir, root_tool, mixins = {})
|
|
17
|
+
return unless dir && File.directory?(dir)
|
|
18
|
+
|
|
19
|
+
loader = new(dir, root_tool, mixins)
|
|
20
|
+
loader.load
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(dir, root_tool, mixins = {})
|
|
24
|
+
@dir = dir
|
|
25
|
+
@root_tool = root_tool
|
|
26
|
+
@mixins = mixins
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def load
|
|
30
|
+
# First, load index file if present (defines mixins and root tool config)
|
|
31
|
+
index_file = File.join(@dir, INDEX_FILE)
|
|
32
|
+
load_index(index_file) if File.exist?(index_file)
|
|
33
|
+
|
|
34
|
+
# Then load all .rb files (excluding index)
|
|
35
|
+
Dir.glob(File.join(@dir, "*.rb")).sort.each do |file|
|
|
36
|
+
next if File.basename(file) == INDEX_FILE
|
|
37
|
+
|
|
38
|
+
load_tool_file(file, @root_tool)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Load subdirectories
|
|
42
|
+
Dir.glob(File.join(@dir, "*")).sort.each do |path|
|
|
43
|
+
next unless File.directory?(path)
|
|
44
|
+
next if File.basename(path).start_with?(".")
|
|
45
|
+
|
|
46
|
+
load_subdirectory(path, @root_tool)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def load_index(file)
|
|
53
|
+
code = File.read(file)
|
|
54
|
+
context = IndexDSL.new(@root_tool, @mixins)
|
|
55
|
+
context.instance_eval(code, file)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def load_tool_file(file, parent)
|
|
59
|
+
name = File.basename(file, ".rb")
|
|
60
|
+
tool = Tool.new(name, parent: parent)
|
|
61
|
+
|
|
62
|
+
code = File.read(file)
|
|
63
|
+
TaskFileDSL.evaluate(tool, code, file)
|
|
64
|
+
|
|
65
|
+
parent.add_subtool(tool)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def load_subdirectory(dir, parent)
|
|
69
|
+
name = File.basename(dir)
|
|
70
|
+
tool = Tool.new(name, parent: parent)
|
|
71
|
+
|
|
72
|
+
# Check for index file in subdirectory
|
|
73
|
+
index_file = File.join(dir, INDEX_FILE)
|
|
74
|
+
if File.exist?(index_file)
|
|
75
|
+
code = File.read(index_file)
|
|
76
|
+
TaskFileDSL.evaluate(tool, code, index_file)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Load .rb files in subdirectory
|
|
80
|
+
Dir.glob(File.join(dir, "*.rb")).sort.each do |file|
|
|
81
|
+
next if File.basename(file) == INDEX_FILE
|
|
82
|
+
|
|
83
|
+
load_tool_file(file, tool)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Recurse into nested subdirectories
|
|
87
|
+
Dir.glob(File.join(dir, "*")).sort.each do |path|
|
|
88
|
+
next unless File.directory?(path)
|
|
89
|
+
next if File.basename(path).start_with?(".")
|
|
90
|
+
|
|
91
|
+
load_subdirectory(path, tool)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
parent.add_subtool(tool) if tool.desc || tool.subtools.any? || tool.run_block
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# DSL for index files - can define mixins
|
|
99
|
+
class IndexDSL
|
|
100
|
+
def initialize(tool, mixins)
|
|
101
|
+
@tool = tool
|
|
102
|
+
@mixins = mixins
|
|
103
|
+
@dsl = DSL.new(tool)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Define a mixin
|
|
107
|
+
def mixin(name, &block) = @mixins[name.to_s] = block
|
|
108
|
+
|
|
109
|
+
# Forward to regular DSL
|
|
110
|
+
def desc(text) = @dsl.desc(text)
|
|
111
|
+
|
|
112
|
+
def long_desc(text) = @dsl.long_desc(text)
|
|
113
|
+
|
|
114
|
+
def flag(name, *specs, **) = @dsl.flag(name, *specs, **)
|
|
115
|
+
|
|
116
|
+
def required_arg(name, **) = @dsl.required_arg(name, **)
|
|
117
|
+
|
|
118
|
+
def optional_arg(name, **) = @dsl.optional_arg(name, **)
|
|
119
|
+
|
|
120
|
+
def tool(name, &) = @dsl.tool(name, &)
|
|
121
|
+
|
|
122
|
+
def to_run(&) = @dsl.to_run(&)
|
|
123
|
+
|
|
124
|
+
# Capture run method
|
|
125
|
+
def run
|
|
126
|
+
# Will be captured by method definition
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Allow arbitrary methods (for mixin definitions)
|
|
130
|
+
def method_missing(name, *args, &)
|
|
131
|
+
# Ignore - these are likely mixin method definitions
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def respond_to_missing?(_name, _include_private = false) = true
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/devex/output.rb
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require_relative "support/ansi"
|
|
5
|
+
|
|
6
|
+
module Devex
|
|
7
|
+
# Output helpers for tasks that respect runtime context.
|
|
8
|
+
#
|
|
9
|
+
# Provides styled output methods that automatically adapt to the environment:
|
|
10
|
+
# - Full color and unicode in interactive terminals
|
|
11
|
+
# - Plain text in agent mode, CI, or piped output
|
|
12
|
+
# - JSON-structured output when explicitly requested
|
|
13
|
+
#
|
|
14
|
+
# Usage in tasks:
|
|
15
|
+
# include Devex::Output
|
|
16
|
+
# header "Running tests"
|
|
17
|
+
# success "All tests passed"
|
|
18
|
+
# error "Build failed"
|
|
19
|
+
# warn "Deprecated API used"
|
|
20
|
+
#
|
|
21
|
+
# See docs/ref/io-handling.md for design rationale.
|
|
22
|
+
#
|
|
23
|
+
module Output
|
|
24
|
+
# Unicode symbols - basic unicode that works everywhere
|
|
25
|
+
# (Not nerdfont glyphs or emoji that render as images)
|
|
26
|
+
SYMBOLS = {
|
|
27
|
+
success: "✓",
|
|
28
|
+
error: "✗",
|
|
29
|
+
warning: "⚠",
|
|
30
|
+
info: "ℹ",
|
|
31
|
+
arrow: "→",
|
|
32
|
+
bullet: "•",
|
|
33
|
+
check: "✓",
|
|
34
|
+
cross: "✗",
|
|
35
|
+
dot: "·"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Color definitions (truecolor RGB values)
|
|
39
|
+
COLORS = {
|
|
40
|
+
success: [0x5A, 0xF7, 0x8E], # Green
|
|
41
|
+
error: [0xFF, 0x6B, 0x6B], # Red
|
|
42
|
+
warning: [0xFF, 0xE6, 0x6D], # Yellow
|
|
43
|
+
info: [0x6B, 0xC5, 0xFF], # Blue
|
|
44
|
+
header: [0xC4, 0xB5, 0xFD], # Purple
|
|
45
|
+
muted: [0x88, 0x88, 0x88], # Gray
|
|
46
|
+
emphasis: [0xFF, 0xFF, 0xFF] # White
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# Get symbol - always unicode (basic unicode works everywhere)
|
|
51
|
+
def symbol(name) = SYMBOLS.fetch(name, name.to_s)
|
|
52
|
+
|
|
53
|
+
# Apply color to text if colors are enabled
|
|
54
|
+
def colorize(text, color_name)
|
|
55
|
+
return text unless Context.color?
|
|
56
|
+
|
|
57
|
+
rgb = COLORS[color_name]
|
|
58
|
+
return text unless rgb
|
|
59
|
+
|
|
60
|
+
Support::ANSI[text, rgb]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# --- Primary output methods ---
|
|
64
|
+
|
|
65
|
+
# Print a section header
|
|
66
|
+
def header(text, io: $stderr)
|
|
67
|
+
if Context.agent_mode?
|
|
68
|
+
io.puts "=== #{text} ==="
|
|
69
|
+
else
|
|
70
|
+
styled = colorize(text, :header)
|
|
71
|
+
io.puts
|
|
72
|
+
io.puts styled
|
|
73
|
+
io.puts colorize("─" * text.length, :muted)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Print a success message
|
|
78
|
+
def success(text, io: $stderr)
|
|
79
|
+
sym = symbol(:success)
|
|
80
|
+
if Context.color?
|
|
81
|
+
io.puts "#{colorize(sym, :success)} #{text}"
|
|
82
|
+
else
|
|
83
|
+
io.puts "#{sym} #{text}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Print an error message
|
|
88
|
+
def error(text, io: $stderr)
|
|
89
|
+
sym = symbol(:error)
|
|
90
|
+
if Context.color?
|
|
91
|
+
io.puts "#{colorize(sym, :error)} #{colorize(text, :error)}"
|
|
92
|
+
else
|
|
93
|
+
io.puts "#{sym} #{text}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Print a warning message
|
|
98
|
+
def warn(text, io: $stderr)
|
|
99
|
+
sym = symbol(:warning)
|
|
100
|
+
if Context.color?
|
|
101
|
+
io.puts "#{colorize(sym, :warning)} #{text}"
|
|
102
|
+
else
|
|
103
|
+
io.puts "#{sym} #{text}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Print an info message
|
|
108
|
+
def info(text, io: $stderr)
|
|
109
|
+
sym = symbol(:info)
|
|
110
|
+
if Context.color?
|
|
111
|
+
io.puts "#{colorize(sym, :info)} #{text}"
|
|
112
|
+
else
|
|
113
|
+
io.puts "#{sym} #{text}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Print muted/secondary text
|
|
118
|
+
def muted(text, io: $stderr) = io.puts colorize(text, :muted)
|
|
119
|
+
|
|
120
|
+
# Print a bullet point
|
|
121
|
+
def bullet(text, io: $stderr)
|
|
122
|
+
sym = symbol(:bullet)
|
|
123
|
+
io.puts " #{sym} #{text}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Print an indented line
|
|
127
|
+
def indent(text, level: 1, io: $stdout) = io.puts "#{' ' * (level * 2)}#{text}"
|
|
128
|
+
|
|
129
|
+
# --- Structured output ---
|
|
130
|
+
|
|
131
|
+
# Output data in the requested format
|
|
132
|
+
# Respects --format flag if provided, defaults to text
|
|
133
|
+
#
|
|
134
|
+
# For composed tools outputting multiple documents:
|
|
135
|
+
# - JSON: outputs as JSONL (one JSON object per line)
|
|
136
|
+
# - YAML: uses --- between documents, ... at the end
|
|
137
|
+
def data(obj, format: nil, io: $stdout)
|
|
138
|
+
format ||= Context.agent_mode? ? :json : :text
|
|
139
|
+
|
|
140
|
+
case format.to_sym
|
|
141
|
+
when :json
|
|
142
|
+
require "json"
|
|
143
|
+
io.print JSON.generate(obj), "\n"
|
|
144
|
+
when :yaml
|
|
145
|
+
require "yaml"
|
|
146
|
+
# YAML.dump adds --- automatically, we just ensure clean output
|
|
147
|
+
io.print obj.to_yaml
|
|
148
|
+
else
|
|
149
|
+
io.print obj.to_s, "\n"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Start a new YAML document (use between multiple outputs)
|
|
154
|
+
# The first document doesn't need this - YAML.dump adds --- automatically
|
|
155
|
+
def yaml_document_separator(io: $stdout) = io.print "---\n"
|
|
156
|
+
|
|
157
|
+
# End the YAML stream (use after all documents are written)
|
|
158
|
+
def yaml_end_stream(io: $stdout) = io.print "...\n"
|
|
159
|
+
|
|
160
|
+
# Output multiple objects as a YAML stream with proper separators
|
|
161
|
+
def yaml_stream(objects, io: $stdout)
|
|
162
|
+
require "yaml"
|
|
163
|
+
objects.each_with_index do |obj, i|
|
|
164
|
+
yaml_document_separator(io: io) if i > 0
|
|
165
|
+
io.print obj.to_yaml.sub(/\A---\n?/, "") # Remove auto-added ---
|
|
166
|
+
end
|
|
167
|
+
yaml_end_stream(io: io)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Output multiple objects as JSONL (JSON Lines)
|
|
171
|
+
def jsonl_stream(objects, io: $stdout)
|
|
172
|
+
require "json"
|
|
173
|
+
objects.each do |obj|
|
|
174
|
+
io.print JSON.generate(obj), "\n"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# --- Template rendering ---
|
|
179
|
+
|
|
180
|
+
# Render an ERB template string with the given binding
|
|
181
|
+
def render_template(template_string, bind = nil)
|
|
182
|
+
erb = ERB.new(template_string, trim_mode: "-")
|
|
183
|
+
erb.result(bind || binding)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Render an ERB template file
|
|
187
|
+
def render_template_file(path, bind = nil)
|
|
188
|
+
template = File.read(path)
|
|
189
|
+
render_template(template, bind)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# --- Progress indicators ---
|
|
193
|
+
|
|
194
|
+
# Print a progress indicator (only in interactive mode)
|
|
195
|
+
def progress(current, total, label: nil, io: $stderr)
|
|
196
|
+
return if Context.agent_mode?
|
|
197
|
+
return unless Context.interactive?
|
|
198
|
+
|
|
199
|
+
pct = (current.to_f / total * 100).round
|
|
200
|
+
bar_width = 20
|
|
201
|
+
filled = (bar_width * current / total).round
|
|
202
|
+
empty = bar_width - filled
|
|
203
|
+
|
|
204
|
+
bar = ("█" * filled) + ("░" * empty)
|
|
205
|
+
label_text = label ? "#{label}: " : ""
|
|
206
|
+
|
|
207
|
+
# Use carriage return to update in place
|
|
208
|
+
io.print "\r#{label_text}[#{bar}] #{pct}% (#{current}/#{total})"
|
|
209
|
+
io.puts if current >= total
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Clear the current line (for progress updates)
|
|
213
|
+
def clear_line(io: $stderr)
|
|
214
|
+
return unless Context.interactive?
|
|
215
|
+
|
|
216
|
+
io.print "\r\e[K"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Instance methods that delegate to class methods
|
|
221
|
+
# These are included when a task does `include Devex::Output`
|
|
222
|
+
|
|
223
|
+
def header(text) = Output.header(text)
|
|
224
|
+
|
|
225
|
+
def success(text) = Output.success(text)
|
|
226
|
+
|
|
227
|
+
def error(text) = Output.error(text)
|
|
228
|
+
|
|
229
|
+
def warn(text) = Output.warn(text)
|
|
230
|
+
|
|
231
|
+
def info(text) = Output.info(text)
|
|
232
|
+
|
|
233
|
+
def muted(text) = Output.muted(text)
|
|
234
|
+
|
|
235
|
+
def bullet(text) = Output.bullet(text)
|
|
236
|
+
|
|
237
|
+
def indent(text, level: 1) = Output.indent(text, level: level)
|
|
238
|
+
|
|
239
|
+
def data(obj, format: nil) = Output.data(obj, format: format)
|
|
240
|
+
|
|
241
|
+
def yaml_stream(objects) = Output.yaml_stream(objects)
|
|
242
|
+
|
|
243
|
+
def jsonl_stream(objects) = Output.jsonl_stream(objects)
|
|
244
|
+
|
|
245
|
+
def yaml_document_separator = Output.yaml_document_separator
|
|
246
|
+
|
|
247
|
+
def yaml_end_stream = Output.yaml_end_stream
|
|
248
|
+
|
|
249
|
+
def render_template(template_string, bind = nil) = Output.render_template(template_string, bind)
|
|
250
|
+
|
|
251
|
+
def render_template_file(path, bind = nil) = Output.render_template_file(path, bind)
|
|
252
|
+
|
|
253
|
+
def progress(current, total, label: nil) = Output.progress(current, total, label: label)
|
|
254
|
+
|
|
255
|
+
def clear_line = Output.clear_line
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "support/path"
|
|
4
|
+
require_relative "dirs"
|
|
5
|
+
|
|
6
|
+
module Devex
|
|
7
|
+
# Project path resolution with lazy discovery and fail-fast feedback.
|
|
8
|
+
#
|
|
9
|
+
# Access conventional project locations via the `prj` object:
|
|
10
|
+
#
|
|
11
|
+
# prj.root # Project root directory
|
|
12
|
+
# prj.lib # lib/
|
|
13
|
+
# prj.test # test/ or spec/ (discovered)
|
|
14
|
+
# prj.config # config file path
|
|
15
|
+
# prj["**/*.rb"] # Glob from project root
|
|
16
|
+
#
|
|
17
|
+
# Paths are resolved lazily on first access. If a conventional path
|
|
18
|
+
# doesn't exist, you get a clear error with remediation steps.
|
|
19
|
+
#
|
|
20
|
+
# @example Core usage with custom config
|
|
21
|
+
# config = Devex::Core::Configuration.new(
|
|
22
|
+
# organized_dir: ".mycli",
|
|
23
|
+
# config_file: ".mycli.yml"
|
|
24
|
+
# )
|
|
25
|
+
# prj = Devex::ProjectPaths.new(root: project_root, config: config)
|
|
26
|
+
#
|
|
27
|
+
class ProjectPaths
|
|
28
|
+
Path = Support::Path
|
|
29
|
+
|
|
30
|
+
# Default conventional path mappings
|
|
31
|
+
# Values can be:
|
|
32
|
+
# - String: exact path relative to root
|
|
33
|
+
# - Array: list of alternatives (first existing wins)
|
|
34
|
+
# - Symbol: special handler method
|
|
35
|
+
# - String with *: glob pattern
|
|
36
|
+
# - nil: returns root
|
|
37
|
+
DEFAULT_CONVENTIONS = {
|
|
38
|
+
root: nil, # Always project_dir
|
|
39
|
+
git: ".git",
|
|
40
|
+
lib: "lib",
|
|
41
|
+
src: "src",
|
|
42
|
+
bin: "bin",
|
|
43
|
+
exe: "exe",
|
|
44
|
+
test: %w[test spec tests],
|
|
45
|
+
features: "features",
|
|
46
|
+
property_tests: "property_tests",
|
|
47
|
+
simulations: "simulations",
|
|
48
|
+
spec_tests: "specification_tests",
|
|
49
|
+
types: "sig",
|
|
50
|
+
docs: %w[docs doc documentation],
|
|
51
|
+
system_docs: "system_docs",
|
|
52
|
+
version: :detect_version,
|
|
53
|
+
gemfile: "Gemfile",
|
|
54
|
+
gemspec: "*.gemspec",
|
|
55
|
+
linter: %w[.standard.yml .rubocop.yml],
|
|
56
|
+
mise: %w[.mise.toml .tool-versions],
|
|
57
|
+
env: ".env",
|
|
58
|
+
tmp: "tmp",
|
|
59
|
+
log: "log",
|
|
60
|
+
vendor: "vendor",
|
|
61
|
+
db: "db",
|
|
62
|
+
config_dir: "config",
|
|
63
|
+
scripts: "scripts"
|
|
64
|
+
}.freeze
|
|
65
|
+
|
|
66
|
+
# dx-specific conventions (added when no config provided)
|
|
67
|
+
DX_CONVENTIONS = {
|
|
68
|
+
dx: ".dx",
|
|
69
|
+
config: :detect_config,
|
|
70
|
+
tools: :detect_tools,
|
|
71
|
+
templates: :detect_templates,
|
|
72
|
+
hooks: :detect_hooks
|
|
73
|
+
}.freeze
|
|
74
|
+
|
|
75
|
+
# @param root [String, Path, nil] project root (defaults to Dirs.project_dir)
|
|
76
|
+
# @param config [Core::Configuration, nil] configuration for customization
|
|
77
|
+
# @param overrides [Hash] explicit path overrides
|
|
78
|
+
def initialize(root: nil, config: nil, overrides: {})
|
|
79
|
+
@root = root ? Path[root] : Dirs.project_dir
|
|
80
|
+
@config = config
|
|
81
|
+
@overrides = overrides.transform_keys(&:to_sym)
|
|
82
|
+
@cache = {}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Project root directory
|
|
86
|
+
attr_reader :root
|
|
87
|
+
|
|
88
|
+
# Framework configuration (if any)
|
|
89
|
+
# Note: Named `configuration` to avoid shadowing the `config` path accessor
|
|
90
|
+
def configuration
|
|
91
|
+
@config
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Glob from project root
|
|
95
|
+
def [](pattern, **) = @root[pattern, **]
|
|
96
|
+
|
|
97
|
+
# Is this project in organized mode? (organized_dir exists)
|
|
98
|
+
def organized_mode?
|
|
99
|
+
@organized ||= begin
|
|
100
|
+
org_dir = organized_dir_name
|
|
101
|
+
org_dir && (@root / org_dir).directory?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Dynamic path resolution
|
|
106
|
+
def method_missing(name, *args, &)
|
|
107
|
+
return super unless conventions.key?(name) || @overrides.key?(name)
|
|
108
|
+
|
|
109
|
+
@cache[name] ||= resolve(name)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def respond_to_missing?(name, include_private = false)
|
|
113
|
+
conventions.key?(name) || @overrides.key?(name) || super
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# Merged conventions (config overrides + defaults)
|
|
119
|
+
def conventions
|
|
120
|
+
@conventions ||= begin
|
|
121
|
+
base = DEFAULT_CONVENTIONS.dup
|
|
122
|
+
|
|
123
|
+
# Add dx-specific conventions unless config specifies otherwise
|
|
124
|
+
if @config
|
|
125
|
+
# Add organized_dir convention if configured
|
|
126
|
+
base[:organized_dir] = @config.organized_dir if @config.organized_dir
|
|
127
|
+
# Add config/tools/templates/hooks with detection
|
|
128
|
+
base[:config] = :detect_config if @config.config_file || @config.organized_dir
|
|
129
|
+
base[:tools] = :detect_tools
|
|
130
|
+
base[:templates] = :detect_templates
|
|
131
|
+
base[:hooks] = :detect_hooks
|
|
132
|
+
# Merge custom conventions from config
|
|
133
|
+
base.merge!(@config.path_conventions) if @config.path_conventions
|
|
134
|
+
else
|
|
135
|
+
# No config = dx mode for backward compatibility
|
|
136
|
+
base.merge!(DX_CONVENTIONS)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
base
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Name of organized mode directory (e.g., ".dx", ".mycli")
|
|
144
|
+
def organized_dir_name
|
|
145
|
+
@config&.organized_dir || ".dx"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Name of simple mode config file (e.g., ".dx.yml", ".mycli.yml")
|
|
149
|
+
def config_file_name
|
|
150
|
+
@config&.config_file || ".dx.yml"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def resolve(name)
|
|
154
|
+
# Check overrides first
|
|
155
|
+
if @overrides.key?(name)
|
|
156
|
+
path = @root / @overrides[name]
|
|
157
|
+
return path if path.exist?
|
|
158
|
+
|
|
159
|
+
return fail_missing!(name, [@overrides[name]])
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Special handlers
|
|
163
|
+
convention = conventions[name]
|
|
164
|
+
case convention
|
|
165
|
+
when nil then @root # root returns the root
|
|
166
|
+
when Symbol then send(convention)
|
|
167
|
+
when Array
|
|
168
|
+
found = convention.map { |p| @root / p }.find(&:exist?)
|
|
169
|
+
found || fail_missing!(name, convention)
|
|
170
|
+
when String
|
|
171
|
+
if convention.include?("*")
|
|
172
|
+
# Glob pattern
|
|
173
|
+
matches = @root.glob(convention)
|
|
174
|
+
matches.first || fail_missing!(name, [convention])
|
|
175
|
+
else
|
|
176
|
+
path = @root / convention
|
|
177
|
+
path.exist? ? path : fail_missing!(name, [convention])
|
|
178
|
+
end
|
|
179
|
+
else
|
|
180
|
+
fail_missing!(name, [convention.to_s])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ─────────────────────────────────────────────────────────────
|
|
185
|
+
# Special Detection Methods
|
|
186
|
+
# ─────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
def detect_config
|
|
189
|
+
org_dir_name = organized_dir_name
|
|
190
|
+
cfg_file_name = config_file_name
|
|
191
|
+
|
|
192
|
+
org_dir = org_dir_name ? (@root / org_dir_name) : nil
|
|
193
|
+
cfg_file = cfg_file_name ? (@root / cfg_file_name) : nil
|
|
194
|
+
|
|
195
|
+
# Check for conflict (both exist)
|
|
196
|
+
if org_dir&.exist? && cfg_file&.exist?
|
|
197
|
+
fail_config_conflict!(org_dir, cfg_file)
|
|
198
|
+
elsif org_dir&.exist?
|
|
199
|
+
org_dir / "config.yml"
|
|
200
|
+
elsif cfg_file
|
|
201
|
+
cfg_file # May or may not exist
|
|
202
|
+
else
|
|
203
|
+
@root / "config.yml" # Fallback
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def detect_tools
|
|
208
|
+
if organized_mode?
|
|
209
|
+
@root / organized_dir_name / "tools"
|
|
210
|
+
else
|
|
211
|
+
@root / (@config&.tools_dir || "tools")
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def detect_templates
|
|
216
|
+
if organized_mode?
|
|
217
|
+
@root / organized_dir_name / "templates"
|
|
218
|
+
else
|
|
219
|
+
@root / "templates"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def detect_hooks
|
|
224
|
+
if organized_mode?
|
|
225
|
+
@root / organized_dir_name / "hooks"
|
|
226
|
+
else
|
|
227
|
+
@root / "hooks"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def detect_version
|
|
232
|
+
# Check common version file locations
|
|
233
|
+
candidates = [
|
|
234
|
+
@root / "VERSION",
|
|
235
|
+
@root / "version"
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
# Also check lib/*/version.rb patterns
|
|
239
|
+
version_rbs = @root.glob("lib/*/version.rb")
|
|
240
|
+
candidates.concat(version_rbs)
|
|
241
|
+
|
|
242
|
+
found = candidates.find(&:exist?)
|
|
243
|
+
found || fail_missing!(:version, ["VERSION", "lib/*/version.rb"])
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# ─────────────────────────────────────────────────────────────
|
|
247
|
+
# Fail-Fast with Feedback
|
|
248
|
+
# ─────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
def fail_missing!(name, tried)
|
|
251
|
+
cfg_file = config_file_name || "config file"
|
|
252
|
+
|
|
253
|
+
message = <<~ERR
|
|
254
|
+
ERROR: Project #{name} directory not found
|
|
255
|
+
|
|
256
|
+
Looked for: #{tried.join(', ')}
|
|
257
|
+
Project root: #{@root}
|
|
258
|
+
|
|
259
|
+
To configure a custom location, add to #{cfg_file}:
|
|
260
|
+
paths:
|
|
261
|
+
#{name}: your/#{name}/dir/
|
|
262
|
+
|
|
263
|
+
Exit code: 78 (EX_CONFIG)
|
|
264
|
+
ERR
|
|
265
|
+
|
|
266
|
+
raise message
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def fail_config_conflict!(org_dir, cfg_file)
|
|
270
|
+
org_dir_time = begin
|
|
271
|
+
org_dir.mtime
|
|
272
|
+
rescue StandardError
|
|
273
|
+
Time.now
|
|
274
|
+
end
|
|
275
|
+
cfg_file_time = begin
|
|
276
|
+
cfg_file.mtime
|
|
277
|
+
rescue StandardError
|
|
278
|
+
Time.now
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
org_name = organized_dir_name
|
|
282
|
+
cfg_name = config_file_name
|
|
283
|
+
|
|
284
|
+
message = <<~ERR
|
|
285
|
+
ERROR: Conflicting configuration
|
|
286
|
+
|
|
287
|
+
Found both:
|
|
288
|
+
#{cfg_name} (modified: #{cfg_file_time.strftime('%Y-%m-%d %H:%M:%S')})
|
|
289
|
+
#{org_name}/ (modified: #{org_dir_time.strftime('%Y-%m-%d %H:%M:%S')})
|
|
290
|
+
|
|
291
|
+
Please use one or the other:
|
|
292
|
+
- Simple: #{cfg_name} + tools/
|
|
293
|
+
- Organized: #{org_name}/config.yml + #{org_name}/tools/
|
|
294
|
+
|
|
295
|
+
To migrate from simple to organized:
|
|
296
|
+
mkdir -p #{org_name}
|
|
297
|
+
mv #{cfg_name} #{org_name}/config.yml
|
|
298
|
+
mv tools/ #{org_name}/tools/
|
|
299
|
+
|
|
300
|
+
Exit code: 78 (EX_CONFIG)
|
|
301
|
+
ERR
|
|
302
|
+
|
|
303
|
+
raise message
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Backward compatibility: CONVENTIONS constant
|
|
308
|
+
ProjectPaths::CONVENTIONS = ProjectPaths::DEFAULT_CONVENTIONS.merge(ProjectPaths::DX_CONVENTIONS).freeze
|
|
309
|
+
end
|