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.
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ export * from "./types.ts"
2
+ export * from "./parse.ts"
3
+ export * from "./config.ts"
4
+ export * from "./schemas.ts"
5
+ export * from "./engine.ts"
6
+ export {
7
+ diagnosticViews,
8
+ diagnosticDetails,
9
+ type DiagnosticView,
10
+ } from "./diagnostics.ts"