diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 802dd79..5008673 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -22,11 +22,25 @@ jobs: - name: Run manifest validation without dependencies run: python -S ./conformance/bin/conformance-test manifest ./conformance/examples/basic/ard.json + - name: Run conformance tool tests without dependencies + run: python -S ./conformance/tests/test_media_type_diagnostics.py -v + + - name: Validate extension media-type fixture without dependencies + run: python -S ./conformance/bin/conformance-test manifest ./conformance/tests/fixtures/extension-media-types.json + - name: Install dependencies run: | python -m pip install --upgrade pip pip install jsonschema + - name: Validate extension media-type fixture with JSON Schema + run: python ./conformance/bin/conformance-test manifest ./conformance/tests/fixtures/extension-media-types.json + + - name: Run conformance tool tests with JSON Schema + run: python ./conformance/tests/test_media_type_diagnostics.py -v + env: + ARD_REQUIRE_JSONSCHEMA: "1" + - name: Make scripts executable run: | chmod +x ./conformance/bin/run-conformance-demo diff --git a/.gitignore b/.gitignore index 872d5f6..16245b1 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,10 @@ lib-cov coverage *.lcov +# Python bytecode +__pycache__/ +*.py[cod] + # nyc test coverage .nyc_output diff --git a/conformance/README.md b/conformance/README.md index 9b2aa28..00d0f8c 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -82,6 +82,13 @@ When checking an ARD manifest (`ard.json`), the tool executes the following vali * **Strict URN Pattern Matching**: Enforces that each entry's `identifier` adheres strictly to the domain-anchored URN namespace format defined in the spec: `urn:air:::` (RFC 8141). * **Value-or-Reference Delivery**: Enforces the mutual exclusivity constraint of the specification. Each entry **MUST** contain precisely one of either `"url"` (remote reference) or `"data"` (embedded payload), and will fail if both or neither are provided. +* **Media Type Diagnostics**: + * Accepts the standard discovery media types without a diagnostic. + * Warns clearly when a standard discovery type is missing required parameters or carries unrecognized parameters. + * Reports well-formed, unrecognized `application/*` extension types as informational. ARD's `type` term is intentionally open, so extension types do not require registration in the conformance tool. + * Warns when the deprecated `application/mcp-server+json` form from **ADR-0008** is used and names `application/mcp-server-card+json` as its replacement. + * Gives a distinct syntax warning for malformed media types. + * Retains the existing warning for well-formed, unrecognized types outside the `application` top-level type. * **Discovery Constraints (§D.2)**: * Warns when `"representativeQueries"` is **absent** — the semantic index is built from it, so such an entry is a valid catalog entry but not a discoverable ARD entry. * Warns when it is present but does not contain **2 to 5** natural-language queries. diff --git a/conformance/bin/conformance-test b/conformance/bin/conformance-test index 672c29a..d8ec814 100755 --- a/conformance/bin/conformance-test +++ b/conformance/bin/conformance-test @@ -20,6 +20,68 @@ if hasattr(sys.stdout, 'reconfigure'): # Strict URN Regex matching urn:air::: URN_REGEX = re.compile(r"^urn:air:([a-zA-Z0-9.-]+)(?::([a-zA-Z0-9._:-]+))?:([a-zA-Z0-9._-]+)$") +# RFC 6838 restricted-name for type/subtype plus RFC 9110 parameters. +RESTRICTED_NAME = r"[0-9A-Za-z][0-9A-Za-z!#$&^_.+\-]{0,126}" +TOKEN = r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+" +QUOTED_STRING = r'"(?:[\t !#-\[\]-~]|\\[\t !-~])*"' +MEDIA_TYPE_REGEX = re.compile( + rf"(?P{RESTRICTED_NAME})/(?P{RESTRICTED_NAME})" + rf"(?P(?:[ \t]*;[ \t]*{TOKEN}[ \t]*=[ \t]*(?:{TOKEN}|{QUOTED_STRING}))*)" +) +MEDIA_TYPE_PARAMETER_REGEX = re.compile( + rf"[ \t]*;[ \t]*(?P{TOKEN})[ \t]*=[ \t]*" + rf"(?P{TOKEN}|{QUOTED_STRING})" +) + +def parse_media_type(media_type): + match = MEDIA_TYPE_REGEX.fullmatch(media_type) + if match is None: + return None + + base_media_type = ( + f"{match.group('type').lower()}/{match.group('subtype').lower()}" + ) + parameters = tuple( + sorted( + ( + parameter.group("name").lower(), + parameter.group("value"), + ) + for parameter in MEDIA_TYPE_PARAMETER_REGEX.finditer( + match.group("parameters") + ) + ) + ) + parameter_names = [name for name, _ in parameters] + if len(parameter_names) != len(set(parameter_names)): + return None + return base_media_type, parameters + +STANDARD_MEDIA_TYPES = ( + "application/ai-catalog+json", + "application/agent-card+json", + "application/a2a-agent-card+json", + "application/mcp-server-card+json", + "application/agent-skills+zip", + "application/agent-skills+gzip", + 'text/markdown; profile="urn:air:agent-skills"', + "application/ai-registry", + "application/ai-registry+json", +) +STANDARD_MEDIA_TYPE_KEYS = frozenset( + parse_media_type(media_type) for media_type in STANDARD_MEDIA_TYPES +) +STANDARD_BASE_MEDIA_TYPES = frozenset( + base_media_type for base_media_type, _ in STANDARD_MEDIA_TYPE_KEYS +) +STANDARD_PARAMETERS_BY_BASE = { + base_media_type: dict(parameters) + for base_media_type, parameters in STANDARD_MEDIA_TYPE_KEYS +} +DEPRECATED_MEDIA_TYPES = { + "application/mcp-server+json": "application/mcp-server-card+json", +} + # Colors for beautiful CLI output COLOR_RESET = "\033[0m" COLOR_BOLD = "\033[1m" @@ -40,9 +102,82 @@ def print_failure(msg): def print_warning(msg): print(f" {COLOR_YELLOW}⚠{COLOR_RESET} {msg}") +def print_info(msg): + print(f" {COLOR_CYAN}ℹ{COLOR_RESET} {msg}") + def print_bullet(msg): print(f" • {msg}") +def classify_media_type(media_type): + if not isinstance(media_type, str): + return ( + "warning", + f"Media type must be a string; got {type(media_type).__name__}.", + ) + + parsed_media_type = parse_media_type(media_type) + if parsed_media_type is None: + return ( + "warning", + f"Media type '{media_type}' is not a valid IANA media type. " + "Expected 'type/subtype' with optional parameters.", + ) + + base_media_type, parameters = parsed_media_type + if parsed_media_type in STANDARD_MEDIA_TYPE_KEYS: + return None + + replacement = DEPRECATED_MEDIA_TYPES.get(base_media_type) + if replacement is not None: + return ( + "warning", + f"Media type '{media_type}' was renamed by ADR-0008. " + f"Use '{replacement}'.", + ) + + if base_media_type in STANDARD_BASE_MEDIA_TYPES: + expected_parameters = STANDARD_PARAMETERS_BY_BASE[base_media_type] + actual_parameters = dict(parameters) + missing_parameters = [ + f"{name}={value}" + for name, value in expected_parameters.items() + if actual_parameters.get(name) != value + ] + unrecognized_parameters = [ + name + for name, value in parameters + if expected_parameters.get(name) != value + ] + differences = [] + if missing_parameters: + differences.append( + "missing required parameters: " + ", ".join(missing_parameters) + ) + if unrecognized_parameters: + differences.append( + "unrecognized parameters: " + + ", ".join(unrecognized_parameters) + ) + return ( + "warning", + f"Media type '{media_type}' is based on standard discovery type " + f"'{base_media_type}' but has " + f"{'; '.join(differences)}.", + ) + + if base_media_type.startswith("application/"): + return ( + "info", + f"Media type '{media_type}' is a valid application extension media type. " + "ARD permits extension types without core registration.", + ) + + return ( + "warning", + f"Media type '{media_type}' is not one of standard discovery types: " + f"{list(STANDARD_MEDIA_TYPES)}.", + ) + def parse_request_headers(header_args): headers = {} for raw_header in header_args: @@ -66,6 +201,7 @@ class ConformanceTester: def __init__(self): self.errors = [] self.warnings = [] + self.infos = [] self.jsonschema_available = False try: import jsonschema @@ -81,6 +217,10 @@ class ConformanceTester: self.warnings.append(msg) print_warning(msg) + def add_info(self, msg): + self.infos.append(msg) + print_info(msg) + def _load_entry_schema(self): """The ARD entry schema is authoritative for entry structure (spec Appendix D.1).""" schema_path = os.path.join(os.path.dirname(__file__), "../../spec/schemas/ard-entry.schema.json") @@ -195,19 +335,13 @@ class ConformanceTester: if not media_type: self.add_error(f"[{label}] Missing required 'type' (mediaType).") else: - valid_types = [ - "application/ai-catalog+json", - "application/agent-card+json", - "application/a2a-agent-card+json", - "application/mcp-server-card+json", - "application/agent-skills+zip", - "application/agent-skills+gzip", - "text/markdown; profile=\"urn:air:agent-skills\"", - "application/ai-registry", - "application/ai-registry+json" - ] - if media_type not in valid_types: - self.add_warning(f"[{label}] Media type '{media_type}' is not one of standard discovery types: {valid_types}.") + diagnostic = classify_media_type(media_type) + if diagnostic is not None: + severity, message = diagnostic + if severity == "info": + self.add_info(f"[{label}] {message}") + else: + self.add_warning(f"[{label}] {message}") # Strict Value-or-Reference checks has_url = "url" in entry @@ -524,7 +658,13 @@ def main(): print_header("Conformance Validation Summary") if success: print(f"{COLOR_BOLD}{COLOR_GREEN}CONFORMANCE STATUS: PASS{COLOR_RESET}") - print(f"Validated with 0 critical specification errors and {len(tester.warnings)} warnings.") + summary = ( + f"Validated with 0 critical specification errors and " + f"{len(tester.warnings)} warnings" + ) + if tester.infos: + summary += f" and {len(tester.infos)} informational messages" + print(f"{summary}.") sys.exit(0) else: print(f"{COLOR_BOLD}{COLOR_RED}CONFORMANCE STATUS: FAIL{COLOR_RESET}") diff --git a/conformance/tests/fixtures/extension-media-types.json b/conformance/tests/fixtures/extension-media-types.json new file mode 100644 index 0000000..29f9e75 --- /dev/null +++ b/conformance/tests/fixtures/extension-media-types.json @@ -0,0 +1,89 @@ +{ + "specVersion": "1.0", + "host": { + "displayName": "Extension media type conformance fixture", + "identifier": "https://media-types.test" + }, + "entries": [ + { + "identifier": "urn:air:media-types.test:fixture:standard-mcp", + "displayName": "Standard MCP card", + "type": "application/mcp-server-card+json", + "url": "https://media-types.test/artifacts/standard-mcp", + "representativeQueries": [ + "find the standard MCP fixture", + "locate the standard protocol card" + ] + }, + { + "identifier": "urn:air:media-types.test:fixture:standard-a2a", + "displayName": "Standard A2A card", + "type": "application/a2a-agent-card+json", + "url": "https://media-types.test/artifacts/standard-a2a", + "representativeQueries": [ + "find the standard A2A fixture", + "locate the standard agent card" + ] + }, + { + "identifier": "urn:air:media-types.test:fixture:vendor-card", + "displayName": "Vendor extension card", + "type": "application/vnd.example.tool-manifest+json", + "url": "https://media-types.test/artifacts/vendor-card", + "representativeQueries": [ + "find a vendor extension card", + "locate an example tool manifest" + ] + }, + { + "identifier": "urn:air:media-types.test:fixture:extension-bundle", + "displayName": "Extension bundle", + "type": "application/example-bundle+zip", + "url": "https://media-types.test/artifacts/extension-bundle", + "representativeQueries": [ + "find an extension bundle", + "locate an example archive" + ] + }, + { + "identifier": "urn:air:media-types.test:fixture:extension-widget", + "displayName": "Extension widget", + "type": "application/x-example-widget", + "url": "https://media-types.test/artifacts/extension-widget", + "representativeQueries": [ + "find an extension widget", + "locate a custom application artifact" + ] + }, + { + "identifier": "urn:air:media-types.test:fixture:deprecated-mcp", + "displayName": "Deprecated MCP card", + "type": "application/mcp-server+json", + "url": "https://media-types.test/artifacts/deprecated-mcp", + "representativeQueries": [ + "find the deprecated MCP fixture", + "locate the transition media type" + ] + }, + { + "identifier": "urn:air:media-types.test:fixture:other-top-level", + "displayName": "Other top-level type", + "type": "text/x-example-notes", + "url": "https://media-types.test/artifacts/other-top-level", + "representativeQueries": [ + "find text extension notes", + "locate a non-application artifact" + ] + }, + { + "identifier": "urn:air:media-types.test:fixture:malformed-type", + "displayName": "Malformed media type", + "type": "application/ example", + "url": "https://media-types.test/artifacts/malformed-type", + "representativeQueries": [ + "find the malformed syntax fixture", + "locate an invalid media type example" + ] + } + ] +} diff --git a/conformance/tests/test_media_type_diagnostics.py b/conformance/tests/test_media_type_diagnostics.py new file mode 100644 index 0000000..960062f --- /dev/null +++ b/conformance/tests/test_media_type_diagnostics.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 + +import json +import os +import re +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CONFORMANCE_TOOL = REPO_ROOT / "conformance" / "bin" / "conformance-test" +EXTENSION_FIXTURE = ( + REPO_ROOT + / "conformance" + / "tests" + / "fixtures" + / "extension-media-types.json" +) + +STANDARD_MEDIA_TYPES = ( + "application/ai-catalog+json", + "application/agent-card+json", + "application/a2a-agent-card+json", + "application/mcp-server-card+json", + "application/agent-skills+zip", + "application/agent-skills+gzip", + 'text/markdown; profile="urn:air:agent-skills"', + "application/ai-registry", + "application/ai-registry+json", +) + +EXTENSION_MEDIA_TYPES = ( + "application/vnd.example.tool-manifest+json", + "application/install-manifest+json", + "application/okf-bundle+zip", + "application/x-example-widget", + "application/pdf", + "application/ai-skill+md", + "application/asm+json", +) + +NON_APPLICATION_MEDIA_TYPES = ( + "text/plain", + "image/png", + "video/mp4", + "model/gltf+json", + "example/example-card+json", +) + +MALFORMED_MEDIA_TYPES = ( + "application", + "application/", + "/json", + "application//json", + "application/ json", + " application/json", + "application/json ", + "application/json;", + "application/json;charset", + "application/json; =utf-8", + "application/json charset=utf-8", + '"application/json"', + "application/jsøn", +) + + +def _entry(media_type, index): + return { + "identifier": f"urn:air:media-types.test:fixture:entry-{index}", + "displayName": f"Media type fixture {index}", + "type": media_type, + "url": f"https://media-types.test/artifacts/{index}", + "representativeQueries": [ + f"find media type fixture {index}", + f"locate extension artifact {index}", + ], + } + + +def _run_manifest_file(path, *, without_site_packages=True, timeout=10): + command = [sys.executable] + if without_site_packages: + command.append("-S") + command.extend([str(CONFORMANCE_TOOL), "manifest", str(path)]) + return subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + ) + + +def _run_media_types(media_types, *, timeout=10): + manifest = { + "entries": [ + _entry(media_type, index) + for index, media_type in enumerate(media_types, start=1) + ] + } + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "ard.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + return _run_manifest_file(path, timeout=timeout) + + +def _summary_counts(stdout): + match = re.search( + r"Validated with 0 critical specification errors and " + r"(\d+) warnings(?: and (\d+) informational messages)?\.", + stdout, + ) + if match is None: + raise AssertionError(f"Conformance summary was not found in output:\n{stdout}") + warnings = int(match.group(1)) + infos = int(match.group(2)) if match.group(2) is not None else 0 + return warnings, infos + + +def _line_containing(stdout, value): + diagnostic = f"Media type '{value}'" + for line in stdout.splitlines(): + if diagnostic in line: + return line + raise AssertionError(f"No output line contains {value!r}:\n{stdout}") + + +class MediaTypeDiagnosticTests(unittest.TestCase): + def assert_passed(self, result): + self.assertEqual( + result.returncode, + 0, + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", + ) + + def test_all_existing_standard_media_types_remain_silent(self): + result = _run_media_types(STANDARD_MEDIA_TYPES) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (0, 0)) + self.assertNotIn("informational messages", result.stdout) + for media_type in STANDARD_MEDIA_TYPES: + self.assertNotIn( + f"Media type '{media_type}'", + result.stdout, + f"Unexpected diagnostic for standard type {media_type}", + ) + + def test_profiled_standard_type_accepts_equivalent_http_spelling(self): + equivalent_types = ( + 'text/markdown;profile="urn:air:agent-skills"', + 'Text/Markdown; PROFILE="urn:air:agent-skills"', + ) + + result = _run_media_types(equivalent_types) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (0, 0)) + + def test_standard_base_types_with_extra_parameters_warn_clearly(self): + parameterized_types = ( + "application/agent-skills+zip; anything=go", + 'application/mcp-server-card+json; profile="example"', + ) + + result = _run_media_types(parameterized_types) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (2, 0)) + for media_type in parameterized_types: + self.assertIn( + "is based on standard discovery type", + _line_containing(result.stdout, media_type), + ) + + def test_profiled_standard_type_with_extra_parameter_warns_clearly(self): + media_type = ( + 'text/markdown; profile="urn:air:agent-skills"; charset=utf-8' + ) + + result = _run_media_types((media_type,)) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (1, 0)) + line = _line_containing(result.stdout, media_type) + self.assertIn("is based on standard discovery type", line) + self.assertIn("unrecognized parameters: charset", line) + self.assertNotIn("unrecognized parameters: charset, profile", line) + + def test_profiled_standard_type_without_profile_reports_missing_parameter(self): + media_type = "text/markdown" + + result = _run_media_types((media_type,)) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (1, 0)) + line = _line_containing(result.stdout, media_type) + self.assertIn( + 'missing required parameters: profile="urn:air:agent-skills"', + line, + ) + self.assertNotIn("unrecognized parameters", line) + + def test_application_extension_media_types_are_informational(self): + result = _run_media_types(EXTENSION_MEDIA_TYPES) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (0, len(EXTENSION_MEDIA_TYPES))) + for media_type in EXTENSION_MEDIA_TYPES: + line = _line_containing(result.stdout, media_type) + self.assertIn("valid application extension media type", line) + self.assertNotIn("⚠", line) + self.assertNotIn("not one of standard discovery types", line) + + def test_deprecated_mcp_media_type_warns_with_replacement(self): + deprecated_types = ( + "application/mcp-server+json", + "application/mcp-server+json; charset=utf-8", + ) + + result = _run_media_types(deprecated_types) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (2, 0)) + for media_type in deprecated_types: + line = _line_containing(result.stdout, media_type) + self.assertIn("⚠", line) + self.assertIn("ADR-0008", line) + self.assertIn("application/mcp-server-card+json", line) + + def test_similar_mcp_extension_types_are_not_marked_deprecated(self): + similar_types = ( + "application/mcp-server-card+json", + "application/x-mcp-server+json", + "application/mcp-server+xml", + ) + + result = _run_media_types(similar_types) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (0, 2)) + self.assertNotIn("ADR-0008", result.stdout) + + def test_other_top_level_media_types_keep_existing_warning(self): + result = _run_media_types(NON_APPLICATION_MEDIA_TYPES) + + self.assert_passed(result) + self.assertEqual( + _summary_counts(result.stdout), + (len(NON_APPLICATION_MEDIA_TYPES), 0), + ) + for media_type in NON_APPLICATION_MEDIA_TYPES: + line = _line_containing(result.stdout, media_type) + self.assertIn("⚠", line) + self.assertIn("is not one of standard discovery types", line) + + def test_application_word_in_wrong_position_is_not_an_extension(self): + result = _run_media_types(("applicationx/example", "xapplication/example")) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (2, 0)) + self.assertNotIn("valid application extension media type", result.stdout) + + def test_malformed_media_types_get_syntax_warnings(self): + result = _run_media_types(MALFORMED_MEDIA_TYPES) + + self.assert_passed(result) + self.assertEqual( + _summary_counts(result.stdout), + (len(MALFORMED_MEDIA_TYPES), 0), + ) + self.assertEqual( + result.stdout.count("is not a valid IANA media type"), + len(MALFORMED_MEDIA_TYPES), + ) + self.assertNotIn("valid application extension media type", result.stdout) + + def test_trailing_newline_is_not_accepted_by_media_type_parser(self): + result = _run_media_types(("application/json\n",)) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (1, 0)) + self.assertIn("is not a valid IANA media type", result.stdout) + self.assertNotIn("valid application extension media type", result.stdout) + + def test_parameterized_application_extension_is_informational(self): + media_type = 'application/vnd.example.card+json; version="1"; charset=utf-8' + + result = _run_media_types((media_type,)) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (0, 1)) + line = _line_containing(result.stdout, media_type) + self.assertIn("valid application extension media type", line) + self.assertNotIn("⚠", line) + + def test_duplicate_parameter_names_are_malformed(self): + media_type = ( + 'text/markdown; profile="urn:air:agent-skills"; ' + 'profile="urn:air:agent-skills"' + ) + + result = _run_media_types((media_type,)) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (1, 0)) + self.assertIn( + "is not a valid IANA media type", + _line_containing(result.stdout, media_type), + ) + + def test_truthy_non_string_type_does_not_abort_later_entries(self): + result = _run_media_types((123, "application/vnd.example.card+json")) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (1, 1)) + self.assertIn("Media type must be a string", result.stdout) + self.assertIn( + "valid application extension media type", + result.stdout, + ) + + def test_long_invalid_type_finishes_without_pathological_backtracking(self): + result = _run_media_types( + ("application/" + ("a!" * 5_000) + "é",), + timeout=2, + ) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (1, 0)) + self.assertIn("is not a valid IANA media type", result.stdout) + + def test_neutral_fixture_covers_every_diagnostic_class(self): + result = _run_manifest_file(EXTENSION_FIXTURE) + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (3, 3)) + self.assertIn( + "application/mcp-server-card+json", + EXTENSION_FIXTURE.read_text(encoding="utf-8"), + ) + self.assertIn("application/vnd.example.tool-manifest+json", result.stdout) + self.assertIn("application/mcp-server+json", result.stdout) + self.assertIn("text/x-example-notes", result.stdout) + self.assertIn("application/ example", result.stdout) + + def test_neutral_fixture_with_json_schema_keeps_diagnostic_counts(self): + result = _run_manifest_file( + EXTENSION_FIXTURE, + without_site_packages=False, + ) + + if "Skipping strict JSON Schema check" in result.stdout: + if os.environ.get("ARD_REQUIRE_JSONSCHEMA") == "1": + self.fail("ARD_REQUIRE_JSONSCHEMA=1 but jsonschema did not load") + self.skipTest("jsonschema is not installed") + + self.assert_passed(result) + self.assertEqual(_summary_counts(result.stdout), (3, 3)) + self.assertIn("Manifest validates against ArdManifest", result.stdout) + + def test_existing_examples_emit_no_extension_info(self): + example_paths = sorted( + (REPO_ROOT / "conformance" / "examples").rglob("*.json") + ) + self.assertTrue(example_paths) + + for path in example_paths: + with self.subTest(path=path.relative_to(REPO_ROOT)): + result = _run_manifest_file(path) + self.assert_passed(result) + _, infos = _summary_counts(result.stdout) + self.assertEqual(infos, 0) + + +if __name__ == "__main__": + unittest.main()