///|
/// The encoding of a transcript input.
priv enum TranscriptFormat {
JsonTranscript
MarkdownTranscript
JsonlTranscript
}
///|
priv struct TranscriptOptions {
/// The transcript file, or "-" for stdin.
file : String
format : TranscriptFormat
/// The --version flag; the document can also give the version.
version : @rpc.ProtocolVersion?
/// The --complete or --partial flag; it overrides the document.
completeness : @rpc.Completeness?
/// The log mapping for JSONL input; `None` for JSON and Markdown.
log : LogChoice?
/// The --expect file: expected findings for any input format.
expect : String?
/// The --legacy-errors mapping; `None` when the flag is absent.
legacy_errors : @rpc.LegacyErrorMode?
}
///|
fn transcript_command() -> @argparse.Command {
input_command(
"transcript",
"Validate a JSON-RPC transcript between the two peers of a protocol: a JSON or Markdown document, or a JSONL log. Findings of a log are written as each line is read. The native and WASI binaries read a log file or stdin in chunks as it arrives; the js and wasm-gc binaries read all of the input first and give the same findings.",
files_about="Model files, then the transcript file or '-' for stdin. Use '--' before paths beginning with '-'.",
options=[
@argparse.OptionArg(
"protocol",
about="Absolute ID of the @jsonRpcProtocol structure.",
required=true,
),
@argparse.OptionArg(
"version",
about="JSON-RPC version: 1.0 or 2.0. Required when the transcript does not give it, and always for JSONL.",
),
@argparse.OptionArg(
"input-format",
about="Transcript format: json, markdown or jsonl; default from .json, .md, .jsonl or .ndjson, and jsonl for stdin.",
),
@argparse.OptionArg(
"log-preset",
about="JSONL lines: raw (JSON-RPC messages from --sender) or dir-message ({\"dir\", \"message\"} records); default dir-message.",
),
@argparse.OptionArg(
"log-mapping",
about="JSONL mapping file: prefix, selector, message and sender. Conflicts with --log-preset.",
),
@argparse.OptionArg(
"sender",
about="The peer that sends every message of a raw log. Required for --log-preset raw; correlation is then off.",
),
@argparse.OptionArg(
"direction",
about="value=peer: the sender of dir-message records with that dir value. Repeat it for each value; default request= and response=.",
action=@argparse.Append,
),
@argparse.OptionArg(
"diagnostics",
about="Findings format: text or json; default text. For JSONL, json writes one finding per line and then a summary line.",
),
@argparse.OptionArg(
"expect",
about="JSON file with an array of expected findings, {\"code\", \"entry\"?, \"related\"?}. The exit status is then 0 when they match. Conflicts with an expect list in the transcript.",
),
@argparse.OptionArg(
"legacy-errors",
about="1.0 response error mapping: opaque or code-message-data; default opaque.",
),
],
flags=[
@argparse.FlagArg(
"complete",
about="The transcript is complete: an unanswered call is an error.",
conflicts_with=["partial"],
),
@argparse.FlagArg(
"partial",
about="The transcript is partial: an unanswered call is an advisory. The default.",
),
],
)
}
///|
/// Selects the transcript format from the --input-format value, or else from
/// the file extension. Stdin defaults to JSONL.
fn transcript_format(
file : String,
flag : String?,
) -> TranscriptFormat raise CliError {
match flag {
Some("json") => JsonTranscript
Some("markdown") => MarkdownTranscript
Some("jsonl") => JsonlTranscript
Some(other) =>
raise CliError(
"expected json, markdown or jsonl after --input-format, found '\{other}'",
2,
)
None =>
if file == "-" || file.has_suffix(".jsonl") || file.has_suffix(".ndjson") {
JsonlTranscript
} else if file.has_suffix(".json") {
JsonTranscript
} else if file.has_suffix(".md") {
MarkdownTranscript
} else {
raise CliError(
"cannot infer the transcript format of '\{file}'; use --input-format json, markdown or jsonl",
2,
)
}
}
}
///|
fn transcript_options(
file : String,
value : (String) -> String?,
flags : Map[String, Bool],
directions~ : Array[String] = [],
) -> TranscriptOptions raise CliError {
let version = match value("version") {
None => None
Some(text) =>
match @rpc.ProtocolVersion::parse(text) {
Some(version) => Some(version)
None => raise CliError("expected 1.0 or 2.0 after --version", 2)
}
}
let complete = flags.get("complete") == Some(true)
let partial = flags.get("partial") == Some(true)
if complete && partial {
raise CliError("use only one of --complete and --partial", 2)
}
let completeness = if complete {
Some(@rpc.Complete)
} else if partial {
Some(@rpc.Partial)
} else {
None
}
let legacy_errors = match value("legacy-errors") {
None => None
Some(text) => {
let mode = legacy_errors_mode(text)
// A version from the document is checked when the document is read.
if version is Some(given) {
require_legacy_version(given)
}
Some(mode)
}
}
let format = transcript_format(file, value("input-format"))
let preset = value("log-preset")
let mapping = value("log-mapping")
let sender = value("sender")
let log = match format {
JsonTranscript | MarkdownTranscript => {
if preset is Some(_) ||
mapping is Some(_) ||
sender is Some(_) ||
!directions.is_empty() {
raise CliError(
"--log-preset, --log-mapping, --sender and --direction apply only to JSONL input",
2,
)
}
None
}
JsonlTranscript => {
// A log has no document header to give the version.
if version is None {
raise CliError(
"a JSONL log gives no JSON-RPC version; use --version 1.0 or --version 2.0",
2,
)
}
Some(log_choice(preset, mapping, sender, directions))
}
}
{
file,
format,
version,
completeness,
log,
expect: value("expect"),
legacy_errors,
}
}
///|
/// Gives the version from the --version flag or from the transcript document.
/// They must agree when both give it. `None` means neither gives it.
fn transcript_version(
flag : @rpc.ProtocolVersion?,
document : @rpc.ProtocolVersion?,
file : String,
) -> @rpc.ProtocolVersion? raise CliError {
match (flag, document) {
(Some(chosen), Some(written)) if chosen != written =>
raise CliError(
"--version \{chosen.to_string()} conflicts with version \{written.to_string()} in \{file}",
2,
)
(Some(version), _) | (None, Some(version)) => Some(version)
(None, None) => None
}
}
///|
fn run_transcript_validation(
opts : ValueOptions,
transcript : TranscriptOptions,
assembled : @smithy.LoadResult,
) -> Int32 raise {
let expected = transcript.expect.map(read_expect_file)
if !validation_model_ok(opts, assembled) {
return 1
}
if transcript.log is Some(log) {
// transcript_options requires --version for a log.
guard transcript_protocol(opts, assembled, transcript.version.unwrap())
is Some(protocol) else {
return 2
}
return run_log_transcript(opts, transcript, protocol, log, expected)
}
let text = try {
if transcript.file == "-" {
read_stdin()
} else {
@fs.read_file_to_string(transcript.file)
}
} catch {
_ => return transcript_input_problem(opts)
}
let (document, read_findings) = match transcript.format {
MarkdownTranscript => @rpc.read_markdown_transcript(text)
_ => @rpc.read_json_transcript(text)
}
let document = match (document.expect, expected) {
(Some(_), Some(_)) =>
raise CliError(
"--expect \{transcript.expect.unwrap()} and the expect list in \{transcript.file} both give expected findings; use only one",
2,
)
(None, Some(_)) => { ..document, expect: expected, }
_ => document
}
guard transcript_version(
transcript.version,
document.version,
transcript.file,
)
is Some(version) else {
// A document that I could not read may have lost its version; its error
// findings explain the problem better than a usage error. Advisories
// alone do not, so they fall through to the usage error.
if read_findings.any(f => f.severity == @validation.Error) {
return print_transcript_report(
opts,
read_findings,
None,
expected.map(e => @rpc.match_expected(e, read_findings)),
complete=false,
)
}
raise CliError(
"\{transcript.file} gives no JSON-RPC version; use --version 1.0 or --version 2.0",
2,
)
}
if transcript.legacy_errors is Some(_) {
require_legacy_version(version)
}
guard transcript_protocol(opts, assembled, version) is Some(protocol) else {
return 2
}
let run = @rpc.run_transcript(
protocol,
document,
read_findings,
completeness?=transcript.completeness,
legacy_errors=transcript.legacy_errors.unwrap_or(@rpc.Opaque),
)
print_transcript_report(
opts,
run.findings,
Some(run.summary),
run.expectations,
complete=run.summary.complete,
)
}
///|
/// Reports a transcript input that I could not open or read, and gives the
/// exit status. A missing file, a directory and an unreadable stream give the
/// same report.
fn transcript_input_problem(opts : ValueOptions) -> Int32 {
value_problem(
opts, "InputRead", "I could not read the JSON-RPC transcript input.", 1,
)
}
///|
/// Reads the --expect file. A file that I cannot read or that is not a valid
/// expect list is a usage error.
fn read_expect_file(
file : String,
) -> Array[@rpc.ExpectedFinding] raise CliError {
let text = @fs.read_file_to_string(file) catch {
_ => raise CliError("could not read the --expect file \{file}", 2)
}
let (expected, problems) = @rpc.read_expected_findings(text)
if problems is [problem, ..] {
raise CliError(
"\{transcript_finding_place(file, problem)}: \{problem.message}",
2,
)
}
expected
}
///|
/// Prepares the --protocol structure for `version`. On a contract error it
/// reports the problem and gives `None`; the exit status is then 2.
fn transcript_protocol(
opts : ValueOptions,
assembled : @smithy.LoadResult,
version : @rpc.ProtocolVersion,
) -> @rpc.ProtocolDescription? {
let protocol = @rpc.ProtocolDescription::from_smithy(
assembled.model.unwrap(),
opts.target,
version,
config=opts.config,
) catch {
@rpc.InvalidContract(id, message) => {
ignore(
value_problem(
opts,
"InvalidContract",
id.to_string() + ": " + message,
2,
source=Some("--protocol"),
),
)
return None
}
@validation.UnsupportedConstraint(id, message) => {
ignore(
value_problem(
opts,
"UnsupportedConstraint",
id.to_string() + ": " + message,
2,
),
)
return None
}
error => {
ignore(value_problem(opts, "InvalidConfiguration", error.to_string(), 2))
return None
}
}
Some(protocol)
}
///|
/// Prints the findings and the expectation result, and gives the exit status:
/// with an expect list, 0 when it matches; otherwise 0 when no finding is an
/// error.
fn print_transcript_report(
opts : ValueOptions,
findings : Array[@rpc.TranscriptFinding],
summary : @rpc.TranscriptSummary?,
expectations : @rpc.ExpectationResult?,
complete~ : Bool,
) -> Int32 {
let valid = complete &&
!findings.any(finding => finding.severity == @validation.Error)
if opts.diagnostics == JsonFindings {
let report = Map::from_array(summary_fields(valid, complete, summary))
report["findings"] = Json::array(
findings.map(finding => @json.to_json(finding)),
)
if expectations is Some(result) {
report["expectations"] = expectations_json(result)
}
println(Json::object(report).stringify())
} else {
for finding in findings {
eprintln(transcript_finding_text(opts.file, finding))
}
if expectations is Some(result) {
for line in expectation_lines(opts.file, result) {
eprintln(line)
}
}
}
let passed = match expectations {
Some(result) => result.is_match()
None => valid
}
if passed {
0
} else {
1
}
}
///|
/// The summary members of a transcript report, in report order. With no
/// checker summary, because the document could not be read, the counts are 0
/// and correlation is on. A JSONL log adds its reader `counts`.
fn summary_fields(
valid : Bool,
complete : Bool,
summary : @rpc.TranscriptSummary?,
counts? : @rpc.LogCounts,
) -> Array[(String, Json)] {
fn number(n : Int32) -> Json {
Json::number(n.to_double())
}
let fields = [
("valid", Json::boolean(valid)),
("complete", Json::boolean(complete)),
("entries", number(summary.map(s => s.entries).unwrap_or(0))),
("outstanding", number(summary.map(s => s.outstanding).unwrap_or(0))),
(
"correlation",
Json::boolean(summary.map(s => s.correlation).unwrap_or(true)),
),
]
if counts is Some(counts) {
fields.push(("lines", number(counts.lines)))
fields.push(("not_json", number(counts.not_json)))
fields.push(("skipped", number(counts.skipped)))
}
fields
}
///|
/// The expectation match as JSON: whether it matched, the missing expected
/// findings and the unexpected error findings.
fn expectations_json(result : @rpc.ExpectationResult) -> Json {
Json::object({
"matched": Json::boolean(result.is_match()),
"missing": Json::array(result.missing.map(expected_json)),
"unexpected": Json::array(
unexpected_errors(result).map(finding => @json.to_json(finding)),
),
})
}
///|
/// The text lines of an expectation mismatch: one per missing expected
/// finding and one per unexpected error finding.
fn expectation_lines(
file : String,
result : @rpc.ExpectationResult,
) -> Array[String] {
let lines = result.missing.map(expected => {
"\{file}: expect: I expected \{expected_text(expected)}; no finding matches it."
})
for finding in unexpected_errors(result) {
lines.push(
"\{transcript_finding_place(file, finding)}: expect: I did not expect error \{finding.code.to_string()}.",
)
}
lines
}
///|
/// The unlisted error findings. An unlisted advisory does not break the match,
/// so the report leaves it out.
fn unexpected_errors(
result : @rpc.ExpectationResult,
) -> Array[@rpc.TranscriptFinding] {
result.unexpected.filter(finding => finding.severity == @validation.Error)
}
///|
/// Renders the location and subject of a finding:
/// `::: entry `, leaving out the parts that the
/// finding does not have.
fn transcript_finding_place(
file : String,
finding : @rpc.TranscriptFinding,
) -> String {
let location = match finding.location {
Some(at) => "\{file}:\{at.line}:\{at.column}"
None => file
}
let subject = []
if finding.entry is Some(entry) {
subject.push("entry \{entry}")
}
if finding.sender is Some(sender) {
subject.push(sender.to_string())
}
if subject.is_empty() {
location
} else {
"\{location}: \{subject.join(" ")}"
}
}
///|
fn transcript_finding_text(
file : String,
finding : @rpc.TranscriptFinding,
) -> String {
let severity = if finding.severity == @validation.Error {
"error"
} else {
"advisory"
}
"\{transcript_finding_place(file, finding)}: \{severity} \{finding.code.to_string()}: \{finding.message}"
}
///|
fn expected_text(expected : @rpc.ExpectedFinding) -> String {
let entry = expected.entry().map(n => " at entry \{n}").unwrap_or("")
let related = expected
.related()
.map(n => " related to entry \{n}")
.unwrap_or("")
"\{expected.code()}\{entry}\{related}"
}
///|
fn expected_json(expected : @rpc.ExpectedFinding) -> Json {
fn number(n : Int32) -> Json {
Json::number(n.to_double())
}
Json::object({
"code": Json::string(expected.code()),
"entry": expected.entry().map(number).unwrap_or(Json::null()),
"related": expected.related().map(number).unwrap_or(Json::null()),
})
}