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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release-provenance.yml +5 -2
  3. data/.rubocop_todo.yml +27 -0
  4. data/CLAUDE.md +17 -5
  5. data/README.adoc +237 -11
  6. data/lib/suma/anchor_validator.rb +85 -0
  7. data/lib/suma/artifact_server.rb +102 -0
  8. data/lib/suma/cli/artifact_server_cli.rb +22 -0
  9. data/lib/suma/cli/build.rb +4 -0
  10. data/lib/suma/cli/core.rb +50 -4
  11. data/lib/suma/cli/export.rb +1 -1
  12. data/lib/suma/cli/extract_terms.rb +1 -1
  13. data/lib/suma/cli/generate_register.rb +1 -1
  14. data/lib/suma/cli/ingest_translations.rb +36 -0
  15. data/lib/suma/cli/package_gcr.rb +51 -0
  16. data/lib/suma/cli/validate.rb +16 -0
  17. data/lib/suma/cli.rb +4 -1
  18. data/lib/suma/closure_validator.rb +124 -20
  19. data/lib/suma/corpus_graph.rb +79 -16
  20. data/lib/suma/gcr_package.rb +230 -0
  21. data/lib/suma/link_validation_result.rb +8 -0
  22. data/lib/suma/link_validator.rb +0 -3
  23. data/lib/suma/output_canonicalizer.rb +107 -0
  24. data/lib/suma/processor.rb +94 -20
  25. data/lib/suma/provenance.rb +92 -12
  26. data/lib/suma/schema_collection.rb +1 -0
  27. data/lib/suma/schema_compiler.rb +79 -13
  28. data/lib/suma/schema_manifest_generator.rb +10 -3
  29. data/lib/suma/schema_profile.rb +12 -0
  30. data/lib/suma/schema_template/anchors.rb +154 -0
  31. data/lib/suma/schema_template/document.rb +2 -93
  32. data/lib/suma/schema_template/plain.rb +10 -6
  33. data/lib/suma/schema_template.rb +1 -0
  34. data/lib/suma/site_build.rb +153 -0
  35. data/lib/suma/staged_collection_builder.rb +11 -3
  36. data/lib/suma/translation_ingester.rb +169 -0
  37. data/lib/suma/urn_resolver.rb +51 -1
  38. data/lib/suma/version.rb +1 -1
  39. data/lib/suma.rb +7 -0
  40. 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
@@ -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 generic)"
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 generic)"
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,
@@ -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 generic)"
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 generic)"
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 generic)"
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
@@ -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, "suma/cli/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, the enclosing entity's
19
- # own attributes (for entity-scoped expressions), the enclosing rule's
20
- # applies-to attributes and variables, and the EXPRESS builtin vocabulary.
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.flat_map do |entry|
60
- repo = Expressir::Express::Parser.from_file(entry.path.to_s)
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
- refs = typed_references(owner)
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 then entity_references(owner)
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 then rule_references(owner)
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
- refs << ["subtype_constraint", constraint.entity.id] if constraint.entity
166
- refs.concat(supertype_references(constraint))
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
- refs << [kind, child.id] if simple_reference?(child)
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?