forge-ci 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.
@@ -0,0 +1,537 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ForgeCI
4
+ # The config compiler: YAML text in, normalized IR out.
5
+ #
6
+ # ir, errors = ForgeCI::Compiler.compile(yaml_text, pipeline_params: {...})
7
+ # errors = ForgeCI::Compiler.validate(yaml_text)
8
+ #
9
+ # pipeline_params (all optional, string or symbol keys):
10
+ # "branch" -> resolves << pipeline.git.branch >> and branch filters
11
+ # "number" -> resolves << pipeline.number >>
12
+ # "parameters" -> Hash overriding top-level `parameters:` defaults
13
+ #
14
+ # compile returns [ir, errors]. errors is empty on success; ir is nil when
15
+ # errors is non-empty. ForgeCI::Error is raised only when the YAML text
16
+ # cannot be parsed at all.
17
+ class Compiler
18
+ def self.compile(yaml_text, pipeline_params: {})
19
+ new.compile(yaml_text, pipeline_params: pipeline_params)
20
+ end
21
+
22
+ def self.validate(yaml_text)
23
+ new.validate(yaml_text)
24
+ end
25
+
26
+ def compile(yaml_text, pipeline_params: {})
27
+ pp = normalize_pipeline_params(pipeline_params)
28
+ doc = parse_document(yaml_text)
29
+ errors = ErrorCollector.new(LineIndex.new(doc))
30
+ warnings = []
31
+
32
+ config = doc.to_ruby
33
+ unless config.is_a?(Hash)
34
+ errors.add([], "config root must be a mapping of top-level keys (got #{ruby_type(config)})")
35
+ return [nil, errors.to_a]
36
+ end
37
+
38
+ Validator.top_level(config, errors)
39
+ Validator.parameters(config["parameters"], errors)
40
+ each_mapping(config, "commands") { |n, d| Validator.command(n, d, errors) }
41
+ each_mapping(config, "executors") { |n, d| Validator.executor_definition(n, d, errors) }
42
+ return [nil, errors.to_a] if errors.any?
43
+
44
+ prenormalize_all_steps(config)
45
+ expand_commands(config, errors)
46
+ expand_executors(config, errors)
47
+ return [nil, errors.to_a] if errors.any?
48
+
49
+ param_values = resolve_parameters(config["parameters"], pp["parameters"], errors, warnings)
50
+ ctx = {
51
+ "parameters" => param_values,
52
+ "pipeline" => { "branch" => pp["branch"], "number" => pp["number"] },
53
+ "matrix" => {}
54
+ }
55
+
56
+ # Pipeline values that were not supplied (e.g. no branch) are left
57
+ # unresolved with a warning instead of failing the compile — the
58
+ # runner resolves them at execution time.
59
+ on_missing = lambda do |ref, path|
60
+ hint = ref == "pipeline.git.branch" ? "\"branch\"" : "\"number\""
61
+ warnings << "#{path.join('.')}: no value supplied for << #{ref} >>; " \
62
+ "pass #{hint} in pipeline_params to resolve it"
63
+ "<< #{ref} >>"
64
+ end
65
+
66
+ # Global interpolation. Matrix references are intentionally left alone
67
+ # here; they resolve per matrix job during the workflow pass.
68
+ config["jobs"] = Interpolator.interpolate(config["jobs"], ctx, errors, ["jobs"],
69
+ skip_prefixes: ["matrix."], on_missing: on_missing)
70
+ config["workflows"] = Interpolator.interpolate(config["workflows"], ctx, errors, ["workflows"],
71
+ skip_prefixes: ["matrix."], on_missing: on_missing)
72
+ return [nil, errors.to_a] if errors.any?
73
+
74
+ known_jobs = Array(config["jobs"]).to_h.keys.map(&:to_s)
75
+ Array(config["jobs"]).to_h.each { |n, j| Validator.job(n.to_s, j, errors) }
76
+ Array(config["workflows"]).to_h.each { |n, w| Validator.workflow(n.to_s, w, errors, known_jobs) }
77
+ return [nil, errors.to_a] if errors.any?
78
+
79
+ workflows_ir = build_workflows(config, errors, ctx, warnings, on_missing)
80
+ return [nil, errors.to_a] if errors.any?
81
+
82
+ [IR.document(config["version"], workflows_ir, warnings), []]
83
+ end
84
+
85
+ def validate(yaml_text)
86
+ _ir, errors = compile(yaml_text, pipeline_params: {})
87
+ errors
88
+ end
89
+
90
+ private
91
+
92
+ def normalize_pipeline_params(pp)
93
+ pp = (pp || {}).transform_keys(&:to_s)
94
+ {
95
+ "branch" => pp["branch"],
96
+ "number" => pp["number"],
97
+ "parameters" => (pp["parameters"] || {}).transform_keys(&:to_s)
98
+ }
99
+ end
100
+
101
+ def parse_document(yaml_text)
102
+ Psych.parse(yaml_text)
103
+ rescue Psych::SyntaxError => e
104
+ raise Error, "could not parse YAML: #{e.message}"
105
+ end
106
+
107
+ def ruby_type(obj)
108
+ obj.nil? ? "nothing (empty file?)" : obj.class.name
109
+ end
110
+
111
+ def each_mapping(config, key)
112
+ value = config[key]
113
+ return if value.nil?
114
+ return unless value.is_a?(Hash) # type error already reported by top_level
115
+
116
+ value.each { |name, definition| yield(name.to_s, definition) }
117
+ end
118
+
119
+ # --- step pre-normalization ------------------------------------------------
120
+
121
+ # Accepts the common shorthand forms before validation:
122
+ # - run: echo hi -> - run: {command: "echo hi"}
123
+ # - checkout: -> - checkout: {}
124
+ def prenormalize_all_steps(config)
125
+ each_mapping(config, "commands") do |_name, definition|
126
+ next unless definition.is_a?(Hash) && definition["steps"].is_a?(Array)
127
+
128
+ definition["steps"] = prenormalize_steps(definition["steps"])
129
+ end
130
+ each_mapping(config, "jobs") do |_name, job|
131
+ next unless job.is_a?(Hash) && job["steps"].is_a?(Array)
132
+
133
+ job["steps"] = prenormalize_steps(job["steps"])
134
+ end
135
+ end
136
+
137
+ def prenormalize_steps(steps)
138
+ steps.map do |step|
139
+ # Bare `- checkout` parses as the String "checkout", not a mapping.
140
+ if step.is_a?(String)
141
+ { step => {} }
142
+ elsif step.is_a?(Hash) && step.size == 1
143
+ type, cfg = step.first
144
+ case type.to_s
145
+ when "run" then cfg.is_a?(String) ? { "run" => { "command" => cfg } } : step
146
+ when "checkout" then cfg.nil? ? { "checkout" => {} } : step
147
+ else step
148
+ end
149
+ else
150
+ step
151
+ end
152
+ end
153
+ end
154
+
155
+ # --- reusable commands -----------------------------------------------------
156
+
157
+ # Inlines `commands:` call sites. A step whose single key matches a
158
+ # command name is replaced by the command's steps, with
159
+ # << parameters.x >> substituted from the call-site overrides (or the
160
+ # command parameter defaults). References to anything else are left for
161
+ # the global interpolation pass.
162
+ def expand_commands(config, errors)
163
+ commands = config["commands"]
164
+ return unless commands.is_a?(Hash)
165
+
166
+ each_mapping(config, "jobs") do |job_name, job|
167
+ next unless job.is_a?(Hash) && job["steps"].is_a?(Array)
168
+
169
+ path = ["jobs", job_name, "steps"]
170
+ expanded = []
171
+ job["steps"].each_with_index do |step, i|
172
+ expanded.concat(expand_step(step, commands, errors, path + [i], 0))
173
+ end
174
+ job["steps"] = expanded
175
+ end
176
+ end
177
+
178
+ def expand_step(step, commands, errors, path, depth)
179
+ if depth > 10
180
+ errors.add(path, "command expansion exceeded 10 levels; check for recursive command calls")
181
+ return [step]
182
+ end
183
+ return [step] unless step.is_a?(Hash) && step.size == 1
184
+
185
+ key, value = step.first
186
+ key = key.to_s
187
+ return [step] unless commands.key?(key)
188
+
189
+ definition = commands[key]
190
+ params_def = (definition.is_a?(Hash) && definition["parameters"]) || {}
191
+ unless value.nil? || value.is_a?(Hash)
192
+ errors.add(path, "arguments to command #{key.inspect} must be a mapping of parameter names to values")
193
+ return [step]
194
+ end
195
+ overrides = (value || {}).transform_keys(&:to_s)
196
+
197
+ values = resolve_call_params(params_def, overrides, errors, path, "command #{key.inspect}")
198
+ steps = deep_dup(definition["steps"] || [])
199
+ local_ctx = { "parameters" => values, "pipeline" => {}, "matrix" => {} }
200
+ only = values.keys.map { |name| "parameters.#{name}" }
201
+ steps = Interpolator.interpolate(steps, local_ctx, errors, path, only: only)
202
+ steps.flat_map { |s| expand_step(s, commands, errors, path, depth + 1) }
203
+ end
204
+
205
+ def resolve_call_params(params_def, overrides, errors, path, owner)
206
+ params_def = params_def.is_a?(Hash) ? params_def : {}
207
+ unknown = overrides.keys - params_def.keys.map(&:to_s)
208
+ unknown.each { |u| errors.add(path, "#{owner} has no parameter #{u.inspect}") }
209
+
210
+ values = {}
211
+ params_def.each do |pname, pdef|
212
+ pname = pname.to_s
213
+ if overrides.key?(pname)
214
+ values[pname] = overrides[pname]
215
+ elsif pdef.is_a?(Hash) && pdef.key?("default")
216
+ values[pname] = pdef["default"]
217
+ else
218
+ errors.add(path, "#{owner} requires parameter #{pname.inspect} but no value was given")
219
+ end
220
+ end
221
+ values
222
+ end
223
+
224
+ # --- reusable executors ----------------------------------------------------
225
+
226
+ # Resolves `executor:` references on jobs. Supports both forms:
227
+ # executor: my-executor
228
+ # executor: {name: my-executor, ruby_version: "3.2"}
229
+ # Job-level keys win over the executor definition (environment maps merge).
230
+ def expand_executors(config, errors)
231
+ executors = config["executors"]
232
+ return unless executors.is_a?(Hash)
233
+
234
+ each_mapping(config, "jobs") do |job_name, job|
235
+ next unless job.is_a?(Hash)
236
+
237
+ path = ["jobs", job_name]
238
+ ref = job["executor"]
239
+ next if ref.nil?
240
+
241
+ if job.key?("docker")
242
+ errors.add(path, "job #{job_name.inspect} declares both \"docker:\" and \"executor:\"; use one")
243
+ next
244
+ end
245
+
246
+ name, overrides = parse_executor_ref(ref, errors, path + ["executor"])
247
+ next if name.nil?
248
+
249
+ definition = executors[name]
250
+ if definition.nil?
251
+ errors.add(path + ["executor"], "unknown executor #{name.inspect}; " \
252
+ "defined executors: #{executors.keys.map(&:to_s).sort.join(', ')}")
253
+ next
254
+ end
255
+
256
+ values = resolve_call_params(definition["parameters"], overrides, errors, path + ["executor"], "executor #{name.inspect}")
257
+ expanded = deep_dup(definition)
258
+ expanded.delete("parameters")
259
+ only = values.keys.map { |pname| "parameters.#{pname}" }
260
+ expanded = Interpolator.interpolate(expanded, { "parameters" => values, "pipeline" => {}, "matrix" => {} },
261
+ errors, path + ["executor"], only: only)
262
+
263
+ expanded.each do |key, value|
264
+ if key == "environment" && job["environment"].is_a?(Hash) && value.is_a?(Hash)
265
+ job["environment"] = value.merge(job["environment"])
266
+ elsif !job.key?(key)
267
+ job[key] = value
268
+ end
269
+ end
270
+ job.delete("executor")
271
+ end
272
+ end
273
+
274
+ def parse_executor_ref(ref, errors, path)
275
+ case ref
276
+ when String
277
+ [ref, {}]
278
+ when Hash
279
+ if ref.size == 1 && !ref.key?("name")
280
+ name, params = ref.first
281
+ [name.to_s, params.is_a?(Hash) ? params.transform_keys(&:to_s) : {}]
282
+ elsif ref["name"]
283
+ [ref["name"].to_s, ref.reject { |k, _| k.to_s == "name" }.transform_keys(&:to_s)]
284
+ else
285
+ errors.add(path, "\"executor:\" mapping needs a \"name:\" key")
286
+ [nil, {}]
287
+ end
288
+ else
289
+ errors.add(path, "\"executor:\" must be an executor name or a mapping with \"name:\"")
290
+ [nil, {}]
291
+ end
292
+ end
293
+
294
+ # --- pipeline parameters ---------------------------------------------------
295
+
296
+ def resolve_parameters(params_def, overrides, errors, warnings)
297
+ params_def = params_def.is_a?(Hash) ? params_def : {}
298
+ unknown = overrides.keys - params_def.keys.map(&:to_s)
299
+ unknown.each do |name|
300
+ warnings << "pipeline_params references unknown parameter #{name.inspect}; ignored"
301
+ end
302
+
303
+ values = {}
304
+ params_def.each do |name, definition|
305
+ name = name.to_s
306
+ type = (definition.is_a?(Hash) && definition["type"] || "string").to_s
307
+ if overrides.key?(name)
308
+ value = overrides[name]
309
+ unless Validator.value_matches_type?(value, type)
310
+ errors.add(["parameters", name],
311
+ "override value #{value.inspect} does not match declared type #{type.inspect}")
312
+ next
313
+ end
314
+ values[name] = value
315
+ elsif definition.is_a?(Hash) && definition.key?("default")
316
+ values[name] = definition["default"]
317
+ else
318
+ errors.add(["parameters", name],
319
+ "parameter #{name.inspect} has no default and no value was supplied via pipeline_params")
320
+ end
321
+ end
322
+ values
323
+ end
324
+
325
+ # --- workflows ---------------------------------------------------------------
326
+
327
+ def build_workflows(config, errors, ctx, warnings, on_missing)
328
+ workflows_cfg = config["workflows"]
329
+ jobs_def = config["jobs"] || {}
330
+
331
+ if !workflows_cfg.is_a?(Hash) || workflows_cfg.empty?
332
+ warnings << "no \"workflows:\" defined; running all jobs in a single implicit \"default\" workflow"
333
+ names = jobs_def.keys.map(&:to_s)
334
+ return [IR.workflow("default", names.map { |n| build_job_ir(n, n, {}, jobs_def, errors, ctx, on_missing) })]
335
+ end
336
+
337
+ workflows_cfg.map do |wf_name, wf|
338
+ build_workflow(wf_name.to_s, wf, jobs_def, errors, ctx, warnings, on_missing)
339
+ end.compact
340
+ end
341
+
342
+ def build_workflow(wf_name, wf, jobs_def, errors, ctx, warnings, on_missing)
343
+ branch = ctx["pipeline"]["branch"]
344
+
345
+ # 1. Expand matrix entries into concrete job instances.
346
+ instances = [] # [instance_name, base_job_name, entry_cfg, matrix_values]
347
+ sources = Hash.new { |h, k| h[k] = [] } # base name or matrix alias -> [instance names]
348
+ (wf["jobs"] || []).each_with_index do |entry, i|
349
+ job_name, cfg = Validator::CompilerEntry.parse(entry)
350
+ path = ["workflows", wf_name, "jobs", i]
351
+
352
+ if cfg["matrix"]
353
+ combos = matrix_combinations(cfg["matrix"]["parameters"] || {})
354
+ combos.each_with_index do |combo, idx|
355
+ inst = "#{job_name}-#{idx}"
356
+ instances << [inst, job_name, cfg, combo]
357
+ sources[job_name] << inst
358
+ sources[cfg["matrix"]["alias"].to_s] << inst if cfg["matrix"]["alias"]
359
+ end
360
+ else
361
+ instances << [job_name, job_name, cfg, {}]
362
+ sources[job_name] << job_name
363
+ end
364
+
365
+ if (filters = cfg["filters"]) && filters.is_a?(Hash)
366
+ extra = filters.keys.map(&:to_s) - ["branches"]
367
+ unless extra.empty?
368
+ warnings << "workflows.#{wf_name}: only \"branches\" filters are supported in v1; " \
369
+ "#{extra.map(&:inspect).join(', ')} on job #{job_name.inspect} will be ignored"
370
+ end
371
+ end
372
+ end
373
+
374
+ seen = {}
375
+ instances.each do |inst, _base, _cfg, _combo|
376
+ if seen[inst]
377
+ errors.add(["workflows", wf_name], "job #{inst.inspect} appears more than once in the workflow")
378
+ end
379
+ seen[inst] = true
380
+ end
381
+ return nil if errors.any?
382
+
383
+ # 2. Branch filters decide which instances are active.
384
+ active = {}
385
+ instances.each do |inst, _base, cfg, _combo|
386
+ filters = cfg["filters"] || {}
387
+ if filters["branches"] && branch.nil?
388
+ warnings << "no branch supplied via pipeline_params; branch filters for job #{inst.inspect} not applied"
389
+ active[inst] = true
390
+ else
391
+ active[inst] = branch_matches?(filters, branch)
392
+ end
393
+ end
394
+
395
+ # 3. Resolve requires edges (names and matrix aliases both work).
396
+ edges = {}
397
+ instances.each_with_index do |(inst, _base, cfg, _combo), i|
398
+ path = ["workflows", wf_name, "jobs", i]
399
+ deps = []
400
+ Array(cfg["requires"]).each do |req|
401
+ targets = sources[req.to_s]
402
+ if targets.nil? || targets.empty?
403
+ errors.add(path + [inst, "requires"],
404
+ "job #{inst.inspect} requires unknown job or matrix alias #{req.inspect}")
405
+ next
406
+ end
407
+ targets.each do |target|
408
+ if active[target]
409
+ deps << target
410
+ else
411
+ warnings << "job #{inst.inspect} requires #{target.inspect}, which was filtered out " \
412
+ "by branch filters; treating the dependency as satisfied"
413
+ end
414
+ end
415
+ end
416
+ edges[inst] = deps.uniq
417
+ end
418
+ return nil if errors.any?
419
+
420
+ # 4. Cycle detection + topological order over active instances only.
421
+ active_names = instances.map(&:first).select { |n| active[n] }
422
+ pruned = active_names.to_h { |n| [n, edges[n].select { |d| active[d] }] }
423
+ if (cycle = DAG.find_cycle(active_names, pruned))
424
+ errors.add(["workflows", wf_name], "dependency cycle detected: #{cycle.join(' -> ')}")
425
+ return nil
426
+ end
427
+ order = DAG.toposort(active_names, pruned)
428
+
429
+ # 5. Emit IR jobs in topological order.
430
+ lookup = instances.to_h { |inst, base, cfg, combo| [inst, [base, cfg, combo]] }
431
+ jobs_ir = order.map do |inst|
432
+ base, _cfg, combo = lookup[inst]
433
+ build_job_ir(inst, base, combo, jobs_def, errors, ctx, on_missing, pruned[inst] || [])
434
+ end
435
+ return nil if errors.any?
436
+
437
+ IR.workflow(wf_name, jobs_ir)
438
+ end
439
+
440
+ def build_job_ir(instance_name, base_name, matrix_values, jobs_def, errors, ctx, on_missing, requires = [])
441
+ job_def = deep_dup(jobs_def[base_name])
442
+ job_ctx = ctx.merge("matrix" => matrix_values)
443
+ job_def = Interpolator.interpolate(job_def, job_ctx, errors, ["jobs", base_name], on_missing: on_missing)
444
+ # Anything still unresolved here is a real problem — except pipeline
445
+ # values, which were deliberately left with a warning above.
446
+ leftover = Interpolator.unresolved_refs(job_def).reject { |r| Interpolator.pipeline_ref?(r) }
447
+ unless leftover.empty?
448
+ errors.add(["jobs", base_name],
449
+ "unresolved interpolation #{leftover.map { |r| "<< #{r} >>" }.join(', ')}; " \
450
+ "matrix references are only allowed inside matrix jobs")
451
+ return nil
452
+ end
453
+ job_def["steps"] = evaluate_conditionals(job_def["steps"] || [], errors, ["jobs", base_name, "steps"])
454
+ IR.job(instance_name, job_def, requires)
455
+ end
456
+
457
+ def matrix_combinations(parameters)
458
+ keys = parameters.keys.map(&:to_s)
459
+ values = keys.map { |k| Array(parameters[k]) }
460
+ values.first.product(*values.drop(1)).map do |combo|
461
+ keys.zip(Array(combo)).to_h
462
+ end
463
+ end
464
+
465
+ # --- branch filters ------------------------------------------------------------
466
+
467
+ def branch_matches?(filters, branch)
468
+ branches = (filters || {})["branches"] || {}
469
+ only = branches["only"]
470
+ ignore = branches["ignore"]
471
+ return false if only && !matches_any?(only, branch)
472
+ return false if ignore && matches_any?(ignore, branch)
473
+
474
+ true
475
+ end
476
+
477
+ def matches_any?(patterns, branch)
478
+ Array(patterns).any? do |pattern|
479
+ if pattern.start_with?("/") && pattern.end_with?("/") && pattern.length > 1
480
+ begin
481
+ Regexp.new(pattern[1..-2]).match?(branch.to_s)
482
+ rescue RegexpError
483
+ false
484
+ end
485
+ else
486
+ pattern == branch
487
+ end
488
+ end
489
+ end
490
+
491
+ # --- when / unless conditional steps ----------------------------------------------
492
+
493
+ # Evaluates compile-time conditional steps. Conditions were interpolated
494
+ # already, so they are booleans (or "true"/"false" strings).
495
+ def evaluate_conditionals(steps, errors, path)
496
+ result = []
497
+ steps.each_with_index do |step, i|
498
+ unless step.is_a?(Hash) && step.size == 1
499
+ result << step
500
+ next
501
+ end
502
+ type, cfg = step.first
503
+ unless %w[when unless].include?(type.to_s)
504
+ result << step
505
+ next
506
+ end
507
+ cond = truthy?(cfg["condition"], errors, path + [i, type, "condition"])
508
+ include_steps = type.to_s == "when" ? cond : !cond
509
+ next unless include_steps
510
+
511
+ result.concat(evaluate_conditionals(cfg["steps"] || [], errors, path + [i, type, "steps"]))
512
+ end
513
+ result
514
+ end
515
+
516
+ def truthy?(value, errors, path)
517
+ case value
518
+ when true, "true" then true
519
+ when false, "false" then false
520
+ else
521
+ errors.add(path, "condition must resolve to true/false (got #{value.inspect}); " \
522
+ "use a boolean parameter like \"<< parameters.run_lint >>\"")
523
+ false
524
+ end
525
+ end
526
+
527
+ # --- misc --------------------------------------------------------------------------
528
+
529
+ def deep_dup(obj)
530
+ case obj
531
+ when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
532
+ when Array then obj.map { |v| deep_dup(v) }
533
+ else obj
534
+ end
535
+ end
536
+ end
537
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ForgeCI
4
+ # Directed-acyclic-graph helpers for workflow `requires` edges.
5
+ #
6
+ # nodes - job names in config order (stable tie-breaking)
7
+ # edges - Hash of job name => Array of names it depends on
8
+ module DAG
9
+ module_function
10
+
11
+ # Returns an Array describing the cycle (e.g. ["a", "b", "a"]) or nil.
12
+ def find_cycle(nodes, edges)
13
+ visited = {} # name => :visiting | :done
14
+ stack = []
15
+
16
+ visit = lambda do |node|
17
+ visited[node] = :visiting
18
+ stack << node
19
+ (edges[node] || []).each do |dep|
20
+ if visited[dep] == :visiting
21
+ return stack.drop(stack.index(dep)) + [dep]
22
+ elsif !visited.key?(dep)
23
+ cycle = visit.call(dep)
24
+ return cycle if cycle
25
+ end
26
+ end
27
+ stack.pop
28
+ visited[node] = :done
29
+ nil
30
+ end
31
+
32
+ nodes.each do |node|
33
+ next if visited.key?(node)
34
+
35
+ cycle = visit.call(node)
36
+ return cycle if cycle
37
+ end
38
+ nil
39
+ end
40
+
41
+ # Kahn's algorithm, stable: among ready nodes, config order wins.
42
+ # Assumes the graph is acyclic (run find_cycle first).
43
+ def toposort(nodes, edges)
44
+ indegree = Hash.new(0)
45
+ dependents = Hash.new { |h, k| h[k] = [] }
46
+ nodes.each do |node|
47
+ (edges[node] || []).each do |dep|
48
+ indegree[node] += 1
49
+ dependents[dep] << node
50
+ end
51
+ end
52
+
53
+ ready = nodes.select { |n| indegree[n].zero? }
54
+ order = []
55
+ until ready.empty?
56
+ node = ready.shift
57
+ order << node
58
+ dependents[node].each do |dep|
59
+ indegree[dep] -= 1
60
+ ready << dep if indegree[dep].zero?
61
+ end
62
+ # Keep config order among newly-ready nodes.
63
+ ready.sort_by! { |n| nodes.index(n) }
64
+ end
65
+ order
66
+ end
67
+ end
68
+ end