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,138 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Nightly scale benchmark: generates a synthetic EXPRESS corpus, then times
|
|
5
|
+
# the corpus-scale operations (parse/graph, closure, OWL projection) and
|
|
6
|
+
# asserts wall-time and peak-RSS budgets. Budgets are environment-tunable so
|
|
7
|
+
# CI machines can be calibrated without code changes.
|
|
8
|
+
|
|
9
|
+
require "suma"
|
|
10
|
+
require "tmpdir"
|
|
11
|
+
require "fileutils"
|
|
12
|
+
require "json"
|
|
13
|
+
|
|
14
|
+
module CorpusBenchmark
|
|
15
|
+
SCHEMA_COUNT = Integer(ENV.fetch("SUMA_BENCH_SCHEMAS", "1000"))
|
|
16
|
+
MAX_SECONDS = Float(ENV.fetch("SUMA_BENCH_MAX_SECONDS", "180"))
|
|
17
|
+
MAX_RSS_MB = Integer(ENV.fetch("SUMA_BENCH_MAX_RSS_MB", "2048"))
|
|
18
|
+
URN = "urn:example:benchmark:corpus:ed-1:en"
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Half resources, half modules; each module USEs one resource so the graph
|
|
23
|
+
# has interface edges (dependents are exercised by the closure walk).
|
|
24
|
+
def generate(directory)
|
|
25
|
+
resources = SCHEMA_COUNT / 2
|
|
26
|
+
FileUtils.mkdir_p(File.join(directory, "resources"))
|
|
27
|
+
FileUtils.mkdir_p(File.join(directory, "modules"))
|
|
28
|
+
manifest = { "schemas" => {} }
|
|
29
|
+
|
|
30
|
+
resources.times do |index|
|
|
31
|
+
id = format("res_%04d", index)
|
|
32
|
+
write_schema(directory, "resources", id, resource_source(id, index))
|
|
33
|
+
manifest["schemas"][id] = { "path" => "resources/#{id}.exp" }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
(SCHEMA_COUNT - resources).times do |index|
|
|
37
|
+
id = format("mod_%04d", index)
|
|
38
|
+
resource = format("res_%04d", index)
|
|
39
|
+
write_schema(directory, "modules", id,
|
|
40
|
+
module_source(id, index, resource))
|
|
41
|
+
manifest["schemas"][id] = { "path" => "modules/#{id}.exp" }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
path = File.join(directory, "schemas.yaml")
|
|
45
|
+
File.write(path, YAML.dump(manifest))
|
|
46
|
+
path
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def resource_source(id, index)
|
|
50
|
+
<<~EXPRESS
|
|
51
|
+
SCHEMA #{id};
|
|
52
|
+
ENTITY thing_#{format('%04d', index)};
|
|
53
|
+
name : STRING;
|
|
54
|
+
weight : REAL;
|
|
55
|
+
END_ENTITY;
|
|
56
|
+
END_SCHEMA;
|
|
57
|
+
EXPRESS
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def module_source(id, index, resource)
|
|
61
|
+
<<~EXPRESS
|
|
62
|
+
SCHEMA #{id};
|
|
63
|
+
USE FROM #{resource};
|
|
64
|
+
ENTITY wrapper_#{format('%04d', index)};
|
|
65
|
+
item : thing_#{format('%04d', index)};
|
|
66
|
+
END_ENTITY;
|
|
67
|
+
END_SCHEMA;
|
|
68
|
+
EXPRESS
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def write_schema(directory, kind, id, source)
|
|
72
|
+
File.write(File.join(directory, kind, "#{id}.exp"), source)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def measure
|
|
76
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
77
|
+
value = yield
|
|
78
|
+
[value, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def peak_rss_mb
|
|
82
|
+
if File.exist?("/proc/self/status")
|
|
83
|
+
File.read("/proc/self/status")[/VmHWM:\s+(\d+)/, 1].to_i / 1024.0
|
|
84
|
+
else
|
|
85
|
+
`ps -o rss= -p #{Process.pid}`.to_i / 1024.0
|
|
86
|
+
end.round
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run
|
|
90
|
+
Dir.mktmpdir("suma-benchmark") do |directory|
|
|
91
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
92
|
+
manifest = generate(directory)
|
|
93
|
+
|
|
94
|
+
graph, graph_seconds = measure { Suma::CorpusGraph.from_manifest(manifest) }
|
|
95
|
+
violations, closure_seconds = measure do
|
|
96
|
+
Suma::ClosureValidator.new(graph).validate_manifest(manifest)
|
|
97
|
+
end
|
|
98
|
+
projection, owl_seconds = measure do
|
|
99
|
+
Suma::OwlProjection.new(manifest, urn: URN)
|
|
100
|
+
end
|
|
101
|
+
turtle, turtle_seconds = measure { projection.to_turtle }
|
|
102
|
+
total = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
103
|
+
rss = peak_rss_mb
|
|
104
|
+
|
|
105
|
+
report = {
|
|
106
|
+
schemas: SCHEMA_COUNT,
|
|
107
|
+
graph_seconds: graph_seconds.round(2),
|
|
108
|
+
closure_seconds: closure_seconds.round(2),
|
|
109
|
+
owl_seconds: owl_seconds.round(2),
|
|
110
|
+
turtle_seconds: turtle_seconds.round(2),
|
|
111
|
+
total_seconds: total.round(2),
|
|
112
|
+
peak_rss_mb: rss,
|
|
113
|
+
closure_violations: violations.length,
|
|
114
|
+
turtle_bytes: turtle.bytesize,
|
|
115
|
+
budgets: { max_seconds: MAX_SECONDS, max_rss_mb: MAX_RSS_MB },
|
|
116
|
+
}
|
|
117
|
+
puts JSON.pretty_generate(report)
|
|
118
|
+
|
|
119
|
+
failures = []
|
|
120
|
+
unless violations.empty?
|
|
121
|
+
failures << "#{violations.length} closure violations"
|
|
122
|
+
end
|
|
123
|
+
if total > MAX_SECONDS
|
|
124
|
+
failures << "wall time #{total.round(2)}s > #{MAX_SECONDS}s"
|
|
125
|
+
end
|
|
126
|
+
if rss > MAX_RSS_MB
|
|
127
|
+
failures << "peak RSS #{rss}MB > #{MAX_RSS_MB}MB"
|
|
128
|
+
end
|
|
129
|
+
unless failures.empty?
|
|
130
|
+
warn "benchmark FAILED: #{failures.join('; ')}"
|
|
131
|
+
exit 1
|
|
132
|
+
end
|
|
133
|
+
puts "benchmark passed"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
CorpusBenchmark.run if $PROGRAM_NAME == __FILE__
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Emit provenance for a release build: the built gem's sha256 plus the
|
|
5
|
+
# dependency lockfile and toolchain versions. Run after `gem build`.
|
|
6
|
+
|
|
7
|
+
require "suma"
|
|
8
|
+
|
|
9
|
+
ROOT = File.expand_path("..", __dir__)
|
|
10
|
+
gem_file = Dir[File.join(ROOT, "*.gem")].max
|
|
11
|
+
abort "release_provenance: no built gem found in #{ROOT}" unless gem_file
|
|
12
|
+
|
|
13
|
+
manifest_path = File.join(Dir.pwd, "provenance.json")
|
|
14
|
+
Suma::Provenance.write(
|
|
15
|
+
manifest_path,
|
|
16
|
+
inputs: [
|
|
17
|
+
File.join(ROOT, "Gemfile.lock"),
|
|
18
|
+
File.join(ROOT, "suma.gemspec"),
|
|
19
|
+
],
|
|
20
|
+
outputs: [gem_file],
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
problems = Suma::Provenance.verify(manifest_path)
|
|
24
|
+
unless problems.empty?
|
|
25
|
+
warn "release_provenance: verification failed: #{problems.join('; ')}"
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
puts "Wrote #{manifest_path} (#{File.basename(gem_file)})"
|
|
30
|
+
|
|
31
|
+
sign_method = ENV.fetch("SUMA_PROVENANCE_SIGN", nil)
|
|
32
|
+
sign_method = nil if sign_method.to_s.empty?
|
|
33
|
+
if sign_method
|
|
34
|
+
sign_key = ENV.fetch("SUMA_PROVENANCE_SIGN_KEY", nil)
|
|
35
|
+
sign_key = nil if sign_key.to_s.empty?
|
|
36
|
+
signature = Suma::Provenance.sign(
|
|
37
|
+
manifest_path, method: sign_method, key: sign_key
|
|
38
|
+
)
|
|
39
|
+
puts "Signed provenance (#{sign_method}): #{signature}"
|
|
40
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Suma
|
|
8
|
+
# Decides which schemas an incremental build must re-render.
|
|
9
|
+
#
|
|
10
|
+
# The state is a map of schema id → sha256 of its source. A schema is
|
|
11
|
+
# changed when its digest differs from the previous build; rendering is
|
|
12
|
+
# required for every changed schema plus all transitive dependents, because
|
|
13
|
+
# a dependent's cross-references and rendered anchors may shift. The
|
|
14
|
+
# dependents come from the corpus graph (S1), the same USE/REFERENCE edges
|
|
15
|
+
# that drive change impact (S3).
|
|
16
|
+
class BuildPlan
|
|
17
|
+
# @param entries [Array<#id,#path>] schema manifest entries
|
|
18
|
+
# @param graph [CorpusGraph]
|
|
19
|
+
# @param previous_state [Hash{String=>String}] digest per schema id
|
|
20
|
+
def initialize(entries:, graph:, previous_state: {})
|
|
21
|
+
@entries = entries
|
|
22
|
+
@graph = graph
|
|
23
|
+
@previous_state = previous_state || {}
|
|
24
|
+
@ids_by_key = entries.to_h { |entry| [entry.id.downcase, entry.id] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Array<String>] declared ids whose source differs since the
|
|
28
|
+
# previous build (all ids on a first build)
|
|
29
|
+
def changed
|
|
30
|
+
current_state.reject do |id, digest|
|
|
31
|
+
@previous_state[id] == digest
|
|
32
|
+
end.keys
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Array<String>] schemas to re-render: every changed schema plus
|
|
36
|
+
# its transitive dependents; empty when nothing changed
|
|
37
|
+
def rebuild
|
|
38
|
+
changed_ids = changed
|
|
39
|
+
return [] if changed_ids.empty?
|
|
40
|
+
|
|
41
|
+
expanded = changed_ids.flat_map do |id|
|
|
42
|
+
[id] + @graph.dependents_of(id).map { |key| @ids_by_key[key] || key }
|
|
43
|
+
end
|
|
44
|
+
expanded.uniq
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Hash{String=>String}] digest per schema id
|
|
48
|
+
def current_state
|
|
49
|
+
@current_state ||= @entries.to_h do |entry|
|
|
50
|
+
[entry.id, Digest::SHA256.file(entry.path.to_s).hexdigest]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Persist the current state for the next incremental build.
|
|
55
|
+
def self.write_state(path, state)
|
|
56
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
57
|
+
temp = "#{path}.#{Process.pid}.tmp"
|
|
58
|
+
File.write(temp, JSON.pretty_generate(state))
|
|
59
|
+
File.rename(temp, path)
|
|
60
|
+
ensure
|
|
61
|
+
File.delete(temp) if temp && File.exist?(temp)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Hash{String=>String}] the previous state, empty when absent
|
|
65
|
+
def self.read_state(path)
|
|
66
|
+
return {} unless path && File.exist?(path)
|
|
67
|
+
|
|
68
|
+
JSON.parse(File.read(path))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Suma
|
|
4
|
+
# The typed change taxonomy (S3): every schema change is classified as
|
|
5
|
+
# additive, compatible, breaking, or editorial.
|
|
6
|
+
#
|
|
7
|
+
# The mapping is data — a frozen Hash keyed by the eengine action — so a
|
|
8
|
+
# finer-grained rule is a one-line addition. Modifications are classified
|
|
9
|
+
# as breaking conservatively: eengine reports structural deltas (type
|
|
10
|
+
# changes, attribute additions on entities, interface changes) without
|
|
11
|
+
# enough detail to prove backwards compatibility, so additive/editorial
|
|
12
|
+
# claims are only made where the action itself guarantees them.
|
|
13
|
+
class ChangeClassification
|
|
14
|
+
ADDITIVE = "additive"
|
|
15
|
+
COMPATIBLE = "compatible"
|
|
16
|
+
BREAKING = "breaking"
|
|
17
|
+
EDITORIAL = "editorial"
|
|
18
|
+
|
|
19
|
+
BY_ACTION = {
|
|
20
|
+
"added" => ADDITIVE,
|
|
21
|
+
"removed" => BREAKING,
|
|
22
|
+
"modified" => BREAKING,
|
|
23
|
+
"mapping" => EDITORIAL,
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
ALL = [ADDITIVE, COMPATIBLE, BREAKING, EDITORIAL].freeze
|
|
27
|
+
|
|
28
|
+
# @param action [String, Symbol] added / removed / modified / mapping
|
|
29
|
+
# @return [String] one of ALL
|
|
30
|
+
def self.classify(action:)
|
|
31
|
+
BY_ACTION.fetch(action.to_s) do
|
|
32
|
+
raise Error, "[suma] unknown change action #{action.inspect} " \
|
|
33
|
+
"(known: #{BY_ACTION.keys.sort.join(', ')})"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "expressir"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Suma
|
|
7
|
+
# Change-impact analysis (S3): given a changed schema, which schemas and
|
|
8
|
+
# AP publications are affected?
|
|
9
|
+
#
|
|
10
|
+
# Walks the reverse interface closure of the CorpusGraph, so the answer
|
|
11
|
+
# is transitive: every schema that USEs FROM the changed schema directly
|
|
12
|
+
# or through other schemas is reported, and every AP publication whose
|
|
13
|
+
# dependency closure contains the changed schema is flagged.
|
|
14
|
+
class ChangeImpact
|
|
15
|
+
attr_reader :graph, :schema_manifest_file
|
|
16
|
+
|
|
17
|
+
# @param graph [CorpusGraph]
|
|
18
|
+
# @param schema_manifest_file [String] corpus manifest, for AP detection
|
|
19
|
+
def initialize(graph, schema_manifest_file)
|
|
20
|
+
@graph = graph
|
|
21
|
+
@schema_manifest_file = schema_manifest_file
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param schema_id [String] the changed schema
|
|
25
|
+
# @return [Array<String>] transitively affected schema ids
|
|
26
|
+
def affected_schemas(schema_id)
|
|
27
|
+
graph.dependents_of(schema_id)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param schema_id [String] the changed schema
|
|
31
|
+
# @return [Array<String>] AP publication leads whose closure includes it
|
|
32
|
+
def affected_publications(schema_id)
|
|
33
|
+
target = schema_id.to_s.downcase
|
|
34
|
+
application_protocol_ids.select do |lead|
|
|
35
|
+
graph.dependency_closure(lead).include?(target)
|
|
36
|
+
end.sort
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param schema_id [String]
|
|
40
|
+
# @return [Hash]
|
|
41
|
+
def report(schema_id)
|
|
42
|
+
{
|
|
43
|
+
"schema" => schema_id,
|
|
44
|
+
"affected_schemas" => affected_schemas(schema_id),
|
|
45
|
+
"affected_publications" => affected_publications(schema_id),
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def application_protocol_ids
|
|
52
|
+
manifest = Expressir::SchemaManifest.from_file(
|
|
53
|
+
File.expand_path(schema_manifest_file),
|
|
54
|
+
)
|
|
55
|
+
manifest.schemas.select do |entry|
|
|
56
|
+
SchemaCategory.for_schema(id: entry.id, path: entry.path) ==
|
|
57
|
+
SchemaCategory::APPLICATION_PROTOCOLS
|
|
58
|
+
end.map { |entry| entry.id.downcase }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
require "expressir/changes"
|
|
5
|
+
|
|
6
|
+
module Suma
|
|
7
|
+
# One typed change entry: which element changed, how, and whether the
|
|
8
|
+
# change is additive/compatible/breaking/editorial.
|
|
9
|
+
class ChangeEntry < Lutaml::Model::Serializable
|
|
10
|
+
attribute :element, :string
|
|
11
|
+
attribute :element_type, :string
|
|
12
|
+
attribute :action, :string
|
|
13
|
+
attribute :classification, :string
|
|
14
|
+
attribute :description, :string, collection: true
|
|
15
|
+
|
|
16
|
+
yaml do
|
|
17
|
+
map "element", to: :element
|
|
18
|
+
map "element_type", to: :element_type
|
|
19
|
+
map "action", to: :action
|
|
20
|
+
map "classification", to: :classification
|
|
21
|
+
map "description", to: :description
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The typed change manifest for one schema edition pair (S3): the
|
|
26
|
+
# machine-readable companion to eengine's .changes.yaml.
|
|
27
|
+
#
|
|
28
|
+
# Built from the Expressir::Changes::SchemaChange model (the same model
|
|
29
|
+
# the .changes.yaml embodies), adding the classification dimension and a
|
|
30
|
+
# rendered changelog.
|
|
31
|
+
class ChangeManifest < Lutaml::Model::Serializable
|
|
32
|
+
attribute :schema, :string
|
|
33
|
+
attribute :version, :integer
|
|
34
|
+
attribute :entries, ChangeEntry, collection: true
|
|
35
|
+
|
|
36
|
+
yaml do
|
|
37
|
+
map "schema", to: :schema
|
|
38
|
+
map "version", to: :version
|
|
39
|
+
map "entries", to: :entries
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Build a manifest from a SchemaChange model.
|
|
44
|
+
#
|
|
45
|
+
# @param change [Expressir::Changes::SchemaChange]
|
|
46
|
+
# @param version [Integer, String, nil] version to select
|
|
47
|
+
# (default: the latest)
|
|
48
|
+
# @return [ChangeManifest]
|
|
49
|
+
def from_schema_change(change, version: nil)
|
|
50
|
+
selected = select_version(change.versions, version)
|
|
51
|
+
return new(schema: change.schema) if selected.nil?
|
|
52
|
+
|
|
53
|
+
new(
|
|
54
|
+
schema: change.schema,
|
|
55
|
+
version: selected.version,
|
|
56
|
+
entries: entries_for(selected),
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Build a manifest from a .changes.yaml file.
|
|
61
|
+
#
|
|
62
|
+
# @param path [String]
|
|
63
|
+
# @param version [Integer, String, nil]
|
|
64
|
+
# @return [ChangeManifest]
|
|
65
|
+
def from_file(path, version: nil)
|
|
66
|
+
from_schema_change(
|
|
67
|
+
Expressir::Changes::SchemaChange.from_file(path),
|
|
68
|
+
version: version,
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def select_version(versions, version)
|
|
75
|
+
return versions.last if version.nil?
|
|
76
|
+
|
|
77
|
+
versions.find { |candidate| candidate.version == version.to_i }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def entries_for(version_change)
|
|
81
|
+
entries = []
|
|
82
|
+
entries.concat(item_entries(version_change.additions, "added"))
|
|
83
|
+
entries.concat(item_entries(version_change.modifications, "modified"))
|
|
84
|
+
entries.concat(item_entries(version_change.deletions, "removed"))
|
|
85
|
+
entries.concat(mapping_entries(version_change.mappings))
|
|
86
|
+
entries.concat(mapping_entries(version_change.changes))
|
|
87
|
+
entries
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def item_entries(items, action)
|
|
91
|
+
Array(items).map do |item|
|
|
92
|
+
ChangeEntry.new(
|
|
93
|
+
element: item.name,
|
|
94
|
+
element_type: item.type,
|
|
95
|
+
action: action,
|
|
96
|
+
classification: ChangeClassification.classify(action: action),
|
|
97
|
+
description: Array(item.description),
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def mapping_entries(mappings)
|
|
103
|
+
Array(mappings).map do |mapping|
|
|
104
|
+
ChangeEntry.new(
|
|
105
|
+
element: mapping.name,
|
|
106
|
+
element_type: "MAPPING",
|
|
107
|
+
action: "mapping",
|
|
108
|
+
classification: ChangeClassification::EDITORIAL,
|
|
109
|
+
description: [mapping.description].compact,
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Counts per classification.
|
|
116
|
+
#
|
|
117
|
+
# @return [Hash{String => Integer}]
|
|
118
|
+
def summary
|
|
119
|
+
entries.group_by(&:classification)
|
|
120
|
+
.transform_values(&:size)
|
|
121
|
+
.sort.to_h
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Boolean]
|
|
125
|
+
def breaking?
|
|
126
|
+
entries.any? do |entry|
|
|
127
|
+
entry.classification == ChangeClassification::BREAKING
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Render a markdown changelog section.
|
|
132
|
+
#
|
|
133
|
+
# @return [String]
|
|
134
|
+
def to_changelog
|
|
135
|
+
lines = ["## Schema `#{schema}` — version #{version}", ""]
|
|
136
|
+
entries.sort_by { |entry| [entry.classification, entry.element.to_s] }
|
|
137
|
+
.each do |entry|
|
|
138
|
+
lines << "- **#{entry.classification}** — #{entry.element_type} " \
|
|
139
|
+
"`#{entry.element}` #{entry.action}"
|
|
140
|
+
Array(entry.description).each do |description|
|
|
141
|
+
lines << " - #{description}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
lines.join("\n")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
data/lib/suma/cli/build.rb
CHANGED
|
@@ -16,6 +16,23 @@ module Suma
|
|
|
16
16
|
desc: "Memory-bounded staged build: compile each member in " \
|
|
17
17
|
"its own process (sequential) and reinflate. For large " \
|
|
18
18
|
"collections that OOM a single-process build (suma#94)."
|
|
19
|
+
option :incremental, type: :boolean, default: false,
|
|
20
|
+
desc: "Re-render only changed schemas and their " \
|
|
21
|
+
"transitive dependents (state file defaults " \
|
|
22
|
+
"to plain_schemas/.suma-build-state.json)"
|
|
23
|
+
option :state_file, type: :string,
|
|
24
|
+
desc: "Path to the incremental build state file"
|
|
25
|
+
option :jobs, type: :numeric, default: 1,
|
|
26
|
+
desc: "Concurrent schema export workers (document " \
|
|
27
|
+
"compilation remains sequential)"
|
|
28
|
+
option :provenance, type: :string,
|
|
29
|
+
desc: "Write a provenance manifest (sha256 of inputs " \
|
|
30
|
+
"and outputs + toolchain versions) to this path"
|
|
31
|
+
option :sign, type: :string, banner: "METHOD",
|
|
32
|
+
desc: "Sign the provenance manifest: minisign or sigstore"
|
|
33
|
+
option :sign_key, type: :string,
|
|
34
|
+
desc: "Signing key (minisign requires it; sigstore " \
|
|
35
|
+
"signs keylessly when omitted)"
|
|
19
36
|
|
|
20
37
|
def build(metanorma_site_manifest)
|
|
21
38
|
unless File.exist?(metanorma_site_manifest)
|
|
@@ -39,6 +56,12 @@ module Suma
|
|
|
39
56
|
compile: options[:compile],
|
|
40
57
|
output_directory: "_site",
|
|
41
58
|
staged: options[:staged],
|
|
59
|
+
incremental: options[:incremental],
|
|
60
|
+
state_file: options[:state_file],
|
|
61
|
+
jobs: options[:jobs],
|
|
62
|
+
provenance_file: options[:provenance],
|
|
63
|
+
sign_method: options[:sign],
|
|
64
|
+
sign_key: options[:sign_key],
|
|
42
65
|
).run
|
|
43
66
|
end
|
|
44
67
|
|
data/lib/suma/cli/compare.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
3
5
|
require "thor"
|
|
4
6
|
|
|
5
7
|
module Suma
|
|
@@ -39,6 +41,16 @@ module Suma
|
|
|
39
41
|
desc: "Override auto-detected reference repo root"
|
|
40
42
|
option :verbose, type: :boolean, default: false,
|
|
41
43
|
desc: "Enable verbose output"
|
|
44
|
+
option :typed_output, type: :string, banner: "FILE",
|
|
45
|
+
desc: "Write the typed ChangeManifest " \
|
|
46
|
+
"(classification + descriptions) as YAML"
|
|
47
|
+
option :changelog, type: :boolean, default: false,
|
|
48
|
+
desc: "Print a markdown changelog for the changes"
|
|
49
|
+
option :impact, type: :boolean, default: false,
|
|
50
|
+
desc: "Print affected schemas and AP publications " \
|
|
51
|
+
"(requires --manifest)"
|
|
52
|
+
option :manifest, type: :string, banner: "SCHEMAS_YAML",
|
|
53
|
+
desc: "Corpus schema manifest, for --impact"
|
|
42
54
|
|
|
43
55
|
def compare(trial_schema, reference_schema)
|
|
44
56
|
comparer = SchemaComparer.new(trial_schema, reference_schema, options)
|
|
@@ -47,12 +59,47 @@ module Suma
|
|
|
47
59
|
|
|
48
60
|
if result.nil?
|
|
49
61
|
say "No changes detected between schemas", :yellow
|
|
50
|
-
|
|
51
|
-
say "Change YAML file: #{result}", :green
|
|
62
|
+
return
|
|
52
63
|
end
|
|
64
|
+
|
|
65
|
+
say "Change YAML file: #{result}", :green
|
|
66
|
+
report_typed_changes(result, trial_schema)
|
|
53
67
|
rescue Suma::Error => e
|
|
54
68
|
raise Thor::Error, e.message
|
|
55
69
|
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def report_typed_changes(change_yaml, trial_schema)
|
|
74
|
+
manifest = ChangeManifest.from_file(change_yaml,
|
|
75
|
+
version: options[:version])
|
|
76
|
+
write_typed_output(manifest)
|
|
77
|
+
say manifest.to_changelog if options[:changelog]
|
|
78
|
+
print_impact(manifest, trial_schema) if options[:impact]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def write_typed_output(manifest)
|
|
82
|
+
path = options[:typed_output]
|
|
83
|
+
return unless path
|
|
84
|
+
|
|
85
|
+
File.write(path, manifest.to_yaml)
|
|
86
|
+
say "Typed change manifest: #{path}", :green
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def print_impact(manifest, trial_schema)
|
|
90
|
+
unless options[:manifest]
|
|
91
|
+
raise Thor::Error, "--impact requires --manifest SCHEMAS_YAML"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
graph = CorpusGraph.from_manifest(options[:manifest])
|
|
95
|
+
impact = ChangeImpact.new(graph, options[:manifest])
|
|
96
|
+
.report(changed_schema_id(manifest, trial_schema))
|
|
97
|
+
say impact.to_yaml
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def changed_schema_id(manifest, trial_schema)
|
|
101
|
+
manifest.schema || File.basename(trial_schema, ".exp")
|
|
102
|
+
end
|
|
56
103
|
end
|
|
57
104
|
end
|
|
58
105
|
end
|