Skip to content

JSON Schema 2019-09 support#308

Open
keegan-caruso wants to merge 9 commits intomicrosoft:mainfrom
keegan-caruso:users/keegancaruso/2019-09-schema
Open

JSON Schema 2019-09 support#308
keegan-caruso wants to merge 9 commits intomicrosoft:mainfrom
keegan-caruso:users/keegancaruso/2019-09-schema

Conversation

@keegan-caruso
Copy link
Copy Markdown
Member

@keegan-caruso keegan-caruso commented Feb 1, 2026

Overview

This PR adds further support for JSON Schema draft 2019-09.
The changes focus on three main areas:

  1. Vocabulary-based keyword filtering
  2. $recursiveRef resolution
  3. Improved $ref handling with embedded schemas

This is a very large change, happy to break it up however would be easiest to review.

Key Changes

1. Vocabulary Support ($vocabulary)

Files: vocabularies.ts (new), jsonSchemaService.ts, jsonParser.ts, jsonLanguageTypes.ts

JSON Schema 2019-09 introduced vocabularies, which allow meta-schemas to declare which keyword sets are active. This enables custom meta-schemas to disable certain validation keywords.

Implementation

  • Added new Vocabularies type (Set<string>) to represent active vocabulary URIs
  • Created isKeywordEnabled() function that checks if a keyword should be processed based on active vocabularies
  • The schema service now fetches the meta-schema (via $schema) and extracts $vocabulary declarations
  • Validation functions now call enabled(keyword) before processing each keyword
  • Core keywords ($id, $ref, $schema, etc.) are always enabled per the spec

Example

A meta-schema with only applicator vocabulary (no validation vocabulary) will skip minimum, required, type, etc.

2. $recursiveRef and $recursiveAnchor Support

Files: jsonParser.ts, jsonSchema.ts

These keywords (2019-09) enable extensible recursive schemas—a pattern where a base schema can be extended while maintaining recursion to the extending schema.

Recursive Ref Implementation

  • Added schemaStack and schemaRoots parameters to the validate() function to track schema traversal
  • When $recursiveRef is encountered:
    1. Find the nearest schema resource root (schema with $id)
    2. If it has $recursiveAnchor: true, find the first $recursiveAnchor: true in the stack
    3. Otherwise, use the current resource root or document root
    4. Validate against the resolved target schema
  • Fixed $recursiveAnchor type from string to boolean | string to handle both spec-correct and real-world schemas

3. Improved $ref Resolution with Embedded $id Schemas

Files: jsonSchemaService.ts

Schemas can contain embedded sub-schemas with their own $id, creating new URI scopes. This PR properly resolves $ref against the correct base URI.

Key Fixes

Function Purpose
traverseWithBaseTracking() Maintains the current base URI as it traverses schemas with embedded $id values
registerEmbeddedSchemas() Pre-registers all embedded schemas so they can be resolved as external refs
collectAnchors() Now stops at embedded $id boundaries since anchors are scoped to their document

4. Scope Isolation for $ref with Sibling Keywords

Files: jsonSchemaService.ts

In 2019-09+, $ref can have sibling keywords, but unevaluatedProperties/unevaluatedItems in the referenced schema shouldn't see properties evaluated by those siblings.

Scope Isolation Implementation

  • Added needsScopeIsolation() to detect when isolation is needed
  • When needed, siblings are moved into a separate schema and combined with allOf:
{
  "allOf": [
    { /* $ref'd schema */ },
    { /* sibling keywords */ }
  ]
}

5. dependentSchemas Integration with unevaluatedProperties

Files: jsonParser.ts

Properties validated by dependentSchemas were not being tracked as "evaluated," causing false positives with unevaluatedProperties: false.

Fix: Added validationResult.mergeProcessedProperties() after validating dependentSchemas.

6. patternProperties Handling Fix

Files: jsonParser.ts

Previously, patternProperties was processed incrementally, which caused issues when a property matched multiple patterns.

Fix: Collect all pattern matches first, validate against all matching patterns, then mark as processed.

7. New Format Validators

Files: jsonParser.ts

Added validators for two new 2019-09 formats:

Format Description Example
duration ISO 8601 duration P1Y2M3DT4H5M6S
uuid UUID format 550e8400-e29b-41d4-a716-446655440000

8. Type Definition Updates

Files: jsonSchema.ts

Property Old Type New Type
$recursiveAnchor string boolean | string
$vocabulary any { [uri: string]: boolean }

Testing

New Test Files

  • vocabularies.test.ts: Unit tests for isKeywordEnabled() covering all vocabulary combinations

Updated Test Files

  • parser.test.ts: Added tests for:

    • $recursiveRef
    • dependentSchemas + unevaluatedProperties
    • duration and uuid formats
  • schema.test.ts: Added tests for:

    • Vocabulary integration
    • Unsupported feature warnings
  • jsonSchemaTestSuite.test.ts:

    • Added schema request service to load remote schemas from test suite
    • Reduced skipped tests from ~100 to ~35

Tests Now Passing

The following test categories now pass:

  • $id resolution
  • patternProperties
  • unevaluatedProperties
  • $recursiveRef

Unsupported Features

The following 2020-12 features are detected and produce warnings:

  • $dynamicRef
  • $dynamicAnchor

Breaking Changes

None. All changes are backward compatible.

Remaining Skipped Tests

Most remaining skipped tests fall into two categories:

  1. Remote refs: Tests requiring HTTP fetches to localhost:1234 (partially addressed)
  2. $dynamicRef/$dynamicAnchor: 2020-12 features not yet implemented

- Add $vocabulary support for vocabulary-based keyword filtering
- Implement $recursiveRef and $recursiveAnchor resolution
- Add duration and uuid format validators
- Fix $ref resolution with embedded $id schemas
- Improve unevaluatedProperties handling with dependentSchemas
- Support scope isolation for $ref with sibling keywords
@keegan-caruso
Copy link
Copy Markdown
Member Author

cc @aeschli

Copy link
Copy Markdown

@jdesrosiers jdesrosiers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. This is a huge step toward being fully compliant with the JSON Schema spec, but there's definitely a few pieces still missing. I expect that this isn't intended to implement everything, but it's way closer than what we have now.

The following list is all the things I tested. I tested it with these changes running in vscode. Most of the issues are related to identifying which keywords should be recognized/ignored depending on the version of JSON Schema in use. It respects all implemented keywords regardless of what version of JSON Schema the schema uses. That didn't surprise me. I know this implementations doesn't make those distinctions, but I did notice in the code that you tried to add support for those things by implementing the $vocabulary keyword. However, I didn't see any indication that it was working at all. I see there are tests, but when I try those same scenarios in vscode, I don't see $vocabulary being respected.

The other thing I noticed is that in 2019-09, the format keyword is supposed to be an annotation only. You're allowed to provide a way to enable format validation, but it's not supposed to be enabled by default. I understand if the intention is enable this by default, but technically that's not correct.

In 2019-09, you can also enable/disable format using $vocabulary. false means annotation-only and true means validate. This can be overridden by configuration, but those are the defaults. It would be nice to see that working fully.

  • ❌ Relative reference with $id
    • This has never worked properly, not just with 2019-09 changes. I'm sure it wasn't intended for this to be in scope for these changes, but it would be nice to get this working properly at some point. I don't think it would be too hard compared to everything else you've achieved in this PR.
  • ✔️ Reference an $anchor in the same schema
  • ✔️ Reference an $anchor in an external schema
  • ✔️ Reference an $anchor in an embedded schema
  • $id fragments aren't anchors in a 2020-12 schema
  • $anchor isn't an anchor in a draft-07/6/4 schema
  • ✔️ embedded schemas
  • ✔️ nested embedded schemas
  • ✔️ recursive references
  • ✔️ $ref with siblings
  • ✔️ dependentRequired works in 2019-09
  • dependentRequired shouldn't work in draft-07
  • dependentSchemas works in 2019-09
    • Works, but the error is on the wrong node. It appears on the property key, not the property value.
  • dependentSchemas shouldn't work in draft-07
  • dependencies shouldn't work in 2019-09
  • ✔️ unevaluatedItems
  • ✔️ unevaluatedProperties
  • ✔️ unevalautedProperties with dependentSchemas
  • ✔️ minContains/maxContains
  • format doesn't validate by default
  • ❌ Enable format validation using $vocabulary
  • ❌ Disable vocab using $vocabulary

One more thing. I'm not sure if there was something missing in my testing setup or what, but I wasn't seeing vscode recognize the changes in my schemas while editing. I had to refresh (Ctrl+R) in order for the schema changes to be recognized in the JSON document that linked it. If that's related to these changes, that could be a concern, but I expect it's just my setup.

Thanks for this work! It will be huge for JSON Schema to get this and 2020-12 support in vscode.

Comment thread src/jsonSchema.ts Outdated
Keegan Caruso added 2 commits February 10, 2026 18:25
…2019-09

- Change Vocabularies type from Set to Map to track required/optional status
- Add isFormatAssertionEnabled for format-annotation vs format-assertion
- Handle $ref sibling keywords correctly per draft version
- Only recognize $anchor in draft-2019-09 and later schemas
- Fix dependencies keyword to only apply in draft-07 and earlier
- Fix missing property error location to use object offset
@keegan-caruso
Copy link
Copy Markdown
Member Author

keegan-caruso commented Feb 11, 2026

@jdesrosiers

  • I thought dependentRequired shouldn't work in draft-07, instead I thought it still used dependencies. e.g. https://json-schema.org/understanding-json-schema/reference/conditionals
  • dependencies working in 2019-09: I see it, I was using the vocabulary check for this, but dependencies is not in vocabularies since that is a 2019-09 or greater feature.
  • I believe I addressed your comment around format assertions but let me know if anything needs adjusted.
  • Adjusted id/$id/$anchor handling
  • I had an error in extractVocabularies where I wasn't including them all, this might have been what you were seeing with VS Code?

@jdesrosiers
Copy link
Copy Markdown

I think there was some confusion because I wasn't clear enough in my checklist. I haven't had a chance to test this round of changes yet, but I want to make sure there aren't any miscommunications.

  • I thought dependentRequired shouldn't work in draft-07, instead I thought it still used dependencies.

That's correct. dependentRequired should be ignored in a draft-07 schema. I'm not seeing dependentRequired be ignored when testing with a draft-07 schema.

dependencies working in 2019-09: I see it, I was using the vocabulary check for this, but dependencies is not in vocabularies since that is a 2019-09 or greater feature.

dependencies should be ignored in draft-2019-09. My tests showed that it was not ignored. I think these are all related to the vocabulary check. Maybe I'm missing something, but it doesn't appear that the vocabulary check is working at all.

@jdesrosiers
Copy link
Copy Markdown

I believe I addressed your comment around format assertions but let me know if anything needs adjusted.

I'm still seeing format being evaluated. Example,

{
  "$schema": "./my-schema.json",
  "date": "not a date" // <-- Should be ok, but has a validation error
}
{
  "$schema": "https://json-schema.org/draft/2019-09/schema",
  "type": "object",
  "properties": {
    "date": {
      "type": "string",
      "format": "date" // <-- Should be annotation-only in 2019-09
    }
  }
}

Adjusted id/$id/$anchor handling

Mostly looks right except for one case.

{
  "$schema": "./my-schema.json",
  "foo": 42 // <-- This is showing that the value should be a string, but the reference shouldn't have worked.
}
{
  "$schema": "https://json-schema.org/draft/2019-09/schema",
  "type": "object",
  "properties": {
    "foo": { "$ref": "#foo" } // <-- The reference target shouldn't exist
  },
  "$defs": {
    "": {
      "$id": "#foo", // <-- Should not create an anchor in draft-2019-09
      "type": "string"
    }
  }
}

I had an error in extractVocabularies where I wasn't including them all, this might have been what you were seeing with VS Code?

I think this is working better. I'm seeing draft-2019-09 schemas allowing only draft-2019-09 keywords. But, in draft-07 schemas, all keywords seem to work including draft-2019-09 keywords.

Also, the vocabulary filtering only seems to work for built-in schemas like draft-2019-09. I'm not seeing it work for custom meta-schemas. Not sure if that was intended or not, but it looks like you wrote tests for it so I expected it to work.

{
  "$schema": "https://json-schema.org/draft/2019-09/schema",
  "$vocabulary": {
    "https://json-schema.org/draft/2019-09/vocab/core": true,
    "https://json-schema.org/draft/2019-09/vocab/applicator": true
  },

  "$ref": "https://json-schema.org/draft/2019-09/schema"
}
{
  "$schema": "./my-dialect.json",

  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "age": { "minimum": 0 } // <-- "minimum" should do nothing
  },
  "required": ["name"] // <-- "required" should do nothing
}
{ // <-- I'm seeing an error from "required" for "name" not being included, but "required" should be ignored.
  "$schema": "./my-schema.json",
  "age": -3 // <-- I'm seeing an error from "minimum" that should be ignored
}

My checklist so far:

  • ❌ Relative reference with $id
    • This has never worked properly, not just with 2019-09 changes. I'm sure it wasn't intended for this to be in scope for these changes, but it would be nice to get this working properly at some point. I don't think it would be too hard compared to everything else you've achieved in this PR.
  • ✔️ Reference an $anchor in the same schema
  • ✔️ Reference an $anchor in an external schema
  • ✔️ Reference an $anchor in an embedded schema
  • $id fragments aren't anchors in a 2019-09 schema
  • ✔️ $anchor isn't an anchor in a draft-07/6/4 schema
  • ✔️ embedded schemas
  • ✔️ nested embedded schemas
  • ✔️ recursive references
  • ✔️ $ref with siblings
  • ✔️ dependentRequired works in 2019-09
  • dependentRequired shouldn't work in draft-07
  • ✔️ dependencies shouldn't work in 2019-09
  • dependentSchemas works in 2019-09
    • This works, but the error is still not quite in the right place. It's now on the value, but it's just on the opening curly brace, not the whole object like dependentRequired.
  • dependentSchemas shouldn't work in draft-07
  • dependencies with a schema should work in draft-07
    • This works, but the error is not in the right place. It's on the value, but it's just on the opening curly brace, not the whole object like dependencies with an array.
  • ✔️ unevaluatedItems
  • ✔️ unevaluatedProperties
  • ✔️ unevalautedProperties with dependentSchemas
  • ✔️ minContains/maxContains
  • format shouldn't validate by default
  • ❌ Enable format validation using $vocabulary
  • ❌ Disable vocab using $vocabulary

@keegan-caruso
Copy link
Copy Markdown
Member Author

keegan-caruso commented Feb 27, 2026

Quick update. I will get back to this soon, hopefully by early next week.

Using vocabulary this way, doesn't quite work. Will update.

…a 2019-09+

- Gate dependentRequired, dependentSchemas, unevaluatedProperties,
  unevaluatedItems, minContains, maxContains to 2019-09+
- Gate prefixItems to 2020-12+
- Gate dependencies to draft-07 and earlier
- Apply vocabulary filtering only for 2019-09+ schemas
- Make format annotation-only by default for 2019-09+ per spec
- Support enabling format assertion via $vocabulary
- Stop treating $id fragments as anchors in 2019-09+
- Fix relative $id resolution when reached via JSON pointer $ref
- Highlight full object range for required property errors
@keegan-caruso
Copy link
Copy Markdown
Member Author

@jdesrosiers - Thanks for the test cases, these helped.

@jdesrosiers
Copy link
Copy Markdown

I haven't gone through everything, but I found some regressions this time around.

Embedded schemas stopped working. This example get the schema from the embedded location, not try to retrieve it.

{
  "$schema": "https://json-schema.org/draft/2019-09/schema",
  "type": "object",
  "properties": {
    "subject": { "$ref": "https://example.com/embedded" }
  },
  "$defs": {
    "": {
      "$id": "https://example.com/embedded",
      "type": "string"
    }
  }
}
{
  "$schema": "./example.json", // Error: Unable to load schema from 'https://example.com/embedded': Location https://example.com/embedded is untrusted.
  "subject": "foo"
}

$anchor isn't an anchor in a draft-07/6/4 schema. This was working correctly before.

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "subject": { "$ref": "#foo" }
  },
  "$defs": {
    "": {
      "$anchor": "foo",
      "type": "number"
    }
  }
}
{
  "$schema": "./example.json", // Expect a reference error reported here
  "subject": "foo"
}

Here's a weird one I stumbled into because my tests were sloppy. If you reference the same schema using two different URIs, an anchor can get double counted.

{
  "$schema": "https://json-schema.org/draft/2019-09/schema",
  "type": "object",
  "properties": {
    "a": { "$ref": "#foo" },
    "b": { "$ref": "#/$defs/foo" }
  },
  "$defs": {
    "foo": {
      "$anchor": "foo",
      "type": "string"
    }
  }
}
{
    "$schema": "./example.json" // Error: Duplicate anchor declaration: 'foo'
}

@aeschli
Copy link
Copy Markdown
Collaborator

aeschli commented Mar 10, 2026

Thanks a lot of the work done so far and the testing!
@jdesrosiers We should add all these test cases to our test suite. Just ask Copilot to create these from your examples.

@karenetheridge
Copy link
Copy Markdown

karenetheridge commented Mar 11, 2026

as per https://json-schema.org/draft/2019-09/draft-handrews-json-schema-02#rfc.section.8.2.4.2.2, "The value of the "$recursiveAnchor" property MUST be a boolean."

@karenetheridge
Copy link
Copy Markdown

In 2019-09+, $ref can have sibling keywords, but unevaluatedProperties/unevaluatedItems in the referenced schema shouldn't see properties evaluated by those siblings.

This is incorrect. Properties evaluated by subschemas under any keywords adjacent to the unevaluated keyword are considered to be evaluated -- schemas reached via $ref are no different from anyOf, allOf etc.

@karenetheridge
Copy link
Copy Markdown

(from examples) "$schema": "./example.json" -- $schema must be a uri, not a uri-reference -- that is, it must have a scheme and host, and is not resolved against $id as it is already absolute (using RFC3986 terminology).

@jdesrosiers
Copy link
Copy Markdown

(from examples) "$schema": "./example.json" -- $schema must be a uri, not a uri-reference -- that is, it must have a scheme and host, and is not resolved against $id as it is already absolute (using RFC3986 terminology).

That's plain JSON not a JSON Schema. It's the vscode $schema that goes in JSON files, not the JSON Schema $schema that goes in schemas. So, it can be relative and is referencing a schema relative to the current file on the file system. This implementation doesn't recognize $id, so referencing local schema by relative reference to it's file location is necessary.

@keegan-caruso
Copy link
Copy Markdown
Member Author

@jdesrosiers - Thanks! I added these all as tests cases and fixed them. I verified in a local instance of VS Code as well. To get this to work with the ESM work on main I had to add

      "default": "./lib/esm/jsonLanguageService.js"

to the exports in package.json

In f822189 I also found an error around scope isolation, fixed and added test cases.

@jdesrosiers
Copy link
Copy Markdown

It took some effort to get this running with the esm changes. I had to convert the json-language-features extension in the vscode repo to esm to get it to work. The "default" hack didn't work for me.

I took some time to organize my tests. They are very close to all passing at this point. I've put an "Expected Error" comment everywhere an error is expected. If you see an error anywhere else, that's a bug.

  • PASS anchor-collision
  • PASS anchor-embedded
  • PASS anchor-external
  • PASS anchor-ignore
  • PASS anchor-legacy
  • PASS anchor-legacy-ignore
  • PASS anchor-local
  • PASS dependencies-array
  • PASS dependencies-array-legacy
  • PASS dependencies-schema
  • PASS dependencies-schema-legacy
  • PASS dependent-required
  • PASS dependent-required-legacy-ignore
  • PASS dependent-schemas
  • PASS dependent-schemas-legacy-ignore
  • PASS embedded
  • PASS embedded-embedded
  • PASS format
  • PASS format-legacy
  • FAIL id-relative-ref
    • This has never worked properly, not just with these changes. Not sure if you consider it in scope or not.
  • PASS min-max-contains
  • PASS recursive-ref
  • PASS ref-siblings
  • PASS ref-siblings-legacy
  • PASS unevaluated-items
  • PASS unevaluated-items-legacy-ignore
  • PASS unevaluated-properties
  • PASS unevaluated-properties-dependent-schemas
  • PASS unevaluated-properties-legacy-ignore
  • PARTIAL vocabulary-disable
    • This works given a full URI, but doesn't with a relative URI. In this test, if I replace ./dialect.jsonc with file://***redacted***/vscode-json-languageservice/tests/vocabulary-disable/dialect.jsonc, then it works.
  • FAIL vocabulary-format-enable

I noticed another issue not related to validation. It has to do with the Goto Definition feature. Not sure how much of this you want to consider in scope because some of it doesn't work currently. I put comments in my test cases to show some of the cases. These references all resolve correctly for validation, just not for Goto Definition.

  • Following a reference to an embedded schema doesn't go to the embedded schema. It tries to open the schema in a web browser. (See the embedded test)
  • Following a local anchor doesn't work. This works for JSON Pointer fragments (#/$defs/foo), but not plain-name fragments (#foo). (See the anchor-local test)
  • Following a reference to an external schema doesn't work. (See the external test)

The last one doesn't work currently, so might be considered out of scope. But, jumping to local references is currently supported, so I think since we added support for anchors and embedded schemas, those local references should be considered in scope for these changes.

Keegan Caruso added 2 commits April 9, 2026 23:14
…a vocabulary resolution

Address PR review feedback for JSON Schema 2019-09 support:

- Fix Goto Definition (findLinks) to support plain-name anchor fragments
  (#foo via $anchor or legacy $id), and embedded schema references by $id URI
- Fix vocabulary extraction for custom dialects referenced via relative
  $schema URI (e.g. "./dialect.jsonc") by resolving against the schema's
  base URI before fetching
- Extract repeated absolute URI scheme regex into hasSchemeRegex constant
- Add tests for: anchor/embedded Goto Definition, $ref siblings ignored
  in draft-07, unevaluatedProperties/unevaluatedItems ignored in draft-07,
  nested embedded schemas, and vocabulary disable with relative URI
@keegan-caruso
Copy link
Copy Markdown
Member Author

keegan-caruso commented Apr 10, 2026

@jdesrosiers - I don't quite understand this one: vocabulary-format-enable

5c8674a I added additional tests here for how I understand the spec. From your earlier comment I understood that in 2019-09 this should be annotation only, with an optional ability to enable assertions vs in 2020-12 it is split into format-assertions and format-annotations.

id-relative-ref

Following a reference to an external schema doesn't work. (See the external test)

Agreed out of scope for both of these.

Good feedback on Goto Definition, agreed it should be part of this feature, added in 5c8674a as well

@jdesrosiers
Copy link
Copy Markdown

From your earlier comment I understood that in 2019-09 this should be annotation only, with an optional ability to enable assertions

That's mostly correct, but format assertion can be enabled in two ways. One is by configuration and technically it isn't optional for the validator to make that configuration option available to users.

The other way to enable assertion is to set the format vocabulary to true.

Using the vocabulary option is a little more strict than the config option. If you enable using the config option, the implementation can have any level of support for different formats. Some formats might not be supported or only partially supported. If you enable using the vocabulary option, the validator must support all formats and should produce an error condition if it doesn't support all of them. Actually, I believe vscode doesn't support all formats and therefore should technically not support enabling by vocab at all. But, that's a dumb rule and it would be reasonable to only error if the schema uses a format that isn't supported. That's what I do in my validator.

The draft-2019-09 meta-schema sets the format vocabulary to false and the configuration option is required to be off by default. So, format assertion is always off by default, but can be enabled either by using a custom meta-schema and setting the format vocabulary to true or by setting the configuration option.

The way draft-2019-09 uses vocabularies to enable or disable assertion is not how the vocabulary system normally works. Normally, setting a vocabulary to false should tell the validator to use it if it's supported and don't complain if it's not. In draft-2019-09, setting the format vocabulary to false means, don't use it. That's why it changed in draft-2020-12, so we don't have one vocabulary that works differently than all the rest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants