suma 0.5.7 → 0.5.9
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.
- checksums.yaml +4 -4
- data/.github/workflows/nightly-benchmark.yml +25 -0
- data/.github/workflows/release-provenance.yml +41 -0
- data/.rubocop_todo.yml +84 -0
- data/CLAUDE.md +44 -0
- data/README.adoc +136 -11
- data/Rakefile +13 -1
- data/bin/benchmark_corpus +138 -0
- data/bin/release_provenance +40 -0
- data/lib/suma/build_plan.rb +71 -0
- data/lib/suma/change_classification.rb +37 -0
- data/lib/suma/change_impact.rb +61 -0
- data/lib/suma/change_manifest.rb +147 -0
- data/lib/suma/cli/build.rb +23 -0
- data/lib/suma/cli/compare.rb +49 -2
- data/lib/suma/cli/core.rb +83 -0
- data/lib/suma/cli/export.rb +140 -10
- data/lib/suma/cli/extract_terms.rb +11 -0
- data/lib/suma/cli/generate_register.rb +12 -0
- data/lib/suma/cli/resolve.rb +45 -0
- data/lib/suma/cli/validate.rb +90 -0
- data/lib/suma/cli.rb +1 -0
- data/lib/suma/closure_validator.rb +317 -0
- data/lib/suma/corpus_graph.rb +141 -0
- data/lib/suma/express_schema.rb +22 -28
- data/lib/suma/null_cache.rb +4 -0
- data/lib/suma/owl_projection.rb +611 -0
- data/lib/suma/processor.rb +55 -6
- data/lib/suma/provenance.rb +159 -0
- data/lib/suma/publication.rb +108 -0
- data/lib/suma/register_manifest_generator.rb +76 -8
- data/lib/suma/schema_cache.rb +72 -13
- data/lib/suma/schema_category.rb +31 -2
- data/lib/suma/schema_collection.rb +58 -5
- data/lib/suma/schema_exporter.rb +5 -5
- data/lib/suma/schema_naming.rb +29 -33
- data/lib/suma/schema_profile.rb +134 -0
- data/lib/suma/term_classification.rb +9 -2
- data/lib/suma/term_extractor.rb +76 -35
- data/lib/suma/urn_resolver.rb +368 -0
- data/lib/suma/version.rb +1 -1
- data/lib/suma/worker_pool.rb +62 -0
- data/lib/suma.rb +12 -0
- metadata +19 -2
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "expressir"
|
|
8
|
+
|
|
9
|
+
module Suma
|
|
10
|
+
# Build provenance: sha256 of every input and output artifact plus the
|
|
11
|
+
# toolchain versions that produced them.
|
|
12
|
+
#
|
|
13
|
+
# Paths are recorded relative to the working directory so a manifest can be
|
|
14
|
+
# moved with its build root. {.verify} recomputes the digests independently.
|
|
15
|
+
class Provenance
|
|
16
|
+
SECTIONS = %w[inputs outputs].freeze
|
|
17
|
+
|
|
18
|
+
# Sign a provenance manifest with an external tool.
|
|
19
|
+
#
|
|
20
|
+
# Optional by design: callers invoke this only when signing is requested.
|
|
21
|
+
# minisign uses a secret key (`key:`), sigstore/cosign signs with a key or
|
|
22
|
+
# keylessly when no key is given (ambient OIDC, e.g. GitHub Actions).
|
|
23
|
+
#
|
|
24
|
+
# @param path [String] provenance manifest
|
|
25
|
+
# @param method [String] minisign or sigstore
|
|
26
|
+
# @param key [String, nil] secret key path (minisign requires it)
|
|
27
|
+
# @return [String] signature artifact path
|
|
28
|
+
def self.sign(path, method:, key: nil)
|
|
29
|
+
binary, argv, signature = signing_command(
|
|
30
|
+
method: method, path: path, key: key,
|
|
31
|
+
)
|
|
32
|
+
sign_with(binary, argv)
|
|
33
|
+
signature
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The external command and signature path for a signing method. Pure
|
|
37
|
+
# apart from filesystem-free validation; exposed for cross-platform specs.
|
|
38
|
+
#
|
|
39
|
+
# @return [Array(String, Array<String>, String)] binary, argv, signature
|
|
40
|
+
def self.signing_command(method:, path:, key: nil)
|
|
41
|
+
case method.to_s
|
|
42
|
+
when "minisign"
|
|
43
|
+
unless key
|
|
44
|
+
raise ArgumentError, "minisign signing requires a secret key"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
signature = "#{path}.minisig"
|
|
48
|
+
["minisign", ["minisign", "-S", "-W", "-s", key, "-m", path,
|
|
49
|
+
"-x", signature], signature]
|
|
50
|
+
when "sigstore"
|
|
51
|
+
signature = "#{path}.bundle"
|
|
52
|
+
argv = ["cosign", "sign-blob", "--yes",
|
|
53
|
+
"--bundle", signature]
|
|
54
|
+
argv += ["--key", key] if key
|
|
55
|
+
argv << path
|
|
56
|
+
["cosign", argv, signature]
|
|
57
|
+
else
|
|
58
|
+
raise ArgumentError, "unsupported signing method: #{method}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Boolean] whether the signing tool is on PATH
|
|
63
|
+
def self.signing_available?(method)
|
|
64
|
+
case method.to_s
|
|
65
|
+
when "minisign" then executable?("minisign")
|
|
66
|
+
when "sigstore" then executable?("cosign")
|
|
67
|
+
else false
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.sign_with(binary, argv)
|
|
72
|
+
unless executable?(binary)
|
|
73
|
+
raise Error, "#{binary} is not available on PATH"
|
|
74
|
+
end
|
|
75
|
+
return if system(*argv)
|
|
76
|
+
|
|
77
|
+
raise Error, "#{binary} signing failed: #{argv.join(' ')}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.executable?(binary)
|
|
81
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
|
|
82
|
+
File.executable?(File.join(directory, binary))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Hash] toolchain versions that determine output bytes
|
|
87
|
+
def self.toolchain
|
|
88
|
+
{
|
|
89
|
+
"ruby" => RUBY_VERSION,
|
|
90
|
+
"expressir" => Expressir::VERSION,
|
|
91
|
+
"suma" => Suma::VERSION,
|
|
92
|
+
}.merge(optional_versions)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.optional_versions
|
|
96
|
+
versions = {}
|
|
97
|
+
versions["metanorma"] = Metanorma::VERSION if defined?(Metanorma::VERSION)
|
|
98
|
+
versions
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @param inputs [Array<String>] files or directories
|
|
102
|
+
# @param outputs [Array<String>] files or directories
|
|
103
|
+
# @return [Hash] manifest
|
|
104
|
+
def self.record(inputs:, outputs:)
|
|
105
|
+
{
|
|
106
|
+
"toolchain" => toolchain,
|
|
107
|
+
"inputs" => digests(inputs),
|
|
108
|
+
"outputs" => digests(outputs),
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Write a provenance manifest as canonical JSON.
|
|
113
|
+
def self.write(path, inputs:, outputs:)
|
|
114
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
115
|
+
manifest = record(inputs: inputs, outputs: outputs)
|
|
116
|
+
File.write(path, "#{JSON.pretty_generate(manifest)}\n")
|
|
117
|
+
path
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Independently recompute every digest in the manifest.
|
|
121
|
+
#
|
|
122
|
+
# @return [Array<String>] problems (empty = verified)
|
|
123
|
+
def self.verify(path)
|
|
124
|
+
manifest = JSON.parse(File.read(path))
|
|
125
|
+
SECTIONS.flat_map do |section|
|
|
126
|
+
manifest.fetch(section, {}).filter_map do |relative, digest|
|
|
127
|
+
full = File.expand_path(relative)
|
|
128
|
+
if !File.exist?(full)
|
|
129
|
+
"#{section}: missing #{relative}"
|
|
130
|
+
elsif Digest::SHA256.file(full).hexdigest != digest
|
|
131
|
+
"#{section}: changed #{relative}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @param paths [Array<String>] files or directories
|
|
138
|
+
# @return [Hash{String=>String}] relative path → sha256
|
|
139
|
+
def self.digests(paths)
|
|
140
|
+
files = paths.flat_map do |path|
|
|
141
|
+
File.directory?(path) ? Dir.glob(File.join(path, "**/*")) : [path]
|
|
142
|
+
end
|
|
143
|
+
files.select { |file| File.file?(file) }
|
|
144
|
+
.map { |file| relative(file) }
|
|
145
|
+
.uniq.sort
|
|
146
|
+
.to_h { |file| [file, Digest::SHA256.file(file).hexdigest] }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def self.relative(path)
|
|
150
|
+
Pathname.new(File.expand_path(path))
|
|
151
|
+
.relative_path_from(Pathname.getwd)
|
|
152
|
+
.to_s
|
|
153
|
+
rescue ArgumentError
|
|
154
|
+
path.to_s
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private_class_method :optional_versions, :sign_with, :executable?
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "expressir"
|
|
4
|
+
|
|
5
|
+
module Suma
|
|
6
|
+
# An application-protocol publication bundle: a lead schema (the AP's
|
|
7
|
+
# ARM/MIM) plus the transitive closure of the schemas it USEs FROM or
|
|
8
|
+
# REFERENCEs, resolved against a schema manifest.
|
|
9
|
+
#
|
|
10
|
+
# The closure is computed from the parsed EXPRESS interfaces
|
|
11
|
+
# (Expressir `Schema#interfaces`, `Interface#schema.id`), so the bundle
|
|
12
|
+
# mirrors what a STEP publication actually ships: one AP volume with
|
|
13
|
+
# every schema its AP needs.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# publication = Publication.new(
|
|
17
|
+
# lead_id: "ap242_foo_mim",
|
|
18
|
+
# schema_manifest_file: "schemas.yaml",
|
|
19
|
+
# )
|
|
20
|
+
# publication.schemas.each { |s| s.save_exp }
|
|
21
|
+
class Publication
|
|
22
|
+
attr_reader :lead_id, :schema_manifest_file, :profile
|
|
23
|
+
|
|
24
|
+
# @param lead_id [String] schema id of the publication's lead schema
|
|
25
|
+
# @param schema_manifest_file [String] path to the schema manifest
|
|
26
|
+
# @param profile [SchemaProfile, Symbol, String, nil] schema profile
|
|
27
|
+
def initialize(lead_id:, schema_manifest_file:, profile: nil)
|
|
28
|
+
@lead_id = lead_id
|
|
29
|
+
@schema_manifest_file = schema_manifest_file
|
|
30
|
+
@profile = SchemaProfile.fetch(profile)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The bundle's schemas, lead first, then breadth-first over the
|
|
34
|
+
# interface closure.
|
|
35
|
+
#
|
|
36
|
+
# @param output_path [String] directory the caller will export into
|
|
37
|
+
# @return [Array<ExpressSchema>]
|
|
38
|
+
# @raise [Suma::SchemaNotFoundError] when the lead or a dependency is
|
|
39
|
+
# absent from the manifest
|
|
40
|
+
def schemas(output_path: ".")
|
|
41
|
+
entries = load_entries
|
|
42
|
+
resolve(lead_id, entries, output_path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def load_entries
|
|
48
|
+
manifest = Expressir::SchemaManifest.from_file(
|
|
49
|
+
File.expand_path(schema_manifest_file),
|
|
50
|
+
)
|
|
51
|
+
manifest.schemas.to_h do |entry|
|
|
52
|
+
[entry.id, entry]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resolve(lead, entries, output_path)
|
|
57
|
+
closure_ids(lead, entries).map do |id|
|
|
58
|
+
build_schema(id, entries, output_path)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Breadth-first ids of the interface closure reachable from +lead+.
|
|
63
|
+
#
|
|
64
|
+
# @param lead [String]
|
|
65
|
+
# @param entries [Hash{String => Expressir::SchemaManifestEntry}]
|
|
66
|
+
# @return [Array<String>] lead first, then closure order
|
|
67
|
+
def closure_ids(lead, entries)
|
|
68
|
+
order = []
|
|
69
|
+
queue = [lead]
|
|
70
|
+
until queue.empty?
|
|
71
|
+
id = queue.shift
|
|
72
|
+
next if order.include?(id)
|
|
73
|
+
|
|
74
|
+
order << id
|
|
75
|
+
queue.concat(interface_ids(id, entries))
|
|
76
|
+
end
|
|
77
|
+
order
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Interface (USE FROM / REFERENCE) schema ids declared by one schema.
|
|
81
|
+
#
|
|
82
|
+
# @param id [String]
|
|
83
|
+
# @param entries [Hash{String => Expressir::SchemaManifestEntry}]
|
|
84
|
+
# @return [Array<String>]
|
|
85
|
+
def interface_ids(id, entries)
|
|
86
|
+
schema = build_schema(id, entries, ".")
|
|
87
|
+
dependency_ids(schema)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_schema(id, entries, output_path)
|
|
91
|
+
entry = entries.fetch(id) do
|
|
92
|
+
raise SchemaNotFoundError,
|
|
93
|
+
"[suma] schema #{id.inspect} (needed by publication " \
|
|
94
|
+
"#{lead_id.inspect}) not found in #{schema_manifest_file}"
|
|
95
|
+
end
|
|
96
|
+
ExpressSchema.new(id: id, path: entry.path,
|
|
97
|
+
output_path: output_path,
|
|
98
|
+
is_standalone_file: true,
|
|
99
|
+
profile: profile)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def dependency_ids(schema)
|
|
103
|
+
parsed = schema.parsed
|
|
104
|
+
schema_obj = parsed.schemas.first
|
|
105
|
+
(schema_obj.interfaces || []).filter_map { |i| i.schema&.id }.uniq
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -42,8 +42,14 @@ module Suma
|
|
|
42
42
|
# @param ref [String] human-readable reference label
|
|
43
43
|
# @param language_code [String] language for section names
|
|
44
44
|
# @param owner [String] dataset owner organisation
|
|
45
|
+
# @param profile [SchemaProfile, Symbol, String, nil] schema profile
|
|
46
|
+
# governing classification, naming, and category ordering
|
|
47
|
+
# @param year [Integer, nil] edition year
|
|
48
|
+
# @param supersedes [String, nil] URN/ref this edition supersedes
|
|
49
|
+
# @param edition [Integer, nil] edition number (tagged as edition:N)
|
|
45
50
|
def initialize(schema_manifest_file, output_path, urn:, id:, ref:,
|
|
46
|
-
language_code: "eng", owner: DEFAULT_OWNER
|
|
51
|
+
language_code: "eng", owner: DEFAULT_OWNER, profile: nil,
|
|
52
|
+
year: nil, supersedes: nil, edition: nil)
|
|
47
53
|
@schema_manifest_file = File.expand_path(schema_manifest_file)
|
|
48
54
|
@output_path = output_path
|
|
49
55
|
@urn = Suma::Urn.new(urn)
|
|
@@ -51,6 +57,10 @@ module Suma
|
|
|
51
57
|
@ref = ref
|
|
52
58
|
@language_code = language_code
|
|
53
59
|
@owner = owner
|
|
60
|
+
@profile = SchemaProfile.fetch(profile)
|
|
61
|
+
@year = year
|
|
62
|
+
@supersedes = supersedes
|
|
63
|
+
@edition = edition
|
|
54
64
|
end
|
|
55
65
|
|
|
56
66
|
# Generate and write register.yaml.
|
|
@@ -118,33 +128,91 @@ module Suma
|
|
|
118
128
|
owner: @owner,
|
|
119
129
|
languages: [@language_code],
|
|
120
130
|
ordering: DEFAULT_ORDERING,
|
|
131
|
+
year: @year,
|
|
132
|
+
supersedes: @supersedes,
|
|
133
|
+
tags: edition_tags,
|
|
121
134
|
sections: build_sections(schemas),
|
|
122
135
|
)
|
|
123
136
|
end
|
|
124
137
|
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
|
|
138
|
+
# Edition metadata for the register: the dataset tag groups sibling
|
|
139
|
+
# editions, and the edition number groups nothing on its own.
|
|
140
|
+
def edition_tags
|
|
141
|
+
tags = ["dataset:#{@id}"]
|
|
142
|
+
tags << "edition:#{@edition}" if @edition
|
|
143
|
+
tags
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Build a list of top-level sections, one per category, in the
|
|
147
|
+
# schema profile's category order. Categories with no schemas are
|
|
148
|
+
# omitted.
|
|
128
149
|
#
|
|
129
150
|
# @param schemas [Array<Expressir::SchemaManifestEntry>]
|
|
130
151
|
# @return [Array<Glossarist::Section>]
|
|
131
152
|
def build_sections(schemas)
|
|
132
153
|
groups = schemas.group_by do |s|
|
|
133
|
-
SchemaCategory.for_schema(id: s.id, path: s.path)
|
|
154
|
+
SchemaCategory.for_schema(id: s.id, path: s.path, profile: @profile)
|
|
134
155
|
end
|
|
135
156
|
|
|
136
|
-
SchemaCategory
|
|
157
|
+
SchemaCategory.for_profile(@profile).filter_map do |category|
|
|
137
158
|
children = groups[category] || []
|
|
138
159
|
next if children.empty?
|
|
139
160
|
|
|
140
161
|
Glossarist::Section.new(
|
|
141
162
|
id: category.id,
|
|
142
163
|
names: { @language_code => category.label },
|
|
143
|
-
children: children
|
|
164
|
+
children: section_children(category, children),
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Application protocols are grouped one level deeper: the schemas of
|
|
170
|
+
# one AP publication (its `_arm`/`_mim` pair) become the children of
|
|
171
|
+
# a per-AP parent section, so the register mirrors the publication
|
|
172
|
+
# bundling.
|
|
173
|
+
#
|
|
174
|
+
# @param category [SchemaCategory]
|
|
175
|
+
# @param schemas [Array<Expressir::SchemaManifestEntry>]
|
|
176
|
+
# @return [Array<Glossarist::Section>]
|
|
177
|
+
def section_children(category, schemas)
|
|
178
|
+
if category == SchemaCategory::APPLICATION_PROTOCOLS
|
|
179
|
+
build_ap_publication_sections(schemas)
|
|
180
|
+
else
|
|
181
|
+
schemas.map { |s| build_section(s) }
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Build one parent section per AP publication, keyed by the AP's
|
|
186
|
+
# base identity (the schema id with its ARM/MIM suffix stripped).
|
|
187
|
+
#
|
|
188
|
+
# @param schemas [Array<Expressir::SchemaManifestEntry>]
|
|
189
|
+
# @return [Array<Glossarist::Section>]
|
|
190
|
+
def build_ap_publication_sections(schemas)
|
|
191
|
+
schemas.group_by { |s| ap_base_id(s.id) }.sort_by { |base, _| base }
|
|
192
|
+
.map do |base, members|
|
|
193
|
+
Glossarist::Section.new(
|
|
194
|
+
id: base,
|
|
195
|
+
names: {
|
|
196
|
+
@language_code => "Application Protocol: " \
|
|
197
|
+
"#{SchemaNaming.display_name(base, profile: @profile)}",
|
|
198
|
+
},
|
|
199
|
+
children: members.sort_by { |s| s.id.downcase }
|
|
200
|
+
.map { |s| build_section(s) },
|
|
144
201
|
)
|
|
145
202
|
end
|
|
146
203
|
end
|
|
147
204
|
|
|
205
|
+
# Strip the ARM/MIM suffix to get the AP publication's base identity.
|
|
206
|
+
#
|
|
207
|
+
# @param id [String]
|
|
208
|
+
# @return [String]
|
|
209
|
+
def ap_base_id(id)
|
|
210
|
+
%w[_arm _mim].each do |suffix|
|
|
211
|
+
return id.delete_suffix(suffix) if id.downcase.end_with?(suffix)
|
|
212
|
+
end
|
|
213
|
+
id
|
|
214
|
+
end
|
|
215
|
+
|
|
148
216
|
# Build a single leaf section from a schema descriptor.
|
|
149
217
|
#
|
|
150
218
|
# @param schema [Expressir::SchemaManifestEntry]
|
|
@@ -154,7 +222,7 @@ module Suma
|
|
|
154
222
|
id: schema.id,
|
|
155
223
|
names: {
|
|
156
224
|
@language_code => SchemaNaming.prefixed_name(
|
|
157
|
-
schema.id, path: schema.path
|
|
225
|
+
schema.id, path: schema.path, profile: @profile
|
|
158
226
|
),
|
|
159
227
|
},
|
|
160
228
|
)
|
data/lib/suma/schema_cache.rb
CHANGED
|
@@ -4,25 +4,45 @@ require "digest"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
|
|
6
6
|
module Suma
|
|
7
|
-
# Content-addressed, on-disk cache of
|
|
8
|
-
#
|
|
9
|
-
# text produced from a source schema.
|
|
7
|
+
# Content-addressed, on-disk cache of build artifacts derived from EXPRESS
|
|
8
|
+
# source text.
|
|
10
9
|
#
|
|
11
|
-
#
|
|
12
|
-
# annotation mode. Those three fully determine the output (Expressir's
|
|
13
|
-
# serialiser is deterministic), so a hit is guaranteed byte-identical to a
|
|
14
|
-
# fresh generation -- and the store can be shared across builds and git
|
|
15
|
-
# worktrees: identical source content hits regardless of which build wrote it.
|
|
10
|
+
# Two artifact kinds share one store:
|
|
16
11
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
12
|
+
# - generated `.exp` output (plain or annotated), keyed by source bytes,
|
|
13
|
+
# Expressir version, and annotation mode;
|
|
14
|
+
# - parsed Expressir repositories (S6.1), keyed by source bytes, Expressir
|
|
15
|
+
# version, Suma version, and Ruby's Marshal format version.
|
|
16
|
+
#
|
|
17
|
+
# Those inputs fully determine the artifact, so a hit is equivalent to a
|
|
18
|
+
# fresh build and the store can be shared across builds and worktrees.
|
|
19
|
+
# Parsed entries are Marshal payloads: only point the cache at a directory
|
|
20
|
+
# this process controls.
|
|
20
21
|
class SchemaCache
|
|
22
|
+
# Bump when the persisted parsed-repository contract changes.
|
|
23
|
+
PARSED_FORMAT_VERSION = 1
|
|
24
|
+
|
|
25
|
+
attr_reader :hits, :misses
|
|
26
|
+
|
|
27
|
+
# Build the configured cache, or a null cache when caching is disabled.
|
|
28
|
+
#
|
|
29
|
+
# @param options [Hash] may carry :cache_dir
|
|
30
|
+
# @return [SchemaCache, NullCache]
|
|
31
|
+
def self.build(options = {})
|
|
32
|
+
directory = options[:cache_dir] || ENV.fetch("SUMA_SCHEMA_CACHE_DIR", nil)
|
|
33
|
+
return NullCache.new if directory.nil? || directory.empty?
|
|
34
|
+
|
|
35
|
+
new(directory)
|
|
36
|
+
end
|
|
37
|
+
|
|
21
38
|
def initialize(directory)
|
|
22
39
|
@directory = directory.to_s
|
|
40
|
+
@hits = 0
|
|
41
|
+
@misses = 0
|
|
42
|
+
@counter_lock = Mutex.new
|
|
23
43
|
end
|
|
24
44
|
|
|
25
|
-
# The cached output for +source+, or +nil+ on a miss.
|
|
45
|
+
# The cached EXPRESS output for +source+, or +nil+ on a miss.
|
|
26
46
|
def fetch(source, annotations:)
|
|
27
47
|
path = entry_path(source, annotations: annotations)
|
|
28
48
|
return unless File.exist?(path)
|
|
@@ -38,26 +58,65 @@ module Suma
|
|
|
38
58
|
path
|
|
39
59
|
end
|
|
40
60
|
|
|
61
|
+
# The cached parsed repository for +source+, or +nil+ on a miss.
|
|
62
|
+
# Hit and miss counts support the "second build parses nothing" gate.
|
|
63
|
+
def fetch_parsed(source)
|
|
64
|
+
path = parsed_entry_path(source)
|
|
65
|
+
unless File.exist?(path)
|
|
66
|
+
@counter_lock.synchronize { @misses += 1 }
|
|
67
|
+
return nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@counter_lock.synchronize { @hits += 1 }
|
|
71
|
+
# Local build cache only; never point SchemaCache at an untrusted path.
|
|
72
|
+
Marshal.load(File.binread(path)) # rubocop:disable Security/MarshalLoad
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Cache a parsed Expressir repository for +source+.
|
|
76
|
+
def store_parsed(source, repository)
|
|
77
|
+
path = parsed_entry_path(source)
|
|
78
|
+
FileUtils.mkdir_p(@directory)
|
|
79
|
+
write_atomically(path, Marshal.dump(repository))
|
|
80
|
+
path
|
|
81
|
+
end
|
|
82
|
+
|
|
41
83
|
private
|
|
42
84
|
|
|
43
85
|
def entry_path(source, annotations:)
|
|
44
86
|
File.join(@directory, key(source, annotations: annotations))
|
|
45
87
|
end
|
|
46
88
|
|
|
89
|
+
def parsed_entry_path(source)
|
|
90
|
+
File.join(@directory, parsed_key(source))
|
|
91
|
+
end
|
|
92
|
+
|
|
47
93
|
def key(source, annotations:)
|
|
48
94
|
mode = annotations ? "annotated" : "plain"
|
|
49
95
|
"#{Digest::SHA256.hexdigest(source)}-#{expressir_version}-#{mode}.exp"
|
|
50
96
|
end
|
|
51
97
|
|
|
98
|
+
def parsed_key(source)
|
|
99
|
+
"#{Digest::SHA256.hexdigest(source)}-#{expressir_version}-" \
|
|
100
|
+
"#{model_version}-#{marshal_version}.parsed"
|
|
101
|
+
end
|
|
102
|
+
|
|
52
103
|
def expressir_version
|
|
53
104
|
@expressir_version ||= Expressir::VERSION
|
|
54
105
|
end
|
|
55
106
|
|
|
107
|
+
def model_version
|
|
108
|
+
@model_version ||= "#{Suma::VERSION}-#{PARSED_FORMAT_VERSION}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def marshal_version
|
|
112
|
+
@marshal_version ||= "#{Marshal::MAJOR_VERSION}.#{Marshal::MINOR_VERSION}"
|
|
113
|
+
end
|
|
114
|
+
|
|
56
115
|
# Write through a sibling temp file and rename, so a concurrent reader never
|
|
57
116
|
# observes a half-written entry.
|
|
58
117
|
def write_atomically(path, content)
|
|
59
118
|
temp = "#{path}.#{Process.pid}.tmp"
|
|
60
|
-
File.
|
|
119
|
+
File.binwrite(temp, content)
|
|
61
120
|
File.rename(temp, path)
|
|
62
121
|
ensure
|
|
63
122
|
File.delete(temp) if temp && File.exist?(temp)
|
data/lib/suma/schema_category.rb
CHANGED
|
@@ -34,6 +34,13 @@ module Suma
|
|
|
34
34
|
types: [ExpressSchema::Type::RESOURCE].freeze,
|
|
35
35
|
directory: "resources",
|
|
36
36
|
)
|
|
37
|
+
APPLICATION_PROTOCOLS = new(
|
|
38
|
+
id: "application_protocols",
|
|
39
|
+
label: "Application Protocols",
|
|
40
|
+
prefix: "Application Protocol",
|
|
41
|
+
types: [ExpressSchema::Type::APPLICATION_PROTOCOL].freeze,
|
|
42
|
+
directory: "applications",
|
|
43
|
+
)
|
|
37
44
|
MODULES = new(
|
|
38
45
|
id: "modules",
|
|
39
46
|
label: "Application Modules",
|
|
@@ -65,18 +72,40 @@ module Suma
|
|
|
65
72
|
|
|
66
73
|
ALL = [
|
|
67
74
|
RESOURCES,
|
|
75
|
+
APPLICATION_PROTOCOLS,
|
|
68
76
|
MODULES,
|
|
69
77
|
BUSINESS_OBJECT_MODELS,
|
|
70
78
|
CORE_MODEL,
|
|
71
79
|
OTHER,
|
|
72
80
|
].freeze
|
|
73
81
|
|
|
82
|
+
BY_ID = ALL.to_h do |category|
|
|
83
|
+
[category.id, category]
|
|
84
|
+
end.freeze
|
|
85
|
+
|
|
74
86
|
def self.for_type(type)
|
|
75
87
|
ALL.find { |category| category.member?(type) } || OTHER
|
|
76
88
|
end
|
|
77
89
|
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
# Look up a category by its slug; unknown ids fall back to OTHER so
|
|
91
|
+
# that schema profiles naming an unknown category still resolve.
|
|
92
|
+
#
|
|
93
|
+
# @param id [String, Symbol]
|
|
94
|
+
# @return [SchemaCategory]
|
|
95
|
+
def self.find(id)
|
|
96
|
+
BY_ID.fetch(id.to_s, OTHER)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Ordered category list for a schema profile.
|
|
100
|
+
#
|
|
101
|
+
# @param profile [SchemaProfile, Symbol, String, nil]
|
|
102
|
+
# @return [Array<SchemaCategory>]
|
|
103
|
+
def self.for_profile(profile)
|
|
104
|
+
SchemaProfile.fetch(profile).category_ids.map { |id| find(id) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.for_schema(id:, path:, profile: nil)
|
|
108
|
+
type = ExpressSchema::Type.classify(id: id, path: path, profile: profile)
|
|
80
109
|
for_type(type)
|
|
81
110
|
end
|
|
82
111
|
end
|
|
@@ -6,6 +6,7 @@ module Suma
|
|
|
6
6
|
class SchemaCollection
|
|
7
7
|
attr_accessor :config, :schemas, :docs, :output_path_docs,
|
|
8
8
|
:output_path_schemas, :manifest
|
|
9
|
+
attr_reader :cache
|
|
9
10
|
|
|
10
11
|
def initialize(config: nil, config_yaml: nil, output_path_docs: nil, # rubocop:disable Metrics/ParameterLists
|
|
11
12
|
output_path_schemas: nil, manifest: nil,
|
|
@@ -21,6 +22,7 @@ module Suma
|
|
|
21
22
|
@config ||= config_yaml && Expressir::SchemaManifest.from_file(config_yaml)
|
|
22
23
|
@manifest = manifest
|
|
23
24
|
@options = options
|
|
25
|
+
@cache = SchemaCache.build(@options)
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
def doc_from_schema_name(schema_name)
|
|
@@ -40,21 +42,71 @@ module Suma
|
|
|
40
42
|
|
|
41
43
|
def compile
|
|
42
44
|
finalize
|
|
45
|
+
targets = rebuild_targets
|
|
43
46
|
|
|
44
47
|
exporter = SchemaExporter.new(
|
|
45
|
-
schemas: schemas.values,
|
|
48
|
+
schemas: targets ? schemas_for(targets) : schemas.values,
|
|
46
49
|
output_path: @output_path_schemas,
|
|
47
50
|
options: @options,
|
|
48
51
|
)
|
|
49
52
|
exporter.export
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
docs_for(targets).each_value(&:compile)
|
|
55
|
+
persist_build_state
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Schema ids an incremental build must re-render: changed schemas plus
|
|
59
|
+
# their transitive dependents. Returns nil when a full build is required
|
|
60
|
+
# (incremental disabled, outputs missing, or no prior state).
|
|
61
|
+
#
|
|
62
|
+
# Requires {#finalize} to have populated the collection.
|
|
63
|
+
#
|
|
64
|
+
# @return [Array<String>, nil]
|
|
65
|
+
def rebuild_targets
|
|
66
|
+
return nil unless @options[:incremental]
|
|
67
|
+
return nil unless outputs_present?
|
|
68
|
+
|
|
69
|
+
plan.rebuild
|
|
54
70
|
end
|
|
55
71
|
|
|
56
72
|
private
|
|
57
73
|
|
|
74
|
+
def plan
|
|
75
|
+
@plan ||= BuildPlan.new(
|
|
76
|
+
entries: @config.schemas,
|
|
77
|
+
graph: CorpusGraph.new(schemas.values.map(&:parsed)),
|
|
78
|
+
previous_state: BuildPlan.read_state(state_file),
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def state_file
|
|
83
|
+
@options[:state_file] ||
|
|
84
|
+
File.join(@output_path_schemas, ".suma-build-state.json")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def outputs_present?
|
|
88
|
+
File.directory?(@output_path_docs) &&
|
|
89
|
+
File.directory?(@output_path_schemas)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def persist_build_state
|
|
93
|
+
return unless @options[:incremental]
|
|
94
|
+
|
|
95
|
+
BuildPlan.write_state(state_file, plan.current_state)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def schemas_for(targets)
|
|
99
|
+
wanted = targets.map(&:downcase)
|
|
100
|
+
schemas.values.select { |schema| wanted.include?(schema.id.downcase) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def docs_for(targets)
|
|
104
|
+
return docs unless targets
|
|
105
|
+
|
|
106
|
+
wanted = targets.map(&:downcase)
|
|
107
|
+
docs.select { |id, _compiler| wanted.include?(id.downcase) }
|
|
108
|
+
end
|
|
109
|
+
|
|
58
110
|
def process_schemas(schemas, template_class)
|
|
59
111
|
schemas.each { |s| process_schema(s, template_class) }
|
|
60
112
|
end
|
|
@@ -65,7 +117,8 @@ module Suma
|
|
|
65
117
|
)
|
|
66
118
|
express = ExpressSchema.new(
|
|
67
119
|
id: config_schema.id, path: config_schema.path.to_s,
|
|
68
|
-
output_path: @output_path_schemas.join(category.directory).to_s
|
|
120
|
+
output_path: @output_path_schemas.join(category.directory).to_s,
|
|
121
|
+
cache: @cache
|
|
69
122
|
)
|
|
70
123
|
|
|
71
124
|
compiler = SchemaCompiler.new(
|