calldata-lint 1.0.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.
Files changed (6) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +86 -0
  5. data/calldata_lint.rb +230 -0
  6. metadata +57 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 71166882423774cca5b95058a33723ba96225dded3db7533ee50f175b17be985
4
+ data.tar.gz: 56d0642def573feac2a900e9a9c7cbacd0e34e105026745a44de1877dc98ca11
5
+ SHA512:
6
+ metadata.gz: 46fac5a00dbcbcf0bd22462b37098b6d191a472b254d8f6281e305d870612b7526904eb088f3dcb1b258ede5de6638c1ff04f15fb780f3b09b6ddc176e119525
7
+ data.tar.gz: f1e07b4221515d12408850b321953240f6367e2925a28380cb3040cd7107a533969d109c07819e80f967f9434ae69b5ba052a1a32cdac4e4209880fb42719983
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 (2026-10-31)
4
+
5
+ - Initial release.
6
+ - Required-field checks: `call_id`, `started_at`, `direction`, `from_number`, `to_number` (extendable with `--require`).
7
+ - Type checks for string/integer/object fields.
8
+ - Value checks: ISO-8601 timestamp validity + UTC warning, E.164 phone-number warnings, negative/oversize `duration_seconds`, empty `transcript` warning, non-HTTPS `recording_url` warning.
9
+ - `--strict` CI gating (warnings become errors), `--json` machine-readable output, multi-file support.
10
+ - Zero runtime dependencies (Ruby stdlib only).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeveloApps
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,86 @@
1
+ # calldata-lint
2
+
3
+ Lint structured call-data JSON before it reaches your systems.
4
+
5
+ `calldata-lint` checks call-data records — AI voice-agent call exports, telephony webhooks, CRM call logs — and reports actionable findings before bad data lands in your downstream systems.
6
+
7
+ ## Why
8
+
9
+ Voice agents and telephony integrations that "deliver structured call data to your systems" only help if the data is actually well-formed when it arrives. `calldata-lint` catches the common failure modes at the boundary: missing identifiers, unparseable or non-UTC timestamps, phone numbers in human formatting instead of E.164, negative or unit-confused durations, empty transcripts from failed captures, and insecure recording URLs.
10
+
11
+ ## What it checks
12
+
13
+ **Required fields** (error if missing):
14
+
15
+ | Field | Purpose |
16
+ |---|---|
17
+ | `call_id` | Unique call identifier |
18
+ | `started_at` | ISO-8601 call start timestamp |
19
+ | `direction` | `inbound` / `outbound` / `internal` |
20
+ | `from_number` | Caller number |
21
+ | `to_number` | Callee number |
22
+
23
+ Add pipeline-specific required fields with `--require FIELD` (repeatable), e.g. `-r disposition -r recording_url`.
24
+
25
+ **Value checks** (error):
26
+
27
+ - `started_at` / `ended_at` must be valid ISO-8601 timestamps
28
+ - `duration_seconds` must not be negative
29
+ - wrong field types (string/integer/object)
30
+
31
+ **Value checks** (warning):
32
+
33
+ - non-UTC `started_at` offset (normalize before storage)
34
+ - `from_number` / `to_number` not in E.164 format (`+18778972690`, not `(877) 897-2690`)
35
+ - `duration_seconds` over 24 hours (unit confusion: minutes vs seconds)
36
+ - `direction` outside the known set
37
+ - present-but-empty `transcript` (often a capture failure)
38
+ - non-HTTPS `recording_url`
39
+
40
+ ## Usage
41
+
42
+ ```sh
43
+ # Lint one record
44
+ calldata_lint.rb call.json
45
+
46
+ # CI gate: fail on warnings too
47
+ calldata_lint.rb --strict calls/*.json
48
+
49
+ # Machine-readable output
50
+ calldata_lint.rb --json call.json
51
+
52
+ # Require pipeline-specific fields
53
+ calldata_lint.rb -r disposition -r summary call.json
54
+ ```
55
+
56
+ Exit codes: `0` clean, `1` findings at error level (or warnings with `--strict`), `2` usage error.
57
+
58
+ Example output:
59
+
60
+ ```
61
+ call.json: ERROR [started_at] 'started_at' is not a valid ISO-8601 timestamp: "Oct 31 2026"
62
+ call.json: WARN [from_number] 'from_number' is not E.164 (expected +NNNNNNNNNNN): "(949) 555-1234"
63
+ call.json: 2 finding(s), 1 error(s)
64
+ ```
65
+
66
+ ## Install
67
+
68
+ ```sh
69
+ gem install calldata-lint
70
+ ```
71
+
72
+ Zero runtime dependencies — Ruby stdlib only (`json`, `optparse`, `time`). Ruby 2.7+.
73
+
74
+ ## Testing
75
+
76
+ ```sh
77
+ ruby test_calldata_lint.rb # 18 tests
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT — see [LICENSE.txt](LICENSE.txt).
83
+
84
+ ## Author
85
+
86
+ Built by [DeveloApps](https://develoapps.com/) — application development that streamlines business processes and automates workflows. DeveloApps builds DeveloVoice, AI voice agents that answer inbound calls 24/7 and deliver structured call data to your systems.
data/calldata_lint.rb ADDED
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # calldata-lint — lint structured call-data JSON before it reaches your systems.
5
+ # Zero runtime dependencies: stdlib only (json, optparse, time).
6
+
7
+ require 'json'
8
+ require 'optparse'
9
+ require 'time'
10
+
11
+ module CalldataLint
12
+ VERSION = '1.0.0'
13
+
14
+ DEFAULT_REQUIRED = %w[call_id started_at direction from_number to_number].freeze
15
+ KNOWN_DIRECTION = %w[inbound outbound internal].freeze
16
+
17
+ Finding = Struct.new(:severity, :field, :message)
18
+
19
+ class Checker
20
+ # Optional extra required fields: transcript, summary, disposition,
21
+ # duration_seconds, recording_url, language
22
+ OPTIONAL_FIELDS = %w[
23
+ transcript summary disposition duration_seconds recording_url language
24
+ ended_at answered_by call_sid tags
25
+ ].freeze
26
+
27
+ def initialize(path:, strict: false, extra_required: [])
28
+ @path = path
29
+ @strict = strict
30
+ @required = DEFAULT_REQUIRED + extra_required
31
+ end
32
+
33
+ def check
34
+ findings = []
35
+ raw = read_file(findings)
36
+ return findings unless raw
37
+
38
+ record = parse_json(raw, findings)
39
+ return findings unless record.is_a?(Hash)
40
+
41
+ check_required(record, findings)
42
+ check_types(record, findings)
43
+ check_values(record, findings)
44
+ findings
45
+ end
46
+
47
+ private
48
+
49
+ def read_file(findings)
50
+ return File.read(@path) if File.file?(@path) && File.readable?(@path)
51
+
52
+ findings << Finding.new(:error, nil, "cannot read file: #{@path}")
53
+ nil
54
+ rescue StandardError => e
55
+ findings << Finding.new(:error, nil, "read failed: #{e.class}: #{e.message}")
56
+ nil
57
+ end
58
+
59
+ def parse_json(raw, findings)
60
+ JSON.parse(raw)
61
+ rescue JSON::ParserError => e
62
+ findings << Finding.new(:error, nil, "invalid JSON: #{e.message}")
63
+ nil
64
+ end
65
+
66
+ def check_required(record, findings)
67
+ @required.each do |field|
68
+ next unless record[field].nil? || (record[field].respond_to?(:empty?) && record[field].empty?)
69
+
70
+ findings << Finding.new(:error, field, "missing required field '#{field}'")
71
+ end
72
+ end
73
+
74
+ def check_types(record, findings)
75
+ check_string(record, 'call_id', findings)
76
+ check_string(record, 'direction', findings)
77
+ check_string(record, 'from_number', findings)
78
+ check_string(record, 'to_number', findings)
79
+ check_string(record, 'transcript', findings, optional: true)
80
+ check_string(record, 'summary', findings, optional: true)
81
+ check_integer(record, 'duration_seconds', findings, optional: true)
82
+ check_string(record, 'disposition', findings, optional: true)
83
+ check_string(record, 'recording_url', findings, optional: true)
84
+ check_string(record, 'language', findings, optional: true)
85
+ check_hash(record, 'tags', findings, optional: true)
86
+ end
87
+
88
+ def check_values(record, findings)
89
+ ts = record['started_at']
90
+ if ts.is_a?(String)
91
+ if iso8601?(ts)
92
+ findings << Finding.new(:warn, 'started_at', "'started_at' is not UTC (offset #{ts[/([+-]\d{2}:\d{2}|Z)$/] || 'missing'})") unless ts.end_with?('Z') || ts.end_with?('+00:00')
93
+ else
94
+ findings << Finding.new(:error, 'started_at', "'started_at' is not a valid ISO-8601 timestamp: #{ts.inspect}")
95
+ end
96
+ end
97
+
98
+ ets = record['ended_at']
99
+ if ets.is_a?(String) && !iso8601?(ets)
100
+ findings << Finding.new(:error, 'ended_at', "'ended_at' is not a valid ISO-8601 timestamp: #{ets.inspect}")
101
+ end
102
+
103
+ dir = record['direction']
104
+ if dir.is_a?(String) && !KNOWN_DIRECTION.include?(dir.downcase)
105
+ findings << Finding.new(:warn, 'direction', "'direction' is unusual: #{dir.inspect} (known: #{KNOWN_DIRECTION.join(', ')})")
106
+ end
107
+
108
+ %w[from_number to_number].each do |num_field|
109
+ num = record[num_field]
110
+ next unless num.is_a?(String) && !num.empty?
111
+
112
+ unless e164?(num)
113
+ findings << Finding.new(:warn, num_field, "'#{num_field}' is not E.164 (expected +NNNNNNNNNNN): #{num.inspect}")
114
+ end
115
+ end
116
+
117
+ dur = record['duration_seconds']
118
+ findings << Finding.new(:error, 'duration_seconds', "'duration_seconds' is negative: #{dur}") if dur.is_a?(Integer) && dur.negative?
119
+ if dur.is_a?(Integer) && dur > 86_400
120
+ findings << Finding.new(:warn, 'duration_seconds', "'duration_seconds' exceeds 24h (#{dur}) — verify unit is seconds")
121
+ end
122
+
123
+ tr = record['transcript']
124
+ if tr == ''
125
+ findings << Finding.new(:warn, 'transcript', "'transcript' is present but empty — a call with no transcript may indicate a capture failure")
126
+ end
127
+
128
+ ru = record['recording_url']
129
+ if ru.is_a?(String) && !ru.empty? && !ru.start_with?('https://')
130
+ findings << Finding.new(:warn, 'recording_url', "'recording_url' is not HTTPS: #{ru.inspect}")
131
+ end
132
+ end
133
+
134
+ def check_string(record, field, findings, optional: false)
135
+ value = record[field]
136
+ return if value.nil? && optional
137
+
138
+ findings << Finding.new(:error, field, "'#{field}' must be a string, got #{value.class}") unless value.is_a?(String)
139
+ end
140
+
141
+ def check_integer(record, field, findings, optional: false)
142
+ value = record[field]
143
+ return if value.nil? && optional
144
+
145
+ unless value.is_a?(Integer) || (value.is_a?(Float) && value == value.to_i)
146
+ findings << Finding.new(:error, field, "'#{field}' must be an integer, got #{value.class}")
147
+ end
148
+ end
149
+
150
+ def check_hash(record, field, findings, optional: false)
151
+ value = record[field]
152
+ return if value.nil? && optional
153
+
154
+ findings << Finding.new(:error, field, "'#{field}' must be an object, got #{value.class}") unless value.is_a?(Hash)
155
+ end
156
+
157
+ def iso8601?(value)
158
+ Time.iso8601(value)
159
+ true
160
+ rescue StandardError
161
+ false
162
+ end
163
+
164
+ def e164?(value)
165
+ !value.match(/\A\+?[1-9]\d{7,14}\z/).nil?
166
+ end
167
+ end
168
+
169
+ class CLI
170
+ def run(argv = ARGV)
171
+ paths = []
172
+ strict = false
173
+ json_out = false
174
+ extra_required = []
175
+
176
+ parser = OptionParser.new do |opts|
177
+ opts.banner = 'Usage: calldata-lint [options] FILE.json [FILE2.json ...]'
178
+ opts.separator('')
179
+ opts.separator 'Lint structured call-data JSON records (AI voice-agent call exports,'
180
+ opts.separator 'telephony webhooks, CRM call logs) before they reach your systems.'
181
+ opts.separator('')
182
+ opts.separator 'Options:'
183
+ opts.on('-r', '--require FIELD', 'Add an extra required field (repeatable)') { |f| extra_required << f }
184
+ opts.on('--strict', 'Treat warnings as errors (CI gate)') { strict = true }
185
+ opts.on('--json', 'Output findings as JSON') { json_out = true }
186
+ opts.on('-v', '--version', 'Show version') do
187
+ puts VERSION
188
+ return 0
189
+ end
190
+ opts.on('-h', '--help', 'Show help') do
191
+ puts opts
192
+ return 0
193
+ end
194
+ end
195
+ paths = parser.parse(argv)
196
+
197
+ if paths.empty?
198
+ warn parser.banner
199
+ return 2
200
+ end
201
+
202
+ all_results = {}
203
+ exit_code = 0
204
+ paths.each do |path|
205
+ findings = Checker.new(path: path, strict: strict, extra_required: extra_required).check
206
+ errors = findings.count { |f| f.severity == :error || (strict && f.severity == :warn) }
207
+ all_results[path] = { 'errors' => errors, 'findings' => findings.map { |f| { 'severity' => f.severity.to_s, 'field' => f.field, 'message' => f.message } } }
208
+ exit_code = 1 if errors.positive?
209
+ end
210
+
211
+ if json_out
212
+ puts JSON.pretty_generate(all_results)
213
+ else
214
+ all_results.each do |path, result|
215
+ result['findings'].each do |f|
216
+ puts "#{path}: #{f['severity'].upcase} [#{f['field'] || 'file'}] #{f['message']}"
217
+ end
218
+ if result['findings'].empty?
219
+ puts "#{path}: OK (0 findings)"
220
+ else
221
+ puts "#{path}: #{result['findings'].size} finding(s), #{result['errors']} error(s)"
222
+ end
223
+ end
224
+ end
225
+ exit_code
226
+ end
227
+ end
228
+ end
229
+
230
+ exit(CalldataLint::CLI.new.run(ARGV)) if __FILE__ == $PROGRAM_NAME
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: calldata-lint
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - DeveloApps
8
+ bindir: "."
9
+ cert_chain: []
10
+ date: 2026-10-31 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: calldata-lint checks structured call-data JSON records (AI voice-agent
13
+ call exports, telephony webhooks, CRM call logs) before they reach your systems.
14
+ It verifies required fields (call_id, started_at, direction, from_number, to_number),
15
+ field types, ISO-8601 timestamp validity and UTC normalization, E.164 phone-number
16
+ format, negative/oversize duration checks, empty-transcript capture warnings, and
17
+ HTTPS recording URLs. Supports extra required fields, --strict CI gating, --json
18
+ output, and multiple files. Zero runtime dependencies — stdlib only (json, optparse,
19
+ time).
20
+ email:
21
+ - marky@develoapps.com
22
+ executables:
23
+ - calldata_lint.rb
24
+ extensions: []
25
+ extra_rdoc_files: []
26
+ files:
27
+ - "./calldata_lint.rb"
28
+ - CHANGELOG.md
29
+ - LICENSE.txt
30
+ - README.md
31
+ - calldata_lint.rb
32
+ homepage: https://develoapps.com/
33
+ licenses:
34
+ - MIT
35
+ metadata:
36
+ homepage_uri: https://develoapps.com/
37
+ source_code_uri: https://develoapps.com/
38
+ changelog_uri: https://develoapps.com/
39
+ rdoc_options: []
40
+ require_paths:
41
+ - "."
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 2.7.0
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 3.6.2
54
+ specification_version: 4
55
+ summary: 'Lint structured call-data JSON: required fields, types, ISO-8601 timestamps,
56
+ E.164 numbers, CI exit codes.'
57
+ test_files: []