fun_ci 2.0.0-arm64-darwin

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 (200) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +294 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +230 -0
  5. data/exe/fun-ci +7 -0
  6. data/lib/fun_ci/agent/age.rb +15 -0
  7. data/lib/fun_ci/agent/command_support.rb +27 -0
  8. data/lib/fun_ci/agent/commands.rb +27 -0
  9. data/lib/fun_ci/agent/context.rb +10 -0
  10. data/lib/fun_ci/agent/digest.rb +49 -0
  11. data/lib/fun_ci/agent/events.rb +47 -0
  12. data/lib/fun_ci/agent/events_command.rb +61 -0
  13. data/lib/fun_ci/agent/evidence_text.rb +46 -0
  14. data/lib/fun_ci/agent/exit_code.rb +11 -0
  15. data/lib/fun_ci/agent/git.rb +31 -0
  16. data/lib/fun_ci/agent/live_pipeline.rb +35 -0
  17. data/lib/fun_ci/agent/options.rb +75 -0
  18. data/lib/fun_ci/agent/output.rb +81 -0
  19. data/lib/fun_ci/agent/report_stage.rb +73 -0
  20. data/lib/fun_ci/agent/reports.rb +47 -0
  21. data/lib/fun_ci/agent/run_report.rb +30 -0
  22. data/lib/fun_ci/agent/runs_command.rb +34 -0
  23. data/lib/fun_ci/agent/runs_text.rb +27 -0
  24. data/lib/fun_ci/agent/snapshots.rb +39 -0
  25. data/lib/fun_ci/agent/stage_summary.rb +30 -0
  26. data/lib/fun_ci/agent/status_command.rb +36 -0
  27. data/lib/fun_ci/agent/status_json.rb +27 -0
  28. data/lib/fun_ci/agent/status_text.rb +37 -0
  29. data/lib/fun_ci/agent/system_clock.rb +11 -0
  30. data/lib/fun_ci/agent/verdict.rb +32 -0
  31. data/lib/fun_ci/agent/wait_command.rb +71 -0
  32. data/lib/fun_ci/agent/waiting.rb +32 -0
  33. data/lib/fun_ci/agent/why_command.rb +50 -0
  34. data/lib/fun_ci/agent/why_json.rb +35 -0
  35. data/lib/fun_ci/agent/why_text.rb +43 -0
  36. data/lib/fun_ci/cli.rb +128 -0
  37. data/lib/fun_ci/cli_help.rb +65 -0
  38. data/lib/fun_ci/console/board_data.rb +66 -0
  39. data/lib/fun_ci/console/board_heartbeat.rb +40 -0
  40. data/lib/fun_ci/console/console_events.rb +20 -0
  41. data/lib/fun_ci/console/console_log.rb +26 -0
  42. data/lib/fun_ci/console/console_loop.rb +41 -0
  43. data/lib/fun_ci/console/console_session.rb +82 -0
  44. data/lib/fun_ci/console/console_state.rb +42 -0
  45. data/lib/fun_ci/console/key_handler.rb +73 -0
  46. data/lib/fun_ci/console/launcher.rb +43 -0
  47. data/lib/fun_ci/console/milestone_events.rb +38 -0
  48. data/lib/fun_ci/console/milestones.rb +25 -0
  49. data/lib/fun_ci/console/renderer_lookup.rb +51 -0
  50. data/lib/fun_ci/console/renderer_process.rb +60 -0
  51. data/lib/fun_ci/console/run_message.rb +33 -0
  52. data/lib/fun_ci/console/stage_change_detector.rb +32 -0
  53. data/lib/fun_ci/console/stage_events.rb +31 -0
  54. data/lib/fun_ci/console/stop_report.rb +39 -0
  55. data/lib/fun_ci/console/streak_counter.rb +19 -0
  56. data/lib/fun_ci/console/view.rb +47 -0
  57. data/lib/fun_ci/evidence/about.rb +12 -0
  58. data/lib/fun_ci/evidence/about_stage.rb +29 -0
  59. data/lib/fun_ci/evidence/caps.rb +77 -0
  60. data/lib/fun_ci/evidence/catalog.rb +83 -0
  61. data/lib/fun_ci/evidence/collector.rb +95 -0
  62. data/lib/fun_ci/evidence/command_output.rb +45 -0
  63. data/lib/fun_ci/evidence/command_reading.rb +38 -0
  64. data/lib/fun_ci/evidence/command_runner.rb +75 -0
  65. data/lib/fun_ci/evidence/context.rb +21 -0
  66. data/lib/fun_ci/evidence/context_document.rb +29 -0
  67. data/lib/fun_ci/evidence/deadline.rb +15 -0
  68. data/lib/fun_ci/evidence/detection.rb +82 -0
  69. data/lib/fun_ci/evidence/document.rb +59 -0
  70. data/lib/fun_ci/evidence/extract_command.rb +83 -0
  71. data/lib/fun_ci/evidence/extract_options.rb +45 -0
  72. data/lib/fun_ci/evidence/extraction.rb +54 -0
  73. data/lib/fun_ci/evidence/extractors/command.rb +52 -0
  74. data/lib/fun_ci/evidence/extractors/grep.rb +49 -0
  75. data/lib/fun_ci/evidence/extractors/json_log.rb +45 -0
  76. data/lib/fun_ci/evidence/extractors/junit_files.rb +32 -0
  77. data/lib/fun_ci/evidence/extractors/last_lines.rb +25 -0
  78. data/lib/fun_ci/evidence/extractors/log_file.rb +62 -0
  79. data/lib/fun_ci/evidence/extractors/process_tree.rb +51 -0
  80. data/lib/fun_ci/evidence/extractors/section.rb +49 -0
  81. data/lib/fun_ci/evidence/findings.rb +14 -0
  82. data/lib/fun_ci/evidence/line_ranges.rb +21 -0
  83. data/lib/fun_ci/evidence/line_scan.rb +22 -0
  84. data/lib/fun_ci/evidence/log_record.rb +58 -0
  85. data/lib/fun_ci/evidence/masking.rb +55 -0
  86. data/lib/fun_ci/evidence/option_check.rb +53 -0
  87. data/lib/fun_ci/evidence/outcome.rb +15 -0
  88. data/lib/fun_ci/evidence/patterns.rb +22 -0
  89. data/lib/fun_ci/evidence/presets/README.md +7 -0
  90. data/lib/fun_ci/evidence/presets/bun-test.yml +6 -0
  91. data/lib/fun_ci/evidence/presets/cargo-test.yml +6 -0
  92. data/lib/fun_ci/evidence/presets/dart-test.yml +6 -0
  93. data/lib/fun_ci/evidence/presets/deno-test.yml +6 -0
  94. data/lib/fun_ci/evidence/presets/dotnet-test.yml +6 -0
  95. data/lib/fun_ci/evidence/presets/ecs.yml +3 -0
  96. data/lib/fun_ci/evidence/presets/eslint.yml +6 -0
  97. data/lib/fun_ci/evidence/presets/exunit.yml +6 -0
  98. data/lib/fun_ci/evidence/presets/gcc.yml +6 -0
  99. data/lib/fun_ci/evidence/presets/go-build.yml +5 -0
  100. data/lib/fun_ci/evidence/presets/go-test.yml +6 -0
  101. data/lib/fun_ci/evidence/presets/gradle.yml +6 -0
  102. data/lib/fun_ci/evidence/presets/gtest.yml +6 -0
  103. data/lib/fun_ci/evidence/presets/jest.yml +6 -0
  104. data/lib/fun_ci/evidence/presets/logstash.yml +3 -0
  105. data/lib/fun_ci/evidence/presets/maven.yml +6 -0
  106. data/lib/fun_ci/evidence/presets/minitest.yml +6 -0
  107. data/lib/fun_ci/evidence/presets/mocha.yml +6 -0
  108. data/lib/fun_ci/evidence/presets/mypy.yml +6 -0
  109. data/lib/fun_ci/evidence/presets/node-test.yml +6 -0
  110. data/lib/fun_ci/evidence/presets/phpunit.yml +6 -0
  111. data/lib/fun_ci/evidence/presets/pino.yml +5 -0
  112. data/lib/fun_ci/evidence/presets/prove.yml +6 -0
  113. data/lib/fun_ci/evidence/presets/pytest.yml +6 -0
  114. data/lib/fun_ci/evidence/presets/rspec.yml +6 -0
  115. data/lib/fun_ci/evidence/presets/rubocop.yml +6 -0
  116. data/lib/fun_ci/evidence/presets/ruff.yml +6 -0
  117. data/lib/fun_ci/evidence/presets/rustc.yml +6 -0
  118. data/lib/fun_ci/evidence/presets/shellcheck.yml +5 -0
  119. data/lib/fun_ci/evidence/presets/structlog.yml +4 -0
  120. data/lib/fun_ci/evidence/presets/swift-test.yml +5 -0
  121. data/lib/fun_ci/evidence/presets/tsc.yml +6 -0
  122. data/lib/fun_ci/evidence/presets/unittest.yml +6 -0
  123. data/lib/fun_ci/evidence/presets/vitest.yml +6 -0
  124. data/lib/fun_ci/evidence/presets.rb +30 -0
  125. data/lib/fun_ci/evidence/problem.rb +9 -0
  126. data/lib/fun_ci/evidence/process_table.rb +40 -0
  127. data/lib/fun_ci/evidence/saved_run.rb +22 -0
  128. data/lib/fun_ci/evidence/section_walk.rb +49 -0
  129. data/lib/fun_ci/evidence/settings.rb +64 -0
  130. data/lib/fun_ci/evidence/settings_check.rb +78 -0
  131. data/lib/fun_ci/evidence/source.rb +34 -0
  132. data/lib/fun_ci/evidence/sources.rb +21 -0
  133. data/lib/fun_ci/evidence/stage_context.rb +30 -0
  134. data/lib/fun_ci/evidence/stamp.rb +10 -0
  135. data/lib/fun_ci/evidence/start.rb +36 -0
  136. data/lib/fun_ci/evidence/worktree.rb +62 -0
  137. data/lib/fun_ci/persistence/active_runs.rb +69 -0
  138. data/lib/fun_ci/persistence/database.rb +81 -0
  139. data/lib/fun_ci/persistence/null_recorder.rb +25 -0
  140. data/lib/fun_ci/persistence/output_tail.rb +24 -0
  141. data/lib/fun_ci/persistence/pipeline_recorder.rb +120 -0
  142. data/lib/fun_ci/persistence/pipeline_run.rb +68 -0
  143. data/lib/fun_ci/persistence/project_runs.rb +52 -0
  144. data/lib/fun_ci/persistence/raw_outputs.rb +46 -0
  145. data/lib/fun_ci/persistence/retention.rb +25 -0
  146. data/lib/fun_ci/persistence/run_status.rb +31 -0
  147. data/lib/fun_ci/persistence/stage_job.rb +91 -0
  148. data/lib/fun_ci/persistence/state_dir.rb +26 -0
  149. data/lib/fun_ci/persistence/write_trouble.rb +46 -0
  150. data/lib/fun_ci/pipeline/background_fork.rb +42 -0
  151. data/lib/fun_ci/pipeline/command_executor.rb +58 -0
  152. data/lib/fun_ci/pipeline/gate.rb +35 -0
  153. data/lib/fun_ci/pipeline/git_environment.rb +16 -0
  154. data/lib/fun_ci/pipeline/head_part.rb +40 -0
  155. data/lib/fun_ci/pipeline/in_place.rb +17 -0
  156. data/lib/fun_ci/pipeline/output_window.rb +73 -0
  157. data/lib/fun_ci/pipeline/pipeline_forker.rb +40 -0
  158. data/lib/fun_ci/pipeline/process_runner.rb +101 -0
  159. data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
  160. data/lib/fun_ci/pipeline/run_canceller.rb +75 -0
  161. data/lib/fun_ci/pipeline/slot.rb +30 -0
  162. data/lib/fun_ci/pipeline/slot_lock.rb +13 -0
  163. data/lib/fun_ci/pipeline/slot_run.rb +86 -0
  164. data/lib/fun_ci/pipeline/stage_dir.rb +50 -0
  165. data/lib/fun_ci/pipeline/stage_end.rb +70 -0
  166. data/lib/fun_ci/pipeline/stage_execution.rb +67 -0
  167. data/lib/fun_ci/pipeline/stage_runner.rb +63 -0
  168. data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +36 -0
  169. data/lib/fun_ci/pipeline/tail_segments.rb +60 -0
  170. data/lib/fun_ci/pipeline/test_report.rb +48 -0
  171. data/lib/fun_ci/pipeline/trigger.rb +93 -0
  172. data/lib/fun_ci/pipeline/trigger_command.rb +62 -0
  173. data/lib/fun_ci/pipeline/trigger_params.rb +48 -0
  174. data/lib/fun_ci/pipeline/workspaces.rb +22 -0
  175. data/lib/fun_ci/pipeline/worktree_pool.rb +66 -0
  176. data/lib/fun_ci/pipeline/worktree_prune.rb +49 -0
  177. data/lib/fun_ci/pipeline/worktrees.rb +45 -0
  178. data/lib/fun_ci/setup/agent_instructions.rb +31 -0
  179. data/lib/fun_ci/setup/commands.rb +49 -0
  180. data/lib/fun_ci/setup/git_hooks.rb +20 -0
  181. data/lib/fun_ci/setup/hook_script.rb +47 -0
  182. data/lib/fun_ci/setup/hook_writer.rb +57 -0
  183. data/lib/fun_ci/setup/installer.rb +71 -0
  184. data/lib/fun_ci/setup/legacy_hooks.rb +38 -0
  185. data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
  186. data/lib/fun_ci/setup/project_config.rb +68 -0
  187. data/lib/fun_ci/setup/project_detector.rb +54 -0
  188. data/lib/fun_ci/setup/settings.rb +36 -0
  189. data/lib/fun_ci/setup/setup_checker.rb +36 -0
  190. data/lib/fun_ci/setup/stage_templates/javascript.rb +18 -0
  191. data/lib/fun_ci/setup/stage_templates/managed.rb +24 -0
  192. data/lib/fun_ci/setup/stage_templates/native.rb +25 -0
  193. data/lib/fun_ci/setup/stage_templates/scripting.rb +27 -0
  194. data/lib/fun_ci/setup/stage_templates/shell.rb +16 -0
  195. data/lib/fun_ci/setup/stage_templates.rb +25 -0
  196. data/lib/fun_ci/setup/template_writer.rb +28 -0
  197. data/lib/fun_ci/version.rb +6 -0
  198. data/lib/fun_ci.rb +13 -0
  199. data/libexec/fun-ci-renderer +0 -0
  200. metadata +286 -0
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../findings"
4
+ require_relative "../source"
5
+ require_relative "../line_scan"
6
+ require_relative "../patterns"
7
+ require_relative "../section_walk"
8
+
9
+ module FunCi
10
+ module Evidence
11
+ module Extractors
12
+ # `section`: the lines of the output, or of a file, from a line that
13
+ # matches any of `start` up to one that matches any of `end` (or the
14
+ # last line), each section at most `lines` long. A preset names a tool's
15
+ # sections, such as rspec's failures.
16
+ class Section
17
+ OPTIONS = { "start" => :patterns, "end" => :patterns, "path" => :path, "lines" => :count,
18
+ "title" => :word }.freeze
19
+ REQUIRED = %w[start].freeze
20
+ LINES = 200
21
+
22
+ def initialize(options)
23
+ @options = options
24
+ end
25
+
26
+ def extract(context) = found_in(Source.for(context, @options["path"]), context.deadline)
27
+
28
+ def found_in(source, deadline)
29
+ walk = new_walk
30
+ truncated = LineScan.cut_short?(source.lines, deadline) { |line, index| walk.step(line, index) }
31
+ Findings.new(excerpts: walk.finish(source.lines).map { |found| excerpt(source, found, truncated) })
32
+ end
33
+
34
+ private
35
+
36
+ def new_walk
37
+ SectionWalk.new(Patterns.compile(@options["start"]), Patterns.compile(@options["end"]),
38
+ @options.fetch("lines", LINES))
39
+ end
40
+
41
+ def excerpt(source, found, truncated)
42
+ { title: @options.fetch("title") { "Lines from #{Array(@options["start"]).join(" or ")}" },
43
+ location: source.location(found.first, found.last), lines: source.lines[found.first..found.last],
44
+ truncated: truncated || found.cut }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Evidence
5
+ # What an extractor found: facts ({ name:, value: }), failures ({ test:,
6
+ # file:, line:, message:, output: }) and excerpts ({ title:, location:,
7
+ # lines:, truncated: }).
8
+ Findings = Data.define(:facts, :failures, :excerpts)
9
+
10
+ class Findings
11
+ def initialize(facts: [], failures: [], excerpts: []) = super
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Evidence
5
+ # The runs of lines around chosen lines: `context` lines either side of
6
+ # each, within 0...size, joined where they meet.
7
+ module LineRanges
8
+ def self.around(indexes, context, size)
9
+ indexes.map { |index| [index - context, 0].max..[index + context, size - 1].min }
10
+ .each_with_object([]) { |range, joined| join(joined, range) }
11
+ end
12
+
13
+ def self.join(joined, range)
14
+ return joined << range if joined.empty? || joined.last.last + 1 < range.first
15
+
16
+ joined[-1] = joined.last.first..[joined.last.last, range.last].max
17
+ end
18
+ private_class_method :join
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Evidence
5
+ # Walks lines for a built-in extractor, checking the deadline every
6
+ # CHECK_EVERY lines, since a built-in runs in fun-ci's own process and
7
+ # can't be killed.
8
+ module LineScan
9
+ CHECK_EVERY = 1000
10
+
11
+ # Yields each line and its index; answers whether the deadline stopped it first.
12
+ def self.cut_short?(lines, deadline)
13
+ lines.each_with_index do |line, index|
14
+ return true if (index % CHECK_EVERY).zero? && deadline.passed?
15
+
16
+ yield line, index
17
+ end
18
+ false
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module FunCi
6
+ module Evidence
7
+ # One JSON log line, read through the field names a logging setup uses
8
+ # (`fields`: time, level, logger, message, stack), each a key or a dotted
9
+ # path, and `levels`, the names of numeric levels.
10
+ class LogRecord
11
+ RANKS = %w[trace debug info warn error fatal].freeze
12
+ ALIASES = { "warning" => "warn", "critical" => "fatal", "err" => "error", "panic" => "fatal",
13
+ "verbose" => "debug" }.freeze
14
+
15
+ # The record on a line, or nil for one that isn't a JSON object.
16
+ def self.parse(line, fields, levels)
17
+ return nil unless line.lstrip.start_with?("{")
18
+
19
+ new(JSON.parse(line), fields, levels)
20
+ rescue JSON::ParserError
21
+ nil
22
+ end
23
+
24
+ def self.rank(level) = RANKS.index(ALIASES.fetch(level.to_s.downcase, level.to_s.downcase))
25
+
26
+ def initialize(parsed, fields, levels)
27
+ @parsed = parsed
28
+ @fields = fields
29
+ @levels = levels
30
+ end
31
+
32
+ def level = LogRecord.rank(@levels.fetch(field("level").to_s, field("level")))
33
+ def at_least?(rank) = !level.nil? && level >= rank
34
+
35
+ def lines
36
+ logger = field("logger")
37
+ head = [field("time"), RANKS[level].upcase, logger && "#{logger}:", field("message")].compact.join(" ")
38
+ [head, *stack.map { |line| " #{line}" }]
39
+ end
40
+
41
+ private
42
+
43
+ def stack
44
+ trace = field("stack")
45
+ trace.is_a?(Array) ? trace.map(&:to_s) : trace.to_s.lines(chomp: true)
46
+ end
47
+
48
+ def field(name)
49
+ key = @fields[name]
50
+ return nil unless key
51
+
52
+ @parsed.key?(key) ? @parsed[key] : @parsed.dig(*key.split("."))
53
+ rescue TypeError
54
+ nil
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Evidence
5
+ # Masks secrets in text before fun-ci keeps it (architecture.md, "Evidence
6
+ # of a failed stage"): the values of the stage's secret-named variables,
7
+ # well-known token shapes, and a project's own patterns. A best effort, not
8
+ # a guarantee. Given the environment as a hash; never reads ENV.
9
+ class Masking
10
+ SECRET_NAME = /TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|PRIVATE_KEY|CREDENTIAL/
11
+ SHORTEST = 8
12
+ SHAPES = [
13
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----/m,
14
+ /\bgh[pousr]_[A-Za-z0-9]{36,}/,
15
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
16
+ /\bxox[abpors]-[A-Za-z0-9-]{10,}/,
17
+ /Authorization: \K.+/i
18
+ ].freeze
19
+
20
+ # Masks nothing, for a project that turned masking off.
21
+ def self.none = new({}, shapes: [])
22
+
23
+ def initialize(environment, patterns: [], shapes: SHAPES)
24
+ @secrets = secrets(environment)
25
+ @patterns = shapes + patterns
26
+ end
27
+
28
+ # Every string in the document masked.
29
+ def document(document) = document.with(**document.to_h.transform_values { |items| deep(items) })
30
+
31
+ # Bytes that aren't UTF-8 become U+FFFD first, as patterns can't read them.
32
+ def mask(text)
33
+ text = text.dup.force_encoding(Encoding::UTF_8).scrub
34
+ masked = @secrets.reduce(text) { |current, (name, value)| current.gsub(value, "[masked:#{name}]") }
35
+ @patterns.reduce(masked) { |current, pattern| current.gsub(pattern, "[masked]") }
36
+ end
37
+
38
+ private
39
+
40
+ def deep(value)
41
+ return mask(value) if value.is_a?(String)
42
+ return value.map { |item| deep(item) } if value.is_a?(Array)
43
+ return value.transform_values { |item| deep(item) } if value.is_a?(Hash)
44
+
45
+ value
46
+ end
47
+
48
+ # Longest first, so a value that contains another is masked whole.
49
+ def secrets(environment)
50
+ environment.select { |name, value| name.match?(SECRET_NAME) && value.to_s.length >= SHORTEST }
51
+ .sort_by { |_name, value| -value.length }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "patterns"
4
+ require_relative "log_record"
5
+
6
+ module FunCi
7
+ module Evidence
8
+ # Checks the options of an entry for a built-in against what the built-in
9
+ # declares it takes (OPTIONS, each with a kind, and REQUIRED), and
10
+ # answers the first mistake, or nil.
11
+ module OptionCheck
12
+ KINDS = {
13
+ patterns: lambda { |value|
14
+ next "must be a list of patterns" unless Array(value).all?(String)
15
+
16
+ Array(value).lazy.filter_map { |source| Patterns.mistake(source) }.first
17
+ },
18
+ count: ->(value) { "must be a whole number, not #{value.inspect}" unless value.is_a?(Integer) && value >= 0 },
19
+ path: ->(value) { "must be a path, not #{value.inspect}" unless value.is_a?(String) && !value.empty? },
20
+ globs: ->(value) { "must be a list of paths, not #{value.inspect}" unless Array(value).all?(String) },
21
+ word: ->(value) { "must be a word, not #{value.inspect}" unless value.is_a?(String) },
22
+ level: lambda { |value|
23
+ "must be one of trace, debug, info, warn, error or fatal, not #{value.inspect}" unless LogRecord.rank(value)
24
+ },
25
+ fields: lambda { |value|
26
+ "must map time, level, logger, message and stack to field names" unless mapping_of_strings?(value)
27
+ },
28
+ levels: ->(value) { "must map each level's number to its name" unless mapping_of_strings?(value) }
29
+ }.freeze
30
+
31
+ def self.mapping_of_strings?(value) = value.is_a?(Hash) && value.values.all?(String)
32
+
33
+ def self.mistake(name, built_in, options)
34
+ unknown = options.keys.find { |key| !built_in::OPTIONS.key?(key) }
35
+ return "#{name} doesn't take '#{unknown}'" if unknown
36
+
37
+ missing = built_in::REQUIRED.find { |key| !options.key?(key) }
38
+ return "#{name} needs '#{missing}'" if missing
39
+
40
+ kinds(name, built_in, options)
41
+ end
42
+
43
+ def self.kinds(name, built_in, options)
44
+ options.each do |key, value|
45
+ problem = KINDS.fetch(built_in::OPTIONS.fetch(key)).call(value)
46
+ return "#{name}: '#{key}' #{problem}" if problem
47
+ end
48
+ nil
49
+ end
50
+ private_class_method :kinds
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Evidence
5
+ # How a failed stage ended, known once it has: its state in the words
6
+ # `status --json` uses, its exit status or signal, and the other stages
7
+ # that shared its slot while it ran, and for an overrun, what was found
8
+ # before the kill (an Extraction::Result).
9
+ Outcome = Data.define(:state, :exit_status, :signal, :alongside, :overrun)
10
+
11
+ class Outcome
12
+ def initialize(**given) = super(exit_status: nil, signal: nil, alongside: [], overrun: nil, **given)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Evidence
5
+ # Patterns a project or a preset supplies, each compiled with a timeout of
6
+ # its own rather than the process-wide Regexp.timeout, since stages run in
7
+ # threads side by side. The timeout bounds each match.
8
+ module Patterns
9
+ TIMEOUT = 1.0
10
+
11
+ def self.compile(sources, timeout: TIMEOUT) = Array(sources).map { |source| Regexp.new(source, timeout: timeout) }
12
+
13
+ # Why a pattern doesn't compile, or nil.
14
+ def self.mistake(source)
15
+ Regexp.new(source)
16
+ nil
17
+ rescue RegexpError => e
18
+ "has '#{source}', which doesn't compile: #{e.message}"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,7 @@
1
+ Presets: config entries fun-ci ships for popular tools, one file each, named
2
+ for the preset, in the shape a project would write an entry (`use:` and the
3
+ built-in's options), with when it applies: `markers`, files any of which the
4
+ worktree must have (a path or a glob, or `{ path:, contains: }`), and `signature`, a
5
+ pattern only the tool prints, matched within one line. Each is pinned by the
6
+ recorded output of a real failing run in `test/fixtures/evidence/<name>/`, and
7
+ none ships without one (`test/policy/test_evidence_fixtures.rb`).
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: Bun's failed tests
3
+ start: ['^\s*\d+ \| ', '^error: ', '^\(fail\) ']
4
+ end: ['^\((pass|skip|todo)\) ', '^ \d+ pass$', '^\S+:$']
5
+ markers: [bun.lockb, bun.lock, bunfig.toml]
6
+ signature: '^bun test v\d'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: cargo test's failed tests
3
+ start: ['^---- \S+ stdout ----$']
4
+ end: ['^test result: ', '^failures:$', '^---- \S+ stdout ----$']
5
+ markers: [Cargo.toml]
6
+ signature: '^test result: FAILED\. '
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: dart test's failures
3
+ start: ['^\d+:\d+ \+\d+( ~\d+)?( -\d+)?: .* \[E\]$']
4
+ end: ['^\d+:\d+ \+\d+( ~\d+)?( -\d+)?: ']
5
+ markers: [pubspec.yaml]
6
+ signature: '^\d+:\d+ \+\d+( ~\d+)?( -\d+)?: Some tests failed\.$'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: Deno's failing tests
3
+ start: ['^ ERRORS $']
4
+ end: ['^ FAILURES $']
5
+ markers: [deno.json, deno.jsonc]
6
+ signature: 'running \d+ tests? from \./'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: dotnet test's failed tests
3
+ start: ['^\s+Failed \S.* \[[^\]]+\]$']
4
+ end: ['^(Failed|Passed)!\s+- Failed:', '^Total tests: ']
5
+ markers: ['*.sln', '*.csproj', '*.fsproj']
6
+ signature: '^Failed!\s+- Failed:\s+\d+, Passed:\s+\d+'
@@ -0,0 +1,3 @@
1
+ use: json-log
2
+ fields: { time: '@timestamp', level: log.level, logger: log.logger, message: message, stack: error.stack_trace }
3
+ signature: '"log\.level": ?"'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: ESLint's problems, file by file
3
+ start: ['^/\S+$']
4
+ end: ['^\S+ \d+ problems? \(\d+ errors?, \d+ warnings?\)']
5
+ markers: [eslint.config.js, eslint.config.mjs, .eslintrc.json, .eslintrc.js]
6
+ signature: '\d+ problems? \(\d+ errors?, \d+ warnings?\)'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: ExUnit's failures
3
+ start: ['^\s+\d+\) (test|doctest|property) .* \(\S+\)$', '^\s+\d+\) \S+: failure on setup_all callback']
4
+ end: ['^Finished in [\d.]+ seconds \(']
5
+ markers: [mix.exs]
6
+ signature: '^Running ExUnit with seed: \d+'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: gcc's errors, file by file
3
+ start: ['^(In file included from )?\S+\.(c|cc|cp|cpp|cxx|c\+\+|h|hh|hpp|hxx):']
4
+ end: ['^g?make(\[\d+\])?: ', '^(cc|c\+\+|gcc|g\+\+|clang|clang\+\+) ']
5
+ markers: [Makefile, makefile, GNUmakefile, CMakeLists.txt]
6
+ signature: '^\S+\.(c|cc|cp|cpp|cxx|c\+\+|h|hh|hpp|hxx):\d+:\d+: (fatal )?error: '
@@ -0,0 +1,5 @@
1
+ use: grep
2
+ title: go build's and go vet's errors
3
+ patterns: ['^(vet: )?\S+\.go:\d+:\d+: ']
4
+ markers: [go.mod]
5
+ signature: '^(vet: )?\S+\.go:\d+:\d+: '
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: go test's failed tests
3
+ start: ['^\s*--- FAIL: ']
4
+ end: ['^(FAIL|ok)\s']
5
+ markers: [go.mod]
6
+ signature: '^\s*--- FAIL: \S+ \(\d+\.\d+s\)$'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: Gradle's failed tests, and what went wrong
3
+ start: [' FAILED$', '^\* What went wrong:']
4
+ end: ['^\d+ tests? completed', '^\* Try:']
5
+ markers: [build.gradle, build.gradle.kts, settings.gradle, settings.gradle.kts, gradlew]
6
+ signature: '^BUILD FAILED in '
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: GoogleTest's failures
3
+ start: [': Failure$']
4
+ end: ['^\[-{10}\] ', '^\[ RUN \] ']
5
+ markers: [{ path: CMakeLists.txt, contains: 'gtest' }]
6
+ signature: '^\[ FAILED \] '
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: Jest's failed tests
3
+ start: ['^\s+● ']
4
+ end: ['^Test Suites: ', '^(FAIL|PASS) ', '^\s+● ']
5
+ markers: [{ path: package.json, contains: '"jest"' }, jest.config.js, jest.config.ts, jest.config.mjs, jest.config.cjs]
6
+ signature: '^Test Suites: \d+ '
@@ -0,0 +1,3 @@
1
+ use: json-log
2
+ fields: { time: '@timestamp', level: level, logger: logger_name, message: message, stack: stack_trace }
3
+ signature: '"logger_name":'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: Maven's failed tests
3
+ start: ['<<< (FAILURE|ERROR)!', '^\[ERROR\] (Failures|Errors): $']
4
+ end: ['^\[INFO\] ?$', '^\[INFO\] Results:']
5
+ markers: [pom.xml, mvnw]
6
+ signature: '^\[INFO\] BUILD FAILURE'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: Minitest's failures and errors
3
+ start: ['^\s+\d+\) (Failure|Error):$']
4
+ end: ['^\d+ runs, \d+ assertions']
5
+ markers: [Gemfile, Rakefile]
6
+ signature: '^\d+ runs, \d+ assertions, \d+ failures, \d+ errors'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: Mocha's failing tests
3
+ start: ['^ \d+\) ']
4
+ end: ['^ \d+\) ']
5
+ markers: [{ path: package.json, contains: '"mocha"' }, .mocharc.yml, .mocharc.yaml, .mocharc.json, .mocharc.js, .mocharc.cjs]
6
+ signature: '^ \d+ passing \(\d+\w+\)$'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: mypy's type errors
3
+ start: ['^\S+\.pyi?:\d+(:\d+)?: (error|note): ']
4
+ end: ['^Found \d+ errors? in \d+ files? \(checked ']
5
+ markers: [{ path: pyproject.toml, contains: mypy }, mypy.ini, .mypy.ini, { path: setup.cfg, contains: mypy }]
6
+ signature: '^Found \d+ errors? in \d+ files? \(checked \d+ source files?\)$'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: Node's failed tests
3
+ start: ['^\s*not ok \d+ - ']
4
+ end: ['^\s*\.\.\.$']
5
+ markers: [{ path: package.json, contains: 'node --test' }]
6
+ signature: '^\s+failureType: '''
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: PHPUnit's errors and failures
3
+ start: ['^There (was|were) \d+ (errors?|failures?):$']
4
+ end: ['^(ERRORS|FAILURES)!$', '^Tests: \d+, Assertions: \d+']
5
+ markers: [phpunit.xml, phpunit.xml.dist, { path: composer.json, contains: phpunit }]
6
+ signature: '^Tests: \d+, Assertions: \d+, '
@@ -0,0 +1,5 @@
1
+ use: json-log
2
+ fields: { time: time, level: level, logger: name, message: msg, stack: err.stack }
3
+ levels: { '10': trace, '20': debug, '30': info, '40': warn, '50': error, '60': fatal }
4
+ markers: [{ path: package.json, contains: '"pino"' }]
5
+ signature: '"level":\d+,"time":\d+,"pid":\d+,"hostname":'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: prove's failed tests
3
+ start: ['^#\s+Failed test\b', '^Test Summary Report$']
4
+ end: ['^\S+\.t \.+ ', '^Files=\d+, Tests=\d+, ']
5
+ markers: [cpanfile, Makefile.PL, Build.PL, dist.ini]
6
+ signature: '^Files=\d+, Tests=\d+, '
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: pytest's failures and errors
3
+ start: ['^=+ (FAILURES|ERRORS) =+$']
4
+ end: ['^=+ (short test summary info|warnings summary) =+$', '^=+ .* in [\d.]+s( \([\d:]+\))? =+$']
5
+ markers: [pyproject.toml, pytest.ini, setup.cfg, tox.ini, conftest.py]
6
+ signature: '^=+ (FAILURES|ERRORS|short test summary info) =+$'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: rspec's failures
3
+ start: ['^Failures:$']
4
+ end: ['^Finished in ']
5
+ markers: [Gemfile, .rspec]
6
+ signature: '^rspec \./'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: RuboCop's offenses
3
+ start: ['^Offenses:$']
4
+ end: ['^\d+ files? inspected, ']
5
+ markers: [.rubocop.yml, { path: Gemfile, contains: rubocop }]
6
+ signature: '^\d+ files? inspected, (\d+|no) offenses? detected'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: ruff's lint errors
3
+ start: ['^[A-Z]+[0-9]+ ', '^\S+:\d+:\d+: [A-Z]+[0-9]+ ']
4
+ end: ['^Found \d+ errors?']
5
+ markers: [{ path: pyproject.toml, contains: ruff }, ruff.toml, .ruff.toml, { path: .pre-commit-config.yaml, contains: ruff }]
6
+ signature: '^Found \d+ errors?( \(\d+ fixed, \d+ remaining\))?\.$'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: rustc's errors
3
+ start: ['^error(\[E\d+\])?: (?!could not compile |aborting due to |test failed)']
4
+ end: ['^$']
5
+ markers: [Cargo.toml]
6
+ signature: '^error\[E\d{4}\]: '
@@ -0,0 +1,5 @@
1
+ use: section
2
+ title: ShellCheck's findings
3
+ start: ['^In .+ line \d+:$', '^Did you mean:$']
4
+ end: ['^$']
5
+ signature: '\^-*\^? SC\d{4} \((error|warning|info|style)\): '
@@ -0,0 +1,4 @@
1
+ use: json-log
2
+ fields: { time: timestamp, level: level, logger: logger, message: event, stack: exception }
3
+ markers: [{ path: pyproject.toml, contains: structlog }, { path: requirements.txt, contains: structlog }, { path: setup.cfg, contains: structlog }, { path: setup.py, contains: structlog }]
4
+ signature: '"event": ?".*"level": ?"(debug|info|warning|error|critical)"'
@@ -0,0 +1,5 @@
1
+ use: grep
2
+ title: XCTest's failures
3
+ patterns: ['^.+:\d+: error: (-\[.+\]|\S+) : ']
4
+ markers: [Package.swift]
5
+ signature: '^Test Suite ''.+'' failed at '
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: TypeScript's type errors
3
+ start: ['\(\d+,\d+\): error TS\d+: ']
4
+ end: ['^Found \d+ errors?']
5
+ markers: [tsconfig.json]
6
+ signature: '\(\d+,\d+\): error TS\d+: '
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: unittest's failures and errors
3
+ start: ['^={70}$']
4
+ end: ['^Ran \d+ tests? in ']
5
+ markers: [pyproject.toml, setup.py, setup.cfg, requirements.txt]
6
+ signature: '^Ran \d+ tests? in \d+\.\d+s$'
@@ -0,0 +1,6 @@
1
+ use: section
2
+ title: Vitest's failed tests
3
+ start: ['^ FAIL ']
4
+ end: ['⎯\[\d+/\d+\]⎯', '^\s+Test Files ']
5
+ markers: [{ path: package.json, contains: '"vitest"' }, vitest.config.ts, vitest.config.js, vitest.config.mts, vitest.config.mjs]
6
+ signature: '^\s+Test Files \d+ '
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module FunCi
6
+ module Evidence
7
+ # The presets in presets/, one YAML file each (see its README): each a
8
+ # built-in (`use`) and its options.
9
+ module Presets
10
+ DIR = File.join(__dir__, "presets")
11
+
12
+ # markers: files any of which the worktree must have, each a path or
13
+ # { path, contains }; signature: a pattern only the tool prints. Either
14
+ # may be missing (architecture.md, "Evidence of a failed stage").
15
+ Preset = Data.define(:name, :use, :options, :markers, :signature)
16
+ OWN = %w[use markers signature].freeze
17
+
18
+ def self.all
19
+ @all ||= Dir.glob("*.yml", base: DIR).sort.map do |file|
20
+ raw = YAML.safe_load_file(File.join(DIR, file))
21
+ Preset.new(name: File.basename(file, ".yml"), use: raw.fetch("use"), options: raw.except(*OWN),
22
+ markers: raw.fetch("markers", []), signature: raw["signature"])
23
+ end
24
+ end
25
+
26
+ def self.fetch(name) = all.find { |preset| preset.name == name } || raise(KeyError, "no preset '#{name}'")
27
+ def self.find(use, name) = all.find { |preset| preset.use == use && preset.name == name }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Evidence
5
+ # What went wrong running an extractor, said as the problem the evidence
6
+ # records against its entry.
7
+ class Problem < StandardError; end
8
+ end
9
+ end