gitlab_quality-test_tooling 3.1.0 → 3.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be0541b0d2e39102c7665bb733cdd068467959cf727bee7cafc385d2117b2689
4
- data.tar.gz: 0dcc0e738cc51add35954ad281e7c0a86aa8662956e6133644f72bafd28b03a5
3
+ metadata.gz: e9344fabcbb0c753d63b87584efe906b972a6504e7cdb87ef14f46ec2fdeccab
4
+ data.tar.gz: 9f30d7e5edebcf6116cd09343bffb4cc3a0946071a5500ac1d4b3372cd034376
5
5
  SHA512:
6
- metadata.gz: a9f3927bc5646c115610015be3b780408518de92b8ebd741fc9270605fbf93e4a976b7eeb460a0d988b11cbadbfe154ccdb937da24f05637ae5574cb2cdd0f18
7
- data.tar.gz: 9c641bc6e4f22c57fd16b5481a47d09ac45eac346b0b52da7f70ba3f4f4954d8e25e56fa54e9a8df5f939da95fb54c09d8dafc0d9f9e6e3733e7e1bc9e85da37
6
+ metadata.gz: 4596d36fee333944d309fc9eb6428e9cca37990f2cc6ca13914af87f6a4dff208dd993c8c51a9642daf63c50f051e2902a8b6d2647ddf42270c348d7e9c701e5
7
+ data.tar.gz: 480a96a3a1242d87f946d3e610729c97c374d69cb1b6bd96e7dc1255f4558c505126f5b74fe8e7d730c7c84359138a7e9677eee49c7b616d3362f9d072887046
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (3.1.0)
4
+ gitlab_quality-test_tooling (3.2.0)
5
5
  activesupport (>= 7.0)
6
6
  amatch (~> 0.4.1)
7
7
  fog-google (~> 1.24, >= 1.24.1)
data/exe/test-coverage CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "optparse"
5
5
  require "uri"
6
+ require "yaml"
6
7
 
7
8
  require_relative "../lib/gitlab_quality/test_tooling"
8
9
 
@@ -15,9 +16,11 @@ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/artifacts'
15
16
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_report'
16
17
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_map'
17
18
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier'
19
+ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier'
20
+ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config'
18
21
 
19
22
  params = {}
20
- required_params = [:test_reports, :coverage_report, :test_map, :clickhouse_url, :clickhouse_database, :clickhouse_username]
23
+ required_params = [:test_reports, :coverage_report, :test_map, :clickhouse_url, :clickhouse_database, :clickhouse_username, :responsibility_patterns]
21
24
 
22
25
  options = OptionParser.new do |opts|
23
26
  opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
@@ -50,6 +53,10 @@ options = OptionParser.new do |opts|
50
53
  params[:clickhouse_username] = username
51
54
  end
52
55
 
56
+ opts.on('--responsibility-patterns PATH', 'Path to YAML file with responsibility classification patterns') do |path|
57
+ params[:responsibility_patterns] = path
58
+ end
59
+
53
60
  opts.separator ""
54
61
  opts.separator "Environment variables:"
55
62
  opts.separator " GLCI_CLICKHOUSE_METRICS_PASSWORD ClickHouse password (required, not passed via CLI for security)"
@@ -107,7 +114,9 @@ if params.any? && (required_params - params.keys).none?
107
114
 
108
115
  code_coverage_by_source_file = GitlabQuality::TestTooling::CodeCoverage::LcovFile.new(coverage_report).parsed_content
109
116
 
110
- source_file_to_tests = GitlabQuality::TestTooling::CodeCoverage::TestMap.new(test_map).source_to_tests
117
+ test_map_parser = GitlabQuality::TestTooling::CodeCoverage::TestMap.new(test_map)
118
+ source_file_to_tests = test_map_parser.source_to_tests
119
+ test_to_sources = test_map_parser.test_to_sources
111
120
 
112
121
  # Process test reports
113
122
  tests_to_categories = artifacts.test_reports.reduce({}) do |combined_hash, test_report_file|
@@ -121,12 +130,31 @@ if params.any? && (required_params - params.keys).none?
121
130
  source_file_classifier = GitlabQuality::TestTooling::CodeCoverage::SourceFileClassifier.new
122
131
  source_file_types = source_file_classifier.classify(code_coverage_by_source_file.keys)
123
132
 
133
+ # Load responsibility patterns from config file
134
+ begin
135
+ patterns_config = GitlabQuality::TestTooling::CodeCoverage::ResponsibilityPatternsConfig.new(
136
+ params[:responsibility_patterns]
137
+ )
138
+ rescue GitlabQuality::TestTooling::CodeCoverage::ResponsibilityPatternsConfig::ConfigError => e
139
+ puts "Error: #{e.message}"
140
+ exit 1
141
+ end
142
+
143
+ # Classify test files as responsible or dependent
144
+ responsibility_classifier = GitlabQuality::TestTooling::CodeCoverage::ResponsibilityClassifier.new(
145
+ test_to_sources,
146
+ responsible_patterns: patterns_config.responsible_patterns,
147
+ dependent_patterns: patterns_config.dependent_patterns
148
+ )
149
+ test_classifications = responsibility_classifier.classify_tests
150
+
124
151
  coverage_data = GitlabQuality::TestTooling::CodeCoverage::CoverageData.new(
125
152
  code_coverage_by_source_file,
126
153
  source_file_to_tests,
127
154
  tests_to_categories,
128
155
  category_owners.categories_to_teams,
129
- source_file_types
156
+ source_file_types,
157
+ test_classifications
130
158
  )
131
159
 
132
160
  clickhouse_data = {
@@ -0,0 +1,113 @@
1
+ # Code Coverage Module
2
+
3
+ Exports test coverage data to ClickHouse, enriched with:
4
+
5
+ - **Feature category ownership** - group, stage, and section for each covered file
6
+ - **Responsibility classification** - whether coverage comes from unit or integration tests
7
+
8
+ ## Responsibility Classification
9
+
10
+ Tests are classified as either **responsible** or **dependent**:
11
+
12
+ - **Responsible**: Unit tests that directly test a component in isolation
13
+ - **Dependent**: Integration/E2E tests that exercise a component through other layers
14
+
15
+ This classification is tracked per (source_file, feature_category) combination using two boolean columns:
16
+
17
+ | is_responsible | is_dependent | Meaning |
18
+ |----------------|--------------|---------|
19
+ | `true` | `true` | Source file has both unit AND integration test coverage from this feature category |
20
+ | `true` | `false` | Source file has only unit test coverage from this feature category |
21
+ | `false` | `true` | Source file has only integration test coverage from this feature category |
22
+ | `nil` | `nil` | No test mapping exists for this source file |
23
+
24
+ ### Configuration
25
+
26
+ This gem is designed to be reusable across different projects. Classification patterns
27
+ are project-specific and must be provided via a YAML config file, since different
28
+ codebases have different test directory structures. The config file defines regex
29
+ patterns for matching test file paths:
30
+
31
+ > **Note:** The table above describes the *semantic meaning* of the flags. The patterns
32
+ > you configure determine *which tests* produce those flags for your project.
33
+
34
+ ```yaml
35
+ # responsibility_patterns.yml
36
+ responsible:
37
+ - "^spec/(models|controllers|services)/" # Backend unit tests
38
+ - "^spec/frontend/" # Frontend unit tests
39
+ - "_test\\.go$" # Go unit tests
40
+
41
+ dependent:
42
+ - "^spec/(requests|features|integration)/" # Backend integration tests
43
+ - "^spec/frontend_integration/" # Frontend integration tests
44
+ - "^qa/" # E2E tests
45
+ - "_integration_test\\.go$" # Go integration tests
46
+ ```
47
+
48
+ **Pattern matching rules:**
49
+ 1. Dependent patterns are checked first (higher priority)
50
+ 2. If no pattern matches, the test defaults to "dependent"
51
+ 3. Patterns are Ruby regexes (escape special characters like `.` with `\\`)
52
+
53
+ **Why dependent has priority:** We use a conservative approach. `is_responsible: true`
54
+ makes a stronger claim ("this file has unit test coverage") than `is_dependent: true`.
55
+ If a test matches both patterns or no patterns, defaulting to "dependent" avoids
56
+ incorrectly inflating unit test coverage metrics. It's safer to under-claim than over-claim.
57
+
58
+ ### Example: GitLab Configuration
59
+
60
+ ```yaml
61
+ # .gitlab/coverage/responsibility_patterns.yml
62
+ responsible:
63
+ # Backend unit test directories
64
+ - "^spec/(models|controllers|services|workers|helpers|mailers|policies|presenters|uploaders|validators|lib|graphql|serializers|components)/"
65
+ - "^ee/spec/(models|controllers|services|workers|helpers|mailers|policies|presenters|uploaders|validators|lib|graphql|serializers|components)/"
66
+ # Frontend unit tests
67
+ - "^spec/frontend/"
68
+ - "^ee/spec/frontend/"
69
+ # Go unit tests
70
+ - "_test\\.go$"
71
+
72
+ dependent:
73
+ # Backend integration tests
74
+ - "^spec/(requests|features|system|integration)/"
75
+ - "^ee/spec/(requests|features|system|integration)/"
76
+ # Frontend integration tests
77
+ - "^spec/frontend_integration/"
78
+ - "^ee/spec/frontend_integration/"
79
+ # E2E tests
80
+ - "^qa/"
81
+ # Go integration tests
82
+ - "_integration_test\\.go$"
83
+ ```
84
+
85
+ ### Example: Standard Rails Project
86
+
87
+ ```yaml
88
+ # config/responsibility_patterns.yml
89
+ responsible:
90
+ - "^test/(models|controllers|services|helpers|mailers)/"
91
+ - "^test/unit/"
92
+
93
+ dependent:
94
+ - "^test/(integration|system)/"
95
+ - "^spec/features/"
96
+ ```
97
+
98
+ ## CLI
99
+
100
+ Example usage:
101
+
102
+ ```bash
103
+ test-coverage \
104
+ --test-reports 'rspec/*.json' \
105
+ --coverage-report 'coverage/lcov.info' \
106
+ --test-map 'mapping.json' \
107
+ --responsibility-patterns 'config/responsibility_patterns.yml' \
108
+ --clickhouse-url 'https://clickhouse.example.com' \
109
+ --clickhouse-database 'coverage' \
110
+ --clickhouse-username 'user'
111
+ ```
112
+
113
+ See `exe/test-coverage --help` for full usage.
@@ -11,7 +11,8 @@ module GitlabQuality
11
11
  class Artifacts
12
12
  # Loads coverage artifacts from the filesystem
13
13
  #
14
- # @param test_reports [String] Glob pattern for test JSON report files (RSpec or Jest) (e.g., "reports/**/*.json")
14
+ # @param test_reports [String] Glob pattern(s) for test JSON report files (RSpec or Jest).
15
+ # Supports comma-separated patterns (e.g., "jest/**/*.json,rspec/**/*.json")
15
16
  # @param coverage_report [String] Path to the LCOV coverage report file (e.g., "coverage/lcov/gitlab.lcov")
16
17
  # @param test_map [String] Path to the test map file, gzipped or plain JSON (e.g., "crystalball/packed-mapping.json.gz")
17
18
  def initialize(coverage_report:, test_map:, test_reports:)
@@ -54,7 +55,9 @@ module GitlabQuality
54
55
 
55
56
  def test_reports_paths
56
57
  @test_reports_paths ||= begin
57
- paths = Dir.glob(@test_reports_glob)
58
+ # Support comma-separated glob patterns (e.g., "jest/**/*.json,rspec/**/*.json")
59
+ patterns = @test_reports_glob.split(',').map(&:strip).reject(&:empty?)
60
+ paths = Dir.glob(patterns)
58
61
 
59
62
  raise "No test reports found matching pattern: #{@test_reports_glob}" if paths.empty?
60
63
 
@@ -15,17 +15,17 @@ module GitlabQuality
15
15
  BASE_DELAY = 1 # seconds
16
16
  MAX_RETRIES = 3
17
17
 
18
- # @return [Hash] Category ownership hierarchy, section -> stage -> group -> [categories]
18
+ # @return [Hash] Feature category ownership hierarchy, section -> stage -> group -> [feature_categories]
19
19
  # @example Return value
20
20
  # {
21
21
  # "team_planning" => { # section
22
22
  # "project_management" => { # stage
23
23
  # "plan" => [ # group
24
- # "dev", # category
25
- # "service_desk" # category
24
+ # "dev", # feature_category
25
+ # "service_desk" # feature_category
26
26
  # ],
27
27
  # "product_planning" => [ # group
28
- # "portfolio_management", # category
28
+ # "portfolio_management", # feature_category
29
29
  # ...
30
30
  # ]
31
31
  # }
@@ -35,7 +35,7 @@ module GitlabQuality
35
35
  attr_reader :hierarchy
36
36
 
37
37
  def initialize
38
- @categories_map = {}
38
+ @feature_categories_map = {}
39
39
  @hierarchy = {}
40
40
 
41
41
  yaml_file = fetch_yaml_file
@@ -43,12 +43,12 @@ module GitlabQuality
43
43
  populate_ownership_hierarchy(yaml_content)
44
44
  end
45
45
 
46
- # @return [Array<Hash>] Flattened category ownership
46
+ # @return [Array<Hash>] Flattened feature category ownership
47
47
  # @example Return value
48
48
  # [
49
- # { category: "team_planning", group: "project_management", stage: "plan", section: "dev" },
50
- # { category: "service_desk", group: "project_management", stage: "plan", section: "dev" },
51
- # { category: "portfolio_management", group: "product_planning", stage: "plan", section: "dev" }
49
+ # { feature_category: "team_planning", group: "project_management", stage: "plan", section: "dev" },
50
+ # { feature_category: "service_desk", group: "project_management", stage: "plan", section: "dev" },
51
+ # { feature_category: "portfolio_management", group: "product_planning", stage: "plan", section: "dev" }
52
52
  # ...
53
53
  # ]
54
54
  def as_db_table
@@ -58,10 +58,10 @@ module GitlabQuality
58
58
  stages.each do |stage, groups|
59
59
  next unless groups
60
60
 
61
- groups.each do |group, categories|
62
- Array(categories).each do |category|
61
+ groups.each do |group, feature_categories|
62
+ Array(feature_categories).each do |feature_category|
63
63
  flattened_hierarchy << {
64
- category: category,
64
+ feature_category: feature_category,
65
65
  group: group,
66
66
  stage: stage,
67
67
  section: section
@@ -72,7 +72,7 @@ module GitlabQuality
72
72
  end
73
73
  end
74
74
 
75
- # @return [Hash] Mapping of categories to teams (i.e., groups, stages, sections)
75
+ # @return [Hash] Mapping of feature categories to teams (i.e., groups, stages, sections)
76
76
  # @example Return value
77
77
  # {
78
78
  # "team_planning" => { group: "project_management", stage: "plan", section: "dev" },
@@ -80,9 +80,9 @@ module GitlabQuality
80
80
  # "portfolio_management" => { group: "product_planning", stage: "plan", section: "dev" },
81
81
  # ...
82
82
  # }
83
- def categories_to_teams
84
- populate_categories_map(hierarchy)
85
- @categories_map
83
+ def feature_categories_to_teams
84
+ populate_feature_categories_map(hierarchy)
85
+ @feature_categories_map
86
86
  end
87
87
 
88
88
  private
@@ -126,25 +126,25 @@ module GitlabQuality
126
126
  @hierarchy[section][stage][group] = categories || []
127
127
  end
128
128
 
129
- def populate_categories_map(data, current_section = nil, current_stage = nil, current_group = nil)
129
+ def populate_feature_categories_map(data, current_section = nil, current_stage = nil, current_group = nil)
130
130
  case data
131
131
  when Hash # Sections / Stages / Groups
132
132
  data.each do |key, value|
133
133
  if current_section.nil? # Sections
134
- populate_categories_map(value, key, nil, nil)
134
+ populate_feature_categories_map(value, key, nil, nil)
135
135
  elsif current_stage.nil? # Stages
136
- populate_categories_map(value, current_section, key, nil)
136
+ populate_feature_categories_map(value, current_section, key, nil)
137
137
  elsif current_group.nil? # Groups
138
- populate_categories_map(value, current_section, current_stage, key)
139
- else # Categories
140
- populate_categories_map(value, current_section, current_stage, current_group)
138
+ populate_feature_categories_map(value, current_section, current_stage, key)
139
+ else # Feature categories
140
+ populate_feature_categories_map(value, current_section, current_stage, current_group)
141
141
  end
142
142
  end
143
- when Array # Categories array
144
- data.each do |category|
145
- next unless category.is_a?(String)
143
+ when Array # Feature categories array
144
+ data.each do |feature_category|
145
+ next unless feature_category.is_a?(String)
146
146
 
147
- @categories_map[category] = {
147
+ @feature_categories_map[feature_category] = {
148
148
  section: current_section,
149
149
  stage: current_stage,
150
150
  group: current_group
@@ -42,14 +42,14 @@ module GitlabQuality
42
42
  logger.info("#{LOG_PREFIX} Successfully truncated table #{full_table_name}")
43
43
  end
44
44
 
45
- # Owners of particular category as group, stage and section
45
+ # Owners of particular feature category as group, stage and section
46
46
  #
47
- # @param category_name [String]
47
+ # @param feature_category_name [String] the feature_category name
48
48
  # @return [Hash]
49
- def owners(category_name)
50
- records.fetch(category_name)
49
+ def owners(feature_category_name)
50
+ records.fetch(feature_category_name)
51
51
  rescue KeyError
52
- raise(MissingMappingError, "Category '#{category_name}' not found in table '#{table_name}'")
52
+ raise(MissingMappingError, "Feature category '#{feature_category_name}' not found in table '#{table_name}'")
53
53
  end
54
54
 
55
55
  private
@@ -23,6 +23,8 @@ module GitlabQuality
23
23
  branch_coverage Nullable(Float64),
24
24
  function_coverage Nullable(Float64),
25
25
  source_file_type String,
26
+ is_responsible Nullable(Bool),
27
+ is_dependent Nullable(Bool),
26
28
  category Nullable(String),
27
29
  ci_project_id Nullable(UInt32),
28
30
  ci_project_path Nullable(String),
@@ -97,7 +99,9 @@ module GitlabQuality
97
99
  branch_coverage: record[:branch_coverage],
98
100
  function_coverage: record[:function_coverage],
99
101
  source_file_type: record[:source_file_type],
100
- category: record[:category],
102
+ is_responsible: record[:is_responsible],
103
+ is_dependent: record[:is_dependent],
104
+ category: record[:feature_category],
101
105
  **ci_metadata
102
106
  }
103
107
  end
@@ -4,22 +4,31 @@ module GitlabQuality
4
4
  module TestTooling
5
5
  module CodeCoverage
6
6
  class CoverageData
7
+ RESPONSIBLE = 'responsible'
8
+ DEPENDENT = 'dependent'
9
+
7
10
  # @param [Hash<String, Hash>] code_coverage_by_source_file Source file
8
11
  # mapped to test coverage data
9
12
  # @param [Hash<String, Array<String>>] source_file_to_tests Source files
10
13
  # mapped to all test files testing them
11
- # @param [Hash<String, Array<String>>] tests_to_categories Test files
14
+ # @param [Hash<String, Array<String>>] tests_to_feature_categories Test files
12
15
  # mapped to all feature categories they belong to
13
- # @param [Hash<String, Hash>] categories_to_teams Mapping of categories
16
+ # @param [Hash<String, Hash>] feature_categories_to_teams Mapping of feature categories
14
17
  # to teams (i.e., groups, stages, sections)
15
18
  # @param [Hash<String, String>] source_file_types Mapping of source files
16
19
  # to their types (frontend, backend, etc.)
17
- def initialize(code_coverage_by_source_file, source_file_to_tests, tests_to_categories, categories_to_teams, source_file_types = {})
20
+ # @param [Hash<String, String>] test_classifications Mapping of test files
21
+ # to their responsibility classification (responsible or dependent)
22
+ def initialize(
23
+ code_coverage_by_source_file, source_file_to_tests, tests_to_feature_categories,
24
+ feature_categories_to_teams, source_file_types = {}, test_classifications = {}
25
+ )
18
26
  @code_coverage_by_source_file = code_coverage_by_source_file
19
27
  @source_file_to_tests = source_file_to_tests
20
- @tests_to_categories = tests_to_categories
21
- @categories_to_teams = categories_to_teams
28
+ @tests_to_feature_categories = tests_to_feature_categories
29
+ @feature_categories_to_teams = feature_categories_to_teams
22
30
  @source_file_types = source_file_types
31
+ @test_classifications = test_classifications
23
32
  end
24
33
 
25
34
  # @return [Array<Hash<Symbol, String>>] Mapping of column name to row
@@ -32,7 +41,9 @@ module GitlabQuality
32
41
  # branch_coverage: 95.0
33
42
  # function_coverage: 100.0
34
43
  # source_file_type: "backend"
35
- # category: "team_planning"
44
+ # is_responsible: true
45
+ # is_dependent: false
46
+ # feature_category: "team_planning"
36
47
  # group: "project_management"
37
48
  # stage: "plan"
38
49
  # section: "dev"
@@ -40,55 +51,81 @@ module GitlabQuality
40
51
  # ...
41
52
  # ]
42
53
  def as_db_table
43
- all_files.flat_map do |file|
44
- coverage_data = @code_coverage_by_source_file[file]
45
- line_coverage = coverage_data&.dig(:percentage)
46
- branch_coverage = coverage_data&.dig(:branch_percentage)
47
- function_coverage = coverage_data&.dig(:function_percentage)
48
-
49
- categories = categories_for(file)
50
- base_data = {
51
- file: file,
52
- line_coverage: line_coverage,
53
- branch_coverage: branch_coverage,
54
- function_coverage: function_coverage,
55
- source_file_type: @source_file_types[file] || 'other'
56
- }
57
-
58
- if categories.empty?
59
- base_data.merge(no_owner_info)
60
- else
61
- categories.map do |category|
62
- base_data.merge(owner_info(category))
63
- end
54
+ all_files.flat_map { |file| records_for_file(file) }
55
+ end
56
+
57
+ private
58
+
59
+ def records_for_file(file)
60
+ base_data = base_data_for(file)
61
+ feature_categories_with_flags = feature_categories_with_responsibility_flags_for(file)
62
+
63
+ if feature_categories_with_flags.empty?
64
+ base_data.merge(no_owner_info).merge(is_responsible: nil, is_dependent: nil)
65
+ else
66
+ feature_categories_with_flags.map do |feature_category, flags|
67
+ base_data.merge(owner_info(feature_category)).merge(
68
+ is_responsible: flags[:is_responsible],
69
+ is_dependent: flags[:is_dependent]
70
+ )
64
71
  end
65
72
  end
66
73
  end
67
74
 
68
- private
75
+ def base_data_for(file)
76
+ coverage_data = @code_coverage_by_source_file[file]
77
+
78
+ {
79
+ file: file,
80
+ line_coverage: coverage_data&.dig(:percentage),
81
+ branch_coverage: coverage_data&.dig(:branch_percentage),
82
+ function_coverage: coverage_data&.dig(:function_percentage),
83
+ source_file_type: @source_file_types[file] || 'other'
84
+ }
85
+ end
69
86
 
70
87
  def no_owner_info
71
88
  {
72
- category: nil,
89
+ feature_category: nil,
73
90
  group: nil,
74
91
  stage: nil,
75
92
  section: nil
76
93
  }
77
94
  end
78
95
 
79
- def owner_info(category)
80
- owner_info = @categories_to_teams[category]
96
+ def owner_info(feature_category)
97
+ owner_info = @feature_categories_to_teams[feature_category]
81
98
 
82
99
  {
83
- category: category,
100
+ feature_category: feature_category,
84
101
  group: owner_info&.dig(:group),
85
102
  stage: owner_info&.dig(:stage),
86
103
  section: owner_info&.dig(:section)
87
104
  }
88
105
  end
89
106
 
90
- def categories_for(file)
91
- @source_file_to_tests[file]&.flat_map { |test_file| @tests_to_categories[test_file] || [] }&.uniq || []
107
+ # Returns a hash of feature_category => { is_responsible: bool, is_dependent: bool }
108
+ # for a given source file. A feature category can have both flags true if it has
109
+ # both unit tests (responsible) and integration/E2E tests (dependent).
110
+ def feature_categories_with_responsibility_flags_for(file)
111
+ test_files = @source_file_to_tests[file] || []
112
+ return {} if test_files.empty?
113
+
114
+ test_files.each_with_object({}) do |test_file, feature_category_to_flags|
115
+ feature_categories = @tests_to_feature_categories[test_file] || []
116
+ classification = @test_classifications[test_file]
117
+
118
+ feature_categories.each do |feature_category|
119
+ feature_category_to_flags[feature_category] ||= { is_responsible: false, is_dependent: false }
120
+
121
+ case classification
122
+ when RESPONSIBLE
123
+ feature_category_to_flags[feature_category][:is_responsible] = true
124
+ when DEPENDENT
125
+ feature_category_to_flags[feature_category][:is_dependent] = true
126
+ end
127
+ end
128
+ end
92
129
  end
93
130
 
94
131
  def all_files
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module CodeCoverage
6
+ class ResponsibilityClassifier
7
+ RESPONSIBLE = 'responsible'
8
+ DEPENDENT = 'dependent'
9
+
10
+ # @param test_to_sources [Hash<String, Array<String>>] Test files mapped to source files they cover
11
+ # @param responsible_patterns [Array<Regexp>] Patterns for unit tests
12
+ # @param dependent_patterns [Array<Regexp>] Patterns for integration/E2E tests
13
+ def initialize(test_to_sources, responsible_patterns:, dependent_patterns:)
14
+ @test_to_sources = test_to_sources
15
+ @responsible_patterns = responsible_patterns
16
+ @dependent_patterns = dependent_patterns
17
+ end
18
+
19
+ # Classifies each test file as responsible or dependent
20
+ # @return [Hash<String, String>] Test file path => classification
21
+ def classify_tests
22
+ @test_to_sources.keys.each_with_object({}) do |test_file, result|
23
+ result[test_file] = classify_test(test_file)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ # Classifies a test file as responsible (unit) or dependent (integration/E2E).
30
+ #
31
+ # Dependent patterns are checked first because it's the safer default:
32
+ # - is_responsible: true claims "this file has unit test coverage"
33
+ # - is_dependent: true claims "this file has integration test coverage"
34
+ #
35
+ # If uncertain (overlapping patterns or no match), we default to dependent
36
+ # to avoid incorrectly inflating unit test coverage metrics.
37
+ def classify_test(test_file)
38
+ return DEPENDENT if @dependent_patterns.any? { |p| test_file.match?(p) }
39
+ return RESPONSIBLE if @responsible_patterns.any? { |p| test_file.match?(p) }
40
+
41
+ # Default to dependent for unknown test types
42
+ DEPENDENT
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module GitlabQuality
6
+ module TestTooling
7
+ module CodeCoverage
8
+ class ResponsibilityPatternsConfig
9
+ ConfigError = Class.new(StandardError)
10
+
11
+ attr_reader :responsible_patterns, :dependent_patterns
12
+
13
+ # @param file_path [String] Path to YAML config file
14
+ # @raise [ConfigError] if file cannot be loaded or parsed
15
+ def initialize(file_path)
16
+ @file_path = file_path
17
+ @config = load_config
18
+ @responsible_patterns = parse_patterns('responsible')
19
+ @dependent_patterns = parse_patterns('dependent')
20
+ end
21
+
22
+ private
23
+
24
+ def load_config
25
+ YAML.load_file(@file_path)
26
+ rescue Errno::ENOENT
27
+ raise ConfigError, "Config file not found: #{@file_path}"
28
+ rescue Psych::SyntaxError => e
29
+ raise ConfigError, "Invalid YAML syntax in #{@file_path}: #{e.message}"
30
+ end
31
+
32
+ def parse_patterns(key)
33
+ patterns = @config[key]
34
+
35
+ raise ConfigError, "Missing or invalid '#{key}' key in #{@file_path}. Expected an array of patterns." unless patterns.is_a?(Array)
36
+
37
+ patterns.map do |pattern|
38
+ Regexp.new(pattern)
39
+ rescue RegexpError => e
40
+ raise ConfigError, "Invalid regex pattern '#{pattern}' in #{@file_path}: #{e.message}"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GitlabQuality
4
4
  module TestTooling
5
- VERSION = "3.1.0"
5
+ VERSION = "3.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab_quality-test_tooling
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.0
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab Quality
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-12-03 00:00:00.000000000 Z
11
+ date: 2025-12-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: climate_control
@@ -484,6 +484,7 @@ files:
484
484
  - lefthook.yml
485
485
  - lib/gitlab_quality/test_tooling.rb
486
486
  - lib/gitlab_quality/test_tooling/click_house/client.rb
487
+ - lib/gitlab_quality/test_tooling/code_coverage/README.md
487
488
  - lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb
488
489
  - lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb
489
490
  - lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb
@@ -491,6 +492,8 @@ files:
491
492
  - lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb
492
493
  - lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb
493
494
  - lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb
495
+ - lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier.rb
496
+ - lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config.rb
494
497
  - lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb
495
498
  - lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier.rb
496
499
  - lib/gitlab_quality/test_tooling/code_coverage/test_map.rb