correctly 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -2
- package/README.md +21 -15
- package/dist/ajv.d.mts +11 -0
- package/dist/ajv.d.mts.map +1 -0
- package/dist/ajv.mjs +221 -0
- package/dist/ajv.mjs.map +1 -0
- package/dist/check-BI7U3ZLc.mjs +88 -0
- package/dist/check-BI7U3ZLc.mjs.map +1 -0
- package/dist/cli.mjs +14 -62
- package/dist/cli.mjs.map +1 -1
- package/dist/config-Bx1-XXeo.d.mts +189 -0
- package/dist/config-Bx1-XXeo.d.mts.map +1 -0
- package/dist/config-Cb6o4YnJ.mjs +348 -0
- package/dist/config-Cb6o4YnJ.mjs.map +1 -0
- package/dist/core.d.mts +27 -125
- package/dist/core.d.mts.map +1 -1
- package/dist/core.mjs +5 -2
- package/dist/diagnostics-BqD1WOy4.mjs +70 -0
- package/dist/diagnostics-BqD1WOy4.mjs.map +1 -0
- package/dist/engine-VST0hoSk.mjs +111 -0
- package/dist/engine-VST0hoSk.mjs.map +1 -0
- package/dist/extensions-ClclWslY.d.mts +11 -0
- package/dist/extensions-ClclWslY.d.mts.map +1 -0
- package/dist/hints-HvSIhuMM.mjs +57 -0
- package/dist/hints-HvSIhuMM.mjs.map +1 -0
- package/dist/lsp.mjs +40 -91
- package/dist/lsp.mjs.map +1 -1
- package/dist/renovate.d.mts +5 -0
- package/dist/renovate.d.mts.map +1 -0
- package/dist/renovate.mjs +11 -0
- package/dist/renovate.mjs.map +1 -0
- package/dist/schemas-UxCtIdo2.mjs +265 -0
- package/dist/schemas-UxCtIdo2.mjs.map +1 -0
- package/dist/session-DCFav56x.mjs +113 -0
- package/dist/session-DCFav56x.mjs.map +1 -0
- package/dist/worker.mjs +80 -0
- package/dist/worker.mjs.map +1 -0
- package/docs/guide.md +166 -34
- package/examples/basic/correctly.config.ts +13 -0
- package/package.json +69 -60
- package/src/cli/check.ts +41 -11
- package/src/cli/main.ts +3 -2
- package/src/cli/readable.ts +24 -5
- package/src/cli/vsix.ts +1 -0
- package/src/core/adapters.ts +54 -0
- package/src/core/config.ts +201 -64
- package/src/core/engine.ts +118 -218
- package/src/core/extensions.ts +144 -0
- package/src/core/index.ts +3 -0
- package/src/core/parse.ts +26 -2
- package/src/core/parsers.ts +22 -0
- package/src/core/schema-walk.ts +43 -0
- package/src/core/schemas.ts +21 -41
- package/src/core/types.ts +33 -6
- package/src/extensions/renovate.ts +4 -0
- package/src/lsp/hints.ts +22 -12
- package/src/lsp/server.ts +9 -2
- package/src/lsp/workspace.ts +49 -54
- package/src/runtime/session.ts +173 -0
- package/src/runtime/worker.ts +99 -0
- package/src/validators/ajv.ts +257 -0
- package/src/vscode/README.md +1 -1
- package/src/vscode/extension.ts +2 -4
- package/src/vscode/package.json +9 -2
- package/dist/engine-BgWBnb1A.mjs +0 -746
- package/dist/engine-BgWBnb1A.mjs.map +0 -1
- package/examples/basic/correctly.config.json +0 -10
- package/src/core/config.schema.json +0 -47
package/src/core/engine.ts
CHANGED
|
@@ -1,247 +1,147 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
associationFor,
|
|
11
|
-
isIncluded,
|
|
12
|
-
modeFor,
|
|
13
|
-
schemaUri,
|
|
14
|
-
type Project,
|
|
15
|
-
} from "./config.ts"
|
|
16
|
-
import {
|
|
17
|
-
diagnostic,
|
|
18
|
-
parseDocument,
|
|
19
|
-
pointer,
|
|
20
|
-
type ParsedDocument,
|
|
21
|
-
} from "./parse.ts"
|
|
22
|
-
import { DIALECTS, SchemaStore, type StoreOptions } from "./schemas.ts"
|
|
23
|
-
import { validationContexts } from "./diagnostics.ts"
|
|
24
|
-
import {
|
|
25
|
-
CorrectlyError,
|
|
26
|
-
failure,
|
|
27
|
-
type Diagnostic,
|
|
28
|
-
type FileResult,
|
|
29
|
-
} from "./types.ts"
|
|
1
|
+
import { associationFor, isIncluded, readText, type Project } from "./config.ts"
|
|
2
|
+
import { defaultParser } from "./parsers.ts"
|
|
3
|
+
import { CorrectlyError, failure, type FileResult } from "./types.ts"
|
|
4
|
+
import type { StoreOptions } from "./schemas.ts"
|
|
5
|
+
import type {
|
|
6
|
+
PreparedValidator,
|
|
7
|
+
ValidationContext,
|
|
8
|
+
Validator,
|
|
9
|
+
} from "./adapters.ts"
|
|
30
10
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"enumDescriptions",
|
|
34
|
-
"markdownEnumDescriptions",
|
|
35
|
-
"defaultSnippets",
|
|
36
|
-
"errorMessage",
|
|
37
|
-
"patternErrorMessage",
|
|
38
|
-
"deprecationMessage",
|
|
39
|
-
"suggestSortText",
|
|
40
|
-
"doNotSuggest",
|
|
41
|
-
"allowComments",
|
|
42
|
-
"allowTrailingCommas",
|
|
43
|
-
"deprecated",
|
|
44
|
-
"$vocabulary",
|
|
45
|
-
]
|
|
46
|
-
|
|
47
|
-
function registerRootAnchors(ajv: Ajv, schema: unknown, uri: string) {
|
|
48
|
-
if (typeof schema !== "object" || schema === null) return
|
|
49
|
-
const object = schema as SchemaObject
|
|
50
|
-
for (const anchor of [object.$anchor, object.$dynamicAnchor]) {
|
|
51
|
-
if (typeof anchor === "string")
|
|
52
|
-
ajv.refs[new URL(`#${anchor}`, object.$id ?? uri).href] =
|
|
53
|
-
object.$id ?? uri
|
|
54
|
-
}
|
|
11
|
+
export type EngineOptions = StoreOptions & {
|
|
12
|
+
onDependency?: (uri: string) => void
|
|
55
13
|
}
|
|
56
|
-
|
|
57
|
-
function validationMessage(error: ErrorObject): string {
|
|
58
|
-
if (error.keyword === "anyOf")
|
|
59
|
-
return "must match at least one alternative; none matched"
|
|
60
|
-
if (
|
|
61
|
-
error.keyword === "if" &&
|
|
62
|
-
["then", "else"].includes(error.params.failingKeyword as string)
|
|
63
|
-
)
|
|
64
|
-
return `must satisfy the ${error.params.failingKeyword} branch of the conditional schema`
|
|
65
|
-
if (error.keyword === "uniqueItems") {
|
|
66
|
-
const { i, j } = error.params
|
|
67
|
-
if (Number.isInteger(i) && Number.isInteger(j))
|
|
68
|
-
return `duplicates item at ${error.instancePath}${pointer([Math.min(i, j)])}; array items must be unique`
|
|
69
|
-
}
|
|
70
|
-
if (error.keyword === "enum" && Array.isArray(error.params.allowedValues))
|
|
71
|
-
return `must be one of: ${error.params.allowedValues.map((value: unknown) => JSON.stringify(value)).join(", ")}`
|
|
72
|
-
if (error.keyword === "const" && Object.hasOwn(error.params, "allowedValue"))
|
|
73
|
-
return `must equal ${JSON.stringify(error.params.allowedValue)}`
|
|
74
|
-
if (error.keyword === "oneOf") {
|
|
75
|
-
if (error.params.passingSchemas === null)
|
|
76
|
-
return "must match exactly one alternative; none matched"
|
|
77
|
-
if (Array.isArray(error.params.passingSchemas))
|
|
78
|
-
return `must match exactly one alternative; multiple matched (alternatives ${error.params.passingSchemas.map((index: number) => index + 1).join(", ")})`
|
|
79
|
-
}
|
|
80
|
-
return error.message ?? "Schema violation"
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function validationDiagnostic(
|
|
84
|
-
text: string,
|
|
85
|
-
parsed: ParsedDocument,
|
|
86
|
-
error: ErrorObject,
|
|
87
|
-
): Diagnostic {
|
|
88
|
-
let jsonPointer = error.instancePath
|
|
89
|
-
if (
|
|
90
|
-
error.keyword === "uniqueItems" &&
|
|
91
|
-
Number.isInteger(error.params.i) &&
|
|
92
|
-
Number.isInteger(error.params.j)
|
|
93
|
-
)
|
|
94
|
-
jsonPointer += pointer([Math.max(error.params.i, error.params.j)])
|
|
95
|
-
const property: unknown =
|
|
96
|
-
error.params.additionalProperty ??
|
|
97
|
-
error.params.unevaluatedProperty ??
|
|
98
|
-
error.params.missingProperty ??
|
|
99
|
-
error.params.propertyName ??
|
|
100
|
-
error.propertyName
|
|
101
|
-
if (typeof property === "string") jsonPointer += pointer([property])
|
|
102
|
-
const node = parsed.locate(
|
|
103
|
-
jsonPointer,
|
|
104
|
-
error.keyword === "additionalProperties" ||
|
|
105
|
-
error.keyword === "unevaluatedProperties" ||
|
|
106
|
-
error.keyword === "propertyNames" ||
|
|
107
|
-
typeof error.propertyName === "string",
|
|
108
|
-
)
|
|
109
|
-
const rangeNode =
|
|
110
|
-
error.keyword === "required" && error.propertyName === undefined
|
|
111
|
-
? parsed.locate(error.instancePath)
|
|
112
|
-
: node
|
|
113
|
-
const length =
|
|
114
|
-
error.keyword === "required" && error.propertyName === undefined
|
|
115
|
-
? 1
|
|
116
|
-
: rangeNode?.length
|
|
117
|
-
return diagnostic(
|
|
118
|
-
text,
|
|
119
|
-
`schema/${error.keyword}`,
|
|
120
|
-
`${jsonPointer || "/"}: ${validationMessage(error)}`,
|
|
121
|
-
jsonPointer,
|
|
122
|
-
rangeNode?.offset ?? 0,
|
|
123
|
-
length ?? 1,
|
|
124
|
-
)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
14
|
export class Engine {
|
|
128
15
|
readonly project: Project
|
|
129
|
-
readonly
|
|
130
|
-
private readonly
|
|
131
|
-
|
|
16
|
+
readonly context: ValidationContext
|
|
17
|
+
private readonly prepared = new Map<Validator, Promise<PreparedValidator>>()
|
|
18
|
+
private registration: Promise<void> | undefined
|
|
19
|
+
constructor(project: Project, options: EngineOptions = {}) {
|
|
132
20
|
this.project = project
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
21
|
+
const watch = (uri: string) => options.onDependency?.(uri)
|
|
22
|
+
this.context = {
|
|
23
|
+
root: project.root,
|
|
24
|
+
configPath: project.configPath,
|
|
25
|
+
remote: {
|
|
26
|
+
...project.config.remote,
|
|
27
|
+
...(options.offline === undefined ? {} : { offline: options.offline }),
|
|
28
|
+
},
|
|
29
|
+
read: async (uri) => {
|
|
30
|
+
watch(uri)
|
|
31
|
+
return (options.read ?? readText)(uri)
|
|
32
|
+
},
|
|
33
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
34
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
35
|
+
watch,
|
|
36
|
+
services: new Map(),
|
|
141
37
|
}
|
|
142
|
-
return compiled
|
|
143
38
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
)
|
|
165
|
-
registerRootAnchors(ajv, loaded.schema, loaded.uri)
|
|
166
|
-
return loaded.schema as SchemaObject
|
|
167
|
-
},
|
|
168
|
-
}
|
|
169
|
-
const ajv =
|
|
170
|
-
resource.dialect === "2020-12" ? new Ajv2020(options) : new Ajv(options)
|
|
171
|
-
// CommonJS package exports retain a callable default at runtime.
|
|
172
|
-
const formats = addFormats as unknown as (instance: Ajv) => void
|
|
173
|
-
formats(ajv)
|
|
174
|
-
for (const keyword of ANNOTATIONS)
|
|
175
|
-
if (!ajv.RULES.keywords[keyword])
|
|
176
|
-
ajv.addKeyword({ keyword, valid: true })
|
|
177
|
-
// Ajv indexes plain anchors during reference resolution, but does not
|
|
178
|
-
// register the annotation keyword in its strict vocabulary.
|
|
179
|
-
if (resource.dialect === "2020-12")
|
|
180
|
-
ajv.addKeyword({
|
|
181
|
-
keyword: "$anchor",
|
|
182
|
-
schemaType: "string",
|
|
183
|
-
valid: true,
|
|
184
|
-
})
|
|
185
|
-
ajv.addSchema(resource.schema, resource.uri)
|
|
186
|
-
registerRootAnchors(ajv, resource.schema, resource.uri)
|
|
187
|
-
const validate = await ajv.compileAsync({
|
|
188
|
-
$schema: DIALECTS[resource.dialect],
|
|
189
|
-
$ref: uri,
|
|
190
|
-
})
|
|
191
|
-
return validate as ValidateFunction
|
|
192
|
-
} catch (error) {
|
|
193
|
-
if (error instanceof CorrectlyError) throw error
|
|
194
|
-
throw new CorrectlyError(
|
|
195
|
-
"schema",
|
|
196
|
-
`Cannot compile schema ${uri}: ${error instanceof Error ? error.message : String(error)}`,
|
|
197
|
-
)
|
|
39
|
+
private register(): Promise<void> {
|
|
40
|
+
return (this.registration ??= (async () => {
|
|
41
|
+
for (const rule of this.project.config.associations)
|
|
42
|
+
await rule.validate?.register?.(this.context)
|
|
43
|
+
})())
|
|
44
|
+
}
|
|
45
|
+
private validator(validator: Validator): Promise<PreparedValidator> {
|
|
46
|
+
let prepared = this.prepared.get(validator)
|
|
47
|
+
if (!prepared) {
|
|
48
|
+
prepared = (async () => {
|
|
49
|
+
await this.register()
|
|
50
|
+
const result = await validator.prepare(this.context)
|
|
51
|
+
if (!result || typeof result.validate !== "function")
|
|
52
|
+
throw new CorrectlyError(
|
|
53
|
+
"validator",
|
|
54
|
+
`Validator ${validator.id} did not prepare a validate function`,
|
|
55
|
+
)
|
|
56
|
+
return result
|
|
57
|
+
})()
|
|
58
|
+
this.prepared.set(validator, prepared)
|
|
198
59
|
}
|
|
60
|
+
return prepared
|
|
199
61
|
}
|
|
200
|
-
|
|
201
62
|
async prepare(): Promise<void> {
|
|
202
|
-
|
|
203
|
-
for (const
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
63
|
+
await this.register()
|
|
64
|
+
for (const rule of this.project.config.associations) {
|
|
65
|
+
if (rule.validate) {
|
|
66
|
+
if (!rule.validate.accepts.includes(rule.parse?.valueModel ?? "json"))
|
|
67
|
+
throw new CorrectlyError(
|
|
68
|
+
"adapter-incompatible",
|
|
69
|
+
`Parser ${rule.parse?.id ?? "json"} produces ${rule.parse?.valueModel ?? "json"}; validator ${rule.validate.id} accepts ${rule.validate.accepts.join(", ")}`,
|
|
70
|
+
)
|
|
71
|
+
await this.validator(rule.validate)
|
|
72
|
+
}
|
|
210
73
|
}
|
|
211
74
|
}
|
|
212
|
-
|
|
75
|
+
async editor(file: string) {
|
|
76
|
+
if (!isIncluded(this.project, file)) return undefined
|
|
77
|
+
const association = associationFor(this.project, file)
|
|
78
|
+
if (!association) return undefined
|
|
79
|
+
const rule = this.project.config.associations[association.index]!
|
|
80
|
+
const parser = rule.parse ?? defaultParser(file)
|
|
81
|
+
if (!rule.validate || !parser.editorLanguage) return undefined
|
|
82
|
+
if (!rule.validate.accepts.includes(parser.valueModel)) return undefined
|
|
83
|
+
const prepared = await this.validator(rule.validate)
|
|
84
|
+
return prepared.editor
|
|
85
|
+
? {
|
|
86
|
+
support: prepared.editor,
|
|
87
|
+
language: parser.editorLanguage,
|
|
88
|
+
association: association.index,
|
|
89
|
+
}
|
|
90
|
+
: undefined
|
|
91
|
+
}
|
|
213
92
|
async validate(file: string, text: string): Promise<FileResult> {
|
|
214
93
|
const association = associationFor(this.project, file)
|
|
215
|
-
const
|
|
94
|
+
const rule = association
|
|
95
|
+
? this.project.config.associations[association.index]
|
|
96
|
+
: undefined
|
|
97
|
+
const parser = rule?.parse ?? defaultParser(file)
|
|
216
98
|
const result: FileResult = {
|
|
217
99
|
file,
|
|
218
|
-
mode,
|
|
100
|
+
mode: parser.id,
|
|
219
101
|
association,
|
|
220
102
|
coverage: "excluded",
|
|
221
103
|
diagnostics: [],
|
|
222
104
|
failures: [],
|
|
223
105
|
}
|
|
224
106
|
if (!isIncluded(this.project, file)) return result
|
|
225
|
-
result.coverage =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
107
|
+
result.coverage = rule?.validate
|
|
108
|
+
? association?.schema
|
|
109
|
+
? "schema"
|
|
110
|
+
: "validated"
|
|
111
|
+
: "syntax-only"
|
|
112
|
+
try {
|
|
113
|
+
this.context.signal?.throwIfAborted()
|
|
114
|
+
const parsed = await parser.parse(text, {
|
|
115
|
+
file,
|
|
116
|
+
...(this.context.signal ? { signal: this.context.signal } : {}),
|
|
117
|
+
})
|
|
118
|
+
result.diagnostics.push(...parsed.diagnostics)
|
|
119
|
+
if (rule?.validate) {
|
|
120
|
+
if (!rule.validate.accepts.includes(parser.valueModel))
|
|
121
|
+
throw new CorrectlyError(
|
|
122
|
+
"adapter-incompatible",
|
|
123
|
+
`Parser ${parser.id} produces ${parser.valueModel}; validator ${rule.validate.id} accepts ${rule.validate.accepts.join(", ")}`,
|
|
124
|
+
)
|
|
125
|
+
const validator = await this.validator(rule.validate)
|
|
126
|
+
if (!parsed.diagnostics.length)
|
|
232
127
|
result.diagnostics.push(
|
|
233
|
-
...
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
),
|
|
238
|
-
),
|
|
128
|
+
...(await validator.validate({
|
|
129
|
+
file,
|
|
130
|
+
text,
|
|
131
|
+
parsed,
|
|
132
|
+
...(this.context.signal ? { signal: this.context.signal } : {}),
|
|
133
|
+
})),
|
|
239
134
|
)
|
|
240
|
-
}
|
|
241
|
-
} catch (error) {
|
|
242
|
-
result.failures.push(failure(error, file))
|
|
243
135
|
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
result.failures.push(failure(error, file))
|
|
244
138
|
}
|
|
245
139
|
return result
|
|
246
140
|
}
|
|
141
|
+
async dispose(): Promise<void> {
|
|
142
|
+
for (const prepared of this.prepared.values()) {
|
|
143
|
+
const result = await prepared.catch(() => undefined)
|
|
144
|
+
await result?.dispose?.()
|
|
145
|
+
}
|
|
146
|
+
}
|
|
247
147
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Ajv, FormatDefinition } from "ajv"
|
|
2
|
+
import { CorrectlyError } from "./types.ts"
|
|
3
|
+
import { schemaChildren } from "./schema-walk.ts"
|
|
4
|
+
|
|
5
|
+
/** Synchronous assertions and annotation-only keywords. Registration is explicit. */
|
|
6
|
+
export type AjvExtension = {
|
|
7
|
+
id: string
|
|
8
|
+
formats?: Readonly<
|
|
9
|
+
Record<string, FormatDefinition<string> | FormatDefinition<number>>
|
|
10
|
+
>
|
|
11
|
+
annotations?: readonly string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type ExtensionSuggestion = {
|
|
15
|
+
id: string
|
|
16
|
+
formats: readonly string[]
|
|
17
|
+
guidance: string
|
|
18
|
+
}
|
|
19
|
+
// Discovery metadata only: implementations are installed by the consuming project.
|
|
20
|
+
export const knownExtensions: readonly ExtensionSuggestion[] = [
|
|
21
|
+
{
|
|
22
|
+
id: "@correctlyjs/schemars",
|
|
23
|
+
formats: [
|
|
24
|
+
"int",
|
|
25
|
+
"uint",
|
|
26
|
+
"int8",
|
|
27
|
+
"uint8",
|
|
28
|
+
"int16",
|
|
29
|
+
"uint16",
|
|
30
|
+
"int32",
|
|
31
|
+
"uint32",
|
|
32
|
+
"int64",
|
|
33
|
+
"uint64",
|
|
34
|
+
"int128",
|
|
35
|
+
"uint128",
|
|
36
|
+
"float",
|
|
37
|
+
"double",
|
|
38
|
+
"ip",
|
|
39
|
+
"phone",
|
|
40
|
+
"partial-date-time",
|
|
41
|
+
],
|
|
42
|
+
guidance: `For a Schemars-generated schema, install @correctlyjs/schemars and import { schemars } from "@correctlyjs/schemars/ajv". Select the schema's reviewed Schemars version with schemars({ version: "..." }) in this Ajv validator's extensions in correctly.config.ts.`,
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
export function installExtensions(
|
|
47
|
+
ajv: Ajv,
|
|
48
|
+
extensions: readonly AjvExtension[],
|
|
49
|
+
): void {
|
|
50
|
+
const formats = new Set<string>(),
|
|
51
|
+
annotations = new Set<string>()
|
|
52
|
+
const identifiers = new Set<string>()
|
|
53
|
+
for (const extension of extensions) {
|
|
54
|
+
if (
|
|
55
|
+
!extension ||
|
|
56
|
+
typeof extension !== "object" ||
|
|
57
|
+
typeof extension.id !== "string" ||
|
|
58
|
+
!extension.id
|
|
59
|
+
)
|
|
60
|
+
throw new CorrectlyError(
|
|
61
|
+
"extension",
|
|
62
|
+
"Pass extension implementations to ajv({ extensions: [...] }); string identifiers are not implementations.",
|
|
63
|
+
)
|
|
64
|
+
const id = extension.id
|
|
65
|
+
if (identifiers.has(id))
|
|
66
|
+
throw new CorrectlyError(
|
|
67
|
+
"extension",
|
|
68
|
+
`Duplicate extension identifier: ${id}`,
|
|
69
|
+
)
|
|
70
|
+
identifiers.add(id)
|
|
71
|
+
for (const [name, definition] of Object.entries(extension.formats ?? {})) {
|
|
72
|
+
if (formats.has(name))
|
|
73
|
+
throw new CorrectlyError(
|
|
74
|
+
"extension",
|
|
75
|
+
`Enabled extensions both define format ${JSON.stringify(name)}.`,
|
|
76
|
+
)
|
|
77
|
+
if (definition.async || typeof definition.validate !== "function")
|
|
78
|
+
throw new CorrectlyError(
|
|
79
|
+
"extension",
|
|
80
|
+
`Extension ${id} must provide a synchronous validator for format ${JSON.stringify(name)}.`,
|
|
81
|
+
)
|
|
82
|
+
formats.add(name)
|
|
83
|
+
const validate = definition.validate as (
|
|
84
|
+
value: string | number,
|
|
85
|
+
) => unknown
|
|
86
|
+
ajv.addFormat(name, {
|
|
87
|
+
...definition,
|
|
88
|
+
validate: (value: string | number) => {
|
|
89
|
+
const valid = validate(value)
|
|
90
|
+
if (typeof valid !== "boolean")
|
|
91
|
+
throw new CorrectlyError(
|
|
92
|
+
"extension",
|
|
93
|
+
`Extension ${id} returned a non-boolean result for format ${JSON.stringify(name)}. Validators must be synchronous.`,
|
|
94
|
+
)
|
|
95
|
+
return valid
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
for (const keyword of extension.annotations ?? []) {
|
|
100
|
+
if (annotations.has(keyword) || ajv.RULES.keywords[keyword])
|
|
101
|
+
throw new CorrectlyError(
|
|
102
|
+
"extension",
|
|
103
|
+
`Extension ${id} conflicts with keyword ${JSON.stringify(keyword)}.`,
|
|
104
|
+
)
|
|
105
|
+
annotations.add(keyword)
|
|
106
|
+
ajv.addKeyword({ keyword, valid: true })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function assertFormats(
|
|
112
|
+
ajv: Ajv,
|
|
113
|
+
schema: unknown,
|
|
114
|
+
uri: string,
|
|
115
|
+
registry: readonly ExtensionSuggestion[],
|
|
116
|
+
location = "",
|
|
117
|
+
): void {
|
|
118
|
+
if (typeof schema !== "object" || schema === null || Array.isArray(schema))
|
|
119
|
+
return
|
|
120
|
+
const object = schema as Record<string, unknown>
|
|
121
|
+
if (
|
|
122
|
+
typeof object.format === "string" &&
|
|
123
|
+
(!Object.hasOwn(ajv.formats, object.format) ||
|
|
124
|
+
ajv.formats[object.format] === true)
|
|
125
|
+
) {
|
|
126
|
+
const format = object.format
|
|
127
|
+
const extension = registry.find((entry) => entry.formats.includes(format))
|
|
128
|
+
const hint =
|
|
129
|
+
extension?.guidance ??
|
|
130
|
+
`Pass an extension implementing ${JSON.stringify(format)} to this Ajv validator's extensions in correctly.config.ts.`
|
|
131
|
+
throw new CorrectlyError(
|
|
132
|
+
"extension-required",
|
|
133
|
+
`This schema needs an extension.\nFormat ${JSON.stringify(format)} has no enabled validator; validation cannot proceed.\nSchema: ${uri}#${location}/format\n${hint}`,
|
|
134
|
+
{
|
|
135
|
+
schemaUri: uri,
|
|
136
|
+
schemaPointer: `${location}/format`,
|
|
137
|
+
format,
|
|
138
|
+
...(extension ? { suggestedExtension: extension.id } : {}),
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
for (const [path, child] of schemaChildren(object))
|
|
143
|
+
assertFormats(ajv, child, uri, registry, location + path)
|
|
144
|
+
}
|
package/src/core/index.ts
CHANGED
package/src/core/parse.ts
CHANGED
|
@@ -7,13 +7,15 @@ import {
|
|
|
7
7
|
type Node,
|
|
8
8
|
type ParseError,
|
|
9
9
|
} from "jsonc-parser"
|
|
10
|
-
import type { Diagnostic,
|
|
10
|
+
import type { Diagnostic, Position } from "./types.ts"
|
|
11
11
|
|
|
12
12
|
export type SourceSpan = { offset: number; length: number }
|
|
13
13
|
export type ParsedDocument = {
|
|
14
14
|
value: unknown
|
|
15
15
|
diagnostics: Diagnostic[]
|
|
16
16
|
locate: (pointer: string, key?: boolean) => SourceSpan | undefined
|
|
17
|
+
/** Exact numeric lexeme when the parser can preserve it. */
|
|
18
|
+
rawNumber?: (pointer: string) => string | undefined
|
|
17
19
|
}
|
|
18
20
|
export type ParsedJsonDocument = ParsedDocument & { root: Node | undefined }
|
|
19
21
|
|
|
@@ -63,7 +65,10 @@ export function diagnostic(
|
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
export function parseDocument(
|
|
68
|
+
export function parseDocument(
|
|
69
|
+
text: string,
|
|
70
|
+
mode: "json" | "jsonc",
|
|
71
|
+
): ParsedJsonDocument {
|
|
67
72
|
const errors: ParseError[] = []
|
|
68
73
|
const root = parseTree(text, errors, {
|
|
69
74
|
disallowComments: mode === "json",
|
|
@@ -134,6 +139,25 @@ export function parseDocument(text: string, mode: Mode): ParsedJsonDocument {
|
|
|
134
139
|
root,
|
|
135
140
|
value: root ? value(root) : undefined,
|
|
136
141
|
diagnostics,
|
|
142
|
+
rawNumber: (jsonPointer) => {
|
|
143
|
+
const parts =
|
|
144
|
+
jsonPointer === ""
|
|
145
|
+
? []
|
|
146
|
+
: jsonPointer
|
|
147
|
+
.slice(1)
|
|
148
|
+
.split("/")
|
|
149
|
+
.map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"))
|
|
150
|
+
let node = root
|
|
151
|
+
for (const part of parts) {
|
|
152
|
+
if (!node) break
|
|
153
|
+
node = findNodeAtLocation(node, [
|
|
154
|
+
node.type === "array" ? Number(part) : part,
|
|
155
|
+
])
|
|
156
|
+
}
|
|
157
|
+
return node?.type === "number"
|
|
158
|
+
? text.slice(node.offset, node.offset + node.length)
|
|
159
|
+
: undefined
|
|
160
|
+
},
|
|
137
161
|
locate: (jsonPointer, key = false) => {
|
|
138
162
|
const node = locate(root, jsonPointer, key)
|
|
139
163
|
return node ? { offset: node.offset, length: node.length } : undefined
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Parser } from "./adapters.ts"
|
|
2
|
+
import { parseDocument } from "./parse.ts"
|
|
3
|
+
|
|
4
|
+
export function json(): Parser {
|
|
5
|
+
return {
|
|
6
|
+
id: "json",
|
|
7
|
+
valueModel: "json",
|
|
8
|
+
editorLanguage: "json",
|
|
9
|
+
parse: (text) => parseDocument(text, "json"),
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function jsonc(): Parser {
|
|
13
|
+
return {
|
|
14
|
+
id: "jsonc",
|
|
15
|
+
valueModel: "json",
|
|
16
|
+
editorLanguage: "jsonc",
|
|
17
|
+
parse: (text) => parseDocument(text, "jsonc"),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function defaultParser(file: string): Parser {
|
|
21
|
+
return file.toLowerCase().endsWith(".jsonc") ? jsonc() : json()
|
|
22
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { pointer } from "./parse.ts"
|
|
2
|
+
|
|
3
|
+
/** Only schema positions: defaults, enums, examples and other data stay opaque. */
|
|
4
|
+
export function* schemaChildren(
|
|
5
|
+
schema: Record<string, unknown>,
|
|
6
|
+
): Generator<[string, unknown]> {
|
|
7
|
+
for (const key of [
|
|
8
|
+
"additionalItems",
|
|
9
|
+
"additionalProperties",
|
|
10
|
+
"contains",
|
|
11
|
+
"propertyNames",
|
|
12
|
+
"not",
|
|
13
|
+
"if",
|
|
14
|
+
"then",
|
|
15
|
+
"else",
|
|
16
|
+
"unevaluatedProperties",
|
|
17
|
+
"unevaluatedItems",
|
|
18
|
+
"contentSchema",
|
|
19
|
+
]) {
|
|
20
|
+
if (schema[key] !== undefined) yield [pointer([key]), schema[key]]
|
|
21
|
+
}
|
|
22
|
+
for (const key of [
|
|
23
|
+
"properties",
|
|
24
|
+
"patternProperties",
|
|
25
|
+
"definitions",
|
|
26
|
+
"$defs",
|
|
27
|
+
"dependentSchemas",
|
|
28
|
+
"dependencies",
|
|
29
|
+
]) {
|
|
30
|
+
const map = schema[key]
|
|
31
|
+
if (typeof map === "object" && map !== null && !Array.isArray(map))
|
|
32
|
+
for (const [name, child] of Object.entries(map))
|
|
33
|
+
yield [pointer([key, name]), child]
|
|
34
|
+
}
|
|
35
|
+
for (const key of ["allOf", "anyOf", "oneOf", "prefixItems", "items"]) {
|
|
36
|
+
const children = schema[key]
|
|
37
|
+
if (Array.isArray(children)) {
|
|
38
|
+
for (const [index, child] of children.entries())
|
|
39
|
+
yield [pointer([key, index]), child]
|
|
40
|
+
} else if (key === "items" && children !== undefined)
|
|
41
|
+
yield [pointer([key]), children]
|
|
42
|
+
}
|
|
43
|
+
}
|