ecoportal-api-graphql 1.3.10 → 1.3.11

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.ai-assistance/code/diff_pairing_engine.md +243 -0
  3. data/.ai-assistance/code/graphql_domain_knowledge.md +20 -10
  4. data/.ai-assistance/code/template_diff_pairing_domain.md +175 -0
  5. data/.ai-assistance/code/workflow-command-guide.md +28 -0
  6. data/.ai-assistance/projects/ooze-graphql-native-migration/INVENTORY.md +136 -0
  7. data/.ai-assistance/projects/ooze-graphql-native-migration/TODO.md +6 -1
  8. data/.ai-assistance/projects/qa-services-delivery/DECISIONS.md +93 -0
  9. data/.ai-assistance/projects/qa-services-delivery/INTENT.md +76 -0
  10. data/.ai-assistance/projects/qa-services-delivery/PHASE3-SCOPE.md +115 -0
  11. data/.ai-assistance/projects/qa-services-delivery/ROADMAP.md +99 -0
  12. data/.ai-assistance/projects/qa-services-delivery/TODO.md +81 -0
  13. data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +77 -0
  14. data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +97 -0
  15. data/.ai-assistance/projects/template-diff-deploy/INTENT.md +12 -0
  16. data/.ai-assistance/projects/template-diff-deploy/TODO.md +9 -0
  17. data/.ai-assistance/projects/template-maintenance/PHASE0-FINDINGS.md +93 -0
  18. data/.ai-assistance/projects/template-maintenance/README.md +14 -0
  19. data/CHANGELOG.md +87 -0
  20. data/docs/worklog.md +279 -0
  21. data/ecoportal-api-graphql.gemspec +1 -1
  22. data/lib/ecoportal/api/graphql/base/page/data_field.rb +1 -1
  23. data/lib/ecoportal/api/graphql/builder/template_builder.rb +174 -0
  24. data/lib/ecoportal/api/graphql/builder.rb +17 -16
  25. data/lib/ecoportal/api/graphql/diff/change.rb +59 -0
  26. data/lib/ecoportal/api/graphql/diff/command_synthesizer.rb +329 -0
  27. data/lib/ecoportal/api/graphql/diff/cross_object_diff.rb +165 -0
  28. data/lib/ecoportal/api/graphql/diff/deploy.rb +121 -0
  29. data/lib/ecoportal/api/graphql/diff/id_resolver.rb +64 -0
  30. data/lib/ecoportal/api/graphql/diff/pairing/candidate.rb +32 -0
  31. data/lib/ecoportal/api/graphql/diff/pairing/engine.rb +173 -0
  32. data/lib/ecoportal/api/graphql/diff/pairing/ledger.rb +119 -0
  33. data/lib/ecoportal/api/graphql/diff/pairing/signals.rb +104 -0
  34. data/lib/ecoportal/api/graphql/diff/strategy.rb +113 -0
  35. data/lib/ecoportal/api/graphql/diff/version_diff.rb +332 -0
  36. data/lib/ecoportal/api/graphql/diff.rb +34 -0
  37. data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +1 -0
  38. data/lib/ecoportal/api/graphql/input/workflow_command/add_field.rb +27 -18
  39. data/lib/ecoportal/api/graphql/mutation/action/archive.rb +1 -1
  40. data/lib/ecoportal/api/graphql/mutation/action/create.rb +1 -1
  41. data/lib/ecoportal/api/graphql/mutation/action/update.rb +1 -1
  42. data/lib/ecoportal/api/graphql/mutation/contractor_entity/create.rb +1 -1
  43. data/lib/ecoportal/api/graphql/mutation/contractor_entity/destroy.rb +1 -1
  44. data/lib/ecoportal/api/graphql/mutation/contractor_entity/update.rb +1 -1
  45. data/lib/ecoportal/api/graphql/mutation/kickstand/fail_workflow.rb +1 -1
  46. data/lib/ecoportal/api/graphql/mutation/kickstand/start_workflow.rb +1 -1
  47. data/lib/ecoportal/api/graphql/mutation/kickstand/stop_workflow.rb +1 -1
  48. data/lib/ecoportal/api/graphql.rb +1 -0
  49. data/lib/ecoportal/api/graphql_version.rb +1 -1
  50. data/tests/dump_template_model.rb +90 -0
  51. data/tests/validate_queries.rb +31 -9
  52. metadata +31 -3
@@ -0,0 +1,104 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Diff
5
+ module Pairing
6
+ # Weak pairing SIGNALS combined into a confidence score. Each signal scores a candidate
7
+ # (source-field-doc, target-field-doc) pair in 0.0..1.0. NONE is ground truth on its own —
8
+ # per the domain reference genomeSignature is strong-but-fallible (6 documented failure
9
+ # modes: manual/no-platform-support, new features never added, wrong for select options,
10
+ # wrongly syncs checklists, re-used/re-purposed fields, incomplete data-loss accounting).
11
+ # So genome is ONE signal among several, weighted highest but able to be outvoted /
12
+ # confirmed by type+label+options. The Engine aggregates these with `WEIGHTS`.
13
+ module Signals
14
+ module_function
15
+
16
+ # Relative weights (need not sum to 1; the Engine normalises by the weights that applied).
17
+ WEIGHTS = {
18
+ genome: 0.5,
19
+ type: 0.2,
20
+ label: 0.2,
21
+ options: 0.1
22
+ }.freeze
23
+
24
+ # genomeSignature match. Strong but fallible, so a MATCH scores high (not 1.0) and a
25
+ # MISMATCH scores 0 rather than vetoing (the field may have been re-purposed keeping its
26
+ # genome, or genome may be absent on newer field types). Returns nil when neither side
27
+ # carries a genome — the signal simply does not apply and is excluded from the average.
28
+ def genome(source, target)
29
+ a = source['genomeSignature']
30
+ b = target['genomeSignature']
31
+ return nil if blank?(a) && blank?(b)
32
+ return 0.0 if blank?(a) || blank?(b)
33
+
34
+ a == b ? 1.0 : 0.0
35
+ end
36
+
37
+ # Field type (__typename) equality. A type change is a strong DISQUALIFIER for a data
38
+ # pairing (you cannot faithfully migrate PlainText data into a Gauge), so a mismatch is 0.
39
+ def type(source, target)
40
+ ta = type_of(source)
41
+ tb = type_of(target)
42
+ return nil if ta.nil? && tb.nil?
43
+
44
+ ta == tb ? 1.0 : 0.0
45
+ end
46
+
47
+ # Label similarity — exact (case/space-insensitive) = 1.0, else a token-overlap
48
+ # (Jaccard) score, so `Date logged` vs `Date of sign-off` still gets partial credit
49
+ # (a re-purpose candidate the human should adjudicate rather than an auto-accept).
50
+ def label(source, target)
51
+ a = normalise(source['label'])
52
+ b = normalise(target['label'])
53
+ return nil if a.empty? && b.empty?
54
+ return 1.0 if a == b && !a.empty?
55
+
56
+ jaccard(a.split, b.split)
57
+ end
58
+
59
+ # Select-option overlap by VALUE (primary) / label (auxiliary) — the CORRECT option
60
+ # identity, never genome. Returns nil for non-select fields (no options either side).
61
+ def options(source, target)
62
+ a = option_keys(source)
63
+ b = option_keys(target)
64
+ return nil if a.empty? && b.empty?
65
+
66
+ jaccard(a, b)
67
+ end
68
+
69
+ def type_of(field)
70
+ field['__typename'] || field['type']
71
+ end
72
+
73
+ def normalise(value)
74
+ value.to_s.strip.downcase.gsub(/\s+/, ' ')
75
+ end
76
+
77
+ def option_keys(field)
78
+ Array(field['options']).filter_map do |o|
79
+ next unless o.is_a?(Hash)
80
+
81
+ normalise(o['value'] || o['label'] || o['name'])
82
+ end.reject(&:empty?)
83
+ end
84
+
85
+ # Jaccard similarity of two token collections (|∩| / |∪|). 0.0 when both empty.
86
+ def jaccard(list_a, list_b)
87
+ set_a = list_a.to_a.uniq
88
+ set_b = list_b.to_a.uniq
89
+ return 0.0 if set_a.empty? && set_b.empty?
90
+
91
+ inter = (set_a & set_b).size.to_f
92
+ union = (set_a | set_b).size
93
+ union.zero? ? 0.0 : inter / union
94
+ end
95
+
96
+ def blank?(value)
97
+ value.nil? || value.to_s.strip.empty?
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,113 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Diff
5
+ # Composable configuration for a diff. There is NO single diff — different processes need
6
+ # different modes (see `template_diff_pairing_domain.md` §8). A `Strategy` is the value object
7
+ # that names the axes, consumed by the diff front-end (`VersionDiff` / `CrossObjectDiff`) and
8
+ # by `Deploy`.
9
+ #
10
+ # Axes:
11
+ #
12
+ # * **pairing** — how counterpart objects are identified across the two snapshots:
13
+ # :id same object, retained Mongo ids (self-version) — NO equivalence needed;
14
+ # :genome pair by genomeSignature (cross-object; a strong-but-fallible signal);
15
+ # :type_label pair by __typename + label (cross-object; the TypedFieldsPairing precedent);
16
+ # :assisted the full multi-signal `Pairing::Engine` (+ `Ledger`), human-in-the-loop
17
+ # for ambiguous/unmatched — the general cross-object case.
18
+ # `:id` is the default and keeps the existing self-version behaviour untouched.
19
+ #
20
+ # * **scope** — which change kinds are emitted:
21
+ # :structural stages / sections / fields (add/remove/move/relabel/retype) + options + gauge;
22
+ # :config_only field configuration (byType), select options, gauge stops — NOT structure;
23
+ # :data_migration field<->field data pairing only (structure-agnostic): pairings + type/label
24
+ # changes on paired fields, no add/remove of scaffolding.
25
+ #
26
+ # * **move_sensitive** — when false, section/stage MOVES are suppressed (a section-agnostic
27
+ # diff that only cares about a field's data pairing, not where it now lives).
28
+ #
29
+ # * **intent** — the consuming process (documentation only; does not alter emission, but lets a
30
+ # caller/`Deploy` branch on it):
31
+ # :changelog what changed, for a ticket / review checklist;
32
+ # :deploy UAT->PROD replay delta (drives placeholderId threading ON in Deploy);
33
+ # :sync_readiness can this register/subset be synced to the active template.
34
+ #
35
+ # Cross-object pairings (:genome/:type_label/:assisted) require a pairing map to translate the
36
+ # two id-spaces before change emission — see `CrossObjectDiff`. `:id` needs no map.
37
+ class Strategy
38
+ PAIRINGS = %i[id genome type_label assisted].freeze
39
+ SCOPES = %i[structural config_only data_migration].freeze
40
+ INTENTS = %i[changelog deploy sync_readiness].freeze
41
+
42
+ # Change KINDS considered structure vs configuration — used to filter by `scope`.
43
+ STRUCTURAL_KINDS = %i[stage section field].freeze
44
+ CONFIG_KINDS = %i[field_config option gauge_stop].freeze
45
+
46
+ attr_reader :pairing, :scope, :intent
47
+
48
+ def initialize(pairing: :id, scope: :structural, move_sensitive: true, intent: :changelog)
49
+ @pairing = validate!(:pairing, pairing, PAIRINGS)
50
+ @scope = validate!(:scope, scope, SCOPES)
51
+ @move_sensitive = move_sensitive ? true : false
52
+ @intent = validate!(:intent, intent, INTENTS)
53
+ end
54
+
55
+ # The default self-version strategy (id-paired, structural, move-aware, changelog). This is
56
+ # exactly the behaviour `VersionDiff` had before Phase 4 — used when no strategy is passed.
57
+ def self.default
58
+ new
59
+ end
60
+
61
+ def move_sensitive?
62
+ @move_sensitive
63
+ end
64
+
65
+ # True when pairing does NOT rely on retained ids — the two snapshots live in different
66
+ # id-spaces and need a pairing map (`CrossObjectDiff`) before change emission.
67
+ def cross_object?
68
+ @pairing != :id
69
+ end
70
+
71
+ # Should a change of this KIND be emitted under this scope?
72
+ def emit_kind?(kind)
73
+ case @scope
74
+ when :structural then true
75
+ when :config_only then CONFIG_KINDS.include?(kind)
76
+ when :data_migration then kind == :field # field-level data pairing only
77
+ end
78
+ end
79
+
80
+ # Should a :moved change be emitted? Suppressed when move-insensitive, or under a scope that
81
+ # ignores scaffolding placement (config_only / data_migration never care about moves).
82
+ def emit_move?
83
+ move_sensitive? && @scope == :structural
84
+ end
85
+
86
+ # Filter a raw change-set to what this strategy emits (scope + move-sensitivity). The diff
87
+ # front-ends compute the full change-set once and let the strategy decide what survives.
88
+ def filter(changes)
89
+ Array(changes).select do |change|
90
+ next false unless emit_kind?(change.kind)
91
+ next false if change.op == :moved && !emit_move?
92
+
93
+ true
94
+ end
95
+ end
96
+
97
+ def to_h
98
+ { pairing: @pairing, scope: @scope, move_sensitive: move_sensitive?, intent: @intent }
99
+ end
100
+
101
+ private
102
+
103
+ def validate!(axis, value, allowed)
104
+ sym = value&.to_sym
105
+ return sym if allowed.include?(sym)
106
+
107
+ raise ArgumentError, "invalid #{axis}: #{value.inspect} (expected one of #{allowed.inspect})"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,332 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Diff
5
+ # Self-version diff: two snapshots of the SAME page/template (same Mongo ids).
6
+ #
7
+ # Because the object keeps its ids across versions, pairing is EXACT (match by id) — no
8
+ # equivalence matching needed. This is the "what did this ticket change to the UAT template"
9
+ # tool, and the basis of a portable changelog ("commit") that — once id-translated through a
10
+ # pairing map — can be replayed onto PROD. Cross-object diff (UAT<->PROD, page<->template)
11
+ # is a different, harder problem handled by the pairing engine (not here).
12
+ #
13
+ # Operates on the raw page/template doc (the JSON the gem returns for a page/template), so it
14
+ # is a shared, session-less capability usable by admin/troubleshooting/integration/QA alike.
15
+ #
16
+ # diff = Diff::VersionDiff.new(before_doc, after_doc)
17
+ # diff.changes # => [Change, ...]
18
+ # diff.summary # => { added:, removed:, changed:, moved:, total: }
19
+ # diff.changelog # => ["~ field 'Severity' ...", ...]
20
+ #
21
+ # MODALITIES — pass a `Diff::Strategy` to select a scope / move-sensitivity. The default
22
+ # strategy (id-paired, structural, move-aware, changelog) reproduces the pre-Phase-4 behaviour
23
+ # exactly, so `VersionDiff.new(before, after).changes` is unchanged. A narrower scope
24
+ # (`:config_only`, `:data_migration`) or a move-insensitive strategy filters the SAME computed
25
+ # change-set — the pairing is still by-id here (`VersionDiff` is the `:id` pairing front-end).
26
+ # Cross-object pairings (:genome/:type_label/:assisted) are handled by `CrossObjectDiff`.
27
+ class VersionDiff
28
+ # Typed field-configuration properties that this diff EMITS, keyed by the field's
29
+ # `__typename`. Each entry maps a `byType` input key (the sub-hash the schema's
30
+ # `editFieldConfiguration.byType` accepts) to the list of doc property names that make up
31
+ # that sub-hash. Only pairs where the READ property name matches the WRITE (byType) input
32
+ # key EXACTLY are listed here — this diff never fabricates a config change it cannot map
33
+ # back to a real command input.
34
+ #
35
+ # Deliberately conservative: e.g. PlainText carries `multiline` in the read model but the
36
+ # `plainText` byType input has no `multiline` key (it exposes `maxLength`), so PlainText
37
+ # config is NOT emitted here (it would be unmappable). Likewise `required` has no
38
+ # editFieldConfiguration key at all, so a `required` flip is not a config change we emit.
39
+ BYTYPE_CONFIG = {
40
+ 'Gauge' => { gauge: %w[max] },
41
+ 'Select' => { select: %w[dataType multiple flat other otherDesc] },
42
+ 'Date' => { date: %w[showTime pastOnly todayButton] }
43
+ }.freeze
44
+
45
+ def initialize(before_doc, after_doc, strategy: Strategy.default)
46
+ @before = before_doc || {}
47
+ @after = after_doc || {}
48
+ @strategy = strategy || Strategy.default
49
+ end
50
+
51
+ # The change-set, filtered by the strategy's scope + move-sensitivity. The full set is
52
+ # computed once (`all_changes`); the strategy decides what survives. The default strategy
53
+ # keeps everything (the historical behaviour).
54
+ def changes
55
+ @changes ||= @strategy.filter(all_changes)
56
+ end
57
+
58
+ def summary
59
+ by_op = changes.group_by(&:op)
60
+ {
61
+ added: by_op.fetch(:added, []).size,
62
+ removed: by_op.fetch(:removed, []).size,
63
+ changed: by_op.fetch(:changed, []).size,
64
+ moved: by_op.fetch(:moved, []).size,
65
+ total: changes.size
66
+ }
67
+ end
68
+
69
+ def changelog
70
+ changes.map(&:description)
71
+ end
72
+
73
+ def to_h
74
+ { summary: summary, changes: changes.map(&:to_h) }
75
+ end
76
+
77
+ private
78
+
79
+ # The complete id-paired change-set (all kinds, all ops) before any strategy filtering.
80
+ def all_changes
81
+ stage_changes + section_changes + field_changes + option_changes +
82
+ field_config_changes + gauge_stop_changes
83
+ end
84
+
85
+ # Generic set-diff over an index {id => info}. Emits added/removed (label/path pulled from
86
+ # info via the given procs) and delegates matched-id comparison to the block.
87
+ #
88
+ # `parent:` (optional) is a proc mapping an entry's info to the id of its OWNING node — the
89
+ # section id for a field, the stage id for a section. Recorded on ADDED changes as
90
+ # `parent_id` so the synthesiser can wire the structural back-ref (addField.sectionId /
91
+ # addStageSection.stageId), threading through a placeholder when the parent is created in
92
+ # the same batch. nil (default) means no parent id is known/recorded — the historical
93
+ # behaviour, unchanged.
94
+ def diff_index(kind, before_idx, after_idx, label:, path:, parent: nil, owner: nil, &compare)
95
+ procs = { label: label, path: path, parent: parent, owner: owner }
96
+ removed = (before_idx.keys - after_idx.keys).map { |id| removed_change(kind, id, before_idx[id], procs) }
97
+ added = (after_idx.keys - before_idx.keys).map { |id| added_change(kind, id, after_idx[id], procs) }
98
+ matched = (before_idx.keys & after_idx.keys).flat_map { |id| Array(compare.call(id, before_idx[id], after_idx[id])) }
99
+ removed + added + matched
100
+ end
101
+
102
+ def removed_change(kind, id, info, procs)
103
+ Change.new(op: :removed, kind: kind, id: id, label: procs[:label].call(info), path: procs[:path].call(info))
104
+ end
105
+
106
+ def added_change(kind, id, info, procs)
107
+ Change.new(op: :added, kind: kind, id: id, label: procs[:label].call(info), path: procs[:path].call(info),
108
+ parent_id: procs[:parent]&.call(info), owner_id: procs[:owner]&.call(info))
109
+ end
110
+
111
+ def stage_changes
112
+ diff_index(:stage, index_stages(@before), index_stages(@after),
113
+ label: ->(s) { s['name'] }, path: ->(s) { s['name'] }) do |id, b, a|
114
+ cmp = []
115
+ cmp << changed(:stage, id, a['name'], a['name'], 'name', b['name'], a['name']) if b['name'] != a['name']
116
+ cmp << moved(:stage, id, a['name'], 'ordering', b['ordering'], a['ordering']) if b['ordering'] != a['ordering']
117
+ cmp
118
+ end
119
+ end
120
+
121
+ def section_changes
122
+ diff_index(:section, index_sections(@before), index_sections(@after),
123
+ label: ->(s) { s[:heading] }, path: ->(s) { s[:path] },
124
+ parent: ->(s) { s[:stage_id] }) do |id, b, a|
125
+ cmp = []
126
+ cmp << changed(:section, id, a[:heading], a[:path], 'heading', b[:heading], a[:heading]) if b[:heading] != a[:heading]
127
+ cmp << moved(:section, id, a[:heading], 'stage', b[:stage], a[:stage]) if b[:stage] != a[:stage]
128
+ cmp
129
+ end
130
+ end
131
+
132
+ def field_changes
133
+ diff_index(:field, index_fields(@before), index_fields(@after),
134
+ label: ->(f) { f[:label] }, path: ->(f) { f[:path] },
135
+ parent: ->(f) { f[:section_id] }, owner: ->(f) { f[:stage_id] }, &method(:compare_field))
136
+ end
137
+
138
+ def compare_field(id, before, after)
139
+ cmp = []
140
+ cmp << changed(:field, id, after[:label], after[:path], 'label', before[:label], after[:label]) if before[:label] != after[:label]
141
+ cmp << changed(:field, id, after[:label], after[:path], 'type', before[:type], after[:type]) if before[:type] != after[:type]
142
+ cmp << moved(:field, id, after[:label], 'section', before[:section], after[:section]) if before[:section] != after[:section]
143
+ cmp
144
+ end
145
+
146
+ def option_changes
147
+ bf = index_fields(@before)
148
+ af = index_fields(@after)
149
+ (bf.keys & af.keys).flat_map do |fid|
150
+ options_diff(fid, af[fid][:path], bf[fid][:field], af[fid][:field])
151
+ end
152
+ end
153
+
154
+ def options_diff(field_id, path, before_field, after_field)
155
+ diff_index(:option, index_options(before_field), index_options(after_field),
156
+ label: ->(o) { o[:label] }, path: ->(_o) { path }) do |k, b, a|
157
+ cmp = []
158
+ cmp << changed(:option, k, a[:label], path, 'label', b[:label], a[:label], field_id) if b[:label] != a[:label]
159
+ cmp << changed(:option, k, a[:label], path, 'weight', b[:weight], a[:weight], field_id) if b[:weight] != a[:weight]
160
+ cmp
161
+ end.each { |c| c.parent_id ||= field_id }
162
+ end
163
+
164
+ # --- typed field configuration (byType) ---------------------------
165
+
166
+ # Per-type field-configuration changes (the `byType` body of editFieldConfiguration).
167
+ # Matched by retained field id (self-version). For each field type in BYTYPE_CONFIG, emit
168
+ # a `:field_config` change per changed property, carrying the byType key so the synthesiser
169
+ # can nest it under the right sub-hash. Properties absent from BYTYPE_CONFIG are ignored
170
+ # here (unmappable → never fabricated).
171
+ def field_config_changes
172
+ bf = index_fields(@before)
173
+ af = index_fields(@after)
174
+ (bf.keys & af.keys).flat_map do |fid|
175
+ config_diff_for_field(fid, bf[fid], af[fid])
176
+ end
177
+ end
178
+
179
+ def config_diff_for_field(field_id, before_info, after_info)
180
+ type = after_info[:type]
181
+ return [] if before_info[:type] != type # a type change is handled/blocked elsewhere
182
+
183
+ (BYTYPE_CONFIG[type] || {}).flat_map do |by_type, props|
184
+ props.filter_map do |prop|
185
+ bv = before_info[:field][prop]
186
+ av = after_info[:field][prop]
187
+ next if bv == av
188
+
189
+ config_change(field_id, after_info, by_type, prop, bv, av)
190
+ end
191
+ end
192
+ end
193
+
194
+ def config_change(field_id, after_info, by_type, prop, before, after) # rubocop:disable Metrics/ParameterLists
195
+ Change.new(op: :changed, kind: :field_config, id: field_id, label: after_info[:label],
196
+ path: after_info[:path], attribute: prop, before: before, after: after,
197
+ parent_id: field_id, by_type: by_type)
198
+ end
199
+
200
+ # --- gauge stops ---------------------------------------------------
201
+
202
+ # Gauge fields carry an ordered list of `stops` ({ id, threshold, color }). Diff them per
203
+ # gauge field, matched by retained stop id (self-version). Added / removed / changed
204
+ # (threshold or color). parent_id = the gauge field id (the stop commands address it).
205
+ def gauge_stop_changes
206
+ bf = index_fields(@before)
207
+ af = index_fields(@after)
208
+ (bf.keys & af.keys).flat_map do |fid|
209
+ next [] unless af[fid][:type] == 'Gauge' && bf[fid][:type] == 'Gauge'
210
+
211
+ stops_diff(fid, af[fid][:path], bf[fid][:field], af[fid][:field])
212
+ end
213
+ end
214
+
215
+ # Explicit add/remove/change diff (NOT the generic diff_index): an ADDED stop must carry
216
+ # its full spec (threshold + color) on the Change so addGaugeFieldStop can be built without
217
+ # re-reading the doc.
218
+ def stops_diff(field_id, path, before_field, after_field)
219
+ before_idx = index_stops(before_field)
220
+ after_idx = index_stops(after_field)
221
+
222
+ removed = (before_idx.keys - after_idx.keys).map do |sid|
223
+ Change.new(op: :removed, kind: :gauge_stop, id: sid, label: before_idx[sid][:label],
224
+ path: path, parent_id: field_id)
225
+ end
226
+ added = (after_idx.keys - before_idx.keys).map do |sid|
227
+ s = after_idx[sid]
228
+ Change.new(op: :added, kind: :gauge_stop, id: sid, label: s[:label], path: path,
229
+ parent_id: field_id, after: { threshold: s[:threshold], color: s[:color] })
230
+ end
231
+ changed = (before_idx.keys & after_idx.keys).flat_map do |sid|
232
+ stop_attr_changes(field_id, sid, path, before_idx[sid], after_idx[sid])
233
+ end
234
+ removed + added + changed
235
+ end
236
+
237
+ def stop_attr_changes(field_id, stop_id, path, before, after)
238
+ cmp = []
239
+ cmp << stop_changed(field_id, stop_id, path, 'threshold', before[:threshold], after[:threshold]) if before[:threshold] != after[:threshold]
240
+ cmp << stop_changed(field_id, stop_id, path, 'color', before[:color], after[:color]) if before[:color] != after[:color]
241
+ cmp
242
+ end
243
+
244
+ def stop_changed(field_id, stop_id, path, attribute, before, after) # rubocop:disable Metrics/ParameterLists
245
+ Change.new(op: :changed, kind: :gauge_stop, id: stop_id, label: "#{attribute} #{after}".strip,
246
+ path: path, attribute: attribute, before: before, after: after, parent_id: field_id)
247
+ end
248
+
249
+ # Stops keyed by id, carrying threshold + color (an ADD needs both to build the command).
250
+ def index_stops(field)
251
+ Array(field['stops']).each_with_object({}) do |s, h|
252
+ next unless s.is_a?(Hash) && s['id']
253
+
254
+ h[s['id']] = { threshold: s['threshold'], color: s['color'],
255
+ label: "#{s['threshold']} #{s['color']}".strip }
256
+ end
257
+ end
258
+
259
+ # --- indexing helpers ---------------------------------------------
260
+
261
+ def index_stages(doc)
262
+ stages(doc).each_with_object({}) { |st, h| h[st['id']] = st if st['id'] }
263
+ end
264
+
265
+ def index_sections(doc)
266
+ out = {}
267
+ stages(doc).each do |st|
268
+ sections(st).each do |sec|
269
+ next unless sec['id']
270
+
271
+ out[sec['id']] = { heading: sec['heading'], stage: st['name'], stage_id: st['id'],
272
+ path: "#{st['name']} / #{sec['heading']}" }
273
+ end
274
+ end
275
+ out
276
+ end
277
+
278
+ def index_fields(doc)
279
+ out = {}
280
+ stages(doc).each do |st|
281
+ sections(st).each do |sec|
282
+ fields(sec).each do |f|
283
+ next unless f['id']
284
+
285
+ out[f['id']] = {
286
+ field: f, label: f['label'], type: f['__typename'] || f['type'],
287
+ section: sec['heading'], section_id: sec['id'], stage_id: st['id'],
288
+ path: "#{st['name']} / #{sec['heading']} / #{f['label']}"
289
+ }
290
+ end
291
+ end
292
+ end
293
+ out
294
+ end
295
+
296
+ # Options keyed by id when present, else by value (primary) / name (auxiliary) — the
297
+ # correct select-option identity (NOT genome).
298
+ def index_options(field)
299
+ Array(field['options']).each_with_object({}) do |o, h|
300
+ next unless o.is_a?(Hash)
301
+
302
+ key = o['id'] || o['value'] || o['label'] || o['name']
303
+ h[key] = { label: o['label'] || o['name'] || o['value'], weight: o['weight'] }
304
+ end
305
+ end
306
+
307
+ def stages(doc)
308
+ Array(doc['stages'])
309
+ end
310
+
311
+ def sections(stage)
312
+ Array(stage['sections'])
313
+ end
314
+
315
+ def fields(section)
316
+ Array(section['dataFields']) + Array(section['leftDataFields']) + Array(section['rightDataFields'])
317
+ end
318
+
319
+ def changed(kind, id, label, path, attribute, before, after, parent_id = nil) # rubocop:disable Metrics/ParameterLists
320
+ Change.new(op: :changed, kind: kind, id: id, label: label, path: path,
321
+ attribute: attribute, before: before, after: after, parent_id: parent_id)
322
+ end
323
+
324
+ def moved(kind, id, label, attribute, before, after) # rubocop:disable Metrics/ParameterLists
325
+ Change.new(op: :moved, kind: kind, id: id, label: label,
326
+ attribute: attribute, before: before, after: after)
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
332
+ end
@@ -0,0 +1,34 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ # Diff engines over the page/template model.
5
+ #
6
+ # `VersionDiff` handles the EASY, exact case — two snapshots of the SAME object (same ids),
7
+ # e.g. a UAT template at version N vs N+1 ("what did this ticket change"). The HARD case —
8
+ # pairing two DIFFERENT objects with different ids (UAT<->PROD, page<->template) — is an
9
+ # equivalence-matching problem handled by `Diff::Pairing` (multi-signal + learning ledger).
10
+ #
11
+ # `CommandSynthesizer` turns a change-set into an ordered WorkflowCommand batch (the replayable
12
+ # "commit"); `IdResolver` fills in move-target ids from a target doc; `Deploy` orchestrates
13
+ # diff -> commands -> apply, gating on the honest `unsupported` list.
14
+ #
15
+ # `Strategy` makes diffing a COMPOSABLE family (pairing x scope x move-sensitivity x intent);
16
+ # `CrossObjectDiff` is the cross-object front-end (pairs fields via `Pairing::Engine`, then
17
+ # emits the same `Change` output against the pairing map).
18
+ module Diff
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ require_relative 'diff/change'
25
+ require_relative 'diff/strategy'
26
+ require_relative 'diff/version_diff'
27
+ require_relative 'diff/id_resolver'
28
+ require_relative 'diff/command_synthesizer'
29
+ require_relative 'diff/pairing/ledger'
30
+ require_relative 'diff/pairing/candidate'
31
+ require_relative 'diff/pairing/signals'
32
+ require_relative 'diff/pairing/engine'
33
+ require_relative 'diff/cross_object_diff'
34
+ require_relative 'diff/deploy'
@@ -119,6 +119,7 @@ module Ecoportal
119
119
  __typename
120
120
  id @skip(if: $only_content)
121
121
  label
122
+ genomeSignature @skip(if: $only_content)
122
123
  deindex @include(if: $content)
123
124
  linkedFieldConfig @skip(if: $only_content) {
124
125
  crossReferenceId
@@ -1,18 +1,27 @@
1
- module Ecoportal
2
- module API
3
- class GraphQL
4
- module Input
5
- class WorkflowCommand
6
- module AddField
7
- SCHEMA_VERSION = '20260605'.freeze
8
- VALID_KEYS = %i[placeholderId fieldType label stageId sectionId column].freeze
9
-
10
- def self.build(**kwargs)
11
- kwargs.slice(*VALID_KEYS).compact
12
- end
13
- end
14
- end
15
- end
16
- end
17
- end
18
- end
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Input
5
+ class WorkflowCommand
6
+ module AddField
7
+ SCHEMA_VERSION = '20260605'.freeze
8
+ # VERIFIED against the live schema dump (WorkflowAddFieldInput, 20260605): the create
9
+ # command accepts ONLY these keys. There is NO `description` and NO `required` field on
10
+ # WorkflowAddFieldInput. `description` is instead a top-level key of
11
+ # WorkflowEditFieldConfigurationInput, so a field's description is persisted via a
12
+ # follow-up editFieldConfiguration command (see Builder::TemplateBuilder /
13
+ # Diff::CommandSynthesizer). `required` has NO input key anywhere in the schema (not on
14
+ # AddField, not on editFieldConfiguration top-level or any byType sub-input) and so
15
+ # cannot be persisted through the workflow command bus at all — callers must treat it as
16
+ # a read-only/unsupported attribute. Do NOT add these keys here (the server rejects them).
17
+ VALID_KEYS = %i[placeholderId fieldType label stageId sectionId column].freeze
18
+
19
+ def self.build(**kwargs)
20
+ kwargs.slice(*VALID_KEYS).compact
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -29,7 +29,7 @@ module Ecoportal
29
29
  fullMessages
30
30
  }
31
31
  item {
32
- ___Fragment__Action
32
+ spread :Action
33
33
  }
34
34
  }
35
35
  end
@@ -28,7 +28,7 @@ module Ecoportal
28
28
  fullMessages
29
29
  }
30
30
  item {
31
- ___Fragment__Action
31
+ spread :Action
32
32
  }
33
33
  }
34
34
  end
@@ -28,7 +28,7 @@ module Ecoportal
28
28
  fullMessages
29
29
  }
30
30
  item {
31
- ___Fragment__Action
31
+ spread :Action
32
32
  }
33
33
  }
34
34
  end