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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/nightly-benchmark.yml +25 -0
  3. data/.github/workflows/release-provenance.yml +41 -0
  4. data/.rubocop_todo.yml +84 -0
  5. data/CLAUDE.md +44 -0
  6. data/README.adoc +136 -11
  7. data/Rakefile +13 -1
  8. data/bin/benchmark_corpus +138 -0
  9. data/bin/release_provenance +40 -0
  10. data/lib/suma/build_plan.rb +71 -0
  11. data/lib/suma/change_classification.rb +37 -0
  12. data/lib/suma/change_impact.rb +61 -0
  13. data/lib/suma/change_manifest.rb +147 -0
  14. data/lib/suma/cli/build.rb +23 -0
  15. data/lib/suma/cli/compare.rb +49 -2
  16. data/lib/suma/cli/core.rb +83 -0
  17. data/lib/suma/cli/export.rb +140 -10
  18. data/lib/suma/cli/extract_terms.rb +11 -0
  19. data/lib/suma/cli/generate_register.rb +12 -0
  20. data/lib/suma/cli/resolve.rb +45 -0
  21. data/lib/suma/cli/validate.rb +90 -0
  22. data/lib/suma/cli.rb +1 -0
  23. data/lib/suma/closure_validator.rb +317 -0
  24. data/lib/suma/corpus_graph.rb +141 -0
  25. data/lib/suma/express_schema.rb +22 -28
  26. data/lib/suma/null_cache.rb +4 -0
  27. data/lib/suma/owl_projection.rb +611 -0
  28. data/lib/suma/processor.rb +55 -6
  29. data/lib/suma/provenance.rb +159 -0
  30. data/lib/suma/publication.rb +108 -0
  31. data/lib/suma/register_manifest_generator.rb +76 -8
  32. data/lib/suma/schema_cache.rb +72 -13
  33. data/lib/suma/schema_category.rb +31 -2
  34. data/lib/suma/schema_collection.rb +58 -5
  35. data/lib/suma/schema_exporter.rb +5 -5
  36. data/lib/suma/schema_naming.rb +29 -33
  37. data/lib/suma/schema_profile.rb +134 -0
  38. data/lib/suma/term_classification.rb +9 -2
  39. data/lib/suma/term_extractor.rb +76 -35
  40. data/lib/suma/urn_resolver.rb +368 -0
  41. data/lib/suma/version.rb +1 -1
  42. data/lib/suma/worker_pool.rb +62 -0
  43. data/lib/suma.rb +12 -0
  44. 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
@@ -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
 
@@ -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
- else
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