Class: RosettAi::Compiler::CompilationPipeline

Inherits:
Object
  • Object
show all
Defined in:
lib/rosett_ai/compiler/compilation_pipeline.rb

Overview

Core compilation pipeline that transforms YAML configuration files into target-specific output formats.

Delegates rendering to a pluggable Backend instance. Owns discovery, validation, checksumming, diffing, lockfile generation, and orphan management.

Direct Known Subclasses

BehaviourCompiler

Constant Summary collapse

NON_COMPILABLE_CATEGORIES =

Categories excluded from compilation. These directories contain configuration files validated by their respective modules (e.g., Policy::DenyList, Build::Package) rather than the compile pipeline.

['packaging', 'policy', 'tooling'].freeze
PROJECT_ONLY_CATEGORIES =

Categories compiled only in project scope. Design documents are project specifications, not operational rules — compiling them globally would pollute ~/.claude/rules/ with empty files.

['design'].freeze
VALID_SCOPES =
[:global, :project].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source_dir:, target_dir:, schema_dir:, backend:, scope: :global, additional_source_dirs: []) ⇒ CompilationPipeline

Returns a new instance of CompilationPipeline.

Parameters:

  • source_dir (String, Pathname)

    primary conf/ root (project scope when in project)

  • target_dir (String, Pathname)

    output directory

  • schema_dir (String, Pathname)

    JSON Schema directory

  • backend (RosettAi::Compiler::Backend)

    rendering backend

  • scope (Symbol) (defaults to: :global)

    compilation scope (:global or :project)

  • additional_source_dirs (Array<String, Pathname>) (defaults to: [])

    extra source directories (e.g. global conf/ when compiling in project scope). Files from additional sources are included unless overridden by a file with the same basename in the primary source_dir. Empty by default for backward compatibility.

  • source_dir (String, Pathname)

    primary conf/ root

  • target_dir (String, Pathname)

    output directory

  • schema_dir (String, Pathname)

    JSON Schema directory

  • backend (RosettAi::Compiler::Backend)

    rendering backend

  • scope (Symbol) (defaults to: :global)

    compilation scope (:global or :project)

  • additional_source_dirs (Array<String, Pathname>) (defaults to: [])

    extra source directories (e.g. global conf/ when compiling in project scope)



52
53
54
55
56
57
58
59
60
61
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 52

def initialize(source_dir:, target_dir:, schema_dir:, backend:, # rubocop:disable Metrics/ParameterLists
               scope: :global, additional_source_dirs: [])
  validate_scope!(scope)
  @source_dir = Pathname.new(source_dir)
  @target_dir = Pathname.new(target_dir)
  @schema_dir = Pathname.new(schema_dir)
  @backend = backend
  @scope = scope
  @additional_source_dirs = additional_source_dirs.map { |d| Pathname.new(d) }
end

Instance Attribute Details

#additional_source_dirsObject (readonly)

Returns the value of attribute additional_source_dirs.



33
34
35
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 33

def additional_source_dirs
  @additional_source_dirs
end

#backendObject (readonly)

Returns the value of attribute backend.



33
34
35
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 33

def backend
  @backend
end

#schema_dirObject (readonly)

Returns the value of attribute schema_dir.



33
34
35
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 33

def schema_dir
  @schema_dir
end

#scopeObject (readonly)

Returns the value of attribute scope.



33
34
35
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 33

def scope
  @scope
end

#source_dirObject (readonly)

Returns the value of attribute source_dir.



33
34
35
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 33

def source_dir
  @source_dir
end

#target_dirObject (readonly)

Returns the value of attribute target_dir.



33
34
35
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 33

def target_dir
  @target_dir
end

Instance Method Details

#checksum(content) ⇒ String

Computes a SHA-256 hex digest of the given content.

Parameters:

  • content (String)

    content to hash

Returns:

  • (String)

    hex-encoded SHA-256 digest



155
156
157
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 155

def checksum(content)
  Digest::SHA256.hexdigest(content)
end

#compileHash{String => CompiledOutput}

Compiles all discovered categories into rendered output. When additional_source_dirs are present, files are collected from all sources. The primary source_dir takes precedence: a file with the same basename in source_dir overrides one from additional sources.

After compilation, runs duplicate rule ID detection across all behaviour files. Access warnings via #conflict_warnings.

Returns:



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 103

def compile
  results = {}
  all_category_files = {}
  discover_categories.each do |category|
    yaml_files = collect_category_files(category)
    yaml_files.concat(premium_files_for(category))
    all_category_files[category] = yaml_files
    yaml_files.each do |file|
      data = load_yaml(file)
      next unless data

      validate!(category, data, file)
      key, output = compile_file(category, data, file)
      results[key] = output
    end
  end
  @conflict_warnings = detect_duplicate_rule_ids(all_category_files)
  results
end

#conflict_warningsArray<String>

Returns conflict warnings from the most recent compile run.

Returns:

  • (Array<String>)

    conflict warning messages



126
127
128
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 126

def conflict_warnings
  @conflict_warnings || []
end

#diff(existing_path, new_content) ⇒ String?

Generates a unified diff between an existing file and new content.

Parameters:

  • existing_path (String)

    path to the existing file

  • new_content (String)

    proposed new content

Returns:

  • (String, nil)

    unified diff string, or nil if unchanged/missing



184
185
186
187
188
189
190
191
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 184

def diff(existing_path, new_content)
  return nil unless File.exist?(existing_path)

  existing = File.read(existing_path, encoding: 'utf-8')
  return nil if existing == new_content

  generate_unified_diff(existing_path, existing, new_content)
end

#discover_categoriesArray<String>

Discovers compilable config categories across all source directories.

Returns:

  • (Array<String>)

    category names with matching schemas



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 73

def discover_categories
  skip = ['schemas', 'targets'] + NON_COMPILABLE_CATEGORIES
  skip += PROJECT_ONLY_CATEGORIES if scope == :global

  categories = Set.new
  all_source_dirs.each do |sdir|
    next unless sdir.exist?

    Dir.children(sdir).each do |entry|
      next unless sdir.join(entry).directory?
      next if skip.include?(entry)

      categories << entry
    end
  end

  categories.select do |category|
    schema_dir.join("#{category}_schema.json").exist?
  end.sort
end

#lockfile_data(compiled) ⇒ Hash

Builds a lockfile hash from compiled outputs.

Parameters:

  • compiled (Hash)

    compiled output map (filename => info)

Returns:

  • (Hash)

    lockfile structure suitable for YAML serialization



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 197

def lockfile_data(compiled)
  timestamp = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
  categories = {}
  compiled.each_value do |info|
    cat = info[:category]
    categories[cat] ||= { 'schema' => "conf/schemas/#{cat}_schema.json", 'files' => {} }
    categories[cat]['files'][info[:name]] = info.to_lockfile_hash(
      display_path: RosettAi.paths.rules_display_path,
      source_path: relative_path(info[:source]),
      content_checksum: checksum(info[:content]),
      timestamp: timestamp
    )
  end

  {
    'generated_at' => timestamp,
    'generator' => 'rosett-ai',
    'generator_version' => RosettAi::VERSION,
    'target_directory' => RosettAi.paths.rules_display_path,
    'categories' => categories
  }
end

#managed_file?(path) ⇒ Boolean

Checks whether the file at path was generated by the compiler.

Parameters:

  • path (String)

    file path to check

Returns:

  • (Boolean)


163
164
165
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 163

def managed_file?(path)
  backend.managed_file?(path)
end

#orphaned_files(compiled_names) ⇒ Array<String>

Finds managed target files not present in the compiled set.

Parameters:

  • compiled_names (Array<String>)

    basenames of compiled outputs

Returns:

  • (Array<String>)

    full paths of orphaned files



171
172
173
174
175
176
177
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 171

def orphaned_files(compiled_names)
  return [] unless target_dir.exist?

  Dir.glob(target_dir.join("*#{backend.file_extension}")).select do |file|
    managed_file?(file) && !compiled_names.include?(File.basename(file))
  end
end

#skipped_project_categoriesArray<String>

Returns project-only categories that were skipped in this scope.

Returns:

  • (Array<String>)

    skipped category names (empty for project scope)



66
67
68
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 66

def skipped_project_categories
  scope == :global ? PROJECT_ONLY_CATEGORIES : []
end

#validate!(category, data, file)

This method returns an undefined value.

Validates data against the JSON Schema for the given category.

Parameters:

  • category (String)

    configuration category name

  • data (Hash)

    parsed YAML data to validate

  • file (String)

    source file path (used in error messages)

Raises:



137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/rosett_ai/compiler/compilation_pipeline.rb', line 137

def validate!(category, data, file)
  schema_file = schema_dir.join("#{category}_schema.json")
  schema = JSON.parse(schema_file.read)
  schemer = JSONSchemer.schema(schema)
  errors = schemer.validate(data).to_a
  return if errors.empty?

  messages = errors.map do |err|
    path = err['data_pointer'].empty? ? 'root' : err['data_pointer']
    "#{path}: #{err['type']}"
  end
  raise RosettAi::CompileError, "Validation failed for #{file}: #{messages.join(', ')}"
end