edoxen 0.1.1 → 0.3.0

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.
data/lib/edoxen/cli.rb ADDED
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "fileutils"
5
+
6
+ module Edoxen
7
+ # Thor command-line surface for the gem. Two responsibilities:
8
+ # * `validate PATTERN` — runs both JSON-Schema validation and the
9
+ # Ruby model parser against each matching YAML file.
10
+ # * `normalize PATTERN (--output DIR | --inplace)` — round-trips each
11
+ # matching YAML file through the Ruby model, preserving any
12
+ # `# yaml-language-server: $schema=...` directive on the first line.
13
+ #
14
+ # The CLI deliberately does NOT own schema-or-model decisions — those
15
+ # live in `SchemaValidator` and `Lutaml::Model` respectively. It only
16
+ # glues them together and formats output.
17
+ class Cli < Thor
18
+ package_name "edoxen"
19
+
20
+ desc "validate YAML_FILE_PATTERN",
21
+ "Validate one or more Edoxen YAML files against the schema and the model."
22
+
23
+ def validate(pattern)
24
+ files = expand_yaml_pattern(pattern)
25
+ if files.empty?
26
+ say "No files found matching pattern: #{pattern}", :red
27
+ exit 1
28
+ end
29
+
30
+ say "🔍 Validating #{files.size} file(s)...", :blue
31
+
32
+ validator = SchemaValidator.new
33
+ valid_count = 0
34
+ invalid_count = 0
35
+
36
+ files.each do |file|
37
+ print " #{File.basename(file)}... "
38
+
39
+ schema_errors = validator.validate_file(file)
40
+ model_errors = collect_model_errors(file)
41
+
42
+ if schema_errors.empty? && model_errors.empty?
43
+ say "✅ VALID", :green
44
+ valid_count += 1
45
+ else
46
+ say "❌ INVALID", :red
47
+ invalid_count += 1
48
+ schema_errors.each { |e| say " #{e.to_clickable_format}", :red }
49
+ model_errors.each { |m| say " #{file}:1:1: #{m}", :red }
50
+ end
51
+ end
52
+
53
+ print_summary(files.size, valid_count, invalid_count, validator_type: :binary)
54
+ exit(invalid_count.positive? ? 1 : 0)
55
+ end
56
+
57
+ desc "normalize YAML_FILE_PATTERN",
58
+ "Round-trip YAML file(s) through the Edoxen model (--output DIR or --inplace)."
59
+
60
+ option :output, type: :string, desc: "Output directory for normalized files"
61
+ option :inplace, type: :boolean, desc: "Modify files in place (no backup)"
62
+
63
+ def normalize(pattern)
64
+ if options[:output] && options[:inplace]
65
+ say "Error: Cannot use both --output and --inplace options", :red
66
+ exit 1
67
+ end
68
+
69
+ unless options[:output] || options[:inplace]
70
+ say "Error: Must specify either --output or --inplace option", :red
71
+ exit 1
72
+ end
73
+
74
+ files = expand_yaml_pattern(pattern)
75
+ if files.empty?
76
+ say "No files found matching pattern: #{pattern}", :red
77
+ exit 1
78
+ end
79
+
80
+ say "🔄 Normalizing #{files.size} file(s)...", :blue
81
+
82
+ success_count = 0
83
+ error_count = 0
84
+
85
+ files.each do |file|
86
+ print " #{File.basename(file)}... "
87
+ begin
88
+ yaml_language_server_comment = extract_yaml_language_server_comment(File.read(file))
89
+ normalized = ResolutionCollection.from_yaml(File.read(file)).to_yaml
90
+ normalized = "#{yaml_language_server_comment}\n#{normalized}" if yaml_language_server_comment
91
+
92
+ if options[:inplace]
93
+ File.write(file, normalized)
94
+ say "✅ NORMALIZED", :green
95
+ else
96
+ out = File.join(options[:output], File.basename(file))
97
+ FileUtils.mkdir_p(File.dirname(out))
98
+ File.write(out, normalized)
99
+ say "✅ NORMALIZED → #{out}", :green
100
+ end
101
+ success_count += 1
102
+ rescue StandardError => e
103
+ say "❌ FAILED — #{e.message}", :red
104
+ error_count += 1
105
+ end
106
+ end
107
+
108
+ print_summary(files.size, success_count, error_count, validator_type: :lax,
109
+ extra: [
110
+ [" Output directory", options[:output]],
111
+ [" Mode", options[:inplace] ? "in place" : "--output"]
112
+ ].compact)
113
+ exit(error_count.positive? ? 1 : 0)
114
+ end
115
+
116
+ no_commands do
117
+ # Reserved for private Thor plumbing if we add it later.
118
+ end
119
+
120
+ private
121
+
122
+ def expand_yaml_pattern(pattern)
123
+ Dir.glob(pattern).select { |f| File.file?(f) && f.match?(/\.ya?ml\z/i) }.sort
124
+ end
125
+
126
+ # Round-trip the file through the model to catch structural issues
127
+ # (missing nested classes, type mismatches) that the JSON-Schema can't
128
+ # express. The model is permissive about field names — schema is the
129
+ # strict source.
130
+ def collect_model_errors(file)
131
+ ResolutionCollection.from_yaml(File.read(file))
132
+ []
133
+ rescue StandardError => e
134
+ ["Model parsing failed: #{e.message}"]
135
+ end
136
+
137
+ def extract_yaml_language_server_comment(content)
138
+ lines = content.split("\n").first(5)
139
+ lines.find { |l| l.strip.match?(/\A#\s*yaml-language-server:\s*\$schema=/) }&.rstrip
140
+ end
141
+
142
+ def print_summary(total, ok_count, bad_count, validator_type:, extra: [])
143
+ say "\n📊 Summary:", :blue
144
+ say " Total: #{total}", :blue
145
+ label_text = if validator_type == :binary
146
+ " Valid: #{ok_count}, Invalid: #{bad_count}"
147
+ else
148
+ " Success: #{ok_count}, Failed: #{bad_count}"
149
+ end
150
+ say label_text, bad_count.positive? ? :red : :green
151
+ success_rate = total.zero? ? 0 : ((ok_count.to_f / total) * 100).round(1)
152
+ say " Success rate: #{success_rate}%", :blue
153
+ extra.each { |label, value| say " #{label}: #{value}", :blue }
154
+ end
155
+ end
156
+ end
@@ -1,47 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Consideration {
4
- # type: ConsiderationType
5
- # dateEffective: Date
6
- # message: Text
7
- # }
8
-
9
- # enum ConsiderationType {
10
- # having / having regard
11
- # noting
12
- # recognizing
13
- # acknowledging
14
- # recalling / further recalling
15
- # reaffirming
16
- # considering
17
- # taking into account
18
- # pursuant to
19
- # bearing in mind
20
- # emphasizing
21
- # concerned
22
- # accepts
23
- # observing
24
- # referring
25
- # acting
26
- # empowers
27
- # reaffirming
28
- # }
29
-
30
- require "lutaml/model"
31
-
32
3
  module Edoxen
4
+ # Basis for a resolution: a verb (having, noting, considering, ...) plus
5
+ # one effective date and the elaborated reasoning.
33
6
  class Consideration < Lutaml::Model::Serializable
34
- CONSIDERATION_TYPE_ENUM = %w[having noting recognizing acknowledging recalling reaffirming considering
35
- taking-into-account pursuant-to bearing-in-mind emphasizing concerned accepts observing
36
- referring acting empowers reaffirming].freeze
37
-
38
- attribute :type, :string, values: CONSIDERATION_TYPE_ENUM
39
- attribute :date, :date
7
+ attribute :type, :string, values: Enums::CONSIDERATION_TYPE
8
+ attribute :date_effective, ResolutionDate
40
9
  attribute :message, :string
41
10
 
42
11
  key_value do
43
12
  map "type", to: :type
44
- map "date", to: :date
13
+ map "date_effective", to: :date_effective
45
14
  map "message", to: :message
46
15
  end
47
16
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Single source of truth for every enum used by the Edoxen information model.
5
+ #
6
+ # Mirrors ../edoxen-model/models/*.lutaml, deduped.
7
+ # Both:
8
+ # * Ruby model attribute declarations (`attribute :type, :string, values: Enums::ACTION_TYPE`)
9
+ # * JSON-Schema (`schema/edoxen.yaml`)
10
+ # reference these constants.
11
+ #
12
+ # The schema <-> Ruby enum-sync spec asserts the YAML schema's enum arrays
13
+ # equal these arrays. If you change a constant here, change the schema in
14
+ # the same PR.
15
+ module Enums
16
+ ACTION_TYPE = %w[
17
+ adopts thanks approves decides declares asks invites
18
+ resolves confirms welcomes recommends requests congratulates
19
+ instructs urges appoints calls-upon encourages affirms elects
20
+ authorizes charges states remarks judges sanctions abrogates empowers
21
+ ].freeze
22
+
23
+ CONSIDERATION_TYPE = %w[
24
+ having noting recognizing acknowledging recalling reaffirming
25
+ considering taking-into-account pursuant-to bearing-in-mind
26
+ emphasizing concerned accepts observing referring acting empowers
27
+ ].freeze
28
+
29
+ RESOLUTION_TYPE = %w[resolution recommendation decision declaration].freeze
30
+
31
+ RESOLUTION_RELATION_TYPE = %w[
32
+ annexOf hasAnnex updates refines replaces considers
33
+ ].freeze
34
+
35
+ RESOLUTION_DATE_TYPE = %w[adoption drafted discussed].freeze
36
+
37
+ APPROVAL_TYPE = %w[affirmative negative].freeze
38
+
39
+ APPROVAL_DEGREE = %w[unanimous majority minority].freeze
40
+
41
+ URL_KIND = %w[access report].freeze
42
+ end
43
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Base class for any Edoxen-level error. Reserved for raise-on-failure paths;
5
+ # schema validation errors live under SchemaValidator::ValidationError.
6
+ class Error < StandardError; end
7
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # A monolingual rendering of a Resolution. Mirrors the glossarist
5
+ # LocalizedConcept pattern: language-agnostic fields live on the
6
+ # parent Resolution; per-language content lives here.
7
+ class Localization < Lutaml::Model::Serializable
8
+ attribute :language_code, :string
9
+ attribute :script, :string
10
+ attribute :title, :string
11
+ attribute :subject, :string
12
+ attribute :message, :string
13
+ attribute :considering, :string
14
+ attribute :considerations, Consideration, collection: true
15
+ attribute :approvals, Approval, collection: true
16
+ attribute :actions, Action, collection: true
17
+
18
+ key_value do
19
+ map "language_code", to: :language_code
20
+ map "script", to: :script
21
+ map "title", to: :title
22
+ map "subject", to: :subject
23
+ map "message", to: :message
24
+ map "considering", to: :considering
25
+ map "considerations", to: :considerations
26
+ map "approvals", to: :approvals
27
+ map "actions", to: :actions
28
+ end
29
+ end
30
+ end
@@ -1,12 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # MeetingIdentifier {
4
- # venue: String
5
- # date: DateTime
6
- # }
7
- require "lutaml/model"
8
-
9
3
  module Edoxen
4
+ # Identifier of a meeting (venue + date). Singular — the meeting a
5
+ # particular Resolution belongs to.
10
6
  class MeetingIdentifier < Lutaml::Model::Serializable
11
7
  attribute :venue, :string
12
8
  attribute :date, :date
@@ -1,61 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "lutaml/model"
4
-
5
3
  module Edoxen
4
+ # A formal Resolution. Language-agnostic admin fields live here; every
5
+ # translatable field is wrapped inside `localizations[]` (one entry per
6
+ # available language; at least one is required by the schema).
6
7
  class Resolution < Lutaml::Model::Serializable
7
- RESOLUTION_TYPE_ENUM = %w[resolution recommendation decision declaration].freeze
8
-
9
- attribute :category, :string
10
- attribute :dates, :date, collection: true
11
- attribute :subject, :string
12
- attribute :title, :string
13
- attribute :type, :string, values: RESOLUTION_TYPE_ENUM
14
- attribute :identifier, :string
15
- attribute :considerations, Consideration, collection: true
16
- attribute :approvals, Approval, collection: true
17
- attribute :actions, Action, collection: true
8
+ attribute :identifier, StructuredIdentifier, collection: true
9
+ attribute :type, :string, values: Enums::RESOLUTION_TYPE
10
+ attribute :doi, :string
11
+ attribute :urn, :string
12
+ attribute :agenda_item, :string
13
+ attribute :dates, ResolutionDate, collection: true
14
+ attribute :categories, :string, collection: true
15
+ attribute :meeting, MeetingIdentifier
16
+ attribute :relations, ResolutionRelation, collection: true
17
+ attribute :urls, Url, collection: true
18
+ attribute :localizations, Localization, collection: true
18
19
 
19
20
  key_value do
20
- map "category", to: :category
21
- map "dates", to: :dates
22
- map "subject", to: :subject
23
- map "title", to: :title
24
- map "type", to: :type
25
21
  map "identifier", to: :identifier
26
- map "considerations", to: :considerations
27
- map "approvals", to: :approvals
28
- map "actions", to: :actions
22
+ map "type", to: :type
23
+ map "doi", to: :doi
24
+ map "urn", to: :urn
25
+ map "agenda_item", to: :agenda_item
26
+ map "dates", to: :dates
27
+ map "categories", to: :categories
28
+ map "meeting", to: :meeting
29
+ map "relations", to: :relations
30
+ map "urls", to: :urls
31
+ map "localizations", to: :localizations
29
32
  end
30
-
31
- # Example of a Resolution
32
- # category: Resolutions related to JWG 1
33
- # dates:
34
- # - 2019-10-17
35
- # subject: ISO/TC 154
36
- # title: "Adoption of NWIP ballot for ISO/PWI 9735-11 "Electronic data..."
37
- # identifier: 2019-01
38
- # considerations:
39
- # - type: considering
40
- # date_effective: 2019-10-17
41
- # message: considering the voting result ...
42
-
43
- # - type: considering
44
- # date_effective: 2019-10-17
45
- # message: considering the importance of ...
46
-
47
- # - type: considering
48
- # date_effective: 2019-10-17
49
- # message: considering the request from JWG1...
50
-
51
- # approvals:
52
- # - type: affirmative
53
- # degree: unanimous
54
- # message: The resolution was taken by unanimity.
55
-
56
- # actions:
57
- # - type: resolves
58
- # date_effective: 2019-10-17
59
- # message: resolves to submit ISO 9735-11...
60
33
  end
61
34
  end
@@ -1,25 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "lutaml/model"
4
-
5
3
  module Edoxen
4
+ # Top-level container for a published resolution collection: metadata
5
+ # plus the list of resolutions.
6
6
  class ResolutionCollection < Lutaml::Model::Serializable
7
- attribute :metadata, :hash
7
+ attribute :metadata, ResolutionMetadata
8
8
  attribute :resolutions, Resolution, collection: true
9
9
 
10
10
  key_value do
11
11
  map "metadata", to: :metadata
12
12
  map "resolutions", to: :resolutions
13
13
  end
14
-
15
- # Example of a ResolutionCollection
16
- # metadata:
17
- # title: Resolutions of the 38th plenary meeting of ISO/TC 154
18
- # date: 2019-10-17
19
- # source: ISO/TC 154 Secretariat
20
- # resolutions:
21
- # - category: Resolutions related to JWG 1
22
- # dates: 2019/10/17
23
- # ...
24
14
  end
25
15
  end
@@ -1,33 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # class ResolutionRelation {
4
- # source: StructuredIdentifier (Resolution)
5
- # destination: StructuredIdentifier (Resolution)
6
- # type: ResolutionRelationType
7
- # }
3
+ module Edoxen
4
+ # Date with semantic kind (adoption, drafted, discussed).
5
+ # ResolutionDate is the only carrier of a *typed* date in the model —
6
+ # plain `Date` in lutaml-model has no semantic context.
7
+ class ResolutionDate < Lutaml::Model::Serializable
8
+ attribute :date, :date
9
+ attribute :type, :string, values: Enums::RESOLUTION_DATE_TYPE
8
10
 
9
- # enum ResolutionRelationType {
10
- # annexOf {
11
- # This resolution is an annex to the target resolution.
12
- # }
13
-
14
- # hasAnnex {
15
- # The target resolution is an annex of the source resolution.
16
- # }
17
-
18
- # updates {
19
- # This resolution updates the target resolution.
20
- # }
21
-
22
- # refines {
23
- # This resolution refines the target resolution.
24
- # }
25
-
26
- # replaces/obsoletes {
27
- # This resolution replaces/obsoletes the target resolution.
28
- # }
29
-
30
- # considers {
31
- # This resolution is made in consideration of the target resolution.
32
- # }
33
- # }
11
+ key_value do
12
+ map "date", to: :date
13
+ map "type", to: :type
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Collection-level metadata: the title (string for default / single-language
5
+ # collections, or `title_localized[]` for multilingual), the meeting date,
6
+ # the source secretariat, per-language source PDFs, and the host venue.
7
+ class ResolutionMetadata < Lutaml::Model::Serializable
8
+ attribute :title, :string
9
+ attribute :title_localized, Localization, collection: true
10
+ attribute :date, :date
11
+ attribute :source, :string
12
+ attribute :source_urls, SourceUrl, collection: true
13
+ attribute :city, :string
14
+ attribute :country_code, :string
15
+
16
+ key_value do
17
+ map "title", to: :title
18
+ map "title_localized", to: :title_localized
19
+ map "date", to: :date
20
+ map "source", to: :source
21
+ map "source_urls", to: :source_urls
22
+ map "city", to: :city
23
+ map "country_code", to: :country_code
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Directed relation between two resolutions, identified by their
5
+ # StructuredIdentifier (prefix + number).
6
+ class ResolutionRelation < Lutaml::Model::Serializable
7
+ attribute :source, StructuredIdentifier
8
+ attribute :destination, StructuredIdentifier
9
+ attribute :type, :string, values: Enums::RESOLUTION_RELATION_TYPE
10
+
11
+ key_value do
12
+ map "source", to: :source
13
+ map "destination", to: :destination
14
+ map "type", to: :type
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+ require_relative "metadata"
5
+ require_relative "resolution"
6
+
7
+ module Edoxen
8
+ class ResolutionSet < Lutaml::Model::Serializable
9
+ attribute :metadata, Metadata
10
+ attribute :resolutions, Resolution, collection: true
11
+
12
+ key_value do
13
+ map "metadata", to: :metadata
14
+ map "resolutions", to: :resolutions
15
+ end
16
+ end
17
+ end