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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +86 -0
- data/calldata_lint.rb +230 -0
- 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: []
|