From e063f35a3377f8273ddae8dfa1b9ed1b80ed6a79 Mon Sep 17 00:00:00 2001 From: James Lal Date: Thu, 7 May 2026 22:16:59 -0600 Subject: [PATCH 01/13] chore(conformance): scaffold harness, catalog generator, and beads tracker Establishes the conformance infrastructure that backs OpenAPI 3.1/3.2 work: - catalog-gen: parses OAS 3.2 spec markdown into tests/conformance/catalog.yaml (30 objects, 141 fields, 57 JSON Schema 2020-12 keywords, 7 style combos). This is the denominator for "100% coverage". - conformance harness (tests/conformance.rs): walks fixtures, runs L0 lossless-parse check, supports layered fails_at: markers so fixtures for layers we haven't built yet (L1/L3/L5) are recorded as deferred. - 10 seed fixtures across the audit's top-impact gaps (webhooks, additional-operations, query method, deepObject, header params, type-array nullability, components/pathItems, components/mediaTypes, mutualTLS, $dynamicRef). - JSON Schema 2020-12 corpus runner via git submodule (json-schema-org/JSON-Schema-Test-Suite). 47 test files exercised. - APIs.guru lazy-clone + smoke runner (off by default). - status.toml: honest support claims, derived from harness results. - beads.yaml + file-beads bin: source-of-truth for the work plan; emitted as GitHub issue #14 with parallel-execution batches computed from dependency depth and file-set disjointness. Cross-cutting findings live in tmp/openapi-specs/reports/00-SUMMARY.md. Tracking issue: https://github.com/gpu-cli/openapi-to-rust/issues/14 Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 8 + .gitmodules | 3 + src/bin/catalog-gen.rs | 420 ++ src/bin/file-beads.rs | 510 ++ tests/conformance.rs | 358 ++ tests/conformance/beads.yaml | 990 +++ tests/conformance/catalog.yaml | 994 +++ tests/conformance/external/apis-guru-sync.sh | 19 + .../external/json-schema-test-suite | 1 + .../components/media-types-component-3.2.yaml | 25 + .../components/path-items-component.yaml | 15 + .../fixtures/parameter/deepobject-query.yaml | 21 + .../fixtures/parameter/header-required.yaml | 26 + .../pathitem/additional-operations-3.2.yaml | 20 + .../fixtures/pathitem/query-method-3.2.yaml | 26 + .../fixtures/schema/dynamic-ref-tree.yaml | 19 + .../fixtures/schema/type-string-or-null.yaml | 14 + .../fixtures/security/mutual-tls.yaml | 18 + .../fixtures/webhooks/basic-webhook.yaml | 28 + tests/conformance/specs/openapi-3.1.2.md | 4786 ++++++++++++++ tests/conformance/specs/openapi-3.2.0.md | 5604 +++++++++++++++++ tests/conformance/status.toml | 72 + tests/conformance_apis_guru.rs | 131 + tests/conformance_json_schema.rs | 219 + 24 files changed, 14327 insertions(+) create mode 100644 .gitmodules create mode 100644 src/bin/catalog-gen.rs create mode 100644 src/bin/file-beads.rs create mode 100644 tests/conformance.rs create mode 100644 tests/conformance/beads.yaml create mode 100644 tests/conformance/catalog.yaml create mode 100755 tests/conformance/external/apis-guru-sync.sh create mode 160000 tests/conformance/external/json-schema-test-suite create mode 100644 tests/conformance/fixtures/components/media-types-component-3.2.yaml create mode 100644 tests/conformance/fixtures/components/path-items-component.yaml create mode 100644 tests/conformance/fixtures/parameter/deepobject-query.yaml create mode 100644 tests/conformance/fixtures/parameter/header-required.yaml create mode 100644 tests/conformance/fixtures/pathitem/additional-operations-3.2.yaml create mode 100644 tests/conformance/fixtures/pathitem/query-method-3.2.yaml create mode 100644 tests/conformance/fixtures/schema/dynamic-ref-tree.yaml create mode 100644 tests/conformance/fixtures/schema/type-string-or-null.yaml create mode 100644 tests/conformance/fixtures/security/mutual-tls.yaml create mode 100644 tests/conformance/fixtures/webhooks/basic-webhook.yaml create mode 100644 tests/conformance/specs/openapi-3.1.2.md create mode 100644 tests/conformance/specs/openapi-3.2.0.md create mode 100644 tests/conformance/status.toml create mode 100644 tests/conformance_apis_guru.rs create mode 100644 tests/conformance_json_schema.rs diff --git a/.gitignore b/.gitignore index 84b8c03..6ebcb4b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,11 @@ *.swo *~ .DS_Store + +# Lazy-cloned external corpus (see tests/conformance/external/apis-guru-sync.sh) +/tests/conformance/external/apis-guru/ + +# Conformance reports are regenerated; keep the .toml/.yaml inputs tracked +/tests/conformance/coverage-report.md +/tests/conformance/json-schema-2020-12-report.md +/tests/conformance/apis-guru-report.md diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..58d3f1c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "tests/conformance/external/json-schema-test-suite"] + path = tests/conformance/external/json-schema-test-suite + url = https://github.com/json-schema-org/JSON-Schema-Test-Suite.git diff --git a/src/bin/catalog-gen.rs b/src/bin/catalog-gen.rs new file mode 100644 index 0000000..cb3afbb --- /dev/null +++ b/src/bin/catalog-gen.rs @@ -0,0 +1,420 @@ +//! Parses the OpenAPI 3.2.0 spec markdown into a structured coverage catalog. +//! +//! Run with: cargo run --bin catalog-gen +//! Reads: tests/conformance/specs/openapi-3.2.0.md +//! Writes: tests/conformance/catalog.yaml + +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Default, serde::Serialize)] +struct Catalog { + spec_version: String, + spec_source: String, + objects: BTreeMap, + json_schema_2020_12_keywords: Vec, + parameter_style_matrix: Vec, + appendices: Vec, + totals: Totals, +} + +#[derive(Debug, Default, serde::Serialize)] +struct ObjectEntry { + section_anchor: String, + fields: Vec, + patterned_fields: Vec, + notes: Vec, +} + +#[derive(Debug, Default, serde::Serialize, Clone)] +struct FieldEntry { + name: String, + anchor: String, + type_str: String, + required: bool, + description_excerpt: String, +} + +#[derive(Debug, serde::Serialize)] +struct StyleEntry { + style: &'static str, + parameter_in: &'static [&'static str], + types: &'static [&'static str], + rfc6570: &'static str, +} + +#[derive(Debug, serde::Serialize)] +struct AppendixEntry { + id: &'static str, + title: &'static str, +} + +#[derive(Debug, Default, serde::Serialize)] +struct Totals { + object_count: usize, + field_count: usize, + patterned_field_count: usize, + json_schema_keyword_count: usize, + parameter_style_combo_count: usize, +} + +fn main() -> Result<(), Box> { + let workspace = workspace_root(); + let spec_path = workspace.join("tests/conformance/specs/openapi-3.2.0.md"); + let out_path = workspace.join("tests/conformance/catalog.yaml"); + + let md = fs::read_to_string(&spec_path)?; + let mut catalog = parse(&md); + + catalog.spec_source = spec_path + .strip_prefix(&workspace) + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| spec_path.display().to_string()); + + catalog.json_schema_2020_12_keywords = json_schema_2020_12_keywords(); + catalog.parameter_style_matrix = parameter_style_matrix(); + catalog.appendices = appendices(); + + catalog.totals.object_count = catalog.objects.len(); + catalog.totals.field_count = catalog.objects.values().map(|o| o.fields.len()).sum(); + catalog.totals.patterned_field_count = + catalog.objects.values().map(|o| o.patterned_fields.len()).sum(); + catalog.totals.json_schema_keyword_count = catalog.json_schema_2020_12_keywords.len(); + catalog.totals.parameter_style_combo_count = catalog.parameter_style_matrix.len(); + + let yaml = serde_yaml::to_string(&catalog)?; + let header = format!( + "# Generated by `cargo run --bin catalog-gen`. Do not edit by hand.\n\ + # Source: {}\n", + catalog.spec_source + ); + fs::write(&out_path, format!("{header}{yaml}"))?; + + println!( + "wrote {} ({} objects, {} fields, {} patterned, {} JSON Schema keywords, {} style combos)", + out_path.display(), + catalog.totals.object_count, + catalog.totals.field_count, + catalog.totals.patterned_field_count, + catalog.totals.json_schema_keyword_count, + catalog.totals.parameter_style_combo_count, + ); + Ok(()) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn parse(md: &str) -> Catalog { + let mut catalog = Catalog::default(); + if let Some(v) = scan_version(md) { + catalog.spec_version = v; + } + + enum State { + Outside, + InObject { + name: String, + }, + AwaitingTable { + object: String, + kind: TableKind, + }, + InTable { + object: String, + kind: TableKind, + saw_separator: bool, + }, + } + + enum TableKind { + Fixed, + Patterned, + } + + let mut state = State::Outside; + + for line in md.lines() { + match &state { + State::Outside | State::InObject { .. } | State::AwaitingTable { .. } => { + if let Some((name, anchor)) = scan_object_heading(line) { + catalog + .objects + .entry(name.clone()) + .or_insert_with(|| ObjectEntry { + section_anchor: anchor.clone(), + ..ObjectEntry::default() + }); + state = State::InObject { name }; + continue; + } + } + _ => {} + } + + match &state { + State::InObject { name, .. } | State::AwaitingTable { object: name, .. } => { + if line.trim_start().starts_with("#### Fixed Fields") { + state = State::AwaitingTable { + object: name.clone(), + kind: TableKind::Fixed, + }; + continue; + } + if line.trim_start().starts_with("#### Patterned Fields") { + state = State::AwaitingTable { + object: name.clone(), + kind: TableKind::Patterned, + }; + continue; + } + } + _ => {} + } + + if let State::AwaitingTable { object, kind } = &state { + if line.starts_with("| Field Name") { + state = State::InTable { + object: object.clone(), + kind: match kind { + TableKind::Fixed => TableKind::Fixed, + TableKind::Patterned => TableKind::Patterned, + }, + saw_separator: false, + }; + continue; + } + if line.trim_start().starts_with("####") || line.starts_with("### ") { + state = State::Outside; + } + } + + if let State::InTable { + object, + kind, + saw_separator, + } = &mut state + { + if !*saw_separator { + if line.starts_with("| --") || line.starts_with("|--") { + *saw_separator = true; + } + continue; + } + if line.trim().is_empty() || !line.starts_with('|') { + state = State::Outside; + continue; + } + if let Some(field) = parse_field_row(line) { + let entry = catalog + .objects + .get_mut(object) + .expect("object inserted on heading"); + match kind { + TableKind::Fixed => entry.fields.push(field), + TableKind::Patterned => entry.patterned_fields.push(field), + } + } + } + } + + catalog +} + +fn scan_version(md: &str) -> Option { + md.lines() + .find(|l| l.starts_with("## Version ")) + .map(|l| l.trim_start_matches("## Version ").trim().to_string()) +} + +fn scan_object_heading(line: &str) -> Option<(String, String)> { + let rest = line.strip_prefix("### ")?; + let name = rest.trim_end().trim_end_matches(" Object").to_string(); + if !rest.ends_with(" Object") { + return None; + } + let anchor = name.to_lowercase().replace(' ', "-") + "-object"; + Some((name, anchor)) +} + +fn parse_field_row(line: &str) -> Option { + let cells: Vec<&str> = line + .trim() + .trim_start_matches('|') + .trim_end_matches('|') + .split('|') + .map(|c| c.trim()) + .collect(); + if cells.len() < 3 { + return None; + } + let name_cell = cells[0]; + let type_cell = cells[1]; + let desc_cell = cells[2]; + let (name, anchor) = extract_name_and_anchor(name_cell); + if name.is_empty() { + return None; + } + let required = desc_cell.contains("**REQUIRED**"); + let description_excerpt = strip_markdown(desc_cell) + .chars() + .take(160) + .collect::(); + Some(FieldEntry { + name, + anchor, + type_str: strip_markdown(type_cell), + required, + description_excerpt, + }) +} + +fn extract_name_and_anchor(cell: &str) -> (String, String) { + // Format: fieldName + let mut anchor = String::new(); + let mut rest = cell; + if let Some(start) = cell.find("name=\"") { + let after = &cell[start + 6..]; + if let Some(end) = after.find('"') { + anchor = after[..end].to_string(); + } + } + if let Some(end_tag) = cell.find("") { + rest = &cell[end_tag + 4..]; + } + let name = rest.trim().trim_start_matches('`').trim_end_matches('`'); + let name = name.split_whitespace().next().unwrap_or("").to_string(); + (name, anchor) +} + +fn strip_markdown(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut in_link_text = false; + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '`' => {} // strip code fences inline + '[' => { + in_link_text = true; + } + ']' => { + in_link_text = false; + while let Some(&n) = chars.peek() { + if n == '(' { + chars.next(); + for inner in chars.by_ref() { + if inner == ')' { + break; + } + } + break; + } else { + break; + } + } + } + '*' => {} // strip emphasis + _ if !in_link_text && c == '<' => { + for inner in chars.by_ref() { + if inner == '>' { + break; + } + } + } + _ => out.push(c), + } + } + out.split_whitespace().collect::>().join(" ") +} + +fn json_schema_2020_12_keywords() -> Vec { + [ + // Core + "$schema", "$vocabulary", "$id", "$ref", "$anchor", "$dynamicRef", "$dynamicAnchor", + "$defs", "$comment", + // Applicators + "allOf", "anyOf", "oneOf", "not", "if", "then", "else", "dependentSchemas", + "prefixItems", "items", "contains", "properties", "patternProperties", + "additionalProperties", "propertyNames", + // Validation + "type", "enum", "const", + "multipleOf", "maximum", "exclusiveMaximum", "minimum", "exclusiveMinimum", + "maxLength", "minLength", "pattern", + "maxItems", "minItems", "uniqueItems", "maxContains", "minContains", + "maxProperties", "minProperties", "required", "dependentRequired", + // Meta-data + "title", "description", "default", "deprecated", "readOnly", "writeOnly", + "examples", + // Format + "format", + // Content + "contentEncoding", "contentMediaType", "contentSchema", + // Unevaluated + "unevaluatedItems", "unevaluatedProperties", + ] + .iter() + .map(|s| s.to_string()) + .collect() +} + +fn parameter_style_matrix() -> Vec { + // From OAS 3.2 §"Parameter Object" style tables and Appendix C. + vec![ + StyleEntry { + style: "matrix", + parameter_in: &["path"], + types: &["primitive", "array", "object"], + rfc6570: "{;var}", + }, + StyleEntry { + style: "label", + parameter_in: &["path"], + types: &["primitive", "array", "object"], + rfc6570: "{.var}", + }, + StyleEntry { + style: "simple", + parameter_in: &["path", "header"], + types: &["primitive", "array", "object"], + rfc6570: "{var}", + }, + StyleEntry { + style: "form", + parameter_in: &["query", "cookie"], + types: &["primitive", "array", "object"], + rfc6570: "{?var}/{&var}", + }, + StyleEntry { + style: "spaceDelimited", + parameter_in: &["query"], + types: &["array", "object"], + rfc6570: "n/a (custom)", + }, + StyleEntry { + style: "pipeDelimited", + parameter_in: &["query"], + types: &["array", "object"], + rfc6570: "n/a (custom)", + }, + StyleEntry { + style: "deepObject", + parameter_in: &["query"], + types: &["object"], + rfc6570: "n/a (custom)", + }, + ] +} + +fn appendices() -> Vec { + vec![ + AppendixEntry { id: "A", title: "Revision History" }, + AppendixEntry { id: "B", title: "Data Type Conversion" }, + AppendixEntry { id: "C", title: "Using RFC6570-Based Serialization" }, + AppendixEntry { id: "D", title: "Serializing Headers and Cookies" }, + AppendixEntry { id: "E", title: "Percent-Encoding and Form Media Types" }, + AppendixEntry { id: "F", title: "Base URI Determination and Reference Resolution" }, + AppendixEntry { id: "G", title: "Parsing and Resolution Guidance" }, + ] +} diff --git a/src/bin/file-beads.rs b/src/bin/file-beads.rs new file mode 100644 index 0000000..420a9cb --- /dev/null +++ b/src/bin/file-beads.rs @@ -0,0 +1,510 @@ +//! Reads tests/conformance/beads.yaml and creates labels + issues in the +//! configured GitHub repo via `gh`. Idempotent: existing labels/issues with +//! the same name/title are skipped. +//! +//! Default: dry-run. Pass `--apply` to actually create. +//! +//! cargo run --bin file-beads # dry-run, prints plan +//! cargo run --bin file-beads -- --apply # creates labels + issues +//! cargo run --bin file-beads -- --apply --beads F1,T1 # subset + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +#[derive(Debug, serde::Deserialize)] +struct Beads { + repo: String, + labels: Vec