Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ jobs:

steps:
- uses: actions/checkout@v6
with:
submodules: true

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
Expand Down Expand Up @@ -72,6 +74,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: true

- name: Install Rust 1.88
uses: dtolnay/rust-toolchain@1.88
Expand Down Expand Up @@ -104,6 +108,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: true

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
Expand All @@ -122,6 +128,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: true

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
Expand Down Expand Up @@ -243,6 +251,8 @@ jobs:
- "signatures,encryption"
steps:
- uses: actions/checkout@v6
with:
submodules: true

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
Expand Down
32 changes: 32 additions & 0 deletions .github/workflows/spec-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Spec Staleness Check

on:
schedule:
- cron: '0 9 * * 1' # Weekly, Monday 9am UTC
workflow_dispatch: {}

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: true

- name: Check for upstream spec updates
run: |
cd spec
git fetch origin main
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [ "$LOCAL" != "$REMOTE" ]; then
BEHIND=$(git rev-list --count HEAD..origin/main)
echo "::warning::Spec submodule is $BEHIND commits behind upstream"
gh issue create --title "Spec submodule is $BEHIND commits behind" \
--body "The spec submodule is pinned at \`$LOCAL\` but upstream is at \`$REMOTE\` ($BEHIND commits behind).\n\nRun:\n\`\`\`\ngit submodule update --remote spec\n\`\`\`" \
--label "dependencies" || true
else
echo "Spec submodule is up to date"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "spec"]
path = spec
url = https://github.com/Entrolution/codex-file-format-spec.git
1 change: 1 addition & 0 deletions cdx-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ tempfile = "3.14"
pretty_assertions = "1.4"
proptest = "1.5"
criterion = { version = "0.8", features = ["html_reports"] }
jsonschema = "0.28"

[[bench]]
name = "document"
Expand Down
132 changes: 4 additions & 128 deletions cdx-core/tests/conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,108 +91,9 @@ fn mixed_mark_array_deserializes() {
assert_eq!(marks[2], Mark::Italic);
}

#[test]
fn extension_mark_serializes_without_wrapper() {
use cdx_core::content::ExtensionMark;

let mark = Mark::Extension(ExtensionMark::citation("smith2023"));
let json = serde_json::to_string(&mark).unwrap();
let val: serde_json::Value = serde_json::from_str(&json).unwrap();

// Type should be "semantic:citation", not "extension"
assert_eq!(val["type"], "semantic:citation");
assert_eq!(val["refs"], serde_json::json!(["smith2023"]));

// Should NOT have wrapper fields
assert!(val.get("namespace").is_none());
assert!(val.get("markType").is_none());
// Should NOT have old singular "ref"
assert!(val.get("ref").is_none());
}

#[test]
fn extension_mark_deserializes_new_format() {
let json = r#"{"type":"semantic:citation","refs":["smith2023"]}"#;
let mark: Mark = serde_json::from_str(json).unwrap();

if let Mark::Extension(ext) = &mark {
assert_eq!(ext.namespace, "semantic");
assert_eq!(ext.mark_type, "citation");
assert_eq!(
ext.get_string_array_attribute("refs"),
Some(vec!["smith2023"])
);
} else {
panic!("Expected Extension mark, got {mark:?}");
}
}

#[test]
fn extension_mark_deserializes_old_format() {
// Backward compat: old "extension" wrapper format
let json = r#"{"type":"extension","namespace":"semantic","markType":"citation","attributes":{"ref":"smith2023"}}"#;
let mark: Mark = serde_json::from_str(json).unwrap();

if let Mark::Extension(ext) = &mark {
assert_eq!(ext.namespace, "semantic");
assert_eq!(ext.mark_type, "citation");
// Old format preserves "ref" as-is in attributes; use get_citation_refs for compat
assert_eq!(ext.get_citation_refs(), Some(vec!["smith2023"]));
} else {
panic!("Expected Extension mark, got {mark:?}");
}
}

#[test]
fn citation_mark_backward_compat_singular_ref() {
// Old format with singular "ref" string
let json = r#"{"type":"semantic:citation","ref":"smith2023"}"#;
let mark: Mark = serde_json::from_str(json).unwrap();

if let Mark::Extension(ext) = &mark {
assert_eq!(ext.get_citation_refs(), Some(vec!["smith2023"]));
} else {
panic!("Expected Extension mark, got {mark:?}");
}
}

#[test]
fn citation_mark_multi_refs_roundtrip() {
use cdx_core::content::ExtensionMark;

let refs = vec!["smith2023".into(), "jones2024".into()];
let mark = Mark::Extension(ExtensionMark::multi_citation(&refs));

let json = serde_json::to_string(&mark).unwrap();
let val: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(val["refs"], serde_json::json!(["smith2023", "jones2024"]));

// Round-trip
let parsed: Mark = serde_json::from_str(&json).unwrap();
if let Mark::Extension(ext) = &parsed {
assert_eq!(
ext.get_string_array_attribute("refs"),
Some(vec!["smith2023", "jones2024"])
);
} else {
panic!("Expected Extension mark");
}
}

#[test]
fn citation_mark_normalize_attrs() {
use cdx_core::content::ExtensionMark;

let mut ext = ExtensionMark::new("semantic", "citation")
.with_attributes(serde_json::json!({"ref": "smith2023"}));
ext.normalize_citation_attrs();

assert_eq!(
ext.get_string_array_attribute("refs"),
Some(vec!["smith2023"])
);
assert!(ext.get_string_attribute("ref").is_none());
}
// NOTE: Extension mark serialization, deserialization, and backward-compat
// tests for citations and glossary have been consolidated into
// tests/spec_compliance.rs (mark_schema_validation + backward_compatibility).

#[test]
fn math_mark_uses_source_field() {
Expand Down Expand Up @@ -326,32 +227,7 @@ fn spec_example_text_with_bold_string_mark() {
assert_eq!(output_val, spec_val);
}

#[test]
fn spec_example_text_with_citation_mark() {
// Spec: extension marks use "namespace:markType" as type, attributes flattened
let spec_json = r#"{"value":"important claim","marks":[{"type":"semantic:citation","refs":["smith2023"]}]}"#;

let text: Text = serde_json::from_str(spec_json).unwrap();
assert_eq!(text.value, "important claim");
assert_eq!(text.marks.len(), 1);

if let Mark::Extension(ext) = &text.marks[0] {
assert_eq!(ext.namespace, "semantic");
assert_eq!(ext.mark_type, "citation");
assert_eq!(
ext.get_string_array_attribute("refs"),
Some(vec!["smith2023"])
);
} else {
panic!("Expected Extension mark");
}

// Re-serialize matches spec format
let output = serde_json::to_string(&text).unwrap();
let output_val: serde_json::Value = serde_json::from_str(&output).unwrap();
let spec_val: serde_json::Value = serde_json::from_str(spec_json).unwrap();
assert_eq!(output_val, spec_val);
}
// NOTE: spec_example_text_with_citation_mark moved to spec_compliance.rs

#[test]
fn spec_example_extension_block_academic_theorem() {
Expand Down
Loading
Loading