correctly 0.0.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 +8 -0
- package/LICENSE +373 -0
- package/README.md +24 -0
- package/dist/cli.mjs +294 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/core.d.mts +183 -0
- package/dist/core.d.mts.map +1 -0
- package/dist/core.mjs +2 -0
- package/dist/engine-BgWBnb1A.mjs +746 -0
- package/dist/engine-BgWBnb1A.mjs.map +1 -0
- package/dist/lsp.mjs +315 -0
- package/dist/lsp.mjs.map +1 -0
- package/docs/guide.md +114 -0
- package/examples/basic/correctly.config.json +10 -0
- package/examples/basic/project.json +1 -0
- package/examples/basic/project.jsonc +5 -0
- package/examples/basic/project.schema.json +10 -0
- package/package.json +61 -0
- package/src/cli/check.ts +83 -0
- package/src/cli/main.ts +86 -0
- package/src/cli/readable.ts +295 -0
- package/src/cli/vsix.ts +86 -0
- package/src/core/config.schema.json +47 -0
- package/src/core/config.ts +200 -0
- package/src/core/diagnostics.ts +114 -0
- package/src/core/engine.ts +247 -0
- package/src/core/index.ts +10 -0
- package/src/core/parse.ts +168 -0
- package/src/core/schemas.ts +379 -0
- package/src/core/types.ts +56 -0
- package/src/lsp/hints.ts +82 -0
- package/src/lsp/server.ts +265 -0
- package/src/lsp/workspace.ts +132 -0
- package/src/vscode/README.md +7 -0
- package/src/vscode/extension.ts +33 -0
- package/src/vscode/package.json +43 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
4
|
+
import { Ajv } from "ajv"
|
|
5
|
+
import picomatch from "picomatch"
|
|
6
|
+
import configSchema from "./config.schema.json" with { type: "json" }
|
|
7
|
+
import { parseDocument } from "./parse.ts"
|
|
8
|
+
import { CorrectlyError, type Association, type Mode } from "./types.ts"
|
|
9
|
+
|
|
10
|
+
export const CONFIG_NAME = "correctly.config.json"
|
|
11
|
+
export const DEFAULT_EXCLUDES = [
|
|
12
|
+
"**/node_modules/**",
|
|
13
|
+
"**/.git/**",
|
|
14
|
+
"**/dist/**",
|
|
15
|
+
"**/artifacts/**",
|
|
16
|
+
"**/.correctly-cache/**",
|
|
17
|
+
]
|
|
18
|
+
export type ProjectConfig = {
|
|
19
|
+
files?: string[]
|
|
20
|
+
exclude?: string[]
|
|
21
|
+
associations: {
|
|
22
|
+
name?: string
|
|
23
|
+
files: string[]
|
|
24
|
+
schema: string | null
|
|
25
|
+
mode?: Mode
|
|
26
|
+
}[]
|
|
27
|
+
remote?: {
|
|
28
|
+
offline?: boolean
|
|
29
|
+
cacheDir?: string
|
|
30
|
+
timeoutMs?: number
|
|
31
|
+
maxBytes?: number
|
|
32
|
+
maxRequests?: number
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export type Project = {
|
|
36
|
+
configPath: string
|
|
37
|
+
root: string
|
|
38
|
+
config: ProjectConfig
|
|
39
|
+
}
|
|
40
|
+
export type ReadText = (uri: string) => Promise<string>
|
|
41
|
+
export const readText: ReadText = (uri) => readFile(fileURLToPath(uri), "utf8")
|
|
42
|
+
const validateConfig = new Ajv({ allErrors: true, strict: true }).compile(
|
|
43
|
+
configSchema,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
export function contains(root: string, file: string): boolean {
|
|
47
|
+
const relative = path.relative(root, file)
|
|
48
|
+
return (
|
|
49
|
+
relative !== ".." &&
|
|
50
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
51
|
+
!path.isAbsolute(relative)
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function discoverConfig(
|
|
56
|
+
start: string,
|
|
57
|
+
boundary?: string,
|
|
58
|
+
virtualFiles: ReadonlySet<string> = new Set(),
|
|
59
|
+
): Promise<string> {
|
|
60
|
+
let directory = path.resolve(start)
|
|
61
|
+
if (boundary && !contains(boundary, directory))
|
|
62
|
+
throw new CorrectlyError("config", `Path is outside workspace ${boundary}`)
|
|
63
|
+
while (true) {
|
|
64
|
+
const candidate = path.join(directory, CONFIG_NAME)
|
|
65
|
+
if (virtualFiles.has(candidate)) return candidate
|
|
66
|
+
try {
|
|
67
|
+
const info = await stat(candidate)
|
|
68
|
+
if (info.isFile()) return candidate
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
|
|
71
|
+
}
|
|
72
|
+
const parent = path.dirname(directory)
|
|
73
|
+
if (directory === boundary || parent === directory) break
|
|
74
|
+
directory = parent
|
|
75
|
+
}
|
|
76
|
+
throw new CorrectlyError(
|
|
77
|
+
"config",
|
|
78
|
+
`No ${CONFIG_NAME} found from ${start}${boundary ? ` within ${boundary}` : ""}`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function loadProject(
|
|
83
|
+
configPath: string,
|
|
84
|
+
read: ReadText = readText,
|
|
85
|
+
): Promise<Project> {
|
|
86
|
+
const resolved = path.resolve(configPath)
|
|
87
|
+
let text: string
|
|
88
|
+
try {
|
|
89
|
+
text = await read(pathToFileURL(resolved).href)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new CorrectlyError(
|
|
92
|
+
"config",
|
|
93
|
+
`Cannot read configuration ${resolved}: ${String(error)}`,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
const parsed = parseDocument(text, "json")
|
|
97
|
+
if (parsed.diagnostics.length) {
|
|
98
|
+
const d = parsed.diagnostics[0]!
|
|
99
|
+
throw new CorrectlyError(
|
|
100
|
+
"config",
|
|
101
|
+
`${resolved}:${d.range.start.line + 1}:${d.range.start.character + 1}: ${d.message}`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
if (!validateConfig(parsed.value)) {
|
|
105
|
+
throw new CorrectlyError(
|
|
106
|
+
"config",
|
|
107
|
+
`${resolved}: ${validateConfig.errors?.map((e) => `${e.instancePath || "/"} ${e.message}`).join("; ")}`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
const config = parsed.value as ProjectConfig
|
|
111
|
+
for (const pattern of [
|
|
112
|
+
...(config.files ?? []),
|
|
113
|
+
...(config.exclude ?? []),
|
|
114
|
+
...config.associations.flatMap((a) => a.files),
|
|
115
|
+
]) {
|
|
116
|
+
if (pattern.includes("\\"))
|
|
117
|
+
throw new CorrectlyError(
|
|
118
|
+
"config",
|
|
119
|
+
`Use forward slashes in patterns: ${pattern}`,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
return { configPath: resolved, root: path.dirname(resolved), config }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function schemaUri(value: string, root: string): string {
|
|
126
|
+
if (
|
|
127
|
+
/^[A-Za-z][A-Za-z\d+.-]*:/.test(value) &&
|
|
128
|
+
!/^[A-Za-z]:[\\/]/.test(value)
|
|
129
|
+
) {
|
|
130
|
+
const uri = new URL(value)
|
|
131
|
+
if (!["file:", "http:", "https:"].includes(uri.protocol))
|
|
132
|
+
throw new CorrectlyError(
|
|
133
|
+
"schema",
|
|
134
|
+
`Unsupported schema protocol: ${uri.protocol}`,
|
|
135
|
+
)
|
|
136
|
+
return uri.href
|
|
137
|
+
}
|
|
138
|
+
const hash = value.indexOf("#")
|
|
139
|
+
const filename = hash === -1 ? value : value.slice(0, hash)
|
|
140
|
+
const uri = pathToFileURL(path.resolve(root, filename))
|
|
141
|
+
if (hash !== -1) uri.hash = value.slice(hash + 1)
|
|
142
|
+
return uri.href
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function exclusions(project: Project): string[] {
|
|
146
|
+
const cachePath = path.resolve(
|
|
147
|
+
project.root,
|
|
148
|
+
project.config.remote?.cacheDir ?? ".correctly-cache",
|
|
149
|
+
)
|
|
150
|
+
const relative = path
|
|
151
|
+
.relative(project.root, cachePath)
|
|
152
|
+
.split(path.sep)
|
|
153
|
+
.join("/")
|
|
154
|
+
return [
|
|
155
|
+
...DEFAULT_EXCLUDES,
|
|
156
|
+
...(project.config.exclude ?? []),
|
|
157
|
+
...(relative && contains(project.root, cachePath)
|
|
158
|
+
? [`${relative}/**`]
|
|
159
|
+
: []),
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function isIncluded(project: Project, file: string): boolean {
|
|
164
|
+
if (!contains(project.root, file)) return false
|
|
165
|
+
const relative = path.relative(project.root, file).split(path.sep).join("/")
|
|
166
|
+
const options = { dot: true }
|
|
167
|
+
return (
|
|
168
|
+
picomatch(
|
|
169
|
+
project.config.files ?? ["**/*.json", "**/*.jsonc"],
|
|
170
|
+
options,
|
|
171
|
+
)(relative) && !picomatch(exclusions(project), options)(relative)
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function associationFor(
|
|
176
|
+
project: Project,
|
|
177
|
+
file: string,
|
|
178
|
+
): Association | null {
|
|
179
|
+
const relative = path.relative(project.root, file).split(path.sep).join("/")
|
|
180
|
+
for (
|
|
181
|
+
let index = project.config.associations.length - 1;
|
|
182
|
+
index >= 0;
|
|
183
|
+
index--
|
|
184
|
+
) {
|
|
185
|
+
const rule = project.config.associations[index]!
|
|
186
|
+
if (picomatch(rule.files, { dot: true })(relative)) {
|
|
187
|
+
return {
|
|
188
|
+
index,
|
|
189
|
+
name: rule.name ?? `association ${index + 1}`,
|
|
190
|
+
schema:
|
|
191
|
+
rule.schema === null ? null : schemaUri(rule.schema, project.root),
|
|
192
|
+
mode: rule.mode ?? modeFor(file),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
export function modeFor(file: string): Mode {
|
|
199
|
+
return path.extname(file).toLowerCase() === ".jsonc" ? "jsonc" : "json"
|
|
200
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ErrorObject } from "ajv"
|
|
2
|
+
import type { Diagnostic } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
export type DiagnosticView = Diagnostic & {
|
|
5
|
+
branches: { label: string; diagnostics: DiagnosticView[] }[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Ajv emits branch failures before the applicator's summary. Keep the complete
|
|
9
|
+
// flat report and annotate only relationships established by schema paths.
|
|
10
|
+
// Reference paths can lose their caller; leave those errors standalone.
|
|
11
|
+
export function validationContexts(
|
|
12
|
+
errors: readonly ErrorObject[],
|
|
13
|
+
diagnostics: Diagnostic[],
|
|
14
|
+
): Diagnostic[] {
|
|
15
|
+
const summaries = errors.flatMap((error, index) => {
|
|
16
|
+
if (error.keyword === "anyOf" || error.keyword === "oneOf")
|
|
17
|
+
return [
|
|
18
|
+
{ index, error, prefix: `${error.schemaPath}/`, alternative: true },
|
|
19
|
+
]
|
|
20
|
+
if (
|
|
21
|
+
error.keyword === "if" &&
|
|
22
|
+
["then", "else"].includes(error.params.failingKeyword as string)
|
|
23
|
+
)
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
index,
|
|
27
|
+
error,
|
|
28
|
+
prefix: `${error.schemaPath.slice(0, -3)}/${error.params.failingKeyword}`,
|
|
29
|
+
alternative: false,
|
|
30
|
+
},
|
|
31
|
+
]
|
|
32
|
+
return []
|
|
33
|
+
})
|
|
34
|
+
return diagnostics.map((diagnostic, index) => {
|
|
35
|
+
const error = errors[index]!
|
|
36
|
+
const candidates = summaries.filter(
|
|
37
|
+
(parent) =>
|
|
38
|
+
parent.index > index &&
|
|
39
|
+
(error.instancePath === parent.error.instancePath ||
|
|
40
|
+
error.instancePath.startsWith(`${parent.error.instancePath}/`)) &&
|
|
41
|
+
(parent.error.propertyName === undefined ||
|
|
42
|
+
error.propertyName === parent.error.propertyName) &&
|
|
43
|
+
(parent.alternative
|
|
44
|
+
? error.schemaPath.startsWith(parent.prefix)
|
|
45
|
+
: error.schemaPath === parent.prefix ||
|
|
46
|
+
error.schemaPath.startsWith(`${parent.prefix}/`)),
|
|
47
|
+
)
|
|
48
|
+
const parent = candidates.toSorted(
|
|
49
|
+
(a, b) => b.prefix.length - a.prefix.length || a.index - b.index,
|
|
50
|
+
)[0]
|
|
51
|
+
if (!parent) return diagnostic
|
|
52
|
+
const branch = parent.alternative
|
|
53
|
+
? error.schemaPath.slice(parent.prefix.length).split("/")[0]!
|
|
54
|
+
: (parent.error.params.failingKeyword as string)
|
|
55
|
+
if (parent.alternative && !/^\d+$/.test(branch)) return diagnostic
|
|
56
|
+
return {
|
|
57
|
+
...diagnostic,
|
|
58
|
+
context: {
|
|
59
|
+
parent: parent.index,
|
|
60
|
+
label: parent.alternative
|
|
61
|
+
? `Alternative ${Number(branch) + 1}`
|
|
62
|
+
: branch === "then"
|
|
63
|
+
? "Then branch"
|
|
64
|
+
: "Else branch",
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function diagnosticViews(
|
|
71
|
+
diagnostics: readonly Diagnostic[],
|
|
72
|
+
): DiagnosticView[] {
|
|
73
|
+
const views = diagnostics.map((diagnostic): DiagnosticView => ({
|
|
74
|
+
...diagnostic,
|
|
75
|
+
branches: [],
|
|
76
|
+
}))
|
|
77
|
+
const roots: DiagnosticView[] = []
|
|
78
|
+
for (const [index, view] of views.entries()) {
|
|
79
|
+
const context = view.context
|
|
80
|
+
// Parents must follow their children, so malformed metadata cannot create
|
|
81
|
+
// cycles or make a diagnostic disappear from the displayed report.
|
|
82
|
+
const parent =
|
|
83
|
+
context && Number.isInteger(context.parent) && context.parent > index
|
|
84
|
+
? views[context.parent]
|
|
85
|
+
: undefined
|
|
86
|
+
if (!parent) {
|
|
87
|
+
roots.push(view)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
let branch = parent.branches.find(
|
|
91
|
+
(branch) => branch.label === context!.label,
|
|
92
|
+
)
|
|
93
|
+
if (!branch) {
|
|
94
|
+
branch = { label: context!.label, diagnostics: [] }
|
|
95
|
+
parent.branches.push(branch)
|
|
96
|
+
}
|
|
97
|
+
branch.diagnostics.push(view)
|
|
98
|
+
}
|
|
99
|
+
return roots
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function diagnosticDetails(
|
|
103
|
+
view: DiagnosticView,
|
|
104
|
+
): { label: string; diagnostic: DiagnosticView }[] {
|
|
105
|
+
return view.branches.flatMap((branch) =>
|
|
106
|
+
branch.diagnostics.flatMap((child) => [
|
|
107
|
+
{ label: branch.label, diagnostic: child },
|
|
108
|
+
...diagnosticDetails(child).map((detail) => ({
|
|
109
|
+
...detail,
|
|
110
|
+
label: `${branch.label} / ${detail.label}`,
|
|
111
|
+
})),
|
|
112
|
+
]),
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Ajv,
|
|
3
|
+
type ErrorObject,
|
|
4
|
+
type ValidateFunction,
|
|
5
|
+
type SchemaObject,
|
|
6
|
+
} from "ajv"
|
|
7
|
+
import { Ajv2020 } from "ajv/dist/2020.js"
|
|
8
|
+
import addFormats from "ajv-formats"
|
|
9
|
+
import {
|
|
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"
|
|
30
|
+
|
|
31
|
+
const ANNOTATIONS = [
|
|
32
|
+
"markdownDescription",
|
|
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
|
+
}
|
|
55
|
+
}
|
|
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
|
+
export class Engine {
|
|
128
|
+
readonly project: Project
|
|
129
|
+
readonly store: SchemaStore
|
|
130
|
+
private readonly compiled = new Map<string, Promise<ValidateFunction>>()
|
|
131
|
+
constructor(project: Project, options: StoreOptions = {}) {
|
|
132
|
+
this.project = project
|
|
133
|
+
this.store = new SchemaStore(project, options)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
validator(uri: string): Promise<ValidateFunction> {
|
|
137
|
+
let compiled = this.compiled.get(uri)
|
|
138
|
+
if (!compiled) {
|
|
139
|
+
compiled = this.compile(uri)
|
|
140
|
+
this.compiled.set(uri, compiled)
|
|
141
|
+
}
|
|
142
|
+
return compiled
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async compile(uri: string): Promise<ValidateFunction> {
|
|
146
|
+
try {
|
|
147
|
+
const resource = await this.store.load(uri)
|
|
148
|
+
const options = {
|
|
149
|
+
allErrors: true,
|
|
150
|
+
strictSchema: true,
|
|
151
|
+
strictTypes: false,
|
|
152
|
+
strictTuples: false,
|
|
153
|
+
strictRequired: false,
|
|
154
|
+
coerceTypes: false,
|
|
155
|
+
useDefaults: false,
|
|
156
|
+
removeAdditional: false,
|
|
157
|
+
validateSchema: true,
|
|
158
|
+
loadSchema: async (ref: string) => {
|
|
159
|
+
const loaded = await this.store.load(ref, resource.dialect)
|
|
160
|
+
if (loaded.dialect !== resource.dialect)
|
|
161
|
+
throw new CorrectlyError(
|
|
162
|
+
"unsupported-dialect",
|
|
163
|
+
`Mixed schema dialects: ${uri} references ${ref}`,
|
|
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
|
+
)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async prepare(): Promise<void> {
|
|
202
|
+
// Register configured resources before compiling references to their IDs.
|
|
203
|
+
for (const association of this.project.config.associations) {
|
|
204
|
+
if (association.schema !== null)
|
|
205
|
+
await this.store.load(schemaUri(association.schema, this.project.root))
|
|
206
|
+
}
|
|
207
|
+
for (const association of this.project.config.associations) {
|
|
208
|
+
if (association.schema !== null)
|
|
209
|
+
await this.validator(schemaUri(association.schema, this.project.root))
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async validate(file: string, text: string): Promise<FileResult> {
|
|
214
|
+
const association = associationFor(this.project, file)
|
|
215
|
+
const mode = association?.mode ?? modeFor(file)
|
|
216
|
+
const result: FileResult = {
|
|
217
|
+
file,
|
|
218
|
+
mode,
|
|
219
|
+
association,
|
|
220
|
+
coverage: "excluded",
|
|
221
|
+
diagnostics: [],
|
|
222
|
+
failures: [],
|
|
223
|
+
}
|
|
224
|
+
if (!isIncluded(this.project, file)) return result
|
|
225
|
+
result.coverage = association?.schema ? "schema" : "syntax-only"
|
|
226
|
+
const parsed = parseDocument(text, mode)
|
|
227
|
+
result.diagnostics.push(...parsed.diagnostics)
|
|
228
|
+
if (association?.schema) {
|
|
229
|
+
try {
|
|
230
|
+
const validate = await this.validator(association.schema)
|
|
231
|
+
if (parsed.diagnostics.length === 0 && !validate(parsed.value)) {
|
|
232
|
+
result.diagnostics.push(
|
|
233
|
+
...validationContexts(
|
|
234
|
+
validate.errors ?? [],
|
|
235
|
+
(validate.errors ?? []).map((e) =>
|
|
236
|
+
validationDiagnostic(text, parsed, e),
|
|
237
|
+
),
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
} catch (error) {
|
|
242
|
+
result.failures.push(failure(error, file))
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return result
|
|
246
|
+
}
|
|
247
|
+
}
|