menuconform 0.1.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.
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Menuconform
6
+ # Machine-readable conformance report: score, breakdown, and findings
7
+ # enriched with catalog titles and fix hints. Contains no wall-clock
8
+ # timestamp of its own — reference_time is the only time input, and it is
9
+ # stated so the report is reproducible.
10
+ class Report
11
+ attr_reader :doc, :findings, :catalog, :reference_time
12
+
13
+ def initialize(doc:, findings:, catalog:, reference_time:)
14
+ @doc = doc
15
+ @findings = findings
16
+ @catalog = catalog
17
+ @reference_time = reference_time
18
+ end
19
+
20
+ def score_breakdown = @score_breakdown ||= Scorer.score(findings)
21
+ def score = score_breakdown["score"]
22
+
23
+ def to_h
24
+ {
25
+ "menuconform_version" => VERSION,
26
+ "rules_version" => catalog.rules_version,
27
+ "reference_time" => reference_time.utc.iso8601,
28
+ "menu" => {
29
+ "name" => doc["name"],
30
+ "ir_version" => doc["ir_version"],
31
+ "currency" => doc["currency"],
32
+ "timezone" => doc["timezone"]
33
+ },
34
+ "score" => score,
35
+ "score_breakdown" => score_breakdown.except("score"),
36
+ "severity_counts" => severity_counts,
37
+ "findings" => findings.map { |f| enrich(f) }
38
+ }
39
+ end
40
+
41
+ def to_json(*args)
42
+ JSON.pretty_generate(to_h, *args)
43
+ end
44
+
45
+ private
46
+
47
+ def severity_counts
48
+ base = { "error" => 0, "warn" => 0, "info" => 0 }
49
+ findings.each_with_object(base) { |f, h| h[f.severity] += 1 }
50
+ end
51
+
52
+ def enrich(finding)
53
+ entry = catalog.entry(finding.rule)
54
+ finding.to_h.merge(
55
+ "title" => entry["title"],
56
+ "fix_hint" => entry["fix_hint"]
57
+ )
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Menuconform
4
+ module Rules
5
+ # AGE-001..002. AGE-001 is a name heuristic by necessity: menus don't label
6
+ # alcohol, which is exactly the problem. The exclusion list guards the known
7
+ # soda/dessert traps (root beer, ginger ale, rum raisin); keep both lists
8
+ # tight — a false 'unlabeled alcohol' error costs the report credibility.
9
+ module AgeRules
10
+ ALCOHOL = /\b(margarita|mojito|daiquiri|sangria|mimosa|bellini|negroni|martini|cocktail|
11
+ beer|lager|ale|ipa|stout|porter|pilsner|hefeweizen|
12
+ wine|chardonnay|merlot|cabernet|riesling|rose\s+all\s+day|prosecco|champagne|
13
+ whiskey|whisky|bourbon|scotch|vodka|tequila|mezcal|rum|gin|brandy|cognac|
14
+ liqueur|sake|soju|cider|hard\s+seltzer)\b/xi
15
+ EXCLUSIONS = /root\s+beer|ginger\s+ale|ginger\s+beer|birch\s+beer|butter\s*beer|
16
+ rum\s+(raisin|cake)|beer\s*battered|beer\s*cheese|
17
+ non.?alcoholic|alcohol.?free|virgin|0\.0/xi
18
+ KIDS_CATEGORY = /\bkids?\b|\bchild(ren)?\b|\bjunior\b|\blittle\b/i
19
+
20
+ module_function
21
+
22
+ def call(menu)
23
+ unlabeled_alcohol(menu) + kids_category_conflicts(menu)
24
+ end
25
+
26
+ def alcohol_indicative?(name)
27
+ n = name || ""
28
+ n.match?(ALCOHOL) && !n.match?(EXCLUSIONS)
29
+ end
30
+
31
+ def unlabeled_alcohol(menu)
32
+ check = lambda do |entity, type|
33
+ return nil if entity["min_age"] || !alcohol_indicative?(entity["name"])
34
+ Finding.new("AGE-001", type, entity["id"],
35
+ "name '#{entity['name']}' indicates alcohol but no min_age is set")
36
+ end
37
+ menu.items_by_id.each_value.filter_map { |it| check.call(it, "item") } +
38
+ menu.modifiers_by_id.each_value.filter_map { |m| check.call(m, "modifier") }
39
+ end
40
+
41
+ def kids_category_conflicts(menu)
42
+ out = {}
43
+ menu.categories_by_id.each_value do |c|
44
+ next unless (c["name"] || "").match?(KIDS_CATEGORY)
45
+ (c["item_ids"] || []).each do |iid|
46
+ it = menu.items_by_id[iid]
47
+ next unless it
48
+ next unless it["min_age"] || alcohol_indicative?(it["name"])
49
+ out[iid] ||= Finding.new("AGE-002", "item", iid,
50
+ "age-restricted or alcohol-indicative item listed in kids-oriented category #{c['id']}")
51
+ end
52
+ end
53
+ out.values
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Menuconform
4
+ module Rules
5
+ # ALLERGEN-001..002. An explicit empty contains list is a declaration
6
+ # ("we state this has none of the big-9") and satisfies ALLERGEN-001;
7
+ # only a wholly absent allergens object is an enrichment gap.
8
+ module AllergenRules
9
+ module_function
10
+
11
+ def call(menu)
12
+ missing(menu) + contradictions(menu)
13
+ end
14
+
15
+ def missing(menu)
16
+ menu.items_by_id.each_value.filter_map do |it|
17
+ next if it.key?("allergens")
18
+ Finding.new("ALLERGEN-001", "item", it["id"], "no allergen information declared")
19
+ end
20
+ end
21
+
22
+ def contradictions(menu)
23
+ check = lambda do |entity, type|
24
+ a = entity["allergens"]
25
+ return nil unless a
26
+ both = (a["contains"] || []) & (a["may_contain"] || [])
27
+ return nil if both.empty?
28
+ Finding.new("ALLERGEN-002", type, entity["id"],
29
+ "#{both.join(', ')} declared in both contains and may_contain")
30
+ end
31
+ menu.items_by_id.each_value.filter_map { |it| check.call(it, "item") } +
32
+ menu.modifiers_by_id.each_value.filter_map { |m| check.call(m, "modifier") }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Menuconform
6
+ module Rules
7
+ # AVAIL-001..003. Weekly-window math: windows expand to minute intervals over
8
+ # a 10080-minute week; end < start wraps past midnight; '24:00' is end-of-day.
9
+ # Date bounds (start_date/end_date) are ignored for overlap checks in this
10
+ # release — AVAIL-001 reasons about the recurring weekly pattern only.
11
+ module AvailRules
12
+ DAYS = %w[mon tue wed thu fri sat sun].freeze
13
+ WEEK_MINUTES = 7 * 1440
14
+ SUSPENSION_HORIZON_SECONDS = 365 * 24 * 3600
15
+
16
+ module_function
17
+
18
+ def call(menu)
19
+ contradictions(menu) + declared_never(menu) + indefinite_suspensions(menu)
20
+ end
21
+
22
+ def contradictions(menu)
23
+ out = []
24
+ menu_windows = menu.doc["availability"]
25
+
26
+ menu.categories_by_id.each_value do |c|
27
+ next unless present?(c["availability"]) && present?(menu_windows)
28
+ next if overlap?(c["availability"], menu_windows)
29
+ out << Finding.new("AVAIL-001", "category", c["id"],
30
+ "category windows never overlap the menu's windows")
31
+ end
32
+
33
+ menu.items_by_id.each_value do |it|
34
+ next unless present?(it["availability"])
35
+ contexts = menu.categories.select { |c| (c["item_ids"] || []).include?(it["id"]) }
36
+ .map { |c| present?(c["availability"]) ? c["availability"] : menu_windows }
37
+ contexts = [menu_windows] if contexts.empty?
38
+ contexts = contexts.select { |w| present?(w) }
39
+ next if contexts.empty? # nothing to contradict
40
+ next if contexts.any? { |w| overlap?(it["availability"], w) }
41
+ out << Finding.new("AVAIL-001", "item", it["id"],
42
+ "item windows never overlap its category/menu windows — never orderable")
43
+ end
44
+ out
45
+ end
46
+
47
+ def declared_never(menu)
48
+ out = []
49
+ menu.categories_by_id.each_value do |c|
50
+ out << Finding.new("AVAIL-002", "category", c["id"], "availability is an explicit empty list") if c["availability"] == []
51
+ end
52
+ menu.items_by_id.each_value do |it|
53
+ out << Finding.new("AVAIL-002", "item", it["id"], "availability is an explicit empty list") if it["availability"] == []
54
+ end
55
+ out
56
+ end
57
+
58
+ def indefinite_suspensions(menu)
59
+ horizon = menu.reference_time + SUSPENSION_HORIZON_SECONDS
60
+ check = lambda do |entity, type|
61
+ raw = entity["suspended_until"]
62
+ return nil unless raw
63
+ t = begin
64
+ Time.iso8601(raw)
65
+ rescue ArgumentError
66
+ nil
67
+ end
68
+ return nil unless t && t > horizon
69
+ Finding.new("AVAIL-003", type, entity["id"],
70
+ "suspended_until #{raw} is more than a year past the reference time — retirement via 86")
71
+ end
72
+ menu.items_by_id.each_value.filter_map { |it| check.call(it, "item") } +
73
+ menu.modifiers_by_id.each_value.filter_map { |m| check.call(m, "modifier") }
74
+ end
75
+
76
+ def present?(windows)
77
+ !windows.nil? && !windows.empty?
78
+ end
79
+
80
+ def overlap?(windows_a, windows_b)
81
+ ints_a = intervals(windows_a)
82
+ ints_b = intervals(windows_b)
83
+ ints_a.any? { |a| ints_b.any? { |b| a[0] < b[1] && b[0] < a[1] } }
84
+ end
85
+
86
+ def intervals(windows)
87
+ windows.flat_map do |w|
88
+ days = w["days"] || DAYS
89
+ s = minute_of_day(w["start"])
90
+ e = minute_of_day(w["end"])
91
+ days.flat_map do |d|
92
+ di = DAYS.index(d)
93
+ next [] if di.nil? || s.nil? || e.nil?
94
+ base = di * 1440
95
+ if e > s
96
+ [[base + s, base + e]]
97
+ elsif e < s # wraps past midnight into the next day
98
+ nxt = ((di + 1) % 7) * 1440
99
+ [[base + s, base + 1440], [nxt, nxt + e]].reject { |i| i[0] == i[1] }
100
+ else
101
+ [] # zero-length window
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ def minute_of_day(hhmm)
108
+ return nil unless hhmm.is_a?(String) && hhmm.match?(/\A\d{2}:\d{2}\z/)
109
+ h, m = hhmm.split(":").map(&:to_i)
110
+ (h * 60) + m
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Menuconform
4
+ module Rules
5
+ # CART-001..004, produced by the cart solver (see Menuconform::Solver).
6
+ module CartRules
7
+ module_function
8
+
9
+ def call(menu)
10
+ Solver.new(menu).findings
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Menuconform
4
+ module Rules
5
+ # CONFLICT-001..005: statically unsatisfiable or ambiguous constraints.
6
+ module ConflictRules
7
+ module_function
8
+
9
+ def call(menu)
10
+ out = []
11
+ menu.groups_by_id.each_value do |g|
12
+ out.concat(group_findings(g))
13
+ end
14
+ menu.each_conditional_context do |g, o, trigger|
15
+ next unless trigger[:status] == :invalid
16
+ out << Finding.new("CONFLICT-005", "modifier_group", g["id"],
17
+ "option #{o['modifier_id']} has conditional prices whose trigger " \
18
+ "is not a single max_select:1 group attached to the same item")
19
+ end
20
+ out
21
+ end
22
+
23
+ def group_findings(g)
24
+ out = []
25
+ min = g["min_select"] || 0
26
+ max = g["max_select"]
27
+ min_units = g["min_total_units"]
28
+ max_units = g["max_total_units"]
29
+ options = g["options"] || []
30
+
31
+ if (!max.nil? && min > max) || (min_units && !max_units.nil? && min_units > max_units)
32
+ out << Finding.new("CONFLICT-001", "modifier_group", g["id"],
33
+ "minimum exceeds maximum (min_select #{min} / max_select #{max.inspect}, " \
34
+ "min_total_units #{min_units.inspect} / max_total_units #{max_units.inspect})")
35
+ end
36
+ if min.positive? && options.empty?
37
+ out << Finding.new("CONFLICT-002", "modifier_group", g["id"],
38
+ "requires #{min} selection(s) but offers no options")
39
+ end
40
+ out.concat(default_findings(g, options, max, max_units))
41
+ out.concat(included_findings(g, max, max_units))
42
+ out
43
+ end
44
+
45
+ def default_findings(g, options, max, max_units)
46
+ defaults = options.select { |o| (o["default_quantity"] || 0).positive? }
47
+ default_units = defaults.sum { |o| o["default_quantity"] }
48
+ problems = []
49
+ problems << "#{defaults.size} default option(s) exceed max_select #{max}" if !max.nil? && defaults.size > max
50
+ problems << "#{default_units} default unit(s) exceed max_total_units #{max_units}" if !max_units.nil? && default_units > max_units
51
+ defaults.each do |o|
52
+ mq = o["max_quantity"] || 1
53
+ problems << "option #{o['modifier_id']} defaults to #{o['default_quantity']} but max_quantity is #{mq}" if o["default_quantity"] > mq
54
+ end
55
+ return [] if problems.empty?
56
+ [Finding.new("CONFLICT-003", "modifier_group", g["id"], problems.join("; "))]
57
+ end
58
+
59
+ def included_findings(g, max, max_units)
60
+ inc = g["included_quantity"] || 0
61
+ return [] unless inc.positive?
62
+ cap = g["included_counting"] == "options" ? max : max_units
63
+ return [] if cap.nil? || inc <= cap
64
+ [Finding.new("CONFLICT-004", "modifier_group", g["id"],
65
+ "included_quantity #{inc} exceeds the group maximum (#{cap})")]
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Menuconform
4
+ module Rules
5
+ # NAME-001..003: naming quality of customer-facing entities.
6
+ module NameRules
7
+ JUNK_TOKENS = /\b(86|dnu|do not use|do not sell|test|void|zzz+)\b/i
8
+
9
+ module_function
10
+
11
+ def call(menu)
12
+ empty_names(menu) + duplicate_names(menu) + junk_names(menu)
13
+ end
14
+
15
+ def each_named_entity(menu)
16
+ menu.categories_by_id.each_value { |e| yield e, "category" }
17
+ menu.items_by_id.each_value { |e| yield e, "item" }
18
+ menu.groups_by_id.each_value { |e| yield e, "modifier_group" }
19
+ menu.modifiers_by_id.each_value { |e| yield e, "modifier" }
20
+ end
21
+
22
+ def empty_names(menu)
23
+ out = []
24
+ each_named_entity(menu) do |e, type|
25
+ next unless (e["name"] || "").strip.empty?
26
+ out << Finding.new("NAME-001", type, e["id"], "name is empty or whitespace-only")
27
+ end
28
+ out
29
+ end
30
+
31
+ # Scopes: item names within one category; modifier names within one group.
32
+ # Every occurrence after the first (in listing order) is flagged.
33
+ def duplicate_names(menu)
34
+ out = {}
35
+ menu.categories_by_id.each_value do |c|
36
+ seen = {}
37
+ (c["item_ids"] || []).each do |iid|
38
+ it = menu.items_by_id[iid]
39
+ next unless it
40
+ key = normalize(it["name"])
41
+ next if key.empty?
42
+ if seen.key?(key) && seen[key] != iid
43
+ out[["item", iid]] ||= Finding.new("NAME-002", "item", iid,
44
+ "name duplicates '#{seen[key]}' in category #{c['id']} (case-insensitive)")
45
+ else
46
+ seen[key] ||= iid
47
+ end
48
+ end
49
+ end
50
+ menu.groups_by_id.each_value do |g|
51
+ seen = {}
52
+ (g["options"] || []).each do |o|
53
+ m = menu.modifiers_by_id[o["modifier_id"]]
54
+ next unless m
55
+ key = normalize(m["name"])
56
+ next if key.empty?
57
+ if seen.key?(key) && seen[key] != m["id"]
58
+ out[["modifier", m["id"]]] ||= Finding.new("NAME-002", "modifier", m["id"],
59
+ "name duplicates '#{seen[key]}' in group #{g['id']} (case-insensitive)")
60
+ else
61
+ seen[key] ||= m["id"]
62
+ end
63
+ end
64
+ end
65
+ out.values
66
+ end
67
+
68
+ def junk_names(menu)
69
+ out = []
70
+ each_named_entity(menu) do |e, type|
71
+ name = e["name"] || ""
72
+ next if name.strip.empty? || !name.match?(JUNK_TOKENS)
73
+ out << Finding.new("NAME-003", type, e["id"],
74
+ "customer-facing name '#{name}' carries back-of-house tokens")
75
+ end
76
+ out
77
+ end
78
+
79
+ def normalize(name)
80
+ (name || "").strip.downcase
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Menuconform
4
+ module Rules
5
+ # PRICE-001..003. PRICE-003 uses a conservative static under-approximation of
6
+ # the minimum reachable total (depth-1 groups only, included_quantity ignored,
7
+ # cheapest conditional price assumed): it can miss negative totals hidden in
8
+ # nested groups, but a total it reports as negative genuinely is reachable-
9
+ # negative under those assumptions. The day 7-8 cart solver supersedes it for
10
+ # exact analysis.
11
+ module PriceRules
12
+ module_function
13
+
14
+ def call(menu)
15
+ negative_prices(menu) + incomplete_matrices(menu) + negative_totals(menu)
16
+ end
17
+
18
+ def negative_prices(menu)
19
+ menu.items_by_id.each_value.filter_map do |it|
20
+ next unless it["price"].negative?
21
+ Finding.new("PRICE-001", "item", it["id"], "item price #{it['price']} is negative")
22
+ end
23
+ end
24
+
25
+ def incomplete_matrices(menu)
26
+ out = []
27
+ menu.each_conditional_context do |g, o, trigger|
28
+ next unless trigger[:status] == :valid
29
+ covered = (o["conditional_prices"] || []).map { |cp| cp["when_modifier_id"] }
30
+ all = (trigger[:group]["options"] || []).map { |t| t["modifier_id"] }
31
+ missing = all - covered
32
+ next if missing.empty?
33
+ out << Finding.new("PRICE-002", "modifier_group", g["id"],
34
+ "option #{o['modifier_id']} conditional-price matrix does not cover " \
35
+ "#{missing.join(', ')} in trigger group #{trigger[:group]['id']} " \
36
+ "(falls back to base price)")
37
+ end
38
+ out
39
+ end
40
+
41
+ def negative_totals(menu)
42
+ menu.items_by_id.each_value.filter_map do |it|
43
+ next if it["price"].negative? # PRICE-001 already anchors this item
44
+ min_total = it["price"] + menu.attached_group_ids(it).uniq.sum do |gid|
45
+ g = menu.groups_by_id[gid]
46
+ g ? min_group_contribution(menu, g) : 0
47
+ end
48
+ next unless min_total.negative?
49
+ Finding.new("PRICE-003", "item", it["id"],
50
+ "a valid selection can drive the total to #{min_total}")
51
+ end
52
+ end
53
+
54
+ # Cheapest satisfying contribution of one group: forced picks take the
55
+ # cheapest options; beyond the minimum, only negative-priced options are
56
+ # added, within max_select / max_total_units / per-option max_quantity.
57
+ def min_group_contribution(menu, group)
58
+ options = (group["options"] || []).select { |o| menu.modifiers_by_id.key?(o["modifier_id"]) }
59
+ min_sel = [group["min_select"] || 0, 0].max
60
+ max_sel = group["max_select"] || options.size
61
+ return 0 if options.empty? || min_sel > max_sel || min_sel > options.size
62
+
63
+ budget = group["max_total_units"] || Float::INFINITY
64
+ candidates = options.map do |o|
65
+ unit_price = menu.resolved_min_price(o)
66
+ units = if unit_price.negative?
67
+ o["max_quantity"] || 1
68
+ else
69
+ [o["min_quantity"] || 0, 1].max
70
+ end
71
+ { price: unit_price, units: units, cost: unit_price * units }
72
+ end.sort_by { |c| c[:cost] }
73
+
74
+ total = 0
75
+ used_units = 0.0
76
+ selected = 0
77
+ candidates.each do |c|
78
+ forced = selected < min_sel
79
+ break if !forced && selected >= max_sel
80
+ next unless forced || c[:cost].negative?
81
+ units = [c[:units], budget - used_units].min
82
+ units = 1 if forced && units < 1
83
+ next if units < 1
84
+ total += c[:price] * units
85
+ used_units += units
86
+ selected += 1
87
+ end
88
+ total
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Menuconform
4
+ module Rules
5
+ # STRUCT-002..008. STRUCT-001 (schema validity) runs in the engine before
6
+ # any rule sees the document.
7
+ module StructRules
8
+ module_function
9
+
10
+ def call(menu)
11
+ dangling_refs(menu) + duplicate_ids(menu) + orphans(menu) +
12
+ nesting_depth(menu) + uncategorized_items(menu) + empty_categories(menu)
13
+ end
14
+
15
+ def dangling_refs(menu)
16
+ out = {}
17
+ add = lambda do |type, id, missing, what|
18
+ out[[type, id, missing]] ||=
19
+ Finding.new("STRUCT-002", type, id, "references #{what} #{missing}, which is not defined")
20
+ end
21
+ menu.categories.each do |c|
22
+ (c["item_ids"] || []).each do |iid|
23
+ add.call("category", c["id"], iid, "item") unless menu.items_by_id.key?(iid)
24
+ end
25
+ end
26
+ menu.items.each do |it|
27
+ menu.attached_group_ids(it).each do |gid|
28
+ add.call("item", it["id"], gid, "modifier group") unless menu.groups_by_id.key?(gid)
29
+ end
30
+ end
31
+ menu.modifier_groups.each do |g|
32
+ (g["options"] || []).each do |o|
33
+ add.call("modifier_group", g["id"], o["modifier_id"], "modifier") unless menu.modifiers_by_id.key?(o["modifier_id"])
34
+ (o["child_modifier_group_ids"] || []).each do |cgid|
35
+ add.call("modifier_group", g["id"], cgid, "child modifier group") unless menu.groups_by_id.key?(cgid)
36
+ end
37
+ (o["conditional_prices"] || []).each do |cp|
38
+ wid = cp["when_modifier_id"]
39
+ add.call("modifier_group", g["id"], wid, "conditional-price trigger modifier") unless menu.modifiers_by_id.key?(wid)
40
+ end
41
+ end
42
+ end
43
+ out.values
44
+ end
45
+
46
+ def duplicate_ids(menu)
47
+ menu.duplicate_ids.flat_map do |type, ids|
48
+ ids.map { |id| Finding.new("STRUCT-003", type, id, "more than one #{type} entry shares this id") }
49
+ end
50
+ end
51
+
52
+ # Orphan groups and modifiers. Items get STRUCT-007 instead.
53
+ def orphans(menu)
54
+ out = []
55
+ menu.groups_by_id.each_value do |g|
56
+ next if menu.referenced_group_ids.include?(g["id"])
57
+ out << Finding.new("STRUCT-004", "modifier_group", g["id"], "defined but referenced by nothing")
58
+ end
59
+ menu.modifiers_by_id.each_value do |m|
60
+ next if menu.referenced_modifier_ids.include?(m["id"])
61
+ out << Finding.new("STRUCT-004", "modifier", m["id"], "defined but referenced by nothing")
62
+ end
63
+ out
64
+ end
65
+
66
+ def nesting_depth(menu)
67
+ menu.items_by_id.each_value.filter_map do |it|
68
+ info = menu.depth_info(it)
69
+ if info[:cyclic]
70
+ Finding.new("STRUCT-005", "item", it["id"], "modifier group nesting contains a cycle")
71
+ elsif info[:max_depth] > 6
72
+ Finding.new("STRUCT-005", "item", it["id"], "modifier group nesting depth #{info[:max_depth]} exceeds 6")
73
+ elsif info[:max_depth] > 4
74
+ Finding.new("STRUCT-006", "item", it["id"], "modifier group nesting depth #{info[:max_depth]} exceeds 4")
75
+ end
76
+ end
77
+ end
78
+
79
+ def uncategorized_items(menu)
80
+ listed = menu.categories.flat_map { |c| c["item_ids"] || [] }.to_set
81
+ menu.items_by_id.each_value.filter_map do |it|
82
+ next if listed.include?(it["id"])
83
+ Finding.new("STRUCT-007", "item", it["id"], "not listed in any category")
84
+ end
85
+ end
86
+
87
+ def empty_categories(menu)
88
+ menu.categories_by_id.each_value.filter_map do |c|
89
+ next unless (c["item_ids"] || []).empty?
90
+ Finding.new("STRUCT-008", "category", c["id"], "category lists no items")
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Menuconform
4
+ # The published SPEC §7 scoring formula. Pure function of the findings and
5
+ # the weights below — same findings, same rules version, same score.
6
+ module Scorer
7
+ WEIGHTS = { "error" => 5.0, "warn" => 2.0, "info" => 0.5 }.freeze
8
+ PER_RULE_CAP = 15.0
9
+ CART_CAP_RULES = %w[CART-001 CART-002 CART-003].freeze
10
+ CART_CAP = 49
11
+ SCHEMA_CAP_RULE = "STRUCT-001"
12
+ SCHEMA_CAP = 59
13
+
14
+ module_function
15
+
16
+ # findings: [Finding] with severities set. Returns a breakdown hash.
17
+ def score(findings)
18
+ deductions = findings.group_by(&:rule).sort.map do |rule, group|
19
+ severity = group.first.severity
20
+ raw_points = group.size * WEIGHTS.fetch(severity)
21
+ points = [raw_points, PER_RULE_CAP].min
22
+ {
23
+ "rule" => rule,
24
+ "severity" => severity,
25
+ "count" => group.size,
26
+ "points" => points,
27
+ "capped" => raw_points > PER_RULE_CAP
28
+ }
29
+ end
30
+
31
+ raw = 100.0 - deductions.sum { |d| d["points"] }
32
+ capped = [raw, 0.0].max
33
+
34
+ hard_caps = []
35
+ if findings.any? { |f| CART_CAP_RULES.include?(f.rule) } && capped > CART_CAP
36
+ hard_caps << { "reason" => "cart-blocking findings present (CART-001/002/003)", "cap" => CART_CAP }
37
+ capped = CART_CAP
38
+ end
39
+ if findings.any? { |f| f.rule == SCHEMA_CAP_RULE } && capped > SCHEMA_CAP
40
+ hard_caps << { "reason" => "document fails schema validation (STRUCT-001)", "cap" => SCHEMA_CAP }
41
+ capped = SCHEMA_CAP
42
+ end
43
+
44
+ {
45
+ "score" => capped.round, # Ruby rounds half away from zero: half-up for our range
46
+ "base" => 100,
47
+ "deductions" => deductions,
48
+ "raw_score" => raw,
49
+ "hard_caps" => hard_caps
50
+ }
51
+ end
52
+ end
53
+ end