fontisan 0.4.35 → 0.4.37

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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the 'kern' table if present. The kern table is
7
+ # deprecated by the OpenType spec — GPOS is the replacement.
8
+ # Many legacy fonts still ship kern; this check warns about
9
+ # deprecation and validates basic structure.
10
+ #
11
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/kern
12
+ class KernCheck < Check
13
+ def self.call(font)
14
+ return [] unless font.has_table?("kern")
15
+
16
+ issues = [deprecation_warning]
17
+ issues.concat(validate_gpos_presence(font))
18
+ issues
19
+ end
20
+
21
+ def self.code
22
+ :ot_kern
23
+ end
24
+
25
+ def self.deprecation_warning
26
+ issue(severity: :warning,
27
+ message: "kern table is present but deprecated by the " \
28
+ "OpenType spec — use GPOS instead. Some modern " \
29
+ "renderers (HarfBuzz, Core Text) ignore kern",
30
+ location: "tables.kern")
31
+ end
32
+ private_class_method :deprecation_warning
33
+
34
+ def self.validate_gpos_presence(font)
35
+ return [] if font.has_table?("GPOS")
36
+
37
+ [issue(severity: :warning,
38
+ message: "kern table present but no GPOS table — " \
39
+ "kerning data should be migrated to a GPOS " \
40
+ "'kern' feature for full renderer support",
41
+ location: "tables.GPOS")]
42
+ end
43
+ private_class_method :validate_gpos_presence
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the 'maxp' table per the OpenType spec.
7
+ #
8
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/maxp
9
+ class MaxpCheck < Check
10
+ MAX_GLYPHS = 0xFFFF
11
+ MAX_STACK_ELEMENTS = 1000
12
+
13
+ def self.call(font)
14
+ return [] unless font.has_table?("maxp")
15
+
16
+ maxp = font.table("maxp")
17
+ issues = []
18
+ issues.concat(validate_version(maxp, font))
19
+ issues.concat(validate_num_glyphs(maxp))
20
+ issues.concat(validate_hint_capacity(maxp))
21
+ issues
22
+ end
23
+
24
+ def self.code
25
+ :ot_maxp
26
+ end
27
+
28
+ def self.validate_version(maxp, font)
29
+ version_raw = maxp.version_raw
30
+ is_truetype = font.has_table?("glyf")
31
+ tt_version = 0x00010000 # 1.0 as Fixed 16.16
32
+ cff_version = 0x00005000 # 0.5 as Fixed 16.16
33
+ if is_truetype && version_raw != tt_version
34
+ [issue(severity: :error,
35
+ message: "maxp.version is 0x#{version_raw.to_s(16)} but must " \
36
+ "be 1.0 (0x#{tt_version.to_s(16)}) for TrueType fonts",
37
+ location: "maxp.version")]
38
+ elsif !is_truetype && version_raw != cff_version
39
+ [issue(severity: :warning,
40
+ message: "maxp.version is 0x#{version_raw.to_s(16)} but " \
41
+ "should be 0.5 (0x#{cff_version.to_s(16)}) for " \
42
+ "CFF-based fonts",
43
+ location: "maxp.version")]
44
+ else
45
+ []
46
+ end
47
+ end
48
+ private_class_method :validate_version
49
+
50
+ def self.validate_num_glyphs(maxp)
51
+ n = maxp.num_glyphs.to_i
52
+ return [] if n.between?(1, MAX_GLYPHS)
53
+
54
+ [issue(severity: :error,
55
+ message: "maxp.numGlyphs is #{n} but must be between 1 and " \
56
+ "#{MAX_GLYPHS}",
57
+ location: "maxp.num_glyphs")]
58
+ end
59
+ private_class_method :validate_num_glyphs
60
+
61
+ def self.validate_hint_capacity(maxp)
62
+ return [] unless maxp.version_1_0?
63
+
64
+ issues = []
65
+ zones = maxp.max_zones.to_i
66
+ unless zones.between?(1, 2)
67
+ issues << issue(severity: :warning,
68
+ message: "maxp.maxZones is #{zones} but must be 1 or 2 " \
69
+ "per the OpenType spec",
70
+ location: "maxp.max_zones")
71
+ end
72
+ stack = maxp.max_stack_elements.to_i
73
+ if stack > MAX_STACK_ELEMENTS
74
+ issues << issue(severity: :warning,
75
+ message: "maxp.maxStackElements is #{stack} — " \
76
+ "excessively large values waste interpreter memory",
77
+ location: "maxp.max_stack_elements")
78
+ end
79
+ issues
80
+ end
81
+ private_class_method :validate_hint_capacity
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the 'name' table per the OpenType spec.
7
+ #
8
+ # Covers: required nameIDs, PostScript name character validity,
9
+ # version string format. Glyph-name rules are in GlyphNameCheck.
10
+ #
11
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/name
12
+ class NameTableCheck < Check
13
+ REQUIRED_NAME_IDS = {
14
+ 1 => "Family",
15
+ 2 => "Subfamily",
16
+ 4 => "Full",
17
+ 6 => "PostScript",
18
+ }.freeze
19
+ PS_NAME_PATTERN = /\A[A-Za-z][A-Za-z0-9._-]*\z/
20
+
21
+ def self.call(font)
22
+ return [] unless font.has_table?("name")
23
+
24
+ name = font.table("name")
25
+ issues = []
26
+ issues.concat(validate_required_ids(name))
27
+ issues.concat(validate_ps_name(name))
28
+ issues.concat(validate_version_string(name))
29
+ issues
30
+ end
31
+
32
+ def self.code
33
+ :ot_name
34
+ end
35
+
36
+ def self.validate_required_ids(name)
37
+ REQUIRED_NAME_IDS.each_with_object([]) do |(id, label), issues|
38
+ val = name.english_name(id).to_s
39
+ next unless val.empty?
40
+
41
+ issues << issue(severity: :error,
42
+ message: "Required name ID #{id} (#{label}) is " \
43
+ "missing from the name table",
44
+ location: "name.nameID.#{id}")
45
+ end
46
+ end
47
+ private_class_method :validate_required_ids
48
+
49
+ def self.validate_ps_name(name)
50
+ ps = name.english_name(6).to_s
51
+ return [] if ps.empty? || ps.match?(PS_NAME_PATTERN)
52
+
53
+ [issue(severity: :warning,
54
+ message: "PostScript name '#{ps}' contains characters outside " \
55
+ "the OT spec grammar (must start with a letter, then " \
56
+ "A–Z a–z 0–9 . - _)",
57
+ location: "name.nameID.6")]
58
+ end
59
+ private_class_method :validate_ps_name
60
+
61
+ def self.validate_version_string(name)
62
+ ver = name.english_name(5).to_s
63
+ return [] if ver.empty?
64
+
65
+ unless ver.match?(/\AVersion\s/i)
66
+ return [issue(severity: :info,
67
+ message: "name ID 5 (version) is '#{ver}' but should " \
68
+ "start with 'Version ' per OT spec convention",
69
+ location: "name.nameID.5")]
70
+ end
71
+
72
+ []
73
+ end
74
+ private_class_method :validate_version_string
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Foundational cross-table OpenType spec conformance checks.
7
+ # Per-table rules live in their own check modules
8
+ # ({HeadCheck}, {HheaCheck}, {Os2Check}, {NameTableCheck},
9
+ # {PostCheck}, {KernCheck}) to keep each concern MECE.
10
+ #
11
+ # This check owns the rules that span multiple tables:
12
+ #
13
+ # - Required tables for each sfnt flavor (TTF vs CFF vs variable)
14
+ # - head.indexToLocFormat consistency with loca table byte size
15
+ # - hhea.numberOfHMetrics must be ≤ maxp.numGlyphs
16
+ #
17
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/otff
18
+ class OpenTypeConformanceCheck < Check
19
+ # @param font [SfntFont]
20
+ # @return [Array<Models::ValidationReport::Issue>]
21
+ def self.call(font)
22
+ issues = []
23
+ issues.concat(validate_required_tables(font))
24
+ issues.concat(validate_loca_format(font))
25
+ issues.concat(validate_hhea_metrics(font))
26
+ issues
27
+ end
28
+
29
+ def self.code
30
+ :opentype_conformance
31
+ end
32
+
33
+ # ---------- required tables ----------
34
+
35
+ def self.validate_required_tables(font)
36
+ required = required_tables_for_flavor(font)
37
+ missing = required.reject { |tag| font.has_table?(tag) }
38
+ missing.map do |tag|
39
+ issue(severity: :error,
40
+ message: "Required table '#{tag}' is missing for this " \
41
+ "sfnt flavor",
42
+ location: "tables.#{tag}")
43
+ end
44
+ end
45
+ private_class_method :validate_required_tables
46
+
47
+ def self.required_tables_for_flavor(font)
48
+ base = %w[head hhea maxp hmtx name post cmap OS/2]
49
+ if font.has_table?("glyf")
50
+ base + %w[glyf loca]
51
+ elsif font.has_table?("CFF2")
52
+ base + ["CFF2"]
53
+ elsif font.has_table?("CFF ")
54
+ base + ["CFF "]
55
+ else
56
+ base
57
+ end
58
+ end
59
+ private_class_method :required_tables_for_flavor
60
+
61
+ # ---------- loca format ----------
62
+
63
+ def self.validate_loca_format(font)
64
+ return [] unless font.has_table?("head") && font.has_table?("loca")
65
+
66
+ head = font.table("head")
67
+ loca = font.table("loca")
68
+ return [] unless head && loca
69
+
70
+ format = head.index_to_loc_format
71
+ maxp = font.table("maxp")
72
+ num_glyphs = maxp&.num_glyphs.to_i
73
+ return [] if num_glyphs.zero?
74
+
75
+ expected_size = format.zero? ? (num_glyphs + 1) * 2 : (num_glyphs + 1) * 4
76
+ actual_size = loca.to_binary_s.bytesize
77
+ return [] if actual_size == expected_size
78
+
79
+ [issue(severity: :warning,
80
+ message: "loca table size (#{actual_size} bytes) doesn't match " \
81
+ "head.indexToLocFormat=#{format} + numGlyphs=#{num_glyphs} " \
82
+ "(expected #{expected_size} bytes)",
83
+ location: "head.index_to_loc_format")]
84
+ end
85
+ private_class_method :validate_loca_format
86
+
87
+ # ---------- hhea.numberOfHMetrics ----------
88
+
89
+ def self.validate_hhea_metrics(font)
90
+ return [] unless font.has_table?("hhea") && font.has_table?("maxp")
91
+
92
+ num_metrics = font.table("hhea").number_of_h_metrics.to_i
93
+ num_glyphs = font.table("maxp").num_glyphs.to_i
94
+ return [] if num_metrics.positive? && num_metrics <= num_glyphs
95
+
96
+ [issue(severity: :error,
97
+ message: "hhea.numberOfHMetrics (#{num_metrics}) must be " \
98
+ "between 1 and maxp.numGlyphs (#{num_glyphs})",
99
+ location: "hhea.number_of_h_metrics")]
100
+ end
101
+ private_class_method :validate_hhea_metrics
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the 'OS/2' table per the OpenType spec.
7
+ #
8
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/os2
9
+ class Os2Check < Check
10
+ FS_SELECTION_RESERVED = 0xFC00
11
+
12
+ def self.call(font)
13
+ return [] unless font.has_table?("OS/2")
14
+
15
+ os2 = font.table("OS/2")
16
+ issues = []
17
+ issues.concat(validate_weight_class(os2))
18
+ issues.concat(validate_width_class(os2))
19
+ issues.concat(validate_fs_selection(os2))
20
+ issues.concat(validate_typo_metrics(os2))
21
+ issues.concat(validate_win_metrics(os2))
22
+ issues.concat(validate_panose(os2))
23
+ issues
24
+ end
25
+
26
+ def self.code
27
+ :ot_os2
28
+ end
29
+
30
+ def self.validate_weight_class(os2)
31
+ w = os2.us_weight_class.to_i
32
+ return [] if w.between?(1, 1000)
33
+
34
+ [issue(severity: :error,
35
+ message: "OS/2.usWeightClass is #{w} but must be between " \
36
+ "1 and 1000",
37
+ location: "os2.us_weight_class")]
38
+ end
39
+ private_class_method :validate_weight_class
40
+
41
+ def self.validate_width_class(os2)
42
+ w = os2.us_width_class.to_i
43
+ return [] if w.between?(1, 9)
44
+
45
+ [issue(severity: :error,
46
+ message: "OS/2.usWidthClass is #{w} but must be between 1 " \
47
+ "and 9",
48
+ location: "os2.us_width_class")]
49
+ end
50
+ private_class_method :validate_width_class
51
+
52
+ def self.validate_fs_selection(os2)
53
+ fs = os2.fs_selection.to_i
54
+ return [] if (fs & FS_SELECTION_RESERVED).zero?
55
+
56
+ [issue(severity: :warning,
57
+ message: "OS/2.fsSelection uses reserved bits " \
58
+ "(0x#{fs.to_s(16)}, bits 10-15 must be 0)",
59
+ location: "os2.fs_selection")]
60
+ end
61
+ private_class_method :validate_fs_selection
62
+
63
+ def self.validate_typo_metrics(os2)
64
+ issues = []
65
+ asc = os2.s_typo_ascender.to_i
66
+ desc = os2.s_typo_descender.to_i
67
+ if asc <= 0
68
+ issues << issue(severity: :warning,
69
+ message: "OS/2.sTypoAscender is #{asc} but should " \
70
+ "be positive",
71
+ location: "os2.s_typo_ascender")
72
+ end
73
+ if desc.positive?
74
+ issues << issue(severity: :warning,
75
+ message: "OS/2.sTypoDescender is #{desc} but should " \
76
+ "be ≤ 0",
77
+ location: "os2.s_typo_descender")
78
+ end
79
+ issues
80
+ end
81
+ private_class_method :validate_typo_metrics
82
+
83
+ def self.validate_win_metrics(os2)
84
+ issues = []
85
+ if os2.us_win_ascent.to_i <= 0
86
+ issues << issue(severity: :warning,
87
+ message: "OS/2.usWinAscent is #{os2.us_win_ascent} " \
88
+ "but should be positive",
89
+ location: "os2.us_win_ascent")
90
+ end
91
+ if os2.us_win_descent.to_i <= 0
92
+ issues << issue(severity: :warning,
93
+ message: "OS/2.usWinDescent is #{os2.us_win_descent} " \
94
+ "but should be positive",
95
+ location: "os2.us_win_descent")
96
+ end
97
+ issues
98
+ end
99
+ private_class_method :validate_win_metrics
100
+
101
+ def self.validate_panose(os2)
102
+ panose = os2.panose
103
+ return [] unless panose
104
+
105
+ all_zero = panose.to_a.all?(&:zero?)
106
+ return [] unless all_zero
107
+
108
+ [issue(severity: :info,
109
+ message: "OS/2.panose is all zeros — PANOSE classification " \
110
+ "helps font matching in some applications",
111
+ location: "os2.panose")]
112
+ end
113
+ private_class_method :validate_panose
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the 'post' table per the OpenType spec.
7
+ #
8
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/post
9
+ class PostCheck < Check
10
+ VALID_VERSIONS = [1.0, 2.0, 2.5, 3.0, 4.0].freeze
11
+
12
+ def self.call(font)
13
+ return [] unless font.has_table?("post")
14
+
15
+ post = font.table("post")
16
+ head = font.has_table?("head") ? font.table("head") : nil
17
+ issues = []
18
+ issues.concat(validate_version(post))
19
+ issues.concat(validate_italic_angle(post, head))
20
+ issues.concat(validate_is_fixed_pitch(post))
21
+ issues
22
+ end
23
+
24
+ def self.code
25
+ :ot_post
26
+ end
27
+
28
+ def self.validate_version(post)
29
+ version = post.version.to_f
30
+ return [] if VALID_VERSIONS.include?(version)
31
+
32
+ [issue(severity: :error,
33
+ message: "post.version is #{version} but must be one of " \
34
+ "#{VALID_VERSIONS.join(', ')}",
35
+ location: "post.version")]
36
+ end
37
+ private_class_method :validate_version
38
+
39
+ def self.validate_italic_angle(post, head)
40
+ angle = post.italic_angle.to_f
41
+ issues = []
42
+ if head&.mac_style
43
+ is_italic = (head.mac_style.to_i & 0x02).positive?
44
+ if is_italic && angle >= 0
45
+ issues << issue(severity: :warning,
46
+ message: "post.italicAngle is #{angle} but " \
47
+ "head.macStyle indicates italic " \
48
+ "(angle should be negative)",
49
+ location: "post.italic_angle")
50
+ elsif !is_italic && angle != 0
51
+ issues << issue(severity: :info,
52
+ message: "post.italicAngle is #{angle} but " \
53
+ "head.macStyle does not indicate italic " \
54
+ "(angle should be 0)",
55
+ location: "post.italic_angle")
56
+ end
57
+ end
58
+ issues
59
+ end
60
+ private_class_method :validate_italic_angle
61
+
62
+ def self.validate_is_fixed_pitch(post)
63
+ val = post.is_fixed_pitch.to_i
64
+ return [] if [0, 1].include?(val)
65
+
66
+ [issue(severity: :warning,
67
+ message: "post.isFixedPitch is #{val} but must be 0 " \
68
+ "(proportional) or 1 (monospace)",
69
+ location: "post.is_fixed_pitch")]
70
+ end
71
+ private_class_method :validate_is_fixed_pitch
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates variable-font readiness: fvar axis invariants, named
7
+ # instances within ranges, companion-table presence (gvar for
8
+ # TrueType outlines, HVAR for horizontal metrics, STAT for OS
9
+ # style matching). Ports the most commonly-hit fontbakery GF-VF
10
+ # check subset.
11
+ #
12
+ # Only runs when the font HAS an fvar table — static fonts are
13
+ # skipped (returns empty issues array).
14
+ #
15
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/fvar
16
+ class VariableFontCheck < Check
17
+ # @param font [SfntFont]
18
+ # @return [Array<Models::ValidationReport::Issue>]
19
+ def self.call(font)
20
+ return [] unless font.has_table?("fvar")
21
+
22
+ issues = []
23
+ issues.concat(validate_axes(font))
24
+ issues.concat(validate_instances(font))
25
+ issues.concat(validate_companion_tables(font))
26
+ issues
27
+ end
28
+
29
+ def self.code
30
+ :variable_font
31
+ end
32
+
33
+ # ---------- fvar axes ----------
34
+
35
+ def self.validate_axes(font)
36
+ fvar = font.table("fvar")
37
+ axes = fvar.axes
38
+ issues = []
39
+
40
+ issues.concat(check_axis_uniqueness(axes))
41
+ axes.each_with_index { |axis, idx| issues.concat(check_one_axis(axis, idx)) }
42
+ issues
43
+ end
44
+ private_class_method :validate_axes
45
+
46
+ def self.check_axis_uniqueness(axes)
47
+ tags = axes.map(&:axis_tag)
48
+ duplicates = tags.tally.filter_map { |t, c| t if c > 1 }
49
+ duplicates.map do |tag|
50
+ issue(severity: :error,
51
+ message: "Duplicate fvar axis tag '#{tag}' — axis tags " \
52
+ "must be unique within a font",
53
+ location: "fvar.axes.#{tag}")
54
+ end
55
+ end
56
+ private_class_method :check_axis_uniqueness
57
+
58
+ def self.check_one_axis(axis, idx)
59
+ issues = []
60
+ min_v = axis.min_value.to_f
61
+ default_v = axis.default_value.to_f
62
+ max_v = axis.max_value.to_f
63
+
64
+ unless min_v <= default_v && default_v <= max_v
65
+ issues << issue(severity: :error,
66
+ message: "fvar axis '#{axis.axis_tag}' (#{idx}) has " \
67
+ "invalid range: min=#{min_v} " \
68
+ "default=#{default_v} max=#{max_v} " \
69
+ "(must be min ≤ default ≤ max)",
70
+ location: "fvar.axes.#{idx}")
71
+ end
72
+
73
+ if axis.axis_name_id.to_i.zero?
74
+ issues << issue(severity: :warning,
75
+ message: "fvar axis '#{axis.axis_tag}' (#{idx}) has " \
76
+ "no name ID — users cannot identify the axis",
77
+ location: "fvar.axes.#{idx}.axis_name_id")
78
+ end
79
+
80
+ issues
81
+ end
82
+ private_class_method :check_one_axis
83
+
84
+ # ---------- fvar named instances ----------
85
+
86
+ def self.validate_instances(font)
87
+ fvar = font.table("fvar")
88
+ instances = fvar.instances || []
89
+ axes = fvar.axes
90
+
91
+ instances.each_with_index.with_object([]) do |(inst, idx), issues|
92
+ issues.concat(check_instance_ranges(inst, idx, axes))
93
+ end
94
+ end
95
+ private_class_method :validate_instances
96
+
97
+ def self.check_instance_ranges(inst, idx, axes)
98
+ issues = []
99
+ coords = inst[:coordinates] || inst["coordinates"] || []
100
+ coords.each_with_index do |coord, axis_idx|
101
+ next unless axes[axis_idx]
102
+
103
+ axis = axes[axis_idx]
104
+ min_v = axis.min_value.to_f
105
+ max_v = axis.max_value.to_f
106
+ next if coord.to_f.between?(min_v, max_v)
107
+
108
+ issues << issue(severity: :warning,
109
+ message: "fvar instance #{idx} has coordinate " \
110
+ "#{coord} for axis '#{axis.axis_tag}' " \
111
+ "outside its range [#{min_v}, #{max_v}]",
112
+ location: "fvar.instances.#{idx}.#{axis.axis_tag}")
113
+ end
114
+ issues
115
+ end
116
+ private_class_method :check_instance_ranges
117
+
118
+ # ---------- companion tables ----------
119
+
120
+ def self.validate_companion_tables(font)
121
+ issues = []
122
+ is_truetype = font.has_table?("glyf")
123
+ if is_truetype && !font.has_table?("gvar")
124
+ issues << issue(severity: :warning,
125
+ message: "Variable TrueType font has no 'gvar' table — " \
126
+ "glyph outlines won't vary across the design space",
127
+ location: "tables.gvar")
128
+ end
129
+
130
+ unless font.has_table?("HVAR")
131
+ issues << issue(severity: :warning,
132
+ message: "Variable font has no 'HVAR' table — " \
133
+ "advance widths won't vary; renderers must " \
134
+ "fall back to per-glyph hmtx lookups",
135
+ location: "tables.HVAR")
136
+ end
137
+
138
+ unless font.has_table?("STAT")
139
+ issues << issue(severity: :info,
140
+ message: "Variable font has no 'STAT' table — " \
141
+ "OS style-matching won't work; Windows and " \
142
+ "modern font pickers rely on STAT",
143
+ location: "tables.STAT")
144
+ end
145
+ issues
146
+ end
147
+ private_class_method :validate_companion_tables
148
+ end
149
+ end
150
+ end
151
+ end