shipit-csv-validator 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +44 -0
- data/exe/shipit-csv-validate +120 -0
- data/lib/shipit/csv_validator.rb +206 -0
- metadata +50 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e971afcc4413f5b4bde9b9991bb978de54493677a4949bc3d67bb3a03cc43e17
|
|
4
|
+
data.tar.gz: 61db75a326805c874644e1f6ce04203fb66cde7cb456b64464cbafb78de453fe
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1abab5c40be4b160a17e04b0cf8723d04f6e0aca278aaee71079b928da4f8325729edc46345177e366b4fa69dc003215938ed83a455931a6112a1ff265cc6acd
|
|
7
|
+
data.tar.gz: 14952f95d54ca6bd6eb294946916166adc655aefbfbe194be82bcd1d5d10c3a7a8111995fd8e9f2f29cc6f9248807425f3e55013151e5effb0a6da54a436d30d
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shipit Software
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# CSV Validator for Ruby
|
|
2
|
+
|
|
3
|
+
Check UTF-8 CSV structure in Ruby or from a terminal. The library and command
|
|
4
|
+
line tool work locally, report problem locations without printing cell values,
|
|
5
|
+
and leave input files untouched.
|
|
6
|
+
|
|
7
|
+
Use the [online CSV validator](https://csvfile.tools/validate) in a browser.
|
|
8
|
+
The [CSV Validator source repository](https://github.com/shipit-software/csv-validator)
|
|
9
|
+
contains the Ruby source, shared conformance cases and other language editions.
|
|
10
|
+
|
|
11
|
+
## Command line
|
|
12
|
+
|
|
13
|
+
With Ruby 3.0 or newer:
|
|
14
|
+
|
|
15
|
+
~~~sh
|
|
16
|
+
gem install shipit-csv-validator
|
|
17
|
+
shipit-csv-validate --json data.csv
|
|
18
|
+
~~~
|
|
19
|
+
|
|
20
|
+
Pass a file, pass "-" or omit the filename to read standard input. Use
|
|
21
|
+
"--no-header" when the first record is data, "--delimiter tab" for tab-separated
|
|
22
|
+
data, and "--max-errors 20" to cap diagnostics. Exit status is 0 for valid CSV,
|
|
23
|
+
1 for invalid CSV, and 2 for an option or input error. The command accepts up to
|
|
24
|
+
32 MiB of UTF-8 input.
|
|
25
|
+
|
|
26
|
+
## Ruby API
|
|
27
|
+
|
|
28
|
+
~~~ruby
|
|
29
|
+
require "shipit/csv_validator"
|
|
30
|
+
|
|
31
|
+
report = Shipit::CsvValidator.validate("id,name\n1,Ada\n", header: true)
|
|
32
|
+
puts report["valid"] # true
|
|
33
|
+
puts report["columns"] # 2
|
|
34
|
+
~~~
|
|
35
|
+
|
|
36
|
+
The API accepts UTF-8 input without the command line byte limit. It reports
|
|
37
|
+
quoting errors, unsupported NUL characters, inconsistent row widths and empty
|
|
38
|
+
or repeated headers. The first complete record sets the expected width. Header
|
|
39
|
+
comparisons are case sensitive. The parser supports escaped quotes, quoted
|
|
40
|
+
delimiters, multiline fields and CRLF, LF or CR line endings. It ignores a
|
|
41
|
+
single leading byte order mark. Diagnostic positions are one-based, and columns
|
|
42
|
+
count UTF-16 code units. It validates structure without inferring data types
|
|
43
|
+
or applying schemas. An invalid delimiter, option or encoding raises
|
|
44
|
+
"ArgumentError". The implementation uses only Ruby standard libraries.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "shipit/csv_validator"
|
|
6
|
+
|
|
7
|
+
module CsvValidatorCommand
|
|
8
|
+
INPUT_LIMIT = 32 * 1024 * 1024
|
|
9
|
+
HELP = <<~TEXT
|
|
10
|
+
Usage: shipit-csv-validate [options] [FILE|-]
|
|
11
|
+
Validate UTF-8 CSV from a file or standard input.
|
|
12
|
+
|
|
13
|
+
--json Print a JSON report
|
|
14
|
+
--delimiter CHAR Use a different delimiter (use "tab" for a tab)
|
|
15
|
+
--no-header Treat the first record as data
|
|
16
|
+
--max-errors N Cap diagnostics (1-10000, default 100)
|
|
17
|
+
--help Show help
|
|
18
|
+
--version Show the version
|
|
19
|
+
-- End options
|
|
20
|
+
|
|
21
|
+
Exit codes: 0 valid, 1 invalid CSV, 2 usage or input error.
|
|
22
|
+
Input is buffered and limited to 32 MiB. No type or schema inference.
|
|
23
|
+
TEXT
|
|
24
|
+
|
|
25
|
+
def self.parse(arguments)
|
|
26
|
+
args = arguments.dup
|
|
27
|
+
options = {}
|
|
28
|
+
path = nil
|
|
29
|
+
end_options = false
|
|
30
|
+
until args.empty?
|
|
31
|
+
arg = args.shift
|
|
32
|
+
unless end_options
|
|
33
|
+
case arg
|
|
34
|
+
when "--"
|
|
35
|
+
end_options = true
|
|
36
|
+
next
|
|
37
|
+
when "--help", "--version"
|
|
38
|
+
return [arg, nil, options]
|
|
39
|
+
when "--json"
|
|
40
|
+
next
|
|
41
|
+
when "--no-header"
|
|
42
|
+
options[:header] = false
|
|
43
|
+
next
|
|
44
|
+
when "--delimiter"
|
|
45
|
+
value = args.shift or raise ArgumentError, "missing option value"
|
|
46
|
+
options[:delimiter] = value == "tab" ? "\t" : value
|
|
47
|
+
next
|
|
48
|
+
when "--max-errors"
|
|
49
|
+
value = args.shift or raise ArgumentError, "missing option value"
|
|
50
|
+
unless /\A[1-9][0-9]{0,4}\z/.match?(value) && value.to_i <= 10_000
|
|
51
|
+
raise ArgumentError, "maximum errors must be 1-10000"
|
|
52
|
+
end
|
|
53
|
+
options[:max_errors] = value.to_i
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
raise ArgumentError, "unknown option; use --help" if arg.start_with?("-") && arg != "-"
|
|
57
|
+
end
|
|
58
|
+
raise ArgumentError, "provide one file, or standard input" if path
|
|
59
|
+
|
|
60
|
+
path = arg
|
|
61
|
+
end
|
|
62
|
+
[nil, path, options]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.run(args)
|
|
66
|
+
as_json = args.take_while { |arg| arg != "--" }.include?("--json")
|
|
67
|
+
fail_input = lambda do |code, message|
|
|
68
|
+
if as_json
|
|
69
|
+
puts JSON.generate(valid: false, error: { code: code, message: message })
|
|
70
|
+
else
|
|
71
|
+
warn message
|
|
72
|
+
end
|
|
73
|
+
2
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
begin
|
|
77
|
+
action, path, options = parse(args)
|
|
78
|
+
if action
|
|
79
|
+
puts action == "--help" ? HELP : Shipit::CsvValidator::VERSION
|
|
80
|
+
return 0
|
|
81
|
+
end
|
|
82
|
+
# Validate options before reading a file or blocking on standard input.
|
|
83
|
+
Shipit::CsvValidator.validate("", **options)
|
|
84
|
+
rescue ArgumentError => error
|
|
85
|
+
return fail_input.call("USAGE", error.message)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
data = if path.nil? || path == "-"
|
|
90
|
+
STDIN.binmode.read(INPUT_LIMIT + 1)
|
|
91
|
+
else
|
|
92
|
+
File.binread(path, INPUT_LIMIT + 1)
|
|
93
|
+
end
|
|
94
|
+
rescue SystemCallError, IOError
|
|
95
|
+
return fail_input.call("INPUT_READ", "Could not read input")
|
|
96
|
+
end
|
|
97
|
+
return fail_input.call("INPUT_LIMIT", "Input exceeds the 32 MiB limit") if data.to_s.bytesize > INPUT_LIMIT
|
|
98
|
+
|
|
99
|
+
begin
|
|
100
|
+
report = Shipit::CsvValidator.validate(data.to_s, **options)
|
|
101
|
+
rescue ArgumentError => error
|
|
102
|
+
return fail_input.call("INPUT_ENCODING", error.message)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if as_json
|
|
106
|
+
puts JSON.generate(report)
|
|
107
|
+
elsif report["valid"]
|
|
108
|
+
puts "Valid CSV: #{report['dataRecords']} data records, #{report['columns']} columns"
|
|
109
|
+
else
|
|
110
|
+
report["diagnostics"].each do |issue|
|
|
111
|
+
puts format("%<code>s: record %<record>d, field %<field>d, line %<line>d, column %<column>d: %<message>s",
|
|
112
|
+
**issue.transform_keys(&:to_sym))
|
|
113
|
+
end
|
|
114
|
+
puts "Additional diagnostics omitted" if report["diagnosticsTruncated"]
|
|
115
|
+
end
|
|
116
|
+
report["valid"] ? 0 : 1
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
exit CsvValidatorCommand.run(ARGV)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module Shipit
|
|
6
|
+
module CsvValidator
|
|
7
|
+
VERSION = "0.1.0"
|
|
8
|
+
|
|
9
|
+
# Return a report with JSON-compatible string keys and one-based positions.
|
|
10
|
+
# Input is interpreted as UTF-8 bytes. Bad encoding or options raise ArgumentError.
|
|
11
|
+
def self.validate(input, delimiter: ",", header: true, max_errors: 100)
|
|
12
|
+
raise ArgumentError, "input must be a UTF-8 string" unless input.is_a?(String)
|
|
13
|
+
|
|
14
|
+
input = input.dup.force_encoding(Encoding::UTF_8)
|
|
15
|
+
raise ArgumentError, "input must be valid UTF-8" unless input.valid_encoding?
|
|
16
|
+
|
|
17
|
+
unless delimiter.is_a?(String) && delimiter.encoding.ascii_compatible?
|
|
18
|
+
raise ArgumentError, "delimiter must be one BMP character other than quote, newline or NUL"
|
|
19
|
+
end
|
|
20
|
+
delimiter = delimiter.dup.force_encoding(Encoding::UTF_8)
|
|
21
|
+
unless delimiter.valid_encoding? && delimiter.length == 1 &&
|
|
22
|
+
delimiter.ord <= 0xffff && !["\"", "\r", "\n", "\0"].include?(delimiter)
|
|
23
|
+
raise ArgumentError, "delimiter must be one BMP character other than quote, newline or NUL"
|
|
24
|
+
end
|
|
25
|
+
raise ArgumentError, "header must be true or false" unless [true, false].include?(header)
|
|
26
|
+
unless max_errors.is_a?(Integer) && (1..10_000).cover?(max_errors)
|
|
27
|
+
raise ArgumentError, "maximum errors must be 1-10000"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Parser.new(input.delete_prefix("\uFEFF"), delimiter, header, max_errors).report
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Parser
|
|
34
|
+
def initialize(input, delimiter, header, max_errors)
|
|
35
|
+
@scanner = StringScanner.new(input)
|
|
36
|
+
@delimiter, @header, @max_errors = delimiter, header, max_errors
|
|
37
|
+
@records = @columns = @fields = 0
|
|
38
|
+
@line = @column = 1
|
|
39
|
+
@row_line = @field_line = @field_column = 1
|
|
40
|
+
@state = :start
|
|
41
|
+
@touched = false
|
|
42
|
+
@parse_complete = true
|
|
43
|
+
@diagnostics = []
|
|
44
|
+
@truncated = false
|
|
45
|
+
@header_issues = []
|
|
46
|
+
@header_truncated = false
|
|
47
|
+
@names = {}
|
|
48
|
+
@name = +""
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def report
|
|
52
|
+
add(issue("EMPTY_INPUT", "Input contains no records", 1, 1, 1, 1)) if @scanner.eos?
|
|
53
|
+
while (token = @scanner.scan(/\r\n|./m))
|
|
54
|
+
consume(token)
|
|
55
|
+
break unless @parse_complete
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if @parse_complete
|
|
59
|
+
if @state == :quoted
|
|
60
|
+
fatal("UNCLOSED_QUOTE", "Quoted field is missing a closing quote", @field_line, @field_column)
|
|
61
|
+
elsif @touched
|
|
62
|
+
finish_record
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
"valid" => @diagnostics.empty?,
|
|
68
|
+
"records" => @records,
|
|
69
|
+
"dataRecords" => @header && @records.positive? ? @records - 1 : @records,
|
|
70
|
+
"columns" => @columns,
|
|
71
|
+
"header" => @header,
|
|
72
|
+
"diagnostics" => @diagnostics,
|
|
73
|
+
"diagnosticsTruncated" => @truncated,
|
|
74
|
+
"parseComplete" => @parse_complete
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def consume(token)
|
|
81
|
+
newline = token.start_with?("\r", "\n")
|
|
82
|
+
if token == "\0"
|
|
83
|
+
fatal("NUL_CHARACTER", "NUL characters are not supported")
|
|
84
|
+
elsif @state == :quoted
|
|
85
|
+
consume_quoted(token, newline)
|
|
86
|
+
elsif token == @delimiter
|
|
87
|
+
@touched = true
|
|
88
|
+
finish_field
|
|
89
|
+
@column += 1
|
|
90
|
+
@field_line, @field_column = @line, @column
|
|
91
|
+
elsif newline
|
|
92
|
+
finish_record
|
|
93
|
+
advance_line
|
|
94
|
+
@row_line = @field_line = @line
|
|
95
|
+
@field_column = @column
|
|
96
|
+
elsif @state == :closed
|
|
97
|
+
fatal("TEXT_AFTER_QUOTE", "Only a delimiter or line ending may follow a closing quote")
|
|
98
|
+
elsif token == "\""
|
|
99
|
+
if @state == :start
|
|
100
|
+
@state = :quoted
|
|
101
|
+
@touched = true
|
|
102
|
+
@column += 1
|
|
103
|
+
else
|
|
104
|
+
fatal("QUOTE_IN_UNQUOTED_FIELD", "A quote must start at the beginning of a field")
|
|
105
|
+
end
|
|
106
|
+
else
|
|
107
|
+
@state = :unquoted
|
|
108
|
+
@touched = true
|
|
109
|
+
append_name(token)
|
|
110
|
+
@column += token.ord > 0xffff ? 2 : 1
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def consume_quoted(token, newline)
|
|
115
|
+
if token == "\""
|
|
116
|
+
if @scanner.scan(/"/)
|
|
117
|
+
append_name(token)
|
|
118
|
+
@column += 2
|
|
119
|
+
else
|
|
120
|
+
@state = :closed
|
|
121
|
+
@column += 1
|
|
122
|
+
end
|
|
123
|
+
else
|
|
124
|
+
append_name(token)
|
|
125
|
+
if newline
|
|
126
|
+
advance_line
|
|
127
|
+
else
|
|
128
|
+
@column += token.ord > 0xffff ? 2 : 1
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def advance_line
|
|
134
|
+
@line += 1
|
|
135
|
+
@column = 1
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def append_name(token)
|
|
139
|
+
@name << token if @header && @records.zero?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def finish_field
|
|
143
|
+
@fields += 1
|
|
144
|
+
@state = :start
|
|
145
|
+
return unless @header && @records.zero?
|
|
146
|
+
|
|
147
|
+
header_issue("EMPTY_HEADER", "Header name is empty") if @name.empty?
|
|
148
|
+
if (earlier = @names[@name])
|
|
149
|
+
header_issue("DUPLICATE_HEADER", "Header repeats field #{earlier}")
|
|
150
|
+
else
|
|
151
|
+
@names[@name] = @fields
|
|
152
|
+
end
|
|
153
|
+
@name = +""
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def finish_record
|
|
157
|
+
finish_field
|
|
158
|
+
@records += 1
|
|
159
|
+
if @records == 1
|
|
160
|
+
@columns = @fields
|
|
161
|
+
@diagnostics.concat(@header_issues)
|
|
162
|
+
@truncated = @header_truncated
|
|
163
|
+
@header_issues.clear
|
|
164
|
+
@names.clear
|
|
165
|
+
elsif @fields != @columns
|
|
166
|
+
add(issue("ROW_WIDTH", "Expected #{@columns} fields; found #{@fields}",
|
|
167
|
+
@records, [@fields, @columns].min + 1, @row_line, 1))
|
|
168
|
+
end
|
|
169
|
+
@fields = 0
|
|
170
|
+
@touched = false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def issue(code, message, record, field, line, column)
|
|
174
|
+
{ "code" => code, "message" => message, "record" => record, "field" => field,
|
|
175
|
+
"line" => line, "column" => column }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def header_issue(code, message)
|
|
179
|
+
if @header_issues.length < @max_errors
|
|
180
|
+
@header_issues << issue(code, message, 1, @fields, @field_line, @field_column)
|
|
181
|
+
else
|
|
182
|
+
@header_truncated = true
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def add(diagnostic)
|
|
187
|
+
if @diagnostics.length < @max_errors
|
|
188
|
+
@diagnostics << diagnostic
|
|
189
|
+
else
|
|
190
|
+
@truncated = true
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def fatal(code, message, line = @line, column = @column)
|
|
195
|
+
if @diagnostics.length == @max_errors
|
|
196
|
+
@diagnostics.pop
|
|
197
|
+
@truncated = true
|
|
198
|
+
end
|
|
199
|
+
@diagnostics.unshift(issue(code, message, @records + 1, @fields + 1, line, column))
|
|
200
|
+
@parse_complete = false
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private_constant :Parser
|
|
205
|
+
end
|
|
206
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: shipit-csv-validator
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Shipit Software
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-11-05 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Check UTF-8 CSV for quoting, header and row width problems with precise
|
|
14
|
+
diagnostics.
|
|
15
|
+
email:
|
|
16
|
+
executables:
|
|
17
|
+
- shipit-csv-validate
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- exe/shipit-csv-validate
|
|
24
|
+
- lib/shipit/csv_validator.rb
|
|
25
|
+
homepage: https://csvfile.tools/validate
|
|
26
|
+
licenses:
|
|
27
|
+
- MIT
|
|
28
|
+
metadata:
|
|
29
|
+
homepage_uri: https://csvfile.tools/validate
|
|
30
|
+
source_code_uri: https://github.com/shipit-software/csv-validator
|
|
31
|
+
post_install_message:
|
|
32
|
+
rdoc_options: []
|
|
33
|
+
require_paths:
|
|
34
|
+
- lib
|
|
35
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.0'
|
|
40
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: '0'
|
|
45
|
+
requirements: []
|
|
46
|
+
rubygems_version: 3.3.3
|
|
47
|
+
signing_key:
|
|
48
|
+
specification_version: 4
|
|
49
|
+
summary: Validate CSV structure from Ruby or a terminal
|
|
50
|
+
test_files: []
|