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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/.obsidian/app.json +6 -0
  3. data/.obsidian/appearance.json +4 -0
  4. data/.obsidian/community-plugins.json +5 -0
  5. data/.obsidian/core-plugins.json +33 -0
  6. data/.obsidian/plugins/obsidian-minimal-settings/data.json +34 -0
  7. data/.obsidian/plugins/obsidian-minimal-settings/main.js +8 -0
  8. data/.obsidian/plugins/obsidian-minimal-settings/manifest.json +11 -0
  9. data/.obsidian/plugins/obsidian-style-settings/data.json +15 -0
  10. data/.obsidian/plugins/obsidian-style-settings/main.js +165 -0
  11. data/.obsidian/plugins/obsidian-style-settings/manifest.json +10 -0
  12. data/.obsidian/plugins/obsidian-style-settings/styles.css +243 -0
  13. data/.obsidian/plugins/table-editor-obsidian/data.json +6 -0
  14. data/.obsidian/plugins/table-editor-obsidian/main.js +236 -0
  15. data/.obsidian/plugins/table-editor-obsidian/manifest.json +17 -0
  16. data/.obsidian/plugins/table-editor-obsidian/styles.css +78 -0
  17. data/.obsidian/themes/AnuPpuccin/manifest.json +7 -0
  18. data/.obsidian/themes/AnuPpuccin/theme.css +9080 -0
  19. data/.obsidian/themes/Minimal/manifest.json +8 -0
  20. data/.obsidian/themes/Minimal/theme.css +2251 -0
  21. data/.rubocop.yml +231 -0
  22. data/CHANGELOG.md +97 -0
  23. data/LICENSE +21 -0
  24. data/README.md +314 -0
  25. data/Rakefile +13 -0
  26. data/devex-logo.jpg +0 -0
  27. data/docs/developing-tools.md +1000 -0
  28. data/docs/ref/agent-mode.md +46 -0
  29. data/docs/ref/cli-interface.md +60 -0
  30. data/docs/ref/configuration.md +46 -0
  31. data/docs/ref/design-philosophy.md +17 -0
  32. data/docs/ref/error-handling.md +38 -0
  33. data/docs/ref/io-handling.md +88 -0
  34. data/docs/ref/signals.md +141 -0
  35. data/docs/ref/temporal-software-theory.md +790 -0
  36. data/exe/dx +52 -0
  37. data/lib/devex/builtins/.index.rb +10 -0
  38. data/lib/devex/builtins/debug.rb +43 -0
  39. data/lib/devex/builtins/format.rb +44 -0
  40. data/lib/devex/builtins/gem.rb +77 -0
  41. data/lib/devex/builtins/lint.rb +61 -0
  42. data/lib/devex/builtins/test.rb +76 -0
  43. data/lib/devex/builtins/version.rb +156 -0
  44. data/lib/devex/cli.rb +340 -0
  45. data/lib/devex/context.rb +433 -0
  46. data/lib/devex/core/configuration.rb +136 -0
  47. data/lib/devex/core.rb +79 -0
  48. data/lib/devex/dirs.rb +210 -0
  49. data/lib/devex/dsl.rb +100 -0
  50. data/lib/devex/exec/controller.rb +245 -0
  51. data/lib/devex/exec/result.rb +229 -0
  52. data/lib/devex/exec.rb +662 -0
  53. data/lib/devex/loader.rb +136 -0
  54. data/lib/devex/output.rb +257 -0
  55. data/lib/devex/project_paths.rb +309 -0
  56. data/lib/devex/support/ansi.rb +437 -0
  57. data/lib/devex/support/core_ext.rb +560 -0
  58. data/lib/devex/support/global.rb +68 -0
  59. data/lib/devex/support/path.rb +357 -0
  60. data/lib/devex/support.rb +71 -0
  61. data/lib/devex/template_helpers.rb +136 -0
  62. data/lib/devex/templates/debug.erb +24 -0
  63. data/lib/devex/tool.rb +374 -0
  64. data/lib/devex/version.rb +5 -0
  65. data/lib/devex/working_dir.rb +99 -0
  66. data/lib/devex.rb +158 -0
  67. data/ruby-project-template/.gitignore +0 -0
  68. data/ruby-project-template/Gemfile +0 -0
  69. data/ruby-project-template/README.md +0 -0
  70. data/ruby-project-template/docs/README.md +0 -0
  71. data/sig/devex.rbs +4 -0
  72. metadata +122 -0
@@ -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
@@ -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