Class: LittleGhost::Support::Loader

Inherits:
Object
  • Object
show all
Defined in:
lib/little_ghost/support/loader.rb

Overview

Loader finds agents and tools from a conventional application layout. Ruby files map to constants by path, so applications can add extension classes without maintaining a manual require list.

loader = LittleGhost::Support::Loader.new(root: Dir.pwd)
loader.setup.eager_load

Setup is process-serialized, collisions are rejected, and real paths are checked for symbolic-link escapes. Application load roots are trusted code, not a sandbox for untrusted files.

Defined Under Namespace

Classes: ConflictError, ExpectedConstantError

Constant Summary collapse

DEFAULT_DIRECTORIES =

:nodoc:

%w[app/agents app/tools].freeze
PROCESS_LOCK =

:nodoc:

Monitor.new

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(paths: nil, root: nil, directories: DEFAULT_DIRECTORIES) ⇒ Loader

Uses explicit paths or conventional directories beneath root.



26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/little_ghost/support/loader.rb', line 26

def initialize(paths: nil, root: nil, directories: DEFAULT_DIRECTORIES)
  @root = root && File.expand_path(root)
  @directories = Array(directories).map(&:to_s).freeze
  if @root && @directories.any? { |directory| Pathname.new(directory).absolute? || directory.split(File::SEPARATOR).include?("..") }
    raise ArgumentError, "application load directories must stay within root"
  end
  roots = paths || @directories.map { |directory| File.join(@root || Dir.pwd, directory) }
  @paths = Array(roots).map { |path| File.expand_path(path) }.uniq.freeze
  @registry = nil
  @loaded_constants = {}
  @setup = false
end

Instance Attribute Details

#directoriesObject (readonly)

Application root and configured relative load directories.



23
24
25
# File 'lib/little_ghost/support/loader.rb', line 23

def directories
  @directories
end

#rootObject (readonly)

Application root and configured relative load directories.



23
24
25
# File 'lib/little_ghost/support/loader.rb', line 23

def root
  @root
end

Instance Method Details

#constant(name) ⇒ Object

Loads an application constant after installing autoloads.



117
118
119
120
121
122
123
124
125
# File 'lib/little_ghost/support/loader.rb', line 117

def constant(name)
  PROCESS_LOCK.synchronize do
    setup
    constantize(name.to_s)
  end
rescue NameError => error
  raise unless expected_constant_missing?(error, name.to_s)
  raise ExpectedConstantError, "Unknown application constant: #{name}"
end

#eager_loadObject

Loads every registered constant and verifies its defining file.



60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/little_ghost/support/loader.rb', line 60

def eager_load
  PROCESS_LOCK.synchronize do
    setup
    registry.each_key do |constant_name|
      validate_registered_path!(constant_name)
      constantize(constant_name)
      @loaded_constants[constant_name] = registry.fetch(constant_name)
    rescue NameError => error
      raise unless expected_constant_missing?(error, constant_name)
      raise ExpectedConstantError, "#{registry.fetch(constant_name)} must define #{constant_name}"
    end
  end
  self
end

#fetch(relative_path) ⇒ Object

Finds a relative file or raises LoadError.



90
91
92
# File 'lib/little_ghost/support/loader.rb', line 90

def fetch(relative_path)
  find(relative_path) || raise(LoadError, "Could not find #{relative_path} in configured paths")
end

#find(relative_path) ⇒ Object

Finds a relative file beneath configured paths, returning its resolved path or nil.



77
78
79
80
81
82
83
84
85
86
87
# File 'lib/little_ghost/support/loader.rb', line 77

def find(relative_path)
  clean = clean_path(relative_path)
  @paths.each do |path_root|
    candidate = File.expand_path(clean, path_root)
    next unless inside?(candidate, path_root)
    next unless File.file?(candidate)
    real_candidate = File.realpath(candidate)
    return real_candidate if inside?(real_candidate, real_path_root(path_root))
  end
  nil
end

#glob(pattern) ⇒ Object

Finds resolved paths matching a relative glob without root escapes.



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/little_ghost/support/loader.rb', line 100

def glob(pattern)
  clean = clean_path(pattern)
  @paths.flat_map do |path_root|
    next [] unless Dir.exist?(path_root)
    real_root = real_path_root(path_root)
    Dir.glob(File.join(path_root, clean)).filter_map do |candidate|
      expanded = File.expand_path(candidate)
      next unless inside?(expanded, path_root)
      real_candidate = File.realpath(candidate)
      real_candidate if inside?(real_candidate, real_root)
    rescue Errno::ENOENT
      nil
    end
  end.uniq.sort
end

#loaded_constant?(name) ⇒ Boolean

Checks whether name was loaded from its registered source path.

Returns:

  • (Boolean)


133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/little_ghost/support/loader.rb', line 133

def loaded_constant?(name)
  PROCESS_LOCK.synchronize do
    path = registry[name.to_s]
    return false unless path

    names = name.to_s.split("::")
    leaf = names.pop
    namespace = names.inject(Object) do |parent, child|
      break unless parent.const_defined?(child, false)

      parent.const_get(child, false)
    end
    autoload_path = namespace&.autoload?(leaf)
    return File.realpath(autoload_path) == path if autoload_path

    location = constant_source_location(name.to_s)
    location && File.realpath(location.first) == path
  end
rescue Errno::ENOENT
  false
end

#read(relative_path, encoding: "UTF-8") ⇒ Object

Reads a relative file using encoding.



95
96
97
# File 'lib/little_ghost/support/loader.rb', line 95

def read(relative_path, encoding: "UTF-8")
  File.read(fetch(relative_path), encoding:)
end

#registered_constantsObject

Copies registered constant names and resolved paths.



128
129
130
# File 'lib/little_ghost/support/loader.rb', line 128

def registered_constants
  registry.dup.freeze
end

#setupObject

Installs autoloads for the current registry and returns self.



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/little_ghost/support/loader.rb', line 40

def setup
  PROCESS_LOCK.synchronize do
    return self if @setup

    registry.each do |constant_name, path|
      namespace, name = namespace_for(constant_name)
      if namespace.const_defined?(name, false) || namespace.autoload?(name)
        existing = namespace.autoload?(name)
        next if existing && File.expand_path(existing) == path
        next if !existing && constant_source_location(constant_name)&.then { |location| File.realpath(location.first) == path }
        raise ConflictError, "#{constant_name} is already defined"
      end
      namespace.autoload(name, path)
    end
    @setup = true
  end
  self
end