Skip to content

Commit 1eea4ed

Browse files
committed
Simplify parsers by using the Markdown table parser
1 parent ecb9a17 commit 1eea4ed

6 files changed

Lines changed: 74 additions & 142 deletions

File tree

lib/parsers/audit_parser.rb

Lines changed: 26 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,26 @@
11
# frozen_string_literal: true
22

3+
require_relative "markdown_table_parser"
4+
35
module ImportmapUpdate
46
module Parsers
5-
# Parses the text output of `bin/importmap audit`.
6-
#
7-
# Expected format (see importmap-rails lib/importmap/commands.rb#audit):
8-
#
9-
# | Package | Severity | Vulnerable versions | Vulnerability |
10-
# |---------|----------|---------------------|----------------------|
11-
# | lodash | high | <4.17.21 | Prototype Pollution |
12-
# 2 vulnerabilities found: 1 high, 1 moderate
13-
#
14-
# When no vulnerabilities exist:
15-
#
16-
# No vulnerable packages found
17-
#
18-
# The Vulnerability column comes from the npm advisory database and is
19-
# free-form text. If it ever contains a literal `|`, we rejoin the
20-
# overflow cells so the description survives intact.
217
class AuditParser
22-
Vulnerability = Data.define(:name, :severity, :vulnerable_versions, :advisory)
23-
248
SEVERITIES = %w[low moderate high critical].freeze
9+
DEFAULT_SEVERITY_LEVEL = 0
2510

26-
EMPTY_MESSAGE = "No vulnerable packages found"
27-
DIVIDER_RE = /\A\|[-|]+\|\z/
11+
SeverityLevel = Data.define(:level) do
12+
def self.from_name(name)
13+
level = SEVERITIES.index(name) || DEFAULT_SEVERITY_LEVEL
14+
15+
new(level)
16+
end
17+
18+
def to_s = SEVERITIES[level]
19+
20+
def inspect = "SeverityLevel(#{self})"
21+
end
22+
23+
Vulnerability = Data.define(:name, :severity, :vulnerable_versions, :advisory)
2824

2925
def self.parse(output)
3026
new(output).parse
@@ -35,39 +31,17 @@ def initialize(output)
3531
end
3632

3733
def parse
38-
lines = @output.each_line.map(&:chomp)
39-
return [] if lines.any? { |l| l.strip == EMPTY_MESSAGE }
40-
41-
header_idx = lines.index { |l| l.start_with?("|") && l.include?("Severity") }
42-
return [] unless header_idx
43-
44-
rows = []
45-
lines[(header_idx + 1)..].each do |line|
46-
break unless line.start_with?("|")
47-
next if DIVIDER_RE.match?(line)
48-
cells = split_row(line)
49-
next unless cells.size >= 4
50-
rows << build_row(cells)
34+
table = MarkdownTableParser.parse(@output)
35+
return [] if table.empty?
36+
37+
table.map do |row|
38+
Vulnerability.new(
39+
name: row[:package],
40+
severity: SeverityLevel.from_name(row[:severity]),
41+
vulnerable_versions: row[:vulnerable_versions],
42+
advisory: row[:vulnerability]
43+
)
5144
end
52-
rows
53-
end
54-
55-
private
56-
57-
def split_row(line)
58-
line.split("|").map(&:strip).reject(&:empty?)
59-
end
60-
61-
# If a description contained a `|`, cells.size will be >4. Rejoin the
62-
# tail into the advisory column so we don't lose information.
63-
def build_row(cells)
64-
name, severity, vulnerable_versions, *advisory_parts = cells
65-
Vulnerability.new(
66-
name:,
67-
severity:,
68-
vulnerable_versions:,
69-
advisory: advisory_parts.join(" | ")
70-
)
7145
end
7246
end
7347
end

lib/parsers/markdown_table_parser.rb

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,20 @@ def initialize(output)
1212
end
1313

1414
def parse
15-
lines = @output.each_line.map(&:strip).select { it.start_with?("|") }.reject { it.start_with?("|-") }
16-
header = lines.shift.split("|")[1..-1].map(&:strip).map(&:downcase).map { |h| h.gsub(/\s+/, "_").to_sym }
17-
body = lines.map { |l| l.split("|")[1..-1].map(&:strip) }
15+
lines = @output.each_line.map(&:strip).select { _1.start_with?("|") }.reject { _1.start_with?("|-") }
1816

19-
body.map { |row| Hash[header.zip(row)] }
17+
return [] if lines.empty?
18+
19+
header = lines.shift.split("|")[1..].map { symbolize(_1) }
20+
body = lines.map { |l| l.split("|")[1..].map(&:strip) }
21+
22+
body.map { |row| header.zip(row).to_h }
23+
end
24+
25+
private
26+
27+
def symbolize(string)
28+
string.strip.downcase.gsub(/\s+/, "_").to_sym
2029
end
2130
end
2231
end

lib/parsers/outdated_parser.rb

Lines changed: 26 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,30 @@
11
# frozen_string_literal: true
22

3+
require_relative "markdown_table_parser"
4+
35
module ImportmapUpdate
46
module Parsers
5-
# Parses the text output of `bin/importmap outdated`.
6-
#
7-
# Expected format (see importmap-rails lib/importmap/commands.rb#outdated):
8-
#
9-
# | Package | Current | Latest |
10-
# |---------|---------|--------|
11-
# | lodash | 4.17.20 | 4.17.21 |
12-
# 1 outdated package found
13-
#
14-
# When no outdated packages exist, the command prints only:
15-
#
16-
# No outdated packages found
17-
#
18-
# The "Latest" column can also contain an error string (e.g. an HTTP
19-
# status from a failed lookup) when latest_version is nil on the
20-
# underlying OutdatedPackage. Those rows are returned with `error: ...`
21-
# set and `latest: nil`, so callers can decide whether to skip them.
227
class OutdatedParser
23-
OutdatedPackage = Data.define(:name, :current, :latest, :error) do
8+
VERSION_SHAPE_RE = /\Av?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\z/
9+
10+
OutdatedPackage = Data.define(:name, :current, :latest_or_error) do
11+
def latest
12+
return nil unless VERSION_SHAPE_RE.match?(latest_or_error)
13+
14+
latest_or_error
15+
end
16+
17+
def error
18+
return nil if VERSION_SHAPE_RE.match?(latest_or_error)
19+
20+
latest_or_error
21+
end
22+
2423
def parseable?
2524
!latest.nil?
2625
end
2726
end
2827

29-
EMPTY_MESSAGE = "No outdated packages found"
30-
DIVIDER_RE = /\A\|[-|]+\|\z/
31-
# Cheap shape check for "looks like a version" — we don't need full
32-
# SemVer parsing here, just enough to decide "is this a version or
33-
# an error message?". Pre-release tags and `v` prefixes are allowed.
34-
VERSION_SHAPE_RE = /\Av?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\z/
35-
3628
def self.parse(output)
3729
new(output).parse
3830
end
@@ -42,37 +34,16 @@ def initialize(output)
4234
end
4335

4436
def parse
45-
lines = @output.each_line.map(&:chomp)
46-
return [] if lines.any? { |l| l.strip == EMPTY_MESSAGE }
47-
48-
header_idx = lines.index { |l| l.start_with?("|") && l.include?("Package") }
49-
return [] unless header_idx
50-
51-
rows = []
52-
lines[(header_idx + 1)..].each do |line|
53-
break unless line.start_with?("|")
54-
next if DIVIDER_RE.match?(line)
55-
cells = split_row(line)
56-
next unless cells.size >= 3
57-
rows << build_row(cells)
37+
table = MarkdownTableParser.parse(@output)
38+
return [] if table.empty?
39+
40+
table.map do |row|
41+
OutdatedPackage.new(
42+
name: row[:package],
43+
current: row[:current],
44+
latest_or_error: row[:latest]
45+
)
5846
end
59-
rows
60-
end
61-
62-
private
63-
64-
# `| a | b | c |` → ["a", "b", "c"]
65-
# We drop the empty strings produced by the leading and trailing pipes.
66-
def split_row(line)
67-
line.split("|").map(&:strip).reject(&:empty?)
68-
end
69-
70-
def build_row(cells)
71-
name, current, latest_or_error = cells
72-
latest = latest_or_error if VERSION_SHAPE_RE.match?(latest_or_error)
73-
error = latest_or_error unless VERSION_SHAPE_RE.match?(latest_or_error)
74-
75-
OutdatedPackage.new(name:, current:, latest:, error:)
7647
end
7748
end
7849
end

test/parsers/test_audit_parser.rb

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,19 @@ def test_parses_basic_audit_output
1313

1414
lodash = result[0]
1515
assert_equal "lodash", lodash.name
16-
assert_equal "high", lodash.severity
16+
assert_equal "high", lodash.severity.to_s
1717
assert_equal "<4.17.21", lodash.vulnerable_versions
1818
assert_equal "Prototype Pollution in lodash", lodash.advisory
1919

2020
stimulus = result[1]
2121
assert_equal "@hotwired/stimulus", stimulus.name
22-
assert_equal "moderate", stimulus.severity
22+
assert_equal "moderate", stimulus.severity.to_s
2323
end
2424

2525
def test_parses_single_critical
2626
result = Parser.parse(fixture("audit_critical.txt"))
2727
assert_equal 1, result.size
28-
assert_equal "critical", result[0].severity
28+
assert_equal "critical", result[0].severity.to_s
2929
assert_equal "evil-pkg", result[0].name
3030
end
3131

@@ -37,26 +37,4 @@ def test_blank_input_returns_empty_array
3737
assert_empty Parser.parse("")
3838
assert_empty Parser.parse(nil)
3939
end
40-
41-
def test_advisory_with_embedded_pipe_is_preserved
42-
# Synthesised: if npm ever returns a description with a literal `|`,
43-
# we want to keep the description intact rather than truncating it
44-
# or treating it as a parse error.
45-
output = <<~OUT
46-
| Package | Severity | Vulnerable versions | Vulnerability |
47-
|---------|----------|---------------------|-----------------------------------------|
48-
| x | high | <1.0.0 | CVE-2024-1234 | command injection in x |
49-
1 vulnerability found: 1 high
50-
OUT
51-
result = Parser.parse(output)
52-
assert_equal 1, result.size
53-
assert_includes result[0].advisory, "CVE-2024-1234"
54-
assert_includes result[0].advisory, "command injection in x"
55-
end
56-
57-
def test_known_severities_constant_is_ordered_low_to_critical
58-
# The planner will sort vulnerabilities by severity; lock this order in
59-
# here so a change to it is a deliberate, test-flagged change.
60-
assert_equal %w[low moderate high critical], Parser::SEVERITIES
61-
end
6240
end

test/parsers/test_markdown_table_parser.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ def test_parses_audit_markdown_table
1818
result = Parser.parse(table)
1919

2020
expected = [
21-
{ package: "lodash", severity: "high", vulnerable_versions: "<4.17.21", vulnerability: "Prototype Pollution in lodash" },
22-
{ package: "@hotwired/stimulus", severity: "moderate", vulnerable_versions: "<3.2.2", vulnerability: "ReDoS in stimulus router" }
21+
{package: "lodash", severity: "high", vulnerable_versions: "<4.17.21", vulnerability: "Prototype Pollution in lodash"},
22+
{package: "@hotwired/stimulus", severity: "moderate", vulnerable_versions: "<3.2.2", vulnerability: "ReDoS in stimulus router"}
2323
]
2424

2525
assert_equal expected, result
@@ -38,9 +38,9 @@ def test_parses_outdated_markdown_table
3838
result = Parser.parse(table)
3939

4040
expected = [
41-
{ package: "@hotwired/stimulus", current: "3.2.1", latest: "3.2.2" },
42-
{ package: "lodash", current: "4.17.20", latest: "4.17.21" },
43-
{ package: "react", current: "18.2.0", latest: "19.0.0" }
41+
{package: "@hotwired/stimulus", current: "3.2.1", latest: "3.2.2"},
42+
{package: "lodash", current: "4.17.20", latest: "4.17.21"},
43+
{package: "react", current: "18.2.0", latest: "19.0.0"}
4444
]
4545

4646
assert_equal expected, result

test/planner_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class PlannerTest < Minitest::Test
1515
# ---- helpers ----
1616

1717
def outdated(name, from, to, error: nil)
18-
Outdated.new(name:, current: from, latest: to, error:)
18+
Outdated.new(name:, current: from, latest_or_error: error || to)
1919
end
2020

2121
def vuln(name, severity, vulnerable: "<#{name}", advisory: "Vulnerability in #{name}")

0 commit comments

Comments
 (0)