suma 0.5.8 → 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.
- checksums.yaml +4 -4
- data/.github/workflows/release-provenance.yml +5 -2
- data/.rubocop_todo.yml +27 -0
- data/CLAUDE.md +17 -5
- data/README.adoc +237 -11
- data/lib/suma/anchor_validator.rb +85 -0
- data/lib/suma/artifact_server.rb +102 -0
- data/lib/suma/cli/artifact_server_cli.rb +22 -0
- data/lib/suma/cli/build.rb +4 -0
- data/lib/suma/cli/core.rb +50 -4
- data/lib/suma/cli/export.rb +1 -1
- data/lib/suma/cli/extract_terms.rb +1 -1
- data/lib/suma/cli/generate_register.rb +1 -1
- data/lib/suma/cli/ingest_translations.rb +36 -0
- data/lib/suma/cli/package_gcr.rb +51 -0
- data/lib/suma/cli/validate.rb +16 -0
- data/lib/suma/cli.rb +4 -1
- data/lib/suma/closure_validator.rb +124 -20
- data/lib/suma/corpus_graph.rb +79 -16
- data/lib/suma/gcr_package.rb +230 -0
- data/lib/suma/link_validation_result.rb +8 -0
- data/lib/suma/link_validator.rb +0 -3
- data/lib/suma/output_canonicalizer.rb +107 -0
- data/lib/suma/processor.rb +94 -20
- data/lib/suma/provenance.rb +92 -12
- data/lib/suma/schema_collection.rb +1 -0
- data/lib/suma/schema_compiler.rb +79 -13
- data/lib/suma/schema_manifest_generator.rb +10 -3
- data/lib/suma/schema_profile.rb +12 -0
- data/lib/suma/schema_template/anchors.rb +154 -0
- data/lib/suma/schema_template/document.rb +2 -93
- data/lib/suma/schema_template/plain.rb +10 -6
- data/lib/suma/schema_template.rb +1 -0
- data/lib/suma/site_build.rb +153 -0
- data/lib/suma/staged_collection_builder.rb +11 -3
- data/lib/suma/translation_ingester.rb +169 -0
- data/lib/suma/urn_resolver.rb +51 -1
- data/lib/suma/version.rb +1 -1
- data/lib/suma.rb +7 -0
- metadata +13 -2
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Suma
|
|
6
|
+
# Development server for the emitted URN artifact tree: serves the
|
|
7
|
+
# static files at their URN-derived paths, negotiating JSON vs HTML by
|
|
8
|
+
# the Accept header. Production hosting maps the same paths statically.
|
|
9
|
+
class ArtifactServer
|
|
10
|
+
DEFAULT_PORT = 2113
|
|
11
|
+
|
|
12
|
+
attr_reader :directory, :port
|
|
13
|
+
|
|
14
|
+
# @param directory [String] the resolve output directory
|
|
15
|
+
# @param port [Integer] TCP port; 0 picks an ephemeral port
|
|
16
|
+
def initialize(directory, port: DEFAULT_PORT)
|
|
17
|
+
@directory = File.expand_path(directory)
|
|
18
|
+
@port = port
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Resolve one HTTP request to a response.
|
|
22
|
+
#
|
|
23
|
+
# @param path [String] request path (with or without extension)
|
|
24
|
+
# @param accept [String, nil] Accept header value
|
|
25
|
+
# @return [Array(String, String, String)] status, content type, body
|
|
26
|
+
def call(path, accept = nil)
|
|
27
|
+
clean = path.to_s.split("?").first.to_s
|
|
28
|
+
return not_found if clean.include?("..")
|
|
29
|
+
|
|
30
|
+
clean = "/index.html" if clean.empty? || clean == "/"
|
|
31
|
+
file = artifact_file(clean, accept)
|
|
32
|
+
return not_found unless file
|
|
33
|
+
|
|
34
|
+
["200", content_type(file), File.read(file, encoding: "utf-8")]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Handle a single connection (one HTTP request).
|
|
38
|
+
def serve(socket)
|
|
39
|
+
request = socket.gets&.strip
|
|
40
|
+
return unless request
|
|
41
|
+
|
|
42
|
+
accept = read_headers(socket)["accept"]
|
|
43
|
+
status, type, body = call(request.split[1].to_s, accept)
|
|
44
|
+
socket.write(
|
|
45
|
+
"HTTP/1.1 #{status} #{status_name(status)}\r\n" \
|
|
46
|
+
"Content-Type: #{type}\r\nContent-Length: #{body.bytesize}\r\n" \
|
|
47
|
+
"Connection: close\r\n\r\n#{body}",
|
|
48
|
+
)
|
|
49
|
+
ensure
|
|
50
|
+
socket&.close
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Accept connections until interrupted.
|
|
54
|
+
def start
|
|
55
|
+
server = TCPServer.new("localhost", @port)
|
|
56
|
+
@port = server.addr[1]
|
|
57
|
+
Utils.log "Serving #{@directory} at http://localhost:#{@port}"
|
|
58
|
+
loop { serve(server.accept) }
|
|
59
|
+
ensure
|
|
60
|
+
server&.close
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def read_headers(socket)
|
|
66
|
+
headers = {}
|
|
67
|
+
while (line = socket.gets) && line != "\r\n" && !line.strip.empty?
|
|
68
|
+
key, value = line.split(":", 2)
|
|
69
|
+
headers[key.strip.downcase] = value.strip if value
|
|
70
|
+
end
|
|
71
|
+
headers
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def artifact_file(clean, accept)
|
|
75
|
+
base = clean.delete_prefix("/")
|
|
76
|
+
candidates = if base.end_with?(".json", ".html")
|
|
77
|
+
[base]
|
|
78
|
+
else
|
|
79
|
+
wants_json = accept.to_s.include?("application/json")
|
|
80
|
+
["#{base}.#{wants_json ? 'json' : 'html'}"]
|
|
81
|
+
end
|
|
82
|
+
candidates.map { |candidate| File.join(@directory, candidate) }
|
|
83
|
+
.find { |file| File.file?(file) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def content_type(file)
|
|
87
|
+
if file.end_with?(".json")
|
|
88
|
+
"application/json"
|
|
89
|
+
else
|
|
90
|
+
"text/html; charset=utf-8"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def not_found
|
|
95
|
+
["404", "text/plain", "not found\n"]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def status_name(status)
|
|
99
|
+
status == "200" ? "OK" : "Not Found"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Suma
|
|
6
|
+
module Cli
|
|
7
|
+
# Serve the emitted URN artifact tree with Accept negotiation.
|
|
8
|
+
class ArtifactServerCli < Thor
|
|
9
|
+
def self.exit_on_failure?
|
|
10
|
+
true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc "serve DIRECTORY", "Serve resolved URN artifacts locally"
|
|
14
|
+
option :port, type: :numeric, default: ArtifactServer::DEFAULT_PORT,
|
|
15
|
+
desc: "TCP port to listen on"
|
|
16
|
+
def serve(directory)
|
|
17
|
+
Utils.log "Ctrl-C stops the server."
|
|
18
|
+
ArtifactServer.new(directory, port: options[:port]).start
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/suma/cli/build.rb
CHANGED
|
@@ -33,6 +33,9 @@ module Suma
|
|
|
33
33
|
option :sign_key, type: :string,
|
|
34
34
|
desc: "Signing key (minisign requires it; sigstore " \
|
|
35
35
|
"signs keylessly when omitted)"
|
|
36
|
+
option :no_valid, type: :boolean, default: false,
|
|
37
|
+
desc: "Skip Metanorma document validation " \
|
|
38
|
+
"(equivalent of metanorma --no-valid)"
|
|
36
39
|
|
|
37
40
|
def build(metanorma_site_manifest)
|
|
38
41
|
unless File.exist?(metanorma_site_manifest)
|
|
@@ -62,6 +65,7 @@ module Suma
|
|
|
62
65
|
provenance_file: options[:provenance],
|
|
63
66
|
sign_method: options[:sign],
|
|
64
67
|
sign_key: options[:sign_key],
|
|
68
|
+
no_valid: options[:no_valid],
|
|
65
69
|
).run
|
|
66
70
|
end
|
|
67
71
|
|
data/lib/suma/cli/core.rb
CHANGED
|
@@ -42,6 +42,8 @@ module Suma
|
|
|
42
42
|
desc: "Sign the provenance manifest: minisign or sigstore"
|
|
43
43
|
option :sign_key, type: :string,
|
|
44
44
|
desc: "Signing key (sigstore signs keylessly when omitted)"
|
|
45
|
+
option :no_valid, type: :boolean, default: false,
|
|
46
|
+
desc: "Skip Metanorma document validation"
|
|
45
47
|
def build(_site_manifest)
|
|
46
48
|
Cli::Build.start
|
|
47
49
|
end
|
|
@@ -70,6 +72,8 @@ module Suma
|
|
|
70
72
|
desc: "Owning edition recorded on each concept"
|
|
71
73
|
option :language_codes, type: :array,
|
|
72
74
|
desc: "Additional linked localization languages"
|
|
75
|
+
option :profile, type: :string, default: "iso10303",
|
|
76
|
+
desc: "Corpus profile (iso10303, generic, or ifc)"
|
|
73
77
|
def extract_terms(schema_manifest_file, glossarist_output_path)
|
|
74
78
|
TermExtractor.new(
|
|
75
79
|
schema_manifest_file,
|
|
@@ -78,6 +82,7 @@ module Suma
|
|
|
78
82
|
language_code: options[:language_code],
|
|
79
83
|
edition: options[:edition],
|
|
80
84
|
language_codes: options[:language_codes],
|
|
85
|
+
profile: options[:profile],
|
|
81
86
|
).call
|
|
82
87
|
end
|
|
83
88
|
|
|
@@ -94,7 +99,7 @@ module Suma
|
|
|
94
99
|
option :owner, type: :string, default: Suma::RegisterManifestGenerator::DEFAULT_OWNER,
|
|
95
100
|
desc: "Owner of the dataset (e.g. 'ISO/TC 184/SC 4')"
|
|
96
101
|
option :profile, type: :string, default: "iso10303",
|
|
97
|
-
desc: "Corpus profile (iso10303 or
|
|
102
|
+
desc: "Corpus profile (iso10303, generic, or ifc)"
|
|
98
103
|
option :year, type: :numeric,
|
|
99
104
|
desc: "Edition year for the register"
|
|
100
105
|
option :supersedes, type: :string,
|
|
@@ -141,7 +146,7 @@ module Suma
|
|
|
141
146
|
"USE-FROM/REFERENCE closure (manifest " \
|
|
142
147
|
"input only)"
|
|
143
148
|
option :profile, type: :string, default: "iso10303",
|
|
144
|
-
desc: "Corpus profile (iso10303 or
|
|
149
|
+
desc: "Corpus profile (iso10303, generic, or ifc)"
|
|
145
150
|
option :format, type: :string, default: "express", banner: "FORMAT",
|
|
146
151
|
desc: "Output format: express (default) or owl"
|
|
147
152
|
option :corpus, type: :boolean, default: false,
|
|
@@ -164,8 +169,7 @@ module Suma
|
|
|
164
169
|
desc "validate SUBCOMMAND ...ARGS", "Validate express documents"
|
|
165
170
|
subcommand "validate", Cli::Validate
|
|
166
171
|
|
|
167
|
-
desc "resolve [URN]", "Resolve URNs to canonical JSON + HTML artifacts"
|
|
168
|
-
# Mirrors so Core accepts the switches Cli::Resolve reparses; the
|
|
172
|
+
desc "resolve [URN]", "Resolve URNs to canonical JSON + HTML artifacts" # Mirrors so Core accepts the switches Cli::Resolve reparses; the
|
|
169
173
|
# inner class owns their declarations.
|
|
170
174
|
option :manifest, type: :string, required: true,
|
|
171
175
|
desc: "Schema manifest used for schema/entity URNs"
|
|
@@ -183,6 +187,48 @@ module Suma
|
|
|
183
187
|
Cli::Resolve.start
|
|
184
188
|
end
|
|
185
189
|
|
|
190
|
+
desc "package-gcr CONCEPTS_DIR",
|
|
191
|
+
"Package extracted concepts into a .gcr archive"
|
|
192
|
+
# Mirrors so Core accepts the switches Cli::PackageGcr reparses; the
|
|
193
|
+
# inner class owns their declarations.
|
|
194
|
+
option :register, type: :string, required: true,
|
|
195
|
+
desc: "register.yaml (dataset identity)"
|
|
196
|
+
option :shortname, type: :string, required: true,
|
|
197
|
+
desc: "Dataset id"
|
|
198
|
+
option :version, type: :string, required: true,
|
|
199
|
+
desc: "Semver of this dataset release"
|
|
200
|
+
option :title, type: :string, required: true,
|
|
201
|
+
desc: "Human-readable dataset title"
|
|
202
|
+
option :description, type: :string, required: true,
|
|
203
|
+
desc: "Dataset description"
|
|
204
|
+
option :created_at, type: :string,
|
|
205
|
+
desc: "Package timestamp (pin for reproducibility)"
|
|
206
|
+
option :output, type: :string, aliases: "-o", required: true,
|
|
207
|
+
desc: "Output directory for the .gcr archives"
|
|
208
|
+
def package_gcr(_concepts_dir)
|
|
209
|
+
Cli::PackageGcr.start
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
desc "ingest-translations CONCEPTS_DIR TRANSLATIONS_FILE",
|
|
213
|
+
"Overlay translated localized-concept content onto extracted " \
|
|
214
|
+
"Glossarist concepts"
|
|
215
|
+
# Mirrors so Core accepts the switches Cli::IngestTranslations
|
|
216
|
+
# reparses; the inner class owns their declarations.
|
|
217
|
+
option :output, type: :string, aliases: "-o",
|
|
218
|
+
desc: "Write updated concepts here (default: " \
|
|
219
|
+
"update CONCEPTS_DIR in place)"
|
|
220
|
+
def ingest_translations(_concepts_dir, _translations_file)
|
|
221
|
+
Cli::IngestTranslations.start
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
desc "serve DIRECTORY", "Serve resolved URN artifacts locally"
|
|
225
|
+
option :port, type: :numeric,
|
|
226
|
+
default: Suma::ArtifactServer::DEFAULT_PORT,
|
|
227
|
+
desc: "TCP port to listen on"
|
|
228
|
+
def serve(_directory)
|
|
229
|
+
Cli::ArtifactServerCli.start
|
|
230
|
+
end
|
|
231
|
+
|
|
186
232
|
desc "check_svg_quality [PATH]",
|
|
187
233
|
"Check SVG quality and sort by severity (critical files first)"
|
|
188
234
|
option :pattern, type: :string, default: Cli::CheckSvgQuality::DEFAULT_PATTERN,
|
data/lib/suma/cli/export.rb
CHANGED
|
@@ -35,7 +35,7 @@ module Suma
|
|
|
35
35
|
"USE-FROM/REFERENCE closure (manifest " \
|
|
36
36
|
"input only)"
|
|
37
37
|
option :profile, type: :string, default: "iso10303",
|
|
38
|
-
desc: "Corpus profile (iso10303 or
|
|
38
|
+
desc: "Corpus profile (iso10303, generic, or ifc)"
|
|
39
39
|
option :format, type: :string, default: "express", banner: "FORMAT",
|
|
40
40
|
desc: "Output format: express (default) or owl"
|
|
41
41
|
option :corpus, type: :boolean, default: false,
|
|
@@ -14,7 +14,7 @@ module Suma
|
|
|
14
14
|
desc: "URN for the dataset source " \
|
|
15
15
|
"(used for section references)"
|
|
16
16
|
option :profile, type: :string, default: "iso10303",
|
|
17
|
-
desc: "Corpus profile (iso10303 or
|
|
17
|
+
desc: "Corpus profile (iso10303, generic, or ifc)"
|
|
18
18
|
option :edition, type: :numeric,
|
|
19
19
|
desc: "Owning edition recorded on each concept"
|
|
20
20
|
option :language_codes, type: :array,
|
|
@@ -18,7 +18,7 @@ module Suma
|
|
|
18
18
|
option :owner, type: :string, default: Suma::RegisterManifestGenerator::DEFAULT_OWNER,
|
|
19
19
|
desc: "Owner of the dataset (e.g. 'ISO/TC 184/SC 4')"
|
|
20
20
|
option :profile, type: :string, default: "iso10303",
|
|
21
|
-
desc: "Corpus profile (iso10303 or
|
|
21
|
+
desc: "Corpus profile (iso10303, generic, or ifc)"
|
|
22
22
|
option :year, type: :numeric,
|
|
23
23
|
desc: "Edition year for the register"
|
|
24
24
|
option :supersedes, type: :string,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Suma
|
|
6
|
+
module Cli
|
|
7
|
+
# Ingest translated localized-concept content into extracted concepts.
|
|
8
|
+
class IngestTranslations < Thor
|
|
9
|
+
def self.exit_on_failure?
|
|
10
|
+
true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc "ingest_translations CONCEPTS_DIR TRANSLATIONS_FILE",
|
|
14
|
+
"Overlay translated localized-concept content onto extracted " \
|
|
15
|
+
"Glossarist concepts; fields the translations omit fall back " \
|
|
16
|
+
"to the source-language content"
|
|
17
|
+
option :output, type: :string, aliases: "-o",
|
|
18
|
+
desc: "Write updated concepts here (default: " \
|
|
19
|
+
"update CONCEPTS_DIR in place)"
|
|
20
|
+
|
|
21
|
+
def ingest_translations(concepts_dir, translations_file)
|
|
22
|
+
unless File.file?(translations_file)
|
|
23
|
+
raise Errno::ENOENT, translations_file
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
applied = TranslationIngester.new(
|
|
27
|
+
concepts_dir: concepts_dir,
|
|
28
|
+
translations: translations_file,
|
|
29
|
+
output_dir: options[:output],
|
|
30
|
+
).ingest
|
|
31
|
+
Utils.log "Applied #{applied.values.sum(&:size)} translation(s) " \
|
|
32
|
+
"to #{applied.size} concept(s)."
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Suma
|
|
6
|
+
module Cli
|
|
7
|
+
# Package extracted concepts into a GCR archive for the Vocabulary
|
|
8
|
+
# Browser.
|
|
9
|
+
class PackageGcr < Thor
|
|
10
|
+
def self.exit_on_failure?
|
|
11
|
+
true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
desc "package_gcr CONCEPTS_DIR",
|
|
15
|
+
"Package extracted Glossarist concepts into a .gcr archive"
|
|
16
|
+
option :register, type: :string, required: true,
|
|
17
|
+
desc: "register.yaml (dataset identity)"
|
|
18
|
+
option :shortname, type: :string, required: true,
|
|
19
|
+
desc: "Dataset id (matches datasets.yml `id`)"
|
|
20
|
+
option :version, type: :string, required: true,
|
|
21
|
+
desc: "Semver of this dataset release"
|
|
22
|
+
option :title, type: :string, required: true,
|
|
23
|
+
desc: "Human-readable dataset title"
|
|
24
|
+
option :description, type: :string, required: true,
|
|
25
|
+
desc: "Dataset description"
|
|
26
|
+
option :created_at, type: :string,
|
|
27
|
+
desc: "Package timestamp (ISO 8601); pin for " \
|
|
28
|
+
"reproducible archives"
|
|
29
|
+
option :output, type: :string, aliases: "-o", required: true,
|
|
30
|
+
desc: "Output directory for the .gcr archives"
|
|
31
|
+
|
|
32
|
+
def package_gcr(concepts_dir)
|
|
33
|
+
GcrPackage.new(
|
|
34
|
+
concepts_dir: concepts_dir,
|
|
35
|
+
register_file: options[:register],
|
|
36
|
+
shortname: options[:shortname],
|
|
37
|
+
version: options[:version],
|
|
38
|
+
title: options[:title],
|
|
39
|
+
description: options[:description],
|
|
40
|
+
created_at: pinned_time,
|
|
41
|
+
).write(options[:output])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def pinned_time
|
|
47
|
+
options[:created_at] && Time.iso8601(options[:created_at])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/suma/cli/validate.rb
CHANGED
|
@@ -62,6 +62,22 @@ module Suma
|
|
|
62
62
|
fail_on_new_drift(*drift.check(path), path)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
desc "anchors SCHEMA_DOCS_PATH",
|
|
66
|
+
"Validate rendered schema docs: every Expressir cross-reference " \
|
|
67
|
+
"and in-page link resolves to an anchor in its HTML"
|
|
68
|
+
def anchors(schema_docs_path = "schema_docs")
|
|
69
|
+
violations = AnchorValidator.new(schema_docs_path).validate
|
|
70
|
+
if violations.empty?
|
|
71
|
+
puts "All anchors resolve: no dead links."
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
puts violations.map { |v| "#{v.file}: #{v.link} — #{v.reason}" }
|
|
76
|
+
.join("\n")
|
|
77
|
+
raise Thor::Error,
|
|
78
|
+
"Anchor validation failed: #{violations.size} dead link(s)"
|
|
79
|
+
end
|
|
80
|
+
|
|
65
81
|
private
|
|
66
82
|
|
|
67
83
|
# Print the closure report and raise on any unresolved reference.
|
data/lib/suma/cli.rb
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Suma
|
|
4
4
|
module Cli
|
|
5
|
-
autoload :Core,
|
|
5
|
+
autoload :Core, "suma/cli/core"
|
|
6
|
+
autoload :ArtifactServerCli, "suma/cli/artifact_server_cli"
|
|
6
7
|
autoload :Build, "suma/cli/build"
|
|
7
8
|
autoload :CheckSvgQuality, "suma/cli/check_svg_quality"
|
|
8
9
|
autoload :Compare, "suma/cli/compare"
|
|
@@ -11,6 +12,8 @@ module Suma
|
|
|
11
12
|
autoload :ExtractTerms, "suma/cli/extract_terms"
|
|
12
13
|
autoload :GenerateRegister, "suma/cli/generate_register"
|
|
13
14
|
autoload :GenerateSchemas, "suma/cli/generate_schemas"
|
|
15
|
+
autoload :IngestTranslations, "suma/cli/ingest_translations"
|
|
16
|
+
autoload :PackageGcr, "suma/cli/package_gcr"
|
|
14
17
|
autoload :Reformat, "suma/cli/reformat"
|
|
15
18
|
autoload :Resolve, "suma/cli/resolve"
|
|
16
19
|
autoload :Validate, "suma/cli/validate"
|
|
@@ -15,9 +15,11 @@ module Suma
|
|
|
15
15
|
# and inter-schema interfaces.
|
|
16
16
|
#
|
|
17
17
|
# A reference resolves against, in order: the enclosing schema's declared
|
|
18
|
-
# elements, elements of schemas it interfaces to
|
|
19
|
-
# own attributes (for entity-scoped
|
|
20
|
-
# applies-to attributes and variables,
|
|
18
|
+
# elements, elements of schemas it interfaces to (with USE-FROM cascade),
|
|
19
|
+
# the enclosing entity's own and inherited attributes (for entity-scoped
|
|
20
|
+
# expressions), the enclosing rule's applies-to attributes and variables,
|
|
21
|
+
# QUERY-bound variables, and the EXPRESS builtin vocabulary. Attribute
|
|
22
|
+
# accesses (var.attr) are not schema-level references and are excluded.
|
|
21
23
|
#
|
|
22
24
|
# The predicate is zero-tolerance: any unresolved reference is a
|
|
23
25
|
# violation, and `violations.empty?` is the corpus well-formedness gate.
|
|
@@ -56,10 +58,18 @@ module Suma
|
|
|
56
58
|
manifest = Expressir::SchemaManifest.from_file(
|
|
57
59
|
File.expand_path(manifest_file),
|
|
58
60
|
)
|
|
59
|
-
manifest.schemas.
|
|
60
|
-
|
|
61
|
+
parsed = manifest.schemas.map do |entry|
|
|
62
|
+
[entry, Expressir::Express::Parser.from_file(entry.path.to_s)]
|
|
63
|
+
end
|
|
64
|
+
corpus_entities = parsed.flat_map { |_, repo| repo.schemas }
|
|
65
|
+
.flat_map do |schema|
|
|
66
|
+
schema.children.grep(Expressir::Model::Declarations::Entity)
|
|
67
|
+
end
|
|
68
|
+
.to_h { |entity| [entity.id.downcase, entity] }
|
|
69
|
+
|
|
70
|
+
parsed.flat_map do |entry, repo|
|
|
61
71
|
repo.schemas.flat_map do |schema|
|
|
62
|
-
validate_schema(entry.path.to_s, schema)
|
|
72
|
+
validate_schema(entry.path.to_s, schema, corpus_entities)
|
|
63
73
|
end
|
|
64
74
|
end
|
|
65
75
|
end
|
|
@@ -68,10 +78,14 @@ module Suma
|
|
|
68
78
|
#
|
|
69
79
|
# @param file [String] source file path (for diagnostics)
|
|
70
80
|
# @param schema [Expressir::Model::Declarations::Schema]
|
|
81
|
+
# @param corpus_entities [Hash{String=>Entity}] every entity of the
|
|
82
|
+
# corpus by downcased id, for cross-schema attribute inheritance
|
|
71
83
|
# @return [Array<UnresolvedReference>]
|
|
72
|
-
def validate_schema(file, schema)
|
|
84
|
+
def validate_schema(file, schema, corpus_entities = {})
|
|
73
85
|
violations = []
|
|
74
86
|
scope = SchemaScope.new(schema, graph)
|
|
87
|
+
entities = schema.children.grep(Expressir::Model::Declarations::Entity)
|
|
88
|
+
.to_h { |entity| [entity.id.downcase, entity] }
|
|
75
89
|
|
|
76
90
|
schema.interfaces.each do |interface|
|
|
77
91
|
next if graph.schema_exists?(interface.schema.id)
|
|
@@ -83,7 +97,8 @@ module Suma
|
|
|
83
97
|
|
|
84
98
|
schema.children.each do |owner|
|
|
85
99
|
violations.concat(declared_element_references(file, schema.id, owner,
|
|
86
|
-
scope
|
|
100
|
+
scope, entities,
|
|
101
|
+
corpus_entities))
|
|
87
102
|
end
|
|
88
103
|
violations
|
|
89
104
|
end
|
|
@@ -91,8 +106,9 @@ module Suma
|
|
|
91
106
|
private
|
|
92
107
|
|
|
93
108
|
# Type- and structure-position references of one declared element.
|
|
94
|
-
def declared_element_references(file, schema_id, owner, scope
|
|
95
|
-
|
|
109
|
+
def declared_element_references(file, schema_id, owner, scope, entities,
|
|
110
|
+
corpus_entities)
|
|
111
|
+
refs = typed_references(owner, entities, corpus_entities)
|
|
96
112
|
refs.filter_map do |(kind, identifier)|
|
|
97
113
|
if resolvable?(identifier, scope)
|
|
98
114
|
nil
|
|
@@ -107,11 +123,13 @@ module Suma
|
|
|
107
123
|
end
|
|
108
124
|
|
|
109
125
|
# (kind, identifier) pairs from every position of one declaration.
|
|
110
|
-
def typed_references(owner)
|
|
126
|
+
def typed_references(owner, entities, corpus_entities)
|
|
111
127
|
case owner
|
|
112
|
-
when Expressir::Model::Declarations::Entity
|
|
128
|
+
when Expressir::Model::Declarations::Entity
|
|
129
|
+
entity_references(owner, entities, corpus_entities)
|
|
113
130
|
when Expressir::Model::Declarations::Type then type_references(owner)
|
|
114
|
-
when Expressir::Model::Declarations::Rule
|
|
131
|
+
when Expressir::Model::Declarations::Rule
|
|
132
|
+
rule_references(owner)
|
|
115
133
|
when Expressir::Model::Declarations::Constant
|
|
116
134
|
datatype_references(owner.type, "constant")
|
|
117
135
|
when Expressir::Model::Declarations::Function
|
|
@@ -122,9 +140,10 @@ module Suma
|
|
|
122
140
|
end
|
|
123
141
|
end
|
|
124
142
|
|
|
125
|
-
def entity_references(entity)
|
|
143
|
+
def entity_references(entity, entities, corpus_entities)
|
|
144
|
+
locals = attribute_scope(entity, entities, corpus_entities)
|
|
126
145
|
refs = (entity.where_rules || []).flat_map do |rule|
|
|
127
|
-
expression_references(rule.expression, "where")
|
|
146
|
+
expression_references(rule.expression, "where", locals)
|
|
128
147
|
end
|
|
129
148
|
entity.attributes.each do |attribute|
|
|
130
149
|
refs.concat(datatype_references(attribute.type, "attribute"))
|
|
@@ -141,8 +160,11 @@ module Suma
|
|
|
141
160
|
|
|
142
161
|
def rule_references(rule)
|
|
143
162
|
refs = rule.applies_to.map { |ref| ["rule_applies_to", ref.id] }
|
|
163
|
+
locals = Array(rule.variables).filter_map do |variable|
|
|
164
|
+
variable.id&.downcase
|
|
165
|
+
end
|
|
144
166
|
refs += (rule.where_rules || []).flat_map do |where|
|
|
145
|
-
expression_references(where.expression, "where")
|
|
167
|
+
expression_references(where.expression, "where", locals)
|
|
146
168
|
end
|
|
147
169
|
refs += (rule.variables || []).flat_map do |variable|
|
|
148
170
|
datatype_references(variable.type, "variable")
|
|
@@ -162,8 +184,20 @@ module Suma
|
|
|
162
184
|
|
|
163
185
|
def subtype_constraint_references(constraint)
|
|
164
186
|
refs = []
|
|
165
|
-
|
|
166
|
-
|
|
187
|
+
if constraint.applies_to.is_a?(Expressir::Model::References::SimpleReference)
|
|
188
|
+
refs << ["subtype_constraint", constraint.applies_to.id]
|
|
189
|
+
end
|
|
190
|
+
Array(constraint.total_over).each do |ref|
|
|
191
|
+
next unless ref.is_a?(Expressir::Model::References::SimpleReference)
|
|
192
|
+
|
|
193
|
+
refs << ["total_over", ref.id]
|
|
194
|
+
end
|
|
195
|
+
if constraint.supertype_expression
|
|
196
|
+
refs.concat(
|
|
197
|
+
expression_references(constraint.supertype_expression, "supertype"),
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
refs
|
|
167
201
|
end
|
|
168
202
|
|
|
169
203
|
def supertype_references(owner)
|
|
@@ -183,14 +217,84 @@ module Suma
|
|
|
183
217
|
expression_references(node, kind)
|
|
184
218
|
end
|
|
185
219
|
|
|
186
|
-
def expression_references(node, kind)
|
|
220
|
+
def expression_references(node, kind, locals = [])
|
|
221
|
+
scoped = locals + query_variables(node) +
|
|
222
|
+
attribute_access_identifiers(node)
|
|
187
223
|
refs = []
|
|
188
224
|
walk(node) do |child|
|
|
189
|
-
|
|
225
|
+
next unless simple_reference?(child)
|
|
226
|
+
next if child.id == "?"
|
|
227
|
+
next if scoped.include?(child.id.to_s.downcase)
|
|
228
|
+
|
|
229
|
+
refs << [kind, child.id]
|
|
190
230
|
end
|
|
191
231
|
refs
|
|
192
232
|
end
|
|
193
233
|
|
|
234
|
+
# QUERY expressions bind their variable inside the query; collect every
|
|
235
|
+
# bound variable name in the tree so the walk can skip them.
|
|
236
|
+
def query_variables(node)
|
|
237
|
+
found = []
|
|
238
|
+
walk(node) do |child|
|
|
239
|
+
if child.is_a?(Expressir::Model::Expressions::QueryExpression) &&
|
|
240
|
+
child.id
|
|
241
|
+
found << child.id.to_s.downcase
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
found
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Attribute accesses (var.attr, SELF\entity.attr) resolve against the
|
|
248
|
+
# base value's type, not the schema — neither the accessed attribute
|
|
249
|
+
# nor the base identifier is a schema-level reference.
|
|
250
|
+
def attribute_access_identifiers(node)
|
|
251
|
+
found = []
|
|
252
|
+
walk(node) do |child|
|
|
253
|
+
next unless child.is_a?(Expressir::Model::References::AttributeReference)
|
|
254
|
+
|
|
255
|
+
collect_access_identifier(found, child.ref)
|
|
256
|
+
collect_access_identifier(found, child.attribute)
|
|
257
|
+
end
|
|
258
|
+
found
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def collect_access_identifier(found, part)
|
|
262
|
+
return unless part.is_a?(Expressir::Model::References::SimpleReference)
|
|
263
|
+
return unless part.id
|
|
264
|
+
|
|
265
|
+
found << part.id.to_s.downcase
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Bare identifiers in an entity's expressions resolve against the
|
|
269
|
+
# entity's own attributes and those inherited through its supertype
|
|
270
|
+
# chain, following subtypes across schemas when the parent entity is
|
|
271
|
+
# in the corpus (name-keyed; EXPRESS entity names are schema-scoped,
|
|
272
|
+
# but cross-schema parents resolve through interface visibility, so
|
|
273
|
+
# the name is the identity the child's source references).
|
|
274
|
+
def attribute_scope(entity, entities, corpus_entities)
|
|
275
|
+
names = []
|
|
276
|
+
queue = [entity]
|
|
277
|
+
seen = {}
|
|
278
|
+
until queue.empty?
|
|
279
|
+
current = queue.shift
|
|
280
|
+
key = current.id.to_s.downcase
|
|
281
|
+
next if seen[key]
|
|
282
|
+
|
|
283
|
+
seen[key] = true
|
|
284
|
+
Array(current.attributes).each do |attribute|
|
|
285
|
+
names << attribute.id.downcase if attribute.id
|
|
286
|
+
end
|
|
287
|
+
supertype_references(current).each do |(kind, name)|
|
|
288
|
+
next unless kind == "subtype_of"
|
|
289
|
+
|
|
290
|
+
parent = entities[name.downcase] ||
|
|
291
|
+
corpus_entities[name.downcase]
|
|
292
|
+
queue << parent if parent
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
names
|
|
296
|
+
end
|
|
297
|
+
|
|
194
298
|
def simple_reference?(node)
|
|
195
299
|
node.is_a?(Expressir::Model::References::SimpleReference) &&
|
|
196
300
|
!node.id.nil?
|