Skip to content

Commit 9094ed3

Browse files
jdesrosiersclaude
andcommitted
Ignore unknown keyword values that aren't valid against their schema
Unknown keywords aren't checked against a meta-schema, so the values of VS Code's keywords, and of deprecated in dialects before 2019-09, could be any JSON value and were used as-is. Accessors now check them against the keyword's meta-schema and ignore invalid values. Only the accessors are affected, not the annotations themselves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent acc4d05 commit 9094ed3

9 files changed

Lines changed: 183 additions & 29 deletions

File tree

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,43 @@
1+
import { interpret } from "@hyperjump/json-schema/experimental";
2+
import * as Instance from "@hyperjump/json-schema/instance/experimental";
13
import { JsonSchemaAnnotation } from "./JsonSchemaAnnotation.ts";
24
import { VscodeAnnotation } from "./VscodeAnnotation.ts";
35

6+
import type { Json } from "@hyperjump/json-pointer";
7+
import type { AST } from "@hyperjump/json-schema/experimental";
8+
9+
// The annotations one schema contributed to an instance location, keyed by
10+
// keyword URI
11+
export type AnnotationRecord = Record<string, unknown>;
12+
13+
// Vocabularies add accessors for the annotations of their keywords by
14+
// extending BaseAnnotation with a mixin. The mixins are composed into the
15+
// Annotation class below.
16+
export type Constructor<T> = new (...args: any[]) => T;
17+
18+
export class BaseAnnotation {
19+
protected keywords: AnnotationRecord;
20+
21+
constructor(keywords: AnnotationRecord) {
22+
this.keywords = keywords;
23+
}
24+
25+
// Keywords a dialect doesn't define produce annotations as unknown keywords,
26+
// which aren't checked against a meta-schema, so their values could be
27+
// anything. An accessor that reads an unknown keyword as a keyword it knows
28+
// uses this to ignore values that aren't valid against that keyword's schema.
29+
// Undefined if the keyword isn't present or its value isn't valid.
30+
protected unknownKeyword(keyword: string, schemaUri: string, ast: AST) {
31+
const value = this.keywords[`https://json-schema.org/keyword/unknown#${keyword}`] as Json | undefined;
32+
if (value !== undefined && !interpret({ schemaUri, ast }, Instance.fromJs(value)).valid) {
33+
return;
34+
}
35+
36+
return value;
37+
}
38+
}
39+
440
// The annotations one schema contributed to an instance location, with the
541
// accessors of every vocabulary. A vocabulary's mixin overrides the accessors of
642
// the ones it's applied to, so their order matters.
7-
export class Annotation extends VscodeAnnotation(JsonSchemaAnnotation) {}
43+
export class Annotation extends VscodeAnnotation(JsonSchemaAnnotation(BaseAnnotation)) {}
Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
import * as Pact from "@hyperjump/pact";
2+
import "@hyperjump/json-schema/draft-2020-12";
3+
import { compile, getSchema } from "@hyperjump/json-schema/experimental";
24

3-
// The annotations one schema contributed to an instance location, keyed by
4-
// keyword URI
5-
export type AnnotationRecord = Record<string, unknown>;
6-
7-
// Vocabularies add accessors for the annotations of their keywords by
8-
// extending JsonSchemaAnnotation with a mixin. The mixins are composed into the
9-
// Annotation class in Annotation.ts.
10-
export type Constructor<T> = new (...args: any[]) => T;
5+
import type { BaseAnnotation, Constructor } from "./Annotation.ts";
116

127
const FORMAT_KEYWORDS = [
138
"https://json-schema.org/keyword/draft-2020-12/format",
@@ -19,15 +14,13 @@ const FORMAT_KEYWORDS = [
1914
"https://json-schema.org/keyword/draft-04/format"
2015
];
2116

17+
const metaSchemaUri = "https://json-schema.org/draft/2020-12/meta/meta-data";
18+
const schema = await getSchema(metaSchemaUri);
19+
const { ast } = await compile(schema);
20+
2221
// Accessors for the standard JSON Schema annotations. Some keywords have more
2322
// than one URI depending on the dialect.
24-
export class JsonSchemaAnnotation {
25-
protected keywords: AnnotationRecord;
26-
27-
constructor(keywords: AnnotationRecord) {
28-
this.keywords = keywords;
29-
}
30-
23+
export const JsonSchemaAnnotation = <T extends Constructor<BaseAnnotation>>(Base: T) => class extends Base {
3124
title(): string | undefined {
3225
return this.keywords["https://json-schema.org/keyword/title"] as string | undefined;
3326
}
@@ -36,11 +29,11 @@ export class JsonSchemaAnnotation {
3629
return this.keywords["https://json-schema.org/keyword/description"] as string | undefined;
3730
}
3831

39-
// Dialects before 2019-09 don't have the deprecated keyword, but it's
40-
// commonly used with them anyway
32+
// The deprecated keyword was added in 2019-09, but it's commonly used with
33+
// earlier dialects anyway
4134
deprecated(): boolean {
4235
return (this.keywords["https://json-schema.org/keyword/deprecated"]
43-
?? this.keywords["https://json-schema.org/keyword/unknown#deprecated"]) === true;
36+
?? this.unknownKeyword("deprecated", `${metaSchemaUri}#/properties/deprecated`, ast)) === true;
4437
}
4538

4639
formats(): unknown[] {
@@ -51,4 +44,4 @@ export class JsonSchemaAnnotation {
5144
Pact.collectArray
5245
);
5346
}
54-
}
47+
};

‎language-server/src/evaluation/JsonValueSet.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, test, expect } from "vitest";
22
import { JsonValueSet } from "./JsonValueSet.ts";
33

4-
import type { AnnotationRecord } from "./JsonSchemaAnnotation.ts";
4+
import type { AnnotationRecord } from "./Annotation.ts";
55

66
const a: AnnotationRecord = { a: true };
77
const b: AnnotationRecord = { b: true };

‎language-server/src/evaluation/JsonValueSet.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AnnotationRecord } from "./JsonSchemaAnnotation.ts";
1+
import type { AnnotationRecord } from "./Annotation.ts";
22

33
export type JsonSchemaType = "string" | "number" | "integer" | "boolean" | "null" | "array" | "object";
44

‎language-server/src/evaluation/LspEvaluationPlugin.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { Annotation } from "./Annotation.ts";
99
import type { EvaluationPlugin, Keyword, Node, ValidationContext } from "@hyperjump/json-schema/experimental";
1010
import type { JsonNode } from "@hyperjump/json-schema/instance/experimental";
1111
import type { JsonSchemaType, TypeEntry, ValueEntry } from "./JsonValueSet.ts";
12-
import type { AnnotationRecord } from "./JsonSchemaAnnotation.ts";
12+
import type { AnnotationRecord } from "./Annotation.ts";
1313
import type { SchemaEvaluation } from "../services/JsonSchema.ts";
1414

1515
// A location annotation applies no matter what the value at the location is.

‎language-server/src/evaluation/VscodeAnnotation.ts‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { Constructor, JsonSchemaAnnotation } from "./JsonSchemaAnnotation.ts";
1+
import "../vocabularies/vscode.ts";
2+
import { compile, getSchema } from "@hyperjump/json-schema/experimental";
3+
4+
import type { BaseAnnotation, Constructor } from "./Annotation.ts";
25

36
// Accessors for the annotations of VS Code's custom keywords. The keywords are
47
// defined in vocabularies/vscode.ts.
@@ -11,13 +14,23 @@ export type DefaultSnippet = {
1114
bodyText?: string;
1215
};
1316

17+
const metaSchemaUri = "https://microsoft.com/meta/vscode";
18+
const schema = await getSchema(metaSchemaUri);
19+
const { ast } = await compile(schema);
20+
21+
// The accessors this mixin extends
22+
type Extended = BaseAnnotation & {
23+
description(): string | undefined;
24+
deprecated(): boolean;
25+
};
26+
1427
// Schemas that don't use the vocabulary still produce annotations for its
1528
// keywords as unknown keywords.
16-
export const VscodeAnnotation = <T extends Constructor<JsonSchemaAnnotation>>(Base: T) => class extends Base {
29+
export const VscodeAnnotation = <T extends Constructor<Extended>>(Base: T) => class extends Base {
1730
// Prefers markdownDescription over the standard description keyword
1831
override description(): string | undefined {
1932
return (this.keywords["https://microsoft.com/keyword/markdownDescription"]
20-
?? this.keywords["https://json-schema.org/keyword/unknown#markdownDescription"]
33+
?? this.unknownKeyword("markdownDescription", `${metaSchemaUri}#/properties/markdownDescription`, ast)
2134
?? super.description()) as string | undefined;
2235
}
2336

@@ -31,20 +44,20 @@ export const VscodeAnnotation = <T extends Constructor<JsonSchemaAnnotation>>(Ba
3144
// An empty message is the same as no message
3245
deprecationMessage(): string | undefined {
3346
const message = this.keywords["https://microsoft.com/keyword/deprecationMessage"]
34-
?? this.keywords["https://json-schema.org/keyword/unknown#deprecationMessage"];
47+
?? this.unknownKeyword("deprecationMessage", `${metaSchemaUri}#/properties/deprecationMessage`, ast);
3548
return (message || undefined) as string | undefined;
3649
}
3750

3851
// An empty message is the same as no message
3952
markdownDeprecationMessage(): string | undefined {
4053
const message = this.keywords["https://microsoft.com/keyword/markdownDeprecationMessage"]
41-
?? this.keywords["https://json-schema.org/keyword/unknown#markdownDeprecationMessage"];
54+
?? this.unknownKeyword("markdownDeprecationMessage", `${metaSchemaUri}#/properties/markdownDeprecationMessage`, ast);
4255
return (message || undefined) as string | undefined;
4356
}
4457

4558
defaultSnippets(): DefaultSnippet[] {
4659
return (this.keywords["https://microsoft.com/keyword/defaultSnippets"]
47-
?? this.keywords["https://json-schema.org/keyword/unknown#defaultSnippets"]
60+
?? this.unknownKeyword("defaultSnippets", `${metaSchemaUri}#/properties/defaultSnippets`, ast)
4861
?? []) as DefaultSnippet[];
4962
}
5063
};

‎language-server/src/features/Hover.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,42 @@ The **full name** of the [person](https://example.com).
569569
570570
---
571571
572+
_hyperjump-json-language-server_`
573+
}
574+
});
575+
});
576+
577+
test("should use description when markdownDescription isn't a string", async () => {
578+
fixtureSchemaUri = await client.writeDocument("schema.json", `{
579+
"$schema": "https://json-schema.org/draft/2020-12/schema",
580+
"type": "object",
581+
"properties": {
582+
"name": {
583+
"description": "Plain text description",
584+
"markdownDescription": { "en": "The **markdown** description" },
585+
"type": "string"
586+
}
587+
}
588+
}`);
589+
590+
await client.writeDocument("instance.json", `{
591+
"$schema": "${fixtureSchemaUri}",
592+
"name": "Alice"
593+
}`);
594+
const uri = await client.openDocument("instance.json");
595+
596+
const result = await client.sendRequest(HoverRequest.type, {
597+
textDocument: { uri },
598+
position: { line: 2, character: 10 }
599+
});
600+
601+
expect(result).toEqual({
602+
contents: {
603+
kind: "markdown",
604+
value: `Plain text description
605+
606+
---
607+
572608
_hyperjump-json-language-server_`
573609
}
574610
});

‎language-server/src/features/completions/DefaultSnippetsCompletionsProvider.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
22
import { CompletionItemKind, CompletionRequest, InsertTextFormat } from "vscode-languageserver";
33
import { TestClient } from "../../test/TestClient.ts";
44

5+
import type { CompletionItem } from "vscode-languageserver";
6+
57
describe("Value Completions", () => {
68
let client: TestClient;
79

@@ -286,6 +288,36 @@ describe("Value Completions", () => {
286288
expect(completions).toContainEqual(expect.objectContaining({ label: "FromSecond" }));
287289
});
288290

291+
test("invalid defaultSnippets are ignored without affecting valid ones", async () => {
292+
const fixtureSchemaUri = await client.writeDocument("schema.json", `{
293+
"$schema": "https://json-schema.org/draft/2020-12/schema",
294+
"type": "object",
295+
"properties": {
296+
"value": {
297+
"allOf": [
298+
{ "defaultSnippets": 5 },
299+
{ "defaultSnippets": [null, { "label": "Invalid", "bodyText": "\\"invalid\\"" }] },
300+
{ "defaultSnippets": [{ "label": "Valid", "bodyText": "\\"valid\\"" }] }
301+
]
302+
}
303+
}
304+
}`);
305+
306+
await client.writeDocument("instance.json", `{
307+
"$schema": "${fixtureSchemaUri}",
308+
"value":
309+
}`);
310+
const uri = await client.openDocument("instance.json");
311+
312+
const completions = await client.sendRequest(CompletionRequest.type, {
313+
textDocument: { uri },
314+
position: { line: 2, character: 14 }
315+
});
316+
317+
const snippets = (completions as CompletionItem[]).filter((completion) => completion.kind === CompletionItemKind.Snippet);
318+
expect(snippets).toEqual([expect.objectContaining({ label: "Valid" })]);
319+
});
320+
289321
test("defaultSnippets are offered for an incomplete location whose schema references itself", async () => {
290322
const fixtureSchemaUri = await client.writeDocument("schema.json", `{
291323
"$schema": "https://json-schema.org/draft/2020-12/schema",

‎language-server/src/features/diagnostics/DeprecatedDiagnosticsProvider.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,50 @@ describe("Deprecated Diagnostics", () => {
376376
]);
377377
});
378378

379+
test("a deprecationMessage that isn't a string is ignored", async () => {
380+
fixtureSchemaUri = await client.writeDocument("schema.json", `{
381+
"$schema": "https://json-schema.org/draft/2020-12/schema",
382+
"type": "object",
383+
"properties": {
384+
"name": {
385+
"type": "string",
386+
"deprecationMessage": { "en": "Use fullName instead." }
387+
}
388+
}
389+
}`);
390+
391+
await client.writeDocument("instance.json", `{
392+
"$schema": "${fixtureSchemaUri}",
393+
"name": "Alice"
394+
}`);
395+
const diagnostics = client.getDiagnostics("instance.json");
396+
await client.openDocument("instance.json");
397+
398+
await expect(diagnostics).resolves.toHaveLength(0);
399+
});
400+
401+
test("a deprecated keyword that isn't a boolean is ignored in dialects that don't have it", async () => {
402+
fixtureSchemaUri = await client.writeDocument("schema.json", `{
403+
"$schema": "http://json-schema.org/draft-07/schema#",
404+
"type": "object",
405+
"properties": {
406+
"name": {
407+
"type": "string",
408+
"deprecated": "Use fullName instead."
409+
}
410+
}
411+
}`);
412+
413+
await client.writeDocument("instance.json", `{
414+
"$schema": "${fixtureSchemaUri}",
415+
"name": "Alice"
416+
}`);
417+
const diagnostics = client.getDiagnostics("instance.json");
418+
await client.openDocument("instance.json");
419+
420+
await expect(diagnostics).resolves.toHaveLength(0);
421+
});
422+
379423
test("no diagnostics when nothing is deprecated", async () => {
380424
fixtureSchemaUri = await client.writeDocument("schema.json", `{
381425
"$schema": "https://json-schema.org/draft/2020-12/schema",

0 commit comments

Comments
 (0)