suma 0.5.9 → 0.5.10

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.
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "zip"
5
+ require "glossarist"
6
+ require "fileutils"
7
+
8
+ module Suma
9
+ # Packages extracted Glossarist concepts into a GCR (Glossarist Concept
10
+ # Repository) archive — the sealed distribution format consumed by the
11
+ # Glossarist Vocabulary Browser (docs/gcr-spec.md in concept-browser).
12
+ #
13
+ # Standard variant only: metadata.yaml + register.yaml + concepts/ in the
14
+ # browser's canonical flat format (termid, term, {lang} blocks). The -full
15
+ # variant (SKOS/TBX/JSONL compilations) is out of scope.
16
+ class GcrPackage
17
+ SCHEMA_VERSION = "1.0.0"
18
+
19
+ attr_reader :shortname, :version
20
+
21
+ # @param concepts_dir [String] TermExtractor output directory
22
+ # @param register_file [String] register.yaml (dataset identity)
23
+ # @param shortname [String] dataset id (datasets.yml `id`)
24
+ # @param version [String] semver of this dataset release
25
+ # @param title [String] human-readable dataset title
26
+ # @param description [String] dataset description
27
+ # @param created_at [Time, nil] package timestamp; pinned packages pass
28
+ # a fixed time so the archive is byte-reproducible
29
+ def initialize(concepts_dir:, register_file:, shortname:, version:,
30
+ title:, description:, created_at: nil)
31
+ @concepts_dir = File.expand_path(concepts_dir)
32
+ @register_file = File.expand_path(register_file)
33
+ @shortname = shortname
34
+ @version = version
35
+ @title = title
36
+ @description = description
37
+ @created_at = created_at || Time.now
38
+ end
39
+
40
+ # Write the versioned and unversioned .gcr archives.
41
+ #
42
+ # @param output_dir [String]
43
+ # @return [Array<String>] written archive paths
44
+ def write(output_dir)
45
+ FileUtils.mkdir_p(output_dir)
46
+ entries = package_entries
47
+ [versioned_name, unversioned_name].map do |name|
48
+ path = File.join(output_dir, name)
49
+ write_zip(path, entries)
50
+ Utils.log "Wrote GCR package: #{path}"
51
+ path
52
+ end
53
+ end
54
+
55
+ # Canonical concept hashes, in deterministic (termid) order.
56
+ #
57
+ # @return [Array<Hash>]
58
+ def canonical_concepts
59
+ @canonical_concepts ||= concept_documents
60
+ .map { |document| canonicalize(document) }
61
+ .sort_by { |hash| hash["termid"] }
62
+ end
63
+
64
+ # @return [Hash] metadata.yaml content
65
+ def metadata
66
+ register = Glossarist::DatasetRegister.from_file(@register_file)
67
+ {
68
+ "shortname" => @shortname,
69
+ "version" => @version,
70
+ "title" => @title,
71
+ "description" => @description,
72
+ "glossarist_version" => Glossarist::VERSION,
73
+ "created_at" => @created_at.utc.iso8601,
74
+ "created_by" => "suma CLI",
75
+ # Top-level summary keys as consumed by the Glossarist Vocabulary
76
+ # Browser and the Glossarist::GcrMetadata model.
77
+ "concept_count" => statistics["total_concepts"],
78
+ "languages" => statistics["languages"],
79
+ "uri" => register.urn,
80
+ "uri_aliases" => register.urn_aliases,
81
+ "statistics" => statistics,
82
+ "schema_version" => SCHEMA_VERSION,
83
+ }
84
+ end
85
+
86
+ def statistics
87
+ languages = canonical_concepts
88
+ .flat_map { |hash| hash.keys - %w[termid term] }
89
+ .uniq.sort
90
+ {
91
+ "total_concepts" => canonical_concepts.length,
92
+ "languages" => languages,
93
+ "concepts_with_definitions" => concepts_with("definition"),
94
+ "concepts_with_sources" => concepts_with("sources"),
95
+ "concepts_by_status" => { "valid" => canonical_concepts.length },
96
+ }
97
+ end
98
+
99
+ def concepts_with(key)
100
+ canonical_concepts.count do |hash|
101
+ hash.values.any? { |block| block.is_a?(Hash) && Array(block[key]).any? }
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def concept_documents
108
+ @concept_documents ||= Dir[File.join(@concepts_dir,
109
+ "*.yaml")].map do |path|
110
+ Glossarist::V3::ConceptDocument.from_yamls(File.read(path))
111
+ end
112
+ end
113
+
114
+ # Convert a V3 ConceptDocument stream to the browser's canonical flat
115
+ # concept hash (termid, term, one block per language).
116
+ def canonicalize(document)
117
+ concept = document.concept
118
+ hash = { "termid" => concept.data.id }
119
+ document.localizations.each do |localized|
120
+ data = localized.data
121
+ language = data.language_code
122
+ hash[language] = language_block(data)
123
+ hash["term"] ||= preferred_designation(data.terms)
124
+ end
125
+ validate_canonical!(hash)
126
+ hash
127
+ end
128
+
129
+ def language_block(data)
130
+ block = { "terms" => term_entries(data) }
131
+ definitions = Array(data.definition)
132
+ unless definitions.empty?
133
+ block["definition"] =
134
+ definition_entries(definitions)
135
+ end
136
+ notes = Array(data.notes).map do |note|
137
+ note.respond_to?(:content) ? note.content : note.to_s
138
+ end
139
+ block["notes"] = notes unless notes.empty?
140
+ sources = Array(data.sources).map { |source| source_hash(source) }
141
+ block["sources"] = sources unless sources.empty?
142
+ block["language_code"] = data.language_code
143
+ block
144
+ end
145
+
146
+ def term_entries(data)
147
+ Array(data.terms).map do |term|
148
+ {
149
+ "type" => term.type,
150
+ "designation" => term.designation,
151
+ "normative_status" => term.normative_status,
152
+ }
153
+ end
154
+ end
155
+
156
+ def definition_entries(definitions)
157
+ definitions.map { |definition| { "content" => definition.content } }
158
+ end
159
+
160
+ def source_hash(source)
161
+ ref = source.origin&.ref
162
+ origin = {}
163
+ if ref
164
+ origin["ref"] =
165
+ { "source" => ref.source, "version" => ref.version }.compact
166
+ end
167
+ { "type" => source.type, "origin" => origin }
168
+ end
169
+
170
+ def preferred_designation(terms)
171
+ preferred = Array(terms).find do |term|
172
+ term.normative_status == "preferred"
173
+ end
174
+ (preferred || Array(terms).first)&.designation
175
+ end
176
+
177
+ def validate_canonical!(hash)
178
+ termid = hash["termid"]
179
+ unless termid.is_a?(String) && !termid.empty?
180
+ raise Error, "concept without termid"
181
+ end
182
+
183
+ return if language_blocks(hash).any? do |block|
184
+ Array(block["terms"]).any?
185
+ end
186
+
187
+ raise Error, "concept #{termid} has no language block with a term"
188
+ end
189
+
190
+ def language_blocks(hash)
191
+ hash.values.grep(Hash)
192
+ end
193
+
194
+ def package_entries
195
+ entries = {
196
+ "metadata.yaml" => "#{YAML.dump(metadata)}\n",
197
+ "register.yaml" => File.read(@register_file),
198
+ }
199
+ seen = {}
200
+ canonical_concepts.each do |hash|
201
+ termid = hash["termid"]
202
+ if seen[termid]
203
+ raise Error, "duplicate termid in package: #{termid}"
204
+ end
205
+
206
+ seen[termid] = true
207
+ entries["concepts/#{termid}.yaml"] = YAML.dump(hash)
208
+ end
209
+ entries
210
+ end
211
+
212
+ def versioned_name
213
+ "#{@shortname}-#{@version}.gcr"
214
+ end
215
+
216
+ def unversioned_name
217
+ "#{@shortname}.gcr"
218
+ end
219
+
220
+ def write_zip(path, entries)
221
+ buffer = Zip::OutputStream.write_buffer do |stream|
222
+ entries.each do |name, content|
223
+ stream.put_next_entry(name)
224
+ stream.write(content)
225
+ end
226
+ end
227
+ File.binwrite(path, buffer.string)
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ # One unresolved link reported by a validator: where it was found, what
5
+ # it pointed at, and why it does not resolve.
6
+ LinkValidationResult = Struct.new(:file, :line, :link, :reason,
7
+ keyword_init: true)
8
+ end
@@ -4,9 +4,6 @@ require "expressir"
4
4
  require "suma/link_validation"
5
5
 
6
6
  module Suma
7
- LinkValidationResult = Struct.new(:file, :line, :link, :reason,
8
- keyword_init: true)
9
-
10
7
  class LinkValidator
11
8
  autoload :Step, "suma/link_validator/step"
12
9
 
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "uuidtools"
5
+
6
+ module Suma
7
+ # Removes Metanorma's nondeterminism from collection output.
8
+ #
9
+ # Metanorma assigns a random UUID to every collection manifest node that
10
+ # lacks an identifier (Manifest#identifier's default) and stamps each
11
+ # rendered document with a random 8-letter type marker
12
+ # (collection/xrefprocess). Both leak into the emitted manifest and the XML
13
+ # output, so two identical builds produce different bytes.
14
+ #
15
+ # Manifest identifiers are replaced IN THE MODEL before serialization:
16
+ # Psych's quoting of the resulting strings is value-dependent (a few
17
+ # percent of UUID-looking values get single-quoted), so patching the dumped
18
+ # YAML afterwards would preserve a random quoting decision. Values that
19
+ # appear in the source YAML are preserved.
20
+ class OutputCanonicalizer
21
+ UUID_PATTERN =
22
+ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
23
+ FULL_UUID_PATTERN = /\A#{UUID_PATTERN.source}\z/
24
+ CANONICAL_TYPE = "SUMASUMA"
25
+ NAMESPACE = UUIDTools::UUID.parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
26
+
27
+ def initialize(output_directory:, sources: [])
28
+ @output_directory = File.expand_path(output_directory)
29
+ @sources = sources.map { |path| File.expand_path(path) }
30
+ end
31
+
32
+ # Replace generated manifest identifiers with deterministic UUIDv5s.
33
+ # Must run before the manifest is serialized and the site key computed.
34
+ def canonicalize_manifest(manifest)
35
+ canonicalize_node(manifest, "collection-manifest")
36
+ self
37
+ end
38
+
39
+ # Canonicalize the rendered site's root XML documents.
40
+ def canonicalize_documents
41
+ Dir.glob(File.join(@output_directory, "*.xml")).each do |file|
42
+ canonicalize_document(file)
43
+ end
44
+ self
45
+ end
46
+
47
+ private
48
+
49
+ def canonicalize_node(node, seed)
50
+ return unless node
51
+
52
+ if generated_uuid?(node.identifier)
53
+ node.identifier = uuid_v5("suma-manifest:#{seed}").to_s
54
+ end
55
+ Array(node.entry).each_with_index do |child, index|
56
+ canonicalize_node(child, "#{seed}/#{index}")
57
+ end
58
+ end
59
+
60
+ def generated_uuid?(value)
61
+ value.to_s.match?(FULL_UUID_PATTERN) && !preserved.include?(value)
62
+ end
63
+
64
+ def canonicalize_document(file)
65
+ content = File.read(file)
66
+ canonical = replace_uuids(content, File.basename(file))
67
+ canonical = replace_type(canonical)
68
+ File.write(file, canonical) if canonical != content
69
+ end
70
+
71
+ def replace_uuids(content, seed)
72
+ index = 0
73
+ content.gsub(UUID_PATTERN) do |uuid|
74
+ next uuid if preserved.include?(uuid)
75
+
76
+ index += 1
77
+ uuid_v5("suma-document:#{seed}:#{index}").to_s
78
+ end
79
+ end
80
+
81
+ def replace_type(content)
82
+ content.sub(/(<metanorma\b[^>]*\btype=")[A-Z]{8}(")/) do
83
+ "#{Regexp.last_match(1)}#{CANONICAL_TYPE}#{Regexp.last_match(2)}"
84
+ end
85
+ end
86
+
87
+ # UUIDs that occur in the site's source YAML. Identifiers the author
88
+ # wrote must survive; only generated ones are replaced. YAML is read as
89
+ # bytes: an author-supplied file with non-UTF-8 bytes must not fail the
90
+ # build, and the pattern is ASCII-only.
91
+ def preserved
92
+ @preserved ||= yaml_sources.flat_map do |file|
93
+ File.binread(file).scan(UUID_PATTERN)
94
+ end.uniq
95
+ end
96
+
97
+ def yaml_sources
98
+ @sources.select do |file|
99
+ file.match?(/\.ya?ml\z/i) && File.file?(file)
100
+ end
101
+ end
102
+
103
+ def uuid_v5(name)
104
+ UUIDTools::UUID.sha1_create(NAMESPACE, name)
105
+ end
106
+ end
107
+ end
@@ -14,7 +14,8 @@ module Suma
14
14
  def initialize(metanorma_yaml_path:, schemas_all_path:, compile: true,
15
15
  output_directory: "_site", staged: false,
16
16
  incremental: false, state_file: nil, jobs: 1,
17
- provenance_file: nil, sign_method: nil, sign_key: nil)
17
+ provenance_file: nil, sign_method: nil, sign_key: nil,
18
+ no_valid: false)
18
19
  @metanorma_yaml_path = metanorma_yaml_path
19
20
  @schemas_all_path = schemas_all_path
20
21
  @compile_flag = compile
@@ -28,6 +29,9 @@ module Suma
28
29
  @provenance_file = provenance_file
29
30
  @sign_method = sign_method
30
31
  @sign_key = sign_key
32
+ # Skip Metanorma document validation (metanorma `--no-valid`). Opt-in:
33
+ # for collections whose flavor's validators are still in flight.
34
+ @no_valid = no_valid
31
35
  end
32
36
 
33
37
  def run
@@ -39,7 +43,6 @@ module Suma
39
43
  Utils.log "Compiling schema collection..."
40
44
  compile_schema(schemas_all_path, collection_config)
41
45
 
42
- Utils.log "Compiling complete collection..."
43
46
  compile_collection(collection_config, output_directory)
44
47
  end
45
48
 
@@ -56,6 +59,7 @@ module Suma
56
59
  @provenance_file,
57
60
  inputs: provenance_inputs,
58
61
  outputs: provenance_outputs,
62
+ exclude: provenance_excludes,
59
63
  )
60
64
  if @sign_method
61
65
  signature = Provenance.sign(
@@ -80,6 +84,31 @@ module Suma
80
84
  @output_directory]
81
85
  end
82
86
 
87
+ # Build metadata that is not site content: the site freshness state, the
88
+ # staged-build store, and a provenance file written inside the output tree.
89
+ def provenance_excludes
90
+ excludes = [File.join(@output_directory, SiteBuild::STATE_FILE)]
91
+ if @staged
92
+ excludes << File.join(@output_directory,
93
+ StagedCollectionBuilder::STORE_DIRNAME)
94
+ excludes << File.join(@output_directory, "_staged_reinflate")
95
+ end
96
+ excludes + provenance_artifacts.select { |path| within_output?(path) }
97
+ end
98
+
99
+ def provenance_artifacts
100
+ return [] unless @provenance_file
101
+
102
+ base = File.expand_path(@provenance_file)
103
+ [base, "#{base}.minisig", "#{base}.bundle"]
104
+ end
105
+
106
+ def within_output?(path)
107
+ full = File.expand_path(path)
108
+ output = File.expand_path(@output_directory)
109
+ full == output || full.start_with?("#{output}#{File::SEPARATOR}")
110
+ end
111
+
83
112
  def export_schema_config
84
113
  site_config = Suma::SiteConfig::Config.from_file(metanorma_yaml_path)
85
114
 
@@ -108,6 +137,7 @@ module Suma
108
137
  incremental: @incremental,
109
138
  state_file: @state_file,
110
139
  jobs: @jobs,
140
+ no_valid: @no_valid,
111
141
  },
112
142
  )
113
143
 
@@ -115,36 +145,80 @@ module Suma
115
145
  end
116
146
 
117
147
  def compile_collection(collection_config, output_directory)
118
- metanorma_collection, collection_opts = build_collection(
119
- collection_config, output_directory
120
- )
148
+ prepare_collection_manifest(collection_config)
149
+ write_collection_manifest(collection_config)
150
+
151
+ if site_build.current?
152
+ Utils.log "Collection outputs are up to date; 0 documents rendered."
153
+ return
154
+ end
155
+
156
+ Utils.log "Rendering collection site..."
157
+ render_collection(collection_config, output_directory)
158
+ canonicalizer.canonicalize_documents
159
+ site_build.record
160
+ end
161
+
162
+ # Generated manifest identifiers are canonicalized in the model, before
163
+ # serialization: patching the dumped YAML would preserve Psych's
164
+ # value-dependent (random) quoting decision.
165
+ def prepare_collection_manifest(collection_config)
166
+ ManifestTraverser.new(collection_config.manifest)
167
+ .remove_schemas_only_sources
168
+ canonicalizer.canonicalize_manifest(collection_config.manifest)
169
+ end
121
170
 
171
+ def write_collection_manifest(collection_config)
172
+ collection_config.to_file(COLLECTION_OUTPUT_PATH)
173
+ end
174
+
175
+ def render_collection(collection_config, output_directory)
122
176
  if @staged
123
- # build_collection has written the emitted manifest; stage each member in
124
- # its own process, then reinflate. Bounds peak memory to a single member.
177
+ # The emitted manifest is the staging input; each member is compiled
178
+ # in its own process and then reinflated into +output_directory+.
125
179
  StagedCollectionBuilder.new(
126
180
  collection_config_path: COLLECTION_OUTPUT_PATH,
127
181
  output_directory: output_directory,
128
- coverpage: collection_opts[:coverpage],
182
+ coverpage: collection_coverpage(collection_config),
183
+ no_valid: @no_valid,
129
184
  ).build
130
185
  else
131
- metanorma_collection.render(collection_opts)
186
+ Metanorma::Collection.parse(COLLECTION_OUTPUT_PATH)
187
+ .render(collection_options(collection_config, output_directory))
132
188
  end
133
189
  end
134
190
 
135
- def build_collection(collection_config, output_directory)
136
- new_collection_config_path = COLLECTION_OUTPUT_PATH
137
- ManifestTraverser.new(collection_config.manifest).remove_schemas_only_sources
138
- collection_config.to_file(new_collection_config_path)
139
-
140
- collection = Metanorma::Collection.parse(new_collection_config_path)
141
-
142
- collection_opts = {
191
+ def collection_options(collection_config, output_directory)
192
+ {
143
193
  output_folder: output_directory,
144
- compile: { install_fonts: false },
145
- coverpage: collection_config.coverpage || "cover.html",
194
+ compile: compile_flags,
195
+ coverpage: collection_coverpage(collection_config),
146
196
  }
147
- [collection, collection_opts]
197
+ end
198
+
199
+ def compile_flags
200
+ { install_fonts: false }.tap do |flags|
201
+ flags[:novalid] = true if @no_valid
202
+ end
203
+ end
204
+
205
+ def collection_coverpage(collection_config)
206
+ collection_config.coverpage || "cover.html"
207
+ end
208
+
209
+ def site_build
210
+ @site_build ||= SiteBuild.new(
211
+ root: File.dirname(File.expand_path(metanorma_yaml_path)),
212
+ output_directory: output_directory,
213
+ inputs: [schemas_all_path],
214
+ )
215
+ end
216
+
217
+ def canonicalizer
218
+ @canonicalizer ||= OutputCanonicalizer.new(
219
+ output_directory: output_directory,
220
+ sources: site_build.source_files,
221
+ )
148
222
  end
149
223
  end
150
224
  end
@@ -83,6 +83,9 @@ module Suma
83
83
  end
84
84
  end
85
85
 
86
+ # Gems whose versions determine rendered document bytes.
87
+ RENDER_GEM_PREFIXES = %w[metanorma- isodoc].freeze
88
+
86
89
  # @return [Hash] toolchain versions that determine output bytes
87
90
  def self.toolchain
88
91
  {
@@ -98,30 +101,63 @@ module Suma
98
101
  versions
99
102
  end
100
103
 
104
+ # Toolchain identity for cached rendered documents: the provenance
105
+ # toolchain plus every installed Metanorma/Isodoc gem, so a renderer or
106
+ # flavor update invalidates cached renders even when source is unchanged.
107
+ def self.render_toolchain
108
+ @render_toolchain ||= toolchain.merge("render" => render_gems)
109
+ end
110
+
111
+ def self.render_gems
112
+ gem_specs.select { |spec| render_gem?(spec.name) }
113
+ .sort_by { |spec| [spec.name, spec.version.to_s] }
114
+ .map { |spec| "#{spec.name}-#{spec.version}" }
115
+ end
116
+
117
+ def self.render_gem?(name)
118
+ name == "metanorma" || RENDER_GEM_PREFIXES.any? do |prefix|
119
+ name.start_with?(prefix)
120
+ end
121
+ end
122
+
123
+ def self.gem_specs
124
+ Gem::Specification.select { |spec| spec.name.match?(/\A(?:metanorma|isodoc)/) }
125
+ end
126
+
101
127
  # @param inputs [Array<String>] files or directories
102
128
  # @param outputs [Array<String>] files or directories
129
+ # @param exclude [Array<String>] files or directories omitted from the
130
+ # manifest and from the unrecorded-output check
103
131
  # @return [Hash] manifest
104
- def self.record(inputs:, outputs:)
132
+ def self.record(inputs:, outputs:, exclude: [])
133
+ excluded = exclude.map { |path| File.expand_path(path) }
105
134
  {
106
135
  "toolchain" => toolchain,
107
- "inputs" => digests(inputs),
108
- "outputs" => digests(outputs),
136
+ "inputs" => digests(inputs, exclude: excluded),
137
+ "outputs" => digests(outputs, exclude: excluded),
138
+ "output_roots" => output_roots(outputs, exclude: excluded),
139
+ "excluded" => excluded.map { |path| relative(path) }.sort,
109
140
  }
110
141
  end
111
142
 
112
143
  # Write a provenance manifest as canonical JSON.
113
- def self.write(path, inputs:, outputs:)
144
+ def self.write(path, inputs:, outputs:, exclude: [])
114
145
  FileUtils.mkdir_p(File.dirname(path))
115
- manifest = record(inputs: inputs, outputs: outputs)
146
+ manifest = record(inputs: inputs, outputs: outputs, exclude: exclude)
116
147
  File.write(path, "#{JSON.pretty_generate(manifest)}\n")
117
148
  path
118
149
  end
119
150
 
120
- # Independently recompute every digest in the manifest.
151
+ # Independently recompute every digest in the manifest and flag files
152
+ # that appeared under a recorded output root but are not in the manifest.
121
153
  #
122
154
  # @return [Array<String>] problems (empty = verified)
123
155
  def self.verify(path)
124
156
  manifest = JSON.parse(File.read(path))
157
+ digest_problems(manifest) + unrecorded_outputs(manifest)
158
+ end
159
+
160
+ def self.digest_problems(manifest)
125
161
  SECTIONS.flat_map do |section|
126
162
  manifest.fetch(section, {}).filter_map do |relative, digest|
127
163
  full = File.expand_path(relative)
@@ -134,18 +170,59 @@ module Suma
134
170
  end
135
171
  end
136
172
 
173
+ def self.unrecorded_outputs(manifest)
174
+ recorded = manifest.fetch("outputs", {})
175
+ excluded = expanded(manifest["excluded"])
176
+ Array(manifest["output_roots"]).flat_map do |root|
177
+ output_files(root)
178
+ .reject { |file| recorded.key?(file) || excluded?(file, excluded) }
179
+ .map { |file| "outputs: unrecorded #{file}" }
180
+ end.sort
181
+ end
182
+
183
+ def self.output_files(root)
184
+ full_root = File.expand_path(root)
185
+ return [] unless File.directory?(full_root)
186
+
187
+ files([full_root]).map { |file| relative(file) }.uniq
188
+ end
189
+
190
+ def self.expanded(paths)
191
+ Array(paths).map { |path| File.expand_path(path) }
192
+ end
193
+
194
+ def self.excluded?(relative_path, excluded)
195
+ full = File.expand_path(relative_path)
196
+ excluded.any? do |path|
197
+ full == path || full.start_with?("#{path}#{File::SEPARATOR}")
198
+ end
199
+ end
200
+
137
201
  # @param paths [Array<String>] files or directories
202
+ # @param exclude [Array<String>] expanded paths to skip
138
203
  # @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) }
204
+ def self.digests(paths, exclude: [])
205
+ files(paths)
206
+ .reject { |file| excluded?(file, exclude) }
144
207
  .map { |file| relative(file) }
145
208
  .uniq.sort
146
209
  .to_h { |file| [file, Digest::SHA256.file(file).hexdigest] }
147
210
  end
148
211
 
212
+ def self.files(paths)
213
+ paths.flat_map do |path|
214
+ File.directory?(path) ? Dir.glob(File.join(path, "**/*")) : [path]
215
+ end.select { |file| File.file?(file) }
216
+ end
217
+
218
+ # Relative paths of the directory entries among +outputs+.
219
+ def self.output_roots(outputs, exclude: [])
220
+ outputs.select { |path| File.directory?(path) }
221
+ .map { |path| relative(path) }
222
+ .reject { |path| excluded?(path, exclude) }
223
+ .uniq.sort
224
+ end
225
+
149
226
  def self.relative(path)
150
227
  Pathname.new(File.expand_path(path))
151
228
  .relative_path_from(Pathname.getwd)
@@ -154,6 +231,9 @@ module Suma
154
231
  path.to_s
155
232
  end
156
233
 
157
- private_class_method :optional_versions, :sign_with, :executable?
234
+ private_class_method :optional_versions, :sign_with, :executable?,
235
+ :render_gems, :render_gem?, :gem_specs,
236
+ :digest_problems, :unrecorded_outputs, :output_files,
237
+ :expanded, :excluded?, :files
158
238
  end
159
239
  end
@@ -125,6 +125,7 @@ module Suma
125
125
  schema: express,
126
126
  output_path: @output_path_docs.join(express.id),
127
127
  template: template_class.new(express.id),
128
+ no_valid: @options[:no_valid],
128
129
  )
129
130
 
130
131
  @docs[express.id] = compiler