Skip to content

Commit 4c3b9d6

Browse files
committed
feat: implement artifact forge
1 parent c58264a commit 4c3b9d6

13 files changed

+363
-115
lines changed

lib/forge.rb

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# typed: strict
2+
3+
require_relative "forge/synthesizer"
4+
require_relative "forge/concept"
5+
6+
class Forge
7+
extend T::Sig
8+
9+
sig { void }
10+
def initialize
11+
@synthesizers = T.let(Set.new, T::Set[Forge::Synthesizer])
12+
end
13+
14+
sig { params(synthesizer: Forge::Synthesizer).void }
15+
def register_synthesizer(synthesizer)
16+
synthesizers << synthesizer
17+
end
18+
19+
sig { params(concept: Forge::Concept).void }
20+
def forge(concept)
21+
pending_deletions = Set.new
22+
up_to_date = Set.new
23+
24+
# Find things that need to be deleted and things that are up to date
25+
synthesizers.each do |synthesizer|
26+
template = synthesizer.path_template(concept)
27+
existing_file = template.existing_file
28+
29+
# File does not exist, can not be up to date
30+
next unless existing_file
31+
32+
# File exists but the synthesizer no longer works on it, delete it
33+
unless synthesizer.synthesizes?(concept)
34+
next pending_deletions << existing_file
35+
end
36+
37+
file_signature = template.parse_signature_from_file(existing_file)
38+
# File exists and the synthesizer has pending work
39+
next up_to_date << synthesizer if file_signature != concept.signature
40+
end
41+
42+
# Delete generated files that are no longer needed
43+
T.unsafe(File).delete(*pending_deletions)
44+
45+
need_rebuild = synthesizers_for(concept) - up_to_date
46+
47+
# Find all synthesizers for the concept and synthesize
48+
need_rebuild.each do |synthesizer|
49+
artifact = synthesizer.synthesize(concept)
50+
artifact.write!
51+
end
52+
end
53+
54+
private
55+
56+
sig { params(concept: Forge::Concept).returns(T::Set[Forge::Synthesizer]) }
57+
def synthesizers_for(concept)
58+
Set.new(
59+
synthesizers.filter { |synthesizer| synthesizer.synthesizes?(concept) }
60+
)
61+
end
62+
63+
sig { returns(T::Set[Forge::Synthesizer]) }
64+
attr_reader :synthesizers
65+
end

lib/forge/artifact.rb

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# typed: strict
2+
3+
require_relative "path_template"
4+
5+
class Forge
6+
# The `Artifact` class represents the result of a `Synthesizer` in the form of
7+
# a file that can be written to disk.
8+
class Artifact
9+
extend T::Sig
10+
11+
sig do
12+
params(
13+
content: String,
14+
path_template: Forge::PathTemplate,
15+
signature: String
16+
).void
17+
end
18+
def initialize(content:, path_template:, signature:)
19+
@content = content
20+
@path_template = path_template
21+
@signature = signature
22+
end
23+
24+
sig { returns(String) }
25+
attr_reader :content
26+
27+
sig { returns(Forge::PathTemplate) }
28+
attr_reader :path_template
29+
30+
sig { returns(String) }
31+
attr_reader :signature
32+
33+
sig { void }
34+
def write!
35+
path = path_template.full_path(signature)
36+
File.write(path, content)
37+
end
38+
end
39+
end

lib/forge/concept.rb

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# typed: strict
2+
3+
class Forge
4+
# The `Concept` interface represents any entity in the resource registry system
5+
# that can be used as a source for artifact synthesis. This includes resources,
6+
# schemas, verbs, and any future concepts that may be added.
7+
#
8+
# Implementing classes should provide methods for generating signatures and
9+
# supporting composition with other concepts.
10+
module Concept
11+
extend T::Sig
12+
extend T::Helpers
13+
abstract!
14+
15+
# Returns a unique identifier for this concept
16+
sig { abstract.returns(Symbol) }
17+
def identifier
18+
end
19+
20+
sig { abstract.returns(T::Hash[Symbol, T.untyped]) }
21+
# Returns a hash representation of this concept
22+
def dump
23+
end
24+
25+
# Generates a signature for this concept that can be used to determine
26+
# if it has changed and requires regeneration of artifacts
27+
sig { returns(String) }
28+
def signature
29+
# Generate a signature based on the resource's properties
30+
digest = Digest::SHA1.new
31+
digest.update(dump.to_json)
32+
digest.hexdigest
33+
end
34+
end
35+
end

lib/forge/path_template.rb

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# typed: strict
2+
3+
require_relative "concept"
4+
5+
class Forge
6+
# Allows defining a template for the file and path of the output of a
7+
# synthesizer.
8+
class PathTemplate
9+
extend T::Sig
10+
11+
class BadSignatureError < StandardError
12+
end
13+
14+
class MultipleFilesForConceptError < StandardError
15+
end
16+
17+
sig { params(path: String, basename: String, extension: String).void }
18+
def initialize(path:, basename:, extension:)
19+
@path = path
20+
@basename = basename
21+
@extension = extension
22+
@filename_regex = T.let(nil, T.nilable(Regexp))
23+
@existing_file = T.let(nil, T.nilable(String))
24+
end
25+
26+
sig { params(signature: String).returns(String) }
27+
def filename(signature)
28+
"#{basename}.#{signature}.#{extension}"
29+
end
30+
31+
sig { params(signature: String).returns(String) }
32+
def full_path(signature)
33+
File.join(path, filename(signature))
34+
end
35+
36+
sig { params(path: String).returns(String) }
37+
def parse_signature_from_file(path)
38+
match = path.match(filename_regex)
39+
unless match
40+
raise BadSignatureError, "Cannot parse signature from #{path}"
41+
end
42+
T.must(match[1])
43+
end
44+
45+
sig { returns(T.nilable(String)) }
46+
def existing_file
47+
@existing_file ||= find_existing_file
48+
end
49+
50+
private
51+
52+
sig { returns(T.nilable(String)) }
53+
def find_existing_file
54+
signatures =
55+
Dir
56+
.glob(glob)
57+
.filter_map do |path|
58+
match = path.match(filename_regex)
59+
raise BadSignatureError, "Bad signature found: #{path}" unless match
60+
match[1]
61+
end
62+
63+
if signatures.size > 1
64+
raise MultipleFilesForConceptError, "Multiple files found for #{glob}"
65+
end
66+
signatures.first
67+
end
68+
69+
sig { returns(String) }
70+
def glob
71+
full_path("*").gsub(".", "\.")
72+
end
73+
74+
sig { returns(Regexp) }
75+
def filename_regex
76+
@filename_regex ||= Regexp.new("#{basename}\.(\H+)\.#{extension}", "i")
77+
end
78+
79+
sig { returns(String) }
80+
attr_reader :path
81+
82+
sig { returns(String) }
83+
attr_reader :basename
84+
85+
sig { returns(String) }
86+
attr_reader :extension
87+
end
88+
end
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
# typed: strict
22

3+
require_relative "concept"
4+
require_relative "path_template"
5+
require_relative "artifact"
6+
37
# A `Synthesizer` can be used to synthesize source code
48
# from a given resource.
59
#
610
# This enables defining capabilities that create static source files instead of
711
# metaprogramming things at runtime. Synthesizers should be able to be applied
812
# over a single resource, schema or verb.
9-
module ResourceRegistry
13+
class Forge
1014
module Synthesizer
1115
extend T::Sig
1216
extend T::Helpers
@@ -19,21 +23,24 @@ def name
1923

2024
# Returns true if the synthesizer can synthesize an artifact for the given
2125
# concept.
22-
sig do
23-
abstract
24-
.params(concept: ResourceRegistry::Forge::Concept)
25-
.returns(T::Boolean)
26-
end
26+
# This should implement any checks to determine if the synthesizer can
27+
# synthesize the given concept, for example, checking if the concept has
28+
# appropriate capabilities.
29+
sig { abstract.params(concept: Forge::Concept).returns(T::Boolean) }
2730
def synthesizes?(concept)
2831
end
2932

3033
# Synthesizes an artifact for the given concept.
34+
sig { abstract.params(concept: Forge::Concept).returns(Forge::Artifact) }
35+
def synthesize(concept)
36+
end
37+
38+
# Returns the template to create the path and filename of the output file
39+
# for the given concept
3140
sig do
32-
abstract
33-
.params(concept: ResourceRegistry::Forge::Concept)
34-
.returns(ResourceRegistry::Synthesizer::Artifact)
41+
abstract.params(concept: Forge::Concept).returns(Forge::PathTemplate)
3542
end
36-
def synthesize(concept)
43+
def path_template(concept)
3744
end
3845
end
3946
end

lib/resource_registry/forge.rb

-43
This file was deleted.

lib/resource_registry/resource.rb

+12-12
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
require_relative "relationship"
77
require_relative "verb"
88
require_relative "../schema_registry/schema"
9+
require_relative "../forge/concept"
910

1011
module ResourceRegistry
1112
# The main class that represents a resource in the system.
1213
class Resource < T::Struct
1314
extend T::Sig
15+
include Forge::Concept
1416

1517
class VerbNotFound < StandardError
1618
end
@@ -47,7 +49,7 @@ def slug
4749
@slug ||= T.let(name.to_s.parameterize, T.nilable(String))
4850
end
4951

50-
sig { returns(Symbol) }
52+
sig { override.returns(Symbol) }
5153
def identifier
5254
@identifier ||=
5355
T.let(
@@ -206,22 +208,20 @@ def verbs_except(except)
206208
verbs.values.filter { |v| except.exclude?(v.id) }
207209
end
208210

209-
sig { returns(T::Hash[String, T.untyped]) }
211+
sig { override.returns(T::Hash[Symbol, T.untyped]) }
210212
def dump
211213
{
212-
"identifier" => identifier,
213-
"repository" => repository_raw,
214-
"description" => description,
215-
"relationships" => relationships.values.map(&:dump),
216-
"capabilities" =>
214+
identifier: identifier,
215+
repository: repository_raw,
216+
description: description,
217+
relationships: relationships.values.map(&:dump),
218+
capabilities:
217219
capabilities.values.map { |cap| CapabilityFactory.dump(cap) },
218-
"schema" => schema.dump,
219-
"verbs" =>
220+
schema: schema.dump,
221+
verbs:
220222
verbs
221223
.values
222-
.each_with_object({}) do |verb, memo|
223-
memo[verb.id.to_s] = verb.dump
224-
end
224+
.each_with_object({}) { |verb, memo| memo[verb.id] = verb.dump }
225225
}
226226
end
227227

0 commit comments

Comments
 (0)