suma 0.5.6 → 0.5.8

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 +38 -0
  4. data/.rubocop_todo.yml +84 -0
  5. data/CLAUDE.md +44 -0
  6. data/Gemfile +9 -3
  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 +318 -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,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
data/lib/suma/cli/core.rb CHANGED
@@ -21,6 +21,27 @@ module Suma
21
21
 
22
22
  desc "build METANORMA_SITE_MANIFEST",
23
23
  "Build collection specified in site manifest (`metanorma*.yml`)"
24
+ # Mirrors so Core accepts the switches Cli::Build reparses; the inner
25
+ # class owns their declarations.
26
+ option :compile, type: :boolean, default: true,
27
+ desc: "Compile or skip compile of collection"
28
+ option :schemas_all_path, type: :string, aliases: "-s",
29
+ desc: "Generate file that contains all schemas " \
30
+ "in the collection."
31
+ option :staged, type: :boolean, default: false,
32
+ desc: "Memory-bounded staged build"
33
+ option :incremental, type: :boolean, default: false,
34
+ desc: "Re-render only changed schemas and dependents"
35
+ option :state_file, type: :string,
36
+ desc: "Path to the incremental build state file"
37
+ option :jobs, type: :numeric, default: 1,
38
+ desc: "Concurrent schema export workers"
39
+ option :provenance, type: :string,
40
+ desc: "Write a build provenance manifest"
41
+ option :sign, type: :string, banner: "METHOD",
42
+ desc: "Sign the provenance manifest: minisign or sigstore"
43
+ option :sign_key, type: :string,
44
+ desc: "Signing key (sigstore signs keylessly when omitted)"
24
45
  def build(_site_manifest)
25
46
  Cli::Build.start
26
47
  end
@@ -45,12 +66,18 @@ module Suma
45
66
  "(used for section references)"
46
67
  option :language_code, type: :string, default: "eng", aliases: "-l",
47
68
  desc: "Language code for the Glossarist"
69
+ option :edition, type: :numeric,
70
+ desc: "Owning edition recorded on each concept"
71
+ option :language_codes, type: :array,
72
+ desc: "Additional linked localization languages"
48
73
  def extract_terms(schema_manifest_file, glossarist_output_path)
49
74
  TermExtractor.new(
50
75
  schema_manifest_file,
51
76
  glossarist_output_path,
52
77
  urn: options[:urn],
53
78
  language_code: options[:language_code],
79
+ edition: options[:edition],
80
+ language_codes: options[:language_codes],
54
81
  ).call
55
82
  end
56
83
 
@@ -66,6 +93,14 @@ module Suma
66
93
  desc: "Language code for section names"
67
94
  option :owner, type: :string, default: Suma::RegisterManifestGenerator::DEFAULT_OWNER,
68
95
  desc: "Owner of the dataset (e.g. 'ISO/TC 184/SC 4')"
96
+ option :profile, type: :string, default: "iso10303",
97
+ desc: "Corpus profile (iso10303 or generic)"
98
+ option :year, type: :numeric,
99
+ desc: "Edition year for the register"
100
+ option :supersedes, type: :string,
101
+ desc: "URN/ref of the superseded edition"
102
+ option :edition, type: :numeric,
103
+ desc: "Edition number (tagged as edition:N)"
69
104
  def generate_register(schema_manifest_file, output_path)
70
105
  RegisterManifestGenerator.new(
71
106
  schema_manifest_file,
@@ -75,6 +110,10 @@ module Suma
75
110
  ref: options[:ref],
76
111
  language_code: options[:language_code],
77
112
  owner: options[:owner],
113
+ profile: options[:profile],
114
+ year: options[:year],
115
+ supersedes: options[:supersedes],
116
+ edition: options[:edition],
78
117
  ).generate
79
118
  end
80
119
 
@@ -87,6 +126,31 @@ module Suma
87
126
  desc "export *FILES",
88
127
  "Export EXPRESS schemas from manifest files or " \
89
128
  "standalone EXPRESS files"
129
+ # The inner class reparses ARGV and owns the canonical declarations;
130
+ # these mirrors exist only so Core accepts the switches it passes
131
+ # through (Core checks unknown options before dispatch).
132
+ option :output, type: :string, aliases: "-o", required: true,
133
+ desc: "Output directory path"
134
+ option :annotations, type: :boolean, default: false,
135
+ desc: "Include annotations (remarks/comments)"
136
+ option :zip, type: :boolean, default: false,
137
+ desc: "Create ZIP archive of exported schemas"
138
+ option :publication, type: :string, banner: "SCHEMA_ID",
139
+ desc: "Export an AP publication bundle: the " \
140
+ "lead schema plus its transitive " \
141
+ "USE-FROM/REFERENCE closure (manifest " \
142
+ "input only)"
143
+ option :profile, type: :string, default: "iso10303",
144
+ desc: "Corpus profile (iso10303 or generic)"
145
+ option :format, type: :string, default: "express", banner: "FORMAT",
146
+ desc: "Output format: express (default) or owl"
147
+ option :corpus, type: :boolean, default: false,
148
+ desc: "OWL only: emit one ontology for the whole " \
149
+ "manifest instead of one per schema"
150
+ option :urn, type: :string, banner: "URN",
151
+ desc: "OWL only: base URN for IRIs, before the " \
152
+ ":tech component " \
153
+ "(e.g. urn:iso:std:iso:10303:-2:ed-2:en)"
90
154
  def export(*_files)
91
155
  Cli::Export.start
92
156
  end
@@ -100,6 +164,25 @@ module Suma
100
164
  desc "validate SUBCOMMAND ...ARGS", "Validate express documents"
101
165
  subcommand "validate", Cli::Validate
102
166
 
167
+ desc "resolve [URN]", "Resolve URNs to canonical JSON + HTML artifacts"
168
+ # Mirrors so Core accepts the switches Cli::Resolve reparses; the
169
+ # inner class owns their declarations.
170
+ option :manifest, type: :string, required: true,
171
+ desc: "Schema manifest used for schema/entity URNs"
172
+ option :urn, type: :string,
173
+ desc: "Corpus URN base (defaults to the register URN)"
174
+ option :concepts, type: :string,
175
+ desc: "Directory of Glossarist concept YAML files"
176
+ option :register, type: :string,
177
+ desc: "Glossarist register.yaml (dataset URN)"
178
+ option :output, type: :string, aliases: "-o", required: true,
179
+ desc: "Artifact output directory"
180
+ option :all, type: :boolean, default: false,
181
+ desc: "Emit artifacts for every known URN"
182
+ def resolve(_urn = nil)
183
+ Cli::Resolve.start
184
+ end
185
+
103
186
  desc "check_svg_quality [PATH]",
104
187
  "Check SVG quality and sort by severity (critical files first)"
105
188
  option :pattern, type: :string, default: Cli::CheckSvgQuality::DEFAULT_PATTERN,